diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index ed19af992..6401bb89a 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -4,8 +4,6 @@ from json import JSONEncoder from django.apps import apps from django.db import transaction -from django.db.models.fields.files import ImageFileDescriptor -from django.db.models.fields.related_descriptors import ManyToManyDescriptor from bookwyrm.connectors import ConnectorException, get_data from bookwyrm.tasks import app @@ -77,55 +75,30 @@ class ActivityObject: ) # check for an existing instance, if we're not updating a known obj - if not instance: - instance = model.find_existing(self.serialize()) or model() + instance = instance or model.find_existing(self.serialize()) or model() - many_to_many_fields = {} - image_fields = {} - for field in model._meta.get_fields(): - # check if it's an activitypub field - if not hasattr(field, 'field_to_activity'): - continue - # call the formatter associated with the model field class - value = field.field_from_activity( - getattr(self, field.get_activitypub_field()) - ) - if value is None or value is MISSING: - continue + for field in instance.simple_fields: + field.set_field_from_activity(instance, self) - model_field = getattr(model, field.name) - - if isinstance(model_field, ManyToManyDescriptor): - # status mentions book/users for example, stash this for later - many_to_many_fields[field.name] = value - elif isinstance(model_field, ImageFileDescriptor): - # image fields need custom handling - image_fields[field.name] = value - else: - # just a good old fashioned model.field = value - setattr(instance, field.name, value) - - # if this isn't here, it messes up saving users. who even knows. - for (model_key, value) in image_fields.items(): - getattr(instance, model_key).save(*value, save=save) + # image fields have to be set after other fields because they can save + # too early and jank up users + for field in instance.image_fields: + field.set_field_from_activity(instance, self, save=save) if not save: - # we can't set many to many and reverse fields on an unsaved object return instance + # we can't set many to many and reverse fields on an unsaved object instance.save() # add many to many fields, which have to be set post-save - for (model_key, values) in many_to_many_fields.items(): + for field in instance.many_to_many_fields: # mention books/users, for example - getattr(instance, model_key).set(values) - - if not save or not hasattr(model, 'deserialize_reverse_fields'): - return instance + field.set_field_from_activity(instance, self) # reversed relationships in the models for (model_field_name, activity_field_name) in \ - model.deserialize_reverse_fields: + instance.deserialize_reverse_fields: # attachments on Status, for example values = getattr(self, activity_field_name) if values is None or values is MISSING: diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index 4964d3939..fe521772a 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -185,12 +185,13 @@ def handle_follow_reject(activity): def handle_create(activity): ''' someone did something, good on them ''' # deduplicate incoming activities - status_id = activity['object']['id'] + activity = activity['object'] + status_id = activity['id'] if models.Status.objects.filter(remote_id=status_id).count(): return serializer = activitypub.activity_objects[activity['type']] - status = serializer(**activity) + activity = serializer(**activity) try: model = models.activity_models[activity.type] except KeyError: @@ -198,13 +199,25 @@ def handle_create(activity): return if activity.type == 'Note': + # keep notes if they are replies to existing statuses reply = models.Status.objects.filter( remote_id=activity.inReplyTo ).first() - if not reply: - return - activity.to_model(model) + if not reply: + discard = True + # keep notes if they mention local users + tags = [l['href'] for l in activity.tag if l['type'] == 'Mention'] + for tag in tags: + if models.User.objects.filter( + remote_id=tag, local=True).exists(): + # we found a mention of a known use boost + discard = False + break + if discard: + return + + status = activity.to_model(model) # create a notification if this is a reply if status.reply_parent and status.reply_parent.user.local: status_builder.create_notification( diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index b9a2814e6..86bdf2198 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -25,8 +25,3 @@ from .site import SiteSettings, SiteInvite, PasswordReset cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass) activity_models = {c[1].activity_serializer.__name__: c[1] \ for c in cls_members if hasattr(c[1], 'activity_serializer')} - -def to_activity(activity_json): - ''' link up models and activities ''' - activity_type = activity_json.get('type') - return activity_models[activity_type].to_activity(activity_json) diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index dd3065c9e..08cc60529 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -14,16 +14,9 @@ from django.dispatch import receiver from bookwyrm import activitypub from bookwyrm.settings import DOMAIN, PAGE_LENGTH -from .fields import RemoteIdField +from .fields import ImageField, ManyToManyField, RemoteIdField -PrivacyLevels = models.TextChoices('Privacy', [ - 'public', - 'unlisted', - 'followers', - 'direct' -]) - class BookWyrmModel(models.Model): ''' shared fields ''' created_date = models.DateTimeField(auto_now_add=True) @@ -68,6 +61,33 @@ class ActivitypubMixin: activity_serializer = lambda: {} reverse_unfurl = False + def __init__(self, *args, **kwargs): + ''' collect some info on model fields ''' + self.image_fields = [] + self.many_to_many_fields = [] + self.simple_fields = [] # "simple" + for field in self._meta.get_fields(): + if not hasattr(field, 'field_to_activity'): + continue + + if isinstance(field, ImageField): + self.image_fields.append(field) + elif isinstance(field, ManyToManyField): + self.many_to_many_fields.append(field) + else: + self.simple_fields.append(field) + + self.activity_fields = self.image_fields + \ + self.many_to_many_fields + self.simple_fields + + self.deserialize_reverse_fields = self.deserialize_reverse_fields \ + if hasattr(self, 'deserialize_reverse_fields') else [] + self.serialize_reverse_fields = self.serialize_reverse_fields \ + if hasattr(self, 'serialize_reverse_fields') else [] + + super().__init__(*args, **kwargs) + + @classmethod def find_existing_by_remote_id(cls, remote_id): ''' look up a remote id in the db ''' @@ -115,19 +135,8 @@ class ActivitypubMixin: def to_activity(self): ''' convert from a model to an activity ''' activity = {} - for field in self._meta.get_fields(): - if not hasattr(field, 'field_to_activity'): - continue - value = field.field_to_activity(getattr(self, field.name)) - if value is None: - continue - - key = field.get_activitypub_field() - if key in activity and isinstance(activity[key], list): - # handles tags on status, which accumulate across fields - activity[key] += value - else: - activity[key] = value + for field in self.activity_fields: + field.set_activity_from_field(activity, self) if hasattr(self, 'serialize_reverse_fields'): # for example, editions of a work diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index e6878fb90..34f90103b 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -1,4 +1,5 @@ ''' activitypub-aware django model fields ''' +from dataclasses import MISSING import re from uuid import uuid4 @@ -38,6 +39,30 @@ class ActivitypubFieldMixin: self.activitypub_field = activitypub_field super().__init__(*args, **kwargs) + + def set_field_from_activity(self, instance, data): + ''' helper function for assinging a value to the field ''' + value = getattr(data, self.get_activitypub_field()) + formatted = self.field_from_activity(value) + if formatted is None or formatted is MISSING: + return + setattr(instance, self.name, formatted) + + + def set_activity_from_field(self, activity, instance): + ''' update the json object ''' + value = getattr(instance, self.name) + formatted = self.field_to_activity(value) + if formatted is None: + return + + key = self.get_activitypub_field() + if isinstance(activity.get(key), list): + activity[key] += formatted + else: + activity[key] = formatted + + def field_to_activity(self, value): ''' formatter to convert a model value into activitypub ''' if hasattr(self, 'activitypub_wrapper'): @@ -123,6 +148,52 @@ class UsernameField(ActivitypubFieldMixin, models.CharField): return value.split('@')[0] +PrivacyLevels = models.TextChoices('Privacy', [ + 'public', + 'unlisted', + 'followers', + 'direct' +]) + +class PrivacyField(ActivitypubFieldMixin, models.CharField): + ''' this maps to two differente activitypub fields ''' + public = 'https://www.w3.org/ns/activitystreams#Public' + def __init__(self, *args, **kwargs): + super().__init__( + *args, max_length=255, + choices=PrivacyLevels.choices, default='public') + + def set_field_from_activity(self, instance, data): + to = data.to + cc = data.cc + if to == [self.public]: + setattr(instance, self.name, 'public') + elif cc == []: + setattr(instance, self.name, 'direct') + elif self.public in cc: + setattr(instance, self.name, 'unlisted') + else: + setattr(instance, self.name, 'followers') + + def set_activity_from_field(self, activity, instance): + mentions = [u.remote_id for u in instance.mention_users.all()] + # this is a link to the followers list + followers = instance.user.__class__._meta.get_field('followers')\ + .field_to_activity(instance.user.followers) + if instance.privacy == 'public': + activity['to'] = [self.public] + activity['cc'] = [followers] + mentions + elif instance.privacy == 'unlisted': + activity['to'] = [followers] + activity['cc'] = [self.public] + mentions + elif instance.privacy == 'followers': + activity['to'] = [followers] + activity['cc'] = mentions + if instance.privacy == 'direct': + activity['to'] = mentions + activity['cc'] = [] + + class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey): ''' activitypub-aware foreign key field ''' def field_to_activity(self, value): @@ -145,6 +216,14 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField): self.link_only = link_only super().__init__(*args, **kwargs) + def set_field_from_activity(self, instance, data): + ''' helper function for assinging a value to the field ''' + value = getattr(data, self.get_activitypub_field()) + formatted = self.field_from_activity(value) + if formatted is None or formatted is MISSING: + return + getattr(instance, self.name).set(formatted) + def field_to_activity(self, value): if self.link_only: return '%s/%s' % (value.instance.remote_id, self.name) @@ -210,9 +289,20 @@ def image_serializer(value): class ImageField(ActivitypubFieldMixin, models.ImageField): ''' activitypub-aware image field ''' + # pylint: disable=arguments-differ + def set_field_from_activity(self, instance, data, save=True): + ''' helper function for assinging a value to the field ''' + value = getattr(data, self.get_activitypub_field()) + formatted = self.field_from_activity(value) + if formatted is None or formatted is MISSING: + return + getattr(instance, self.name).save(*formatted, save=save) + + def field_to_activity(self, value): return image_serializer(value) + def field_from_activity(self, value): image_slug = value # when it's an inline image (User avatar/icon, Book cover), it's a json diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py index bf5d5caf5..835094cd7 100644 --- a/bookwyrm/models/import_job.py +++ b/bookwyrm/models/import_job.py @@ -8,7 +8,7 @@ from django.utils import timezone from bookwyrm import books_manager from bookwyrm.models import ReadThrough, User, Book -from .base_model import PrivacyLevels +from .fields import PrivacyLevels # Mapping goodreads -> bookwyrm shelf titles. diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index fc63d198e..68f3614fb 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -4,7 +4,7 @@ from django.db import models from bookwyrm import activitypub from .base_model import BookWyrmModel -from .base_model import OrderedCollectionMixin, PrivacyLevels +from .base_model import OrderedCollectionMixin from . import fields @@ -18,7 +18,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): privacy = fields.CharField( max_length=255, default='public', - choices=PrivacyLevels.choices + choices=fields.PrivacyLevels.choices ) books = models.ManyToManyField( 'Edition', diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 55036f2c9..0bf897dc1 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -6,7 +6,7 @@ from model_utils.managers import InheritanceManager from bookwyrm import activitypub from .base_model import ActivitypubMixin, OrderedCollectionPageMixin -from .base_model import BookWyrmModel, PrivacyLevels +from .base_model import BookWyrmModel from . import fields from .fields import image_serializer @@ -18,13 +18,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): mention_users = fields.TagField('User', related_name='mention_user') mention_books = fields.TagField('Edition', related_name='mention_book') local = models.BooleanField(default=True) - privacy = models.CharField( - max_length=255, - default='public', - choices=PrivacyLevels.choices - ) + privacy = fields.PrivacyField(max_length=255) sensitive = fields.BooleanField(default=False) - # the created date can't be this, because of receiving federated posts + # created date is different than publish date because of federated posts published_date = fields.DateTimeField( default=timezone.now, activitypub_field='published') deleted = models.BooleanField(default=False) @@ -48,12 +44,13 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): serialize_reverse_fields = [('attachments', 'attachment')] deserialize_reverse_fields = [('attachments', 'attachment')] - #----- replies collection activitypub ----# @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() + return cls.objects.filter( + reply_parent=status + ).select_subclasses().order_by('published_date') @property def status_type(self): @@ -68,7 +65,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): **kwargs ) - def to_activity(self, pure=False): + def to_activity(self, pure=False):# pylint: disable=arguments-differ ''' return tombstone if the status is deleted ''' if self.deleted: return activitypub.Tombstone( @@ -80,25 +77,6 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): activity = ActivitypubMixin.to_activity(self) activity['replies'] = self.to_replies() - # privacy controls - public = 'https://www.w3.org/ns/activitystreams#Public' - mentions = [u.remote_id for u in self.mention_users.all()] - # this is a link to the followers list: - followers = self.user.__class__._meta.get_field('followers')\ - .field_to_activity(self.user.followers) - if self.privacy == 'public': - activity['to'] = [public] - activity['cc'] = [followers] + mentions - elif self.privacy == 'unlisted': - activity['to'] = [followers] - activity['cc'] = [public] + mentions - elif self.privacy == 'followers': - activity['to'] = [followers] - activity['cc'] = mentions - if self.privacy == 'direct': - activity['to'] = mentions - activity['cc'] = [] - # "pure" serialization for non-bookwyrm instances if pure: activity['content'] = self.pure_content @@ -190,6 +168,7 @@ class Review(Status): def pure_name(self): ''' clarify review names for mastodon serialization ''' if self.rating: + #pylint: disable=bad-string-format-type return 'Review of "%s" (%d stars): %s' % ( self.book.title, self.rating, diff --git a/bookwyrm/templates/direct_messages.html b/bookwyrm/templates/direct_messages.html new file mode 100644 index 000000000..6a20b1114 --- /dev/null +++ b/bookwyrm/templates/direct_messages.html @@ -0,0 +1,37 @@ +{% extends 'layout.html' %} +{% block content %} + +
+

Direct Messages

+ + {% if not activities %} +

You have no messages right now.

+ {% endif %} + {% for activity in activities %} +
+ {% include 'snippets/status.html' with status=activity %} +
+ {% endfor %} + + +
+ +{% endblock %} diff --git a/bookwyrm/templates/layout.html b/bookwyrm/templates/layout.html index bcbdca2a2..ab113ad09 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -68,6 +68,9 @@ {% include 'snippets/username.html' with user=request.user %}