diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py index bfb22fa32..05ca44476 100644 --- a/bookwyrm/activitypub/__init__.py +++ b/bookwyrm/activitypub/__init__.py @@ -3,7 +3,7 @@ import inspect import sys from .base_activity import ActivityEncoder, Signature, naive_parse -from .base_activity import Link, Mention +from .base_activity import Link, Mention, Hashtag from .base_activity import ActivitySerializerError, resolve_remote_id from .image import Document, Image from .note import Note, GeneratedNote, Article, Comment, Quotation diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 6751f9c8e..840dab6a4 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -186,7 +186,7 @@ class ActivityObject: # add many to many fields, which have to be set post-save for field in instance.many_to_many_fields: - # mention books/users, for example + # mention books/users/hashtags, for example field.set_field_from_activity( instance, self, @@ -241,7 +241,7 @@ class ActivityObject: return data -@app.task(queue=MEDIUM) +@app.task(queue=MEDIUM, ignore_result=True) @transaction.atomic def set_related_field( model_name, origin_model_name, related_field_name, related_remote_id, data @@ -426,3 +426,10 @@ class Mention(Link): """a subtype of Link for mentioning an actor""" type: str = "Mention" + + +@dataclass(init=False) +class Hashtag(Link): + """a subtype of Link for mentioning a hashtag""" + + type: str = "Hashtag" diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py index eb18b8b8a..6a081058c 100644 --- a/bookwyrm/activitypub/note.py +++ b/bookwyrm/activitypub/note.py @@ -1,9 +1,12 @@ """ note serializer and children thereof """ from dataclasses import dataclass, field from typing import Dict, List -from django.apps import apps +import re -from .base_activity import ActivityObject, Link +from django.apps import apps +from django.db import IntegrityError, transaction + +from .base_activity import ActivityObject, ActivitySerializerError, Link from .image import Document @@ -38,6 +41,47 @@ class Note(ActivityObject): updated: str = None type: str = "Note" + # pylint: disable=too-many-arguments + def to_model( + self, + model=None, + instance=None, + allow_create=True, + save=True, + overwrite=True, + allow_external_connections=True, + ): + instance = super().to_model( + model, instance, allow_create, save, overwrite, allow_external_connections + ) + + if instance is None: + return instance + + # Replace links to hashtags in content with local URLs + changed_content = False + for hashtag in instance.mention_hashtags.all(): + updated_content = re.sub( + rf'({hashtag.name})', + rf"\1{hashtag.remote_id}\2", + instance.content, + flags=re.IGNORECASE, + ) + if instance.content != updated_content: + instance.content = updated_content + changed_content = True + + if not save or not changed_content: + return instance + + with transaction.atomic(): + try: + instance.save(broadcast=False, update_fields=["content"]) + except IntegrityError as e: + raise ActivitySerializerError(e) + + return instance + @dataclass(init=False) class Article(Note): diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py index 80774e28d..d4dac1412 100644 --- a/bookwyrm/activitystreams.py +++ b/bookwyrm/activitystreams.py @@ -13,18 +13,18 @@ from bookwyrm.tasks import app, LOW, MEDIUM, HIGH class ActivityStream(RedisStore): """a category of activity stream (like home, local, books)""" - def stream_id(self, user): + def stream_id(self, user_id): """the redis key for this user's instance of this stream""" - return f"{user.id}-{self.key}" + return f"{user_id}-{self.key}" - def unread_id(self, user): + def unread_id(self, user_id): """the redis key for this user's unread count for this stream""" - stream_id = self.stream_id(user) + stream_id = self.stream_id(user_id) return f"{stream_id}-unread" - def unread_by_status_type_id(self, user): + def unread_by_status_type_id(self, user_id): """the redis key for this user's unread count for this stream""" - stream_id = self.stream_id(user) + stream_id = self.stream_id(user_id) return f"{stream_id}-unread-by-type" def get_rank(self, obj): # pylint: disable=no-self-use @@ -37,12 +37,12 @@ class ActivityStream(RedisStore): pipeline = self.add_object_to_related_stores(status, execute=False) if increment_unread: - for user in self.get_audience(status): + for user_id in self.get_audience(status): # add to the unread status count - pipeline.incr(self.unread_id(user)) + pipeline.incr(self.unread_id(user_id)) # add to the unread status count for status type pipeline.hincrby( - self.unread_by_status_type_id(user), get_status_type(status), 1 + self.unread_by_status_type_id(user_id), get_status_type(status), 1 ) # and go! @@ -52,21 +52,21 @@ class ActivityStream(RedisStore): """add a user's statuses to another user's feed""" # only add the statuses that the viewer should be able to see (ie, not dms) statuses = models.Status.privacy_filter(viewer).filter(user=user) - self.bulk_add_objects_to_store(statuses, self.stream_id(viewer)) + self.bulk_add_objects_to_store(statuses, self.stream_id(viewer.id)) def remove_user_statuses(self, viewer, user): """remove a user's status from another user's feed""" # remove all so that followers only statuses are removed statuses = user.status_set.all() - self.bulk_remove_objects_from_store(statuses, self.stream_id(viewer)) + self.bulk_remove_objects_from_store(statuses, self.stream_id(viewer.id)) def get_activity_stream(self, user): """load the statuses to be displayed""" # clear unreads for this feed - r.set(self.unread_id(user), 0) - r.delete(self.unread_by_status_type_id(user)) + r.set(self.unread_id(user.id), 0) + r.delete(self.unread_by_status_type_id(user.id)) - statuses = self.get_store(self.stream_id(user)) + statuses = self.get_store(self.stream_id(user.id)) return ( models.Status.objects.select_subclasses() .filter(id__in=statuses) @@ -83,11 +83,11 @@ class ActivityStream(RedisStore): def get_unread_count(self, user): """get the unread status count for this user's feed""" - return int(r.get(self.unread_id(user)) or 0) + return int(r.get(self.unread_id(user.id)) or 0) def get_unread_count_by_status_type(self, user): """get the unread status count for this user's feed's status types""" - status_types = r.hgetall(self.unread_by_status_type_id(user)) + status_types = r.hgetall(self.unread_by_status_type_id(user.id)) return { str(key.decode("utf-8")): int(value) or 0 for key, value in status_types.items() @@ -95,9 +95,9 @@ class ActivityStream(RedisStore): def populate_streams(self, user): """go from zero to a timeline""" - self.populate_store(self.stream_id(user)) + self.populate_store(self.stream_id(user.id)) - def get_audience(self, status): # pylint: disable=no-self-use + def _get_audience(self, status): # pylint: disable=no-self-use """given a status, what users should see it""" # direct messages don't appeard in feeds, direct comments/reviews/etc do if status.privacy == "direct" and status.status_type == "Note": @@ -136,8 +136,12 @@ class ActivityStream(RedisStore): ) return audience.distinct() + def get_audience(self, status): # pylint: disable=no-self-use + """given a status, what users should see it""" + return [user.id for user in self._get_audience(status)] + def get_stores_for_object(self, obj): - return [self.stream_id(u) for u in self.get_audience(obj)] + return [self.stream_id(user_id) for user_id in self.get_audience(obj)] def get_statuses_for_user(self, user): # pylint: disable=no-self-use """given a user, what statuses should they see on this stream""" @@ -157,13 +161,14 @@ class HomeStream(ActivityStream): key = "home" def get_audience(self, status): - audience = super().get_audience(status) + audience = super()._get_audience(status) if not audience: return [] - return audience.filter( - Q(id=status.user.id) # if the user is the post's author - | Q(following=status.user) # if the user is following the author - ).distinct() + # if the user is the post's author + ids_self = [user.id for user in audience.filter(Q(id=status.user.id))] + # if the user is following the author + ids_following = [user.id for user in audience.filter(Q(following=status.user))] + return ids_self + ids_following def get_statuses_for_user(self, user): return models.Status.privacy_filter( @@ -183,11 +188,11 @@ class LocalStream(ActivityStream): key = "local" - def get_audience(self, status): + def _get_audience(self, status): # this stream wants no part in non-public statuses if status.privacy != "public" or not status.user.local: return [] - return super().get_audience(status) + return super()._get_audience(status) def get_statuses_for_user(self, user): # all public statuses by a local user @@ -202,7 +207,7 @@ class BooksStream(ActivityStream): key = "books" - def get_audience(self, status): + def _get_audience(self, status): """anyone with the mentioned book on their shelves""" # only show public statuses on the books feed, # and only statuses that mention books @@ -217,7 +222,7 @@ class BooksStream(ActivityStream): else status.mention_books.first().parent_work ) - audience = super().get_audience(status) + audience = super()._get_audience(status) if not audience: return [] return audience.filter(shelfbook__book__parent_work=work).distinct() @@ -244,38 +249,38 @@ class BooksStream(ActivityStream): def add_book_statuses(self, user, book): """add statuses about a book to a user's feed""" work = book.parent_work - statuses = ( - models.Status.privacy_filter( - user, - privacy_levels=["public"], - ) - .filter( - Q(comment__book__parent_work=work) - | Q(quotation__book__parent_work=work) - | Q(review__book__parent_work=work) - | Q(mention_books__parent_work=work) - ) - .distinct() + statuses = models.Status.privacy_filter( + user, + privacy_levels=["public"], ) - self.bulk_add_objects_to_store(statuses, self.stream_id(user)) + + book_comments = statuses.filter(Q(comment__book__parent_work=work)) + book_quotations = statuses.filter(Q(quotation__book__parent_work=work)) + book_reviews = statuses.filter(Q(review__book__parent_work=work)) + book_mentions = statuses.filter(Q(mention_books__parent_work=work)) + + self.bulk_add_objects_to_store(book_comments, self.stream_id(user.id)) + self.bulk_add_objects_to_store(book_quotations, self.stream_id(user.id)) + self.bulk_add_objects_to_store(book_reviews, self.stream_id(user.id)) + self.bulk_add_objects_to_store(book_mentions, self.stream_id(user.id)) def remove_book_statuses(self, user, book): """add statuses about a book to a user's feed""" work = book.parent_work - statuses = ( - models.Status.privacy_filter( - user, - privacy_levels=["public"], - ) - .filter( - Q(comment__book__parent_work=work) - | Q(quotation__book__parent_work=work) - | Q(review__book__parent_work=work) - | Q(mention_books__parent_work=work) - ) - .distinct() + statuses = models.Status.privacy_filter( + user, + privacy_levels=["public"], ) - self.bulk_remove_objects_from_store(statuses, self.stream_id(user)) + + book_comments = statuses.filter(Q(comment__book__parent_work=work)) + book_quotations = statuses.filter(Q(quotation__book__parent_work=work)) + book_reviews = statuses.filter(Q(review__book__parent_work=work)) + book_mentions = statuses.filter(Q(mention_books__parent_work=work)) + + self.bulk_remove_objects_from_store(book_comments, self.stream_id(user.id)) + self.bulk_remove_objects_from_store(book_quotations, self.stream_id(user.id)) + self.bulk_remove_objects_from_store(book_reviews, self.stream_id(user.id)) + self.bulk_remove_objects_from_store(book_mentions, self.stream_id(user.id)) # determine which streams are enabled in settings.py @@ -466,7 +471,7 @@ def remove_statuses_on_unshelve(sender, instance, *args, **kwargs): # ---- TASKS -@app.task(queue=LOW) +@app.task(queue=LOW, ignore_result=True) def add_book_statuses_task(user_id, book_id): """add statuses related to a book on shelve""" user = models.User.objects.get(id=user_id) @@ -474,7 +479,7 @@ def add_book_statuses_task(user_id, book_id): BooksStream().add_book_statuses(user, book) -@app.task(queue=LOW) +@app.task(queue=LOW, ignore_result=True) def remove_book_statuses_task(user_id, book_id): """remove statuses about a book from a user's books feed""" user = models.User.objects.get(id=user_id) @@ -482,7 +487,7 @@ def remove_book_statuses_task(user_id, book_id): BooksStream().remove_book_statuses(user, book) -@app.task(queue=MEDIUM) +@app.task(queue=MEDIUM, ignore_result=True) def populate_stream_task(stream, user_id): """background task for populating an empty activitystream""" user = models.User.objects.get(id=user_id) @@ -490,7 +495,7 @@ def populate_stream_task(stream, user_id): stream.populate_streams(user) -@app.task(queue=MEDIUM) +@app.task(queue=MEDIUM, ignore_result=True) def remove_status_task(status_ids): """remove a status from any stream it might be in""" # this can take an id or a list of ids @@ -503,7 +508,7 @@ def remove_status_task(status_ids): stream.remove_object_from_related_stores(status) -@app.task(queue=HIGH) +@app.task(queue=HIGH, ignore_result=True) def add_status_task(status_id, increment_unread=False): """add a status to any stream it should be in""" status = models.Status.objects.select_subclasses().get(id=status_id) @@ -515,7 +520,7 @@ def add_status_task(status_id, increment_unread=False): stream.add_status(status, increment_unread=increment_unread) -@app.task(queue=MEDIUM) +@app.task(queue=MEDIUM, ignore_result=True) def remove_user_statuses_task(viewer_id, user_id, stream_list=None): """remove all statuses by a user from a viewer's stream""" stream_list = [streams[s] for s in stream_list] if stream_list else streams.values() @@ -525,7 +530,7 @@ def remove_user_statuses_task(viewer_id, user_id, stream_list=None): stream.remove_user_statuses(viewer, user) -@app.task(queue=MEDIUM) +@app.task(queue=MEDIUM, ignore_result=True) def add_user_statuses_task(viewer_id, user_id, stream_list=None): """add all statuses by a user to a viewer's stream""" stream_list = [streams[s] for s in stream_list] if stream_list else streams.values() @@ -535,7 +540,7 @@ def add_user_statuses_task(viewer_id, user_id, stream_list=None): stream.add_user_statuses(viewer, user) -@app.task(queue=MEDIUM) +@app.task(queue=MEDIUM, ignore_result=True) def handle_boost_task(boost_id): """remove the original post and other, earlier boosts""" instance = models.Status.objects.get(id=boost_id) diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py index 9a6f834af..4330d4ac2 100644 --- a/bookwyrm/connectors/connector_manager.py +++ b/bookwyrm/connectors/connector_manager.py @@ -143,7 +143,7 @@ def get_or_create_connector(remote_id): return load_connector(connector_info) -@app.task(queue=LOW) +@app.task(queue=LOW, ignore_result=True) def load_more_data(connector_id, book_id): """background the work of getting all 10,000 editions of LoTR""" connector_info = models.Connector.objects.get(id=connector_id) @@ -152,7 +152,7 @@ def load_more_data(connector_id, book_id): connector.expand_book_data(book) -@app.task(queue=LOW) +@app.task(queue=LOW, ignore_result=True) def create_edition_task(connector_id, work_id, data): """separate task for each of the 10,000 editions of LoTR""" connector_info = models.Connector.objects.get(id=connector_id) diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py index 2271077b1..1640c0b73 100644 --- a/bookwyrm/emailing.py +++ b/bookwyrm/emailing.py @@ -75,7 +75,7 @@ def format_email(email_name, data): return (subject, html_content, text_content) -@app.task(queue=HIGH) +@app.task(queue=HIGH, ignore_result=True) def send_email(recipient, subject, html_content, text_content): """use a task to send the email""" email = EmailMultiAlternatives( diff --git a/bookwyrm/lists_stream.py b/bookwyrm/lists_stream.py index 0977ad8c2..7426488ce 100644 --- a/bookwyrm/lists_stream.py +++ b/bookwyrm/lists_stream.py @@ -217,14 +217,14 @@ def add_list_on_account_create_command(user_id): # ---- TASKS -@app.task(queue=MEDIUM) +@app.task(queue=MEDIUM, ignore_result=True) def populate_lists_task(user_id): """background task for populating an empty list stream""" user = models.User.objects.get(id=user_id) ListsStream().populate_lists(user) -@app.task(queue=MEDIUM) +@app.task(queue=MEDIUM, ignore_result=True) def remove_list_task(list_id, re_add=False): """remove a list from any stream it might be in""" stores = models.User.objects.filter(local=True, is_active=True).values_list( @@ -239,14 +239,14 @@ def remove_list_task(list_id, re_add=False): add_list_task.delay(list_id) -@app.task(queue=HIGH) +@app.task(queue=HIGH, ignore_result=True) def add_list_task(list_id): """add a list to any stream it should be in""" book_list = models.List.objects.get(id=list_id) ListsStream().add_list(book_list) -@app.task(queue=MEDIUM) +@app.task(queue=MEDIUM, ignore_result=True) def remove_user_lists_task(viewer_id, user_id, exclude_privacy=None): """remove all lists by a user from a viewer's stream""" viewer = models.User.objects.get(id=viewer_id) @@ -254,7 +254,7 @@ def remove_user_lists_task(viewer_id, user_id, exclude_privacy=None): ListsStream().remove_user_lists(viewer, user, exclude_privacy=exclude_privacy) -@app.task(queue=MEDIUM) +@app.task(queue=MEDIUM, ignore_result=True) def add_user_lists_task(viewer_id, user_id): """add all lists by a user to a viewer's stream""" viewer = models.User.objects.get(id=viewer_id) diff --git a/bookwyrm/migrations/0174_auto_20230222_1742.py b/bookwyrm/migrations/0174_auto_20230222_1742.py new file mode 100644 index 000000000..f30d61a46 --- /dev/null +++ b/bookwyrm/migrations/0174_auto_20230222_1742.py @@ -0,0 +1,46 @@ +# Generated by Django 3.2.18 on 2023-02-22 17:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0174_auto_20230130_1240"), + ] + + operations = [ + migrations.AddField( + model_name="notification", + name="related_link_domains", + field=models.ManyToManyField(to="bookwyrm.LinkDomain"), + ), + migrations.AlterField( + model_name="notification", + name="notification_type", + field=models.CharField( + choices=[ + ("FAVORITE", "Favorite"), + ("REPLY", "Reply"), + ("MENTION", "Mention"), + ("TAG", "Tag"), + ("FOLLOW", "Follow"), + ("FOLLOW_REQUEST", "Follow Request"), + ("BOOST", "Boost"), + ("IMPORT", "Import"), + ("ADD", "Add"), + ("REPORT", "Report"), + ("LINK_DOMAIN", "Link Domain"), + ("INVITE", "Invite"), + ("ACCEPT", "Accept"), + ("JOIN", "Join"), + ("LEAVE", "Leave"), + ("REMOVE", "Remove"), + ("GROUP_PRIVACY", "Group Privacy"), + ("GROUP_NAME", "Group Name"), + ("GROUP_DESCRIPTION", "Group Description"), + ], + max_length=255, + ), + ), + ] diff --git a/bookwyrm/migrations/0176_hashtag_support.py b/bookwyrm/migrations/0176_hashtag_support.py new file mode 100644 index 000000000..96e79ff36 --- /dev/null +++ b/bookwyrm/migrations/0176_hashtag_support.py @@ -0,0 +1,53 @@ +# Generated by Django 3.2.16 on 2022-12-17 19:28 + +import bookwyrm.models.fields +import django.contrib.postgres.fields.citext +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0174_auto_20230130_1240"), + ] + + operations = [ + migrations.CreateModel( + name="Hashtag", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_date", models.DateTimeField(auto_now_add=True)), + ("updated_date", models.DateTimeField(auto_now=True)), + ( + "remote_id", + bookwyrm.models.fields.RemoteIdField( + max_length=255, + null=True, + validators=[bookwyrm.models.fields.validate_remote_id], + ), + ), + ( + "name", + django.contrib.postgres.fields.citext.CICharField(max_length=256), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="status", + name="mention_hashtags", + field=bookwyrm.models.fields.TagField( + related_name="mention_hashtag", to="bookwyrm.Hashtag" + ), + ), + ] diff --git a/bookwyrm/migrations/0177_merge_0174_auto_20230222_1742_0176_hashtag_support.py b/bookwyrm/migrations/0177_merge_0174_auto_20230222_1742_0176_hashtag_support.py new file mode 100644 index 000000000..65ace3059 --- /dev/null +++ b/bookwyrm/migrations/0177_merge_0174_auto_20230222_1742_0176_hashtag_support.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.18 on 2023-03-12 23:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0174_auto_20230222_1742"), + ("bookwyrm", "0176_hashtag_support"), + ] + + operations = [] diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index ae7000162..f5b72f3e4 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -34,6 +34,8 @@ from .antispam import EmailBlocklist, IPBlocklist, AutoMod, automod_task from .notification import Notification +from .hashtag import Hashtag + cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass) activity_models = { c[1].activity_serializer.__name__: c[1] diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index ec1c34a40..83ca90b0a 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -506,7 +506,7 @@ def unfurl_related_field(related_field, sort_field=None): return related_field.remote_id -@app.task(queue=BROADCAST) +@app.task(queue=BROADCAST, ignore_result=True) def broadcast_task(sender_id: int, activity: str, recipients: List[str]): """the celery task for broadcast""" user_model = apps.get_model("bookwyrm.User", require_ready=True) diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py index 1e20df340..c3afadf28 100644 --- a/bookwyrm/models/antispam.py +++ b/bookwyrm/models/antispam.py @@ -65,7 +65,7 @@ class AutoMod(AdminModel): created_by = models.ForeignKey("User", on_delete=models.PROTECT) -@app.task(queue=LOW) +@app.task(queue=LOW, ignore_result=True) def automod_task(): """Create reports""" if not AutoMod.objects.exists(): diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index a970e4124..6cfe4c10c 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -7,6 +7,7 @@ from urllib.parse import urljoin import dateutil.parser from dateutil.parser import ParserError from django.contrib.postgres.fields import ArrayField as DjangoArrayField +from django.contrib.postgres.fields import CICharField as DjangoCICharField from django.core.exceptions import ValidationError from django.db import models from django.forms import ClearableFileInput, ImageField as DjangoImageField @@ -388,13 +389,22 @@ class TagField(ManyToManyField): if tag_type != self.related_model.activity_serializer.type: # tags can contain multiple types continue - items.append( - activitypub.resolve_remote_id( - link.href, - model=self.related_model, - allow_external_connections=allow_external_connections, + + if tag_type == "Hashtag": + # we already have all data to create hashtags, + # no need to fetch from remote + item = self.related_model.activity_serializer(**link_json) + hashtag = item.to_model(model=self.related_model, save=True) + items.append(hashtag) + else: + # for other tag types we fetch them remotely + items.append( + activitypub.resolve_remote_id( + link.href, + model=self.related_model, + allow_external_connections=allow_external_connections, + ) ) - ) return items @@ -546,6 +556,10 @@ class CharField(ActivitypubFieldMixin, models.CharField): """activitypub-aware char field""" +class CICharField(ActivitypubFieldMixin, DjangoCICharField): + """activitypub-aware cichar field""" + + class URLField(ActivitypubFieldMixin, models.URLField): """activitypub-aware url field""" diff --git a/bookwyrm/models/hashtag.py b/bookwyrm/models/hashtag.py new file mode 100644 index 000000000..7894a3528 --- /dev/null +++ b/bookwyrm/models/hashtag.py @@ -0,0 +1,23 @@ +""" model for tags """ +from bookwyrm import activitypub +from .activitypub_mixin import ActivitypubMixin +from .base_model import BookWyrmModel +from .fields import CICharField + + +class Hashtag(ActivitypubMixin, BookWyrmModel): + "a hashtag which can be used in statuses" + + name = CICharField( + max_length=256, + blank=False, + null=False, + activitypub_field="name", + deduplication_field=True, + ) + + name_field = "name" + activity_serializer = activitypub.Hashtag + + def __repr__(self): + return f"<{self.__class__} id={self.id} name={self.name}>" diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py index bdd88c687..5f564d390 100644 --- a/bookwyrm/models/import_job.py +++ b/bookwyrm/models/import_job.py @@ -327,7 +327,7 @@ class ImportItem(models.Model): ) -@app.task(queue=IMPORTS) +@app.task(queue=IMPORTS, ignore_result=True) def start_import_task(job_id): """trigger the child tasks for each row""" job = ImportJob.objects.get(id=job_id) @@ -346,7 +346,7 @@ def start_import_task(job_id): job.save() -@app.task(queue=IMPORTS) +@app.task(queue=IMPORTS, ignore_result=True) def import_item_task(item_id): """resolve a row into a book""" item = ImportItem.objects.get(id=item_id) diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py index fa2ce54e2..29f7b0c2d 100644 --- a/bookwyrm/models/notification.py +++ b/bookwyrm/models/notification.py @@ -2,8 +2,8 @@ from django.db import models, transaction from django.dispatch import receiver from .base_model import BookWyrmModel -from . import Boost, Favorite, GroupMemberInvitation, ImportJob, ListItem, Report -from . import Status, User, UserFollowRequest +from . import Boost, Favorite, GroupMemberInvitation, ImportJob, LinkDomain +from . import ListItem, Report, Status, User, UserFollowRequest class Notification(BookWyrmModel): @@ -28,6 +28,7 @@ class Notification(BookWyrmModel): # Admin REPORT = "REPORT" + LINK_DOMAIN = "LINK_DOMAIN" # Groups INVITE = "INVITE" @@ -43,7 +44,7 @@ class Notification(BookWyrmModel): NotificationType = models.TextChoices( # there has got be a better way to do this "NotificationType", - f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {ADD} {REPORT} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}", + f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {ADD} {REPORT} {LINK_DOMAIN} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}", ) user = models.ForeignKey("User", on_delete=models.CASCADE) @@ -64,6 +65,7 @@ class Notification(BookWyrmModel): "ListItem", symmetrical=False, related_name="notifications" ) related_reports = models.ManyToManyField("Report", symmetrical=False) + related_link_domains = models.ManyToManyField("LinkDomain", symmetrical=False) @classmethod @transaction.atomic @@ -241,6 +243,26 @@ def notify_admins_on_report(sender, instance, created, *args, **kwargs): notification.related_reports.add(instance) +@receiver(models.signals.post_save, sender=LinkDomain) +@transaction.atomic +# pylint: disable=unused-argument +def notify_admins_on_link_domain(sender, instance, created, *args, **kwargs): + """a new link domain needs to be verified""" + if not created: + # otherwise you'll get a notification when you approve a domain + return + + # moderators and superusers should be notified + admins = User.admins() + for admin in admins: + notification, _ = Notification.objects.get_or_create( + user=admin, + notification_type=Notification.LINK_DOMAIN, + read=False, + ) + notification.related_link_domains.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/status.py b/bookwyrm/models/status.py index e51c7b2a1..1fcc9ee75 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -34,6 +34,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): raw_content = models.TextField(blank=True, null=True) mention_users = fields.TagField("User", related_name="mention_user") mention_books = fields.TagField("Edition", related_name="mention_book") + mention_hashtags = fields.TagField("Hashtag", related_name="mention_hashtag") local = models.BooleanField(default=True) content_warning = fields.CharField( max_length=500, blank=True, null=True, activitypub_field="summary" diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 85e1f0edb..6d26b7b17 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -469,7 +469,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel): return super().save(*args, **kwargs) -@app.task(queue=LOW) +@app.task(queue=LOW, ignore_result=True) def set_remote_server(user_id): """figure out the user's remote server in the background""" user = User.objects.get(id=user_id) @@ -513,7 +513,7 @@ def get_or_create_remote_server(domain, refresh=False): return server -@app.task(queue=LOW) +@app.task(queue=LOW, ignore_result=True) def get_remote_reviews(outbox): """ingest reviews by a new remote bookwyrm user""" outbox_page = outbox + "?page=true&type=Review" diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index 549e12472..c218d87df 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -420,7 +420,7 @@ def save_and_cleanup(image, instance=None): # pylint: disable=invalid-name -@app.task(queue=LOW) +@app.task(queue=LOW, ignore_result=True) def generate_site_preview_image_task(): """generate preview_image for the website""" if not settings.ENABLE_PREVIEW_IMAGES: @@ -445,7 +445,7 @@ def generate_site_preview_image_task(): # pylint: disable=invalid-name -@app.task(queue=LOW) +@app.task(queue=LOW, ignore_result=True) def generate_edition_preview_image_task(book_id): """generate preview_image for a book""" if not settings.ENABLE_PREVIEW_IMAGES: @@ -470,7 +470,7 @@ def generate_edition_preview_image_task(book_id): save_and_cleanup(image, instance=book) -@app.task(queue=LOW) +@app.task(queue=LOW, ignore_result=True) def generate_user_preview_image_task(user_id): """generate preview_image for a user""" if not settings.ENABLE_PREVIEW_IMAGES: @@ -496,7 +496,7 @@ def generate_user_preview_image_task(user_id): save_and_cleanup(image, instance=user) -@app.task(queue=LOW) +@app.task(queue=LOW, ignore_result=True) def remove_user_preview_image_task(user_id): """remove preview_image for a user""" if not settings.ENABLE_PREVIEW_IMAGES: diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index bf0467ebc..3f14daf1b 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _ env = Env() env.read_env() DOMAIN = env("DOMAIN") -VERSION = "0.5.5" +VERSION = "0.6.0" RELEASE_API = env( "RELEASE_API", @@ -21,7 +21,7 @@ RELEASE_API = env( PAGE_LENGTH = env("PAGE_LENGTH", 15) DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English") -JS_CACHE = "cd848b9a" +JS_CACHE = "a7d4e720" # email EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js index dee4231b8..6a6c0217f 100644 --- a/bookwyrm/static/js/bookwyrm.js +++ b/bookwyrm/static/js/bookwyrm.js @@ -95,7 +95,6 @@ let BookWyrm = new (class { /** * Update a counter with recurring requests to the API - * The delay is slightly randomized and increased on each cycle. * * @param {Object} counter - DOM node * @param {int} delay - frequency for polling in ms @@ -104,16 +103,19 @@ let BookWyrm = new (class { polling(counter, delay) { const bookwyrm = this; - delay = delay || 10000; - delay += Math.random() * 1000; + delay = delay || 5 * 60 * 1000 + (Math.random() - 0.5) * 30 * 1000; setTimeout( function () { fetch("/api/updates/" + counter.dataset.poll) .then((response) => response.json()) - .then((data) => bookwyrm.updateCountElement(counter, data)); - - bookwyrm.polling(counter, delay * 1.25); + .then((data) => { + bookwyrm.updateCountElement(counter, data); + bookwyrm.polling(counter); + }) + .catch(() => { + bookwyrm.polling(counter, delay * 1.1); + }); }, delay, counter diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py index 91f23dded..ea6b1c55d 100644 --- a/bookwyrm/suggested_users.py +++ b/bookwyrm/suggested_users.py @@ -237,41 +237,41 @@ def domain_level_update(sender, instance, created, update_fields=None, **kwargs) # ------------------- TASKS -@app.task(queue=LOW) +@app.task(queue=LOW, ignore_result=True) def rerank_suggestions_task(user_id): """do the hard work in celery""" suggested_users.rerank_user_suggestions(user_id) -@app.task(queue=LOW) +@app.task(queue=LOW, ignore_result=True) def rerank_user_task(user_id, update_only=False): """do the hard work in celery""" user = models.User.objects.get(id=user_id) suggested_users.rerank_obj(user, update_only=update_only) -@app.task(queue=LOW) +@app.task(queue=LOW, ignore_result=True) def remove_user_task(user_id): """do the hard work in celery""" user = models.User.objects.get(id=user_id) suggested_users.remove_object_from_related_stores(user) -@app.task(queue=MEDIUM) +@app.task(queue=MEDIUM, ignore_result=True) def remove_suggestion_task(user_id, suggested_user_id): """remove a specific user from a specific user's suggestions""" suggested_user = models.User.objects.get(id=suggested_user_id) suggested_users.remove_suggestion(user_id, suggested_user) -@app.task(queue=LOW) +@app.task(queue=LOW, ignore_result=True) def bulk_remove_instance_task(instance_id): """remove a bunch of users from recs""" for user in models.User.objects.filter(federated_server__id=instance_id): suggested_users.remove_object_from_related_stores(user) -@app.task(queue=LOW) +@app.task(queue=LOW, ignore_result=True) def bulk_add_instance_task(instance_id): """remove a bunch of users from recs""" for user in models.User.objects.filter(federated_server__id=instance_id): diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index d27c7ec54..e9eff99ab 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -82,6 +82,8 @@ src="{% static "images/no_cover.jpg" %}" alt="" aria-hidden="true" + loading="lazy" + decoding="async" > {{ book.alt_text }} diff --git a/bookwyrm/templates/book/cover_show_modal.html b/bookwyrm/templates/book/cover_show_modal.html index f244aa535..7ab4dbf29 100644 --- a/bookwyrm/templates/book/cover_show_modal.html +++ b/bookwyrm/templates/book/cover_show_modal.html @@ -5,7 +5,7 @@ diff --git a/bookwyrm/templates/book/edit/edit_book.html b/bookwyrm/templates/book/edit/edit_book.html index f1b60d6c2..d4ca2165d 100644 --- a/bookwyrm/templates/book/edit/edit_book.html +++ b/bookwyrm/templates/book/edit/edit_book.html @@ -37,6 +37,14 @@ {% endif %} +{% if form.errors %} +
+

+ {% trans "Failed to save book, see errors below for more information." %} +

+
+{% endif %} +
- logo + logo
{{ site_name }}
diff --git a/bookwyrm/templates/embed-layout.html b/bookwyrm/templates/embed-layout.html index 6a8d77016..c619bf2dc 100644 --- a/bookwyrm/templates/embed-layout.html +++ b/bookwyrm/templates/embed-layout.html @@ -17,7 +17,7 @@
- + {{ site.name }}
diff --git a/bookwyrm/templates/feed/layout.html b/bookwyrm/templates/feed/layout.html index 16a868c2a..b70ed99ea 100644 --- a/bookwyrm/templates/feed/layout.html +++ b/bookwyrm/templates/feed/layout.html @@ -23,7 +23,7 @@ {% block panel %}{% endblock %} {% if activities %} - {% include 'snippets/pagination.html' with page=activities path=path anchor="#feed" %} + {% include 'snippets/pagination.html' with page=activities path=path anchor="#feed" mode="chronological" %} {% endif %}
diff --git a/bookwyrm/templates/get_started/layout.html b/bookwyrm/templates/get_started/layout.html index b8e7c861b..4eea59fe7 100644 --- a/bookwyrm/templates/get_started/layout.html +++ b/bookwyrm/templates/get_started/layout.html @@ -15,6 +15,8 @@ src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" aria-hidden="true" alt="{{ site.name }}" + loading="lazy" + decoding="async" >

{% blocktrans %}Welcome to {{ site_name }}!{% endblocktrans %} diff --git a/bookwyrm/templates/hashtag.html b/bookwyrm/templates/hashtag.html new file mode 100644 index 000000000..bc36c1704 --- /dev/null +++ b/bookwyrm/templates/hashtag.html @@ -0,0 +1,32 @@ +{% extends "layout.html" %} +{% load i18n %} + +{% block title %}{{ hashtag }}{% endblock %} + +{% block content %} +
+
+
+

{{ hashtag }}

+

+ {% blocktrans trimmed with site_name=site.name %} + See tagged statuses in the local {{ site_name }} community + {% endblocktrans %} +

+
+ + {% for activity in activities %} +
+ {% include 'snippets/status/status.html' with status=activity %} +
+ {% endfor %} + {% if not activities %} +
+

{% trans "No activities for this hashtag yet!" %}

+
+ {% endif %} + + {% include 'snippets/pagination.html' with page=activities path=path %} +
+
+{% endblock %} diff --git a/bookwyrm/templates/layout.html b/bookwyrm/templates/layout.html index c3408f44e..239137b8a 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -28,7 +28,7 @@ {% with notification_count=request.user.unread_notification_count has_unread_mentions=request.user.has_unread_mentions %}