moviewyrm/bookwyrm/models/status.py

391 lines
13 KiB
Python
Raw Permalink 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
2020-02-11 23:17:21 +00:00
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
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
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-02-04 22:36:57 +00:00
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .activitypub_mixin import OrderedCollectionPageMixin
from .base_model import BookWyrmModel
2020-11-30 22:24:31 +00:00
from .fields import image_serializer
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-03-08 16:49:10 +00:00
""" any post, like a reply to a review, etc """
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)
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"
)
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",
)
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:
""" default sorting """
2021-03-24 15:39:37 +00:00
2021-03-24 15:37:25 +00:00
ordering = ("-published_date",)
2021-02-10 22:13:36 +00:00
def save(self, *args, **kwargs):
2021-03-08 16:49:10 +00:00
""" save and notify """
2021-02-10 22:13:36 +00:00
super().save(*args, **kwargs)
2021-03-08 16:49:10 +00:00
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
2021-02-11 00:00:02 +00:00
if self.deleted:
notification_model.objects.filter(related_status=self).delete()
return
2021-02-11 00:00:02 +00:00
2021-03-08 16:49:10 +00:00
if (
self.reply_parent
and self.reply_parent.user != self.user
and self.reply_parent.user.local
):
2021-02-10 22:13:36 +00:00
notification_model.objects.create(
user=self.reply_parent.user,
2021-03-08 16:49:10 +00:00
notification_type="REPLY",
2021-02-10 22:13:36 +00:00
related_user=self.user,
related_status=self,
)
for mention_user in self.mention_users.all():
# avoid double-notifying about this status
2021-03-08 16:49:10 +00:00
if not mention_user.local or (
self.reply_parent and mention_user == self.reply_parent.user
):
2021-02-10 22:13:36 +00:00
continue
notification_model.objects.create(
user=mention_user,
2021-03-08 16:49:10 +00:00
notification_type="MENTION",
2021-02-10 22:13:36 +00:00
related_user=self.user,
related_status=self,
)
2021-03-08 16:49:10 +00:00
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
""" "delete" a status """
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
self.deleted_date = timezone.now()
self.save()
@property
def recipients(self):
2021-03-08 16:49:10 +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-03-08 16:49:10 +00:00
""" 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)
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-03-08 16:49:10 +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-03-08 16:49:10 +00:00
""" you can't boost dms """
return self.privacy in ["unlisted", "public"]
2020-12-18 17:30:08 +00:00
def to_replies(self, **kwargs):
2021-03-08 16:49:10 +00:00
""" helper function for loading AP serialized replies to a status """
return self.to_ordered_collection(
self.replies(self),
2021-03-08 16:49:10 +00:00
remote_id="%s/replies" % self.remote_id,
collection_only=True,
**kwargs
).serialize()
2021-03-08 16:49:10 +00:00
def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ
""" 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
activity.attachment = [
2021-03-08 16:49:10 +00:00
image_serializer(b.cover, b.alt_text)
for b in self.mention_books.all()[:4]
if b.cover
]
if hasattr(self, "book") and self.book.cover:
2021-02-20 19:24:41 +00:00
activity.attachment.append(
2020-12-17 20:46:05 +00:00
image_serializer(self.book.cover, self.book.alt_text)
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
""" json serialized activitypub class """
2021-02-20 19:24:41 +00:00
return self.to_activity_dataclass(pure=pure).serialize()
2020-10-08 19:32:45 +00:00
2020-10-30 22:22:20 +00:00
class GeneratedNote(Status):
2021-03-08 16:49:10 +00:00
""" these are app-generated messages about user activity """
@property
2020-11-30 22:24:31 +00:00
def pure_content(self):
2021-03-08 16:49:10 +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(
'<a href="%s">"%s"</a>' % (book.remote_id, book.title)
2020-10-08 19:32:45 +00:00
for book in self.mention_books.all()
)
2021-03-08 16:49:10 +00:00
return "%s %s %s" % (self.user.display_name, message, books)
activity_serializer = activitypub.GeneratedNote
2021-03-08 16:49:10 +00:00
pure_type = "Note"
2020-03-21 23:50:49 +00:00
class Comment(Status):
2021-03-08 16:49:10 +00:00
""" like a review but without a rating and transient """
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"
)
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-03-08 16:49:10 +00:00
""" indicate the book in question for mastodon (or w/e) users """
return '%s<p>(comment on <a href="%s">"%s"</a>)</p>' % (
self.content,
self.book.remote_id,
self.book.title,
)
activity_serializer = activitypub.Comment
2021-03-08 16:49:10 +00:00
pure_type = "Note"
2020-04-08 16:40:47 +00:00
class Quotation(Status):
2021-03-08 16:49:10 +00:00
""" like a review but without a rating and transient """
2020-12-17 00:47:05 +00:00
quote = fields.HtmlField()
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"
)
2020-03-21 23:50:49 +00:00
@property
2020-11-30 22:24:31 +00:00
def pure_content(self):
2021-03-08 16:49:10 +00:00
""" 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 '%s <p>-- <a href="%s">"%s"</a></p>%s' % (
quote,
2020-11-13 17:47:35 +00:00
self.book.remote_id,
self.book.title,
self.content,
)
activity_serializer = activitypub.Quotation
2021-03-08 16:49:10 +00:00
pure_type = "Note"
class Review(Status):
2021-03-08 16:49:10 +00:00
""" a book review """
2020-11-30 22:24:31 +00:00
name = fields.CharField(max_length=255, null=True)
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"
)
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,
)
@property
2020-11-30 22:24:31 +00:00
def pure_name(self):
2021-03-08 16:49:10 +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-03-08 16:49:10 +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"
class ReviewRating(Review):
2021-03-08 17:54:02 +00:00
""" a subtype of review that only contains a rating """
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-03-08 16:49:10 +00:00
""" boost'ing a post """
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-03-08 16:49:10 +00:00
""" save and notify """
2021-02-11 00:00:02 +00:00
super().save(*args, **kwargs)
if not self.boosted_status.user.local or self.boosted_status.user == self.user:
2021-02-11 00:00:02 +00:00
return
2021-03-08 16:49:10 +00:00
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
2021-02-11 00:00:02 +00:00
notification_model.objects.create(
user=self.boosted_status.user,
related_status=self.boosted_status,
related_user=self.user,
2021-03-08 16:49:10 +00:00
notification_type="BOOST",
2021-02-11 00:00:02 +00:00
)
def delete(self, *args, **kwargs):
2021-03-08 16:49:10 +00:00
""" delete and un-notify """
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
2021-02-11 00:00:02 +00:00
notification_model.objects.filter(
user=self.boosted_status.user,
related_status=self.boosted_status,
related_user=self.user,
2021-03-08 16:49:10 +00:00
notification_type="BOOST",
2021-02-11 00:00:02 +00:00
).delete()
super().delete(*args, **kwargs)
2020-12-15 19:15:06 +00:00
def __init__(self, *args, **kwargs):
2021-03-08 16:49:10 +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 = []
# This constraint can't work as it would cross tables.
# class Meta:
# unique_together = ('user', 'boosted_status')