From 52a979da2d6a5050c32ad78b6241d2ecd79aa222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Fri, 20 Oct 2023 04:33:06 -0300 Subject: [PATCH 01/20] Add failing test case for "January 1st" offset bug --- bookwyrm/tests/views/books/test_edit_book.py | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/bookwyrm/tests/views/books/test_edit_book.py b/bookwyrm/tests/views/books/test_edit_book.py index 2dc25095f..68553e09e 100644 --- a/bookwyrm/tests/views/books/test_edit_book.py +++ b/bookwyrm/tests/views/books/test_edit_book.py @@ -1,4 +1,5 @@ """ test for app action functionality """ +from unittest import expectedFailure from unittest.mock import patch import responses from responses import matchers @@ -8,6 +9,7 @@ from django.contrib.contenttypes.models import ContentType from django.template.response import TemplateResponse from django.test import TestCase from django.test.client import RequestFactory +from django.utils import timezone from bookwyrm import forms, models, views from bookwyrm.views.books.edit_book import add_authors @@ -209,6 +211,29 @@ class EditBookViews(TestCase): book = models.Edition.objects.get(title="New Title") self.assertEqual(book.parent_work.title, "New Title") + @expectedFailure # bookwyrm#3028 + def test_published_date_timezone(self): + """user timezone does not affect publication year""" + # https://github.com/bookwyrm-social/bookwyrm/issues/3028 + self.local_user.groups.add(self.group) + create_book = views.CreateBook.as_view() + book_data = { + "title": "January 1st test", + "parent_work": self.work.id, + "last_edited_by": self.local_user.id, + "published_date_day": "1", + "published_date_month": "1", + "published_date_year": "2020", + } + request = self.factory.post("", book_data) + request.user = self.local_user + + with timezone.override("Europe/Madrid"): # Ahead of UTC. + create_book(request) + + book = models.Edition.objects.get(title="January 1st test") + self.assertEqual(book.edition_info, "2020") + def test_create_book_existing_work(self): """create an entirely new book and work""" view = views.ConfirmEditBook.as_view() From a9c605ea975731d66bb2ee3da73e8d13592e5a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Fri, 20 Oct 2023 22:36:06 -0300 Subject: [PATCH 02/20] Add SealedDate class for globally-stable, maybe-incomplete dates --- bookwyrm/tests/test_sealed_date.py | 27 +++++++++++++++++++++ bookwyrm/utils/sealed_date.py | 39 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 bookwyrm/tests/test_sealed_date.py create mode 100644 bookwyrm/utils/sealed_date.py diff --git a/bookwyrm/tests/test_sealed_date.py b/bookwyrm/tests/test_sealed_date.py new file mode 100644 index 000000000..c01625078 --- /dev/null +++ b/bookwyrm/tests/test_sealed_date.py @@ -0,0 +1,27 @@ +""" test sealed_date module """ + +import datetime +import unittest + +from django.utils import timezone +from bookwyrm.utils import sealed_date + + +class SealedDateTest(unittest.TestCase): + def setUp(self): + self.dt = datetime.datetime(2023, 10, 20, 17, 33, 10, tzinfo=timezone.utc) + + def test_day_seal(self): + sealed = sealed_date.SealedDate.from_datetime(self.dt) + self.assertEqual(self.dt, sealed) + self.assertEqual("2023-10-20", str(sealed)) + + def test_month_seal(self): + sealed = sealed_date.MonthSeal.from_datetime(self.dt) + self.assertEqual(self.dt, sealed) + self.assertEqual("2023-10", str(sealed)) + + def test_year_seal(self): + sealed = sealed_date.YearSeal.from_datetime(self.dt) + self.assertEqual(self.dt, sealed) + self.assertEqual("2023", str(sealed)) diff --git a/bookwyrm/utils/sealed_date.py b/bookwyrm/utils/sealed_date.py new file mode 100644 index 000000000..18a0bea68 --- /dev/null +++ b/bookwyrm/utils/sealed_date.py @@ -0,0 +1,39 @@ +"""Implementation of the SealedDate class.""" + +from datetime import datetime + + +class SealedDate(datetime): # TODO: migrate from DateTimeField to DateField + @property + def has_day(self) -> bool: + return self.has_month + + @property + def has_month(self) -> bool: + return True + + def __str__(self): + return self.strftime("%Y-%m-%d") + + @classmethod + def from_datetime(cls, dt): + # pylint: disable=invalid-name + return cls.combine(dt.date(), dt.time(), tzinfo=dt.tzinfo) + + +class MonthSeal(SealedDate): + @property + def has_day(self) -> bool: + return False + + def __str__(self): + return self.strftime("%Y-%m") + + +class YearSeal(SealedDate): + @property + def has_month(self) -> bool: + return False + + def __str__(self): + return self.strftime("%Y") From 46d80d56a561d5d8f2c0e38e71344e9b873e2700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Sat, 21 Oct 2023 16:55:24 -0300 Subject: [PATCH 03/20] Rename SealedDate.__str__ to `partial_isoformat` Django uses `str(date)` for backends other than PostgreSQL, so do not break "YYYY-MM-DD" formatting, just in case. --- bookwyrm/tests/test_sealed_date.py | 6 +++--- bookwyrm/utils/sealed_date.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bookwyrm/tests/test_sealed_date.py b/bookwyrm/tests/test_sealed_date.py index c01625078..af46519a9 100644 --- a/bookwyrm/tests/test_sealed_date.py +++ b/bookwyrm/tests/test_sealed_date.py @@ -14,14 +14,14 @@ class SealedDateTest(unittest.TestCase): def test_day_seal(self): sealed = sealed_date.SealedDate.from_datetime(self.dt) self.assertEqual(self.dt, sealed) - self.assertEqual("2023-10-20", str(sealed)) + self.assertEqual("2023-10-20", sealed.partial_isoformat()) def test_month_seal(self): sealed = sealed_date.MonthSeal.from_datetime(self.dt) self.assertEqual(self.dt, sealed) - self.assertEqual("2023-10", str(sealed)) + self.assertEqual("2023-10", sealed.partial_isoformat()) def test_year_seal(self): sealed = sealed_date.YearSeal.from_datetime(self.dt) self.assertEqual(self.dt, sealed) - self.assertEqual("2023", str(sealed)) + self.assertEqual("2023", sealed.partial_isoformat()) diff --git a/bookwyrm/utils/sealed_date.py b/bookwyrm/utils/sealed_date.py index 18a0bea68..6b3994bbb 100644 --- a/bookwyrm/utils/sealed_date.py +++ b/bookwyrm/utils/sealed_date.py @@ -12,7 +12,7 @@ class SealedDate(datetime): # TODO: migrate from DateTimeField to DateField def has_month(self) -> bool: return True - def __str__(self): + def partial_isoformat(self) -> str: return self.strftime("%Y-%m-%d") @classmethod @@ -26,7 +26,7 @@ class MonthSeal(SealedDate): def has_day(self) -> bool: return False - def __str__(self): + def partial_isoformat(self) -> str: return self.strftime("%Y-%m") @@ -35,5 +35,5 @@ class YearSeal(SealedDate): def has_month(self) -> bool: return False - def __str__(self): + def partial_isoformat(self) -> str: return self.strftime("%Y") From 777c8b45497d0fd671db76e8774e9ca0cac49d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Fri, 20 Oct 2023 23:05:02 -0300 Subject: [PATCH 04/20] naturalday_partial filter for working with SealedDate --- bookwyrm/templates/book/publisher_info.html | 4 ++-- bookwyrm/templatetags/sealed_dates.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 bookwyrm/templatetags/sealed_dates.py diff --git a/bookwyrm/templates/book/publisher_info.html b/bookwyrm/templates/book/publisher_info.html index e3ffedca8..26d8e43fd 100644 --- a/bookwyrm/templates/book/publisher_info.html +++ b/bookwyrm/templates/book/publisher_info.html @@ -1,7 +1,7 @@ {% spaceless %} {% load i18n %} -{% load humanize %} +{% load sealed_dates %} {% firstof book.physical_format_detail book.get_physical_format_display as format %} {% firstof book.physical_format book.physical_format_detail as format_property %} @@ -57,7 +57,7 @@ {% endfor %} {% endif %} - {% with date=book.published_date|default:book.first_published_date|naturalday publisher=book.publishers|join:', ' %} + {% with date=book.published_date|default:book.first_published_date|naturalday_partial publisher=book.publishers|join:', ' %} {% if book.published_date and publisher %} {% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %} {% elif publisher %} diff --git a/bookwyrm/templatetags/sealed_dates.py b/bookwyrm/templatetags/sealed_dates.py new file mode 100644 index 000000000..fb64734fa --- /dev/null +++ b/bookwyrm/templatetags/sealed_dates.py @@ -0,0 +1,21 @@ +""" formatting of SealedDate instances """ +from django import template +from django.template import defaultfilters +from django.contrib.humanize.templatetags.humanize import naturalday + +from bookwyrm.utils.sealed_date import SealedDate + +register = template.Library() + + +@register.filter(expects_localtime=True, is_safe=False) +def naturalday_partial(date): + if not isinstance(date, SealedDate): + return defaultfilters.date(date) + if date.has_day: + fmt = "DATE_FORMAT" + elif date.has_month: + fmt = "YEAR_MONTH_FORMAT" + else: + fmt = "Y" + return naturalday(date, fmt) 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 05/20] 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) From 4b47646e2825cbdc13c809e8e61e519bd502b58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Sat, 21 Oct 2023 18:16:50 -0300 Subject: [PATCH 06/20] Fix typing hints in sealed_date module In particular, SealedDate's class methods always return an instance of the class they're invoked through (i.e., `SealedDate.from_date_parts` intentionally never returns `MonthSeal` or `YearSeal`). To propertly annotate this, a type variable is needed (or the much simpler `Self` in Python 3.11). --- bookwyrm/utils/sealed_date.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bookwyrm/utils/sealed_date.py b/bookwyrm/utils/sealed_date.py index 9641e3e68..931d1b8e0 100644 --- a/bookwyrm/utils/sealed_date.py +++ b/bookwyrm/utils/sealed_date.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta +from typing import Any, Optional, Type, TypeVar, cast from django.core.exceptions import ValidationError from django.forms import DateField @@ -12,6 +13,8 @@ from django.utils import timezone _westmost_tz = timezone.get_fixed_timezone(timedelta(hours=-12)) +Sealed = TypeVar("Sealed", bound="SealedDate") # TODO: use Self in Python >= 3.11 + # TODO: migrate SealedDate to `date` @@ -30,12 +33,12 @@ class SealedDate(datetime): return self.strftime("%Y-%m-%d") @classmethod - def from_datetime(cls, dt) -> SealedDate: + def from_datetime(cls: Type[Sealed], dt: datetime) -> Sealed: # pylint: disable=invalid-name return cls.combine(dt.date(), dt.time(), tzinfo=dt.tzinfo) @classmethod - def from_date_parts(cls, year, month, day) -> SealedDate: + def from_date_parts(cls: Type[Sealed], year: int, month: int, day: int) -> Sealed: # 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. @@ -63,11 +66,11 @@ class YearSeal(SealedDate): class SealedDateFormField(DateField): """date form field with support for SealedDate""" - def prepare_value(self, value): + def prepare_value(self, value: Any) -> str: # 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) + return cast(str, super().prepare_value(value)) elif value.has_day: return value.strftime("%Y-%m-%d") elif value.has_month: @@ -75,7 +78,7 @@ class SealedDateFormField(DateField): else: return value.strftime("%Y-0-0") - def to_python(self, value) -> SealedDate: + def to_python(self, value: Any) -> Optional[SealedDate]: try: date = super().to_python(value) except ValidationError as ex: From 9752819bdb38bb2d1a9ecb37d2b4f280f850be85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Sat, 21 Oct 2023 20:27:23 -0300 Subject: [PATCH 07/20] Add support for parsing partial isoformats back --- bookwyrm/tests/test_sealed_date.py | 60 ++++++++++++++++++++++++++++++ bookwyrm/utils/sealed_date.py | 23 ++++++++++++ 2 files changed, 83 insertions(+) diff --git a/bookwyrm/tests/test_sealed_date.py b/bookwyrm/tests/test_sealed_date.py index 0eca8a815..cda1ae0fc 100644 --- a/bookwyrm/tests/test_sealed_date.py +++ b/bookwyrm/tests/test_sealed_date.py @@ -18,16 +18,76 @@ class SealedDateTest(unittest.TestCase): sealed = sealed_date.SealedDate.from_datetime(self.dt) self.assertEqual(self.dt, sealed) self.assertEqual("2023-10-20", sealed.partial_isoformat()) + self.assertTrue(sealed.has_day) + self.assertTrue(sealed.has_month) def test_month_seal(self): sealed = sealed_date.MonthSeal.from_datetime(self.dt) self.assertEqual(self.dt, sealed) self.assertEqual("2023-10", sealed.partial_isoformat()) + self.assertFalse(sealed.has_day) + self.assertTrue(sealed.has_month) def test_year_seal(self): sealed = sealed_date.YearSeal.from_datetime(self.dt) self.assertEqual(self.dt, sealed) self.assertEqual("2023", sealed.partial_isoformat()) + self.assertFalse(sealed.has_day) + self.assertFalse(sealed.has_month) + + def test_parse_year_seal(self): + parsed = sealed_date.from_partial_isoformat("1995") + expected = datetime.date(1995, 1, 1) + self.assertEqual(expected, parsed.date()) + self.assertFalse(parsed.has_day) + self.assertFalse(parsed.has_month) + + def test_parse_year_errors(self): + self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "995") + self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "1995x") + self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "1995-") + + def test_parse_month_seal(self): + expected = datetime.date(1995, 5, 1) + test_cases = [ + ("parse_month", "1995-05"), + ("parse_month_lenient", "1995-5"), + ] + for desc, value in test_cases: + with self.subTest(desc): + parsed = sealed_date.from_partial_isoformat(value) + self.assertEqual(expected, parsed.date()) + self.assertFalse(parsed.has_day) + self.assertTrue(parsed.has_month) + + def test_parse_month_dash_required(self): + self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "20056") + self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "200506") + self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "1995-7-") + + def test_parse_day_seal(self): + expected = datetime.date(1995, 5, 6) + test_cases = [ + ("parse_day", "1995-05-06"), + ("parse_day_lenient1", "1995-5-6"), + ("parse_day_lenient2", "1995-05-6"), + ] + for desc, value in test_cases: + with self.subTest(desc): + parsed = sealed_date.from_partial_isoformat(value) + self.assertEqual(expected, parsed.date()) + self.assertTrue(parsed.has_day) + self.assertTrue(parsed.has_month) + + def test_partial_isoformat_no_time_allowed(self): + self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "2005-06-07 ") + self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "2005-06-07T") + self.assertRaises( + ValueError, sealed_date.from_partial_isoformat, "2005-06-07T00:00:00" + ) + self.assertRaises( + ValueError, sealed_date.from_partial_isoformat, "2005-06-07T00:00:00-03" + ) class SealedDateFormFieldTest(unittest.TestCase): diff --git a/bookwyrm/utils/sealed_date.py b/bookwyrm/utils/sealed_date.py index 931d1b8e0..6055b03cc 100644 --- a/bookwyrm/utils/sealed_date.py +++ b/bookwyrm/utils/sealed_date.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta +import re from typing import Any, Optional, Type, TypeVar, cast from django.core.exceptions import ValidationError @@ -11,6 +12,12 @@ from django.forms.widgets import SelectDateWidget from django.utils import timezone +__all__ = [ + "SealedDate", + "from_partial_isoformat", +] + +_partial_re = re.compile(r"(\d{4})(?:-(\d\d?))?(?:-(\d\d?))?$") _westmost_tz = timezone.get_fixed_timezone(timedelta(hours=-12)) Sealed = TypeVar("Sealed", bound="SealedDate") # TODO: use Self in Python >= 3.11 @@ -63,6 +70,22 @@ class YearSeal(SealedDate): return self.strftime("%Y") +def from_partial_isoformat(value: str) -> SealedDate: + match = _partial_re.match(value) + + if not match: + raise ValueError + + year, month, day = [val and int(val) for val in match.groups()] + + if month is None: + return YearSeal.from_date_parts(year, 1, 1) + elif day is None: + return MonthSeal.from_date_parts(year, month, 1) + else: + return SealedDate.from_date_parts(year, month, day) + + class SealedDateFormField(DateField): """date form field with support for SealedDate""" From 737ac8e90885a4c0dce5e0aee8822e553e9dfe06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Mon, 23 Oct 2023 00:10:01 -0300 Subject: [PATCH 08/20] Implement PartialDateField using SealedDate and a custom descriptor --- .../migrations/0182_auto_20231023_0246.py | 54 +++++++++++ bookwyrm/models/book.py | 4 +- bookwyrm/models/fields.py | 34 ++++++- bookwyrm/tests/models/test_fields.py | 34 +++++++ bookwyrm/tests/test_sealed_date.py | 4 + bookwyrm/tests/views/books/test_edit_book.py | 71 ++++++++++++++- bookwyrm/utils/sealed_date.py | 91 ++++++++++++++++++- 7 files changed, 285 insertions(+), 7 deletions(-) create mode 100644 bookwyrm/migrations/0182_auto_20231023_0246.py diff --git a/bookwyrm/migrations/0182_auto_20231023_0246.py b/bookwyrm/migrations/0182_auto_20231023_0246.py new file mode 100644 index 000000000..d3db4056b --- /dev/null +++ b/bookwyrm/migrations/0182_auto_20231023_0246.py @@ -0,0 +1,54 @@ +# Generated by Django 3.2.20 on 2023-10-23 02:46 + +import bookwyrm.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0181_merge_20230806_2302"), + ] + + operations = [ + migrations.AddField( + model_name="book", + name="first_published_date_precision", + field=models.CharField( + blank=True, + choices=[ + ("DAY", "Day seal"), + ("MONTH", "Month seal"), + ("YEAR", "Year seal"), + ], + editable=False, + max_length=10, + null=True, + ), + ), + migrations.AddField( + model_name="book", + name="published_date_precision", + field=models.CharField( + blank=True, + choices=[ + ("DAY", "Day seal"), + ("MONTH", "Month seal"), + ("YEAR", "Year seal"), + ], + editable=False, + max_length=10, + null=True, + ), + ), + migrations.AlterField( + model_name="book", + name="first_published_date", + field=bookwyrm.models.fields.PartialDateField(blank=True, null=True), + ), + migrations.AlterField( + model_name="book", + name="published_date", + field=bookwyrm.models.fields.PartialDateField(blank=True, null=True), + ), + ] diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 9e05c03af..f0a524774 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -135,8 +135,8 @@ class Book(BookDataModel): preview_image = models.ImageField( upload_to="previews/covers/", blank=True, null=True ) - first_published_date = fields.DateTimeField(blank=True, null=True) - published_date = fields.DateTimeField(blank=True, null=True) + first_published_date = fields.PartialDateField(blank=True, null=True) + published_date = fields.PartialDateField(blank=True, null=True) objects = InheritanceManager() field_tracker = FieldTracker(fields=["authors", "title", "subtitle", "cover"]) diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index 28effaf9b..9c8793649 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -20,6 +20,11 @@ from markdown import markdown from bookwyrm import activitypub from bookwyrm.connectors import get_image from bookwyrm.utils.sanitizer import clean +from bookwyrm.utils.sealed_date import ( + SealedDate, + SealedDateField, + from_partial_isoformat, +) from bookwyrm.settings import MEDIA_FULL_URL @@ -537,7 +542,6 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField): def field_from_activity(self, value, allow_external_connections=True): missing_fields = datetime(1970, 1, 1) # "2022-10" => "2022-10-01" try: - # TODO(dato): investigate `ignoretz=True` wrt bookwyrm#3028. date_value = dateutil.parser.parse(value, default=missing_fields) try: return timezone.make_aware(date_value) @@ -547,6 +551,34 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField): return None +class PartialDateField(ActivitypubFieldMixin, SealedDateField): + """activitypub-aware partial date field""" + + def field_to_activity(self, value) -> str: + return value.partial_isoformat() if value else None + + def field_from_activity(self, value, allow_external_connections=True): + try: + return from_partial_isoformat(value) + except ValueError: + pass + + # fallback to full ISO-8601 parsing + try: + parsed = dateutil.parser.isoparse(value) + except (ValueError, ParserError): + return None + + # FIXME #1: add timezone if missing (SealedDate only accepts tz-aware). + # + # FIXME #2: decide whether to fix timestamps like "2023-09-30T21:00:00-03": + # clearly Oct 1st, not Sep 30th (an unwanted side-effect of USE_TZ). It's + # basically the remnants of #3028; there is a data migration pending (see …) + # but over the wire we might get these for an indeterminate amount of time. + + return SealedDate.from_datetime(parsed) + + class HtmlField(ActivitypubFieldMixin, models.TextField): """a text field for storing html""" diff --git a/bookwyrm/tests/models/test_fields.py b/bookwyrm/tests/models/test_fields.py index 553a533d5..1f4a18aef 100644 --- a/bookwyrm/tests/models/test_fields.py +++ b/bookwyrm/tests/models/test_fields.py @@ -2,10 +2,12 @@ from io import BytesIO from collections import namedtuple from dataclasses import dataclass +import datetime import json import pathlib import re from typing import List +from unittest import expectedFailure from unittest.mock import patch from PIL import Image @@ -23,6 +25,7 @@ from bookwyrm.models import fields, User, Status, Edition from bookwyrm.models.base_model import BookWyrmModel from bookwyrm.models.activitypub_mixin import ActivitypubMixin from bookwyrm.settings import DOMAIN +from bookwyrm.utils import sealed_date # pylint: disable=too-many-public-methods @patch("bookwyrm.suggested_users.rerank_suggestions_task.delay") @@ -594,6 +597,37 @@ class ModelFields(TestCase): self.assertEqual(instance.field_from_activity(now.isoformat()), now) self.assertEqual(instance.field_from_activity("bip"), None) + def test_partial_date_legacy_formats(self, *_): + """test support for full isoformat in partial dates""" + instance = fields.PartialDateField() + expected = datetime.date(2023, 10, 20) + test_cases = [ + # XXX: must fix before merging. + # ("no_tz", "2023-10-20T00:00:00"), + # ("no_tz_eod", "2023-10-20T23:59:59.999999"), + ("utc_offset_midday", "2023-10-20T12:00:00+0000"), + ("utc_offset_midnight", "2023-10-20T00:00:00+00"), + ("eastern_tz_parsed", "2023-10-20T15:20:30+04:30"), + ("western_tz_midnight", "2023-10-20:00:00-03"), + ] + for desc, value in test_cases: + with self.subTest(desc): + parsed = instance.field_from_activity(value) + self.assertIsNotNone(parsed) + self.assertEqual(expected, parsed.date()) + self.assertTrue(parsed.has_day) + self.assertTrue(parsed.has_month) + + @expectedFailure + def test_partial_date_timezone_fix(self, *_): + """deserialization compensates for unwanted effects of USE_TZ""" + instance = fields.PartialDateField() + expected = datetime.date(2023, 10, 1) + parsed = instance.field_from_activity("2023-09-30T21:00:00-03") + self.assertEqual(expected, parsed.date()) + self.assertTrue(parsed.has_day) + self.assertTrue(parsed.has_month) + def test_array_field(self, *_): """idk why it makes them strings but probably for a good reason""" instance = fields.ArrayField(fields.IntegerField) diff --git a/bookwyrm/tests/test_sealed_date.py b/bookwyrm/tests/test_sealed_date.py index cda1ae0fc..4e87a26d0 100644 --- a/bookwyrm/tests/test_sealed_date.py +++ b/bookwyrm/tests/test_sealed_date.py @@ -35,6 +35,10 @@ class SealedDateTest(unittest.TestCase): self.assertFalse(sealed.has_day) self.assertFalse(sealed.has_month) + def test_no_naive_datetime(self): + with self.assertRaises(ValueError): + sealed_date.SealedDate.from_datetime(datetime.datetime(2000, 1, 1)) + def test_parse_year_seal(self): parsed = sealed_date.from_partial_isoformat("1995") expected = datetime.date(1995, 1, 1) diff --git a/bookwyrm/tests/views/books/test_edit_book.py b/bookwyrm/tests/views/books/test_edit_book.py index 68553e09e..49e8c7cdb 100644 --- a/bookwyrm/tests/views/books/test_edit_book.py +++ b/bookwyrm/tests/views/books/test_edit_book.py @@ -1,5 +1,4 @@ """ test for app action functionality """ -from unittest import expectedFailure from unittest.mock import patch import responses from responses import matchers @@ -211,7 +210,6 @@ class EditBookViews(TestCase): book = models.Edition.objects.get(title="New Title") self.assertEqual(book.parent_work.title, "New Title") - @expectedFailure # bookwyrm#3028 def test_published_date_timezone(self): """user timezone does not affect publication year""" # https://github.com/bookwyrm-social/bookwyrm/issues/3028 @@ -234,6 +232,75 @@ class EditBookViews(TestCase): book = models.Edition.objects.get(title="January 1st test") self.assertEqual(book.edition_info, "2020") + def test_partial_published_dates(self): + """create a book with partial publication dates, then update them""" + self.local_user.groups.add(self.group) + book_data = { + "title": "An Edition With Dates", + "parent_work": self.work.id, + "last_edited_by": self.local_user.id, + } + initial_pub_dates = { + # published_date: 2023-01-01 + "published_date_day": "1", + "published_date_month": "01", + "published_date_year": "2023", + # first_published_date: 1995 + "first_published_date_day": "", + "first_published_date_month": "", + "first_published_date_year": "1995", + } + updated_pub_dates = { + # published_date: full -> year-only + "published_date_day": "", + "published_date_month": "", + "published_date_year": "2023", + # first_published_date: add month + "first_published_date_day": "", + "first_published_date_month": "03", + "first_published_date_year": "1995", + } + + # create book + create_book = views.CreateBook.as_view() + request = self.factory.post("", book_data | initial_pub_dates) + request.user = self.local_user + + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"): + create_book(request) + + book = models.Edition.objects.get(title="An Edition With Dates") + + self.assertEqual("2023-01-01", book.published_date.partial_isoformat()) + self.assertEqual("1995", book.first_published_date.partial_isoformat()) + + self.assertTrue(book.published_date.has_day) + self.assertTrue(book.published_date.has_month) + + self.assertFalse(book.first_published_date.has_day) + self.assertFalse(book.first_published_date.has_month) + + # now edit publication dates + edit_book = views.ConfirmEditBook.as_view() + request = self.factory.post("", book_data | updated_pub_dates) + request.user = self.local_user + + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"): + result = edit_book(request, book.id) + + self.assertEqual(result.status_code, 302) + + book.refresh_from_db() + + self.assertEqual("2023", book.published_date.partial_isoformat()) + self.assertEqual("1995-03", book.first_published_date.partial_isoformat()) + + self.assertFalse(book.published_date.has_day) + self.assertFalse(book.published_date.has_month) + + self.assertFalse(book.first_published_date.has_day) + self.assertTrue(book.first_published_date.has_month) + def test_create_book_existing_work(self): """create an entirely new book and work""" view = views.ConfirmEditBook.as_view() diff --git a/bookwyrm/utils/sealed_date.py b/bookwyrm/utils/sealed_date.py index 6055b03cc..c7ad3b7f3 100644 --- a/bookwyrm/utils/sealed_date.py +++ b/bookwyrm/utils/sealed_date.py @@ -7,6 +7,7 @@ import re from typing import Any, Optional, Type, TypeVar, cast from django.core.exceptions import ValidationError +from django.db import models from django.forms import DateField from django.forms.widgets import SelectDateWidget from django.utils import timezone @@ -22,11 +23,12 @@ _westmost_tz = timezone.get_fixed_timezone(timedelta(hours=-12)) Sealed = TypeVar("Sealed", bound="SealedDate") # TODO: use Self in Python >= 3.11 -# TODO: migrate SealedDate to `date` +# TODO: migrate SealedDate: `datetime` => `date` +# TODO: migrate SealedDateField: `DateTimeField` => `DateField` class SealedDate(datetime): - """a date object sealed into a certain precision (day, month, year)""" + """a date object sealed into a certain precision (day, month or year)""" @property def has_day(self) -> bool: @@ -42,6 +44,8 @@ class SealedDate(datetime): @classmethod def from_datetime(cls: Type[Sealed], dt: datetime) -> Sealed: # pylint: disable=invalid-name + if timezone.is_naive(dt): + raise ValueError("naive datetime not accepted") return cls.combine(dt.date(), dt.time(), tzinfo=dt.tzinfo) @classmethod @@ -120,3 +124,86 @@ class SealedDateFormField(DateField): year, month, day = date.year, date.month, date.day return SealedDate.from_date_parts(year, month, day) + + +class SealedDateDescriptor: + + _SEAL_TYPES = { + YearSeal: "YEAR", + MonthSeal: "MONTH", + SealedDate: "DAY", + } + + _DATE_CLASSES = { + "YEAR": YearSeal, + "MONTH": MonthSeal, + } + + def __init__(self, field): + self.field = field + + @property + def precision_field(self): + """the name of the accompanying precision field""" + return self.make_precision_name(self.field.attname) + + @classmethod + def make_precision_name(cls, date_attr_name): + # used by SealedDateField to make the name from the outside. + # TODO: migrate to an attribute there? + return f"{date_attr_name}_precision" + + @property + def precision_choices(self): + return (("DAY", "Day seal"), ("MONTH", "Month seal"), ("YEAR", "Year seal")) + + def __get__(self, instance, cls=None): + if instance is None: + return self + + value = instance.__dict__.get(self.field.attname) + + if not value or isinstance(value, SealedDate): + return value + + # use precision field to construct SealedDate. + seal_type = getattr(instance, self.precision_field, None) + date_class = self._DATE_CLASSES.get(seal_type, SealedDate) + + return date_class.from_datetime(value) # FIXME: drop datetimes. + + def __set__(self, instance, value): + """assign value, with precision where available""" + try: + seal_type = self._SEAL_TYPES[value.__class__] + except KeyError: + value = self.field.to_python(value) + else: + setattr(instance, self.precision_field, seal_type) + + instance.__dict__[self.field.attname] = value + + +class SealedDateField(models.DateTimeField): # FIXME: use DateField. + + descriptor_class = SealedDateDescriptor + + def formfield(self, **kwargs): + kwargs.setdefault("form_class", SealedDateFormField) + return super().formfield(**kwargs) + + # pylint: disable-next=arguments-renamed + def contribute_to_class(self, model, our_name_in_model, **kwargs): + # Define precision field. + descriptor = self.descriptor_class(self) + precision = models.CharField( + null=True, + blank=True, + editable=False, + max_length=10, + choices=descriptor.precision_choices, + ) + precision_name = descriptor.make_precision_name(our_name_in_model) + + model.add_to_class(precision_name, precision) + return super().contribute_to_class(model, our_name_in_model, **kwargs) From 170d1fe2055a828dcac206da5ae8bf2daa08abee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Tue, 24 Oct 2023 04:14:38 -0300 Subject: [PATCH 09/20] fix pylint issues (minus `no-else-return`) --- bookwyrm/templatetags/sealed_dates.py | 1 + bookwyrm/tests/models/test_fields.py | 1 - bookwyrm/tests/test_sealed_date.py | 30 +++++++++++++++++---------- bookwyrm/utils/sealed_date.py | 26 +++++++++++++++++++++++ 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/bookwyrm/templatetags/sealed_dates.py b/bookwyrm/templatetags/sealed_dates.py index fb64734fa..f0b0f7d25 100644 --- a/bookwyrm/templatetags/sealed_dates.py +++ b/bookwyrm/templatetags/sealed_dates.py @@ -10,6 +10,7 @@ register = template.Library() @register.filter(expects_localtime=True, is_safe=False) def naturalday_partial(date): + """allow templates to easily format SealedDate objects""" if not isinstance(date, SealedDate): return defaultfilters.date(date) if date.has_day: diff --git a/bookwyrm/tests/models/test_fields.py b/bookwyrm/tests/models/test_fields.py index 1f4a18aef..e9afcdef6 100644 --- a/bookwyrm/tests/models/test_fields.py +++ b/bookwyrm/tests/models/test_fields.py @@ -25,7 +25,6 @@ from bookwyrm.models import fields, User, Status, Edition from bookwyrm.models.base_model import BookWyrmModel from bookwyrm.models.activitypub_mixin import ActivitypubMixin from bookwyrm.settings import DOMAIN -from bookwyrm.utils import sealed_date # pylint: disable=too-many-public-methods @patch("bookwyrm.suggested_users.rerank_suggestions_task.delay") diff --git a/bookwyrm/tests/test_sealed_date.py b/bookwyrm/tests/test_sealed_date.py index 4e87a26d0..7e4c06c39 100644 --- a/bookwyrm/tests/test_sealed_date.py +++ b/bookwyrm/tests/test_sealed_date.py @@ -11,26 +11,30 @@ from bookwyrm.utils import sealed_date class SealedDateTest(unittest.TestCase): + """test SealedDate class in isolation""" + + # pylint: disable=missing-function-docstring + def setUp(self): - self.dt = datetime.datetime(2023, 10, 20, 17, 33, 10, tzinfo=timezone.utc) + self._dt = datetime.datetime(2023, 10, 20, 17, 33, 10, tzinfo=timezone.utc) def test_day_seal(self): - sealed = sealed_date.SealedDate.from_datetime(self.dt) - self.assertEqual(self.dt, sealed) + sealed = sealed_date.SealedDate.from_datetime(self._dt) + self.assertEqual(self._dt, sealed) self.assertEqual("2023-10-20", sealed.partial_isoformat()) self.assertTrue(sealed.has_day) self.assertTrue(sealed.has_month) def test_month_seal(self): - sealed = sealed_date.MonthSeal.from_datetime(self.dt) - self.assertEqual(self.dt, sealed) + sealed = sealed_date.MonthSeal.from_datetime(self._dt) + self.assertEqual(self._dt, sealed) self.assertEqual("2023-10", sealed.partial_isoformat()) self.assertFalse(sealed.has_day) self.assertTrue(sealed.has_month) def test_year_seal(self): - sealed = sealed_date.YearSeal.from_datetime(self.dt) - self.assertEqual(self.dt, sealed) + sealed = sealed_date.YearSeal.from_datetime(self._dt) + self.assertEqual(self._dt, sealed) self.assertEqual("2023", sealed.partial_isoformat()) self.assertFalse(sealed.has_day) self.assertFalse(sealed.has_month) @@ -95,20 +99,24 @@ class SealedDateTest(unittest.TestCase): class SealedDateFormFieldTest(unittest.TestCase): + """test form support for SealedDate objects""" + + # pylint: disable=missing-function-docstring + def setUp(self): - self.dt = datetime.datetime(2022, 11, 21, 17, 1, 0, tzinfo=timezone.utc) + 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) + 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) + 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) + sealed = sealed_date.YearSeal.from_datetime(self._dt) self.assertEqual("2022-0-0", self.field.prepare_value(sealed)) def test_to_python(self): diff --git a/bookwyrm/utils/sealed_date.py b/bookwyrm/utils/sealed_date.py index c7ad3b7f3..9181fcdd3 100644 --- a/bookwyrm/utils/sealed_date.py +++ b/bookwyrm/utils/sealed_date.py @@ -12,6 +12,7 @@ from django.forms import DateField from django.forms.widgets import SelectDateWidget from django.utils import timezone +# pylint: disable=no-else-return __all__ = [ "SealedDate", @@ -32,17 +33,25 @@ class SealedDate(datetime): @property def has_day(self) -> bool: + """whether this is a full date""" return self.has_month @property def has_month(self) -> bool: + """whether this date includes month""" return True def partial_isoformat(self) -> str: + """partial ISO-8601 format""" return self.strftime("%Y-%m-%d") @classmethod def from_datetime(cls: Type[Sealed], dt: datetime) -> Sealed: + """construct a SealedDate object from a timezone-aware datetime + + Use subclasses to specify precision. If `dt` is naive, `ValueError` + is raised. + """ # pylint: disable=invalid-name if timezone.is_naive(dt): raise ValueError("naive datetime not accepted") @@ -50,6 +59,9 @@ class SealedDate(datetime): @classmethod def from_date_parts(cls: Type[Sealed], year: int, month: int, day: int) -> Sealed: + """construct a SealedDate from year, month, day. + + Use sublcasses to specify precision.""" # 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. @@ -57,6 +69,8 @@ class SealedDate(datetime): class MonthSeal(SealedDate): + """a date sealed into month precision""" + @property def has_day(self) -> bool: return False @@ -66,6 +80,8 @@ class MonthSeal(SealedDate): class YearSeal(SealedDate): + """a date sealed into year precision""" + @property def has_month(self) -> bool: return False @@ -75,6 +91,11 @@ class YearSeal(SealedDate): def from_partial_isoformat(value: str) -> SealedDate: + """construct SealedDate from a partial string. + + Accepted formats: YYYY, YYYY-MM, YYYY-MM-DD; otherwise `ValueError` + is raised. + """ match = _partial_re.match(value) if not match: @@ -127,6 +148,10 @@ class SealedDateFormField(DateField): class SealedDateDescriptor: + """descriptor for SealedDateField. + + Encapsulates the "two columns, one field" for SealedDateField. + """ _SEAL_TYPES = { YearSeal: "YEAR", @@ -185,6 +210,7 @@ class SealedDateDescriptor: class SealedDateField(models.DateTimeField): # FIXME: use DateField. + """a date field for Django models, using SealedDate as values""" descriptor_class = SealedDateDescriptor From 1952bb6ddc861a0a79b1c8b9ed824e81adaf546b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Tue, 24 Oct 2023 18:09:27 -0300 Subject: [PATCH 10/20] fix mypy issues The three "ignore" directives are: - avoid unreadable boilerplate from inherited `Field` methods; and: - https://github.com/typeddjango/django-stubs/issues/285#issuecomment-600029858 --- bookwyrm/utils/sealed_date.py | 59 +++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/bookwyrm/utils/sealed_date.py b/bookwyrm/utils/sealed_date.py index 9181fcdd3..62bab4ed4 100644 --- a/bookwyrm/utils/sealed_date.py +++ b/bookwyrm/utils/sealed_date.py @@ -101,11 +101,11 @@ def from_partial_isoformat(value: str) -> SealedDate: if not match: raise ValueError - year, month, day = [val and int(val) for val in match.groups()] + year, month, day = [int(val) if val else -1 for val in match.groups()] - if month is None: + if month < 0: return YearSeal.from_date_parts(year, 1, 1) - elif day is None: + elif day < 0: return MonthSeal.from_date_parts(year, month, 1) else: return SealedDate.from_date_parts(year, month, day) @@ -147,42 +147,32 @@ class SealedDateFormField(DateField): return SealedDate.from_date_parts(year, month, day) +# For typing field and descriptor, below. +_SetType = datetime +_GetType = Optional[SealedDate] + + class SealedDateDescriptor: """descriptor for SealedDateField. Encapsulates the "two columns, one field" for SealedDateField. """ - _SEAL_TYPES = { + _SEAL_TYPES: dict[Type[_SetType], str] = { YearSeal: "YEAR", MonthSeal: "MONTH", SealedDate: "DAY", } - _DATE_CLASSES = { + _DATE_CLASSES: dict[Any, Type[SealedDate]] = { "YEAR": YearSeal, "MONTH": MonthSeal, } - def __init__(self, field): + def __init__(self, field: models.Field[_SetType, _GetType]): self.field = field - @property - def precision_field(self): - """the name of the accompanying precision field""" - return self.make_precision_name(self.field.attname) - - @classmethod - def make_precision_name(cls, date_attr_name): - # used by SealedDateField to make the name from the outside. - # TODO: migrate to an attribute there? - return f"{date_attr_name}_precision" - - @property - def precision_choices(self): - return (("DAY", "Day seal"), ("MONTH", "Month seal"), ("YEAR", "Year seal")) - - def __get__(self, instance, cls=None): + def __get__(self, instance: models.Model, cls: Any = None) -> _GetType: if instance is None: return self @@ -197,7 +187,7 @@ class SealedDateDescriptor: return date_class.from_datetime(value) # FIXME: drop datetimes. - def __set__(self, instance, value): + def __set__(self, instance: models.Model, value: _SetType) -> None: """assign value, with precision where available""" try: seal_type = self._SEAL_TYPES[value.__class__] @@ -208,21 +198,36 @@ class SealedDateDescriptor: instance.__dict__[self.field.attname] = value + @classmethod + def make_precision_name(cls, date_attr_name: str) -> str: + """derive the precision field name from main attr name""" + return f"{date_attr_name}_precision" -class SealedDateField(models.DateTimeField): # FIXME: use DateField. + @property + def precision_field(self) -> str: + """the name of the accompanying precision field""" + return self.make_precision_name(self.field.attname) + + @property + def precision_choices(self) -> list[tuple[str, str]]: + """valid options for precision database field""" + return [("DAY", "Day seal"), ("MONTH", "Month seal"), ("YEAR", "Year seal")] + + +class SealedDateField(models.DateTimeField): # type: ignore """a date field for Django models, using SealedDate as values""" descriptor_class = SealedDateDescriptor - def formfield(self, **kwargs): + def formfield(self, **kwargs): # type: ignore kwargs.setdefault("form_class", SealedDateFormField) return super().formfield(**kwargs) # pylint: disable-next=arguments-renamed - def contribute_to_class(self, model, our_name_in_model, **kwargs): + def contribute_to_class(self, model, our_name_in_model, **kwargs): # type: ignore # Define precision field. descriptor = self.descriptor_class(self) - precision = models.CharField( + precision: models.Field[Optional[str], Optional[str]] = models.CharField( null=True, blank=True, editable=False, From dccac11527973fbb553a2370f07e005e511130f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Thu, 9 Nov 2023 13:04:09 -0300 Subject: [PATCH 11/20] PartialDateField: allow incoming dates without timezone --- bookwyrm/models/fields.py | 12 +++++++----- bookwyrm/tests/models/test_fields.py | 5 ++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index 9c8793649..85ee654e3 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -569,15 +569,17 @@ class PartialDateField(ActivitypubFieldMixin, SealedDateField): except (ValueError, ParserError): return None - # FIXME #1: add timezone if missing (SealedDate only accepts tz-aware). - # - # FIXME #2: decide whether to fix timestamps like "2023-09-30T21:00:00-03": + if timezone.is_aware(parsed): + return SealedDate.from_datetime(parsed) + else: + # Should not happen on the wire, but truncate down to date parts. + return SealedDate.from_date_parts(parsed.year, parsed.month, parsed.day) + + # FIXME: decide whether to fix timestamps like "2023-09-30T21:00:00-03": # clearly Oct 1st, not Sep 30th (an unwanted side-effect of USE_TZ). It's # basically the remnants of #3028; there is a data migration pending (see …) # but over the wire we might get these for an indeterminate amount of time. - return SealedDate.from_datetime(parsed) - class HtmlField(ActivitypubFieldMixin, models.TextField): """a text field for storing html""" diff --git a/bookwyrm/tests/models/test_fields.py b/bookwyrm/tests/models/test_fields.py index e9afcdef6..d04178d4a 100644 --- a/bookwyrm/tests/models/test_fields.py +++ b/bookwyrm/tests/models/test_fields.py @@ -601,9 +601,8 @@ class ModelFields(TestCase): instance = fields.PartialDateField() expected = datetime.date(2023, 10, 20) test_cases = [ - # XXX: must fix before merging. - # ("no_tz", "2023-10-20T00:00:00"), - # ("no_tz_eod", "2023-10-20T23:59:59.999999"), + ("no_tz", "2023-10-20T00:00:00"), + ("no_tz_eod", "2023-10-20T23:59:59.999999"), ("utc_offset_midday", "2023-10-20T12:00:00+0000"), ("utc_offset_midnight", "2023-10-20T00:00:00+00"), ("eastern_tz_parsed", "2023-10-20T15:20:30+04:30"), From 2bb7652dfeadcfeea10a460720b0f730d0a2bdee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Thu, 9 Nov 2023 13:59:39 -0300 Subject: [PATCH 12/20] Update partial date migration to latest main --- ...{0182_auto_20231023_0246.py => 0185_auto_20231109_1657.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename bookwyrm/migrations/{0182_auto_20231023_0246.py => 0185_auto_20231109_1657.py} (93%) diff --git a/bookwyrm/migrations/0182_auto_20231023_0246.py b/bookwyrm/migrations/0185_auto_20231109_1657.py similarity index 93% rename from bookwyrm/migrations/0182_auto_20231023_0246.py rename to bookwyrm/migrations/0185_auto_20231109_1657.py index d3db4056b..d8788b70d 100644 --- a/bookwyrm/migrations/0182_auto_20231023_0246.py +++ b/bookwyrm/migrations/0185_auto_20231109_1657.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.20 on 2023-10-23 02:46 +# Generated by Django 3.2.20 on 2023-11-09 16:57 import bookwyrm.models.fields from django.db import migrations, models @@ -7,7 +7,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("bookwyrm", "0181_merge_20230806_2302"), + ("bookwyrm", "0184_auto_20231106_0421"), ] operations = [ From c120fa8c87bf6c296bcc7786efa4ebb15fa8b828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Thu, 9 Nov 2023 13:06:48 -0300 Subject: [PATCH 13/20] Rename: templatetags/{sealed_dates => date_ext}.py --- bookwyrm/templates/book/publisher_info.html | 2 +- bookwyrm/templatetags/{sealed_dates.py => date_ext.py} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename bookwyrm/templatetags/{sealed_dates.py => date_ext.py} (93%) diff --git a/bookwyrm/templates/book/publisher_info.html b/bookwyrm/templates/book/publisher_info.html index 26d8e43fd..a69b7d86f 100644 --- a/bookwyrm/templates/book/publisher_info.html +++ b/bookwyrm/templates/book/publisher_info.html @@ -1,7 +1,7 @@ {% spaceless %} {% load i18n %} -{% load sealed_dates %} +{% load date_ext %} {% firstof book.physical_format_detail book.get_physical_format_display as format %} {% firstof book.physical_format book.physical_format_detail as format_property %} diff --git a/bookwyrm/templatetags/sealed_dates.py b/bookwyrm/templatetags/date_ext.py similarity index 93% rename from bookwyrm/templatetags/sealed_dates.py rename to bookwyrm/templatetags/date_ext.py index f0b0f7d25..4167893c8 100644 --- a/bookwyrm/templatetags/sealed_dates.py +++ b/bookwyrm/templatetags/date_ext.py @@ -1,4 +1,4 @@ -""" formatting of SealedDate instances """ +""" additional formatting of dates """ from django import template from django.template import defaultfilters from django.contrib.humanize.templatetags.humanize import naturalday From 0e4c5ed4392b2e3349488d49d4348d0067db99df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Thu, 9 Nov 2023 13:19:18 -0300 Subject: [PATCH 14/20] SealedDate renames, pt. 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • SealedDateField -> PartialDateModel • SealedDateFormField -> PartialDateFormField • SealedDateDescriptor -> PartialDateDescriptor --- bookwyrm/models/fields.py | 4 ++-- bookwyrm/tests/test_sealed_date.py | 4 ++-- bookwyrm/utils/sealed_date.py | 17 +++++++++-------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index d51a9efe6..0857b6b64 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -22,7 +22,7 @@ from bookwyrm.connectors import get_image from bookwyrm.utils.sanitizer import clean from bookwyrm.utils.sealed_date import ( SealedDate, - SealedDateField, + PartialDateModel, from_partial_isoformat, ) from bookwyrm.settings import MEDIA_FULL_URL @@ -553,7 +553,7 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField): return None -class PartialDateField(ActivitypubFieldMixin, SealedDateField): +class PartialDateField(ActivitypubFieldMixin, PartialDateModel): """activitypub-aware partial date field""" def field_to_activity(self, value) -> str: diff --git a/bookwyrm/tests/test_sealed_date.py b/bookwyrm/tests/test_sealed_date.py index 7e4c06c39..7986c15fa 100644 --- a/bookwyrm/tests/test_sealed_date.py +++ b/bookwyrm/tests/test_sealed_date.py @@ -98,14 +98,14 @@ class SealedDateTest(unittest.TestCase): ) -class SealedDateFormFieldTest(unittest.TestCase): +class PartialDateFormFieldTest(unittest.TestCase): """test form support for SealedDate objects""" # pylint: disable=missing-function-docstring def setUp(self): self._dt = datetime.datetime(2022, 11, 21, 17, 1, 0, tzinfo=timezone.utc) - self.field = sealed_date.SealedDateFormField() + self.field = sealed_date.PartialDateFormField() def test_prepare_value(self): sealed = sealed_date.SealedDate.from_datetime(self._dt) diff --git a/bookwyrm/utils/sealed_date.py b/bookwyrm/utils/sealed_date.py index 62bab4ed4..a819f7c3b 100644 --- a/bookwyrm/utils/sealed_date.py +++ b/bookwyrm/utils/sealed_date.py @@ -16,6 +16,7 @@ from django.utils import timezone __all__ = [ "SealedDate", + "PartialDateModel", "from_partial_isoformat", ] @@ -25,7 +26,7 @@ _westmost_tz = timezone.get_fixed_timezone(timedelta(hours=-12)) Sealed = TypeVar("Sealed", bound="SealedDate") # TODO: use Self in Python >= 3.11 # TODO: migrate SealedDate: `datetime` => `date` -# TODO: migrate SealedDateField: `DateTimeField` => `DateField` +# TODO: migrate PartialDateModel: `DateTimeField` => `DateField` class SealedDate(datetime): @@ -111,7 +112,7 @@ def from_partial_isoformat(value: str) -> SealedDate: return SealedDate.from_date_parts(year, month, day) -class SealedDateFormField(DateField): +class PartialDateFormField(DateField): """date form field with support for SealedDate""" def prepare_value(self, value: Any) -> str: @@ -152,10 +153,10 @@ _SetType = datetime _GetType = Optional[SealedDate] -class SealedDateDescriptor: - """descriptor for SealedDateField. +class PartialDateDescriptor: + """descriptor for PartialDateModel. - Encapsulates the "two columns, one field" for SealedDateField. + Encapsulates the "two columns, one field" for PartialDateModel. """ _SEAL_TYPES: dict[Type[_SetType], str] = { @@ -214,13 +215,13 @@ class SealedDateDescriptor: return [("DAY", "Day seal"), ("MONTH", "Month seal"), ("YEAR", "Year seal")] -class SealedDateField(models.DateTimeField): # type: ignore +class PartialDateModel(models.DateTimeField): # type: ignore """a date field for Django models, using SealedDate as values""" - descriptor_class = SealedDateDescriptor + descriptor_class = PartialDateDescriptor def formfield(self, **kwargs): # type: ignore - kwargs.setdefault("form_class", SealedDateFormField) + kwargs.setdefault("form_class", PartialDateFormField) return super().formfield(**kwargs) # pylint: disable-next=arguments-renamed From fa80aa54a99261dd03e9874c53120dd6eee6d293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Thu, 9 Nov 2023 13:38:27 -0300 Subject: [PATCH 15/20] SealedDate renames, pt. 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • SealedDate -> PartialDate • MonthSeal -> MonthParts • YearSeal -> YearParts --- bookwyrm/models/fields.py | 7 +-- bookwyrm/templatetags/date_ext.py | 6 +-- bookwyrm/tests/test_sealed_date.py | 28 +++++------ bookwyrm/utils/sealed_date.py | 76 +++++++++++++++--------------- 4 files changed, 59 insertions(+), 58 deletions(-) diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index 0857b6b64..493a8e5c2 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -21,7 +21,7 @@ from bookwyrm import activitypub from bookwyrm.connectors import get_image from bookwyrm.utils.sanitizer import clean from bookwyrm.utils.sealed_date import ( - SealedDate, + PartialDate, PartialDateModel, from_partial_isoformat, ) @@ -560,6 +560,7 @@ class PartialDateField(ActivitypubFieldMixin, PartialDateModel): return value.partial_isoformat() if value else None def field_from_activity(self, value, allow_external_connections=True): + # pylint: disable=no-else-return try: return from_partial_isoformat(value) except ValueError: @@ -572,10 +573,10 @@ class PartialDateField(ActivitypubFieldMixin, PartialDateModel): return None if timezone.is_aware(parsed): - return SealedDate.from_datetime(parsed) + return PartialDate.from_datetime(parsed) else: # Should not happen on the wire, but truncate down to date parts. - return SealedDate.from_date_parts(parsed.year, parsed.month, parsed.day) + return PartialDate.from_date_parts(parsed.year, parsed.month, parsed.day) # FIXME: decide whether to fix timestamps like "2023-09-30T21:00:00-03": # clearly Oct 1st, not Sep 30th (an unwanted side-effect of USE_TZ). It's diff --git a/bookwyrm/templatetags/date_ext.py b/bookwyrm/templatetags/date_ext.py index 4167893c8..d4ca988de 100644 --- a/bookwyrm/templatetags/date_ext.py +++ b/bookwyrm/templatetags/date_ext.py @@ -3,15 +3,15 @@ from django import template from django.template import defaultfilters from django.contrib.humanize.templatetags.humanize import naturalday -from bookwyrm.utils.sealed_date import SealedDate +from bookwyrm.utils.sealed_date import PartialDate register = template.Library() @register.filter(expects_localtime=True, is_safe=False) def naturalday_partial(date): - """allow templates to easily format SealedDate objects""" - if not isinstance(date, SealedDate): + """allow templates to easily format PartialDate objects""" + if not isinstance(date, PartialDate): return defaultfilters.date(date) if date.has_day: fmt = "DATE_FORMAT" diff --git a/bookwyrm/tests/test_sealed_date.py b/bookwyrm/tests/test_sealed_date.py index 7986c15fa..47532f40c 100644 --- a/bookwyrm/tests/test_sealed_date.py +++ b/bookwyrm/tests/test_sealed_date.py @@ -10,8 +10,8 @@ from django.utils import translation from bookwyrm.utils import sealed_date -class SealedDateTest(unittest.TestCase): - """test SealedDate class in isolation""" +class PartialDateTest(unittest.TestCase): + """test PartialDate class in isolation""" # pylint: disable=missing-function-docstring @@ -19,21 +19,21 @@ class SealedDateTest(unittest.TestCase): self._dt = datetime.datetime(2023, 10, 20, 17, 33, 10, tzinfo=timezone.utc) def test_day_seal(self): - sealed = sealed_date.SealedDate.from_datetime(self._dt) + sealed = sealed_date.PartialDate.from_datetime(self._dt) self.assertEqual(self._dt, sealed) self.assertEqual("2023-10-20", sealed.partial_isoformat()) self.assertTrue(sealed.has_day) self.assertTrue(sealed.has_month) def test_month_seal(self): - sealed = sealed_date.MonthSeal.from_datetime(self._dt) + sealed = sealed_date.MonthParts.from_datetime(self._dt) self.assertEqual(self._dt, sealed) self.assertEqual("2023-10", sealed.partial_isoformat()) self.assertFalse(sealed.has_day) self.assertTrue(sealed.has_month) def test_year_seal(self): - sealed = sealed_date.YearSeal.from_datetime(self._dt) + sealed = sealed_date.YearParts.from_datetime(self._dt) self.assertEqual(self._dt, sealed) self.assertEqual("2023", sealed.partial_isoformat()) self.assertFalse(sealed.has_day) @@ -41,7 +41,7 @@ class SealedDateTest(unittest.TestCase): def test_no_naive_datetime(self): with self.assertRaises(ValueError): - sealed_date.SealedDate.from_datetime(datetime.datetime(2000, 1, 1)) + sealed_date.PartialDate.from_datetime(datetime.datetime(2000, 1, 1)) def test_parse_year_seal(self): parsed = sealed_date.from_partial_isoformat("1995") @@ -99,7 +99,7 @@ class SealedDateTest(unittest.TestCase): class PartialDateFormFieldTest(unittest.TestCase): - """test form support for SealedDate objects""" + """test form support for PartialDate objects""" # pylint: disable=missing-function-docstring @@ -108,32 +108,32 @@ class PartialDateFormFieldTest(unittest.TestCase): self.field = sealed_date.PartialDateFormField() def test_prepare_value(self): - sealed = sealed_date.SealedDate.from_datetime(self._dt) + sealed = sealed_date.PartialDate.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) + sealed = sealed_date.MonthParts.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) + sealed = sealed_date.YearParts.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.assertIsInstance(date, sealed_date.PartialDate) 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.assertIsInstance(date, sealed_date.PartialDate) 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.assertIsInstance(date, sealed_date.PartialDate) self.assertEqual("2022", date.partial_isoformat()) with self.assertRaises(ValidationError): self.field.to_python("0-05-25") @@ -142,5 +142,5 @@ class PartialDateFormFieldTest(unittest.TestCase): with translation.override("es"): # check super() is called date = self.field.to_python("5/6/97") - self.assertIsInstance(date, sealed_date.SealedDate) + self.assertIsInstance(date, sealed_date.PartialDate) self.assertEqual("1997-06-05", date.partial_isoformat()) diff --git a/bookwyrm/utils/sealed_date.py b/bookwyrm/utils/sealed_date.py index a819f7c3b..cd2be8eb0 100644 --- a/bookwyrm/utils/sealed_date.py +++ b/bookwyrm/utils/sealed_date.py @@ -1,4 +1,4 @@ -"""Implementation of the SealedDate class.""" +"""Implementation of the PartialDate class.""" from __future__ import annotations @@ -15,7 +15,7 @@ from django.utils import timezone # pylint: disable=no-else-return __all__ = [ - "SealedDate", + "PartialDate", "PartialDateModel", "from_partial_isoformat", ] @@ -23,14 +23,14 @@ __all__ = [ _partial_re = re.compile(r"(\d{4})(?:-(\d\d?))?(?:-(\d\d?))?$") _westmost_tz = timezone.get_fixed_timezone(timedelta(hours=-12)) -Sealed = TypeVar("Sealed", bound="SealedDate") # TODO: use Self in Python >= 3.11 +Partial = TypeVar("Partial", bound="PartialDate") # TODO: use Self in Python >= 3.11 -# TODO: migrate SealedDate: `datetime` => `date` +# TODO: migrate PartialDate: `datetime` => `date` # TODO: migrate PartialDateModel: `DateTimeField` => `DateField` -class SealedDate(datetime): - """a date object sealed into a certain precision (day, month or year)""" +class PartialDate(datetime): + """a date object bound into a certain precision (day, month or year)""" @property def has_day(self) -> bool: @@ -47,8 +47,8 @@ class SealedDate(datetime): return self.strftime("%Y-%m-%d") @classmethod - def from_datetime(cls: Type[Sealed], dt: datetime) -> Sealed: - """construct a SealedDate object from a timezone-aware datetime + def from_datetime(cls: Type[Partial], dt: datetime) -> Partial: + """construct a PartialDate object from a timezone-aware datetime Use subclasses to specify precision. If `dt` is naive, `ValueError` is raised. @@ -59,18 +59,18 @@ class SealedDate(datetime): return cls.combine(dt.date(), dt.time(), tzinfo=dt.tzinfo) @classmethod - def from_date_parts(cls: Type[Sealed], year: int, month: int, day: int) -> Sealed: - """construct a SealedDate from year, month, day. + def from_date_parts(cls: Type[Partial], year: int, month: int, day: int) -> Partial: + """construct a PartialDate from year, month, day. Use sublcasses to specify precision.""" - # because SealedDate is actually a datetime object, we must create it with a + # because PartialDate 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): - """a date sealed into month precision""" +class MonthParts(PartialDate): + """a date bound into month precision""" @property def has_day(self) -> bool: @@ -80,8 +80,8 @@ class MonthSeal(SealedDate): return self.strftime("%Y-%m") -class YearSeal(SealedDate): - """a date sealed into year precision""" +class YearParts(PartialDate): + """a date bound into year precision""" @property def has_month(self) -> bool: @@ -91,8 +91,8 @@ class YearSeal(SealedDate): return self.strftime("%Y") -def from_partial_isoformat(value: str) -> SealedDate: - """construct SealedDate from a partial string. +def from_partial_isoformat(value: str) -> PartialDate: + """construct PartialDate from a partial string. Accepted formats: YYYY, YYYY-MM, YYYY-MM-DD; otherwise `ValueError` is raised. @@ -105,20 +105,20 @@ def from_partial_isoformat(value: str) -> SealedDate: year, month, day = [int(val) if val else -1 for val in match.groups()] if month < 0: - return YearSeal.from_date_parts(year, 1, 1) + return YearParts.from_date_parts(year, 1, 1) elif day < 0: - return MonthSeal.from_date_parts(year, month, 1) + return MonthParts.from_date_parts(year, month, 1) else: - return SealedDate.from_date_parts(year, month, day) + return PartialDate.from_date_parts(year, month, day) class PartialDateFormField(DateField): - """date form field with support for SealedDate""" + """date form field with support for PartialDate""" def prepare_value(self, value: Any) -> str: # 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): + # parts. We piggy-back into that, to make it work with PartialDate. + if not isinstance(value, PartialDate): return cast(str, super().prepare_value(value)) elif value.has_day: return value.strftime("%Y-%m-%d") @@ -127,7 +127,7 @@ class PartialDateFormField(DateField): else: return value.strftime("%Y-0-0") - def to_python(self, value: Any) -> Optional[SealedDate]: + def to_python(self, value: Any) -> Optional[PartialDate]: try: date = super().to_python(value) except ValidationError as ex: @@ -136,21 +136,21 @@ class PartialDateFormField(DateField): 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) + return YearParts.from_date_parts(year, 1, 1) elif not day: - return MonthSeal.from_date_parts(year, month, 1) + return MonthParts.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) + return PartialDate.from_date_parts(year, month, day) # For typing field and descriptor, below. _SetType = datetime -_GetType = Optional[SealedDate] +_GetType = Optional[PartialDate] class PartialDateDescriptor: @@ -160,14 +160,14 @@ class PartialDateDescriptor: """ _SEAL_TYPES: dict[Type[_SetType], str] = { - YearSeal: "YEAR", - MonthSeal: "MONTH", - SealedDate: "DAY", + YearParts: "YEAR", + MonthParts: "MONTH", + PartialDate: "DAY", } - _DATE_CLASSES: dict[Any, Type[SealedDate]] = { - "YEAR": YearSeal, - "MONTH": MonthSeal, + _DATE_CLASSES: dict[Any, Type[PartialDate]] = { + "YEAR": YearParts, + "MONTH": MonthParts, } def __init__(self, field: models.Field[_SetType, _GetType]): @@ -179,12 +179,12 @@ class PartialDateDescriptor: value = instance.__dict__.get(self.field.attname) - if not value or isinstance(value, SealedDate): + if not value or isinstance(value, PartialDate): return value - # use precision field to construct SealedDate. + # use precision field to construct PartialDate. seal_type = getattr(instance, self.precision_field, None) - date_class = self._DATE_CLASSES.get(seal_type, SealedDate) + date_class = self._DATE_CLASSES.get(seal_type, PartialDate) return date_class.from_datetime(value) # FIXME: drop datetimes. @@ -216,7 +216,7 @@ class PartialDateDescriptor: class PartialDateModel(models.DateTimeField): # type: ignore - """a date field for Django models, using SealedDate as values""" + """a date field for Django models, using PartialDate as values""" descriptor_class = PartialDateDescriptor From edfa6b18a1caf419e207bdce49d64b004ce68715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Thu, 9 Nov 2023 13:45:07 -0300 Subject: [PATCH 16/20] Rename utils.sealed_date module (and tests) to utils.partial_date --- bookwyrm/models/fields.py | 2 +- bookwyrm/templatetags/date_ext.py | 2 +- ...st_sealed_date.py => test_partial_date.py} | 58 ++++++++++--------- .../utils/{sealed_date.py => partial_date.py} | 0 4 files changed, 33 insertions(+), 29 deletions(-) rename bookwyrm/tests/{test_sealed_date.py => test_partial_date.py} (67%) rename bookwyrm/utils/{sealed_date.py => partial_date.py} (100%) diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index 493a8e5c2..4bd580705 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -20,7 +20,7 @@ from markdown import markdown from bookwyrm import activitypub from bookwyrm.connectors import get_image from bookwyrm.utils.sanitizer import clean -from bookwyrm.utils.sealed_date import ( +from bookwyrm.utils.partial_date import ( PartialDate, PartialDateModel, from_partial_isoformat, diff --git a/bookwyrm/templatetags/date_ext.py b/bookwyrm/templatetags/date_ext.py index d4ca988de..d2490c13f 100644 --- a/bookwyrm/templatetags/date_ext.py +++ b/bookwyrm/templatetags/date_ext.py @@ -3,7 +3,7 @@ from django import template from django.template import defaultfilters from django.contrib.humanize.templatetags.humanize import naturalday -from bookwyrm.utils.sealed_date import PartialDate +from bookwyrm.utils.partial_date import PartialDate register = template.Library() diff --git a/bookwyrm/tests/test_sealed_date.py b/bookwyrm/tests/test_partial_date.py similarity index 67% rename from bookwyrm/tests/test_sealed_date.py rename to bookwyrm/tests/test_partial_date.py index 47532f40c..364d00933 100644 --- a/bookwyrm/tests/test_sealed_date.py +++ b/bookwyrm/tests/test_partial_date.py @@ -1,4 +1,4 @@ -""" test sealed_date module """ +""" test partial_date module """ import datetime import unittest @@ -7,7 +7,7 @@ from django.core.exceptions import ValidationError from django.utils import timezone from django.utils import translation -from bookwyrm.utils import sealed_date +from bookwyrm.utils import partial_date class PartialDateTest(unittest.TestCase): @@ -19,21 +19,21 @@ class PartialDateTest(unittest.TestCase): self._dt = datetime.datetime(2023, 10, 20, 17, 33, 10, tzinfo=timezone.utc) def test_day_seal(self): - sealed = sealed_date.PartialDate.from_datetime(self._dt) + sealed = partial_date.PartialDate.from_datetime(self._dt) self.assertEqual(self._dt, sealed) self.assertEqual("2023-10-20", sealed.partial_isoformat()) self.assertTrue(sealed.has_day) self.assertTrue(sealed.has_month) def test_month_seal(self): - sealed = sealed_date.MonthParts.from_datetime(self._dt) + sealed = partial_date.MonthParts.from_datetime(self._dt) self.assertEqual(self._dt, sealed) self.assertEqual("2023-10", sealed.partial_isoformat()) self.assertFalse(sealed.has_day) self.assertTrue(sealed.has_month) def test_year_seal(self): - sealed = sealed_date.YearParts.from_datetime(self._dt) + sealed = partial_date.YearParts.from_datetime(self._dt) self.assertEqual(self._dt, sealed) self.assertEqual("2023", sealed.partial_isoformat()) self.assertFalse(sealed.has_day) @@ -41,19 +41,19 @@ class PartialDateTest(unittest.TestCase): def test_no_naive_datetime(self): with self.assertRaises(ValueError): - sealed_date.PartialDate.from_datetime(datetime.datetime(2000, 1, 1)) + partial_date.PartialDate.from_datetime(datetime.datetime(2000, 1, 1)) def test_parse_year_seal(self): - parsed = sealed_date.from_partial_isoformat("1995") + parsed = partial_date.from_partial_isoformat("1995") expected = datetime.date(1995, 1, 1) self.assertEqual(expected, parsed.date()) self.assertFalse(parsed.has_day) self.assertFalse(parsed.has_month) def test_parse_year_errors(self): - self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "995") - self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "1995x") - self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "1995-") + self.assertRaises(ValueError, partial_date.from_partial_isoformat, "995") + self.assertRaises(ValueError, partial_date.from_partial_isoformat, "1995x") + self.assertRaises(ValueError, partial_date.from_partial_isoformat, "1995-") def test_parse_month_seal(self): expected = datetime.date(1995, 5, 1) @@ -63,15 +63,15 @@ class PartialDateTest(unittest.TestCase): ] for desc, value in test_cases: with self.subTest(desc): - parsed = sealed_date.from_partial_isoformat(value) + parsed = partial_date.from_partial_isoformat(value) self.assertEqual(expected, parsed.date()) self.assertFalse(parsed.has_day) self.assertTrue(parsed.has_month) def test_parse_month_dash_required(self): - self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "20056") - self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "200506") - self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "1995-7-") + self.assertRaises(ValueError, partial_date.from_partial_isoformat, "20056") + self.assertRaises(ValueError, partial_date.from_partial_isoformat, "200506") + self.assertRaises(ValueError, partial_date.from_partial_isoformat, "1995-7-") def test_parse_day_seal(self): expected = datetime.date(1995, 5, 6) @@ -82,19 +82,23 @@ class PartialDateTest(unittest.TestCase): ] for desc, value in test_cases: with self.subTest(desc): - parsed = sealed_date.from_partial_isoformat(value) + parsed = partial_date.from_partial_isoformat(value) self.assertEqual(expected, parsed.date()) self.assertTrue(parsed.has_day) self.assertTrue(parsed.has_month) def test_partial_isoformat_no_time_allowed(self): - self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "2005-06-07 ") - self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "2005-06-07T") self.assertRaises( - ValueError, sealed_date.from_partial_isoformat, "2005-06-07T00:00:00" + ValueError, partial_date.from_partial_isoformat, "2005-06-07 " ) self.assertRaises( - ValueError, sealed_date.from_partial_isoformat, "2005-06-07T00:00:00-03" + ValueError, partial_date.from_partial_isoformat, "2005-06-07T" + ) + self.assertRaises( + ValueError, partial_date.from_partial_isoformat, "2005-06-07T00:00:00" + ) + self.assertRaises( + ValueError, partial_date.from_partial_isoformat, "2005-06-07T00:00:00-03" ) @@ -105,35 +109,35 @@ class PartialDateFormFieldTest(unittest.TestCase): def setUp(self): self._dt = datetime.datetime(2022, 11, 21, 17, 1, 0, tzinfo=timezone.utc) - self.field = sealed_date.PartialDateFormField() + self.field = partial_date.PartialDateFormField() def test_prepare_value(self): - sealed = sealed_date.PartialDate.from_datetime(self._dt) + sealed = partial_date.PartialDate.from_datetime(self._dt) self.assertEqual("2022-11-21", self.field.prepare_value(sealed)) def test_prepare_value_month(self): - sealed = sealed_date.MonthParts.from_datetime(self._dt) + sealed = partial_date.MonthParts.from_datetime(self._dt) self.assertEqual("2022-11-0", self.field.prepare_value(sealed)) def test_prepare_value_year(self): - sealed = sealed_date.YearParts.from_datetime(self._dt) + sealed = partial_date.YearParts.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.PartialDate) + self.assertIsInstance(date, partial_date.PartialDate) 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.PartialDate) + self.assertIsInstance(date, partial_date.PartialDate) 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.PartialDate) + self.assertIsInstance(date, partial_date.PartialDate) self.assertEqual("2022", date.partial_isoformat()) with self.assertRaises(ValidationError): self.field.to_python("0-05-25") @@ -142,5 +146,5 @@ class PartialDateFormFieldTest(unittest.TestCase): with translation.override("es"): # check super() is called date = self.field.to_python("5/6/97") - self.assertIsInstance(date, sealed_date.PartialDate) + self.assertIsInstance(date, partial_date.PartialDate) self.assertEqual("1997-06-05", date.partial_isoformat()) diff --git a/bookwyrm/utils/sealed_date.py b/bookwyrm/utils/partial_date.py similarity index 100% rename from bookwyrm/utils/sealed_date.py rename to bookwyrm/utils/partial_date.py From be9d92b1c2e7c6fcb161858b11c4c84e29adcd1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Thu, 9 Nov 2023 13:49:52 -0300 Subject: [PATCH 17/20] Remove last references to "seal" in partial_date.py and migration --- bookwyrm/migrations/0185_auto_20231109_1657.py | 12 ++++++------ bookwyrm/utils/partial_date.py | 14 +++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/bookwyrm/migrations/0185_auto_20231109_1657.py b/bookwyrm/migrations/0185_auto_20231109_1657.py index d8788b70d..eb3a5c56a 100644 --- a/bookwyrm/migrations/0185_auto_20231109_1657.py +++ b/bookwyrm/migrations/0185_auto_20231109_1657.py @@ -17,9 +17,9 @@ class Migration(migrations.Migration): field=models.CharField( blank=True, choices=[ - ("DAY", "Day seal"), - ("MONTH", "Month seal"), - ("YEAR", "Year seal"), + ("DAY", "Day prec."), + ("MONTH", "Month prec."), + ("YEAR", "Year prec."), ], editable=False, max_length=10, @@ -32,9 +32,9 @@ class Migration(migrations.Migration): field=models.CharField( blank=True, choices=[ - ("DAY", "Day seal"), - ("MONTH", "Month seal"), - ("YEAR", "Year seal"), + ("DAY", "Day prec."), + ("MONTH", "Month prec."), + ("YEAR", "Year prec."), ], editable=False, max_length=10, diff --git a/bookwyrm/utils/partial_date.py b/bookwyrm/utils/partial_date.py index cd2be8eb0..2bc97748c 100644 --- a/bookwyrm/utils/partial_date.py +++ b/bookwyrm/utils/partial_date.py @@ -159,13 +159,13 @@ class PartialDateDescriptor: Encapsulates the "two columns, one field" for PartialDateModel. """ - _SEAL_TYPES: dict[Type[_SetType], str] = { + _PRECISION_NAMES: dict[Type[_SetType], str] = { YearParts: "YEAR", MonthParts: "MONTH", PartialDate: "DAY", } - _DATE_CLASSES: dict[Any, Type[PartialDate]] = { + _PARTIAL_CLASSES: dict[Any, Type[PartialDate]] = { "YEAR": YearParts, "MONTH": MonthParts, } @@ -183,19 +183,19 @@ class PartialDateDescriptor: return value # use precision field to construct PartialDate. - seal_type = getattr(instance, self.precision_field, None) - date_class = self._DATE_CLASSES.get(seal_type, PartialDate) + precision = getattr(instance, self.precision_field, None) + date_class = self._PARTIAL_CLASSES.get(precision, PartialDate) return date_class.from_datetime(value) # FIXME: drop datetimes. def __set__(self, instance: models.Model, value: _SetType) -> None: """assign value, with precision where available""" try: - seal_type = self._SEAL_TYPES[value.__class__] + precision = self._PRECISION_NAMES[value.__class__] except KeyError: value = self.field.to_python(value) else: - setattr(instance, self.precision_field, seal_type) + setattr(instance, self.precision_field, precision) instance.__dict__[self.field.attname] = value @@ -212,7 +212,7 @@ class PartialDateDescriptor: @property def precision_choices(self) -> list[tuple[str, str]]: """valid options for precision database field""" - return [("DAY", "Day seal"), ("MONTH", "Month seal"), ("YEAR", "Year seal")] + return [("DAY", "Day prec."), ("MONTH", "Month prec."), ("YEAR", "Year prec.")] class PartialDateModel(models.DateTimeField): # type: ignore From aaea1b1b9eb494f24d8306bd93c49519dfb1a857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Tue, 14 Nov 2023 19:46:22 -0300 Subject: [PATCH 18/20] Add tests for naturalday_partial tag --- bookwyrm/tests/templatetags/test_date_ext.py | 31 ++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 bookwyrm/tests/templatetags/test_date_ext.py diff --git a/bookwyrm/tests/templatetags/test_date_ext.py b/bookwyrm/tests/templatetags/test_date_ext.py new file mode 100644 index 000000000..a8eeb4233 --- /dev/null +++ b/bookwyrm/tests/templatetags/test_date_ext.py @@ -0,0 +1,31 @@ +"""Test date extensions in templates""" +from dateutil.parser import isoparse + +from django.test import TestCase, override_settings + +from bookwyrm.templatetags import date_ext +from bookwyrm.utils.partial_date import MonthParts, YearParts, from_partial_isoformat + + +@override_settings(LANGUAGE_CODE="en-AU") +class PartialDateTags(TestCase): + """PartialDate tags""" + + def setUp(self): + """create dates and set language""" + self._dt = isoparse("2023-12-31T23:59:59Z") + self._date = self._dt.date() + self._partial_day = from_partial_isoformat("2023-06-30") + self._partial_month = MonthParts.from_date_parts(2023, 6, 30) + self._partial_year = YearParts.from_datetime(self._dt) + + def test_standard_date_objects(self): + """should work with standard date/datetime objects""" + self.assertEqual("31 Dec 2023", date_ext.naturalday_partial(self._dt)) + self.assertEqual("31 Dec 2023", date_ext.naturalday_partial(self._date)) + + def test_partial_date_objects(self): + """should work with PartialDate and subclasses""" + self.assertEqual("2023", date_ext.naturalday_partial(self._partial_year)) + self.assertEqual("June 2023", date_ext.naturalday_partial(self._partial_month)) + self.assertEqual("30 Jun 2023", date_ext.naturalday_partial(self._partial_day)) From 6aaff28c139642593f97c157389f3f70ecafde41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Tue, 14 Nov 2023 20:27:44 -0300 Subject: [PATCH 19/20] Accept argument in naturalday_partial, downcast format if necessary --- bookwyrm/templatetags/date_ext.py | 22 +++++++++----- bookwyrm/tests/templatetags/test_date_ext.py | 31 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/bookwyrm/templatetags/date_ext.py b/bookwyrm/templatetags/date_ext.py index d2490c13f..6dc320bed 100644 --- a/bookwyrm/templatetags/date_ext.py +++ b/bookwyrm/templatetags/date_ext.py @@ -8,15 +8,23 @@ from bookwyrm.utils.partial_date import PartialDate register = template.Library() -@register.filter(expects_localtime=True, is_safe=False) -def naturalday_partial(date): - """allow templates to easily format PartialDate objects""" +@register.filter(expects_localtime=True) +def naturalday_partial(date, arg=None): + """chooses appropriate precision if date is a PartialDate object + + If arg is a Django-defined format such as "DATE_FORMAT", it will be adjusted + so that the precision of the PartialDate object is honored. + """ + django_formats = ("DATE_FORMAT", "SHORT_DATE_FORMAT", "YEAR_MONTH_FORMAT") if not isinstance(date, PartialDate): - return defaultfilters.date(date) + return defaultfilters.date(date, arg) + if arg is None: + arg = "DATE_FORMAT" if date.has_day: - fmt = "DATE_FORMAT" + fmt = arg elif date.has_month: - fmt = "YEAR_MONTH_FORMAT" + # there is no SHORT_YEAR_MONTH_FORMAT, so we ignore SHORT_DATE_FORMAT :( + fmt = "YEAR_MONTH_FORMAT" if arg == "DATE_FORMAT" else arg else: - fmt = "Y" + fmt = "Y" if arg in django_formats else arg return naturalday(date, fmt) diff --git a/bookwyrm/tests/templatetags/test_date_ext.py b/bookwyrm/tests/templatetags/test_date_ext.py index a8eeb4233..f7ea73891 100644 --- a/bookwyrm/tests/templatetags/test_date_ext.py +++ b/bookwyrm/tests/templatetags/test_date_ext.py @@ -29,3 +29,34 @@ class PartialDateTags(TestCase): self.assertEqual("2023", date_ext.naturalday_partial(self._partial_year)) self.assertEqual("June 2023", date_ext.naturalday_partial(self._partial_month)) self.assertEqual("30 Jun 2023", date_ext.naturalday_partial(self._partial_day)) + + def test_format_arg_is_used(self): + """the provided format should be used by default""" + self.assertEqual("Dec.31", date_ext.naturalday_partial(self._dt, "M.j")) + self.assertEqual("Dec.31", date_ext.naturalday_partial(self._date, "M.j")) + self.assertEqual("June", date_ext.naturalday_partial(self._partial_day, "F")) + + def test_month_precision_downcast(self): + """precision is adjusted for well-known date formats""" + self.assertEqual( + "June 2023", date_ext.naturalday_partial(self._partial_month, "DATE_FORMAT") + ) + + def test_year_precision_downcast(self): + """precision is adjusted for well-known date formats""" + for fmt in "DATE_FORMAT", "SHORT_DATE_FORMAT", "YEAR_MONTH_FORMAT": + with self.subTest(desc=fmt): + self.assertEqual( + "2023", date_ext.naturalday_partial(self._partial_year, fmt) + ) + + def test_nonstandard_formats_passthru(self): + """garbage-in, garbage-out: we don't mess with unknown date formats""" + # Expected because there is no SHORT_YEAR_MONTH_FORMAT in Django that we can use + self.assertEqual( + "30/06/2023", + date_ext.naturalday_partial(self._partial_month, "SHORT_DATE_FORMAT"), + ) + self.assertEqual( + "December.31", date_ext.naturalday_partial(self._partial_year, "F.j") + ) From ff1f239a574a437ff14204ddf607d8ed22678d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Sun, 19 Nov 2023 14:41:37 -0300 Subject: [PATCH 20/20] Use typing_extensions.Self instead of TypeVar --- bookwyrm/utils/partial_date.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bookwyrm/utils/partial_date.py b/bookwyrm/utils/partial_date.py index 2bc97748c..40b89c838 100644 --- a/bookwyrm/utils/partial_date.py +++ b/bookwyrm/utils/partial_date.py @@ -4,7 +4,8 @@ from __future__ import annotations from datetime import datetime, timedelta import re -from typing import Any, Optional, Type, TypeVar, cast +from typing import Any, Optional, Type, cast +from typing_extensions import Self from django.core.exceptions import ValidationError from django.db import models @@ -23,8 +24,6 @@ __all__ = [ _partial_re = re.compile(r"(\d{4})(?:-(\d\d?))?(?:-(\d\d?))?$") _westmost_tz = timezone.get_fixed_timezone(timedelta(hours=-12)) -Partial = TypeVar("Partial", bound="PartialDate") # TODO: use Self in Python >= 3.11 - # TODO: migrate PartialDate: `datetime` => `date` # TODO: migrate PartialDateModel: `DateTimeField` => `DateField` @@ -47,7 +46,7 @@ class PartialDate(datetime): return self.strftime("%Y-%m-%d") @classmethod - def from_datetime(cls: Type[Partial], dt: datetime) -> Partial: + def from_datetime(cls, dt: datetime) -> Self: """construct a PartialDate object from a timezone-aware datetime Use subclasses to specify precision. If `dt` is naive, `ValueError` @@ -59,7 +58,7 @@ class PartialDate(datetime): return cls.combine(dt.date(), dt.time(), tzinfo=dt.tzinfo) @classmethod - def from_date_parts(cls: Type[Partial], year: int, month: int, day: int) -> Partial: + def from_date_parts(cls, year: int, month: int, day: int) -> Self: """construct a PartialDate from year, month, day. Use sublcasses to specify precision."""