Skip to content
Merged
3 changes: 2 additions & 1 deletion syncano/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@
from .data_views import * # NOQA
from .incentives import * # NOQA
from .traces import * # NOQA
from .push_notification import * # NOQA
from .push_notification import * # NOQA
from .geo import * # NOQA
83 changes: 67 additions & 16 deletions syncano/models/fields.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import json
import re
from collections import namedtuple
from datetime import date, datetime

import six
Expand All @@ -9,6 +8,7 @@
from syncano.exceptions import SyncanoFieldError, SyncanoValueError
from syncano.utils import force_text

from .geo import Distance, GeoPoint
from .manager import SchemaManager
from .registry import registry

Expand Down Expand Up @@ -687,13 +687,10 @@ def to_native(self, value):
return value


GeoPointStruct = namedtuple('GeoPointHelper', ['latitude', 'longitude'])


class GeoPoint(Field):
class GeoPointField(Field):

def validate(self, value, model_instance):
super(GeoPoint, self).validate(value, model_instance)
super(GeoPointField, self).validate(value, model_instance)

if not self.required and not value:
return
Expand All @@ -704,28 +701,85 @@ def validate(self, value, model_instance):
except (ValueError, TypeError):
raise SyncanoValueError('Expected an object')

if not isinstance(value, GeoPointStruct):
raise SyncanoValueError('Expected an GeoPointStruct')
if not isinstance(value, GeoPoint):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This kind of checks can be done with assertions like that https://wiki.python.org/moin/UsingAssertionsEffectively:
assert isinstance(value, GeoPoint), 'Expected GeoPoint'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using asserts in code that is meant to be normally used (outside of tests) is baaad. Especially for validations.
python -O and suddenly my validations do not work ;)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The last time I was running python with -O flag was in 2007. It doesn't give you any kind of advantage :) Maybe if you run your tests with -O flag, then it would be a problem, but otherwise IMHO it's ok. And it's the official way for type assertions.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, not exactly. It's official in terms of places where the object is actually used. This is a validation function so these are two different things to me. Never seen an assert in a validator of any kind. Although it's likely that main reason for this is the fact that we want a specific Exception class that can be handled rather than AssertionError which suggests something went very wrong.

Anyway, python promotes duck-typing heavily and doesn't like using isinstance. In my opinion that's why they are ok with asserts officially. But coming from C/C++ background, depending on asserts is well, ugly.

Also, kind of unrelated - it's pretty ridiculous that they changed in 3.x print into a function but assert is still a statement.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Me not like asserts in such context - so leave it that way.

raise SyncanoValueError('Expected a GeoPoint')

def to_native(self, value):
if value is None:
return

geo_struct = {'latitude': value[0], 'longitude': value[1]}
if isinstance(value, bool):
return value # exists lookup

if isinstance(value, dict):
value = GeoPoint(latitude=value['latitude'], longitude=value['longitude'])

if isinstance(value, tuple):
geo_struct = value[0].to_native()
else:
geo_struct = value.to_native()

geo_struct = json.dumps(geo_struct)

return geo_struct

def to_query(self, value, lookup_type):
"""
Returns field's value prepared for usage in HTTP request query.
"""
super(GeoPointField, self).to_query(value, lookup_type)

if lookup_type not in ['near', 'exists']:
raise SyncanoValueError('Lookup {} not supported for geopoint field'.format(lookup_type))

if lookup_type in ['exists']:
if isinstance(value, bool):
return value
else:
raise SyncanoValueError('Bool expected in {} lookup.'.format(lookup_type))

if isinstance(value, dict):
value = (
GeoPoint(latitude=value.pop('latitude'), longitude=value.pop('longitude')),
Distance(**value)
)

if len(value) != 2 or not isinstance(value[0], GeoPoint) or not isinstance(value[1], Distance):
raise SyncanoValueError('This lookup should be a tuple with GeoPoint and Distance: '
'<field_name>__near=(GeoPoint(52.12, 22.12), Distance(kilometers=100))')

query_dict = value[0].to_native()
query_dict.update(value[1].to_native())

return query_dict

def to_python(self, value):
if value is None:
return

value = self._process_string_types(value)

if isinstance(value, GeoPoint):
return value

latitude, longitude = self._process_value(value)

if not latitude or not longitude:
raise SyncanoValueError('Expected the `longitude` and `latitude` fields.')

return GeoPoint(latitude=latitude, longitude=longitude)

@classmethod
def _process_string_types(cls, value):
if isinstance(value, six.string_types):
try:
value = json.loads(value)
return json.loads(value)
except (ValueError, TypeError):
raise SyncanoValueError('Invalid value: can not be parsed.')
return value

@classmethod
def _process_value(cls, value):
longitude = None
latitude = None

Expand All @@ -737,12 +791,9 @@ def to_python(self, value):
latitude = value[0]
longitude = value[1]
except IndexError:
raise SyncanoValueError('Can not parse the geo struct.')

if not longitude or not latitude:
raise SyncanoValueError('Expected the `longitude` and `latitude` fields.')
raise SyncanoValueError('Can not parse the geo point.')

return GeoPointStruct(latitude, longitude)
return latitude, longitude


MAPPING = {
Expand All @@ -768,5 +819,5 @@ def to_python(self, value):
'schema': SchemaField,
'array': ArrayField,
'object': ObjectField,
'geopoint': GeoPoint,
'geopoint': GeoPointField,
}
37 changes: 37 additions & 0 deletions syncano/models/geo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
from syncano.exceptions import SyncanoValueError


class GeoPoint(object):

def __init__(self, latitude, longitude):
self.latitude = latitude
self.longitude = longitude

def __repr__(self):
return "GeoPoint(latitude={}, longitude={})".format(self.latitude, self.longitude)

def to_native(self):
geo_struct_dump = {'latitude': self.latitude, 'longitude': self.longitude}
return geo_struct_dump


class Distance(object):

KILOMETERS = '_in_kilometers'
MILES = '_in_miles'

def __init__(self, kilometers=None, miles=None):
if kilometers is not None and miles is not None:
raise SyncanoValueError('`kilometers` and `miles` can not be set at the same time.')

if kilometers is None and miles is None:
raise SyncanoValueError('`kilometers` or `miles` attribute should be specified.')

self.distance = kilometers or miles
self.unit = self.KILOMETERS if kilometers is not None else self.MILES

def to_native(self):
return {
'distance{}'.format(self.unit): self.distance
}
2 changes: 1 addition & 1 deletion syncano/models/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -882,7 +882,7 @@ class for :class:`~syncano.models.base.Object` model.
LOOKUP_SEPARATOR = '__'
ALLOWED_LOOKUPS = [
'gt', 'gte', 'lt', 'lte',
'eq', 'neq', 'exists', 'in',
'eq', 'neq', 'exists', 'in', 'near'
]

def __init__(self):
Expand Down
107 changes: 107 additions & 0 deletions tests/integration_test_geo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
import six
from syncano.exceptions import SyncanoValueError
from syncano.models import Class, Distance, GeoPoint, Object
from tests.integration_test import InstanceMixin, IntegrationTest


class GeoPointApiTest(InstanceMixin, IntegrationTest):

@classmethod
def setUpClass(cls):
super(GeoPointApiTest, cls).setUpClass()

cls.city_model = Class.please.create(
instance_name=cls.instance.name,
name='city',
schema=[
{"name": "city", "type": "string"},
{"name": "location", "type": "geopoint", "filter_index": True},
]
)

cls.warsaw = cls.city_model.objects.create(location=(52.2240698, 20.9942933), city='Warsaw')
cls.paris = cls.city_model.objects.create(location=(52.4731384, 13.5425588), city='Berlin')
cls.berlin = cls.city_model.objects.create(location=(48.8589101, 2.3125377), city='Paris')
cls.london = cls.city_model.objects.create(city='London')

cls.list_london = ['London']
cls.list_warsaw = ['Warsaw']
cls.list_warsaw_berlin = ['Warsaw', 'Berlin']
cls.list_warsaw_berlin_paris = ['Warsaw', 'Berlin', 'Paris']

def test_filtering_on_geo_point_near(self):

distances = {
100: self.list_warsaw,
600: self.list_warsaw_berlin,
1400: self.list_warsaw_berlin_paris
}

for distance, cities in six.iteritems(distances):
objects = Object.please.list(instance_name=self.instance.name, class_name="city").filter(
location__near={
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe sth like this ?
location__near= GeoLookup(GeoPoint(52.2297, 21.0122), distance, GeoLookup.KILOMETERS)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sense! ;)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or do it similar to how Django does it and add a simplified Distance() object. And wrapping it in GeoLookup seems unnecessary unless it's consistent with other lookups. We only got one __near

location__near=(GeoPoint(52.2297, 21.0122), Distance(kms=distance))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks quite nice :) kms=distance is for kilometers ms will be for miles?
Or: Distance(kilometers=10) and Distance(miles=10) ? What would you like more as a dev?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@opalczynski as a non-python Dev, your option is more readable to me.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess Distance(kilometers=10) and Distance(miles=10) is better to me.
So:
location__near=(GeoPoint(52.2297, 21.0122), Distance(kilometers=distance))

"latitude": 52.2297,
"longitude": 21.0122,
"kilometers": distance,
}
)

result_list = self._prepare_result_list(objects)

self.assertListEqual(result_list, cities)

def test_filtering_on_geo_point_near_miles(self):
objects = Object.please.list(instance_name=self.instance.name, class_name="city").filter(
location__near={
"latitude": 52.2297,
"longitude": 21.0122,
"miles": 10,
}
)
result_list = self._prepare_result_list(objects)
self.assertListEqual(result_list, self.list_warsaw)

def test_filtering_on_geo_point_near_with_another_syntax(self):
objects = self.city_model.objects.filter(
location__near=(GeoPoint(52.2297, 21.0122), Distance(kilometers=10))
)
result_list = self._prepare_result_list(objects)
self.assertListEqual(result_list, self.list_warsaw)

objects = self.city_model.objects.filter(
location__near=(GeoPoint(52.2297, 21.0122), Distance(miles=10))
)
result_list = self._prepare_result_list(objects)
self.assertListEqual(result_list, self.list_warsaw)

def test_filtering_on_geo_point_exists(self):
objects = self.city_model.objects.filter(
location__exists=True
)

result_list = [o.city for o in objects]

self.assertListEqual(result_list, self.list_warsaw_berlin_paris)

objects = self.city_model.objects.filter(
location__exists=False
)

result_list = self._prepare_result_list(objects)

self.assertListEqual(result_list, self.list_london)

def test_distance_fail(self):
with self.assertRaises(SyncanoValueError):
self.city_model.objects.filter(
location__near=(GeoPoint(52.2297, 21.0122), Distance(miles=10, kilometers=20))
)

with self.assertRaises(SyncanoValueError):
self.city_model.objects.filter(
location__near=(GeoPoint(52.2297, 21.0122), Distance())
)

def _prepare_result_list(self, objects):
return [o.city for o in objects]
20 changes: 20 additions & 0 deletions tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class AllFieldsModel(models.Model):
schema_field = models.SchemaField()
array_field = models.ArrayField()
object_field = models.ObjectField()
geo_field = models.GeoPointField()

class Meta:
endpoints = {
Expand Down Expand Up @@ -571,3 +572,22 @@ def test_to_python(self):

self.field.to_python({'raz': 1, 'dwa': 2})
self.field.to_python('{"raz": 1, "dwa": 2}')


class GeoPointTestCase(BaseTestCase):
field_name = 'geo_field'

def test_validate(self):

with self.assertRaises(SyncanoValueError):
self.field.validate(12, self.instance)

self.field.validate(models.GeoPoint(latitude=52.12, longitude=12.02), self.instance)

def test_to_python(self):
with self.assertRaises(SyncanoValueError):
self.field.to_python(12)

self.field.to_python((52.12, 12.02))
self.field.to_python({'latitude': 52.12, 'longitude': 12.02})
self.field.to_python(models.GeoPoint(52.12, 12.02))