mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-11-23 01:51:08 +00:00
Merge pull request #3059 from dato/stable_dates_v2
Partial, stable dates with automatic precision field
This commit is contained in:
commit
3d9f339bd5
10 changed files with 700 additions and 5 deletions
54
bookwyrm/migrations/0187_partial_publication_dates.py
Normal file
54
bookwyrm/migrations/0187_partial_publication_dates.py
Normal file
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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"])
|
||||||
|
|
|
@ -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.partial_date import (
|
||||||
|
PartialDate,
|
||||||
|
PartialDateModel,
|
||||||
|
from_partial_isoformat,
|
||||||
|
)
|
||||||
from bookwyrm.settings import MEDIA_FULL_URL
|
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):
|
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)
|
||||||
|
@ -549,6 +553,37 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
|
||||||
return None
|
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):
|
class HtmlField(ActivitypubFieldMixin, models.TextField):
|
||||||
"""a text field for storing html"""
|
"""a text field for storing html"""
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{% spaceless %}
|
{% spaceless %}
|
||||||
|
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load humanize %}
|
{% load date_ext %}
|
||||||
|
|
||||||
{% firstof book.physical_format_detail book.get_physical_format_display as format %}
|
{% firstof book.physical_format_detail book.get_physical_format_display as format %}
|
||||||
{% firstof book.physical_format book.physical_format_detail as format_property %}
|
{% firstof book.physical_format book.physical_format_detail as format_property %}
|
||||||
|
@ -57,7 +57,7 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% 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 %}
|
{% if book.published_date and publisher %}
|
||||||
{% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %}
|
{% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %}
|
||||||
{% elif publisher %}
|
{% elif publisher %}
|
||||||
|
|
30
bookwyrm/templatetags/date_ext.py
Normal file
30
bookwyrm/templatetags/date_ext.py
Normal file
|
@ -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)
|
|
@ -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
|
||||||
|
@ -594,6 +596,36 @@ 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 = [
|
||||||
|
("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)
|
||||||
|
|
62
bookwyrm/tests/templatetags/test_date_ext.py
Normal file
62
bookwyrm/tests/templatetags/test_date_ext.py
Normal file
|
@ -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")
|
||||||
|
)
|
150
bookwyrm/tests/test_partial_date.py
Normal file
150
bookwyrm/tests/test_partial_date.py
Normal file
|
@ -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())
|
|
@ -8,6 +8,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test.client import RequestFactory
|
from django.test.client import RequestFactory
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookwyrm import forms, models, views
|
from bookwyrm import forms, models, views
|
||||||
from bookwyrm.views.books.edit_book import add_authors
|
from bookwyrm.views.books.edit_book import add_authors
|
||||||
|
@ -209,6 +210,97 @@ 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")
|
||||||
|
|
||||||
|
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):
|
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()
|
||||||
|
|
240
bookwyrm/utils/partial_date.py
Normal file
240
bookwyrm/utils/partial_date.py
Normal file
|
@ -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)
|
Loading…
Reference in a new issue