Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proof of concept for a loosely defined date field #2691

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions bookwyrm/forms/books.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
""" using django model forms """
import copy

from django import forms

from bookwyrm import models
Expand All @@ -16,6 +18,61 @@ class Meta:


class EditionForm(CustomForm):
def __init__(self, data=None, files=None, instance=None):
# very ugly for now, but essentially does:
# - when data is not None we handle a form submit.
# This is where we can swoop in and check which day/month/year form fields are set,
# set the precision, and set the default value of "1" to the missing day/month fields.
#
# - when data is None but instance is not None we handle a form render.
# This is where we intercept and change the value date value we send to the widget,
# if the precision is "year" we override the value for the date with "{year}-0-0",
# and same for precision "month". This will make the widget render the default "not selected"
# values for those fields. If the precision is "day" we just let the widget handle the
# date as an actual datetime.date

initial = {}
# when data present we handle a submitted form
if data:
data = copy.deepcopy(data)
first_published_date_precision = None
if data.get("first_published_date_day") != "":
first_published_date_precision = "day"
elif data.get("first_published_date_month") != "":
data["first_published_date_day"] = "1"
first_published_date_precision = "month"
elif data.get("first_published_date_year") != "":
data["first_published_date_day"] = "1"
data["first_published_date_month"] = "1"
first_published_date_precision = "year"
data["first_published_date_precision"] = first_published_date_precision

published_date_precision = None
if data.get("published_date_day") != "":
published_date_precision = "day"
elif data.get("published_date_month") != "":
data["published_date_day"] = "1"
published_date_precision = "month"
elif data.get("published_date_year") != "":
data["published_date_day"] = "1"
data["published_date_month"] = "1"
published_date_precision = "year"
data["published_date_precision"] = published_date_precision
# when data not present and instance is not None, handle displaying a form
elif instance is not None:
if instance.first_published_date_precision == "year":
initial["first_published_date"] = f"{instance.first_published_date.year}-0-0"
elif instance.first_published_date_precision == "month":
initial["first_published_date"] = f"{instance.first_published_date.year}-{instance.first_published_date.month}-0"

if instance.published_date_precision == "year":
initial["published_date"] = f"{instance.published_date.year}-0-0"
elif instance.published_date_precision == "month":
initial["published_date"] = f"{instance.published_date.year}-{instance.published_date.month}-0"

super().__init__(data=data, files=files, initial=initial, instance=instance)
self.data

class Meta:
model = models.Edition
fields = [
Expand All @@ -28,7 +85,11 @@ class Meta:
"subjects",
"publishers",
"first_published_date",
"first_published_date_precision",
"first_published_loose_date",
"published_date",
"published_date_precision",
"published_loose_date",
"cover",
"physical_format",
"physical_format_detail",
Expand Down Expand Up @@ -63,9 +124,15 @@ class Meta:
"first_published_date": SelectDateWidget(
attrs={"aria-describedby": "desc_first_published_date"}
),
"first_published_loose_date": SelectDateWidget(
attrs={"aria-describedby": "desc_first_published_loose_date"}
),
"published_date": SelectDateWidget(
attrs={"aria-describedby": "desc_published_date"}
),
"published_loose_date": SelectDateWidget(
attrs={"aria-describedby": "desc_published_loose_date"}
),
"cover": ClearableFileInputWithWarning(
attrs={"aria-describedby": "desc_cover"}
),
Expand Down
3 changes: 3 additions & 0 deletions bookwyrm/forms/widgets.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
""" using django model forms """
from django import forms
from bookwyrm.models import fields


class ArrayWidget(forms.widgets.TextInput):
Expand Down Expand Up @@ -28,6 +29,8 @@ class SelectDateWidget(forms.SelectDateWidget):

def get_context(self, name, value, attrs):
"""sets individual widgets"""
if isinstance(value, fields.LooseDate):
value = repr(value)
context = super().get_context(name, value, attrs)
date_context = {}
year_name = self.year_field % name
Expand Down
24 changes: 24 additions & 0 deletions bookwyrm/migrations/0174_auto_20230222_1854.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 3.2.18 on 2023-02-22 18:54

import bookwyrm.models.fields
from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('bookwyrm', '0173_default_user_auth_group_setting'),
]

operations = [
migrations.AddField(
model_name='book',
name='first_published_loose_date',
field=bookwyrm.models.fields.LooseDateField(blank=True, null=True),
),
migrations.AddField(
model_name='book',
name='published_loose_date',
field=bookwyrm.models.fields.LooseDateField(blank=True, null=True),
),
]
24 changes: 24 additions & 0 deletions bookwyrm/migrations/0175_auto_20230225_1849.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 3.2.16 on 2023-02-25 18:49

import bookwyrm.models.fields
from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('bookwyrm', '0174_auto_20230222_1854'),
]

operations = [
migrations.AddField(
model_name='book',
name='first_published_date_precision',
field=bookwyrm.models.fields.CharField(blank=True, max_length=5, null=True),
),
migrations.AddField(
model_name='book',
name='published_date_precision',
field=bookwyrm.models.fields.CharField(blank=True, max_length=5, null=True),
),
]
4 changes: 4 additions & 0 deletions bookwyrm/models/book.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,11 @@ class Book(BookDataModel):
upload_to="previews/covers/", blank=True, null=True
)
first_published_date = fields.DateTimeField(blank=True, null=True)
first_published_date_precision = fields.CharField(max_length=5, blank=True, null=True)
first_published_loose_date = fields.LooseDateField(blank=True, null=True)
published_date = fields.DateTimeField(blank=True, null=True)
published_date_precision = fields.CharField(max_length=5, blank=True, null=True)
published_loose_date = fields.LooseDateField(blank=True, null=True)

objects = InheritanceManager()
field_tracker = FieldTracker(fields=["authors", "title", "subtitle", "cover"])
Expand Down
94 changes: 94 additions & 0 deletions bookwyrm/models/fields.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
""" activitypub-aware django model fields """
from dataclasses import MISSING
import datetime
import re
from uuid import uuid4
from urllib.parse import urljoin
Expand Down Expand Up @@ -491,6 +492,99 @@ def field_from_activity(self, value):
except (ParserError, TypeError):
return None

class LooseDate(object):
def __init__(self, year, month=None, day=None):
self.year = year
self.month = month
self.day = day

self.valid()

def valid(self):
if self.month is None and self.day is not None:
raise ValueError("Month cannot be missing when day is specified")
m = self.month
if m is None:
m = 1
d = self.day
if d is None:
d = 1

# ensure it's a valid date, this raises ValueError if not
datetime.date(self.year, m, d)

def __str__(self):
repr = f"{self.year:04d}"
if self.month is not None:
repr += f"-{self.month:02d}"
if self.day is not None:
repr += f"-{self.day:02d}"
return repr

def __repr__(self):
month = self.month or 0
day = self.day or 0
return f"{self.year:04d}-{month:02d}-{day:02d}"

# TODO: __eq__
# TODO: __ne__
# TODO: __lt__

def __len__(self):
return len(repr(self))

class LooseDateField(ActivitypubFieldMixin, models.CharField):

def __init__(self, *args, **kwargs):
kwargs["max_length"] = 10
super(LooseDateField, self).__init__(*args, **kwargs)

def deconstruct(self):
name, path, args, kwargs = super(LooseDateField, self).deconstruct()
del kwargs["max_length"]
return name, path, args, kwargs

def to_python(self, value):
print("LooseDate.to_python", repr(value))
if isinstance(value, LooseDate):
print("instance is LooseDate, returning value")
return value
print("Instance is not LooseDate, returning value after from_db_value")
return self.from_db_value(value)

def from_db_value(self, value, *args, **kwargs):
print("LooseDate.from_db_value", repr(value), repr(args), repr(kwargs))
if not value:
return None

p = re.compile(r"^(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})$")
m = p.search(value)
if not m:
raise ValidationError(f"invalid loose date: {value}")
year = int(m.group("year"))
month = int(m.group("month"))
if month == 0:
month = None
day = int(m.group("day"))
if day == 0:
day = None
return LooseDate(year=year, month=month, day=day)

def get_prep_value(self, value):
if not value:
return None

if isinstance(value, LooseDate):
return repr(value)

raise ValidationError(f"wrong type for LooseDateField, got {type(value)}")

def field_to_activity(self, value):
pass

def field_from_activity(self, value):
pass


class HtmlField(ActivitypubFieldMixin, models.TextField):
"""a text field for storing html"""
Expand Down
17 changes: 17 additions & 0 deletions bookwyrm/templates/book/edit/edit_book_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,14 @@ <h2 class="title is-4">
{% include 'snippets/form_errors.html' with errors_list=form.first_published_date.errors id="desc_first_published_date" %}
</div>

<div class="field">
<label class="label" for="id_first_published_loose_date">
{% trans "First published date (loose):" %}
</label>
{{ form.first_published_loose_date }}
{% include 'snippets/form_errors.html' with errors_list=form.first_published_loose_date.errors id="desc_first_published_loose_date" %}
</div>

<div class="field">
<label class="label" for="id_published_date">
{% trans "Published date:" %}
Expand All @@ -167,6 +175,15 @@ <h2 class="title is-4">

{% include 'snippets/form_errors.html' with errors_list=form.published_date.errors id="desc_published_date" %}
</div>

<div class="field">
<label class="label" for="id_published_loose_date">
{% trans "Published date (loose):" %}
</label>
{{ form.published_loose_date }}

{% include 'snippets/form_errors.html' with errors_list=form.published_loose_date.errors id="desc_published_loose_date" %}
</div>
</div>
</section>

Expand Down
31 changes: 31 additions & 0 deletions bookwyrm/tests/models/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,37 @@ def test_datetime_field(self, *_):
self.assertEqual(instance.field_from_activity(now.isoformat()), now)
self.assertEqual(instance.field_from_activity("bip"), None)

import pytest
@pytest.mark.focus
def test_loose_date(self, *_):
instance = fields.LooseDate(2023)
self.assertEqual(instance.year, 2023)
self.assertIsNone(instance.month)
self.assertIsNone(instance.day)
self.assertEqual(repr(instance), "2023")

instance = fields.LooseDate(2023, 2)
self.assertEqual(instance.year, 2023)
self.assertEqual(instance.month, 2)
self.assertIsNone(instance.day)
self.assertEqual(repr(instance), "2023-02")

instance = fields.LooseDate(2023, 2, 9)
self.assertEqual(instance.year, 2023)
self.assertEqual(instance.month, 2)
self.assertEqual(instance.day, 9)
self.assertEqual(repr(instance), "2023-02-09")

self.assertRaises(ValueError, fields.LooseDate, year=2023, day=15)
self.assertRaises(ValueError, fields.LooseDate, year=2023, month=1, day=40)
self.assertRaises(ValueError, fields.LooseDate, year=2023, month=2, day=30)
self.assertRaises(ValueError, fields.LooseDate, year=2023, month=6, day=31)
self.assertRaises(ValueError, fields.LooseDate, year=2023, month=1, day=0)
self.assertRaises(ValueError, fields.LooseDate, year=2023, month=1, day=-1)
self.assertRaises(ValueError, fields.LooseDate, year=2023, month=0, day=1)
self.assertRaises(ValueError, fields.LooseDate, year=2023, month=-1, day=1)
self.assertRaises(ValueError, fields.LooseDate, year=2023, month=13, day=1)

def test_array_field(self, *_):
"""idk why it makes them strings but probably for a good reason"""
instance = fields.ArrayField(fields.IntegerField)
Expand Down