From ffc4cc2018faf1637f20c2eaf9006e6d4aae604c Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 12 Dec 2020 20:59:41 -0800 Subject: [PATCH 01/12] Fixes create status handler --- bookwyrm/incoming.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index 4964d3939..de2c5bcc5 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,14 @@ def handle_create(activity): return if activity.type == 'Note': + # discard notes that aren't replies to existing statuses reply = models.Status.objects.filter( remote_id=activity.inReplyTo ).first() if not reply: return - activity.to_model(model) + 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( From d65657882e5fda65b977155a17c52aaa235916bb Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 12 Dec 2020 21:11:51 -0800 Subject: [PATCH 02/12] Keep any status that mentions a local user --- bookwyrm/incoming.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index de2c5bcc5..fe521772a 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -199,12 +199,23 @@ def handle_create(activity): return if activity.type == 'Note': - # discard notes that aren't replies to existing statuses + # keep notes if they are replies to existing statuses reply = models.Status.objects.filter( remote_id=activity.inReplyTo ).first() + if not reply: - return + 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 From 957f0889aaefba4fbd749469af074de2643c7a15 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 13 Dec 2020 11:15:42 -0800 Subject: [PATCH 03/12] Clean up models removes unused function and sorts replies correctly --- bookwyrm/models/__init__.py | 5 ----- bookwyrm/models/status.py | 9 ++++++--- 2 files changed, 6 insertions(+), 8 deletions(-) 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/status.py b/bookwyrm/models/status.py index 55036f2c9..43fc45115 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -24,7 +24,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): choices=PrivacyLevels.choices ) 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) @@ -53,7 +53,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): 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 +70,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( @@ -190,6 +192,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, From b67aea22fc707c2b06ee59a49d69aadd3794dda3 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 13 Dec 2020 11:16:12 -0800 Subject: [PATCH 04/12] Aggregates (de)serializable model fields --- bookwyrm/models/base_model.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index dd3065c9e..d9aafcce1 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -10,6 +10,8 @@ from Crypto.Hash import SHA256 from django.core.paginator import Paginator from django.db import models from django.db.models import Q +from django.db.models.fields.files import ImageFileDescriptor +from django.db.models.fields.related_descriptors import ManyToManyDescriptor from django.dispatch import receiver from bookwyrm import activitypub @@ -68,6 +70,30 @@ 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, ImageFileDescriptor): + self.image_fields.append(field) + elif isinstance(field, ManyToManyDescriptor): + self.many_to_many_fields.append(field) + else: + self.simple_fields.append(field) + + 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 ''' From c470aeb3cec449d1eec4e22c4c28cb4afb19e37c Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 13 Dec 2020 12:02:26 -0800 Subject: [PATCH 05/12] Create helper function on field for settings values --- bookwyrm/activitypub/base_activity.py | 49 ++++++--------------------- bookwyrm/models/base_model.py | 8 ++--- bookwyrm/models/fields.py | 30 ++++++++++++++++ 3 files changed, 44 insertions(+), 43 deletions(-) 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/models/base_model.py b/bookwyrm/models/base_model.py index d9aafcce1..e899b3c51 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -10,13 +10,11 @@ from Crypto.Hash import SHA256 from django.core.paginator import Paginator from django.db import models from django.db.models import Q -from django.db.models.fields.files import ImageFileDescriptor -from django.db.models.fields.related_descriptors import ManyToManyDescriptor 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', [ @@ -79,9 +77,9 @@ class ActivitypubMixin: if not hasattr(field, 'field_to_activity'): continue - if isinstance(field, ImageFileDescriptor): + if isinstance(field, ImageField): self.image_fields.append(field) - elif isinstance(field, ManyToManyDescriptor): + elif isinstance(field, ManyToManyField): self.many_to_many_fields.append(field) else: self.simple_fields.append(field) diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index e6878fb90..21bdfadb1 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,16 @@ 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 field_to_activity(self, value): ''' formatter to convert a model value into activitypub ''' if hasattr(self, 'activitypub_wrapper'): @@ -145,6 +156,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 +229,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 From b6907f39e97529211f53ee175ed657f999261ecf Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 13 Dec 2020 13:03:17 -0800 Subject: [PATCH 06/12] Creates Privacy field that handles setting to/cc --- bookwyrm/models/base_model.py | 25 +++------------ bookwyrm/models/fields.py | 60 +++++++++++++++++++++++++++++++++++ bookwyrm/models/import_job.py | 2 +- bookwyrm/models/shelf.py | 4 +-- bookwyrm/models/status.py | 28 ++-------------- 5 files changed, 70 insertions(+), 49 deletions(-) diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index e899b3c51..08cc60529 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -17,13 +17,6 @@ from bookwyrm.settings import DOMAIN, PAGE_LENGTH 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) @@ -84,6 +77,9 @@ class ActivitypubMixin: 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 \ @@ -139,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 21bdfadb1..05453397b 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -49,6 +49,20 @@ class ActivitypubFieldMixin: 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] = value + + def field_to_activity(self, value): ''' formatter to convert a model value into activitypub ''' if hasattr(self, 'activitypub_wrapper'): @@ -134,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 self.privacy == 'public': + activity['to'] = [self.public] + activity['cc'] = [followers] + mentions + elif self.privacy == 'unlisted': + activity['to'] = [followers] + activity['cc'] = [self.public] + mentions + elif self.privacy == 'followers': + activity['to'] = [followers] + activity['cc'] = mentions + if self.privacy == 'direct': + activity['to'] = mentions + activity['cc'] = [] + + class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey): ''' activitypub-aware foreign key field ''' def field_to_activity(self, value): 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 43fc45115..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,11 +18,7 @@ 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) # created date is different than publish date because of federated posts published_date = fields.DateTimeField( @@ -48,7 +44,6 @@ 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 @@ -82,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 From 44cbf7c07fd9449dca81b56e798b518fe8be034c Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 13 Dec 2020 14:35:56 -0800 Subject: [PATCH 07/12] Fixes checking privacy when serializing status --- bookwyrm/models/fields.py | 8 +- .../tests/activitypub/test_base_activity.py | 113 +++++++++++++----- 2 files changed, 84 insertions(+), 37 deletions(-) diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index 05453397b..960be1612 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -180,16 +180,16 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField): # this is a link to the followers list followers = instance.user.__class__._meta.get_field('followers')\ .field_to_activity(instance.user.followers) - if self.privacy == 'public': + if instance.privacy == 'public': activity['to'] = [self.public] activity['cc'] = [followers] + mentions - elif self.privacy == 'unlisted': + elif instance.privacy == 'unlisted': activity['to'] = [followers] activity['cc'] = [self.public] + mentions - elif self.privacy == 'followers': + elif instance.privacy == 'followers': activity['to'] = [followers] activity['cc'] = mentions - if self.privacy == 'direct': + if instance.privacy == 'direct': activity['to'] = mentions activity['cc'] = [] diff --git a/bookwyrm/tests/activitypub/test_base_activity.py b/bookwyrm/tests/activitypub/test_base_activity.py index 88997c447..87420aa70 100644 --- a/bookwyrm/tests/activitypub/test_base_activity.py +++ b/bookwyrm/tests/activitypub/test_base_activity.py @@ -95,34 +95,67 @@ class BaseActivity(TestCase): self.assertEqual(result.remote_id, 'https://example.com/user/mouse') self.assertEqual(result.name, 'MOUSE?? MOUSE!!') - def test_to_model(self): - ''' the big boy of this module. it feels janky to test this with actual - models rather than a test model, but I don't know how to make a test - model so here we are. ''' + def test_to_model_invalid_model(self): + ''' catch mismatch between activity type and model type ''' instance = ActivityObject(id='a', type='b') with self.assertRaises(ActivitySerializerError): instance.to_model(models.User) - # test setting simple fields + def test_to_model_simple_fields(self): + ''' test setting simple fields ''' self.assertEqual(self.user.name, '') - update_data = activitypub.Person(**self.user.to_activity()) - update_data.name = 'New Name' - update_data.to_model(models.User, self.user) + + activity = activitypub.Person( + id=self.user.remote_id, + name='New Name', + preferredUsername='mouse', + inbox='http://www.com/', + outbox='http://www.com/', + followers='', + summary='', + publicKey=None, + endpoints={}, + ) + + activity.to_model(models.User, self.user) self.assertEqual(self.user.name, 'New Name') def test_to_model_foreign_key(self): ''' test setting one to one/foreign key ''' - update_data = activitypub.Person(**self.user.to_activity()) - update_data.publicKey['publicKeyPem'] = 'hi im secure' - update_data.to_model(models.User, self.user) + activity = activitypub.Person( + id=self.user.remote_id, + name='New Name', + preferredUsername='mouse', + inbox='http://www.com/', + outbox='http://www.com/', + followers='', + summary='', + publicKey=self.user.key_pair.to_activity(), + endpoints={}, + ) + + activity.publicKey['publicKeyPem'] = 'hi im secure' + + activity.to_model(models.User, self.user) self.assertEqual(self.user.key_pair.public_key, 'hi im secure') @responses.activate def test_to_model_image(self): ''' update an image field ''' - update_data = activitypub.Person(**self.user.to_activity()) - update_data.icon = {'url': 'http://www.example.com/image.jpg'} + activity = activitypub.Person( + id=self.user.remote_id, + name='New Name', + preferredUsername='mouse', + inbox='http://www.com/', + outbox='http://www.com/', + followers='', + summary='', + publicKey=None, + endpoints={}, + icon={'url': 'http://www.example.com/image.jpg'} + ) + responses.add( responses.GET, 'http://www.example.com/image.jpg', @@ -133,7 +166,7 @@ class BaseActivity(TestCase): with self.assertRaises(ValueError): self.user.avatar.file #pylint: disable=pointless-statement - update_data.to_model(models.User, self.user) + activity.to_model(models.User, self.user) self.assertIsNotNone(self.user.avatar.name) self.assertIsNotNone(self.user.avatar.file) @@ -145,19 +178,26 @@ class BaseActivity(TestCase): ) book = models.Edition.objects.create( title='Test Edition', remote_id='http://book.com/book') - update_data = activitypub.Note(**status.to_activity()) - update_data.tag = [ - { - 'type': 'Mention', - 'name': 'gerald', - 'href': 'http://example.com/a/b' - }, - { - 'type': 'Edition', - 'name': 'gerald j. books', - 'href': 'http://book.com/book' - }, - ] + update_data = activitypub.Note( + id=status.remote_id, + content=status.content, + attributedTo=self.user.remote_id, + published='hi', + to=[], + cc=[], + tag=[ + { + 'type': 'Mention', + 'name': 'gerald', + 'href': 'http://example.com/a/b' + }, + { + 'type': 'Edition', + 'name': 'gerald j. books', + 'href': 'http://book.com/book' + }, + ] + ) update_data.to_model(models.Status, instance=status) self.assertEqual(status.mention_users.first(), self.user) self.assertEqual(status.mention_books.first(), book) @@ -171,12 +211,19 @@ class BaseActivity(TestCase): content='test status', user=self.user, ) - update_data = activitypub.Note(**status.to_activity()) - update_data.attachment = [{ - 'url': 'http://www.example.com/image.jpg', - 'name': 'alt text', - 'type': 'Image', - }] + update_data = activitypub.Note( + id=status.remote_id, + content=status.content, + attributedTo=self.user.remote_id, + published='hi', + to=[], + cc=[], + attachment=[{ + 'url': 'http://www.example.com/image.jpg', + 'name': 'alt text', + 'type': 'Image', + }], + ) responses.add( responses.GET, From 5c7ac4611642fff72233e9db7ca92a847940e047 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 13 Dec 2020 14:53:25 -0800 Subject: [PATCH 08/12] Fixes foreign key field setting wrong value on activity --- bookwyrm/models/fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index 960be1612..34f90103b 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -60,7 +60,7 @@ class ActivitypubFieldMixin: if isinstance(activity.get(key), list): activity[key] += formatted else: - activity[key] = value + activity[key] = formatted def field_to_activity(self, value): From c75f5a159827170d082ccdb9623a3e0a244ea03d Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 13 Dec 2020 15:43:39 -0800 Subject: [PATCH 09/12] Unit tests for privacy model field --- bookwyrm/tests/models/test_fields.py | 98 +++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/bookwyrm/tests/models/test_fields.py b/bookwyrm/tests/models/test_fields.py index a1e4ff71f..b0d672638 100644 --- a/bookwyrm/tests/models/test_fields.py +++ b/bookwyrm/tests/models/test_fields.py @@ -1,9 +1,11 @@ ''' testing models ''' from io import BytesIO from collections import namedtuple +from dataclasses import dataclass import json import pathlib import re +from typing import List from unittest.mock import patch from PIL import Image @@ -15,7 +17,9 @@ from django.db import models from django.test import TestCase from django.utils import timezone -from bookwyrm.models import fields, User +from bookwyrm.activitypub.base_activity import ActivityObject +from bookwyrm.models import fields, User, Status +from bookwyrm.models.base_model import ActivitypubMixin, BookWyrmModel class ActivitypubFields(TestCase): ''' overwrites standard model feilds to work with activitypub ''' @@ -90,6 +94,97 @@ class ActivitypubFields(TestCase): self.assertEqual(instance.field_to_activity('test@example.com'), 'test') + + def test_privacy_field_defaults(self): + ''' post privacy field's many default values ''' + instance = fields.PrivacyField() + self.assertEqual(instance.max_length, 255) + self.assertEqual( + [c[0] for c in instance.choices], + ['public', 'unlisted', 'followers', 'direct']) + self.assertEqual(instance.default, 'public') + self.assertEqual( + instance.public, 'https://www.w3.org/ns/activitystreams#Public') + + def test_privacy_field_set_field_from_activity(self): + ''' translate between to/cc fields and privacy ''' + @dataclass(init=False) + class TestActivity(ActivityObject): + ''' real simple mock ''' + to: List[str] + cc: List[str] + id: str = 'http://hi.com' + type: str = 'Test' + + class TestModel(ActivitypubMixin, BookWyrmModel): + ''' real simple mock model because BookWyrmModel is abstract ''' + privacy_field = fields.PrivacyField() + mention_users = fields.TagField(User) + user = fields.ForeignKey(User, on_delete=models.CASCADE) + + public = 'https://www.w3.org/ns/activitystreams#Public' + data = TestActivity( + to=[public], + cc=['bleh'], + ) + model_instance = TestModel(privacy_field='direct') + self.assertEqual(model_instance.privacy_field, 'direct') + + instance = fields.PrivacyField() + instance.name = 'privacy_field' + instance.set_field_from_activity(model_instance, data) + self.assertEqual(model_instance.privacy_field, 'public') + + data.to = ['bleh'] + data.cc = [] + instance.set_field_from_activity(model_instance, data) + self.assertEqual(model_instance.privacy_field, 'direct') + + data.to = ['bleh'] + data.cc = [public, 'waah'] + instance.set_field_from_activity(model_instance, data) + self.assertEqual(model_instance.privacy_field, 'unlisted') + + + def test_privacy_field_set_activity_from_field(self): + ''' translate between to/cc fields and privacy ''' + user = User.objects.create_user( + 'rat', 'rat@rat.rat', 'ratword', local=True) + public = 'https://www.w3.org/ns/activitystreams#Public' + followers = '%s/followers' % user.remote_id + + instance = fields.PrivacyField() + instance.name = 'privacy_field' + + model_instance = Status.objects.create(user=user, content='hi') + activity = {} + instance.set_activity_from_field(activity, model_instance) + self.assertEqual(activity['to'], [public]) + self.assertEqual(activity['cc'], [followers]) + + model_instance = Status.objects.create(user=user, privacy='unlisted') + activity = {} + instance.set_activity_from_field(activity, model_instance) + self.assertEqual(activity['to'], [followers]) + self.assertEqual(activity['cc'], [public]) + + model_instance = Status.objects.create(user=user, privacy='followers') + activity = {} + instance.set_activity_from_field(activity, model_instance) + self.assertEqual(activity['to'], [followers]) + self.assertEqual(activity['cc'], []) + + model_instance = Status.objects.create( + user=user, + privacy='direct', + ) + model_instance.mention_users.set([user]) + activity = {} + instance.set_activity_from_field(activity, model_instance) + self.assertEqual(activity['to'], [user.remote_id]) + self.assertEqual(activity['cc'], []) + + def test_foreign_key(self): ''' should be able to format a related model ''' instance = fields.ForeignKey('User', on_delete=models.CASCADE) @@ -98,6 +193,7 @@ class ActivitypubFields(TestCase): # returns the remote_id field of the related object self.assertEqual(instance.field_to_activity(item), 'https://e.b/c') + @responses.activate def test_foreign_key_from_activity_str(self): ''' create a new object from a foreign key ''' From 4fcdbe5299a21c23bca82b9648b9a920d728db81 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 13 Dec 2020 15:56:30 -0800 Subject: [PATCH 10/12] Fixes clashing test model name --- bookwyrm/tests/models/test_fields.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bookwyrm/tests/models/test_fields.py b/bookwyrm/tests/models/test_fields.py index b0d672638..8c86b23ce 100644 --- a/bookwyrm/tests/models/test_fields.py +++ b/bookwyrm/tests/models/test_fields.py @@ -116,7 +116,7 @@ class ActivitypubFields(TestCase): id: str = 'http://hi.com' type: str = 'Test' - class TestModel(ActivitypubMixin, BookWyrmModel): + class TestPrivacyModel(ActivitypubMixin, BookWyrmModel): ''' real simple mock model because BookWyrmModel is abstract ''' privacy_field = fields.PrivacyField() mention_users = fields.TagField(User) @@ -127,7 +127,7 @@ class ActivitypubFields(TestCase): to=[public], cc=['bleh'], ) - model_instance = TestModel(privacy_field='direct') + model_instance = TestPrivacyModel(privacy_field='direct') self.assertEqual(model_instance.privacy_field, 'direct') instance = fields.PrivacyField() From 943d97c0bc791efd0c62c0e08525aedae2be3734 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 13 Dec 2020 16:16:12 -0800 Subject: [PATCH 11/12] Adds direct messages UI --- bookwyrm/templates/direct_messages.html | 37 +++++++++++++++++++++++++ bookwyrm/templates/layout.html | 3 ++ bookwyrm/urls.py | 3 +- bookwyrm/views.py | 37 ++++++++++++++++++++++++- 4 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 bookwyrm/templates/direct_messages.html 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 %}