Skip to content

Commit 9e8b2f3

Browse files
committed
T50264: allow filtering and sorting for RelativeDeltaField
1 parent 907a047 commit 9e8b2f3

File tree

7 files changed

+174
-97
lines changed

7 files changed

+174
-97
lines changed

binder/json.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
# dateutil.relativedelta serializer, if available
2828
try:
2929
from dateutil.relativedelta import relativedelta
30-
from relativedeltafield import format_relativedelta
30+
from relativedeltafield.utils import format_relativedelta
3131
SERIALIZERS[relativedelta] = format_relativedelta
3232
except ImportError:
3333
pass

binder/models.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,25 @@ def clean_qualifier(self, qualifier, value):
321321
return qualifier, cleaned_value
322322

323323

324+
class RelativeDeltaFieldFilter(FieldFilter):
325+
name = 'RelativeDeltaFieldFilter'
326+
fields = []
327+
allowed_qualifiers = [None, 'in', 'gt', 'gte', 'lt', 'lte', 'range', 'isnull']
328+
329+
def clean_value(self, qualifier, v):
330+
from relativedeltafield.utils import parse_relativedelta
331+
try:
332+
return parse_relativedelta(v)
333+
except ValueError:
334+
raise ValidationError(v + ' is not a valid (extended) ISO8601 interval specification')
335+
336+
try:
337+
from relativedeltafield import RelativeDeltaField
338+
RelativeDeltaFieldFilter.fields.append(RelativeDeltaField)
339+
except ImportError:
340+
pass
341+
342+
324343
class TimeFieldFilter(FieldFilter):
325344
fields = [models.TimeField]
326345
# Maybe allow __startswith? And __year etc?

ci-requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ coverage
99
django-hijack<3.0.0
1010
openpyxl
1111
pika
12+
python-dateutil >= 2.6.0
13+
django-relativedelta >= 2.0.0

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
'pika == 1.3.2',
4545
],
4646
tests_require=[
47+
'django-relativedelta >= 2.0.0',
4748
'django-hijack >= 2.1.10, < 3.0.0',
4849
(
4950
'mysqlclient >= 1.3.12'

tests/__init__.py

Lines changed: 96 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -31,105 +31,105 @@
3131
'USER': 'postgres',
3232
}
3333

34-
settings.configure(**{
35-
'DEBUG': True,
36-
'SECRET_KEY': 'testy mctestface',
37-
'ALLOWED_HOSTS': ['*'],
38-
'DATABASES': {
39-
'default': db_settings,
40-
},
41-
'MIDDLEWARE': [
42-
# TODO: Try to reduce the set of absolutely required middlewares
43-
'request_id.middleware.RequestIdMiddleware',
44-
'django.contrib.sessions.middleware.SessionMiddleware',
45-
'django.middleware.csrf.CsrfViewMiddleware',
46-
'django.contrib.auth.middleware.AuthenticationMiddleware',
47-
'binder.plugins.token_auth.middleware.TokenAuthMiddleware',
48-
],
49-
'INSTALLED_APPS': [
50-
# TODO: Try to reduce the set of absolutely required applications
51-
'django.contrib.auth',
52-
'django.contrib.contenttypes',
53-
'django.contrib.sessions',
54-
'binder',
55-
'binder.plugins.token_auth',
56-
'tests',
57-
'tests.testapp',
58-
],
59-
'MIGRATION_MODULES': {
60-
'testapp': None,
61-
'auth': None,
62-
'sessions': None,
63-
'contenttypes': None,
64-
'binder': None,
65-
'token_auth': None,
66-
},
67-
'USE_TZ': True,
68-
'TIME_ZONE': 'UTC',
69-
'ROOT_URLCONF': 'tests.testapp.urls',
70-
'LOGGING': {
71-
'version': 1,
72-
'handlers': {
73-
'console': {
74-
'level': 'DEBUG',
75-
'class': 'logging.StreamHandler',
76-
},
34+
if not settings.configured:
35+
settings.configure(**{
36+
'DEBUG': True,
37+
'SECRET_KEY': 'testy mctestface',
38+
'ALLOWED_HOSTS': ['*'],
39+
'DATABASES': {
40+
'default': db_settings,
7741
},
78-
'loggers': {
79-
# We override only this one to avoid logspam
80-
# while running tests. Django warnings are
81-
# stil shown.
82-
'binder': {
83-
'handlers': ['console'],
84-
'level': 'ERROR',
85-
},
86-
}
87-
},
88-
'BINDER_PERMISSION': {
89-
'default': [
90-
('auth.reset_password_user', None),
91-
('auth.view_user', 'own'),
92-
('auth.activate_user', None),
93-
('auth.unmasquerade_user', None), # If you are masquarade, the user must be able to unmasquarade
94-
('auth.login_user', None),
95-
('auth.signup_user', None),
96-
('auth.logout_user', None),
97-
('auth.change_own_password_user', None),
42+
'MIDDLEWARE': [
43+
# TODO: Try to reduce the set of absolutely required middlewares
44+
'request_id.middleware.RequestIdMiddleware',
45+
'django.contrib.sessions.middleware.SessionMiddleware',
46+
'django.middleware.csrf.CsrfViewMiddleware',
47+
'django.contrib.auth.middleware.AuthenticationMiddleware',
48+
'binder.plugins.token_auth.middleware.TokenAuthMiddleware',
9849
],
99-
# Basic permissions which can be used to override stuff
100-
'testapp.view_country': [
101-
102-
]
103-
},
104-
'GROUP_PERMISSIONS': {
105-
'admin': [
106-
'testapp.view_country'
107-
]
108-
},
109-
'GROUP_CONTAINS': {
110-
'admin': []
111-
},
112-
'INTERNAL_MEDIA_HEADER': 'X-Accel-Redirect',
113-
'INTERNAL_MEDIA_LOCATION': '/internal/media/',
114-
})
50+
'INSTALLED_APPS': [
51+
# TODO: Try to reduce the set of absolutely required applications
52+
'django.contrib.auth',
53+
'django.contrib.contenttypes',
54+
'django.contrib.sessions',
55+
'binder',
56+
'binder.plugins.token_auth',
57+
'tests',
58+
'tests.testapp',
59+
],
60+
'MIGRATION_MODULES': {
61+
'testapp': None,
62+
'auth': None,
63+
'sessions': None,
64+
'contenttypes': None,
65+
'binder': None,
66+
'token_auth': None,
67+
},
68+
'USE_TZ': True,
69+
'TIME_ZONE': 'UTC',
70+
'ROOT_URLCONF': 'tests.testapp.urls',
71+
'LOGGING': {
72+
'version': 1,
73+
'handlers': {
74+
'console': {
75+
'level': 'DEBUG',
76+
'class': 'logging.StreamHandler',
77+
},
78+
},
79+
'loggers': {
80+
# We override only this one to avoid logspam
81+
# while running tests. Django warnings are
82+
# stil shown.
83+
'binder': {
84+
'handlers': ['console'],
85+
'level': 'ERROR',
86+
},
87+
}
88+
},
89+
'BINDER_PERMISSION': {
90+
'default': [
91+
('auth.reset_password_user', None),
92+
('auth.view_user', 'own'),
93+
('auth.activate_user', None),
94+
('auth.unmasquerade_user', None), # If you are masquarade, the user must be able to unmasquarade
95+
('auth.login_user', None),
96+
('auth.signup_user', None),
97+
('auth.logout_user', None),
98+
('auth.change_own_password_user', None),
99+
],
100+
# Basic permissions which can be used to override stuff
101+
'testapp.view_country': [
115102

116-
setup()
103+
]
104+
},
105+
'GROUP_PERMISSIONS': {
106+
'admin': [
107+
'testapp.view_country'
108+
]
109+
},
110+
'GROUP_CONTAINS': {
111+
'admin': []
112+
},
113+
'INTERNAL_MEDIA_HEADER': 'X-Accel-Redirect',
114+
'INTERNAL_MEDIA_LOCATION': '/internal/media/',
115+
})
116+
setup()
117117

118-
# Do the dance to ensure the models are synched to the DB.
119-
# This saves us from having to include migrations
120-
from django.core.management.commands.migrate import Command as MigrationCommand # noqa
121-
from django.db import connections # noqa
122-
from django.db.migrations.executor import MigrationExecutor # noqa
118+
# Do the dance to ensure the models are synched to the DB.
119+
# This saves us from having to include migrations
120+
from django.core.management.commands.migrate import Command as MigrationCommand # noqa
121+
from django.db import connections # noqa
122+
from django.db.migrations.executor import MigrationExecutor # noqa
123123

124-
# This is oh so hacky....
125-
cmd = MigrationCommand()
126-
cmd.verbosity = 0
127-
connection = connections['default']
128-
executor = MigrationExecutor(connection)
129-
cmd.sync_apps(connection, executor.loader.unmigrated_apps)
124+
# This is oh so hacky....
125+
cmd = MigrationCommand()
126+
cmd.verbosity = 0
127+
connection = connections['default']
128+
executor = MigrationExecutor(connection)
129+
cmd.sync_apps(connection, executor.loader.unmigrated_apps)
130130

131-
# Hack to make the view_country permission, which doesn't work with the MigrationCommand somehow
132-
from django.contrib.auth.models import Group, Permission, ContentType
133-
content_type = ContentType.objects.get_or_create(app_label='testapp', model='country')[0]
134-
Permission.objects.get_or_create(content_type=content_type, codename='view_country')
135-
call_command('define_groups')
131+
# Hack to make the view_country permission, which doesn't work with the MigrationCommand somehow
132+
from django.contrib.auth.models import Group, Permission, ContentType
133+
content_type = ContentType.objects.get_or_create(app_label='testapp', model='country')[0]
134+
Permission.objects.get_or_create(content_type=content_type, codename='view_country')
135+
call_command('define_groups')
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from dateutil.relativedelta import relativedelta
2+
from django.contrib.auth.models import User
3+
from django.test import Client, TestCase
4+
from json import loads
5+
from .testapp.models import Animal
6+
7+
8+
class FilterRelativeDeltaTest(TestCase):
9+
def setUp(self):
10+
super().setUp()
11+
u = User(username='testuser', is_active=True, is_superuser=True)
12+
u.set_password('test')
13+
u.save()
14+
self.client = Client()
15+
r = self.client.login(username='testuser', password='test')
16+
self.assertTrue(r)
17+
18+
def test_filter(self):
19+
bokito = Animal.objects.create(name='Bokito', feeding_period=relativedelta(hours=5))
20+
harambe = Animal.objects.create(name='Harambe', feeding_period=relativedelta(days=2))
21+
self.assertEqual(bokito.id, Animal.objects.filter(feeding_period__lt=relativedelta(days=1)).get().id)
22+
response = self.client.get('/animal/?.feeding_period:gt=P1DT6H')
23+
content = loads(response.content)
24+
self.assertEqual(200, response.status_code)
25+
self.assertEqual(1, len(content['data']))
26+
self.assertEqual(harambe.id, content['data'][0]['id'])
27+
28+
def test_sort(self):
29+
bokito = Animal.objects.create(name='Bokito', feeding_period=relativedelta(hours=5))
30+
harambe = Animal.objects.create(name='Harambe', feeding_period=relativedelta(days=2))
31+
otto = Animal.objects.create(name='Otto', feeding_period=relativedelta(days=1, hours=1))
32+
33+
sanity = list(Animal.objects.all().order_by('feeding_period'))
34+
self.assertEqual(bokito.id, sanity[0].id)
35+
self.assertEqual(otto.id, sanity[1].id)
36+
self.assertEqual(harambe.id, sanity[2].id)
37+
38+
response = self.client.get('/animal/?order_by=feeding_period')
39+
content = loads(response.content)
40+
self.assertEqual(200, response.status_code)
41+
self.assertEqual(3, len(content['data']))
42+
self.assertEqual(bokito.id, content['data'][0]['id'])
43+
self.assertEqual(otto.id, content['data'][1]['id'])
44+
self.assertEqual(harambe.id, content['data'][2]['id'])
45+
46+
response = self.client.get('/animal/?order_by=-feeding_period')
47+
content = loads(response.content)
48+
self.assertEqual(200, response.status_code)
49+
self.assertEqual(3, len(content['data']))
50+
self.assertEqual(harambe.id, content['data'][0]['id'])
51+
self.assertEqual(otto.id, content['data'][1]['id'])
52+
self.assertEqual(bokito.id, content['data'][2]['id'])

tests/testapp/models/animal.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
from dateutil.relativedelta import relativedelta
12
from django.db import models
23
from django.db.models import Value, F, Func
34
from binder.models import BinderModel, ContextAnnotation
45
from binder.exceptions import BinderValidationError
56
from binder.plugins.loaded_values import LoadedValuesMixin
7+
from relativedeltafield import RelativeDeltaField
68

79

810
class Concat(Func):
@@ -15,6 +17,7 @@ class Animal(LoadedValuesMixin, BinderModel):
1517
zoo = models.ForeignKey('Zoo', on_delete=models.CASCADE, related_name='animals', blank=True, null=True)
1618
zoo_of_birth = models.ForeignKey('Zoo', on_delete=models.CASCADE, related_name='+', blank=True, null=True) # might've been born outside captivity
1719
caretaker = models.ForeignKey('Caretaker', on_delete=models.PROTECT, related_name='animals', blank=True, null=True)
20+
feeding_period = RelativeDeltaField(default=relativedelta(days=1))
1821
deleted = models.BooleanField(default=False) # Softdelete
1922
birth_date = models.DateField(blank=True, null=True)
2023

0 commit comments

Comments
 (0)