Merge branch 'main' into user-migration

This commit is contained in:
Mouse Reeve 2023-11-19 19:18:22 -08:00 committed by GitHub
commit d94b27b723
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1000 additions and 83 deletions

View file

@ -396,7 +396,7 @@ def resolve_remote_id(
def get_representative(): def get_representative():
"""Get or create an actor representing the instance """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}" username = f"{INSTANCE_ACTOR_USERNAME}@{DOMAIN}"
email = "bookwyrm@localhost" email = "bookwyrm@localhost"
try: try:

View file

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

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

View file

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

View file

@ -20,6 +20,11 @@ from markdown import markdown
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.connectors import get_image from bookwyrm.connectors import get_image
from bookwyrm.utils.sanitizer import clean from bookwyrm.utils.sanitizer import clean
from bookwyrm.utils.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"""

View file

@ -12,6 +12,7 @@ from . import (
LinkDomain, LinkDomain,
) )
from . import ListItem, Report, Status, User, UserFollowRequest from . import ListItem, Report, Status, User, UserFollowRequest
from .site import InviteRequest
class NotificationType(models.TextChoices): class NotificationType(models.TextChoices):
@ -39,6 +40,7 @@ class NotificationType(models.TextChoices):
# Admin # Admin
REPORT = "REPORT" REPORT = "REPORT"
LINK_DOMAIN = "LINK_DOMAIN" LINK_DOMAIN = "LINK_DOMAIN"
INVITE_REQUEST = "INVITE_REQUEST"
# Groups # Groups
INVITE = "INVITE" INVITE = "INVITE"
@ -77,8 +79,9 @@ class Notification(BookWyrmModel):
related_list_items = models.ManyToManyField( related_list_items = models.ManyToManyField(
"ListItem", symmetrical=False, related_name="notifications" "ListItem", symmetrical=False, related_name="notifications"
) )
related_reports = models.ManyToManyField("Report", symmetrical=False) related_reports = models.ManyToManyField("Report")
related_link_domains = models.ManyToManyField("LinkDomain", symmetrical=False) related_link_domains = models.ManyToManyField("LinkDomain")
related_invite_requests = models.ManyToManyField("InviteRequest")
@classmethod @classmethod
@transaction.atomic @transaction.atomic
@ -276,8 +279,7 @@ def notify_admins_on_report(sender, instance, created, *args, **kwargs):
return return
# moderators and superusers should be notified # moderators and superusers should be notified
admins = User.admins() for admin in User.admins():
for admin in admins:
notification, _ = Notification.objects.get_or_create( notification, _ = Notification.objects.get_or_create(
user=admin, user=admin,
notification_type=NotificationType.REPORT, notification_type=NotificationType.REPORT,
@ -296,8 +298,7 @@ def notify_admins_on_link_domain(sender, instance, created, *args, **kwargs):
return return
# moderators and superusers should be notified # moderators and superusers should be notified
admins = User.admins() for admin in User.admins():
for admin in admins:
notification, _ = Notification.objects.get_or_create( notification, _ = Notification.objects.get_or_create(
user=admin, user=admin,
notification_type=NotificationType.LINK_DOMAIN, notification_type=NotificationType.LINK_DOMAIN,
@ -306,6 +307,24 @@ def notify_admins_on_link_domain(sender, instance, created, *args, **kwargs):
notification.related_link_domains.add(instance) 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) @receiver(models.signals.post_save, sender=GroupMemberInvitation)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def notify_user_on_group_invite(sender, instance, *args, **kwargs): def notify_user_on_group_invite(sender, instance, *args, **kwargs):

View file

@ -8,6 +8,7 @@ from opentelemetry import trace
from bookwyrm import models from bookwyrm import models
from bookwyrm.redis_store import RedisStore, r from bookwyrm.redis_store import RedisStore, r
from bookwyrm.settings import INSTANCE_ACTOR_USERNAME
from bookwyrm.tasks import app, SUGGESTED_USERS from bookwyrm.tasks import app, SUGGESTED_USERS
from bookwyrm.telemetry import open_telemetry from bookwyrm.telemetry import open_telemetry
@ -98,9 +99,15 @@ class SuggestedUsers(RedisStore):
for (pk, score) in values for (pk, score) in values
] ]
# annotate users with mutuals and shared book counts # annotate users with mutuals and shared book counts
users = models.User.objects.filter( users = (
is_active=True, bookwyrm_user=True, id__in=[pk for (pk, _) in values] models.User.objects.filter(
).annotate(mutuals=Case(*annotations, output_field=IntegerField(), default=0)) 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: if local:
users = users.filter(local=True) users = users.filter(local=True)
return users.order_by("-mutuals")[:5] return users.order_by("-mutuals")[:5]

View file

@ -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 %}

View file

@ -25,6 +25,8 @@
{% include 'notifications/items/report.html' %} {% include 'notifications/items/report.html' %}
{% elif notification.notification_type == 'LINK_DOMAIN' %} {% elif notification.notification_type == 'LINK_DOMAIN' %}
{% include 'notifications/items/link_domain.html' %} {% include 'notifications/items/link_domain.html' %}
{% elif notification.notification_type == 'INVITE_REQUEST' %}
{% include 'notifications/items/invite_request.html' %}
{% elif notification.notification_type == 'INVITE' %} {% elif notification.notification_type == 'INVITE' %}
{% include 'notifications/items/invite.html' %} {% include 'notifications/items/invite.html' %}
{% elif notification.notification_type == 'ACCEPT' %} {% elif notification.notification_type == 'ACCEPT' %}

View file

@ -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 %}
<span class="icon icon-envelope"></span>
{% 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 <a href="{{ path }}">invite request</a> awaiting response
{% plural %}
{{ display_count }} new <a href="{{ path }}">invite requests</a> awaiting response
{% endblocktrans %}
{% endblock %}

View file

@ -1,6 +1,7 @@
{% load i18n %} {% load i18n %}
{% load markdown %} {% load markdown %}
{% load humanize %} {% load humanize %}
{% load utilities %}
<div class="block columns"> <div class="block columns">
<div class="column is-flex is-flex-direction-column"> <div class="column is-flex is-flex-direction-column">
@ -13,7 +14,17 @@
</div> </div>
{% endif %} {% endif %}
{% if user.localname|is_instance_admin %}
<div class="message is-warning">
<div class="message-body">
{% trans "This account is the instance actor for signing HTTP requests." %}
</div>
</div>
{% else %}
<p class="mt-2"><a href="{{ user.local_path }}">{% trans "View user profile" %}</a></p> <p class="mt-2"><a href="{{ user.local_path }}">{% trans "View user profile" %}</a></p>
{% endif %}
{% url 'settings-user' user.id as url %} {% url 'settings-user' user.id as url %}
{% if not request.path == url %} {% if not request.path == url %}
<p class="mt-2"><a href="{{ url }}">{% trans "Go to user admin" %}</a></p> <p class="mt-2"><a href="{{ url }}">{% trans "Go to user admin" %}</a></p>

View file

@ -1,4 +1,5 @@
{% load i18n %} {% load i18n %}
{% load utilities %}
<div class="block content"> <div class="block content">
{% if not user.is_active and user.deactivation_reason == "self_deletion" or user.deactivation_reason == "moderator_deletion" %} {% if not user.is_active and user.deactivation_reason == "self_deletion" or user.deactivation_reason == "moderator_deletion" %}
<div class="notification is-danger"> <div class="notification is-danger">
@ -7,77 +8,90 @@
{% else %} {% else %}
<h3>{% trans "User Actions" %}</h3> <h3>{% trans "User Actions" %}</h3>
<div class="box"> {% if user.localname|is_instance_admin %}
<div class="is-flex"> <div class="box">
{% if user.is_active %} <div class="message is-warning">
<p class="mr-1"> <div class="message-header">
<a class="button" href="{% url 'direct-messages-user' user.username %}">{% trans "Send direct message" %}</a> <p>{% trans "This is the instance admin actor" %}</p>
</p> </div>
{% endif %} <div class="message-body">
<p>{% 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." %}</p>
<p>{% trans "This account is not discoverable by ordinary users and does not have a profile page." %}</p>
</div>
</div>
</div>
{% else %}
<div class="box">
<div class="is-flex">
{% if user.is_active %}
<p class="mr-1">
<a class="button" href="{% url 'direct-messages-user' user.username %}">{% trans "Send direct message" %}</a>
</p>
{% endif %}
{% if not user.is_active and user.deactivation_reason == "pending" %} {% if not user.is_active and user.deactivation_reason == "pending" %}
<form name="activate" method="post" action="{% url 'settings-activate-user' user.id %}" class="mr-1"> <form name="activate" method="post" action="{% url 'settings-activate-user' user.id %}" class="mr-1">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="button is-success is-light">{% trans "Activate user" %}</button> <button type="submit" class="button is-success is-light">{% trans "Activate user" %}</button>
</form> </form>
{% endif %} {% endif %}
{% if user.is_active or user.deactivation_reason == "pending" %} {% if user.is_active or user.deactivation_reason == "pending" %}
<form name="suspend" method="post" action="{% url 'settings-report-suspend' user.id report.id %}" class="mr-1"> <form name="suspend" method="post" action="{% url 'settings-report-suspend' user.id report.id %}" class="mr-1">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="button is-danger is-light">{% trans "Suspend user" %}</button> <button type="submit" class="button is-danger is-light">{% trans "Suspend user" %}</button>
</form> </form>
{% else %} {% else %}
<form name="unsuspend" method="post" action="{% url 'settings-report-unsuspend' user.id report.id %}" class="mr-1"> <form name="unsuspend" method="post" action="{% url 'settings-report-unsuspend' user.id report.id %}" class="mr-1">
{% csrf_token %} {% csrf_token %}
<button class="button">{% trans "Un-suspend user" %}</button> <button class="button">{% trans "Un-suspend user" %}</button>
</form> </form>
{% endif %}
{% if user.local %}
<div>
{% 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" %}
</div>
{% endif %}
</div>
{% if user.local %}
<div>
{% include "settings/users/delete_user_form.html" with controls_text="delete_user" class="mt-2 mb-2" %}
</div>
{% endif %} {% endif %}
{% if user.local %} {% if user.local %}
<div> <div>
{% trans "Permanently delete user" as button_text %} <form name="permission" method="post" action="{% url 'settings-user' user.id report.id %}">
{% include "snippets/toggle/open_button.html" with controls_text="delete_user" text=button_text class="is-danger is-light" %} {% csrf_token %}
<label class="label" for="id_user_group">{% trans "Access level:" %}</label>
{% if group_form.non_field_errors %}
{{ group_form.non_field_errors }}
{% endif %}
{% with group=user.groups.first %}
<div class="select">
<select name="groups" id="id_user_group" aria-describedby="desc_user_group">
{% for value, name in group_form.fields.groups.choices %}
<option value="{{ value }}" {% if name == group.name %}selected{% endif %}>
{{ name|title }}
</option>
{% endfor %}
<option value="" {% if not group %}selected{% endif %}>
User
</option>
</select>
</div>
{% include 'snippets/form_errors.html' with errors_list=group_form.groups.errors id="desc_user_group" %}
{% endwith %}
<button class="button">
{% trans "Save" %}
</button>
</form>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if user.local %}
<div>
{% include "settings/users/delete_user_form.html" with controls_text="delete_user" class="mt-2 mb-2" %}
</div>
{% endif %} {% endif %}
{% if user.local %}
<div>
<form name="permission" method="post" action="{% url 'settings-user' user.id report.id %}">
{% csrf_token %}
<label class="label" for="id_user_group">{% trans "Access level:" %}</label>
{% if group_form.non_field_errors %}
{{ group_form.non_field_errors }}
{% endif %}
{% with group=user.groups.first %}
<div class="select">
<select name="groups" id="id_user_group" aria-describedby="desc_user_group">
{% for value, name in group_form.fields.groups.choices %}
<option value="{{ value }}" {% if name == group.name %}selected{% endif %}>
{{ name|title }}
</option>
{% endfor %}
<option value="" {% if not group %}selected{% endif %}>
User
</option>
</select>
</div>
{% include 'snippets/form_errors.html' with errors_list=group_form.groups.errors id="desc_user_group" %}
{% endwith %}
<button class="button">
{% trans "Save" %}
</button>
</form>
</div>
{% endif %}
</div>
{% endif %} {% endif %}
</div> </div>

View 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)

View file

@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
from django.templatetags.static import static from django.templatetags.static import static
from bookwyrm.models import User from bookwyrm.models import User
from bookwyrm.settings import INSTANCE_ACTOR_USERNAME
register = template.Library() register = template.Library()
@ -126,7 +127,7 @@ def id_to_username(user_id):
return value return value
@register.filter(name="get_file_size") @register.filter(name="get_file_size")
def get_file_size(file): def get_file_size(file):
"""display the size of a file in human readable terms""" """display the size of a file in human readable terms"""
@ -142,3 +143,9 @@ def get_file_size(file):
return f"{raw_size/1024**3:.2f} GB" return f"{raw_size/1024**3:.2f} GB"
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
return "" return ""
@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

View file

@ -2,10 +2,12 @@
from io import BytesIO from io import BytesIO
from collections import namedtuple from collections import namedtuple
from dataclasses import dataclass from dataclasses import dataclass
import datetime
import json import json
import pathlib import pathlib
import re import re
from typing import List from typing import List
from unittest import expectedFailure
from unittest.mock import patch from unittest.mock import patch
from PIL import Image from PIL import Image
@ -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)

View file

@ -192,3 +192,90 @@ class Notification(TestCase):
notification_type=models.NotificationType.FAVORITE, notification_type=models.NotificationType.FAVORITE,
) )
self.assertFalse(models.Notification.objects.exists()) 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)

View 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")
)

View 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())

View file

@ -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()

View 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)

View file

@ -11,6 +11,7 @@ from django.utils.decorators import method_decorator
from django.views import View from django.views import View
from bookwyrm import book_search, forms, models from bookwyrm import book_search, forms, models
from bookwyrm.settings import INSTANCE_ACTOR_USERNAME
from bookwyrm.suggested_users import suggested_users from bookwyrm.suggested_users import suggested_users
from .preferences.edit_user import save_user_form from .preferences.edit_user import save_user_form
@ -108,6 +109,7 @@ class GetStartedUsers(View):
.exclude( .exclude(
id=request.user.id, id=request.user.id,
) )
.exclude(localname=INSTANCE_ACTOR_USERNAME)
.order_by("-similarity")[:5] .order_by("-similarity")[:5]
) )
data = {"no_results": not user_results} data = {"no_results": not user_results}

View file

@ -59,7 +59,7 @@ class SavedLists(View):
data = { data = {
"lists": paginated.get_page(request.GET.get("page")), "lists": paginated.get_page(request.GET.get("page")),
"list_form": forms.ListForm(), "list_form": forms.ListForm(),
"path": "/list", "path": "/list/saved",
} }
return TemplateResponse(request, "lists/lists.html", data) return TemplateResponse(request, "lists/lists.html", data)

View file

@ -13,7 +13,7 @@ from csp.decorators import csp_update
from bookwyrm import models from bookwyrm import models
from bookwyrm.connectors import connector_manager from bookwyrm.connectors import connector_manager
from bookwyrm.book_search import search, format_search_result 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 bookwyrm.utils import regex
from .helpers import is_api_request from .helpers import is_api_request
from .helpers import handle_remote_webfinger from .helpers import handle_remote_webfinger
@ -113,6 +113,7 @@ def user_search(request):
.filter( .filter(
similarity__gt=0.5, similarity__gt=0.5,
) )
.exclude(localname=INSTANCE_ACTOR_USERNAME)
.order_by("-similarity") .order_by("-similarity")
) )

View file

@ -11,7 +11,7 @@ from django.views.decorators.http import require_POST
from bookwyrm import models from bookwyrm import models
from bookwyrm.activitypub import ActivitypubResponse 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 from .helpers import get_user_from_username, is_api_request
@ -31,6 +31,10 @@ class User(View):
return ActivitypubResponse(user.to_activity()) return ActivitypubResponse(user.to_activity())
# otherwise we're at a UI view # 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 = [] shelf_preview = []
# only show shelves that should be visible # only show shelves that should be visible

View file

@ -1,4 +1,4 @@
aiohttp==3.8.5 aiohttp==3.8.6
bleach==5.0.1 bleach==5.0.1
celery==5.2.7 celery==5.2.7
colorthief==0.2.1 colorthief==0.2.1