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).
This commit is contained in:
Adeodato Simó 2023-10-21 17:47:05 -03:00
parent 777c8b4549
commit 5f619d7a39
No known key found for this signature in database
GPG key ID: CDF447845F1A986F
2 changed files with 107 additions and 3 deletions

View file

@ -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())

View file

@ -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)