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
2 changes: 2 additions & 0 deletions binder/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import decimal

from django.http import HttpResponse
from django.utils.duration import duration_string

from .exceptions import BinderRequestError
from psycopg2.extras import DateTimeTZRange
Expand All @@ -17,6 +18,7 @@
datetime.datetime: lambda v: v.strftime('%Y-%m-%dT%H:%M:%S.%f%z'), # .isoformat() can omit microseconds
datetime.date: lambda v: v.isoformat(),
datetime.time: lambda v: v.strftime('%H:%M:%S.%f%z'),
datetime.timedelta: duration_string,
uuid.UUID: str,
decimal.Decimal: str,
DateTimeTZRange: lambda v: (v.lower.strftime('%Y-%m-%dT%H:%M:%S.%f%z'), v.upper.strftime('%Y-%m-%dT%H:%M:%S.%f%z'))
Expand Down
13 changes: 12 additions & 1 deletion binder/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from django.utils.timezone import get_fixed_timezone

from django.utils.translation import gettext_lazy as _
from django.utils.dateparse import parse_date, parse_datetime
from django.utils.dateparse import parse_date, parse_datetime, parse_duration

from binder.json import jsonloads

Expand Down Expand Up @@ -382,6 +382,17 @@ def clean_value(self, qualifier, v):



class DurationFieldFilter(FieldFilter):
fields = [models.DurationField]
allowed_qualifiers = [None, 'in', 'gt', 'gte', 'lt', 'lte', 'range', 'isnull']

def clean_value(self, qualifier, v):
value = parse_duration(v)
if value is None:
raise ValidationError('Invalid duration value {{{}}} for {}.'.format(v, self.field_description()))
return value


class BooleanFieldFilter(FieldFilter):
fields = [models.BooleanField]
allowed_qualifiers = [None]
Expand Down
7 changes: 6 additions & 1 deletion binder/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2192,7 +2192,7 @@ def delete_old_file():

else:
try:
f.to_python(value)
converted = f.to_python(value)
except TypeError:
raise BinderFieldTypeError(self.model.__name__, field)
except ValidationError as ve:
Expand All @@ -2210,6 +2210,11 @@ def delete_old_file():
}
}
})
# Store the parsed value instead of the raw string: non-native
# backends (MySQL) call duration_microseconds() which does
# not accept strings, unlike PostgreSQL's interval parser.
if isinstance(f, models.DurationField):
value = converted
setattr(obj, f.attname, value)
return False

Expand Down
1 change: 1 addition & 0 deletions changelog/T53716
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- [T53716] Support DurationField: serialize timedelta values and support filtering on duration fields
47 changes: 47 additions & 0 deletions tests/filters/test_duration_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from datetime import timedelta

from django.contrib.auth.models import User
from django.test import Client, TestCase

from binder.json import jsonloads

from ..testapp.models import Truck


class DurationFiltersTest(TestCase):
def setUp(self):
super().setUp()
u = User(username='testuser', is_active=True, is_superuser=True)
u.set_password('test')
u.save()
self.client = Client()
self.assertTrue(self.client.login(username='testuser', password='test'))

Truck(name='Slow', truck_loading_time=timedelta(hours=2)).save()
Truck(name='Fast', truck_loading_time=timedelta(minutes=30)).save()

def test_exact_match(self):
response = self.client.get('/truck/', data={'.truck_loading_time': '00:30:00'})
self.assertEqual(response.status_code, 200)
result = jsonloads(response.content)
self.assertEqual(1, len(result['data']))
self.assertEqual('Fast', result['data'][0]['name'])

def test_gte(self):
response = self.client.get('/truck/', data={'.truck_loading_time:gte': '01:00:00', 'order_by': 'truck_loading_time'})
self.assertEqual(response.status_code, 200)
result = jsonloads(response.content)
self.assertEqual(1, len(result['data']))
self.assertEqual('Slow', result['data'][0]['name'])

def test_range(self):
response = self.client.get('/truck/', data={'.truck_loading_time:range': '00:20:00,01:00:00'})
self.assertEqual(response.status_code, 200)
result = jsonloads(response.content)
self.assertEqual(1, len(result['data']))
self.assertEqual('Fast', result['data'][0]['name'])

def test_invalid_value(self):
response = self.client.get('/truck/', data={'.truck_loading_time': 'banana'})
self.assertEqual(response.status_code, 418)
self.assertEqual('RequestError', jsonloads(response.content)['code'])
79 changes: 79 additions & 0 deletions tests/test_duration_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import json

from django.contrib.auth.models import User
from django.test import Client, TestCase

from binder.json import jsonloads

from .testapp.models import Truck


class DurationFieldTest(TestCase):
def setUp(self):
super().setUp()
u = User(username='testuser', is_active=True, is_superuser=True)
u.set_password('test')
u.save()
self.client = Client()
self.assertTrue(self.client.login(username='testuser', password='test'))

def _post(self, data):
return self.client.post('/truck/', data=json.dumps(data), content_type='application/json')

def test_post_uses_default(self):
response = self._post({'name': 'Truck 1'})
self.assertEqual(response.status_code, 200)
result = jsonloads(response.content)
self.assertEqual('00:30:00', result['truck_loading_time'])
self.assertIsNone(result['maintenance_time'])

def test_post_get_roundtrip(self):
response = self._post({
'name': 'Truck 2',
'truck_loading_time': '01:30:00',
'maintenance_time': '2 03:04:05',
})
self.assertEqual(response.status_code, 200)
result = jsonloads(response.content)
self.assertEqual('01:30:00', result['truck_loading_time'])
self.assertEqual('2 03:04:05', result['maintenance_time'])
truck_id = result['id']

# GET again to prove the stored value round-trips
# NOTE: detail GET responses nest the object under 'data'
response = self.client.get('/truck/{}/'.format(truck_id))
self.assertEqual(response.status_code, 200)
result = jsonloads(response.content)['data']
self.assertEqual('01:30:00', result['truck_loading_time'])
self.assertEqual('2 03:04:05', result['maintenance_time'])

def test_put_updates_duration_and_accepts_iso8601(self):
truck_id = jsonloads(self._post({'name': 'Truck 3'}).content)['id']
response = self.client.put('/truck/{}/'.format(truck_id),
data=json.dumps({'truck_loading_time': 'PT2H'}), content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertEqual('02:00:00', jsonloads(response.content)['truck_loading_time'])

def test_put_null_on_nullable_field(self):
truck_id = jsonloads(self._post({'name': 'Truck 4', 'maintenance_time': '01:00:00'}).content)['id']
response = self.client.put('/truck/{}/'.format(truck_id),
data=json.dumps({'maintenance_time': None}), content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertIsNone(jsonloads(response.content)['maintenance_time'])

def test_post_invalid_string_returns_validation_error(self):
response = self._post({'name': 'Truck 5', 'truck_loading_time': 'banana'})
self.assertEqual(response.status_code, 400)
self.assertEqual('ValidationError', jsonloads(response.content)['code'])

def test_post_non_string_returns_type_error(self):
response = self._post({'name': 'Truck 6', 'truck_loading_time': 1800})
self.assertEqual(response.status_code, 418)
self.assertEqual('RequestError', jsonloads(response.content)['code'])

def test_put_null_on_non_nullable_field_returns_validation_error(self):
truck_id = jsonloads(self._post({'name': 'Truck 7'}).content)['id']
response = self.client.put('/truck/{}/'.format(truck_id),
data=json.dumps({'truck_loading_time': None}), content_type='application/json')
self.assertEqual(response.status_code, 400)
self.assertEqual('ValidationError', jsonloads(response.content)['code'])
8 changes: 7 additions & 1 deletion tests/test_json.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import json as python_core_json

from datetime import datetime, date, timezone
from datetime import datetime, date, timezone, timedelta
from uuid import UUID
from decimal import Decimal
from django.test import TestCase
Expand Down Expand Up @@ -78,3 +78,9 @@ def test_json_datetimerange_dump_correctly(self):
t = datetime(2016, 1, 1, 1, 2, 3, 313337, tzinfo=timezone.utc)
d = DateTimeTZRange(t, t)
self.assertEqual('["2016-01-01T01:02:03.313337+0000", "2016-01-01T01:02:03.313337+0000"]', binder_json.jsondumps(d))

def test_json_durations_dump_correctly(self):
self.assertEqual('["00:30:00"]', binder_json.jsondumps([timedelta(minutes=30)]))
self.assertEqual('["02:03:04.123456"]', binder_json.jsondumps([timedelta(hours=2, minutes=3, seconds=4, microseconds=123456)]))
self.assertEqual('["2 03:00:00"]', binder_json.jsondumps([timedelta(days=2, hours=3)]))
self.assertEqual('["-1 23:30:00"]', binder_json.jsondumps([timedelta(minutes=-30)]))
1 change: 1 addition & 0 deletions tests/testapp/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .country import Country
from .web_page import WebPage
from .pet import Pet
from .truck import Truck
from .reverse_config_models import (
ReverseParent,
ReverseChild,
Expand Down
11 changes: 11 additions & 0 deletions tests/testapp/models/truck.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from datetime import timedelta

from django.db import models

from binder.models import BinderModel


class Truck(BinderModel):
name = models.TextField(max_length=64)
truck_loading_time = models.DurationField(default=timedelta(minutes=30))
maintenance_time = models.DurationField(null=True, blank=True)
1 change: 1 addition & 0 deletions tests/testapp/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@
from .web_page import WebPageView
from .donor import DonorView
from .pet import PetView
from .truck import TruckView
7 changes: 7 additions & 0 deletions tests/testapp/views/truck.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from binder.views import ModelView

from ..models import Truck


class TruckView(ModelView):
model = Truck
Loading