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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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.""" From f011f2bce90e5c053224097f6c6ed3c80060229b Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Mon, 20 Nov 2023 12:17:52 +1100 Subject: [PATCH 21/21] hide instance actor from users The Instance Actor is required for signing http GET requests but is not a "user" and should not be otherwise interacted with. - hides instance actor profile page, returning a 404 - excludes instance actor from search results and suggestions including in Getting Started - replaces link to user profile in user admin page with a brief message box - replaces panel in user admin page that allows for user to be suspended or removed with a message explaining why that is a very bad idea fixes #3119 --- bookwyrm/activitypub/base_activity.py | 2 +- bookwyrm/suggested_users.py | 13 +- .../templates/settings/users/user_info.html | 11 ++ .../users/user_moderation_actions.html | 140 ++++++++++-------- bookwyrm/templatetags/utilities.py | 7 + bookwyrm/views/get_started.py | 2 + bookwyrm/views/search.py | 3 +- bookwyrm/views/user.py | 6 +- 8 files changed, 115 insertions(+), 69 deletions(-) diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 05e7d8a05..aa4b5b687 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -396,7 +396,7 @@ def resolve_remote_id( def get_representative(): """Get or create an actor representing the instance - to sign requests to 'secure mastodon' servers""" + to sign outgoing HTTP GET requests""" username = f"{INSTANCE_ACTOR_USERNAME}@{DOMAIN}" email = "bookwyrm@localhost" try: diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py index 3e9bef9c4..a13ee97fd 100644 --- a/bookwyrm/suggested_users.py +++ b/bookwyrm/suggested_users.py @@ -8,6 +8,7 @@ from opentelemetry import trace from bookwyrm import models from bookwyrm.redis_store import RedisStore, r +from bookwyrm.settings import INSTANCE_ACTOR_USERNAME from bookwyrm.tasks import app, SUGGESTED_USERS from bookwyrm.telemetry import open_telemetry @@ -98,9 +99,15 @@ class SuggestedUsers(RedisStore): for (pk, score) in values ] # annotate users with mutuals and shared book counts - users = models.User.objects.filter( - is_active=True, bookwyrm_user=True, id__in=[pk for (pk, _) in values] - ).annotate(mutuals=Case(*annotations, output_field=IntegerField(), default=0)) + users = ( + models.User.objects.filter( + is_active=True, bookwyrm_user=True, id__in=[pk for (pk, _) in values] + ) + .annotate( + mutuals=Case(*annotations, output_field=IntegerField(), default=0) + ) + .exclude(localname=INSTANCE_ACTOR_USERNAME) + ) if local: users = users.filter(local=True) return users.order_by("-mutuals")[:5] diff --git a/bookwyrm/templates/settings/users/user_info.html b/bookwyrm/templates/settings/users/user_info.html index f35c60db9..e07a7e439 100644 --- a/bookwyrm/templates/settings/users/user_info.html +++ b/bookwyrm/templates/settings/users/user_info.html @@ -1,6 +1,7 @@ {% load i18n %} {% load markdown %} {% load humanize %} +{% load utilities %}
@@ -13,7 +14,17 @@
{% endif %} + {% if user.localname|is_instance_admin %} +
+
+ {% trans "This account is the instance actor for signing HTTP requests." %} +
+
+ {% else %}

{% trans "View user profile" %}

+ {% endif %} + + {% url 'settings-user' user.id as url %} {% if not request.path == url %}

{% trans "Go to user admin" %}

diff --git a/bookwyrm/templates/settings/users/user_moderation_actions.html b/bookwyrm/templates/settings/users/user_moderation_actions.html index 4a624a5e4..fd3e66aa8 100644 --- a/bookwyrm/templates/settings/users/user_moderation_actions.html +++ b/bookwyrm/templates/settings/users/user_moderation_actions.html @@ -1,4 +1,5 @@ {% load i18n %} +{% load utilities %}
{% if not user.is_active and user.deactivation_reason == "self_deletion" or user.deactivation_reason == "moderator_deletion" %}
@@ -7,77 +8,90 @@ {% else %}

{% trans "User Actions" %}

-
-
- {% if user.is_active %} -

- {% trans "Send direct message" %} -

- {% endif %} + {% if user.localname|is_instance_admin %} +
+
+
+

{% trans "This is the instance admin actor" %}

+
+
+

{% trans "You must not delete or disable this account as it is critical to the functioning of your server. This actor signs outgoing GET requests to smooth interaction with secure ActivityPub servers." %}

+

{% trans "This account is not discoverable by ordinary users and does not have a profile page." %}

+
+
+
+ {% else %} +
+
+ {% if user.is_active %} +

+ {% trans "Send direct message" %} +

+ {% endif %} - {% if not user.is_active and user.deactivation_reason == "pending" %} -
- {% csrf_token %} - -
- {% endif %} - {% if user.is_active or user.deactivation_reason == "pending" %} -
- {% csrf_token %} - -
- {% else %} -
- {% csrf_token %} - -
+ {% if not user.is_active and user.deactivation_reason == "pending" %} +
+ {% csrf_token %} + +
+ {% endif %} + {% if user.is_active or user.deactivation_reason == "pending" %} +
+ {% csrf_token %} + +
+ {% else %} +
+ {% csrf_token %} + +
+ {% endif %} + + {% if user.local %} +
+ {% trans "Permanently delete user" as button_text %} + {% include "snippets/toggle/open_button.html" with controls_text="delete_user" text=button_text class="is-danger is-light" %} +
+ {% endif %} +
+ + {% if user.local %} +
+ {% include "settings/users/delete_user_form.html" with controls_text="delete_user" class="mt-2 mb-2" %} +
{% endif %} {% if user.local %}
- {% trans "Permanently delete user" as button_text %} - {% include "snippets/toggle/open_button.html" with controls_text="delete_user" text=button_text class="is-danger is-light" %} +
+ {% csrf_token %} + + {% if group_form.non_field_errors %} + {{ group_form.non_field_errors }} + {% endif %} + {% with group=user.groups.first %} +
+ +
+ + {% include 'snippets/form_errors.html' with errors_list=group_form.groups.errors id="desc_user_group" %} + {% endwith %} + +
{% endif %}
- - {% if user.local %} -
- {% include "settings/users/delete_user_form.html" with controls_text="delete_user" class="mt-2 mb-2" %} -
{% endif %} - - {% if user.local %} -
-
- {% csrf_token %} - - {% if group_form.non_field_errors %} - {{ group_form.non_field_errors }} - {% endif %} - {% with group=user.groups.first %} -
- -
- - {% include 'snippets/form_errors.html' with errors_list=group_form.groups.errors id="desc_user_group" %} - {% endwith %} - -
-
- {% endif %} -
- {% endif %}
diff --git a/bookwyrm/templatetags/utilities.py b/bookwyrm/templatetags/utilities.py index 42e67990f..2fd99403f 100644 --- a/bookwyrm/templatetags/utilities.py +++ b/bookwyrm/templatetags/utilities.py @@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _ from django.templatetags.static import static from bookwyrm.models import User +from bookwyrm.settings import INSTANCE_ACTOR_USERNAME register = template.Library() @@ -125,3 +126,9 @@ def id_to_username(user_id): value = f"{name}@{domain}" return value + + +@register.filter(name="is_instance_admin") +def is_instance_admin(localname): + """Returns a boolean indicating whether the user is the instance admin account""" + return localname == INSTANCE_ACTOR_USERNAME diff --git a/bookwyrm/views/get_started.py b/bookwyrm/views/get_started.py index fdb8824fa..511a886ca 100644 --- a/bookwyrm/views/get_started.py +++ b/bookwyrm/views/get_started.py @@ -11,6 +11,7 @@ from django.utils.decorators import method_decorator from django.views import View from bookwyrm import book_search, forms, models +from bookwyrm.settings import INSTANCE_ACTOR_USERNAME from bookwyrm.suggested_users import suggested_users from .preferences.edit_user import save_user_form @@ -108,6 +109,7 @@ class GetStartedUsers(View): .exclude( id=request.user.id, ) + .exclude(localname=INSTANCE_ACTOR_USERNAME) .order_by("-similarity")[:5] ) data = {"no_results": not user_results} diff --git a/bookwyrm/views/search.py b/bookwyrm/views/search.py index 2b7303fd7..743f33f59 100644 --- a/bookwyrm/views/search.py +++ b/bookwyrm/views/search.py @@ -13,7 +13,7 @@ from csp.decorators import csp_update from bookwyrm import models from bookwyrm.connectors import connector_manager from bookwyrm.book_search import search, format_search_result -from bookwyrm.settings import PAGE_LENGTH +from bookwyrm.settings import PAGE_LENGTH, INSTANCE_ACTOR_USERNAME from bookwyrm.utils import regex from .helpers import is_api_request from .helpers import handle_remote_webfinger @@ -113,6 +113,7 @@ def user_search(request): .filter( similarity__gt=0.5, ) + .exclude(localname=INSTANCE_ACTOR_USERNAME) .order_by("-similarity") ) diff --git a/bookwyrm/views/user.py b/bookwyrm/views/user.py index e547925a5..ba224d671 100644 --- a/bookwyrm/views/user.py +++ b/bookwyrm/views/user.py @@ -11,7 +11,7 @@ from django.views.decorators.http import require_POST from bookwyrm import models from bookwyrm.activitypub import ActivitypubResponse -from bookwyrm.settings import PAGE_LENGTH +from bookwyrm.settings import PAGE_LENGTH, INSTANCE_ACTOR_USERNAME from .helpers import get_user_from_username, is_api_request @@ -31,6 +31,10 @@ class User(View): return ActivitypubResponse(user.to_activity()) # otherwise we're at a UI view + # if it's not an API request, never show the instance actor profile page + if user.localname == INSTANCE_ACTOR_USERNAME: + raise Http404() + shelf_preview = [] # only show shelves that should be visible