mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-11-26 19:41:11 +00:00
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:
parent
777c8b4549
commit
5f619d7a39
2 changed files with 107 additions and 3 deletions
|
@ -3,7 +3,10 @@
|
||||||
import datetime
|
import datetime
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils import translation
|
||||||
|
|
||||||
from bookwyrm.utils import sealed_date
|
from bookwyrm.utils import sealed_date
|
||||||
|
|
||||||
|
|
||||||
|
@ -25,3 +28,47 @@ class SealedDateTest(unittest.TestCase):
|
||||||
sealed = sealed_date.YearSeal.from_datetime(self.dt)
|
sealed = sealed_date.YearSeal.from_datetime(self.dt)
|
||||||
self.assertEqual(self.dt, sealed)
|
self.assertEqual(self.dt, sealed)
|
||||||
self.assertEqual("2023", sealed.partial_isoformat())
|
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())
|
||||||
|
|
|
@ -1,9 +1,23 @@
|
||||||
"""Implementation of the SealedDate class."""
|
"""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
|
@property
|
||||||
def has_day(self) -> bool:
|
def has_day(self) -> bool:
|
||||||
return self.has_month
|
return self.has_month
|
||||||
|
@ -16,10 +30,17 @@ class SealedDate(datetime): # TODO: migrate from DateTimeField to DateField
|
||||||
return self.strftime("%Y-%m-%d")
|
return self.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_datetime(cls, dt):
|
def from_datetime(cls, dt) -> SealedDate:
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
return cls.combine(dt.date(), dt.time(), tzinfo=dt.tzinfo)
|
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):
|
class MonthSeal(SealedDate):
|
||||||
@property
|
@property
|
||||||
|
@ -37,3 +58,39 @@ class YearSeal(SealedDate):
|
||||||
|
|
||||||
def partial_isoformat(self) -> str:
|
def partial_isoformat(self) -> str:
|
||||||
return self.strftime("%Y")
|
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)
|
||||||
|
|
Loading…
Reference in a new issue