mirror of
https://github.com/correl/SleekXMPP.git
synced 2024-11-24 03:00:15 +00:00
Merge branch 'develop' into roster
This commit is contained in:
commit
debf909359
3 changed files with 146 additions and 94 deletions
|
@ -14,6 +14,8 @@ from sleekxmpp.stanza import Message, Iq, Presence
|
||||||
from sleekxmpp.test import TestSocket, TestLiveSocket
|
from sleekxmpp.test import TestSocket, TestLiveSocket
|
||||||
from sleekxmpp.xmlstream import StanzaBase, ET, register_stanza_plugin
|
from sleekxmpp.xmlstream import StanzaBase, ET, register_stanza_plugin
|
||||||
from sleekxmpp.xmlstream.tostring import tostring
|
from sleekxmpp.xmlstream.tostring import tostring
|
||||||
|
from sleekxmpp.xmlstream.matcher import StanzaPath, MatcherId
|
||||||
|
from sleekxmpp.xmlstream.matcher import MatchXMLMask, MatchXPath
|
||||||
|
|
||||||
|
|
||||||
class SleekTest(unittest.TestCase):
|
class SleekTest(unittest.TestCase):
|
||||||
|
@ -166,7 +168,7 @@ class SleekTest(unittest.TestCase):
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Methods for comparing stanza objects to XML strings
|
# Methods for comparing stanza objects to XML strings
|
||||||
|
|
||||||
def check(self, stanza, xml_string,
|
def check(self, stanza, criteria, method='exact',
|
||||||
defaults=None, use_values=True):
|
defaults=None, use_values=True):
|
||||||
"""
|
"""
|
||||||
Create and compare several stanza objects to a correct XML string.
|
Create and compare several stanza objects to a correct XML string.
|
||||||
|
@ -187,7 +189,10 @@ class SleekTest(unittest.TestCase):
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
stanza -- The stanza object to test.
|
stanza -- The stanza object to test.
|
||||||
xml_string -- A string version of the correct XML expected.
|
criteria -- An expression the stanza must match against.
|
||||||
|
method -- The type of matching to use; one of:
|
||||||
|
'exact', 'mask', 'id', 'xpath', and 'stanzapath'.
|
||||||
|
Defaults to the value of self.match_method.
|
||||||
defaults -- A list of stanza interfaces that have default
|
defaults -- A list of stanza interfaces that have default
|
||||||
values. These interfaces will be set to their
|
values. These interfaces will be set to their
|
||||||
defaults for the given and generated stanzas to
|
defaults for the given and generated stanzas to
|
||||||
|
@ -196,57 +201,74 @@ class SleekTest(unittest.TestCase):
|
||||||
setStanzaValues() should be used. Defaults to
|
setStanzaValues() should be used. Defaults to
|
||||||
True.
|
True.
|
||||||
"""
|
"""
|
||||||
stanza_class = stanza.__class__
|
if method is None and hasattr(self, 'match_method'):
|
||||||
xml = self.parse_xml(xml_string)
|
method = getattr(self, 'match_method')
|
||||||
|
|
||||||
# Ensure that top level namespaces are used, even if they
|
if method != 'exact':
|
||||||
# were not provided.
|
matchers = {'stanzapath': StanzaPath,
|
||||||
self.fix_namespaces(stanza.xml, 'jabber:client')
|
'xpath': MatchXPath,
|
||||||
self.fix_namespaces(xml, 'jabber:client')
|
'mask': MatchXMLMask,
|
||||||
|
'id': MatcherId}
|
||||||
stanza2 = stanza_class(xml=xml)
|
Matcher = matchers.get(method, None)
|
||||||
|
if Matcher is None:
|
||||||
if use_values:
|
raise ValueError("Unknown matching method.")
|
||||||
# Using getStanzaValues() and setStanzaValues() will add
|
test = Matcher(criteria)
|
||||||
# XML for any interface that has a default value. We need
|
self.failUnless(test.match(stanza),
|
||||||
# to set those defaults on the existing stanzas and XML
|
"Stanza did not match using %s method:\n" % method + \
|
||||||
# so that they will compare correctly.
|
"Criteria:\n%s\n" % str(criteria) + \
|
||||||
default_stanza = stanza_class()
|
"Stanza:\n%s" % str(stanza))
|
||||||
if defaults is None:
|
|
||||||
known_defaults = {
|
|
||||||
Message: ['type'],
|
|
||||||
Presence: ['priority']
|
|
||||||
}
|
|
||||||
defaults = known_defaults.get(stanza_class, [])
|
|
||||||
for interface in defaults:
|
|
||||||
stanza[interface] = stanza[interface]
|
|
||||||
stanza2[interface] = stanza2[interface]
|
|
||||||
# Can really only automatically add defaults for top
|
|
||||||
# level attribute values. Anything else must be accounted
|
|
||||||
# for in the provided XML string.
|
|
||||||
if interface not in xml.attrib:
|
|
||||||
if interface in default_stanza.xml.attrib:
|
|
||||||
value = default_stanza.xml.attrib[interface]
|
|
||||||
xml.attrib[interface] = value
|
|
||||||
|
|
||||||
values = stanza2.getStanzaValues()
|
|
||||||
stanza3 = stanza_class()
|
|
||||||
stanza3.setStanzaValues(values)
|
|
||||||
|
|
||||||
debug = "Three methods for creating stanzas do not match.\n"
|
|
||||||
debug += "Given XML:\n%s\n" % tostring(xml)
|
|
||||||
debug += "Given stanza:\n%s\n" % tostring(stanza.xml)
|
|
||||||
debug += "Generated stanza:\n%s\n" % tostring(stanza2.xml)
|
|
||||||
debug += "Second generated stanza:\n%s\n" % tostring(stanza3.xml)
|
|
||||||
result = self.compare(xml, stanza.xml, stanza2.xml, stanza3.xml)
|
|
||||||
else:
|
else:
|
||||||
debug = "Two methods for creating stanzas do not match.\n"
|
stanza_class = stanza.__class__
|
||||||
debug += "Given XML:\n%s\n" % tostring(xml)
|
xml = self.parse_xml(criteria)
|
||||||
debug += "Given stanza:\n%s\n" % tostring(stanza.xml)
|
|
||||||
debug += "Generated stanza:\n%s\n" % tostring(stanza2.xml)
|
|
||||||
result = self.compare(xml, stanza.xml, stanza2.xml)
|
|
||||||
|
|
||||||
self.failUnless(result, debug)
|
# Ensure that top level namespaces are used, even if they
|
||||||
|
# were not provided.
|
||||||
|
self.fix_namespaces(stanza.xml, 'jabber:client')
|
||||||
|
self.fix_namespaces(xml, 'jabber:client')
|
||||||
|
|
||||||
|
stanza2 = stanza_class(xml=xml)
|
||||||
|
|
||||||
|
if use_values:
|
||||||
|
# Using getStanzaValues() and setStanzaValues() will add
|
||||||
|
# XML for any interface that has a default value. We need
|
||||||
|
# to set those defaults on the existing stanzas and XML
|
||||||
|
# so that they will compare correctly.
|
||||||
|
default_stanza = stanza_class()
|
||||||
|
if defaults is None:
|
||||||
|
known_defaults = {
|
||||||
|
Message: ['type'],
|
||||||
|
Presence: ['priority']
|
||||||
|
}
|
||||||
|
defaults = known_defaults.get(stanza_class, [])
|
||||||
|
for interface in defaults:
|
||||||
|
stanza[interface] = stanza[interface]
|
||||||
|
stanza2[interface] = stanza2[interface]
|
||||||
|
# Can really only automatically add defaults for top
|
||||||
|
# level attribute values. Anything else must be accounted
|
||||||
|
# for in the provided XML string.
|
||||||
|
if interface not in xml.attrib:
|
||||||
|
if interface in default_stanza.xml.attrib:
|
||||||
|
value = default_stanza.xml.attrib[interface]
|
||||||
|
xml.attrib[interface] = value
|
||||||
|
|
||||||
|
values = stanza2.getStanzaValues()
|
||||||
|
stanza3 = stanza_class()
|
||||||
|
stanza3.setStanzaValues(values)
|
||||||
|
|
||||||
|
debug = "Three methods for creating stanzas do not match.\n"
|
||||||
|
debug += "Given XML:\n%s\n" % tostring(xml)
|
||||||
|
debug += "Given stanza:\n%s\n" % tostring(stanza.xml)
|
||||||
|
debug += "Generated stanza:\n%s\n" % tostring(stanza2.xml)
|
||||||
|
debug += "Second generated stanza:\n%s\n" % tostring(stanza3.xml)
|
||||||
|
result = self.compare(xml, stanza.xml, stanza2.xml, stanza3.xml)
|
||||||
|
else:
|
||||||
|
debug = "Two methods for creating stanzas do not match.\n"
|
||||||
|
debug += "Given XML:\n%s\n" % tostring(xml)
|
||||||
|
debug += "Given stanza:\n%s\n" % tostring(stanza.xml)
|
||||||
|
debug += "Generated stanza:\n%s\n" % tostring(stanza2.xml)
|
||||||
|
result = self.compare(xml, stanza.xml, stanza2.xml)
|
||||||
|
|
||||||
|
self.failUnless(result, debug)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Methods for simulating stanza streams.
|
# Methods for simulating stanza streams.
|
||||||
|
@ -346,7 +368,7 @@ class SleekTest(unittest.TestCase):
|
||||||
parts.append('xmlns="%s"' % default_ns)
|
parts.append('xmlns="%s"' % default_ns)
|
||||||
return header % ' '.join(parts)
|
return header % ' '.join(parts)
|
||||||
|
|
||||||
def recv(self, data, stanza_class=StanzaBase, defaults=[],
|
def recv(self, data, defaults=[], method='exact',
|
||||||
use_values=True, timeout=1):
|
use_values=True, timeout=1):
|
||||||
"""
|
"""
|
||||||
Pass data to the dummy XMPP client as if it came from an XMPP server.
|
Pass data to the dummy XMPP client as if it came from an XMPP server.
|
||||||
|
@ -354,12 +376,15 @@ class SleekTest(unittest.TestCase):
|
||||||
If using a live connection, verify what the server has sent.
|
If using a live connection, verify what the server has sent.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
data -- String stanza XML to be received and processed by
|
data -- If a dummy socket is being used, the XML that is to
|
||||||
the XMPP client or component.
|
be received next. Otherwise it is the criteria used
|
||||||
stanza_class -- The stanza object class for verifying data received
|
to match against live data that is received.
|
||||||
by a live connection. Defaults to StanzaBase.
|
|
||||||
defaults -- A list of stanza interfaces with default values that
|
defaults -- A list of stanza interfaces with default values that
|
||||||
may interfere with comparisons.
|
may interfere with comparisons.
|
||||||
|
method -- Select the type of comparison to use for
|
||||||
|
verifying the received stanza. Options are 'exact',
|
||||||
|
'id', 'stanzapath', 'xpath', and 'mask'.
|
||||||
|
Defaults to the value of self.match_method.
|
||||||
use_values -- Indicates if stanza comparisons should test using
|
use_values -- Indicates if stanza comparisons should test using
|
||||||
getStanzaValues() and setStanzaValues().
|
getStanzaValues() and setStanzaValues().
|
||||||
Defaults to True.
|
Defaults to True.
|
||||||
|
@ -373,10 +398,13 @@ class SleekTest(unittest.TestCase):
|
||||||
recv_data = self.xmpp.socket.next_recv(timeout)
|
recv_data = self.xmpp.socket.next_recv(timeout)
|
||||||
if recv_data is None:
|
if recv_data is None:
|
||||||
return False
|
return False
|
||||||
stanza = stanza_class(xml=self.parse_xml(recv_data))
|
xml = self.parse_xml(recv_data)
|
||||||
return self.check(stanza_class, stanza, data,
|
self.fix_namespaces(xml, 'jabber:client')
|
||||||
defaults=defaults,
|
stanza = self.xmpp._build_stanza(xml, 'jabber:client')
|
||||||
use_values=use_values)
|
self.check(stanza, data,
|
||||||
|
method=method,
|
||||||
|
defaults=defaults,
|
||||||
|
use_values=use_values)
|
||||||
else:
|
else:
|
||||||
# place the data in the dummy socket receiving queue.
|
# place the data in the dummy socket receiving queue.
|
||||||
data = str(data)
|
data = str(data)
|
||||||
|
@ -450,21 +478,33 @@ class SleekTest(unittest.TestCase):
|
||||||
'%s %s' % (xml.tag, xml.attrib),
|
'%s %s' % (xml.tag, xml.attrib),
|
||||||
'%s %s' % (recv_xml.tag, recv_xml.attrib)))
|
'%s %s' % (recv_xml.tag, recv_xml.attrib)))
|
||||||
|
|
||||||
def recv_feature(self, data, use_values=True, timeout=1):
|
def recv_feature(self, data, method='mask', use_values=True, timeout=1):
|
||||||
"""
|
"""
|
||||||
"""
|
"""
|
||||||
|
if method is None and hasattr(self, 'match_method'):
|
||||||
|
method = getattr(self, 'match_method')
|
||||||
|
|
||||||
if self.xmpp.socket.is_live:
|
if self.xmpp.socket.is_live:
|
||||||
# we are working with a live connection, so we should
|
# we are working with a live connection, so we should
|
||||||
# verify what has been received instead of simulating
|
# verify what has been received instead of simulating
|
||||||
# receiving data.
|
# receiving data.
|
||||||
recv_data = self.xmpp.socket.next_recv(timeout)
|
recv_data = self.xmpp.socket.next_recv(timeout)
|
||||||
if recv_data is None:
|
|
||||||
return False
|
|
||||||
xml = self.parse_xml(data)
|
xml = self.parse_xml(data)
|
||||||
recv_xml = self.parse_xml(recv_data)
|
recv_xml = self.parse_xml(recv_data)
|
||||||
self.failUnless(self.compare(xml, recv_xml),
|
if recv_data is None:
|
||||||
"Features do not match.\nDesired:\n%s\nReceived:\n%s" % (
|
return False
|
||||||
tostring(xml), tostring(recv_xml)))
|
if method == 'exact':
|
||||||
|
self.failUnless(self.compare(xml, recv_xml),
|
||||||
|
"Features do not match.\nDesired:\n%s\nReceived:\n%s" % (
|
||||||
|
tostring(xml), tostring(recv_xml)))
|
||||||
|
elif method == 'mask':
|
||||||
|
matcher = MatchXMLMask(xml)
|
||||||
|
self.failUnless(matcher.match(recv_xml),
|
||||||
|
"Stanza did not match using %s method:\n" % method + \
|
||||||
|
"Criteria:\n%s\n" % tostring(xml) + \
|
||||||
|
"Stanza:\n%s" % tostring(recv_xml))
|
||||||
|
else:
|
||||||
|
raise ValueError("Uknown matching method: %s" % method)
|
||||||
else:
|
else:
|
||||||
# place the data in the dummy socket receiving queue.
|
# place the data in the dummy socket receiving queue.
|
||||||
data = str(data)
|
data = str(data)
|
||||||
|
@ -515,20 +555,29 @@ class SleekTest(unittest.TestCase):
|
||||||
"Stream headers do not match:\nDesired:\n%s\nSent:\n%s" % (
|
"Stream headers do not match:\nDesired:\n%s\nSent:\n%s" % (
|
||||||
header, sent_header))
|
header, sent_header))
|
||||||
|
|
||||||
def send_feature(self, data, use_values=True, timeout=1):
|
def send_feature(self, data, method='mask', use_values=True, timeout=1):
|
||||||
"""
|
"""
|
||||||
"""
|
"""
|
||||||
sent_data = self.xmpp.socket.next_sent(timeout)
|
sent_data = self.xmpp.socket.next_sent(timeout)
|
||||||
if sent_data is None:
|
|
||||||
return False
|
|
||||||
xml = self.parse_xml(data)
|
xml = self.parse_xml(data)
|
||||||
sent_xml = self.parse_xml(sent_data)
|
sent_xml = self.parse_xml(sent_data)
|
||||||
self.failUnless(self.compare(xml, sent_xml),
|
if sent_data is None:
|
||||||
"Features do not match.\nDesired:\n%s\nSent:\n%s" % (
|
return False
|
||||||
tostring(xml), tostring(sent_xml)))
|
if method == 'exact':
|
||||||
|
self.failUnless(self.compare(xml, sent_xml),
|
||||||
|
"Features do not match.\nDesired:\n%s\nReceived:\n%s" % (
|
||||||
|
tostring(xml), tostring(sent_xml)))
|
||||||
|
elif method == 'mask':
|
||||||
|
matcher = MatchXMLMask(xml)
|
||||||
|
self.failUnless(matcher.match(sent_xml),
|
||||||
|
"Stanza did not match using %s method:\n" % method + \
|
||||||
|
"Criteria:\n%s\n" % tostring(xml) + \
|
||||||
|
"Stanza:\n%s" % tostring(sent_xml))
|
||||||
|
else:
|
||||||
|
raise ValueError("Uknown matching method: %s" % method)
|
||||||
|
|
||||||
def send(self, data, defaults=None,
|
def send(self, data, defaults=None, use_values=True,
|
||||||
use_values=True, timeout=.1):
|
timeout=.1, method='exact'):
|
||||||
"""
|
"""
|
||||||
Check that the XMPP client sent the given stanza XML.
|
Check that the XMPP client sent the given stanza XML.
|
||||||
|
|
||||||
|
@ -544,15 +593,20 @@ class SleekTest(unittest.TestCase):
|
||||||
values which may interfere with comparisons.
|
values which may interfere with comparisons.
|
||||||
timeout -- Time in seconds to wait for a stanza before
|
timeout -- Time in seconds to wait for a stanza before
|
||||||
failing the check.
|
failing the check.
|
||||||
|
method -- Select the type of comparison to use for
|
||||||
|
verifying the sent stanza. Options are 'exact',
|
||||||
|
'id', 'stanzapath', 'xpath', and 'mask'.
|
||||||
|
Defaults to the value of self.match_method.
|
||||||
"""
|
"""
|
||||||
|
sent = self.xmpp.socket.next_sent(timeout)
|
||||||
if isinstance(data, str):
|
if isinstance(data, str):
|
||||||
xml = self.parse_xml(data)
|
xml = self.parse_xml(data)
|
||||||
self.fix_namespaces(xml, 'jabber:client')
|
self.fix_namespaces(xml, 'jabber:client')
|
||||||
data = self.xmpp._build_stanza(xml, 'jabber:client')
|
data = self.xmpp._build_stanza(xml, 'jabber:client')
|
||||||
sent = self.xmpp.socket.next_sent(timeout)
|
|
||||||
self.check(data, sent,
|
self.check(data, sent,
|
||||||
defaults=defaults,
|
method=method,
|
||||||
use_values=use_values)
|
defaults=defaults,
|
||||||
|
use_values=use_values)
|
||||||
|
|
||||||
def stream_close(self):
|
def stream_close(self):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -117,7 +117,7 @@ class MatchXMLMask(MatcherBase):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# If the mask includes text, compare it.
|
# If the mask includes text, compare it.
|
||||||
if mask.text and source.text != mask.text:
|
if mask.text and source.text and source.text.strip() != mask.text.strip():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Compare attributes. The stanza must include the attributes
|
# Compare attributes. The stanza must include the attributes
|
||||||
|
@ -127,10 +127,17 @@ class MatchXMLMask(MatcherBase):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Recursively check subelements.
|
# Recursively check subelements.
|
||||||
|
matched_elements = {}
|
||||||
for subelement in mask:
|
for subelement in mask:
|
||||||
if use_ns:
|
if use_ns:
|
||||||
if not self._mask_cmp(source.find(subelement.tag),
|
matched = False
|
||||||
subelement, use_ns):
|
for other in source.findall(subelement.tag):
|
||||||
|
matched_elements[other] = False
|
||||||
|
if self._mask_cmp(other, subelement, use_ns):
|
||||||
|
if not matched_elements.get(other, False):
|
||||||
|
matched_elements[other] = True
|
||||||
|
matched = True
|
||||||
|
if not matched:
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
if not self._mask_cmp(self._get_child(source, subelement.tag),
|
if not self._mask_cmp(self._get_child(source, subelement.tag),
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
from sleekxmpp.test import *
|
from sleekxmpp.test import *
|
||||||
import sleekxmpp.plugins.xep_0033 as xep_0033
|
import sleekxmpp.plugins.xep_0033 as xep_0033
|
||||||
|
|
||||||
|
@ -29,10 +31,6 @@ class TestLiveStream(SleekTest):
|
||||||
<mechanism>DIGEST-MD5</mechanism>
|
<mechanism>DIGEST-MD5</mechanism>
|
||||||
<mechanism>PLAIN</mechanism>
|
<mechanism>PLAIN</mechanism>
|
||||||
</mechanisms>
|
</mechanisms>
|
||||||
<c xmlns="http://jabber.org/protocol/caps"
|
|
||||||
node="http://www.process-one.net/en/ejabberd/"
|
|
||||||
ver="TQ2JFyRoSa70h2G1bpgjzuXb2sU=" hash="sha-1" />
|
|
||||||
<register xmlns="http://jabber.org/features/iq-register" />
|
|
||||||
</stream:features>
|
</stream:features>
|
||||||
""")
|
""")
|
||||||
self.send_feature("""
|
self.send_feature("""
|
||||||
|
@ -49,11 +47,6 @@ class TestLiveStream(SleekTest):
|
||||||
<mechanism>DIGEST-MD5</mechanism>
|
<mechanism>DIGEST-MD5</mechanism>
|
||||||
<mechanism>PLAIN</mechanism>
|
<mechanism>PLAIN</mechanism>
|
||||||
</mechanisms>
|
</mechanisms>
|
||||||
<c xmlns="http://jabber.org/protocol/caps"
|
|
||||||
node="http://www.process-one.net/en/ejabberd/"
|
|
||||||
ver="TQ2JFyRoSa70h2G1bpgjzuXb2sU="
|
|
||||||
hash="sha-1" />
|
|
||||||
<register xmlns="http://jabber.org/features/iq-register" />
|
|
||||||
</stream:features>
|
</stream:features>
|
||||||
""")
|
""")
|
||||||
self.send_feature("""
|
self.send_feature("""
|
||||||
|
@ -69,11 +62,6 @@ class TestLiveStream(SleekTest):
|
||||||
<stream:features>
|
<stream:features>
|
||||||
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind" />
|
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind" />
|
||||||
<session xmlns="urn:ietf:params:xml:ns:xmpp-session" />
|
<session xmlns="urn:ietf:params:xml:ns:xmpp-session" />
|
||||||
<c xmlns="http://jabber.org/protocol/caps"
|
|
||||||
node="http://www.process-one.net/en/ejabberd/"
|
|
||||||
ver="TQ2JFyRoSa70h2G1bpgjzuXb2sU="
|
|
||||||
hash="sha-1" />
|
|
||||||
<register xmlns="http://jabber.org/features/iq-register" />
|
|
||||||
</stream:features>
|
</stream:features>
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
@ -99,6 +87,9 @@ class TestLiveStream(SleekTest):
|
||||||
suite = unittest.TestLoader().loadTestsFromTestCase(TestLiveStream)
|
suite = unittest.TestLoader().loadTestsFromTestCase(TestLiveStream)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
logging.basicConfig(level=logging.DEBUG,
|
||||||
|
format='%(levelname)-8s %(message)s')
|
||||||
|
|
||||||
tests = unittest.TestSuite([suite])
|
tests = unittest.TestSuite([suite])
|
||||||
result = unittest.TextTestRunner(verbosity=2).run(tests)
|
result = unittest.TextTestRunner(verbosity=2).run(tests)
|
||||||
test_ns = 'http://andyet.net/protocol/tests'
|
test_ns = 'http://andyet.net/protocol/tests'
|
||||||
|
|
Loading…
Reference in a new issue