From 69fb9d90c7952f129ca2f4b3128890d9ed44afcb Mon Sep 17 00:00:00 2001 From: Sotiris Date: Thu, 3 Sep 2026 19:32:45 +0200 Subject: [PATCH] T53716: Add DurationField support (serialization, filtering, tests) --- binder/json.py | 2 + binder/models.py | 13 ++++- binder/views.py | 7 ++- changelog/T53716 | 1 + tests/filters/test_duration_filters.py | 47 +++++++++++++++ tests/test_duration_field.py | 79 ++++++++++++++++++++++++++ tests/test_json.py | 8 ++- tests/testapp/models/__init__.py | 1 + tests/testapp/models/truck.py | 11 ++++ tests/testapp/views/__init__.py | 1 + tests/testapp/views/truck.py | 7 +++ 11 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 changelog/T53716 create mode 100644 tests/filters/test_duration_filters.py create mode 100644 tests/test_duration_field.py create mode 100644 tests/testapp/models/truck.py create mode 100644 tests/testapp/views/truck.py diff --git a/binder/json.py b/binder/json.py index 99d3aad7..7af2ae1f 100644 --- a/binder/json.py +++ b/binder/json.py @@ -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 @@ -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')) diff --git a/binder/models.py b/binder/models.py index f38b7875..03a6ba28 100644 --- a/binder/models.py +++ b/binder/models.py @@ -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 @@ -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] diff --git a/binder/views.py b/binder/views.py index 3b2c83a1..8fa35aa2 100644 --- a/binder/views.py +++ b/binder/views.py @@ -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: @@ -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 diff --git a/changelog/T53716 b/changelog/T53716 new file mode 100644 index 00000000..fc8755a9 --- /dev/null +++ b/changelog/T53716 @@ -0,0 +1 @@ +- [T53716] Support DurationField: serialize timedelta values and support filtering on duration fields diff --git a/tests/filters/test_duration_filters.py b/tests/filters/test_duration_filters.py new file mode 100644 index 00000000..453a0f3e --- /dev/null +++ b/tests/filters/test_duration_filters.py @@ -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']) diff --git a/tests/test_duration_field.py b/tests/test_duration_field.py new file mode 100644 index 00000000..029fb96c --- /dev/null +++ b/tests/test_duration_field.py @@ -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']) diff --git a/tests/test_json.py b/tests/test_json.py index dc5348ae..5147a6f0 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -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 @@ -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)])) diff --git a/tests/testapp/models/__init__.py b/tests/testapp/models/__init__.py index f944a1ca..21732bba 100644 --- a/tests/testapp/models/__init__.py +++ b/tests/testapp/models/__init__.py @@ -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, diff --git a/tests/testapp/models/truck.py b/tests/testapp/models/truck.py new file mode 100644 index 00000000..6e756c1e --- /dev/null +++ b/tests/testapp/models/truck.py @@ -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) diff --git a/tests/testapp/views/__init__.py b/tests/testapp/views/__init__.py index 31fb1049..35a15cb6 100644 --- a/tests/testapp/views/__init__.py +++ b/tests/testapp/views/__init__.py @@ -22,3 +22,4 @@ from .web_page import WebPageView from .donor import DonorView from .pet import PetView +from .truck import TruckView diff --git a/tests/testapp/views/truck.py b/tests/testapp/views/truck.py new file mode 100644 index 00000000..86a694c2 --- /dev/null +++ b/tests/testapp/views/truck.py @@ -0,0 +1,7 @@ +from binder.views import ModelView + +from ..models import Truck + + +class TruckView(ModelView): + model = Truck