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 4964d393..de2c5bcc 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 de2c5bcc..fe521772 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 b9a2814e..86bdf219 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 55036f2c..43fc4511 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 dd3065c9..d9aafcce 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 ed19af99..6401bb89 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 d9aafcce..e899b3c5 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 e6878fb9..21bdfadb 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 e899b3c5..08cc6052 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 21bdfadb..05453397 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 bf5d5caf..835094cd 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 fc63d198..68f3614f 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 43fc4511..0bf897dc 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 05453397..960be161 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 88997c44..87420aa7 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 960be161..34f90103 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 a1e4ff71..b0d67263 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 b0d67263..8c86b23c 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 00000000..6a20b111
--- /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 bcbdca2a..ab113ad0 100644
--- a/bookwyrm/templates/layout.html
+++ b/bookwyrm/templates/layout.html
@@ -68,6 +68,9 @@
{% include 'snippets/username.html' with user=request.user %}
+
+ Direct messages
+
Profile
diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py
index a9792038..3cbb7510 100644
--- a/bookwyrm/urls.py
+++ b/bookwyrm/urls.py
@@ -53,7 +53,8 @@ urlpatterns = [
path('', views.home),
re_path(r'^(?P
home|local|federated)/?$', views.home_tab),
- re_path(r'^notifications/?', views.notifications_page),
+ re_path(r'^notifications/?$', views.notifications_page),
+ re_path(r'^direct-messages/?$', views.direct_messages_page),
re_path(r'^import/?$', views.import_page),
re_path(r'^import-status/(\d+)/?$', views.import_status),
re_path(r'^user-edit/?$', views.edit_profile_page),
diff --git a/bookwyrm/views.py b/bookwyrm/views.py
index 562f575e..f32638d4 100644
--- a/bookwyrm/views.py
+++ b/bookwyrm/views.py
@@ -113,11 +113,36 @@ def get_suggested_books(user, max_books=5):
return suggested_books
+@login_required
+@require_GET
+def direct_messages_page(request, page=1):
+ ''' like a feed but for dms only '''
+ activities = get_activity_feed(request.user, 'direct')
+ paginated = Paginator(activities, PAGE_LENGTH)
+ activity_page = paginated.page(page)
+
+ prev_page = next_page = None
+ if activity_page.has_next():
+ next_page = '/direct-message/?page=%d#feed' % \
+ activity_page.next_page_number()
+ if activity_page.has_previous():
+ prev_page = '/direct-messages/?page=%d#feed' % \
+ activity_page.previous_page_number()
+ data = {
+ 'title': 'Direct Messages',
+ 'user': request.user,
+ 'activities': activity_page.object_list,
+ 'next': next_page,
+ 'prev': prev_page,
+ }
+ return TemplateResponse(request, 'direct_messages.html', data)
+
+
def get_activity_feed(user, filter_level, model=models.Status):
''' get a filtered queryset of statuses '''
- # status updates for your follow network
if user.is_anonymous:
user = None
+
if user:
following = models.User.objects.filter(
Q(followers=user) | Q(id=user.id)
@@ -135,6 +160,16 @@ def get_activity_feed(user, filter_level, model=models.Status):
'-published_date'
)
+ if filter_level == 'direct':
+ return activities.filter(
+ Q(user=user) | Q(mention_users=user),
+ privacy='direct'
+ )
+
+ # never show DMs in the regular feed
+ activities = activities.filter(~Q(privacy='direct'))
+
+
if hasattr(activities, 'select_subclasses'):
activities = activities.select_subclasses()
From 693dfc42ac2affeb7535a1300dd8925276987581 Mon Sep 17 00:00:00 2001
From: Mouse Reeve
Date: Sun, 13 Dec 2020 16:20:59 -0800
Subject: [PATCH 12/12] Match privacy level in reply
---
bookwyrm/templates/snippets/reply_form.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bookwyrm/templates/snippets/reply_form.html b/bookwyrm/templates/snippets/reply_form.html
index 40cf6de1..d0a0f6b9 100644
--- a/bookwyrm/templates/snippets/reply_form.html
+++ b/bookwyrm/templates/snippets/reply_form.html
@@ -13,7 +13,7 @@
- {% include 'snippets/privacy_select.html' %}
+ {% include 'snippets/privacy_select.html' with current=activity.privacy %}