moviewyrm/bookwyrm/models/status.py

349 lines
12 KiB
Python
Raw Normal View History

2020-02-11 23:17:21 +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
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
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-02-04 22:36:57 +00:00
from . import fields
2020-09-21 15:16:34 +00:00
class Status(OrderedCollectionPageMixin, BookWyrmModel):
''' any post, like a reply to a review, etc '''
2020-11-30 22:24:31 +00:00
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='attributedTo')
2020-12-17 00:47:05 +00:00
content = fields.HtmlField(blank=True, null=True)
2020-11-30 22:24:31 +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(
2020-12-17 03:20:15 +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(
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(
'User',
symmetrical=False,
through='Favorite',
through_fields=('status', 'user'),
related_name='user_favorites'
)
2020-11-30 22:24:31 +00:00
reply_parent = fields.ForeignKey(
'self',
null=True,
2020-11-30 22:24:31 +00:00
on_delete=models.PROTECT,
activitypub_field='inReplyTo',
)
objects = InheritanceManager()
activity_serializer = activitypub.Note
2021-01-11 19:16:10 +00:00
serialize_reverse_fields = [('attachments', 'attachment', 'id')]
deserialize_reverse_fields = [('attachments', 'attachment')]
2021-02-10 23:18:20 +00:00
2021-02-10 22:13:36 +00:00
def save(self, *args, **kwargs):
''' save and notify '''
super().save(*args, **kwargs)
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()
2021-02-10 23:18:20 +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,
notification_type='REPLY',
related_user=self.user,
related_status=self,
)
for mention_user in self.mention_users.all():
# avoid double-notifying about this status
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,
notification_type='MENTION',
related_user=self.user,
related_status=self,
)
2021-02-17 03:35:43 +00:00
def delete(self, *args, **kwargs):#pylint: disable=unused-argument
2021-02-16 17:35:00 +00:00
''' "delete" a status '''
2021-02-17 21:07:19 +00:00
if hasattr(self, 'boosted_status'):
# 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):
''' 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))
2020-12-18 20:38:27 +00:00
@classmethod
def ignore_activity(cls, activity):
''' keep notes if they are replies to existing statuses '''
2021-02-16 20:31:27 +00:00
if activity.type == 'Announce':
# keep it if the booster or the boosted are local
boosted = activitypub.resolve_remote_id(activity.object, save=False)
return cls.ignore_activity(boosted.to_activity_dataclass())
# keep if it if it's a custom type
2020-12-18 20:38:27 +00:00
if activity.type != 'Note':
return False
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']
2021-02-16 20:31:27 +00:00
user_model = apps.get_model('bookwyrm.User', require_ready=True)
2020-12-18 20:38:27 +00:00
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')
2020-09-28 22:57:31 +00:00
@property
def status_type(self):
''' expose the type of status for the ui using activity type '''
return self.activity_serializer.__name__
2020-12-18 17:30:08 +00:00
@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='%s/replies' % self.remote_id,
collection_only=True,
**kwargs
).serialize()
2021-02-20 19:24:41 +00:00
def to_activity_dataclass(self, pure=False):# pylint: disable=arguments-differ
2020-10-08 19:32:45 +00:00
''' return tombstone if the status is deleted '''
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(),
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
2020-12-16 23:59:42 +00:00
if pure and hasattr(self, 'pure_content'):
2021-02-20 19:24:41 +00:00
activity.content = self.pure_content
if hasattr(activity, 'name'):
activity.name = self.pure_name
activity.type = self.pure_type
activity.attachment = [
2020-12-17 20:46:05 +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-02-20 19:24:41 +00:00
def to_activity(self, pure=False):# pylint: disable=arguments-differ
''' json serialized activitypub class '''
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):
''' these are app-generated messages about user activity '''
@property
2020-11-30 22:24:31 +00:00
def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
message = self.content
books = ', '.join(
2020-11-30 22:24:31 +00:00
'<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()
)
2020-11-30 22:24:31 +00:00
return '%s %s %s' % (self.user.display_name, message, books)
activity_serializer = activitypub.GeneratedNote
2020-11-30 22:24:31 +00:00
pure_type = 'Note'
2020-03-21 23:50:49 +00:00
class Comment(Status):
''' like a review but without a rating and transient '''
2020-12-03 21:14:04 +00:00
book = fields.ForeignKey(
'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):
''' indicate the book in question for mastodon (or w/e) users '''
return '%s<p>(comment on <a href="%s">"%s"</a>)</p>' % \
2020-12-18 19:00:30 +00:00
(self.content, self.book.remote_id, self.book.title)
activity_serializer = activitypub.Comment
2020-11-30 22:24:31 +00:00
pure_type = 'Note'
2020-04-08 16:40:47 +00:00
class Quotation(Status):
''' 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(
'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):
''' 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
2020-11-30 22:24:31 +00:00
pure_type = 'Note'
class Review(Status):
''' 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(
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
2020-11-30 22:24:31 +00:00
rating = fields.IntegerField(
default=None,
null=True,
blank=True,
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
@property
2020-11-30 22:24:31 +00:00
def pure_name(self):
''' clarify review names for mastodon serialization '''
if self.rating:
return 'Review of "{}" ({:d} stars): {}'.format(
self.book.title,
self.rating,
self.name
)
return 'Review of "{}": {}'.format(
self.book.title,
self.name
)
@property
2020-11-30 22:24:31 +00:00
def pure_content(self):
''' 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
2020-11-30 22:24:31 +00:00
pure_type = 'Article'
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):
return 'Rated "{}": {:d} stars'.format(self.book.title, self.rating)
activity_serializer = activitypub.Rating
pure_type = 'Note'
2021-02-04 22:36:57 +00:00
class Boost(ActivityMixin, Status):
''' boost'ing a post '''
2020-11-30 22:24:31 +00:00
boosted_status = fields.ForeignKey(
'Status',
on_delete=models.PROTECT,
2020-11-30 22:24:31 +00:00
related_name='boosters',
activitypub_field='object',
)
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):
''' save and notify '''
super().save(*args, **kwargs)
if not self.boosted_status.user.local:
return
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
notification_model.objects.create(
user=self.boosted_status.user,
related_status=self.boosted_status,
related_user=self.user,
notification_type='BOOST',
)
def delete(self, *args, **kwargs):
''' delete and un-notify '''
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
notification_model.objects.filter(
user=self.boosted_status.user,
related_status=self.boosted_status,
related_user=self.user,
notification_type='BOOST',
).delete()
super().delete(*args, **kwargs)
2020-12-15 19:15:06 +00:00
def __init__(self, *args, **kwargs):
''' the user field is "actor" here instead of "attributedTo" '''
super().__init__(*args, **kwargs)
reserve_fields = ['user', 'boosted_status']
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 = []
# This constraint can't work as it would cross tables.
# class Meta:
# unique_together = ('user', 'boosted_status')