moviewyrm/bookwyrm/models/status.py
2022-02-28 10:47:08 -08:00

449 lines
15 KiB
Python

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