forked from mirrors/bookwyrm
Merge pull request #388 from mouse-reeve/fix-create-sttatus
Handle incoming statuses correctly
This commit is contained in:
commit
a7ee461b97
15 changed files with 416 additions and 138 deletions
|
@ -4,8 +4,6 @@ from json import JSONEncoder
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.db import transaction
|
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.connectors import ConnectorException, get_data
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app
|
||||||
|
@ -77,55 +75,30 @@ class ActivityObject:
|
||||||
)
|
)
|
||||||
|
|
||||||
# check for an existing instance, if we're not updating a known obj
|
# check for an existing instance, if we're not updating a known obj
|
||||||
if not instance:
|
instance = instance or model.find_existing(self.serialize()) or model()
|
||||||
instance = model.find_existing(self.serialize()) or model()
|
|
||||||
|
|
||||||
many_to_many_fields = {}
|
for field in instance.simple_fields:
|
||||||
image_fields = {}
|
field.set_field_from_activity(instance, self)
|
||||||
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
|
|
||||||
|
|
||||||
model_field = getattr(model, field.name)
|
# image fields have to be set after other fields because they can save
|
||||||
|
# too early and jank up users
|
||||||
if isinstance(model_field, ManyToManyDescriptor):
|
for field in instance.image_fields:
|
||||||
# status mentions book/users for example, stash this for later
|
field.set_field_from_activity(instance, self, save=save)
|
||||||
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)
|
|
||||||
|
|
||||||
if not save:
|
if not save:
|
||||||
# we can't set many to many and reverse fields on an unsaved object
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
# we can't set many to many and reverse fields on an unsaved object
|
||||||
instance.save()
|
instance.save()
|
||||||
|
|
||||||
# add many to many fields, which have to be set post-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
|
# mention books/users, for example
|
||||||
getattr(instance, model_key).set(values)
|
field.set_field_from_activity(instance, self)
|
||||||
|
|
||||||
if not save or not hasattr(model, 'deserialize_reverse_fields'):
|
|
||||||
return instance
|
|
||||||
|
|
||||||
# reversed relationships in the models
|
# reversed relationships in the models
|
||||||
for (model_field_name, activity_field_name) in \
|
for (model_field_name, activity_field_name) in \
|
||||||
model.deserialize_reverse_fields:
|
instance.deserialize_reverse_fields:
|
||||||
# attachments on Status, for example
|
# attachments on Status, for example
|
||||||
values = getattr(self, activity_field_name)
|
values = getattr(self, activity_field_name)
|
||||||
if values is None or values is MISSING:
|
if values is None or values is MISSING:
|
||||||
|
|
|
@ -185,12 +185,13 @@ def handle_follow_reject(activity):
|
||||||
def handle_create(activity):
|
def handle_create(activity):
|
||||||
''' someone did something, good on them '''
|
''' someone did something, good on them '''
|
||||||
# deduplicate incoming activities
|
# 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():
|
if models.Status.objects.filter(remote_id=status_id).count():
|
||||||
return
|
return
|
||||||
|
|
||||||
serializer = activitypub.activity_objects[activity['type']]
|
serializer = activitypub.activity_objects[activity['type']]
|
||||||
status = serializer(**activity)
|
activity = serializer(**activity)
|
||||||
try:
|
try:
|
||||||
model = models.activity_models[activity.type]
|
model = models.activity_models[activity.type]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
@ -198,13 +199,25 @@ def handle_create(activity):
|
||||||
return
|
return
|
||||||
|
|
||||||
if activity.type == 'Note':
|
if activity.type == 'Note':
|
||||||
|
# keep notes if they are replies to existing statuses
|
||||||
reply = models.Status.objects.filter(
|
reply = models.Status.objects.filter(
|
||||||
remote_id=activity.inReplyTo
|
remote_id=activity.inReplyTo
|
||||||
).first()
|
).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
|
# create a notification if this is a reply
|
||||||
if status.reply_parent and status.reply_parent.user.local:
|
if status.reply_parent and status.reply_parent.user.local:
|
||||||
status_builder.create_notification(
|
status_builder.create_notification(
|
||||||
|
|
|
@ -25,8 +25,3 @@ from .site import SiteSettings, SiteInvite, PasswordReset
|
||||||
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
|
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
|
||||||
activity_models = {c[1].activity_serializer.__name__: c[1] \
|
activity_models = {c[1].activity_serializer.__name__: c[1] \
|
||||||
for c in cls_members if hasattr(c[1], 'activity_serializer')}
|
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)
|
|
||||||
|
|
|
@ -14,16 +14,9 @@ from django.dispatch import receiver
|
||||||
|
|
||||||
from bookwyrm import activitypub
|
from bookwyrm import activitypub
|
||||||
from bookwyrm.settings import DOMAIN, PAGE_LENGTH
|
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):
|
class BookWyrmModel(models.Model):
|
||||||
''' shared fields '''
|
''' shared fields '''
|
||||||
created_date = models.DateTimeField(auto_now_add=True)
|
created_date = models.DateTimeField(auto_now_add=True)
|
||||||
|
@ -68,6 +61,33 @@ class ActivitypubMixin:
|
||||||
activity_serializer = lambda: {}
|
activity_serializer = lambda: {}
|
||||||
reverse_unfurl = False
|
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
|
@classmethod
|
||||||
def find_existing_by_remote_id(cls, remote_id):
|
def find_existing_by_remote_id(cls, remote_id):
|
||||||
''' look up a remote id in the db '''
|
''' look up a remote id in the db '''
|
||||||
|
@ -115,19 +135,8 @@ class ActivitypubMixin:
|
||||||
def to_activity(self):
|
def to_activity(self):
|
||||||
''' convert from a model to an activity '''
|
''' convert from a model to an activity '''
|
||||||
activity = {}
|
activity = {}
|
||||||
for field in self._meta.get_fields():
|
for field in self.activity_fields:
|
||||||
if not hasattr(field, 'field_to_activity'):
|
field.set_activity_from_field(activity, self)
|
||||||
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
|
|
||||||
|
|
||||||
if hasattr(self, 'serialize_reverse_fields'):
|
if hasattr(self, 'serialize_reverse_fields'):
|
||||||
# for example, editions of a work
|
# for example, editions of a work
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
''' activitypub-aware django model fields '''
|
''' activitypub-aware django model fields '''
|
||||||
|
from dataclasses import MISSING
|
||||||
import re
|
import re
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
@ -38,6 +39,30 @@ class ActivitypubFieldMixin:
|
||||||
self.activitypub_field = activitypub_field
|
self.activitypub_field = activitypub_field
|
||||||
super().__init__(*args, **kwargs)
|
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):
|
def field_to_activity(self, value):
|
||||||
''' formatter to convert a model value into activitypub '''
|
''' formatter to convert a model value into activitypub '''
|
||||||
if hasattr(self, 'activitypub_wrapper'):
|
if hasattr(self, 'activitypub_wrapper'):
|
||||||
|
@ -123,6 +148,52 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
|
||||||
return value.split('@')[0]
|
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):
|
class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
|
||||||
''' activitypub-aware foreign key field '''
|
''' activitypub-aware foreign key field '''
|
||||||
def field_to_activity(self, value):
|
def field_to_activity(self, value):
|
||||||
|
@ -145,6 +216,14 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
||||||
self.link_only = link_only
|
self.link_only = link_only
|
||||||
super().__init__(*args, **kwargs)
|
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):
|
def field_to_activity(self, value):
|
||||||
if self.link_only:
|
if self.link_only:
|
||||||
return '%s/%s' % (value.instance.remote_id, self.name)
|
return '%s/%s' % (value.instance.remote_id, self.name)
|
||||||
|
@ -210,9 +289,20 @@ def image_serializer(value):
|
||||||
|
|
||||||
class ImageField(ActivitypubFieldMixin, models.ImageField):
|
class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||||
''' activitypub-aware image field '''
|
''' 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):
|
def field_to_activity(self, value):
|
||||||
return image_serializer(value)
|
return image_serializer(value)
|
||||||
|
|
||||||
|
|
||||||
def field_from_activity(self, value):
|
def field_from_activity(self, value):
|
||||||
image_slug = value
|
image_slug = value
|
||||||
# when it's an inline image (User avatar/icon, Book cover), it's a json
|
# when it's an inline image (User avatar/icon, Book cover), it's a json
|
||||||
|
|
|
@ -8,7 +8,7 @@ from django.utils import timezone
|
||||||
|
|
||||||
from bookwyrm import books_manager
|
from bookwyrm import books_manager
|
||||||
from bookwyrm.models import ReadThrough, User, Book
|
from bookwyrm.models import ReadThrough, User, Book
|
||||||
from .base_model import PrivacyLevels
|
from .fields import PrivacyLevels
|
||||||
|
|
||||||
|
|
||||||
# Mapping goodreads -> bookwyrm shelf titles.
|
# Mapping goodreads -> bookwyrm shelf titles.
|
||||||
|
|
|
@ -4,7 +4,7 @@ from django.db import models
|
||||||
|
|
||||||
from bookwyrm import activitypub
|
from bookwyrm import activitypub
|
||||||
from .base_model import BookWyrmModel
|
from .base_model import BookWyrmModel
|
||||||
from .base_model import OrderedCollectionMixin, PrivacyLevels
|
from .base_model import OrderedCollectionMixin
|
||||||
from . import fields
|
from . import fields
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
||||||
privacy = fields.CharField(
|
privacy = fields.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
default='public',
|
default='public',
|
||||||
choices=PrivacyLevels.choices
|
choices=fields.PrivacyLevels.choices
|
||||||
)
|
)
|
||||||
books = models.ManyToManyField(
|
books = models.ManyToManyField(
|
||||||
'Edition',
|
'Edition',
|
||||||
|
|
|
@ -6,7 +6,7 @@ from model_utils.managers import InheritanceManager
|
||||||
|
|
||||||
from bookwyrm import activitypub
|
from bookwyrm import activitypub
|
||||||
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
|
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
|
||||||
from .base_model import BookWyrmModel, PrivacyLevels
|
from .base_model import BookWyrmModel
|
||||||
from . import fields
|
from . import fields
|
||||||
from .fields import image_serializer
|
from .fields import image_serializer
|
||||||
|
|
||||||
|
@ -18,13 +18,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
mention_users = fields.TagField('User', related_name='mention_user')
|
mention_users = fields.TagField('User', related_name='mention_user')
|
||||||
mention_books = fields.TagField('Edition', related_name='mention_book')
|
mention_books = fields.TagField('Edition', related_name='mention_book')
|
||||||
local = models.BooleanField(default=True)
|
local = models.BooleanField(default=True)
|
||||||
privacy = models.CharField(
|
privacy = fields.PrivacyField(max_length=255)
|
||||||
max_length=255,
|
|
||||||
default='public',
|
|
||||||
choices=PrivacyLevels.choices
|
|
||||||
)
|
|
||||||
sensitive = fields.BooleanField(default=False)
|
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(
|
published_date = fields.DateTimeField(
|
||||||
default=timezone.now, activitypub_field='published')
|
default=timezone.now, activitypub_field='published')
|
||||||
deleted = models.BooleanField(default=False)
|
deleted = models.BooleanField(default=False)
|
||||||
|
@ -48,12 +44,13 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
serialize_reverse_fields = [('attachments', 'attachment')]
|
serialize_reverse_fields = [('attachments', 'attachment')]
|
||||||
deserialize_reverse_fields = [('attachments', 'attachment')]
|
deserialize_reverse_fields = [('attachments', 'attachment')]
|
||||||
|
|
||||||
#----- replies collection activitypub ----#
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def replies(cls, status):
|
def replies(cls, status):
|
||||||
''' load all replies to a status. idk if there's a better way
|
''' load all replies to a status. idk if there's a better way
|
||||||
to write this so it's just a property '''
|
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
|
@property
|
||||||
def status_type(self):
|
def status_type(self):
|
||||||
|
@ -68,7 +65,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_activity(self, pure=False):
|
def to_activity(self, pure=False):# pylint: disable=arguments-differ
|
||||||
''' return tombstone if the status is deleted '''
|
''' return tombstone if the status is deleted '''
|
||||||
if self.deleted:
|
if self.deleted:
|
||||||
return activitypub.Tombstone(
|
return activitypub.Tombstone(
|
||||||
|
@ -80,25 +77,6 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
activity = ActivitypubMixin.to_activity(self)
|
activity = ActivitypubMixin.to_activity(self)
|
||||||
activity['replies'] = self.to_replies()
|
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
|
# "pure" serialization for non-bookwyrm instances
|
||||||
if pure:
|
if pure:
|
||||||
activity['content'] = self.pure_content
|
activity['content'] = self.pure_content
|
||||||
|
@ -190,6 +168,7 @@ class Review(Status):
|
||||||
def pure_name(self):
|
def pure_name(self):
|
||||||
''' clarify review names for mastodon serialization '''
|
''' clarify review names for mastodon serialization '''
|
||||||
if self.rating:
|
if self.rating:
|
||||||
|
#pylint: disable=bad-string-format-type
|
||||||
return 'Review of "%s" (%d stars): %s' % (
|
return 'Review of "%s" (%d stars): %s' % (
|
||||||
self.book.title,
|
self.book.title,
|
||||||
self.rating,
|
self.rating,
|
||||||
|
|
37
bookwyrm/templates/direct_messages.html
Normal file
37
bookwyrm/templates/direct_messages.html
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
{% extends 'layout.html' %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
<h1 class="title">Direct Messages</h1>
|
||||||
|
|
||||||
|
{% if not activities %}
|
||||||
|
<p>You have no messages right now.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% for activity in activities %}
|
||||||
|
<div class="block">
|
||||||
|
{% include 'snippets/status.html' with status=activity %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<nav class="pagination" role="navigation" aria-label="pagination">
|
||||||
|
{% if prev %}
|
||||||
|
<p class="pagination-previous">
|
||||||
|
<a href="{{ prev }}">
|
||||||
|
<span class="icon icon-arrow-left"></span>
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if next %}
|
||||||
|
<p class="pagination-next">
|
||||||
|
<a href="{{ next }}">
|
||||||
|
Next
|
||||||
|
<span class="icon icon-arrow-right"></span>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
|
@ -68,6 +68,9 @@
|
||||||
{% include 'snippets/username.html' with user=request.user %}
|
{% include 'snippets/username.html' with user=request.user %}
|
||||||
</p></div>
|
</p></div>
|
||||||
<div class="navbar-dropdown">
|
<div class="navbar-dropdown">
|
||||||
|
<a href="/direct-messages" class="navbar-item">
|
||||||
|
Direct messages
|
||||||
|
</a>
|
||||||
<a href="/user/{{request.user.localname}}" class="navbar-item">
|
<a href="/user/{{request.user.localname}}" class="navbar-item">
|
||||||
Profile
|
Profile
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
{% include 'snippets/privacy_select.html' %}
|
{% include 'snippets/privacy_select.html' with current=activity.privacy %}
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<button class="button is-primary" type="submit">
|
<button class="button is-primary" type="submit">
|
||||||
|
|
|
@ -95,34 +95,67 @@ class BaseActivity(TestCase):
|
||||||
self.assertEqual(result.remote_id, 'https://example.com/user/mouse')
|
self.assertEqual(result.remote_id, 'https://example.com/user/mouse')
|
||||||
self.assertEqual(result.name, 'MOUSE?? MOUSE!!')
|
self.assertEqual(result.name, 'MOUSE?? MOUSE!!')
|
||||||
|
|
||||||
def test_to_model(self):
|
def test_to_model_invalid_model(self):
|
||||||
''' the big boy of this module. it feels janky to test this with actual
|
''' catch mismatch between activity type and model type '''
|
||||||
models rather than a test model, but I don't know how to make a test
|
|
||||||
model so here we are. '''
|
|
||||||
instance = ActivityObject(id='a', type='b')
|
instance = ActivityObject(id='a', type='b')
|
||||||
with self.assertRaises(ActivitySerializerError):
|
with self.assertRaises(ActivitySerializerError):
|
||||||
instance.to_model(models.User)
|
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, '')
|
self.assertEqual(self.user.name, '')
|
||||||
update_data = activitypub.Person(**self.user.to_activity())
|
|
||||||
update_data.name = 'New Name'
|
activity = activitypub.Person(
|
||||||
update_data.to_model(models.User, self.user)
|
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')
|
self.assertEqual(self.user.name, 'New Name')
|
||||||
|
|
||||||
def test_to_model_foreign_key(self):
|
def test_to_model_foreign_key(self):
|
||||||
''' test setting one to one/foreign key '''
|
''' test setting one to one/foreign key '''
|
||||||
update_data = activitypub.Person(**self.user.to_activity())
|
activity = activitypub.Person(
|
||||||
update_data.publicKey['publicKeyPem'] = 'hi im secure'
|
id=self.user.remote_id,
|
||||||
update_data.to_model(models.User, self.user)
|
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')
|
self.assertEqual(self.user.key_pair.public_key, 'hi im secure')
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_to_model_image(self):
|
def test_to_model_image(self):
|
||||||
''' update an image field '''
|
''' update an image field '''
|
||||||
update_data = activitypub.Person(**self.user.to_activity())
|
activity = activitypub.Person(
|
||||||
update_data.icon = {'url': 'http://www.example.com/image.jpg'}
|
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.add(
|
||||||
responses.GET,
|
responses.GET,
|
||||||
'http://www.example.com/image.jpg',
|
'http://www.example.com/image.jpg',
|
||||||
|
@ -133,7 +166,7 @@ class BaseActivity(TestCase):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
self.user.avatar.file #pylint: disable=pointless-statement
|
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.name)
|
||||||
self.assertIsNotNone(self.user.avatar.file)
|
self.assertIsNotNone(self.user.avatar.file)
|
||||||
|
|
||||||
|
@ -145,19 +178,26 @@ class BaseActivity(TestCase):
|
||||||
)
|
)
|
||||||
book = models.Edition.objects.create(
|
book = models.Edition.objects.create(
|
||||||
title='Test Edition', remote_id='http://book.com/book')
|
title='Test Edition', remote_id='http://book.com/book')
|
||||||
update_data = activitypub.Note(**status.to_activity())
|
update_data = activitypub.Note(
|
||||||
update_data.tag = [
|
id=status.remote_id,
|
||||||
{
|
content=status.content,
|
||||||
'type': 'Mention',
|
attributedTo=self.user.remote_id,
|
||||||
'name': 'gerald',
|
published='hi',
|
||||||
'href': 'http://example.com/a/b'
|
to=[],
|
||||||
},
|
cc=[],
|
||||||
{
|
tag=[
|
||||||
'type': 'Edition',
|
{
|
||||||
'name': 'gerald j. books',
|
'type': 'Mention',
|
||||||
'href': 'http://book.com/book'
|
'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)
|
update_data.to_model(models.Status, instance=status)
|
||||||
self.assertEqual(status.mention_users.first(), self.user)
|
self.assertEqual(status.mention_users.first(), self.user)
|
||||||
self.assertEqual(status.mention_books.first(), book)
|
self.assertEqual(status.mention_books.first(), book)
|
||||||
|
@ -171,12 +211,19 @@ class BaseActivity(TestCase):
|
||||||
content='test status',
|
content='test status',
|
||||||
user=self.user,
|
user=self.user,
|
||||||
)
|
)
|
||||||
update_data = activitypub.Note(**status.to_activity())
|
update_data = activitypub.Note(
|
||||||
update_data.attachment = [{
|
id=status.remote_id,
|
||||||
'url': 'http://www.example.com/image.jpg',
|
content=status.content,
|
||||||
'name': 'alt text',
|
attributedTo=self.user.remote_id,
|
||||||
'type': 'Image',
|
published='hi',
|
||||||
}]
|
to=[],
|
||||||
|
cc=[],
|
||||||
|
attachment=[{
|
||||||
|
'url': 'http://www.example.com/image.jpg',
|
||||||
|
'name': 'alt text',
|
||||||
|
'type': 'Image',
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
|
||||||
responses.add(
|
responses.add(
|
||||||
responses.GET,
|
responses.GET,
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
''' testing models '''
|
''' testing models '''
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
from dataclasses import dataclass
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
|
from typing import List
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
@ -15,7 +17,9 @@ from django.db import models
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
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):
|
class ActivitypubFields(TestCase):
|
||||||
''' overwrites standard model feilds to work with activitypub '''
|
''' 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')
|
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 TestPrivacyModel(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 = TestPrivacyModel(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):
|
def test_foreign_key(self):
|
||||||
''' should be able to format a related model '''
|
''' should be able to format a related model '''
|
||||||
instance = fields.ForeignKey('User', on_delete=models.CASCADE)
|
instance = fields.ForeignKey('User', on_delete=models.CASCADE)
|
||||||
|
@ -98,6 +193,7 @@ class ActivitypubFields(TestCase):
|
||||||
# returns the remote_id field of the related object
|
# returns the remote_id field of the related object
|
||||||
self.assertEqual(instance.field_to_activity(item), 'https://e.b/c')
|
self.assertEqual(instance.field_to_activity(item), 'https://e.b/c')
|
||||||
|
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_foreign_key_from_activity_str(self):
|
def test_foreign_key_from_activity_str(self):
|
||||||
''' create a new object from a foreign key '''
|
''' create a new object from a foreign key '''
|
||||||
|
|
|
@ -53,7 +53,8 @@ urlpatterns = [
|
||||||
|
|
||||||
path('', views.home),
|
path('', views.home),
|
||||||
re_path(r'^(?P<tab>home|local|federated)/?$', views.home_tab),
|
re_path(r'^(?P<tab>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/?$', views.import_page),
|
||||||
re_path(r'^import-status/(\d+)/?$', views.import_status),
|
re_path(r'^import-status/(\d+)/?$', views.import_status),
|
||||||
re_path(r'^user-edit/?$', views.edit_profile_page),
|
re_path(r'^user-edit/?$', views.edit_profile_page),
|
||||||
|
|
|
@ -113,11 +113,36 @@ def get_suggested_books(user, max_books=5):
|
||||||
return suggested_books
|
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):
|
def get_activity_feed(user, filter_level, model=models.Status):
|
||||||
''' get a filtered queryset of statuses '''
|
''' get a filtered queryset of statuses '''
|
||||||
# status updates for your follow network
|
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
user = None
|
user = None
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
following = models.User.objects.filter(
|
following = models.User.objects.filter(
|
||||||
Q(followers=user) | Q(id=user.id)
|
Q(followers=user) | Q(id=user.id)
|
||||||
|
@ -135,6 +160,16 @@ def get_activity_feed(user, filter_level, model=models.Status):
|
||||||
'-published_date'
|
'-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'):
|
if hasattr(activities, 'select_subclasses'):
|
||||||
activities = activities.select_subclasses()
|
activities = activities.select_subclasses()
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue