bookwyrm-mastodon/bookwyrm/models/status.py

450 lines
15 KiB
Python
Raw Permalink Normal View History

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