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/book_search.py b/bookwyrm/book_search.py
index ceb228f40..3012482fd 100644
--- a/bookwyrm/book_search.py
+++ b/bookwyrm/book_search.py
@@ -137,7 +137,7 @@ def search_title_author(
# filter out multiple editions of the same work
list_results = []
- for work_id in set(editions_of_work[:30]):
+ for work_id in editions_of_work[:30]:
result = (
results.filter(parent_work=work_id)
.order_by("-rank", "-edition_rank")
diff --git a/bookwyrm/forms/books.py b/bookwyrm/forms/books.py
index 4885dc063..f73ce3f5a 100644
--- a/bookwyrm/forms/books.py
+++ b/bookwyrm/forms/books.py
@@ -1,8 +1,9 @@
""" using django model forms """
from django import forms
+from file_resubmit.widgets import ResubmitImageWidget
+
from bookwyrm import models
-from bookwyrm.models.fields import ClearableFileInputWithWarning
from .custom_form import CustomForm
from .widgets import ArrayWidget, SelectDateWidget, Select
@@ -70,9 +71,7 @@ class EditionForm(CustomForm):
"published_date": SelectDateWidget(
attrs={"aria-describedby": "desc_published_date"}
),
- "cover": ClearableFileInputWithWarning(
- attrs={"aria-describedby": "desc_cover"}
- ),
+ "cover": ResubmitImageWidget(attrs={"aria-describedby": "desc_cover"}),
"physical_format": Select(
attrs={"aria-describedby": "desc_physical_format"}
),
diff --git a/bookwyrm/migrations/0186_invite_request_notification.py b/bookwyrm/migrations/0186_invite_request_notification.py
new file mode 100644
index 000000000..3680b1de7
--- /dev/null
+++ b/bookwyrm/migrations/0186_invite_request_notification.py
@@ -0,0 +1,48 @@
+# Generated by Django 3.2.20 on 2023-11-14 10:02
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0185_alter_notification_notification_type"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="notification",
+ name="related_invite_requests",
+ field=models.ManyToManyField(to="bookwyrm.InviteRequest"),
+ ),
+ migrations.AlterField(
+ model_name="notification",
+ name="notification_type",
+ field=models.CharField(
+ choices=[
+ ("FAVORITE", "Favorite"),
+ ("BOOST", "Boost"),
+ ("REPLY", "Reply"),
+ ("MENTION", "Mention"),
+ ("TAG", "Tag"),
+ ("FOLLOW", "Follow"),
+ ("FOLLOW_REQUEST", "Follow Request"),
+ ("IMPORT", "Import"),
+ ("ADD", "Add"),
+ ("REPORT", "Report"),
+ ("LINK_DOMAIN", "Link Domain"),
+ ("INVITE_REQUEST", "Invite Request"),
+ ("INVITE", "Invite"),
+ ("ACCEPT", "Accept"),
+ ("JOIN", "Join"),
+ ("LEAVE", "Leave"),
+ ("REMOVE", "Remove"),
+ ("GROUP_PRIVACY", "Group Privacy"),
+ ("GROUP_NAME", "Group Name"),
+ ("GROUP_DESCRIPTION", "Group Description"),
+ ("MOVE", "Move"),
+ ],
+ max_length=255,
+ ),
+ ),
+ ]
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/migrations/0188_theme_loads.py b/bookwyrm/migrations/0188_theme_loads.py
new file mode 100644
index 000000000..846aaf549
--- /dev/null
+++ b/bookwyrm/migrations/0188_theme_loads.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.23 on 2023-11-20 18:02
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0187_partial_publication_dates"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="theme",
+ name="loads",
+ field=models.BooleanField(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/models/notification.py b/bookwyrm/models/notification.py
index 46b88f5e5..d056c05b3 100644
--- a/bookwyrm/models/notification.py
+++ b/bookwyrm/models/notification.py
@@ -4,6 +4,7 @@ from django.dispatch import receiver
from .base_model import BookWyrmModel
from . import Boost, Favorite, GroupMemberInvitation, ImportJob, LinkDomain
from . import ListItem, Report, Status, User, UserFollowRequest
+from .site import InviteRequest
class NotificationType(models.TextChoices):
@@ -29,6 +30,7 @@ class NotificationType(models.TextChoices):
# Admin
REPORT = "REPORT"
LINK_DOMAIN = "LINK_DOMAIN"
+ INVITE_REQUEST = "INVITE_REQUEST"
# Groups
INVITE = "INVITE"
@@ -64,8 +66,9 @@ class Notification(BookWyrmModel):
related_list_items = models.ManyToManyField(
"ListItem", symmetrical=False, related_name="notifications"
)
- related_reports = models.ManyToManyField("Report", symmetrical=False)
- related_link_domains = models.ManyToManyField("LinkDomain", symmetrical=False)
+ related_reports = models.ManyToManyField("Report")
+ related_link_domains = models.ManyToManyField("LinkDomain")
+ related_invite_requests = models.ManyToManyField("InviteRequest")
@classmethod
@transaction.atomic
@@ -233,8 +236,7 @@ def notify_admins_on_report(sender, instance, created, *args, **kwargs):
return
# moderators and superusers should be notified
- admins = User.admins()
- for admin in admins:
+ for admin in User.admins():
notification, _ = Notification.objects.get_or_create(
user=admin,
notification_type=NotificationType.REPORT,
@@ -253,8 +255,7 @@ def notify_admins_on_link_domain(sender, instance, created, *args, **kwargs):
return
# moderators and superusers should be notified
- admins = User.admins()
- for admin in admins:
+ for admin in User.admins():
notification, _ = Notification.objects.get_or_create(
user=admin,
notification_type=NotificationType.LINK_DOMAIN,
@@ -263,6 +264,24 @@ def notify_admins_on_link_domain(sender, instance, created, *args, **kwargs):
notification.related_link_domains.add(instance)
+@receiver(models.signals.post_save, sender=InviteRequest)
+@transaction.atomic
+# pylint: disable=unused-argument
+def notify_admins_on_invite_request(sender, instance, created, *args, **kwargs):
+ """need to handle a new invite request"""
+ if not created:
+ return
+
+ # moderators and superusers should be notified
+ for admin in User.admins():
+ notification, _ = Notification.objects.get_or_create(
+ user=admin,
+ notification_type=NotificationType.INVITE_REQUEST,
+ read=False,
+ )
+ notification.related_invite_requests.add(instance)
+
+
@receiver(models.signals.post_save, sender=GroupMemberInvitation)
# pylint: disable=unused-argument
def notify_user_on_group_invite(sender, instance, *args, **kwargs):
diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py
index a27c4b70d..b962d597b 100644
--- a/bookwyrm/models/site.py
+++ b/bookwyrm/models/site.py
@@ -149,6 +149,7 @@ class Theme(SiteModel):
created_date = models.DateTimeField(auto_now_add=True)
name = models.CharField(max_length=50, unique=True)
path = models.CharField(max_length=50, unique=True)
+ loads = models.BooleanField(null=True, blank=True)
def __str__(self):
# pylint: disable=invalid-str-returned
diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py
index dcc6107e5..55702b167 100644
--- a/bookwyrm/settings.py
+++ b/bookwyrm/settings.py
@@ -100,6 +100,7 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
"django.contrib.humanize",
"oauth2_provider",
+ "file_resubmit",
"sass_processor",
"bookwyrm",
"celery",
@@ -243,7 +244,11 @@ if env.bool("USE_DUMMY_CACHE", False):
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
- }
+ },
+ "file_resubmit": {
+ "BACKEND": "django.core.cache.backends.dummy.DummyCache",
+ "LOCATION": "/tmp/file_resubmit_tests/",
+ },
}
else:
CACHES = {
@@ -253,7 +258,11 @@ else:
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
- }
+ },
+ "file_resubmit": {
+ "BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
+ "LOCATION": "/tmp/file_resubmit/",
+ },
}
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
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/403.html b/bookwyrm/templates/403.html
new file mode 100644
index 000000000..0b78bc6b8
--- /dev/null
+++ b/bookwyrm/templates/403.html
@@ -0,0 +1,20 @@
+{% extends 'layout.html' %}
+{% load i18n %}
+{% load utilities %}
+
+{% block title %}{% trans "Oh no!" %}{% endblock %}
+
+{% block content %}
+
+
{% trans "Permission Denied" %}
+
+ {% blocktrans trimmed with level=request.user|get_user_permission %}
+ You do not have permission to view this page or perform this action. Your user permission level is {{ level }}
.
+ {% endblocktrans %}
+
+
{% trans "If you think you should have access, please speak to your BookWyrm server administrator." %}
+
+
+
+{% endblock %}
+
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/templates/notifications/item.html b/bookwyrm/templates/notifications/item.html
index 7e7f0da27..a69790f52 100644
--- a/bookwyrm/templates/notifications/item.html
+++ b/bookwyrm/templates/notifications/item.html
@@ -21,6 +21,8 @@
{% include 'notifications/items/report.html' %}
{% elif notification.notification_type == 'LINK_DOMAIN' %}
{% include 'notifications/items/link_domain.html' %}
+{% elif notification.notification_type == 'INVITE_REQUEST' %}
+ {% include 'notifications/items/invite_request.html' %}
{% elif notification.notification_type == 'INVITE' %}
{% include 'notifications/items/invite.html' %}
{% elif notification.notification_type == 'ACCEPT' %}
diff --git a/bookwyrm/templates/notifications/items/invite_request.html b/bookwyrm/templates/notifications/items/invite_request.html
new file mode 100644
index 000000000..acc08d5d0
--- /dev/null
+++ b/bookwyrm/templates/notifications/items/invite_request.html
@@ -0,0 +1,20 @@
+{% extends 'notifications/items/layout.html' %}
+{% load humanize %}
+{% load i18n %}
+
+{% block primary_link %}{% spaceless %}
+{% url 'settings-invite-requests' %}
+{% endspaceless %}{% endblock %}
+
+{% block icon %}
+
+{% endblock %}
+
+{% block description %}
+ {% url 'settings-invite-requests' as path %}
+ {% blocktrans trimmed count counter=notification.related_invite_requests.count with display_count=notification.related_invite_requests.count|intcomma %}
+ New invite request awaiting response
+ {% plural %}
+ {{ display_count }} new invite requests awaiting response
+ {% endblocktrans %}
+{% endblock %}
diff --git a/bookwyrm/templates/settings/themes.html b/bookwyrm/templates/settings/themes.html
index d27aeb0ce..c077fa5e3 100644
--- a/bookwyrm/templates/settings/themes.html
+++ b/bookwyrm/templates/settings/themes.html
@@ -12,6 +12,15 @@
{% endblock %}
{% block panel %}
+{% if broken_theme %}
+
+
+
+ {% trans "One of your themes appears to be broken. Selecting this theme will make the application unusable." %}
+
+
+{% endif %}
+
{% if success %}
@@ -98,6 +107,9 @@
{% trans "Actions" %}
|
+
+ {% trans "Status" %}
+ |
{% for theme in themes %}
@@ -112,6 +124,37 @@
+
+ {% if theme.loads is None %}
+
+
+
+ {% elif not theme.loads %}
+
+
+
+
+ {% trans "Broken theme" %}
+
+
+
+ {% else %}
+
+
+
+
+ {% trans "Loaded successfully" %}
+
+
+
+ {% endif %}
+ |
{% endfor %}
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 "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" %}
-
- {% endif %}
- {% if user.is_active or user.deactivation_reason == "pending" %}
-
- {% else %}
-
+ {% if not user.is_active and user.deactivation_reason == "pending" %}
+
+ {% endif %}
+ {% if user.is_active or user.deactivation_reason == "pending" %}
+
+ {% else %}
+
+ {% 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" %}
+
{% 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 %}
-
- {% endif %}
-
-
{% endif %}
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/templatetags/utilities.py b/bookwyrm/templatetags/utilities.py
index 42e67990f..76e5d988f 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,16 @@ def id_to_username(user_id):
value = f"{name}@{domain}"
return value
+
+
+@register.filter(name="get_user_permission")
+def get_user_permission(user):
+ """given a user, return their permission level"""
+
+ return user.groups.first() or "User"
+
+
+@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/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/models/test_notification.py b/bookwyrm/tests/models/test_notification.py
index 1c412e1b4..352b7631d 100644
--- a/bookwyrm/tests/models/test_notification.py
+++ b/bookwyrm/tests/models/test_notification.py
@@ -192,3 +192,90 @@ class Notification(TestCase):
notification_type=models.NotificationType.FAVORITE,
)
self.assertFalse(models.Notification.objects.exists())
+
+
+class NotifyInviteRequest(TestCase):
+ """let admins know of invite requests"""
+
+ def setUp(self):
+ """ensure there is one admin"""
+ with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
+ "bookwyrm.activitystreams.populate_stream_task.delay"
+ ), patch("bookwyrm.lists_stream.populate_lists_task.delay"):
+ self.local_user = models.User.objects.create_user(
+ "mouse@local.com",
+ "mouse@mouse.mouse",
+ "password",
+ local=True,
+ localname="mouse",
+ is_superuser=True,
+ )
+
+ def test_invite_request_triggers_notification(self):
+ """requesting an invite notifies the admin"""
+ admin = models.User.objects.filter(is_superuser=True).first()
+ request = models.InviteRequest.objects.create(email="user@example.com")
+
+ self.assertEqual(models.Notification.objects.count(), 1)
+
+ notification = models.Notification.objects.first()
+ self.assertEqual(notification.user, admin)
+ self.assertEqual(
+ notification.notification_type, models.NotificationType.INVITE_REQUEST
+ )
+ self.assertEqual(notification.related_invite_requests.count(), 1)
+ self.assertEqual(notification.related_invite_requests.first(), request)
+
+ def test_notify_only_created(self):
+ """updating an invite request does not trigger a notification"""
+ request = models.InviteRequest.objects.create(email="user@example.com")
+ notification = models.Notification.objects.first()
+
+ notification.delete()
+ self.assertEqual(models.Notification.objects.count(), 0)
+
+ request.ignored = True
+ request.save()
+ self.assertEqual(models.Notification.objects.count(), 0)
+
+ def test_notify_grouping(self):
+ """invites group into the same notification, until read"""
+ requests = [
+ models.InviteRequest.objects.create(email="user1@example.com"),
+ models.InviteRequest.objects.create(email="user2@example.com"),
+ ]
+ self.assertEqual(models.Notification.objects.count(), 1)
+
+ notification = models.Notification.objects.first()
+ self.assertEqual(notification.related_invite_requests.count(), 2)
+ self.assertCountEqual(notification.related_invite_requests.all(), requests)
+
+ notification.read = True
+ notification.save()
+
+ request = models.InviteRequest.objects.create(email="user3@example.com")
+ _, notification = models.Notification.objects.all()
+
+ self.assertEqual(models.Notification.objects.count(), 2)
+ self.assertEqual(notification.related_invite_requests.count(), 1)
+ self.assertEqual(notification.related_invite_requests.first(), request)
+
+ def test_notify_multiple_admins(self):
+ """all admins are notified"""
+ with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
+ "bookwyrm.activitystreams.populate_stream_task.delay"
+ ), patch("bookwyrm.lists_stream.populate_lists_task.delay"):
+ self.local_user = models.User.objects.create_user(
+ "admin@local.com",
+ "admin@example.com",
+ "password",
+ local=True,
+ localname="root",
+ is_superuser=True,
+ )
+ models.InviteRequest.objects.create(email="user@example.com")
+ admins = models.User.objects.filter(is_superuser=True).all()
+ notifications = models.Notification.objects.all()
+
+ self.assertEqual(len(notifications), 2)
+ self.assertCountEqual([notif.user for notif in notifications], admins)
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_book_search.py b/bookwyrm/tests/test_book_search.py
index db6ba8353..ad954f585 100644
--- a/bookwyrm/tests/test_book_search.py
+++ b/bookwyrm/tests/test_book_search.py
@@ -26,10 +26,10 @@ class BookSearch(TestCase):
parent_work=self.work,
isbn_10="1111111111",
openlibrary_key="hello",
+ pages=150,
)
-
self.third_edition = models.Edition.objects.create(
- title="Edition with annoying ISBN",
+ title="Another Edition with annoying ISBN",
parent_work=self.work,
isbn_10="022222222X",
)
@@ -76,16 +76,21 @@ class BookSearch(TestCase):
def test_search_title_author(self):
"""search by unique identifiers"""
- results = book_search.search_title_author("Another", min_confidence=0)
+ results = book_search.search_title_author("annoying", min_confidence=0)
self.assertEqual(len(results), 1)
- self.assertEqual(results[0], self.second_edition)
+ self.assertEqual(results[0], self.third_edition)
def test_search_title_author_return_first(self):
- """search by unique identifiers"""
- results = book_search.search_title_author(
+ """sorts by edition rank"""
+ result = book_search.search_title_author(
"Another", min_confidence=0, return_first=True
)
- self.assertEqual(results, self.second_edition)
+ self.assertEqual(result, self.second_edition) # highest edition rank
+
+ def test_search_title_author_one_edition_per_work(self):
+ """at most one edition per work"""
+ results = book_search.search_title_author("Edition", 0)
+ self.assertEqual(results, [self.first_edition]) # highest edition rank
def test_format_search_result(self):
"""format a search result"""
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/admin/test_themes.py b/bookwyrm/tests/views/admin/test_themes.py
index bc6377681..296cd4d8d 100644
--- a/bookwyrm/tests/views/admin/test_themes.py
+++ b/bookwyrm/tests/views/admin/test_themes.py
@@ -86,3 +86,25 @@ class AdminThemesViews(TestCase):
with self.assertRaises(PermissionDenied):
view(request)
+
+ def test_test_theme(self):
+ """Testing testing testing test"""
+ theme = models.Theme.objects.first()
+ self.assertIsNone(theme.loads)
+ request = self.factory.post("")
+ request.user = self.local_user
+
+ views.test_theme(request, theme.id)
+ theme.refresh_from_db()
+ self.assertTrue(theme.loads)
+
+ def test_test_theme_broken(self):
+ """Testing test for testing when it's a bad theme"""
+ theme = models.Theme.objects.create(name="bad theme", path="dsf/sdf/sdf.sdf")
+ self.assertIsNone(theme.loads)
+ request = self.factory.post("")
+ request.user = self.local_user
+
+ views.test_theme(request, theme.id)
+ theme.refresh_from_db()
+ self.assertIs(False, theme.loads)
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/urls.py b/bookwyrm/urls.py
index 711aa2d86..934b504ff 100644
--- a/bookwyrm/urls.py
+++ b/bookwyrm/urls.py
@@ -109,6 +109,11 @@ urlpatterns = [
views.delete_theme,
name="settings-themes-delete",
),
+ re_path(
+ r"^settings/themes/(?P
\d+)/test/?$",
+ views.test_theme,
+ name="settings-themes-test",
+ ),
re_path(
r"^settings/announcements/?$",
views.Announcements.as_view(),
@@ -793,3 +798,6 @@ urlpatterns.extend(staticfiles_urlpatterns())
# pylint: disable=invalid-name
handler500 = "bookwyrm.views.server_error"
+
+# pylint: disable=invalid-name
+handler403 = "bookwyrm.views.permission_denied"
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)
diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py
index 2d2e97f52..7076eb3ed 100644
--- a/bookwyrm/views/__init__.py
+++ b/bookwyrm/views/__init__.py
@@ -30,7 +30,7 @@ from .admin.reports import (
moderator_delete_user,
)
from .admin.site import Site, Registration, RegistrationLimited
-from .admin.themes import Themes, delete_theme
+from .admin.themes import Themes, delete_theme, test_theme
from .admin.user_admin import UserAdmin, UserAdminList, ActivateUserAdmin
# user preferences
@@ -167,3 +167,4 @@ from .annual_summary import (
summary_revoke_key,
)
from .server_error import server_error
+from .permission_denied import permission_denied
diff --git a/bookwyrm/views/admin/themes.py b/bookwyrm/views/admin/themes.py
index 5658d243a..284a90833 100644
--- a/bookwyrm/views/admin/themes.py
+++ b/bookwyrm/views/admin/themes.py
@@ -6,6 +6,8 @@ from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.http import require_POST
+from sass_processor.processor import sass_processor
+
from bookwyrm import forms, models
@@ -40,6 +42,7 @@ class Themes(View):
def get_view_data():
"""data for view"""
return {
+ "broken_theme": models.Theme.objects.filter(loads=False).exists(),
"themes": models.Theme.objects.all(),
"theme_form": forms.ThemeForm(),
}
@@ -52,3 +55,20 @@ def delete_theme(request, theme_id):
"""Remove a theme"""
get_object_or_404(models.Theme, id=theme_id).delete()
return redirect("settings-themes")
+
+
+@require_POST
+@permission_required("bookwyrm.system_administration", raise_exception=True)
+# pylint: disable=unused-argument
+def test_theme(request, theme_id):
+ """Remove a theme"""
+ theme = get_object_or_404(models.Theme, id=theme_id)
+
+ try:
+ sass_processor(theme.path)
+ theme.loads = True
+ except Exception: # pylint: disable=broad-except
+ theme.loads = False
+
+ theme.save()
+ return redirect("settings-themes")
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/list/lists.py b/bookwyrm/views/list/lists.py
index 2514fad58..52b65357a 100644
--- a/bookwyrm/views/list/lists.py
+++ b/bookwyrm/views/list/lists.py
@@ -59,7 +59,7 @@ class SavedLists(View):
data = {
"lists": paginated.get_page(request.GET.get("page")),
"list_form": forms.ListForm(),
- "path": "/list",
+ "path": "/list/saved",
}
return TemplateResponse(request, "lists/lists.html", data)
diff --git a/bookwyrm/views/permission_denied.py b/bookwyrm/views/permission_denied.py
new file mode 100644
index 000000000..9e62b0933
--- /dev/null
+++ b/bookwyrm/views/permission_denied.py
@@ -0,0 +1,15 @@
+"""custom 403 handler to enable context processors"""
+
+from django.http import HttpResponse
+from django.template.response import TemplateResponse
+
+from .helpers import is_api_request
+
+
+def permission_denied(request, exception): # pylint: disable=unused-argument
+ """permission denied page"""
+
+ if request.method == "POST" or is_api_request(request):
+ return HttpResponse(status=403)
+
+ return TemplateResponse(request, "403.html")
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
diff --git a/requirements.txt b/requirements.txt
index 0bb4028a8..946077ef4 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,9 +1,10 @@
-aiohttp==3.8.5
+aiohttp==3.9.0
bleach==5.0.1
celery==5.2.7
colorthief==0.2.1
Django==3.2.23
django-celery-beat==2.4.0
+bw-file-resubmit==0.6.0rc2
django-compressor==4.3.1
django-imagekit==4.1.0
django-model-utils==4.3.1