moviewyrm/bookwyrm/models/status.py

450 lines
15 KiB
Python
Raw Normal View History

2021-03-08 16:49:10 +00:00
""" models for storing different kinds of Activities """
2020-12-18 20:38:27 +00:00
from dataclasses import MISSING
import re
2020-12-18 20:38:27 +00:00
from django.apps import apps
2022-01-17 20:17:24 +00:00
from django.core.cache import cache
2021-09-27 21:03:17 +00:00
from django.core.exceptions import PermissionDenied
2020-02-11 23:17:21 +00:00
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Q
2021-05-26 07:10:05 +00:00
from django.dispatch import receiver
2021-03-14 02:24:35 +00:00
from django.template.loader import get_template
2020-12-18 20:38:27 +00:00
from django.utils import timezone
from model_utils import FieldTracker
2020-02-11 23:17:21 +00:00
from model_utils.managers import InheritanceManager
2021-03-23 01:39:16 +00:00
from bookwyrm import activitypub
2021-05-26 07:44:32 +00:00
from bookwyrm.preview_images import generate_edition_preview_image_task
2021-06-18 22:28:43 +00:00
from bookwyrm.settings import ENABLE_PREVIEW_IMAGES
2021-02-04 22:36:57 +00:00
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .activitypub_mixin import OrderedCollectionPageMixin
from .base_model import BookWyrmModel
2021-03-21 00:34:58 +00:00
from .readthrough import ProgressMode
2021-02-04 22:36:57 +00:00
from . import fields
2020-09-21 15:16:34 +00:00
class Status(OrderedCollectionPageMixin, BookWyrmModel):
2021-04-26 16:15:42 +00:00
"""any post, like a reply to a review, etc"""
2021-03-08 16:49:10 +00:00
2020-11-30 22:24:31 +00:00
user = fields.ForeignKey(
2021-03-08 16:49:10 +00:00
"User", on_delete=models.PROTECT, activitypub_field="attributedTo"
)
2020-12-17 00:47:05 +00:00
content = fields.HtmlField(blank=True, null=True)
raw_content = models.TextField(blank=True, null=True)
2021-03-08 16:49:10 +00:00
mention_users = fields.TagField("User", related_name="mention_user")
mention_books = fields.TagField("Edition", related_name="mention_book")
2020-02-15 22:38:46 +00:00
local = models.BooleanField(default=True)
2020-12-13 02:00:39 +00:00
content_warning = fields.CharField(
2021-03-08 16:49:10 +00:00
max_length=500, blank=True, null=True, activitypub_field="summary"
)
privacy = fields.PrivacyField(max_length=255)
2020-11-30 22:24:31 +00:00
sensitive = fields.BooleanField(default=False)
# created date is different than publish date because of federated posts
2020-11-30 22:24:31 +00:00
published_date = fields.DateTimeField(
2021-03-08 16:49:10 +00:00
default=timezone.now, activitypub_field="published"
)
edited_date = fields.DateTimeField(
blank=True, null=True, activitypub_field="updated"
)
2020-10-08 19:32:45 +00:00
deleted = models.BooleanField(default=False)
deleted_date = models.DateTimeField(blank=True, null=True)
2020-02-19 07:26:42 +00:00
favorites = models.ManyToManyField(
2021-03-08 16:49:10 +00:00
"User",
2020-02-19 07:26:42 +00:00
symmetrical=False,
2021-03-08 16:49:10 +00:00
through="Favorite",
through_fields=("status", "user"),
related_name="user_favorites",
2020-02-19 07:26:42 +00:00
)
2020-11-30 22:24:31 +00:00
reply_parent = fields.ForeignKey(
2021-03-08 16:49:10 +00:00
"self",
null=True,
2020-11-30 22:24:31 +00:00
on_delete=models.PROTECT,
2021-03-08 16:49:10 +00:00
activitypub_field="inReplyTo",
)
2021-10-01 21:12:03 +00:00
thread_id = models.IntegerField(blank=True, null=True)
objects = InheritanceManager()
activity_serializer = activitypub.Note
2021-03-08 16:49:10 +00:00
serialize_reverse_fields = [("attachments", "attachment", "id")]
deserialize_reverse_fields = [("attachments", "attachment")]
2021-02-10 23:18:20 +00:00
2021-03-24 15:37:25 +00:00
class Meta:
2021-04-26 16:15:42 +00:00
"""default sorting"""
2021-03-24 15:39:37 +00:00
2021-03-24 15:37:25 +00:00
ordering = ("-published_date",)
2021-10-01 21:12:03 +00:00
def save(self, *args, **kwargs):
"""save and notify"""
if self.reply_parent:
self.thread_id = self.reply_parent.thread_id or self.reply_parent.id
super().save(*args, **kwargs)
2021-10-01 21:41:30 +00:00
if not self.reply_parent:
self.thread_id = self.id
2022-01-05 22:54:51 +00:00
2021-10-01 21:41:30 +00:00
super().save(broadcast=False, update_fields=["thread_id"])
2021-03-08 16:49:10 +00:00
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
2021-04-26 16:15:42 +00:00
""" "delete" a status"""
2021-03-08 16:49:10 +00:00
if hasattr(self, "boosted_status"):
2021-02-17 21:07:19 +00:00
# okay but if it's a boost really delete it
super().delete(*args, **kwargs)
return
2021-02-16 17:35:00 +00:00
self.deleted = True
2021-09-22 16:17:14 +00:00
# clear user content
self.content = None
if hasattr(self, "quotation"):
self.quotation = None # pylint: disable=attribute-defined-outside-init
2021-02-16 17:35:00 +00:00
self.deleted_date = timezone.now()
self.save()
@property
def recipients(self):
2021-04-26 16:15:42 +00:00
"""tagged users who definitely need to get this status in broadcast"""
mentions = [u for u in self.mention_users.all() if not u.local]
2021-03-08 16:49:10 +00:00
if (
hasattr(self, "reply_parent")
and self.reply_parent
and not self.reply_parent.user.local
):
mentions.append(self.reply_parent.user)
return list(set(mentions))
2020-12-18 20:38:27 +00:00
@classmethod
2021-03-23 01:39:16 +00:00
def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements
2021-04-26 16:15:42 +00:00
"""keep notes if they are replies to existing statuses"""
2021-03-08 16:49:10 +00:00
if activity.type == "Announce":
try:
boosted = activitypub.resolve_remote_id(
activity.object, get_activity=True
)
except activitypub.ActivitySerializerError:
# if we can't load the status, definitely ignore it
return True
# keep the boost if we would keep the status
return cls.ignore_activity(boosted)
2021-02-16 20:31:27 +00:00
# keep if it if it's a custom type
2021-03-08 16:49:10 +00:00
if activity.type != "Note":
2020-12-18 20:38:27 +00:00
return False
# keep it if it's a reply to an existing status
2021-03-08 16:49:10 +00:00
if cls.objects.filter(remote_id=activity.inReplyTo).exists():
2020-12-18 20:38:27 +00:00
return False
# keep notes if they mention local users
if activity.tag == MISSING or activity.tag is None:
return True
2021-03-08 16:49:10 +00:00
tags = [l["href"] for l in activity.tag if l["type"] == "Mention"]
user_model = apps.get_model("bookwyrm.User", require_ready=True)
2020-12-18 20:38:27 +00:00
for tag in tags:
2021-03-08 16:49:10 +00:00
if user_model.objects.filter(remote_id=tag, local=True).exists():
2020-12-18 20:38:27 +00:00
# we found a mention of a known use boost
return False
return True
@classmethod
def replies(cls, status):
2021-03-08 16:49:10 +00:00
"""load all replies to a status. idk if there's a better way
to write this so it's just a property"""
return (
cls.objects.filter(reply_parent=status)
.select_subclasses()
.order_by("published_date")
)
2020-09-28 22:57:31 +00:00
@property
def status_type(self):
2021-04-26 16:15:42 +00:00
"""expose the type of status for the ui using activity type"""
2020-09-28 22:57:31 +00:00
return self.activity_serializer.__name__
2020-12-18 17:30:08 +00:00
@property
def boostable(self):
2021-04-26 16:15:42 +00:00
"""you can't boost dms"""
2021-03-08 16:49:10 +00:00
return self.privacy in ["unlisted", "public"]
2020-12-18 17:30:08 +00:00
def to_replies(self, **kwargs):
2021-04-26 16:15:42 +00:00
"""helper function for loading AP serialized replies to a status"""
return self.to_ordered_collection(
self.replies(self),
2021-09-18 18:32:00 +00:00
remote_id=f"{self.remote_id}/replies",
collection_only=True,
2021-09-18 18:33:43 +00:00
**kwargs,
).serialize()
2021-03-08 16:49:10 +00:00
def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ
2021-04-26 16:15:42 +00:00
"""return tombstone if the status is deleted"""
2020-10-08 19:32:45 +00:00
if self.deleted:
return activitypub.Tombstone(
id=self.remote_id,
url=self.remote_id,
2020-10-30 22:22:20 +00:00
deleted=self.deleted_date.isoformat(),
2021-03-08 16:49:10 +00:00
published=self.deleted_date.isoformat(),
2021-02-20 19:24:41 +00:00
)
activity = ActivitypubMixin.to_activity_dataclass(self)
activity.replies = self.to_replies()
2020-11-30 22:24:31 +00:00
# "pure" serialization for non-bookwyrm instances
2021-03-08 16:49:10 +00:00
if pure and hasattr(self, "pure_content"):
2021-02-20 19:24:41 +00:00
activity.content = self.pure_content
2021-03-08 16:49:10 +00:00
if hasattr(activity, "name"):
2021-02-20 19:24:41 +00:00
activity.name = self.pure_name
activity.type = self.pure_type
book = getattr(self, "book", None)
books = [book] if book else []
books += list(self.mention_books.all())
if len(books) == 1 and getattr(books[0], "preview_image", None):
2021-11-10 18:58:02 +00:00
covers = [
activitypub.Document(
url=fields.get_absolute_url(books[0].preview_image),
name=books[0].alt_text,
)
]
else:
covers = [
activitypub.Document(
url=fields.get_absolute_url(b.cover),
name=b.alt_text,
)
for b in books
if b and b.cover
]
activity.attachment = covers
2020-11-30 22:24:31 +00:00
return activity
2021-03-08 16:49:10 +00:00
def to_activity(self, pure=False): # pylint: disable=arguments-differ
2021-04-26 16:15:42 +00:00
"""json serialized activitypub class"""
2021-02-20 19:24:41 +00:00
return self.to_activity_dataclass(pure=pure).serialize()
2021-09-27 21:03:17 +00:00
def raise_not_editable(self, viewer):
"""certain types of status aren't editable"""
# first, the standard raise
super().raise_not_editable(viewer)
if isinstance(self, (GeneratedNote, ReviewRating)):
2021-09-27 22:57:22 +00:00
raise PermissionDenied()
2021-09-27 21:03:17 +00:00
@classmethod
def privacy_filter(cls, viewer, privacy_levels=None):
queryset = super().privacy_filter(viewer, privacy_levels=privacy_levels)
2022-02-28 18:47:08 +00:00
return queryset.filter(deleted=False, user__is_active=True)
@classmethod
def direct_filter(cls, queryset, viewer):
"""Overridden filter for "direct" privacy level"""
return queryset.exclude(
~Q(Q(user=viewer) | Q(mention_users=viewer)), privacy="direct"
)
2021-10-15 20:26:02 +00:00
@classmethod
def followers_filter(cls, queryset, viewer):
"""Override-able filter for "followers" privacy level"""
return queryset.exclude(
~Q( # not yourself, a follower, or someone who is tagged
Q(user__followers=viewer) | Q(user=viewer) | Q(mention_users=viewer)
),
privacy="followers", # and the status is followers only
)
2020-10-08 19:32:45 +00:00
2020-10-30 22:22:20 +00:00
class GeneratedNote(Status):
2021-04-26 16:15:42 +00:00
"""these are app-generated messages about user activity"""
2021-03-08 16:49:10 +00:00
@property
2020-11-30 22:24:31 +00:00
def pure_content(self):
2021-04-26 16:15:42 +00:00
"""indicate the book in question for mastodon (or w/e) users"""
message = self.content
2021-03-08 16:49:10 +00:00
books = ", ".join(
2021-09-18 18:32:00 +00:00
f'<a href="{book.remote_id}">"{book.title}"</a>'
2020-10-08 19:32:45 +00:00
for book in self.mention_books.all()
)
2021-09-18 18:32:00 +00:00
return f"{self.user.display_name} {message} {books}"
activity_serializer = activitypub.GeneratedNote
2021-03-08 16:49:10 +00:00
pure_type = "Note"
ReadingStatusChoices = models.TextChoices(
"ReadingStatusChoices", ["to-read", "reading", "read"]
)
class BookStatus(Status):
"""Shared fields for comments, quotes, reviews"""
2021-03-08 16:49:10 +00:00
2020-12-03 21:14:04 +00:00
book = fields.ForeignKey(
2021-03-08 16:49:10 +00:00
"Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
)
pure_type = "Note"
reading_status = fields.CharField(
max_length=255, choices=ReadingStatusChoices.choices, null=True, blank=True
)
class Meta:
"""not a real model, sorry"""
abstract = True
class Comment(BookStatus):
"""like a review but without a rating and transient"""
2020-03-21 23:50:49 +00:00
2021-03-21 00:34:58 +00:00
# this is it's own field instead of a foreign key to the progress update
# so that the update can be deleted without impacting the status
2021-03-21 00:39:05 +00:00
progress = models.IntegerField(
validators=[MinValueValidator(0)], null=True, blank=True
)
2021-03-21 01:03:20 +00:00
progress_mode = models.CharField(
2021-03-21 00:39:05 +00:00
max_length=3,
choices=ProgressMode.choices,
default=ProgressMode.PAGE,
null=True,
blank=True,
2021-03-21 00:34:58 +00:00
)
@property
2020-11-30 22:24:31 +00:00
def pure_content(self):
2021-04-26 16:15:42 +00:00
"""indicate the book in question for mastodon (or w/e) users"""
2021-09-20 23:44:59 +00:00
return (
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
2021-09-21 01:01:12 +00:00
f'"{self.book.title}"</a>)</p>'
2021-09-20 23:44:59 +00:00
)
activity_serializer = activitypub.Comment
class Quotation(BookStatus):
2021-04-26 16:15:42 +00:00
"""like a review but without a rating and transient"""
2021-03-08 16:49:10 +00:00
2020-12-17 00:47:05 +00:00
quote = fields.HtmlField()
raw_quote = models.TextField(blank=True, null=True)
2021-09-05 23:00:40 +00:00
position = models.IntegerField(
validators=[MinValueValidator(0)], null=True, blank=True
)
position_mode = models.CharField(
max_length=3,
choices=ProgressMode.choices,
default=ProgressMode.PAGE,
null=True,
blank=True,
)
2020-03-21 23:50:49 +00:00
@property
2020-11-30 22:24:31 +00:00
def pure_content(self):
2021-04-26 16:15:42 +00:00
"""indicate the book in question for mastodon (or w/e) users"""
2021-03-08 16:49:10 +00:00
quote = re.sub(r"^<p>", '<p>"', self.quote)
quote = re.sub(r"</p>$", '"</p>', quote)
2021-09-20 23:44:59 +00:00
return (
f'{quote} <p>-- <a href="{self.book.remote_id}">'
2021-09-21 01:01:12 +00:00
f'"{self.book.title}"</a></p>{self.content}'
2021-09-20 23:44:59 +00:00
)
activity_serializer = activitypub.Quotation
class Review(BookStatus):
2021-04-26 16:15:42 +00:00
"""a book review"""
2021-03-08 16:49:10 +00:00
2020-11-30 22:24:31 +00:00
name = fields.CharField(max_length=255, null=True)
2021-03-19 19:14:59 +00:00
rating = fields.DecimalField(
default=None,
null=True,
blank=True,
2021-03-08 16:49:10 +00:00
validators=[MinValueValidator(1), MaxValueValidator(5)],
2021-03-19 19:14:59 +00:00
decimal_places=2,
max_digits=3,
)
2021-05-27 19:40:23 +00:00
field_tracker = FieldTracker(fields=["rating"])
@property
2020-11-30 22:24:31 +00:00
def pure_name(self):
2021-04-26 16:15:42 +00:00
"""clarify review names for mastodon serialization"""
template = get_template("snippets/generated_status/review_pure_name.html")
2021-03-24 16:34:21 +00:00
return template.render(
2021-03-24 16:51:49 +00:00
{"book": self.book, "rating": self.rating, "name": self.name}
2021-03-24 16:34:21 +00:00
).strip()
@property
2020-11-30 22:24:31 +00:00
def pure_content(self):
2021-04-26 16:15:42 +00:00
"""indicate the book in question for mastodon (or w/e) users"""
2020-12-18 19:34:21 +00:00
return self.content
activity_serializer = activitypub.Review
2021-03-08 16:49:10 +00:00
pure_type = "Article"
2022-01-17 20:17:24 +00:00
def save(self, *args, **kwargs):
"""clear rating caches"""
if self.book.parent_work:
cache.delete(f"book-rating-{self.book.parent_work.id}-*")
2022-01-17 20:17:24 +00:00
super().save(*args, **kwargs)
class ReviewRating(Review):
2021-04-26 16:15:42 +00:00
"""a subtype of review that only contains a rating"""
2021-03-08 17:54:02 +00:00
def save(self, *args, **kwargs):
if not self.rating:
2021-03-08 17:54:02 +00:00
raise ValueError("ReviewRating object must include a numerical rating")
return super().save(*args, **kwargs)
@property
def pure_content(self):
2021-03-14 02:24:35 +00:00
template = get_template("snippets/generated_status/rating.html")
2021-03-24 16:51:49 +00:00
return template.render({"book": self.book, "rating": self.rating}).strip()
activity_serializer = activitypub.Rating
2021-03-08 17:54:02 +00:00
pure_type = "Note"
2021-02-04 22:36:57 +00:00
class Boost(ActivityMixin, Status):
2021-04-26 16:15:42 +00:00
"""boost'ing a post"""
2021-03-08 16:49:10 +00:00
2020-11-30 22:24:31 +00:00
boosted_status = fields.ForeignKey(
2021-03-08 16:49:10 +00:00
"Status",
on_delete=models.PROTECT,
2021-03-08 16:49:10 +00:00
related_name="boosters",
activitypub_field="object",
2020-11-30 22:24:31 +00:00
)
2021-02-16 05:20:00 +00:00
activity_serializer = activitypub.Announce
2021-02-07 05:26:39 +00:00
2021-02-11 00:00:02 +00:00
def save(self, *args, **kwargs):
2021-04-26 16:15:42 +00:00
"""save and notify"""
2021-04-23 02:36:27 +00:00
# This constraint can't work as it would cross tables.
# class Meta:
# unique_together = ('user', 'boosted_status')
2021-04-23 17:56:17 +00:00
if (
Boost.objects.filter(boosted_status=self.boosted_status, user=self.user)
.exclude(id=self.id)
.exists()
):
2021-04-23 02:36:27 +00:00
return
2021-02-11 00:00:02 +00:00
super().save(*args, **kwargs)
2020-12-15 19:15:06 +00:00
def __init__(self, *args, **kwargs):
2021-04-26 16:15:42 +00:00
"""the user field is "actor" here instead of "attributedTo" """
2020-12-15 19:15:06 +00:00
super().__init__(*args, **kwargs)
reserve_fields = ["user", "boosted_status", "published_date", "privacy"]
2021-03-08 16:49:10 +00:00
self.simple_fields = [f for f in self.simple_fields if f.name in reserve_fields]
2020-12-15 19:15:06 +00:00
self.activity_fields = self.simple_fields
self.many_to_many_fields = []
self.image_fields = []
self.deserialize_reverse_fields = []
2021-05-26 07:10:05 +00:00
# pylint: disable=unused-argument
2021-06-18 22:28:43 +00:00
@receiver(models.signals.post_save)
2021-05-26 07:10:05 +00:00
def preview_image(instance, sender, *args, **kwargs):
2021-06-18 22:28:43 +00:00
"""Updates book previews if the rating has changed"""
if not ENABLE_PREVIEW_IMAGES or sender not in (Review, ReviewRating):
return
changed_fields = instance.field_tracker.changed()
2021-05-27 19:40:23 +00:00
2021-06-18 22:28:43 +00:00
if len(changed_fields) > 0:
edition = instance.book
generate_edition_preview_image_task.delay(edition.id)