Merge pull request #388 from mouse-reeve/fix-create-sttatus

Handle incoming statuses correctly
This commit is contained in:
Mouse Reeve 2020-12-13 16:22:20 -08:00 committed by GitHub
commit a7ee461b97
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 416 additions and 138 deletions

View file

@ -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:

View file

@ -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(

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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',

View file

@ -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,

View 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 %}

View file

@ -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>

View file

@ -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">

View file

@ -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,

View file

@ -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 '''

View file

@ -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),

View file

@ -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()