diff --git a/syncano/models/archetypes.py b/syncano/models/archetypes.py index ce42147..984ebde 100644 --- a/syncano/models/archetypes.py +++ b/syncano/models/archetypes.py @@ -235,6 +235,9 @@ def to_python(self, data): value = data[field_name] setattr(self, field.name, value) + if isinstance(field, fields.RelationField): + setattr(self, "{}_set".format(field_name), field(instance=self, field_name=field_name)) + def to_native(self): """Converts the current instance to raw data which can be serialized to JSON and send to API. diff --git a/syncano/models/fields.py b/syncano/models/fields.py index c0429c3..944bed1 100644 --- a/syncano/models/fields.py +++ b/syncano/models/fields.py @@ -11,6 +11,7 @@ from .geo import Distance, GeoPoint from .manager import SchemaManager from .registry import registry +from .relations import RelationManager, RelationValidatorMixin class JSONToPythonMixin(object): @@ -137,7 +138,7 @@ def to_native(self, value): """ return value - def to_query(self, value, lookup_type): + def to_query(self, value, lookup_type, **kwargs): """ Returns field's value prepared for usage in HTTP request query. """ @@ -622,6 +623,7 @@ class SchemaField(JSONField): 'datetime', 'file', 'reference', + 'relation', 'array', 'object', 'geopoint', @@ -723,11 +725,11 @@ def to_native(self, value): return geo_struct - def to_query(self, value, lookup_type): + def to_query(self, value, lookup_type, **kwargs): """ Returns field's value prepared for usage in HTTP request query. """ - super(GeoPointField, self).to_query(value, lookup_type) + super(GeoPointField, self).to_query(value, lookup_type, **kwargs) if lookup_type not in ['near', 'exists']: raise SyncanoValueError('Lookup {} not supported for geopoint field'.format(lookup_type)) @@ -796,12 +798,63 @@ def _process_value(cls, value): return latitude, longitude +class RelationField(RelationValidatorMixin, WritableField): + query_allowed = True + + def __call__(self, instance, field_name): + return RelationManager(instance=instance, field_name=field_name) + + def to_python(self, value): + if not value: + return None + + if isinstance(value, dict) and 'type' in value and 'value' in value: + value = value['value'] + + if not isinstance(value, (list, tuple)): + return [value] + + return value + + def to_query(self, value, lookup_type, related_field_name=None, related_field_lookup=None, **kwargs): + + if not self.query_allowed: + raise self.ValidationError('Query on this field is not supported.') + + if lookup_type not in ['contains', 'is']: + raise SyncanoValueError('Lookup {} not supported for relation field.'.format(lookup_type)) + + query_dict = {} + + if lookup_type == 'contains': + if self._validate(value): + value = [obj.id for obj in value] + query_dict = value + + if lookup_type == 'is': + query_dict = {related_field_name: {"_{0}".format(related_field_lookup): value}} + + return query_dict + + def to_native(self, value): + if not value: + return None + + if not isinstance(value, (list, tuple)): + value = [value] + + if self._validate(value): + value = [obj.id for obj in value] + return value + + MAPPING = { 'string': StringField, 'text': StringField, 'file': FileField, 'ref': StringField, 'reference': ReferenceField, + 'relation': RelationField, 'integer': IntegerField, 'float': FloatField, 'boolean': BooleanField, diff --git a/syncano/models/manager.py b/syncano/models/manager.py index cd72e0f..8ce1606 100644 --- a/syncano/models/manager.py +++ b/syncano/models/manager.py @@ -882,7 +882,8 @@ class for :class:`~syncano.models.base.Object` model. LOOKUP_SEPARATOR = '__' ALLOWED_LOOKUPS = [ 'gt', 'gte', 'lt', 'lte', - 'eq', 'neq', 'exists', 'in', 'near' + 'eq', 'neq', 'exists', 'in', 'startswith', + 'near', 'is', 'contains', ] def __init__(self): @@ -953,30 +954,60 @@ def filter(self, **kwargs): for field_name, value in six.iteritems(kwargs): lookup = 'eq' + model_name = None if self.LOOKUP_SEPARATOR in field_name: - field_name, lookup = field_name.split(self.LOOKUP_SEPARATOR, 1) - - if field_name not in model._meta.field_names: - allowed = ', '.join(model._meta.field_names) - raise SyncanoValueError('Invalid field name "{0}" allowed are {1}.'.format(field_name, allowed)) - - if lookup not in self.ALLOWED_LOOKUPS: - allowed = ', '.join(self.ALLOWED_LOOKUPS) - raise SyncanoValueError('Invalid lookup type "{0}" allowed are {1}.'.format(lookup, allowed)) + model_name, field_name, lookup = self._get_lookup_attributes(field_name) for field in model._meta.fields: if field.name == field_name: break - query.setdefault(field_name, {}) - query[field_name]['_{0}'.format(lookup)] = field.to_query(value, lookup) + self._validate_lookup(model, model_name, field_name, lookup, field) + + query_main_lookup, query_main_field = self._get_main_lookup(model_name, field_name, lookup) + + query.setdefault(query_main_field, {}) + query[query_main_field]['_{0}'.format(query_main_lookup)] = field.to_query( + value, + query_main_lookup, + related_field_name=field_name, + related_field_lookup=lookup, + ) self.query['query'] = json.dumps(query) self.method = 'GET' self.endpoint = 'list' return self + def _get_lookup_attributes(self, field_name): + try: + model_name, field_name, lookup = field_name.split(self.LOOKUP_SEPARATOR, 2) + except ValueError: + model_name = None + field_name, lookup = field_name.split(self.LOOKUP_SEPARATOR, 1) + + return model_name, field_name, lookup + + def _validate_lookup(self, model, model_name, field_name, lookup, field): + if not model_name and field_name not in model._meta.field_names: + allowed = ', '.join(model._meta.field_names) + raise SyncanoValueError('Invalid field name "{0}" allowed are {1}.'.format(field_name, allowed)) + + if lookup not in self.ALLOWED_LOOKUPS: + allowed = ', '.join(self.ALLOWED_LOOKUPS) + raise SyncanoValueError('Invalid lookup type "{0}" allowed are {1}.'.format(lookup, allowed)) + + if model_name and field.__class__.__name__ != 'RelationField': + raise SyncanoValueError('Lookup supported only for RelationField.') + + @classmethod + def _get_main_lookup(cls, model_name, field_name, lookup): + if model_name: + return 'is', model_name + else: + return lookup, field_name + def bulk_create(self, *objects): """ Creates many new objects. diff --git a/syncano/models/relations.py b/syncano/models/relations.py new file mode 100644 index 0000000..2307d6e --- /dev/null +++ b/syncano/models/relations.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +from syncano.exceptions import SyncanoValueError + + +class RelationValidatorMixin(object): + + def validate(self, value, model_instance): + super(RelationValidatorMixin, self).validate(value, model_instance) + self._validate(value) + + @classmethod + def _validate(cls, value): + value = cls._make_list(value) + all_ints = all([isinstance(x, int) for x in value]) + from .archetypes import Model + all_objects = all([isinstance(obj, Model) for obj in value]) + object_types = [type(obj) for obj in value] + if len(set(object_types)) != 1: + raise SyncanoValueError("All objects should be the same type.") + + if (all_ints and all_objects) or (not all_ints and not all_objects): + raise SyncanoValueError("List elements should be objects or integers.") + + if all_objects: + return True + return False + + @classmethod + def _make_list(cls, value): + if not isinstance(value, (list, tuple)): + value = [value] + return value + + +class RelationManager(RelationValidatorMixin): + + def __init__(self, instance, field_name): + super(RelationManager, self).__init__() + self.instance = instance + self.model = instance._meta + self.field_name = field_name + + def add(self, *args): + self._add_or_remove(args) + + def remove(self, *args): + self._add_or_remove(args, operation='_remove') + + def _add_or_remove(self, id_list, operation='_add'): + if self._validate(id_list): + value_ids = [obj.id for obj in id_list] + else: + value_ids = id_list + + meta = self.instance._meta + connection = meta.connection + + data = {self.field_name: {operation: value_ids}} + update_path = meta.get_endpoint(name='detail')['path'] + update_path = update_path.format(**self.instance.get_endpoint_data()) + response = connection.request('PATCH', update_path, data=data) + self.instance.to_python(response) diff --git a/tests/integration_test_relations.py b/tests/integration_test_relations.py new file mode 100644 index 0000000..37348b6 --- /dev/null +++ b/tests/integration_test_relations.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +from syncano.models import Class +from tests.integration_test import InstanceMixin, IntegrationTest + + +class ResponseTemplateApiTest(InstanceMixin, IntegrationTest): + + @classmethod + def setUpClass(cls): + super(ResponseTemplateApiTest, cls).setUpClass() + + # prapare data + cls.author = Class.please.create(name="author", schema=[ + {"name": "name", "type": "string", "filter_index": True}, + {"name": "birthday", "type": "integer"}, + ]) + + cls.book = Class.please.create(name="book", schema=[ + {"name": "title", "type": "string", "filter_index": True}, + {"name": "authors", "type": "relation", "target": "author", "filter_index": True}, + ]) + + cls.prus = cls.author.objects.create(name='Bolesław Prus', birthday=1847) + cls.lem = cls.author.objects.create(name='Stanisław Lem', birthday=1921) + cls.coehlo = cls.author.objects.create(name='Paulo Coehlo', birthday=1947) + + cls.lalka = cls.book.objects.create(authors=[cls.prus.id], title='Lalka') + cls.niezwyciezony = cls.book.objects.create(authors=[cls.lem.id], title='Niezwyciężony') + cls.brida = cls.book.objects.create(authors=[cls.coehlo.id], title='Brida') + + def test_integers_list(self): + authors_list_ids = [self.prus.id, self.coehlo.id] + book = self.book.objects.create(authors=authors_list_ids, title='Strange title') + self.assertListEqual(sorted(book.authors), sorted(authors_list_ids)) + + book.delete() + + def test_object_list(self): + authors_list_ids = [self.prus.id, self.coehlo.id] + book = self.book.objects.create(authors=authors_list_ids, title='Strange title') + self.assertListEqual(sorted(book.authors), sorted(authors_list_ids)) + + book.delete() + + def test_object_assign(self): + self.lalka.authors = [self.lem, self.coehlo] + self.lalka.save() + + self.assertListEqual(sorted(self.lalka.authors), sorted([self.lem.id, self.coehlo.id])) + self.lalka.authors = [self.prus] + self.lalka.save() + + def test_related_field_add(self): + self.niezwyciezony.authors_set.add(self.coehlo) + self.assertListEqual(sorted(self.niezwyciezony.authors), sorted([self.lem.id, self.coehlo.id])) + + self.niezwyciezony.authors_set.add(self.prus.id, self.coehlo.id) + self.assertListEqual(sorted(self.niezwyciezony.authors), sorted([self.lem.id, self.prus.id, self.coehlo.id])) + + self.niezwyciezony.authors = [self.lem] + self.niezwyciezony.save() + + def test_related_field_remove(self): + self.brida.authors_set.remove(self.coehlo) + self.assertEqual(self.brida.authors, None) + + self.niezwyciezony.authors_set.remove(self.prus, self.lem, self.coehlo) + self.assertEqual(self.niezwyciezony.authors, None) + + self.niezwyciezony.authors = [self.lem] + self.niezwyciezony.save() + self.brida.authors = [self.coehlo] + self.brida.save() + + def test_related_field_lookup_contains(self): + filtered_books = self.book.objects.list().filter(authors__contains=[self.prus]) + + self.assertEqual(len(list(filtered_books)), 1) + + for book in filtered_books: + self.assertEqual(book.title, self.lalka.title) + + def test_related_field_lookup_contains_fail(self): + filtered_books = self.book.objects.list().filter(authors__contains=[self.prus, self.lem]) + self.assertEqual(len(list(filtered_books)), 0) + + def test_related_field_lookup_is(self): + filtered_books = self.book.objects.list().filter(authors__name__startswith='Stan') + + self.assertEqual(len(list(filtered_books)), 1) + for book in filtered_books: + self.assertEqual(book.title, self.niezwyciezony.title)