Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2021-04-07 11:17:16 -07:00
commit c33eacaf3d
23 changed files with 328 additions and 135 deletions

View file

@ -8,6 +8,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-python@v2 - uses: actions/setup-python@v2
- uses: psf/black@20.8b1 - uses: psf/black@stable
with: with:
args: ". --check -l 80 -S" args: ". --check -l 80 -S"

View file

@ -265,7 +265,8 @@ def resolve_remote_id(
"Could not connect to host for remote_id in: %s" % (remote_id) "Could not connect to host for remote_id in: %s" % (remote_id)
) )
# determine the model implicitly, if not provided # determine the model implicitly, if not provided
if not model: # or if it's a model with subclasses like Status, check again
if not model or hasattr(model.objects, "select_subclasses"):
model = get_model_from_type(data.get("type")) model = get_model_from_type(data.get("type"))
# check for existing items with shared unique identifiers # check for existing items with shared unique identifiers

View file

@ -23,6 +23,7 @@ class Person(ActivityObject):
inbox: str inbox: str
publicKey: PublicKey publicKey: PublicKey
followers: str = None followers: str = None
following: str = None
outbox: str = None outbox: str = None
endpoints: Dict = None endpoints: Dict = None
name: str = None name: str = None

View file

@ -1,21 +1,13 @@
""" access the activity streams stored in redis """ """ access the activity streams stored in redis """
from abc import ABC
from django.dispatch import receiver from django.dispatch import receiver
from django.db.models import signals, Q from django.db.models import signals, Q
import redis
from bookwyrm import models, settings from bookwyrm import models
from bookwyrm.redis_store import RedisStore, r
from bookwyrm.views.helpers import privacy_filter from bookwyrm.views.helpers import privacy_filter
r = redis.Redis(
host=settings.REDIS_ACTIVITY_HOST,
port=settings.REDIS_ACTIVITY_PORT,
password=settings.REDIS_ACTIVITY_PASSWORD,
db=0,
)
class ActivityStream(RedisStore):
class ActivityStream(ABC):
""" a category of activity stream (like home, local, federated) """ """ a category of activity stream (like home, local, federated) """
def stream_id(self, user): def stream_id(self, user):
@ -26,58 +18,40 @@ class ActivityStream(ABC):
""" the redis key for this user's unread count for this stream """ """ the redis key for this user's unread count for this stream """
return "{}-unread".format(self.stream_id(user)) return "{}-unread".format(self.stream_id(user))
def get_value(self, status): # pylint: disable=no-self-use def get_rank(self, obj): # pylint: disable=no-self-use
""" the status id and the rank (ie, published date) """ """ statuses are sorted by date published """
return {status.id: status.published_date.timestamp()} return obj.published_date.timestamp()
def add_status(self, status): def add_status(self, status):
""" add a status to users' feeds """ """ add a status to users' feeds """
value = self.get_value(status) # the pipeline contains all the add-to-stream activities
# we want to do this as a bulk operation, hence "pipeline" pipeline = self.add_object_to_related_stores(status, execute=False)
pipeline = r.pipeline()
for user in self.stream_users(status): for user in self.get_audience(status):
# add the status to the feed
pipeline.zadd(self.stream_id(user), value)
pipeline.zremrangebyrank(
self.stream_id(user), 0, -1 * settings.MAX_STREAM_LENGTH
)
# add to the unread status count # add to the unread status count
pipeline.incr(self.unread_id(user)) pipeline.incr(self.unread_id(user))
# and go!
pipeline.execute()
def remove_status(self, status): # and go!
""" remove a status from all feeds """
pipeline = r.pipeline()
for user in self.stream_users(status):
pipeline.zrem(self.stream_id(user), -1, status.id)
pipeline.execute() pipeline.execute()
def add_user_statuses(self, viewer, user): def add_user_statuses(self, viewer, user):
""" add a user's statuses to another user's feed """ """ add a user's statuses to another user's feed """
pipeline = r.pipeline() # only add the statuses that the viewer should be able to see (ie, not dms)
statuses = user.status_set.all()[: settings.MAX_STREAM_LENGTH] statuses = privacy_filter(viewer, user.status_set.all())
for status in statuses: self.bulk_add_objects_to_store(statuses, self.stream_id(viewer))
pipeline.zadd(self.stream_id(viewer), self.get_value(status))
if statuses:
pipeline.zremrangebyrank(
self.stream_id(user), 0, -1 * settings.MAX_STREAM_LENGTH
)
pipeline.execute()
def remove_user_statuses(self, viewer, user): def remove_user_statuses(self, viewer, user):
""" remove a user's status from another user's feed """ """ remove a user's status from another user's feed """
pipeline = r.pipeline() # remove all so that followers only statuses are removed
for status in user.status_set.all()[: settings.MAX_STREAM_LENGTH]: statuses = user.status_set.all()
pipeline.lrem(self.stream_id(viewer), -1, status.id) self.bulk_remove_objects_from_store(statuses, self.stream_id(viewer))
pipeline.execute()
def get_activity_stream(self, user): def get_activity_stream(self, user):
""" load the ids for statuses to be displayed """ """ load the statuses to be displayed """
# clear unreads for this feed # clear unreads for this feed
r.set(self.unread_id(user), 0) r.set(self.unread_id(user), 0)
statuses = r.zrevrange(self.stream_id(user), 0, -1) statuses = self.get_store(self.stream_id(user))
return ( return (
models.Status.objects.select_subclasses() models.Status.objects.select_subclasses()
.filter(id__in=statuses) .filter(id__in=statuses)
@ -88,23 +62,11 @@ class ActivityStream(ABC):
""" get the unread status count for this user's feed """ """ get the unread status count for this user's feed """
return int(r.get(self.unread_id(user)) or 0) return int(r.get(self.unread_id(user)) or 0)
def populate_stream(self, user): def populate_streams(self, user):
""" go from zero to a timeline """ """ go from zero to a timeline """
pipeline = r.pipeline() self.populate_store(self.stream_id(user))
statuses = self.stream_statuses(user)
stream_id = self.stream_id(user) def get_audience(self, status): # pylint: disable=no-self-use
for status in statuses.all()[: settings.MAX_STREAM_LENGTH]:
pipeline.zadd(stream_id, self.get_value(status))
# only trim the stream if statuses were added
if statuses.exists():
pipeline.zremrangebyrank(
self.stream_id(user), 0, -1 * settings.MAX_STREAM_LENGTH
)
pipeline.execute()
def stream_users(self, status): # pylint: disable=no-self-use
""" given a status, what users should see it """ """ given a status, what users should see it """
# direct messages don't appeard in feeds, direct comments/reviews/etc do # direct messages don't appeard in feeds, direct comments/reviews/etc do
if status.privacy == "direct" and status.status_type == "Note": if status.privacy == "direct" and status.status_type == "Note":
@ -132,7 +94,10 @@ class ActivityStream(ABC):
) )
return audience.distinct() return audience.distinct()
def stream_statuses(self, user): # pylint: disable=no-self-use def get_stores_for_object(self, obj):
return [self.stream_id(u) for u in self.get_audience(obj)]
def get_statuses_for_user(self, user): # pylint: disable=no-self-use
""" given a user, what statuses should they see on this stream """ """ given a user, what statuses should they see on this stream """
return privacy_filter( return privacy_filter(
user, user,
@ -140,14 +105,18 @@ class ActivityStream(ABC):
privacy_levels=["public", "unlisted", "followers"], privacy_levels=["public", "unlisted", "followers"],
) )
def get_objects_for_store(self, store):
user = models.User.objects.get(id=store.split("-")[0])
return self.get_statuses_for_user(user)
class HomeStream(ActivityStream): class HomeStream(ActivityStream):
""" users you follow """ """ users you follow """
key = "home" key = "home"
def stream_users(self, status): def get_audience(self, status):
audience = super().stream_users(status) audience = super().get_audience(status)
if not audience: if not audience:
return [] return []
return audience.filter( return audience.filter(
@ -155,7 +124,7 @@ class HomeStream(ActivityStream):
| Q(following=status.user) # if the user is following the author | Q(following=status.user) # if the user is following the author
).distinct() ).distinct()
def stream_statuses(self, user): def get_statuses_for_user(self, user):
return privacy_filter( return privacy_filter(
user, user,
models.Status.objects.select_subclasses(), models.Status.objects.select_subclasses(),
@ -169,13 +138,13 @@ class LocalStream(ActivityStream):
key = "local" key = "local"
def stream_users(self, status): def get_audience(self, status):
# this stream wants no part in non-public statuses # this stream wants no part in non-public statuses
if status.privacy != "public" or not status.user.local: if status.privacy != "public" or not status.user.local:
return [] return []
return super().stream_users(status) return super().get_audience(status)
def stream_statuses(self, user): def get_statuses_for_user(self, user):
# all public statuses by a local user # all public statuses by a local user
return privacy_filter( return privacy_filter(
user, user,
@ -189,13 +158,13 @@ class FederatedStream(ActivityStream):
key = "federated" key = "federated"
def stream_users(self, status): def get_audience(self, status):
# this stream wants no part in non-public statuses # this stream wants no part in non-public statuses
if status.privacy != "public": if status.privacy != "public":
return [] return []
return super().stream_users(status) return super().get_audience(status)
def stream_statuses(self, user): def get_statuses_for_user(self, user):
return privacy_filter( return privacy_filter(
user, user,
models.Status.objects.select_subclasses(), models.Status.objects.select_subclasses(),
@ -220,7 +189,7 @@ def add_status_on_create(sender, instance, created, *args, **kwargs):
if instance.deleted: if instance.deleted:
for stream in streams.values(): for stream in streams.values():
stream.remove_status(instance) stream.remove_object_from_related_stores(instance)
return return
if not created: if not created:
@ -237,7 +206,7 @@ def remove_boost_on_delete(sender, instance, *args, **kwargs):
""" boosts are deleted """ """ boosts are deleted """
# we're only interested in new statuses # we're only interested in new statuses
for stream in streams.values(): for stream in streams.values():
stream.remove_status(instance) stream.remove_object_from_related_stores(instance)
@receiver(signals.post_save, sender=models.UserFollows) @receiver(signals.post_save, sender=models.UserFollows)
@ -297,4 +266,4 @@ def populate_streams_on_account_create(sender, instance, created, *args, **kwarg
return return
for stream in streams.values(): for stream in streams.values():
stream.populate_stream(instance) stream.populate_streams(instance)

View file

@ -179,7 +179,11 @@ class AbstractConnector(AbstractMinimalConnector):
data = get_data(remote_id) data = get_data(remote_id)
mapped_data = dict_from_mappings(data, self.author_mappings) mapped_data = dict_from_mappings(data, self.author_mappings)
activity = activitypub.Author(**mapped_data) try:
activity = activitypub.Author(**mapped_data)
except activitypub.ActivitySerializerError:
return None
# this will dedupe # this will dedupe
return activity.to_model(model=models.Author) return activity.to_model(model=models.Author)

View file

@ -1,5 +1,6 @@
""" interface with whatever connectors the app has """ """ interface with whatever connectors the app has """
import importlib import importlib
import logging
import re import re
from urllib.parse import urlparse from urllib.parse import urlparse
@ -11,6 +12,8 @@ from requests import HTTPError
from bookwyrm import models from bookwyrm import models
from bookwyrm.tasks import app from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
class ConnectorException(HTTPError): class ConnectorException(HTTPError):
""" when the connector can't do what was asked """ """ when the connector can't do what was asked """
@ -44,7 +47,9 @@ def search(query, min_confidence=0.1):
if result_set in (None, []): if result_set in (None, []):
try: try:
result_set = connector.search(query, min_confidence=min_confidence) result_set = connector.search(query, min_confidence=min_confidence)
except (HTTPError, ConnectorException): except Exception as e: # pylint: disable=broad-except
# we don't want *any* error to crash the whole search page
logger.exception(e)
continue continue
# if the search results look the same, ignore them # if the search results look the same, ignore them

View file

@ -93,7 +93,10 @@ class Connector(AbstractConnector):
# this id is "/authors/OL1234567A" # this id is "/authors/OL1234567A"
author_id = author_blob["key"] author_id = author_blob["key"]
url = "%s%s" % (self.base_url, author_id) url = "%s%s" % (self.base_url, author_id)
yield self.get_or_create_author(url) author = self.get_or_create_author(url)
if not author:
continue
yield author
def get_cover_url(self, cover_blob, size="L"): def get_cover_url(self, cover_blob, size="L"):
""" ask openlibrary for the cover """ """ ask openlibrary for the cover """

View file

@ -0,0 +1,33 @@
# Generated by Django 3.1.6 on 2021-04-07 15:45
import bookwyrm.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0061_auto_20210402_1435"),
]
operations = [
migrations.AlterField(
model_name="book",
name="series",
field=bookwyrm.models.fields.TextField(
blank=True, max_length=255, null=True
),
),
migrations.AlterField(
model_name="book",
name="subtitle",
field=bookwyrm.models.fields.TextField(
blank=True, max_length=255, null=True
),
),
migrations.AlterField(
model_name="book",
name="title",
field=bookwyrm.models.fields.TextField(max_length=255),
),
]

View file

@ -1,5 +1,6 @@
""" activitypub model functionality """ """ activitypub model functionality """
from base64 import b64encode from base64 import b64encode
from collections import namedtuple
from functools import reduce from functools import reduce
import json import json
import operator import operator
@ -25,6 +26,15 @@ from bookwyrm.models.fields import ImageField, ManyToManyField
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# I tried to separate these classes into mutliple files but I kept getting # I tried to separate these classes into mutliple files but I kept getting
# circular import errors so I gave up. I'm sure it could be done though! # circular import errors so I gave up. I'm sure it could be done though!
PropertyField = namedtuple("PropertyField", ("set_activity_from_field"))
def set_activity_from_property_field(activity, obj, field):
""" assign a model property value to the activity json """
activity[field[1]] = getattr(obj, field[0])
class ActivitypubMixin: class ActivitypubMixin:
""" add this mixin for models that are AP serializable """ """ add this mixin for models that are AP serializable """
@ -52,6 +62,11 @@ class ActivitypubMixin:
self.activity_fields = ( self.activity_fields = (
self.image_fields + self.many_to_many_fields + self.simple_fields self.image_fields + self.many_to_many_fields + self.simple_fields
) )
if hasattr(self, "property_fields"):
self.activity_fields += [
PropertyField(lambda a, o: set_activity_from_property_field(a, o, f))
for f in self.property_fields
]
# these are separate to avoid infinite recursion issues # these are separate to avoid infinite recursion issues
self.deserialize_reverse_fields = ( self.deserialize_reverse_fields = (
@ -430,7 +445,7 @@ def generate_activity(obj):
) in obj.serialize_reverse_fields: ) in obj.serialize_reverse_fields:
related_field = getattr(obj, model_field_name) related_field = getattr(obj, model_field_name)
activity[activity_field_name] = unfurl_related_field( activity[activity_field_name] = unfurl_related_field(
related_field, sort_field related_field, sort_field=sort_field
) )
if not activity.get("id"): if not activity.get("id"):
@ -440,7 +455,7 @@ def generate_activity(obj):
def unfurl_related_field(related_field, sort_field=None): def unfurl_related_field(related_field, sort_field=None):
""" load reverse lookups (like public key owner or Status attachment """ """ load reverse lookups (like public key owner or Status attachment """
if hasattr(related_field, "all"): if sort_field and hasattr(related_field, "all"):
return [ return [
unfurl_related_field(i) for i in related_field.order_by(sort_field).all() unfurl_related_field(i) for i in related_field.order_by(sort_field).all()
] ]

View file

@ -53,14 +53,14 @@ class Book(BookDataModel):
connector = models.ForeignKey("Connector", on_delete=models.PROTECT, null=True) connector = models.ForeignKey("Connector", on_delete=models.PROTECT, null=True)
# book/work metadata # book/work metadata
title = fields.CharField(max_length=255) title = fields.TextField(max_length=255)
sort_title = fields.CharField(max_length=255, blank=True, null=True) sort_title = fields.CharField(max_length=255, blank=True, null=True)
subtitle = fields.CharField(max_length=255, blank=True, null=True) subtitle = fields.TextField(max_length=255, blank=True, null=True)
description = fields.HtmlField(blank=True, null=True) description = fields.HtmlField(blank=True, null=True)
languages = fields.ArrayField( languages = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list models.CharField(max_length=255), blank=True, default=list
) )
series = fields.CharField(max_length=255, blank=True, null=True) series = fields.TextField(max_length=255, blank=True, null=True)
series_number = fields.CharField(max_length=255, blank=True, null=True) series_number = fields.CharField(max_length=255, blank=True, null=True)
subjects = fields.ArrayField( subjects = fields.ArrayField(
models.CharField(max_length=255), blank=True, null=True, default=list models.CharField(max_length=255), blank=True, null=True, default=list

View file

@ -112,6 +112,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
) )
name_field = "username" name_field = "username"
property_fields = [("following_link", "following")]
@property
def following_link(self):
""" just how to find out the following info """
return "{:s}/following".format(self.remote_id)
@property @property
def alt_text(self): def alt_text(self):

89
bookwyrm/redis_store.py Normal file
View file

@ -0,0 +1,89 @@
""" access the activity stores stored in redis """
from abc import ABC, abstractmethod
import redis
from bookwyrm import settings
r = redis.Redis(
host=settings.REDIS_ACTIVITY_HOST,
port=settings.REDIS_ACTIVITY_PORT,
password=settings.REDIS_ACTIVITY_PASSWORD,
db=0,
)
class RedisStore(ABC):
""" sets of ranked, related objects, like statuses for a user's feed """
max_length = settings.MAX_STREAM_LENGTH
def get_value(self, obj):
""" the object and rank """
return {obj.id: self.get_rank(obj)}
def add_object_to_related_stores(self, obj, execute=True):
""" add an object to all suitable stores """
value = self.get_value(obj)
# we want to do this as a bulk operation, hence "pipeline"
pipeline = r.pipeline()
for store in self.get_stores_for_object(obj):
# add the status to the feed
pipeline.zadd(store, value)
# trim the store
pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
if not execute:
return pipeline
# and go!
return pipeline.execute()
def remove_object_from_related_stores(self, obj):
""" remove an object from all stores """
pipeline = r.pipeline()
for store in self.get_stores_for_object(obj):
pipeline.zrem(store, -1, obj.id)
pipeline.execute()
def bulk_add_objects_to_store(self, objs, store):
""" add a list of objects to a given store """
pipeline = r.pipeline()
for obj in objs[: self.max_length]:
pipeline.zadd(store, self.get_value(obj))
if objs:
pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
pipeline.execute()
def bulk_remove_objects_from_store(self, objs, store):
""" remoev a list of objects from a given store """
pipeline = r.pipeline()
for obj in objs[: self.max_length]:
pipeline.zrem(store, -1, obj.id)
pipeline.execute()
def get_store(self, store): # pylint: disable=no-self-use
""" load the values in a store """
return r.zrevrange(store, 0, -1)
def populate_store(self, store):
""" go from zero to a store """
pipeline = r.pipeline()
queryset = self.get_objects_for_store(store)
for obj in queryset[: self.max_length]:
pipeline.zadd(store, self.get_value(obj))
# only trim the store if objects were added
if queryset.exists():
pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
pipeline.execute()
@abstractmethod
def get_objects_for_store(self, store):
""" a queryset of what should go in a store, used for populating it """
@abstractmethod
def get_stores_for_object(self, obj):
""" the stores that an object belongs in """
@abstractmethod
def get_rank(self, obj):
""" how to rank an object """

View file

@ -88,12 +88,18 @@
<div class="column is-half"> <div class="column is-half">
<section class="block"> <section class="block">
<h2 class="title is-4">{% trans "Metadata" %}</h2> <h2 class="title is-4">{% trans "Metadata" %}</h2>
<p class="mb-2"><label class="label" for="id_title">{% trans "Title:" %}</label> {{ form.title }} </p> <p class="mb-2">
<label class="label" for="id_title">{% trans "Title:" %}</label>
<input type="text" name="title" value="{{ form.title.value }}" maxlength="255" class="input" required="" id="id_title">
</p>
{% for error in form.title.errors %} {% for error in form.title.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
<p class="mb-2"><label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label> {{ form.subtitle }} </p> <p class="mb-2">
<label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label>
<input type="text" name="subtitle" value="{{ form.subtitle.value }}" maxlength="255" class="input" required="" id="id_subtitle">
</p>
{% for error in form.subtitle.errors %} {% for error in form.subtitle.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}

View file

@ -70,7 +70,7 @@
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<a <a
href="{{ user.local_path }}" href="{{ request.user.local_path }}"
class="navbar-link pulldown-menu" class="navbar-link pulldown-menu"
role="button" role="button"
aria-expanded="false" aria-expanded="false"

View file

@ -116,7 +116,9 @@ class Status(TestCase):
def test_status_to_activity_tombstone(self, *_): def test_status_to_activity_tombstone(self, *_):
""" subclass of the base model version with a "pure" serializer """ """ subclass of the base model version with a "pure" serializer """
with patch("bookwyrm.activitystreams.ActivityStream.remove_status"): with patch(
"bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores"
):
status = models.Status.objects.create( status = models.Status.objects.create(
content="test content", content="test content",
user=self.local_user, user=self.local_user,

View file

@ -47,18 +47,18 @@ class Activitystreams(TestCase):
"{}-test-unread".format(self.local_user.id), "{}-test-unread".format(self.local_user.id),
) )
def test_abstractstream_stream_users(self, *_): def test_abstractstream_get_audience(self, *_):
""" get a list of users that should see a status """ """ get a list of users that should see a status """
status = models.Status.objects.create( status = models.Status.objects.create(
user=self.remote_user, content="hi", privacy="public" user=self.remote_user, content="hi", privacy="public"
) )
users = self.test_stream.stream_users(status) users = self.test_stream.get_audience(status)
# remote users don't have feeds # remote users don't have feeds
self.assertFalse(self.remote_user in users) self.assertFalse(self.remote_user in users)
self.assertTrue(self.local_user in users) self.assertTrue(self.local_user in users)
self.assertTrue(self.another_user in users) self.assertTrue(self.another_user in users)
def test_abstractstream_stream_users_direct(self, *_): def test_abstractstream_get_audience_direct(self, *_):
""" get a list of users that should see a status """ """ get a list of users that should see a status """
status = models.Status.objects.create( status = models.Status.objects.create(
user=self.remote_user, user=self.remote_user,
@ -66,7 +66,7 @@ class Activitystreams(TestCase):
privacy="direct", privacy="direct",
) )
status.mention_users.add(self.local_user) status.mention_users.add(self.local_user)
users = self.test_stream.stream_users(status) users = self.test_stream.get_audience(status)
self.assertEqual(users, []) self.assertEqual(users, [])
status = models.Comment.objects.create( status = models.Comment.objects.create(
@ -76,22 +76,22 @@ class Activitystreams(TestCase):
book=self.book, book=self.book,
) )
status.mention_users.add(self.local_user) status.mention_users.add(self.local_user)
users = self.test_stream.stream_users(status) users = self.test_stream.get_audience(status)
self.assertTrue(self.local_user in users) self.assertTrue(self.local_user in users)
self.assertFalse(self.another_user in users) self.assertFalse(self.another_user in users)
self.assertFalse(self.remote_user in users) self.assertFalse(self.remote_user in users)
def test_abstractstream_stream_users_followers_remote_user(self, *_): def test_abstractstream_get_audience_followers_remote_user(self, *_):
""" get a list of users that should see a status """ """ get a list of users that should see a status """
status = models.Status.objects.create( status = models.Status.objects.create(
user=self.remote_user, user=self.remote_user,
content="hi", content="hi",
privacy="followers", privacy="followers",
) )
users = self.test_stream.stream_users(status) users = self.test_stream.get_audience(status)
self.assertFalse(users.exists()) self.assertFalse(users.exists())
def test_abstractstream_stream_users_followers_self(self, *_): def test_abstractstream_get_audience_followers_self(self, *_):
""" get a list of users that should see a status """ """ get a list of users that should see a status """
status = models.Comment.objects.create( status = models.Comment.objects.create(
user=self.local_user, user=self.local_user,
@ -99,12 +99,12 @@ class Activitystreams(TestCase):
privacy="direct", privacy="direct",
book=self.book, book=self.book,
) )
users = self.test_stream.stream_users(status) users = self.test_stream.get_audience(status)
self.assertTrue(self.local_user in users) self.assertTrue(self.local_user in users)
self.assertFalse(self.another_user in users) self.assertFalse(self.another_user in users)
self.assertFalse(self.remote_user in users) self.assertFalse(self.remote_user in users)
def test_abstractstream_stream_users_followers_with_mention(self, *_): def test_abstractstream_get_audience_followers_with_mention(self, *_):
""" get a list of users that should see a status """ """ get a list of users that should see a status """
status = models.Comment.objects.create( status = models.Comment.objects.create(
user=self.remote_user, user=self.remote_user,
@ -114,12 +114,12 @@ class Activitystreams(TestCase):
) )
status.mention_users.add(self.local_user) status.mention_users.add(self.local_user)
users = self.test_stream.stream_users(status) users = self.test_stream.get_audience(status)
self.assertTrue(self.local_user in users) self.assertTrue(self.local_user in users)
self.assertFalse(self.another_user in users) self.assertFalse(self.another_user in users)
self.assertFalse(self.remote_user in users) self.assertFalse(self.remote_user in users)
def test_abstractstream_stream_users_followers_with_relationship(self, *_): def test_abstractstream_get_audience_followers_with_relationship(self, *_):
""" get a list of users that should see a status """ """ get a list of users that should see a status """
self.remote_user.followers.add(self.local_user) self.remote_user.followers.add(self.local_user)
status = models.Comment.objects.create( status = models.Comment.objects.create(
@ -128,77 +128,77 @@ class Activitystreams(TestCase):
privacy="direct", privacy="direct",
book=self.book, book=self.book,
) )
users = self.test_stream.stream_users(status) users = self.test_stream.get_audience(status)
self.assertFalse(self.local_user in users) self.assertFalse(self.local_user in users)
self.assertFalse(self.another_user in users) self.assertFalse(self.another_user in users)
self.assertFalse(self.remote_user in users) self.assertFalse(self.remote_user in users)
def test_homestream_stream_users(self, *_): def test_homestream_get_audience(self, *_):
""" get a list of users that should see a status """ """ get a list of users that should see a status """
status = models.Status.objects.create( status = models.Status.objects.create(
user=self.remote_user, content="hi", privacy="public" user=self.remote_user, content="hi", privacy="public"
) )
users = activitystreams.HomeStream().stream_users(status) users = activitystreams.HomeStream().get_audience(status)
self.assertFalse(users.exists()) self.assertFalse(users.exists())
def test_homestream_stream_users_with_mentions(self, *_): def test_homestream_get_audience_with_mentions(self, *_):
""" get a list of users that should see a status """ """ get a list of users that should see a status """
status = models.Status.objects.create( status = models.Status.objects.create(
user=self.remote_user, content="hi", privacy="public" user=self.remote_user, content="hi", privacy="public"
) )
status.mention_users.add(self.local_user) status.mention_users.add(self.local_user)
users = activitystreams.HomeStream().stream_users(status) users = activitystreams.HomeStream().get_audience(status)
self.assertFalse(self.local_user in users) self.assertFalse(self.local_user in users)
self.assertFalse(self.another_user in users) self.assertFalse(self.another_user in users)
def test_homestream_stream_users_with_relationship(self, *_): def test_homestream_get_audience_with_relationship(self, *_):
""" get a list of users that should see a status """ """ get a list of users that should see a status """
self.remote_user.followers.add(self.local_user) self.remote_user.followers.add(self.local_user)
status = models.Status.objects.create( status = models.Status.objects.create(
user=self.remote_user, content="hi", privacy="public" user=self.remote_user, content="hi", privacy="public"
) )
users = activitystreams.HomeStream().stream_users(status) users = activitystreams.HomeStream().get_audience(status)
self.assertTrue(self.local_user in users) self.assertTrue(self.local_user in users)
self.assertFalse(self.another_user in users) self.assertFalse(self.another_user in users)
def test_localstream_stream_users_remote_status(self, *_): def test_localstream_get_audience_remote_status(self, *_):
""" get a list of users that should see a status """ """ get a list of users that should see a status """
status = models.Status.objects.create( status = models.Status.objects.create(
user=self.remote_user, content="hi", privacy="public" user=self.remote_user, content="hi", privacy="public"
) )
users = activitystreams.LocalStream().stream_users(status) users = activitystreams.LocalStream().get_audience(status)
self.assertEqual(users, []) self.assertEqual(users, [])
def test_localstream_stream_users_local_status(self, *_): def test_localstream_get_audience_local_status(self, *_):
""" get a list of users that should see a status """ """ get a list of users that should see a status """
status = models.Status.objects.create( status = models.Status.objects.create(
user=self.local_user, content="hi", privacy="public" user=self.local_user, content="hi", privacy="public"
) )
users = activitystreams.LocalStream().stream_users(status) users = activitystreams.LocalStream().get_audience(status)
self.assertTrue(self.local_user in users) self.assertTrue(self.local_user in users)
self.assertTrue(self.another_user in users) self.assertTrue(self.another_user in users)
def test_localstream_stream_users_unlisted(self, *_): def test_localstream_get_audience_unlisted(self, *_):
""" get a list of users that should see a status """ """ get a list of users that should see a status """
status = models.Status.objects.create( status = models.Status.objects.create(
user=self.local_user, content="hi", privacy="unlisted" user=self.local_user, content="hi", privacy="unlisted"
) )
users = activitystreams.LocalStream().stream_users(status) users = activitystreams.LocalStream().get_audience(status)
self.assertEqual(users, []) self.assertEqual(users, [])
def test_federatedstream_stream_users(self, *_): def test_federatedstream_get_audience(self, *_):
""" get a list of users that should see a status """ """ get a list of users that should see a status """
status = models.Status.objects.create( status = models.Status.objects.create(
user=self.remote_user, content="hi", privacy="public" user=self.remote_user, content="hi", privacy="public"
) )
users = activitystreams.FederatedStream().stream_users(status) users = activitystreams.FederatedStream().get_audience(status)
self.assertTrue(self.local_user in users) self.assertTrue(self.local_user in users)
self.assertTrue(self.another_user in users) self.assertTrue(self.another_user in users)
def test_federatedstream_stream_users_unlisted(self, *_): def test_federatedstream_get_audience_unlisted(self, *_):
""" get a list of users that should see a status """ """ get a list of users that should see a status """
status = models.Status.objects.create( status = models.Status.objects.create(
user=self.remote_user, content="hi", privacy="unlisted" user=self.remote_user, content="hi", privacy="unlisted"
) )
users = activitystreams.FederatedStream().stream_users(status) users = activitystreams.FederatedStream().get_audience(status)
self.assertEqual(users, []) self.assertEqual(users, [])

View file

@ -85,7 +85,9 @@ class TemplateTags(TestCase):
second_child = models.Status.objects.create( second_child = models.Status.objects.create(
reply_parent=parent, user=self.user, content="hi" reply_parent=parent, user=self.user, content="hi"
) )
with patch("bookwyrm.activitystreams.ActivityStream.remove_status"): with patch(
"bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores"
):
third_child = models.Status.objects.create( third_child = models.Status.objects.create(
reply_parent=parent, reply_parent=parent,
user=self.user, user=self.user,

View file

@ -444,7 +444,7 @@ class Inbox(TestCase):
"object": {"id": self.status.remote_id, "type": "Tombstone"}, "object": {"id": self.status.remote_id, "type": "Tombstone"},
} }
with patch( with patch(
"bookwyrm.activitystreams.ActivityStream.remove_status" "bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores"
) as redis_mock: ) as redis_mock:
views.inbox.activity_task(activity) views.inbox.activity_task(activity)
self.assertTrue(redis_mock.called) self.assertTrue(redis_mock.called)
@ -477,7 +477,7 @@ class Inbox(TestCase):
"object": {"id": self.status.remote_id, "type": "Tombstone"}, "object": {"id": self.status.remote_id, "type": "Tombstone"},
} }
with patch( with patch(
"bookwyrm.activitystreams.ActivityStream.remove_status" "bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores"
) as redis_mock: ) as redis_mock:
views.inbox.activity_task(activity) views.inbox.activity_task(activity)
self.assertTrue(redis_mock.called) self.assertTrue(redis_mock.called)
@ -572,6 +572,56 @@ class Inbox(TestCase):
self.assertEqual(notification.user, self.local_user) self.assertEqual(notification.user, self.local_user)
self.assertEqual(notification.related_status, self.status) self.assertEqual(notification.related_status, self.status)
@responses.activate
@patch("bookwyrm.activitystreams.ActivityStream.add_status")
def test_handle_boost_remote_status(self, redis_mock):
""" boost a status """
work = models.Work.objects.create(title="work title")
book = models.Edition.objects.create(
title="Test",
remote_id="https://bookwyrm.social/book/37292",
parent_work=work,
)
self.assertEqual(models.Notification.objects.count(), 0)
activity = {
"type": "Announce",
"id": "%s/boost" % self.status.remote_id,
"actor": self.remote_user.remote_id,
"object": "https://remote.com/status/1",
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
"published": "Mon, 25 May 2020 19:31:20 GMT",
}
responses.add(
responses.GET,
"https://remote.com/status/1",
json={
"id": "https://remote.com/status/1",
"type": "Comment",
"published": "2021-04-05T18:04:59.735190+00:00",
"attributedTo": self.remote_user.remote_id,
"content": "<p>a comment</p>",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://b875df3d118b.ngrok.io/user/mouse/followers"],
"inReplyTo": "",
"inReplyToBook": book.remote_id,
"summary": "",
"tag": [],
"sensitive": False,
"@context": "https://www.w3.org/ns/activitystreams",
},
)
with patch("bookwyrm.models.status.Status.ignore_activity") as discarder:
discarder.return_value = False
views.inbox.activity_task(activity)
self.assertTrue(redis_mock.called)
boost = models.Boost.objects.get()
self.assertEqual(boost.boosted_status.remote_id, "https://remote.com/status/1")
self.assertEqual(boost.boosted_status.comment.status_type, "Comment")
self.assertEqual(boost.boosted_status.comment.book, book)
@responses.activate @responses.activate
def test_handle_discarded_boost(self): def test_handle_discarded_boost(self):
""" test a boost of a mastodon status that will be discarded """ """ test a boost of a mastodon status that will be discarded """
@ -616,7 +666,7 @@ class Inbox(TestCase):
}, },
} }
with patch( with patch(
"bookwyrm.activitystreams.ActivityStream.remove_status" "bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores"
) as redis_mock: ) as redis_mock:
views.inbox.activity_task(activity) views.inbox.activity_task(activity)
self.assertTrue(redis_mock.called) self.assertTrue(redis_mock.called)

View file

@ -164,7 +164,7 @@ class InteractionViews(TestCase):
self.assertEqual(models.Boost.objects.count(), 1) self.assertEqual(models.Boost.objects.count(), 1)
self.assertEqual(models.Notification.objects.count(), 1) self.assertEqual(models.Notification.objects.count(), 1)
with patch( with patch(
"bookwyrm.activitystreams.ActivityStream.remove_status" "bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores"
) as redis_mock: ) as redis_mock:
view(request, status.id) view(request, status.id)
self.assertTrue(redis_mock.called) self.assertTrue(redis_mock.called)

View file

@ -177,7 +177,9 @@ class StatusViews(TestCase):
content="hi", book=self.book, user=self.local_user content="hi", book=self.book, user=self.local_user
) )
with patch("bookwyrm.activitystreams.ActivityStream.remove_status") as mock: with patch(
"bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores"
) as mock:
result = view(request, status.id) result = view(request, status.id)
self.assertTrue(mock.called) self.assertTrue(mock.called)
result.render() result.render()
@ -196,7 +198,9 @@ class StatusViews(TestCase):
book=self.book, rating=2.0, user=self.local_user book=self.book, rating=2.0, user=self.local_user
) )
with patch("bookwyrm.activitystreams.ActivityStream.remove_status") as mock: with patch(
"bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores"
) as mock:
result = view(request, status.id) result = view(request, status.id)
self.assertFalse(mock.called) self.assertFalse(mock.called)
self.assertEqual(result.status_code, 400) self.assertEqual(result.status_code, 400)
@ -214,7 +218,9 @@ class StatusViews(TestCase):
content="hi", user=self.local_user content="hi", user=self.local_user
) )
with patch("bookwyrm.activitystreams.ActivityStream.remove_status") as mock: with patch(
"bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores"
) as mock:
result = view(request, status.id) result = view(request, status.id)
self.assertFalse(mock.called) self.assertFalse(mock.called)
self.assertEqual(result.status_code, 400) self.assertEqual(result.status_code, 400)
@ -316,7 +322,7 @@ class StatusViews(TestCase):
request.user = self.local_user request.user = self.local_user
with patch( with patch(
"bookwyrm.activitystreams.ActivityStream.remove_status" "bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores"
) as redis_mock: ) as redis_mock:
view(request, status.id) view(request, status.id)
self.assertTrue(redis_mock.called) self.assertTrue(redis_mock.called)
@ -351,7 +357,7 @@ class StatusViews(TestCase):
request.user.is_superuser = True request.user.is_superuser = True
with patch( with patch(
"bookwyrm.activitystreams.ActivityStream.remove_status" "bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores"
) as redis_mock: ) as redis_mock:
view(request, status.id) view(request, status.id)
self.assertTrue(redis_mock.called) self.assertTrue(redis_mock.called)

View file

@ -31,7 +31,6 @@ class Feed(View):
tab = "home" tab = "home"
activities = activitystreams.streams[tab].get_activity_stream(request.user) activities = activitystreams.streams[tab].get_activity_stream(request.user)
paginated = Paginator(activities, PAGE_LENGTH) paginated = Paginator(activities, PAGE_LENGTH)
suggested_users = get_suggested_users(request.user) suggested_users = get_suggested_users(request.user)

View file

@ -138,7 +138,7 @@ def handle_remote_webfinger(query):
user = activitypub.resolve_remote_id( user = activitypub.resolve_remote_id(
link["href"], model=models.User link["href"], model=models.User
) )
except KeyError: except (KeyError, activitypub.ActivitySerializerError):
return None return None
return user return user

16
bw-dev
View file

@ -14,6 +14,9 @@ trap - EXIT
# show commands as they're executed # show commands as they're executed
set -x set -x
function runweb {
docker-compose run --rm web "$@"
}
function execdb { function execdb {
docker-compose exec db $@ docker-compose exec db $@
@ -45,17 +48,16 @@ case "$CMD" in
initdb initdb
;; ;;
makemigrations) makemigrations)
execweb python manage.py makemigrations "$@" runweb python manage.py makemigrations "$@"
;; ;;
migrate) migrate)
execweb python manage.py rename_app fedireads bookwyrm runweb python manage.py migrate "$@"
execweb python manage.py migrate "$@"
;; ;;
bash) bash)
execweb bash runweb bash
;; ;;
shell) shell)
execweb python manage.py shell runweb python manage.py shell
;; ;;
dbshell) dbshell)
execdb psql -U ${POSTGRES_USER} ${POSTGRES_DB} execdb psql -U ${POSTGRES_USER} ${POSTGRES_DB}
@ -64,7 +66,7 @@ case "$CMD" in
docker-compose restart celery_worker docker-compose restart celery_worker
;; ;;
collectstatic) collectstatic)
execweb python manage.py collectstatic --no-input runweb python manage.py collectstatic --no-input
;; ;;
build) build)
docker-compose build docker-compose build
@ -77,7 +79,7 @@ case "$CMD" in
docker-compose restart docker-compose restart
;; ;;
populate_streams) populate_streams)
execweb python manage.py populate_streams runweb python manage.py populate_streams
;; ;;
*) *)
echo "Unrecognised command. Try: build, up, initdb, makemigrations, migrate, bash, shell, dbshell, restart_celery, update, populate_feeds" echo "Unrecognised command. Try: build, up, initdb, makemigrations, migrate, bash, shell, dbshell, restart_celery, update, populate_feeds"