diff --git a/bookwyrm/migrations/0187_partial_publication_dates.py b/bookwyrm/migrations/0187_partial_publication_dates.py new file mode 100644 index 000000000..10ef599a7 --- /dev/null +++ b/bookwyrm/migrations/0187_partial_publication_dates.py @@ -0,0 +1,54 @@ +# Generated by Django 3.2.20 on 2023-11-09 16:57 + +import bookwyrm.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0186_invite_request_notification"), + ] + + operations = [ + migrations.AddField( + model_name="book", + name="first_published_date_precision", + field=models.CharField( + blank=True, + choices=[ + ("DAY", "Day prec."), + ("MONTH", "Month prec."), + ("YEAR", "Year prec."), + ], + editable=False, + max_length=10, + null=True, + ), + ), + migrations.AddField( + model_name="book", + name="published_date_precision", + field=models.CharField( + blank=True, + choices=[ + ("DAY", "Day prec."), + ("MONTH", "Month prec."), + ("YEAR", "Year prec."), + ], + 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 e5941136f..6893b9da1 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 1e458c815..4bd580705 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.partial_date import ( + PartialDate, + PartialDateModel, + from_partial_isoformat, +) from bookwyrm.settings import MEDIA_FULL_URL @@ -539,7 +544,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) @@ -549,6 +553,37 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField): return None +class PartialDateField(ActivitypubFieldMixin, PartialDateModel): + """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): + # pylint: disable=no-else-return + 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 + + if timezone.is_aware(parsed): + return PartialDate.from_datetime(parsed) + else: + # Should not happen on the wire, but truncate down to date parts. + 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 + # 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. + + class HtmlField(ActivitypubFieldMixin, models.TextField): """a text field for storing html""" diff --git a/bookwyrm/templates/book/publisher_info.html b/bookwyrm/templates/book/publisher_info.html index e3ffedca8..a69b7d86f 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 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 %} @@ -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/date_ext.py b/bookwyrm/templatetags/date_ext.py new file mode 100644 index 000000000..6dc320bed --- /dev/null +++ b/bookwyrm/templatetags/date_ext.py @@ -0,0 +1,30 @@ +""" additional formatting of dates """ +from django import template +from django.template import defaultfilters +from django.contrib.humanize.templatetags.humanize import naturalday + +from bookwyrm.utils.partial_date import PartialDate + +register = template.Library() + + +@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, arg) + if arg is None: + arg = "DATE_FORMAT" + if date.has_day: + fmt = arg + elif date.has_month: + # 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" if arg in django_formats else arg + return naturalday(date, fmt) diff --git a/bookwyrm/tests/models/test_fields.py b/bookwyrm/tests/models/test_fields.py index 553a533d5..d04178d4a 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 @@ -594,6 +596,36 @@ 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 = [ + ("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/templatetags/test_date_ext.py b/bookwyrm/tests/templatetags/test_date_ext.py new file mode 100644 index 000000000..f7ea73891 --- /dev/null +++ b/bookwyrm/tests/templatetags/test_date_ext.py @@ -0,0 +1,62 @@ +"""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)) + + 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") + ) diff --git a/bookwyrm/tests/test_partial_date.py b/bookwyrm/tests/test_partial_date.py new file mode 100644 index 000000000..364d00933 --- /dev/null +++ b/bookwyrm/tests/test_partial_date.py @@ -0,0 +1,150 @@ +""" test partial_date module """ + +import datetime +import unittest + +from django.core.exceptions import ValidationError +from django.utils import timezone +from django.utils import translation + +from bookwyrm.utils import partial_date + + +class PartialDateTest(unittest.TestCase): + """test PartialDate class in isolation""" + + # pylint: disable=missing-function-docstring + + def setUp(self): + self._dt = datetime.datetime(2023, 10, 20, 17, 33, 10, tzinfo=timezone.utc) + + def test_day_seal(self): + 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 = 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 = partial_date.YearParts.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_no_naive_datetime(self): + with self.assertRaises(ValueError): + partial_date.PartialDate.from_datetime(datetime.datetime(2000, 1, 1)) + + def test_parse_year_seal(self): + 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, 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) + test_cases = [ + ("parse_month", "1995-05"), + ("parse_month_lenient", "1995-5"), + ] + for desc, value in test_cases: + with self.subTest(desc): + 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, 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) + 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 = 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, partial_date.from_partial_isoformat, "2005-06-07 " + ) + self.assertRaises( + 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" + ) + + +class PartialDateFormFieldTest(unittest.TestCase): + """test form support for PartialDate objects""" + + # pylint: disable=missing-function-docstring + + def setUp(self): + self._dt = datetime.datetime(2022, 11, 21, 17, 1, 0, tzinfo=timezone.utc) + self.field = partial_date.PartialDateFormField() + + def test_prepare_value(self): + 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 = partial_date.MonthParts.from_datetime(self._dt) + self.assertEqual("2022-11-0", self.field.prepare_value(sealed)) + + def test_prepare_value_year(self): + 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, 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, 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, partial_date.PartialDate) + 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, partial_date.PartialDate) + self.assertEqual("1997-06-05", date.partial_isoformat()) diff --git a/bookwyrm/tests/views/books/test_edit_book.py b/bookwyrm/tests/views/books/test_edit_book.py index 2dc25095f..49e8c7cdb 100644 --- a/bookwyrm/tests/views/books/test_edit_book.py +++ b/bookwyrm/tests/views/books/test_edit_book.py @@ -8,6 +8,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 +210,97 @@ class EditBookViews(TestCase): book = models.Edition.objects.get(title="New Title") self.assertEqual(book.parent_work.title, "New Title") + 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_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/partial_date.py b/bookwyrm/utils/partial_date.py new file mode 100644 index 000000000..40b89c838 --- /dev/null +++ b/bookwyrm/utils/partial_date.py @@ -0,0 +1,240 @@ +"""Implementation of the PartialDate class.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import re +from typing import Any, Optional, Type, cast +from typing_extensions import Self + +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 + +# pylint: disable=no-else-return + +__all__ = [ + "PartialDate", + "PartialDateModel", + "from_partial_isoformat", +] + +_partial_re = re.compile(r"(\d{4})(?:-(\d\d?))?(?:-(\d\d?))?$") +_westmost_tz = timezone.get_fixed_timezone(timedelta(hours=-12)) + +# TODO: migrate PartialDate: `datetime` => `date` +# TODO: migrate PartialDateModel: `DateTimeField` => `DateField` + + +class PartialDate(datetime): + """a date object bound into a certain precision (day, month or year)""" + + @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, dt: datetime) -> Self: + """construct a PartialDate 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") + return cls.combine(dt.date(), dt.time(), tzinfo=dt.tzinfo) + + @classmethod + def from_date_parts(cls, year: int, month: int, day: int) -> Self: + """construct a PartialDate from year, month, day. + + Use sublcasses to specify precision.""" + # 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 MonthParts(PartialDate): + """a date bound into month precision""" + + @property + def has_day(self) -> bool: + return False + + def partial_isoformat(self) -> str: + return self.strftime("%Y-%m") + + +class YearParts(PartialDate): + """a date bound into year precision""" + + @property + def has_month(self) -> bool: + return False + + def partial_isoformat(self) -> str: + return self.strftime("%Y") + + +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. + """ + match = _partial_re.match(value) + + if not match: + raise ValueError + + year, month, day = [int(val) if val else -1 for val in match.groups()] + + if month < 0: + return YearParts.from_date_parts(year, 1, 1) + elif day < 0: + return MonthParts.from_date_parts(year, month, 1) + else: + return PartialDate.from_date_parts(year, month, day) + + +class PartialDateFormField(DateField): + """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 PartialDate. + if not isinstance(value, PartialDate): + return cast(str, 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: Any) -> Optional[PartialDate]: + 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 YearParts.from_date_parts(year, 1, 1) + elif not day: + 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 PartialDate.from_date_parts(year, month, day) + + +# For typing field and descriptor, below. +_SetType = datetime +_GetType = Optional[PartialDate] + + +class PartialDateDescriptor: + """descriptor for PartialDateModel. + + Encapsulates the "two columns, one field" for PartialDateModel. + """ + + _PRECISION_NAMES: dict[Type[_SetType], str] = { + YearParts: "YEAR", + MonthParts: "MONTH", + PartialDate: "DAY", + } + + _PARTIAL_CLASSES: dict[Any, Type[PartialDate]] = { + "YEAR": YearParts, + "MONTH": MonthParts, + } + + def __init__(self, field: models.Field[_SetType, _GetType]): + self.field = field + + def __get__(self, instance: models.Model, cls: Any = None) -> _GetType: + if instance is None: + return self + + value = instance.__dict__.get(self.field.attname) + + if not value or isinstance(value, PartialDate): + return value + + # use precision field to construct 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: + precision = self._PRECISION_NAMES[value.__class__] + except KeyError: + value = self.field.to_python(value) + else: + setattr(instance, self.precision_field, precision) + + 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" + + @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 prec."), ("MONTH", "Month prec."), ("YEAR", "Year prec.")] + + +class PartialDateModel(models.DateTimeField): # type: ignore + """a date field for Django models, using PartialDate as values""" + + descriptor_class = PartialDateDescriptor + + def formfield(self, **kwargs): # type: ignore + kwargs.setdefault("form_class", PartialDateFormField) + return super().formfield(**kwargs) + + # pylint: disable-next=arguments-renamed + def contribute_to_class(self, model, our_name_in_model, **kwargs): # type: ignore + # Define precision field. + descriptor = self.descriptor_class(self) + precision: models.Field[Optional[str], Optional[str]] = 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)