mirror of
https://github.com/graphql-python/graphene.git
synced 2025-02-02 12:44:15 +03:00
First types implementation
This commit is contained in:
parent
2f0bd7cf7c
commit
afe8614753
0
graphene/core/ntypes/__init__.py
Normal file
0
graphene/core/ntypes/__init__.py
Normal file
42
graphene/core/ntypes/argument.py
Normal file
42
graphene/core/ntypes/argument.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
from itertools import chain
|
||||||
|
|
||||||
|
from graphql.core.type import GraphQLArgument
|
||||||
|
|
||||||
|
from .base import OrderedType, ArgumentType
|
||||||
|
from ...utils import to_camel_case
|
||||||
|
|
||||||
|
|
||||||
|
class Argument(OrderedType):
|
||||||
|
def __init__(self, type, description=None, default=None, name=None, _creation_counter=None):
|
||||||
|
super(Argument, self).__init__(_creation_counter=_creation_counter)
|
||||||
|
self.name = name
|
||||||
|
self.type = type
|
||||||
|
self.description = description
|
||||||
|
self.default = default
|
||||||
|
|
||||||
|
def internal_type(self, schema):
|
||||||
|
return GraphQLArgument(schema.T(self.type), self.default, self.description)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
def to_arguments(*args, **kwargs):
|
||||||
|
arguments = {}
|
||||||
|
iter_arguments = chain(kwargs.items(), [(None, a) for a in args])
|
||||||
|
|
||||||
|
for name, arg in iter_arguments:
|
||||||
|
if isinstance(arg, Argument):
|
||||||
|
argument = arg
|
||||||
|
elif isinstance(arg, ArgumentType):
|
||||||
|
argument = arg.as_argument()
|
||||||
|
else:
|
||||||
|
raise ValueError('Unknown argument value type %r' % arg)
|
||||||
|
|
||||||
|
if name:
|
||||||
|
argument.name = to_camel_case(name)
|
||||||
|
assert argument.name, 'Argument in field must have a name'
|
||||||
|
assert argument.name not in arguments, 'Found more than one Argument with same name {}'.format(argument.name)
|
||||||
|
arguments[argument.name] = argument
|
||||||
|
|
||||||
|
return sorted(arguments.values())
|
71
graphene/core/ntypes/base.py
Normal file
71
graphene/core/ntypes/base.py
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
from functools import total_ordering
|
||||||
|
from ..types import BaseObjectType, InputObjectType
|
||||||
|
|
||||||
|
|
||||||
|
@total_ordering
|
||||||
|
class OrderedType(object):
|
||||||
|
creation_counter = 0
|
||||||
|
|
||||||
|
def __init__(self, _creation_counter=None):
|
||||||
|
self.creation_counter = _creation_counter or self.gen_counter()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def gen_counter():
|
||||||
|
counter = OrderedType.creation_counter
|
||||||
|
OrderedType.creation_counter += 1
|
||||||
|
return counter
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
# Needed for @total_ordering
|
||||||
|
if type(self) == type(other):
|
||||||
|
return self.creation_counter == other.creation_counter
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
def __lt__(self, other):
|
||||||
|
# This is needed because bisect does not take a comparison function.
|
||||||
|
if type(self) == type(other):
|
||||||
|
return self.creation_counter < other.creation_counter
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash((self.creation_counter))
|
||||||
|
|
||||||
|
|
||||||
|
class MirroredType(OrderedType):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
_creation_counter = kwargs.pop('_creation_counter', None)
|
||||||
|
super(MirroredType, self).__init__(_creation_counter=_creation_counter)
|
||||||
|
self.args = args
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def internal_type(cls, schema):
|
||||||
|
return getattr(cls, 'T', None)
|
||||||
|
|
||||||
|
|
||||||
|
class ArgumentType(MirroredType):
|
||||||
|
def as_argument(self):
|
||||||
|
from .argument import Argument
|
||||||
|
return Argument(self.__class__, _creation_counter=self.creation_counter, *self.args, **self.kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class FieldType(MirroredType):
|
||||||
|
def contribute_to_class(self, cls, name):
|
||||||
|
if issubclass(cls, InputObjectType):
|
||||||
|
inputfield = self.as_inputfield()
|
||||||
|
return inputfield.contribute_to_class(cls, name)
|
||||||
|
elif issubclass(cls, BaseObjectType):
|
||||||
|
field = self.as_field()
|
||||||
|
return field.contribute_to_class(cls, name)
|
||||||
|
|
||||||
|
def as_field(self):
|
||||||
|
from .field import Field
|
||||||
|
return Field(self.__class__, _creation_counter=self.creation_counter, *self.args, **self.kwargs)
|
||||||
|
|
||||||
|
def as_inputfield(self):
|
||||||
|
from .field import InputField
|
||||||
|
return InputField(self.__class__, _creation_counter=self.creation_counter, *self.args, **self.kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class MountedType(FieldType, ArgumentType):
|
||||||
|
pass
|
20
graphene/core/ntypes/definitions.py
Normal file
20
graphene/core/ntypes/definitions.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
from graphql.core.type import (GraphQLList, GraphQLNonNull)
|
||||||
|
|
||||||
|
from .base import MountedType
|
||||||
|
|
||||||
|
|
||||||
|
class OfType(MountedType):
|
||||||
|
def __init__(self, of_type, *args, **kwargs):
|
||||||
|
self.of_type = of_type
|
||||||
|
super(OfType, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def internal_type(self, schema):
|
||||||
|
return self.T(schema.T(self.of_type))
|
||||||
|
|
||||||
|
|
||||||
|
class List(OfType):
|
||||||
|
T = GraphQLList
|
||||||
|
|
||||||
|
|
||||||
|
class NonNull(OfType):
|
||||||
|
T = GraphQLNonNull
|
61
graphene/core/ntypes/field.py
Normal file
61
graphene/core/ntypes/field.py
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from graphql.core.type import GraphQLField, GraphQLInputObjectField
|
||||||
|
|
||||||
|
from .base import OrderedType
|
||||||
|
from .argument import to_arguments
|
||||||
|
from ...utils import to_camel_case
|
||||||
|
from ..types import BaseObjectType, InputObjectType
|
||||||
|
|
||||||
|
|
||||||
|
class Field(OrderedType):
|
||||||
|
def __init__(self, type, description=None, args=None, name=None, resolver=None, *args_list, **kwargs):
|
||||||
|
_creation_counter = kwargs.pop('_creation_counter', None)
|
||||||
|
super(Field, self).__init__(_creation_counter=_creation_counter)
|
||||||
|
self.name = name
|
||||||
|
self.type = type
|
||||||
|
self.description = description
|
||||||
|
args = OrderedDict(args or {}, **kwargs)
|
||||||
|
self.arguments = to_arguments(*args_list, **args)
|
||||||
|
self.resolver = resolver
|
||||||
|
|
||||||
|
def contribute_to_class(self, cls, attname):
|
||||||
|
assert issubclass(cls, BaseObjectType), 'Field {} cannot be mounted in {}'.format(self, cls)
|
||||||
|
if not self.name:
|
||||||
|
self.name = to_camel_case(attname)
|
||||||
|
self.attname = attname
|
||||||
|
self.object_type = cls
|
||||||
|
if self.type == 'self':
|
||||||
|
self.type = cls
|
||||||
|
cls._meta.add_field(self)
|
||||||
|
|
||||||
|
def internal_type(self, schema):
|
||||||
|
return GraphQLField(schema.T(self.type), args=self.get_arguments(schema), resolver=self.resolver,
|
||||||
|
description=self.description,)
|
||||||
|
|
||||||
|
def get_arguments(self, schema):
|
||||||
|
if not self.arguments:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return OrderedDict([(arg.name, schema.T(arg)) for arg in self.arguments])
|
||||||
|
|
||||||
|
|
||||||
|
class InputField(OrderedType):
|
||||||
|
def __init__(self, type, description=None, default=None, name=None, _creation_counter=None):
|
||||||
|
super(InputField, self).__init__(_creation_counter=_creation_counter)
|
||||||
|
self.name = name
|
||||||
|
self.type = type
|
||||||
|
self.description = description
|
||||||
|
self.default = default
|
||||||
|
|
||||||
|
def contribute_to_class(self, cls, attname):
|
||||||
|
assert issubclass(cls, InputObjectType), 'InputField {} cannot be mounted in {}'.format(self, cls)
|
||||||
|
if not self.name:
|
||||||
|
self.name = to_camel_case(attname)
|
||||||
|
self.attname = attname
|
||||||
|
self.object_type = cls
|
||||||
|
cls._meta.add_field(self)
|
||||||
|
|
||||||
|
def internal_type(self, schema):
|
||||||
|
return GraphQLInputObjectField(schema.T(self.type), default_value=self.default,
|
||||||
|
description=self.description)
|
40
graphene/core/ntypes/scalars.py
Normal file
40
graphene/core/ntypes/scalars.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
from graphql.core.type import (GraphQLBoolean, GraphQLFloat, GraphQLID,
|
||||||
|
GraphQLInt, GraphQLScalarType, GraphQLString)
|
||||||
|
|
||||||
|
from .base import MountedType
|
||||||
|
|
||||||
|
|
||||||
|
class String(MountedType):
|
||||||
|
T = GraphQLString
|
||||||
|
|
||||||
|
|
||||||
|
class Int(MountedType):
|
||||||
|
T = GraphQLInt
|
||||||
|
|
||||||
|
|
||||||
|
class Boolean(MountedType):
|
||||||
|
T = GraphQLBoolean
|
||||||
|
|
||||||
|
|
||||||
|
class ID(MountedType):
|
||||||
|
T = GraphQLID
|
||||||
|
|
||||||
|
|
||||||
|
class Float(MountedType):
|
||||||
|
T = GraphQLFloat
|
||||||
|
|
||||||
|
|
||||||
|
class Scalar(MountedType):
|
||||||
|
@classmethod
|
||||||
|
def internal_type(cls, schema):
|
||||||
|
serialize = getattr(cls, 'serialize')
|
||||||
|
parse_literal = getattr(cls, 'parse_literal')
|
||||||
|
parse_value = getattr(cls, 'parse_value')
|
||||||
|
|
||||||
|
return GraphQLScalarType(
|
||||||
|
name=cls.__name__,
|
||||||
|
description=cls.__doc__,
|
||||||
|
serialize=serialize,
|
||||||
|
parse_value=parse_value,
|
||||||
|
parse_literal=parse_literal
|
||||||
|
)
|
0
graphene/core/ntypes/tests/__init__.py
Normal file
0
graphene/core/ntypes/tests/__init__.py
Normal file
45
graphene/core/ntypes/tests/test_argument.py
Normal file
45
graphene/core/ntypes/tests/test_argument.py
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
from pytest import raises
|
||||||
|
from graphql.core.type import GraphQLArgument
|
||||||
|
|
||||||
|
from ..argument import Argument, to_arguments
|
||||||
|
from ..scalars import String
|
||||||
|
from graphene.core.types import ObjectType
|
||||||
|
from graphene.core.schema import Schema
|
||||||
|
|
||||||
|
|
||||||
|
def test_argument_internal_type():
|
||||||
|
class MyObjectType(ObjectType):
|
||||||
|
pass
|
||||||
|
schema = Schema(query=MyObjectType)
|
||||||
|
a = Argument(MyObjectType, description='My argument', default='3')
|
||||||
|
type = schema.T(a)
|
||||||
|
assert isinstance(type, GraphQLArgument)
|
||||||
|
assert type.description == 'My argument'
|
||||||
|
assert type.default_value == '3'
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_arguments():
|
||||||
|
arguments = to_arguments(
|
||||||
|
Argument(String, name='myArg'),
|
||||||
|
String(name='otherArg'),
|
||||||
|
my_kwarg=String(),
|
||||||
|
other_kwarg=String(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [a.name for a in arguments] == ['myArg', 'otherArg', 'myKwarg', 'otherKwarg']
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_arguments_no_name():
|
||||||
|
with raises(AssertionError) as excinfo:
|
||||||
|
to_arguments(
|
||||||
|
String(),
|
||||||
|
)
|
||||||
|
assert 'must have a name' in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_arguments_wrong_type():
|
||||||
|
with raises(ValueError) as excinfo:
|
||||||
|
to_arguments(
|
||||||
|
p=3
|
||||||
|
)
|
||||||
|
assert 'Unknown argument value type 3' == str(excinfo.value)
|
66
graphene/core/ntypes/tests/test_base.py
Normal file
66
graphene/core/ntypes/tests/test_base.py
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
from mock import patch
|
||||||
|
|
||||||
|
from ..base import OrderedType, MountedType
|
||||||
|
from ..field import Field, InputField
|
||||||
|
from ..argument import Argument
|
||||||
|
from graphene.core.types import ObjectType, InputObjectType
|
||||||
|
|
||||||
|
|
||||||
|
def test_orderedtype_equal():
|
||||||
|
a = OrderedType()
|
||||||
|
assert a == a
|
||||||
|
assert hash(a) == hash(a)
|
||||||
|
|
||||||
|
|
||||||
|
def test_orderedtype_different():
|
||||||
|
a = OrderedType()
|
||||||
|
b = OrderedType()
|
||||||
|
assert a != b
|
||||||
|
assert hash(a) != hash(b)
|
||||||
|
assert a < b
|
||||||
|
assert b > a
|
||||||
|
|
||||||
|
|
||||||
|
@patch('graphene.core.ntypes.field.Field')
|
||||||
|
def test_type_as_field_called(Field):
|
||||||
|
resolver = lambda x: x
|
||||||
|
a = MountedType(2, description='A', resolver=resolver)
|
||||||
|
a.as_field()
|
||||||
|
Field.assert_called_with(MountedType, 2, _creation_counter=a.creation_counter, description='A', resolver=resolver)
|
||||||
|
|
||||||
|
|
||||||
|
@patch('graphene.core.ntypes.argument.Argument')
|
||||||
|
def test_type_as_argument_called(Argument):
|
||||||
|
a = MountedType(2, description='A')
|
||||||
|
a.as_argument()
|
||||||
|
Argument.assert_called_with(MountedType, 2, _creation_counter=a.creation_counter, description='A')
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_as_field():
|
||||||
|
resolver = lambda x: x
|
||||||
|
|
||||||
|
class MyObjectType(ObjectType):
|
||||||
|
t = MountedType(description='A', resolver=resolver)
|
||||||
|
|
||||||
|
fields_map = MyObjectType._meta.fields_map
|
||||||
|
field = fields_map.get('t')
|
||||||
|
assert isinstance(field, Field)
|
||||||
|
assert field.description == 'A'
|
||||||
|
assert field.object_type == MyObjectType
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_as_inputfield():
|
||||||
|
class MyObjectType(InputObjectType):
|
||||||
|
t = MountedType(description='A')
|
||||||
|
|
||||||
|
fields_map = MyObjectType._meta.fields_map
|
||||||
|
field = fields_map.get('t')
|
||||||
|
assert isinstance(field, InputField)
|
||||||
|
assert field.description == 'A'
|
||||||
|
assert field.object_type == MyObjectType
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_as_argument():
|
||||||
|
a = MountedType(description='A')
|
||||||
|
argument = a.as_argument()
|
||||||
|
assert isinstance(argument, Argument)
|
26
graphene/core/ntypes/tests/test_definitions.py
Normal file
26
graphene/core/ntypes/tests/test_definitions.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
from graphql.core.type import (GraphQLList, GraphQLString, GraphQLNonNull)
|
||||||
|
|
||||||
|
from ..definitions import List, NonNull
|
||||||
|
from ..scalars import String
|
||||||
|
from graphene.core.schema import Schema
|
||||||
|
|
||||||
|
schema = Schema()
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_scalar():
|
||||||
|
type = schema.T(List(String()))
|
||||||
|
assert isinstance(type, GraphQLList)
|
||||||
|
assert type.of_type == GraphQLString
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonnull_scalar():
|
||||||
|
type = schema.T(NonNull(String()))
|
||||||
|
assert isinstance(type, GraphQLNonNull)
|
||||||
|
assert type.of_type == GraphQLString
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_scalar():
|
||||||
|
type = schema.T(NonNull(List(String())))
|
||||||
|
assert isinstance(type, GraphQLNonNull)
|
||||||
|
assert isinstance(type.of_type, GraphQLList)
|
||||||
|
assert type.of_type.of_type == GraphQLString
|
62
graphene/core/ntypes/tests/test_field.py
Normal file
62
graphene/core/ntypes/tests/test_field.py
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
from graphql.core.type import GraphQLField, GraphQLInputObjectField, GraphQLString
|
||||||
|
|
||||||
|
from ..field import Field, InputField
|
||||||
|
from ..scalars import String
|
||||||
|
from graphene.core.types import ObjectType, InputObjectType
|
||||||
|
from graphene.core.schema import Schema
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_internal_type():
|
||||||
|
resolver = lambda *args: args
|
||||||
|
|
||||||
|
field = Field(String, description='My argument', resolver=resolver)
|
||||||
|
|
||||||
|
class Query(ObjectType):
|
||||||
|
my_field = field
|
||||||
|
schema = Schema(query=Query)
|
||||||
|
|
||||||
|
type = schema.T(field)
|
||||||
|
assert field.name == 'myField'
|
||||||
|
assert isinstance(type, GraphQLField)
|
||||||
|
assert type.description == 'My argument'
|
||||||
|
assert type.resolver == resolver
|
||||||
|
assert type.type == GraphQLString
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_custom_name():
|
||||||
|
field = Field(None, name='my_customName')
|
||||||
|
|
||||||
|
class MyObjectType(ObjectType):
|
||||||
|
my_field = field
|
||||||
|
|
||||||
|
assert field.name == 'my_customName'
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_custom_arguments():
|
||||||
|
field = Field(None, name='my_customName', p=String())
|
||||||
|
|
||||||
|
class MyObjectType(ObjectType):
|
||||||
|
my_field = field
|
||||||
|
|
||||||
|
schema = Schema(query=MyObjectType)
|
||||||
|
|
||||||
|
args = field.get_arguments(schema)
|
||||||
|
assert 'p' in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_inputfield_internal_type():
|
||||||
|
field = InputField(String, description='My input field', default='3')
|
||||||
|
|
||||||
|
class MyObjectType(InputObjectType):
|
||||||
|
my_field = field
|
||||||
|
|
||||||
|
class Query(ObjectType):
|
||||||
|
input_ot = Field(MyObjectType)
|
||||||
|
|
||||||
|
schema = Schema(query=MyObjectType)
|
||||||
|
|
||||||
|
type = schema.T(field)
|
||||||
|
assert field.name == 'myField'
|
||||||
|
assert isinstance(type, GraphQLInputObjectField)
|
||||||
|
assert type.description == 'My input field'
|
||||||
|
assert type.default_value == '3'
|
52
graphene/core/ntypes/tests/test_scalars.py
Normal file
52
graphene/core/ntypes/tests/test_scalars.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
from graphql.core.type import (GraphQLBoolean, GraphQLFloat, GraphQLID,
|
||||||
|
GraphQLInt, GraphQLScalarType, GraphQLString)
|
||||||
|
|
||||||
|
from ..scalars import String, Int, Boolean, ID, Float, Scalar
|
||||||
|
from graphene.core.schema import Schema
|
||||||
|
|
||||||
|
schema = Schema()
|
||||||
|
|
||||||
|
|
||||||
|
def test_string_scalar():
|
||||||
|
assert schema.T(String()) == GraphQLString
|
||||||
|
|
||||||
|
|
||||||
|
def test_int_scalar():
|
||||||
|
assert schema.T(Int()) == GraphQLInt
|
||||||
|
|
||||||
|
|
||||||
|
def test_boolean_scalar():
|
||||||
|
assert schema.T(Boolean()) == GraphQLBoolean
|
||||||
|
|
||||||
|
|
||||||
|
def test_id_scalar():
|
||||||
|
assert schema.T(ID()) == GraphQLID
|
||||||
|
|
||||||
|
|
||||||
|
def test_float_scalar():
|
||||||
|
assert schema.T(Float()) == GraphQLFloat
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_scalar():
|
||||||
|
import datetime
|
||||||
|
from graphql.core.language import ast
|
||||||
|
|
||||||
|
class DateTimeScalar(Scalar):
|
||||||
|
'''DateTimeScalar Documentation'''
|
||||||
|
@staticmethod
|
||||||
|
def serialize(dt):
|
||||||
|
return dt.isoformat()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_literal(node):
|
||||||
|
if isinstance(node, ast.StringValue):
|
||||||
|
return datetime.datetime.strptime(node.value, "%Y-%m-%dT%H:%M:%S.%f")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_value(value):
|
||||||
|
return datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f")
|
||||||
|
|
||||||
|
scalar_type = schema.T(DateTimeScalar)
|
||||||
|
assert isinstance(scalar_type, GraphQLScalarType)
|
||||||
|
assert scalar_type.name == 'DateTimeScalar'
|
||||||
|
assert scalar_type.description == 'DateTimeScalar Documentation'
|
|
@ -37,7 +37,9 @@ class Schema(object):
|
||||||
if object_type not in self._types:
|
if object_type not in self._types:
|
||||||
internal_type = object_type.internal_type(self)
|
internal_type = object_type.internal_type(self)
|
||||||
self._types[object_type] = internal_type
|
self._types[object_type] = internal_type
|
||||||
self._types_names[internal_type.name] = object_type
|
name = getattr(internal_type, 'name', None)
|
||||||
|
if name:
|
||||||
|
self._types_names[name] = object_type
|
||||||
return self._types[object_type]
|
return self._types[object_type]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
[flake8]
|
[flake8]
|
||||||
exclude = tests/*,setup.py
|
exclude = tests/*,setup.py
|
||||||
max-line-length = 160
|
max-line-length = 160
|
||||||
|
|
||||||
|
[coverage:run]
|
||||||
|
omit = core/ntypes/tests/*
|
||||||
|
|
Loading…
Reference in New Issue
Block a user