Implement PartialDateField using SealedDate and a custom descriptor

This commit is contained in:
Adeodato Simó 2023-10-23 00:10:01 -03:00
parent 9752819bdb
commit 737ac8e908
No known key found for this signature in database
GPG key ID: CDF447845F1A986F
7 changed files with 285 additions and 7 deletions

View file

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

View file

@ -135,8 +135,8 @@ class Book(BookDataModel):
preview_image = models.ImageField( preview_image = models.ImageField(
upload_to="previews/covers/", blank=True, null=True upload_to="previews/covers/", blank=True, null=True
) )
first_published_date = fields.DateTimeField(blank=True, null=True) first_published_date = fields.PartialDateField(blank=True, null=True)
published_date = fields.DateTimeField(blank=True, null=True) published_date = fields.PartialDateField(blank=True, null=True)
objects = InheritanceManager() objects = InheritanceManager()
field_tracker = FieldTracker(fields=["authors", "title", "subtitle", "cover"]) field_tracker = FieldTracker(fields=["authors", "title", "subtitle", "cover"])

View file

@ -20,6 +20,11 @@ from markdown import markdown
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.connectors import get_image from bookwyrm.connectors import get_image
from bookwyrm.utils.sanitizer import clean from bookwyrm.utils.sanitizer import clean
from bookwyrm.utils.sealed_date import (
SealedDate,
SealedDateField,
from_partial_isoformat,
)
from bookwyrm.settings import MEDIA_FULL_URL 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): def field_from_activity(self, value, allow_external_connections=True):
missing_fields = datetime(1970, 1, 1) # "2022-10" => "2022-10-01" missing_fields = datetime(1970, 1, 1) # "2022-10" => "2022-10-01"
try: try:
# TODO(dato): investigate `ignoretz=True` wrt bookwyrm#3028.
date_value = dateutil.parser.parse(value, default=missing_fields) date_value = dateutil.parser.parse(value, default=missing_fields)
try: try:
return timezone.make_aware(date_value) return timezone.make_aware(date_value)
@ -547,6 +551,34 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
return None 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): class HtmlField(ActivitypubFieldMixin, models.TextField):
"""a text field for storing html""" """a text field for storing html"""

View file

@ -2,10 +2,12 @@
from io import BytesIO from io import BytesIO
from collections import namedtuple from collections import namedtuple
from dataclasses import dataclass from dataclasses import dataclass
import datetime
import json import json
import pathlib import pathlib
import re import re
from typing import List from typing import List
from unittest import expectedFailure
from unittest.mock import patch from unittest.mock import patch
from PIL import Image 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.base_model import BookWyrmModel
from bookwyrm.models.activitypub_mixin import ActivitypubMixin from bookwyrm.models.activitypub_mixin import ActivitypubMixin
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
from bookwyrm.utils import sealed_date
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay") @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(now.isoformat()), now)
self.assertEqual(instance.field_from_activity("bip"), None) 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, *_): def test_array_field(self, *_):
"""idk why it makes them strings but probably for a good reason""" """idk why it makes them strings but probably for a good reason"""
instance = fields.ArrayField(fields.IntegerField) instance = fields.ArrayField(fields.IntegerField)

View file

@ -35,6 +35,10 @@ class SealedDateTest(unittest.TestCase):
self.assertFalse(sealed.has_day) self.assertFalse(sealed.has_day)
self.assertFalse(sealed.has_month) 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): def test_parse_year_seal(self):
parsed = sealed_date.from_partial_isoformat("1995") parsed = sealed_date.from_partial_isoformat("1995")
expected = datetime.date(1995, 1, 1) expected = datetime.date(1995, 1, 1)

View file

@ -1,5 +1,4 @@
""" test for app action functionality """ """ test for app action functionality """
from unittest import expectedFailure
from unittest.mock import patch from unittest.mock import patch
import responses import responses
from responses import matchers from responses import matchers
@ -211,7 +210,6 @@ class EditBookViews(TestCase):
book = models.Edition.objects.get(title="New Title") book = models.Edition.objects.get(title="New Title")
self.assertEqual(book.parent_work.title, "New Title") self.assertEqual(book.parent_work.title, "New Title")
@expectedFailure # bookwyrm#3028
def test_published_date_timezone(self): def test_published_date_timezone(self):
"""user timezone does not affect publication year""" """user timezone does not affect publication year"""
# https://github.com/bookwyrm-social/bookwyrm/issues/3028 # https://github.com/bookwyrm-social/bookwyrm/issues/3028
@ -234,6 +232,75 @@ class EditBookViews(TestCase):
book = models.Edition.objects.get(title="January 1st test") book = models.Edition.objects.get(title="January 1st test")
self.assertEqual(book.edition_info, "2020") 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): def test_create_book_existing_work(self):
"""create an entirely new book and work""" """create an entirely new book and work"""
view = views.ConfirmEditBook.as_view() view = views.ConfirmEditBook.as_view()

View file

@ -7,6 +7,7 @@ import re
from typing import Any, Optional, Type, TypeVar, cast from typing import Any, Optional, Type, TypeVar, cast
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models
from django.forms import DateField from django.forms import DateField
from django.forms.widgets import SelectDateWidget from django.forms.widgets import SelectDateWidget
from django.utils import timezone 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 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): 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 @property
def has_day(self) -> bool: def has_day(self) -> bool:
@ -42,6 +44,8 @@ class SealedDate(datetime):
@classmethod @classmethod
def from_datetime(cls: Type[Sealed], dt: datetime) -> Sealed: def from_datetime(cls: Type[Sealed], dt: datetime) -> Sealed:
# pylint: disable=invalid-name # 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) return cls.combine(dt.date(), dt.time(), tzinfo=dt.tzinfo)
@classmethod @classmethod
@ -120,3 +124,86 @@ class SealedDateFormField(DateField):
year, month, day = date.year, date.month, date.day year, month, day = date.year, date.month, date.day
return SealedDate.from_date_parts(year, month, 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)