Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions syncano/models/archetypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
59 changes: 56 additions & 3 deletions syncano/models/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -622,6 +623,7 @@ class SchemaField(JSONField):
'datetime',
'file',
'reference',
'relation',
'array',
'object',
'geopoint',
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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,
Expand Down
55 changes: 43 additions & 12 deletions syncano/models/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down
62 changes: 62 additions & 0 deletions syncano/models/relations.py
Original file line number Diff line number Diff line change
@@ -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)
92 changes: 92 additions & 0 deletions tests/integration_test_relations.py
Original file line number Diff line number Diff line change
@@ -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)