From 5f619d7a399b8abeae65a89d8e0ad20caf30f0fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Sat, 21 Oct 2023 17:47:05 -0300 Subject: [PATCH] Implement SealedDateFormField to preserves partial dates Note that Django forms _already_ have suppport for partial date data; we just need to extend it when converting to Python (using SealedDate instead of returning an error). --- bookwyrm/tests/test_sealed_date.py | 47 ++++++++++++++++++++++ bookwyrm/utils/sealed_date.py | 63 ++++++++++++++++++++++++++++-- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/bookwyrm/tests/test_sealed_date.py b/bookwyrm/tests/test_sealed_date.py index af46519a9..0eca8a815 100644 --- a/bookwyrm/tests/test_sealed_date.py +++ b/bookwyrm/tests/test_sealed_date.py @@ -3,7 +3,10 @@ import datetime import unittest +from django.core.exceptions import ValidationError from django.utils import timezone +from django.utils import translation + from bookwyrm.utils import sealed_date @@ -25,3 +28,47 @@ class SealedDateTest(unittest.TestCase): sealed = sealed_date.YearSeal.from_datetime(self.dt) self.assertEqual(self.dt, sealed) self.assertEqual("2023", sealed.partial_isoformat()) + + +class SealedDateFormFieldTest(unittest.TestCase): + def setUp(self): + self.dt = datetime.datetime(2022, 11, 21, 17, 1, 0, tzinfo=timezone.utc) + self.field = sealed_date.SealedDateFormField() + + def test_prepare_value(self): + sealed = sealed_date.SealedDate.from_datetime(self.dt) + self.assertEqual("2022-11-21", self.field.prepare_value(sealed)) + + def test_prepare_value_month(self): + sealed = sealed_date.MonthSeal.from_datetime(self.dt) + self.assertEqual("2022-11-0", self.field.prepare_value(sealed)) + + def test_prepare_value_year(self): + sealed = sealed_date.YearSeal.from_datetime(self.dt) + self.assertEqual("2022-0-0", self.field.prepare_value(sealed)) + + def test_to_python(self): + date = self.field.to_python("2022-11-21") + self.assertIsInstance(date, sealed_date.SealedDate) + self.assertEqual("2022-11-21", date.partial_isoformat()) + + def test_to_python_month(self): + date = self.field.to_python("2022-11-0") + self.assertIsInstance(date, sealed_date.SealedDate) + self.assertEqual("2022-11", date.partial_isoformat()) + with self.assertRaises(ValidationError): + self.field.to_python("2022-0-25") + + def test_to_python_year(self): + date = self.field.to_python("2022-0-0") + self.assertIsInstance(date, sealed_date.SealedDate) + self.assertEqual("2022", date.partial_isoformat()) + with self.assertRaises(ValidationError): + self.field.to_python("0-05-25") + + def test_to_python_other(self): + with translation.override("es"): + # check super() is called + date = self.field.to_python("5/6/97") + self.assertIsInstance(date, sealed_date.SealedDate) + self.assertEqual("1997-06-05", date.partial_isoformat()) diff --git a/bookwyrm/utils/sealed_date.py b/bookwyrm/utils/sealed_date.py index 6b3994bbb..9641e3e68 100644 --- a/bookwyrm/utils/sealed_date.py +++ b/bookwyrm/utils/sealed_date.py @@ -1,9 +1,23 @@ """Implementation of the SealedDate class.""" -from datetime import datetime +from __future__ import annotations + +from datetime import datetime, timedelta + +from django.core.exceptions import ValidationError +from django.forms import DateField +from django.forms.widgets import SelectDateWidget +from django.utils import timezone -class SealedDate(datetime): # TODO: migrate from DateTimeField to DateField +_westmost_tz = timezone.get_fixed_timezone(timedelta(hours=-12)) + +# TODO: migrate SealedDate to `date` + + +class SealedDate(datetime): + """a date object sealed into a certain precision (day, month, year)""" + @property def has_day(self) -> bool: return self.has_month @@ -16,10 +30,17 @@ class SealedDate(datetime): # TODO: migrate from DateTimeField to DateField return self.strftime("%Y-%m-%d") @classmethod - def from_datetime(cls, dt): + def from_datetime(cls, dt) -> SealedDate: # pylint: disable=invalid-name return cls.combine(dt.date(), dt.time(), tzinfo=dt.tzinfo) + @classmethod + def from_date_parts(cls, year, month, day) -> SealedDate: + # because SealedDate is actually a datetime object, we must create it with a + # timezone such that its date remains stable no matter the values of USE_TZ, + # current_timezone and default_timezone. + return cls.from_datetime(datetime(year, month, day, tzinfo=_westmost_tz)) + class MonthSeal(SealedDate): @property @@ -37,3 +58,39 @@ class YearSeal(SealedDate): def partial_isoformat(self) -> str: return self.strftime("%Y") + + +class SealedDateFormField(DateField): + """date form field with support for SealedDate""" + + def prepare_value(self, value): + # As a convention, Django's `SelectDateWidget` uses "0" for missing + # parts. We piggy-back into that, to make it work with SealedDate. + if not isinstance(value, SealedDate): + return super().prepare_value(value) + elif value.has_day: + return value.strftime("%Y-%m-%d") + elif value.has_month: + return value.strftime("%Y-%m-0") + else: + return value.strftime("%Y-0-0") + + def to_python(self, value) -> SealedDate: + try: + date = super().to_python(value) + except ValidationError as ex: + if match := SelectDateWidget.date_re.match(value): + year, month, day = map(int, match.groups()) + if not match or (day and not month) or not year: + raise ex from None + if not month: + return YearSeal.from_date_parts(year, 1, 1) + elif not day: + return MonthSeal.from_date_parts(year, month, 1) + else: + if date is None: + return None + else: + year, month, day = date.year, date.month, date.day + + return SealedDate.from_date_parts(year, month, day)