Skip to content
Snippets Groups Projects
Commit ecd395d6 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Library follows and user notifications

parent a8799932
No related branches found
No related tags found
No related merge requests found
Showing
with 603 additions and 180 deletions
import uuid
import logging
from django.db import transaction, IntegrityError
from django.utils import timezone
from funkwhale_api.common import channels
from funkwhale_api.common import utils as funkwhale_utils
logger = logging.getLogger(__name__)
PUBLIC_ADDRESS = "https://www.w3.org/ns/activitystreams#Public"
ACTIVITY_TYPES = [
......@@ -54,19 +60,10 @@ OBJECT_TYPES = [
] + ACTIVITY_TYPES
def deliver(activity, on_behalf_of, to=[]):
from . import tasks
return tasks.send.delay(activity=activity, actor_id=on_behalf_of.pk, to=to)
def accept_follow(follow):
from . import serializers
serializer = serializers.AcceptFollowSerializer(follow)
return deliver(serializer.data, to=[follow.actor.fid], on_behalf_of=follow.target)
BROADCAST_TO_USER_ACTIVITIES = ["Follow", "Accept"]
@transaction.atomic
def receive(activity, on_behalf_of):
from . import models
from . import serializers
......@@ -78,7 +75,14 @@ def receive(activity, on_behalf_of):
data=activity, context={"actor": on_behalf_of, "local_recipients": True}
)
serializer.is_valid(raise_exception=True)
copy = serializer.save()
try:
copy = serializer.save()
except IntegrityError:
logger.warning(
"[federation] Discarding already elivered activity %s",
serializer.validated_data.get("id"),
)
return
# we create inbox items for further delivery
items = [
models.InboxItem(activity=copy, actor=r, type="to")
......@@ -93,7 +97,7 @@ def receive(activity, on_behalf_of):
models.InboxItem.objects.bulk_create(items)
# at this point, we have the activity in database. Even if we crash, it's
# okay, as we can retry later
tasks.dispatch_inbox.delay(activity_id=copy.pk)
funkwhale_utils.on_commit(tasks.dispatch_inbox.delay, activity_id=copy.pk)
return copy
......@@ -113,17 +117,64 @@ class Router:
class InboxRouter(Router):
@transaction.atomic
def dispatch(self, payload, context):
"""
Receives an Activity payload and some context and trigger our
business logic
"""
from . import api_serializers
from . import models
for route, handler in self.routes:
if match_route(route, payload):
return handler(payload, context=context)
r = handler(payload, context=context)
activity_obj = context.get("activity")
if activity_obj and r:
# handler returned additional data we can use
# to update the activity target
for key, value in r.items():
setattr(activity_obj, key, value)
update_fields = []
for k in r.keys():
if k in ["object", "target", "related_object"]:
update_fields += [
"{}_id".format(k),
"{}_content_type".format(k),
]
else:
update_fields.append(k)
activity_obj.save(update_fields=update_fields)
if payload["type"] not in BROADCAST_TO_USER_ACTIVITIES:
return
inbox_items = context.get(
"inbox_items", models.InboxItem.objects.none()
)
for ii in inbox_items:
user = ii.actor.get_user()
if not user:
continue
group = "user.{}.inbox".format(user.pk)
channels.group_send(
group,
{
"type": "event.send",
"text": "",
"data": {
"type": "inbox.item_added",
"item": api_serializers.InboxItemSerializer(ii).data,
},
},
)
inbox_items.update(is_delivered=True, last_delivery_date=timezone.now())
return
class OutboxRouter(Router):
@transaction.atomic
def dispatch(self, routing, context):
"""
Receives a routing payload and some business objects in the context
......@@ -140,12 +191,11 @@ class OutboxRouter(Router):
# a route can yield zero, one or more activity payloads
if e:
activities_data.append(e)
inbox_items_by_activity_uuid = {}
prepared_activities = []
for activity_data in activities_data:
to = activity_data.pop("to", [])
cc = activity_data.pop("cc", [])
to = activity_data["payload"].pop("to", [])
cc = activity_data["payload"].pop("cc", [])
a = models.Activity(**activity_data)
a.uuid = uuid.uuid4()
to_items, new_to = prepare_inbox_items(to, "to")
......@@ -160,7 +210,6 @@ class OutboxRouter(Router):
prepared_activities.append(a)
activities = models.Activity.objects.bulk_create(prepared_activities)
activities = [a for a in activities if a]
final_inbox_items = []
for a in activities:
......
from django.contrib import admin
from . import models
from . import tasks
def redeliver_inbox_items(modeladmin, request, queryset):
for id in set(
queryset.filter(activity__actor__user__isnull=False).values_list(
"activity", flat=True
)
):
tasks.dispatch_outbox.delay(activity_id=id)
redeliver_inbox_items.short_description = "Redeliver"
def redeliver_activities(modeladmin, request, queryset):
for id in set(
queryset.filter(actor__user__isnull=False).values_list("id", flat=True)
):
tasks.dispatch_outbox.delay(activity_id=id)
redeliver_activities.short_description = "Redeliver"
@admin.register(models.Activity)
class ActivityAdmin(admin.ModelAdmin):
list_display = ["type", "fid", "url", "actor", "creation_date"]
search_fields = ["payload", "fid", "url", "actor__domain"]
list_filter = ["type", "actor__domain"]
actions = [redeliver_activities]
list_select_related = True
@admin.register(models.Actor)
......@@ -25,24 +57,24 @@ class FollowAdmin(admin.ModelAdmin):
list_select_related = True
@admin.register(models.Library)
class LibraryAdmin(admin.ModelAdmin):
list_display = ["actor", "url", "creation_date", "fetched_date", "tracks_count"]
search_fields = ["actor__fid", "url"]
list_filter = ["federation_enabled", "download_files", "autoimport"]
@admin.register(models.LibraryFollow)
class LibraryFollowAdmin(admin.ModelAdmin):
list_display = ["actor", "target", "approved", "creation_date"]
list_filter = ["approved"]
search_fields = ["actor__fid", "target__fid"]
list_select_related = True
@admin.register(models.LibraryTrack)
class LibraryTrackAdmin(admin.ModelAdmin):
@admin.register(models.InboxItem)
class InboxItemAdmin(admin.ModelAdmin):
list_display = [
"title",
"artist_name",
"album_title",
"url",
"library",
"creation_date",
"published_date",
"actor",
"activity",
"type",
"last_delivery_date",
"delivery_attempts",
]
search_fields = ["library__url", "url", "artist_name", "title", "album_title"]
list_filter = ["type"]
search_fields = ["actor__fid", "activity__fid"]
list_select_related = True
actions = [redeliver_inbox_items]
......@@ -3,8 +3,9 @@ from rest_framework import serializers
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.music import models as music_models
from . import serializers as federation_serializers
from . import filters
from . import models
from . import serializers as federation_serializers
class NestedLibraryFollowSerializer(serializers.ModelSerializer):
......@@ -44,14 +45,79 @@ class LibrarySerializer(serializers.ModelSerializer):
class LibraryFollowSerializer(serializers.ModelSerializer):
target = common_serializers.RelatedField("uuid", LibrarySerializer(), required=True)
actor = serializers.SerializerMethodField()
class Meta:
model = models.LibraryFollow
fields = ["creation_date", "uuid", "target", "approved"]
read_only_fields = ["uuid", "approved", "creation_date"]
fields = ["creation_date", "actor", "uuid", "target", "approved"]
read_only_fields = ["uuid", "actor", "approved", "creation_date"]
def validate_target(self, v):
actor = self.context["actor"]
if v.received_follows.filter(actor=actor).exists():
raise serializers.ValidationError("You are already following this library")
return v
def get_actor(self, o):
return federation_serializers.APIActorSerializer(o.actor).data
def serialize_generic_relation(activity, obj):
data = {"uuid": obj.uuid, "type": obj._meta.label}
if data["type"] == "music.Library":
data["name"] = obj.name
if data["type"] == "federation.LibraryFollow":
data["approved"] = obj.approved
return data
class ActivitySerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer()
object = serializers.SerializerMethodField()
target = serializers.SerializerMethodField()
related_object = serializers.SerializerMethodField()
class Meta:
model = models.Activity
fields = [
"uuid",
"fid",
"actor",
"payload",
"object",
"target",
"related_object",
"actor",
"creation_date",
"type",
]
def get_object(self, o):
if o.object:
return serialize_generic_relation(o, o.object)
def get_related_object(self, o):
if o.related_object:
return serialize_generic_relation(o, o.related_object)
def get_target(self, o):
if o.target:
return serialize_generic_relation(o, o.target)
class InboxItemSerializer(serializers.ModelSerializer):
activity = ActivitySerializer()
class Meta:
model = models.InboxItem
fields = ["id", "type", "activity", "is_read"]
read_only_fields = ["id", "type", "activity"]
class InboxItemActionSerializer(common_serializers.ActionSerializer):
actions = [common_serializers.Action("read", allow_all=True)]
filterset_class = filters.InboxItemFilter
def handle_read(self, objects):
return objects.update(is_read=True)
......@@ -4,6 +4,7 @@ from . import api_views
router = routers.SimpleRouter()
router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-follows")
router.register(r"inbox", api_views.InboxItemViewSet, "inbox")
router.register(r"libraries", api_views.LibraryViewSet, "libraries")
urlpatterns = router.urls
import requests.exceptions
from django.db import transaction
from django.db.models import Count
from rest_framework import decorators
......@@ -10,6 +11,7 @@ from rest_framework import viewsets
from funkwhale_api.music import models as music_models
from . import activity
from . import api_serializers
from . import filters
from . import models
......@@ -18,6 +20,13 @@ from . import serializers
from . import utils
@transaction.atomic
def update_follow(follow, approved):
follow.approved = approved
follow.save(update_fields=["approved"])
routes.outbox.dispatch({"type": "Accept"}, context={"follow": follow})
class LibraryFollowViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
......@@ -48,6 +57,29 @@ class LibraryFollowViewSet(
context["actor"] = self.request.user.actor
return context
@decorators.detail_route(methods=["post"])
def accept(self, request, *args, **kwargs):
try:
follow = self.queryset.get(
target__actor=self.request.user.actor, uuid=kwargs["uuid"]
)
except models.LibraryFollow.DoesNotExist:
return response.Response({}, status=404)
update_follow(follow, approved=True)
return response.Response(status=204)
@decorators.detail_route(methods=["post"])
def reject(self, request, *args, **kwargs):
try:
follow = self.queryset.get(
target__actor=self.request.user.actor, uuid=kwargs["uuid"]
)
except models.LibraryFollow.DoesNotExist:
return response.Response({}, status=404)
update_follow(follow, approved=False)
return response.Response(status=204)
class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
lookup_field = "uuid"
......@@ -59,8 +91,6 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
)
serializer_class = api_serializers.LibrarySerializer
permission_classes = [permissions.IsAuthenticated]
filter_class = filters.LibraryFollowFilter
ordering_fields = ("creation_date",)
def get_queryset(self):
qs = super().get_queryset()
......@@ -90,3 +120,36 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
)
serializer = self.serializer_class(library)
return response.Response({"count": 1, "results": [serializer.data]})
class InboxItemViewSet(
mixins.UpdateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
queryset = (
models.InboxItem.objects.select_related("activity__actor")
.prefetch_related("activity__object", "activity__target")
.filter(activity__type__in=activity.BROADCAST_TO_USER_ACTIVITIES, type="to")
.order_by("-activity__creation_date")
)
serializer_class = api_serializers.InboxItemSerializer
permission_classes = [permissions.IsAuthenticated]
filter_class = filters.InboxItemFilter
ordering_fields = ("activity__creation_date",)
def get_queryset(self):
qs = super().get_queryset()
return qs.filter(actor=self.request.user.actor)
@decorators.list_route(methods=["post"])
def action(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = api_serializers.InboxItemActionSerializer(
request.data, queryset=queryset
)
serializer.is_valid(raise_exception=True)
result = serializer.save()
return response.Response(result, status=200)
import django_filters
import django_filters.widgets
from funkwhale_api.common import fields
......@@ -32,3 +32,17 @@ class LibraryFollowFilter(django_filters.FilterSet):
class Meta:
model = models.LibraryFollow
fields = ["approved"]
class InboxItemFilter(django_filters.FilterSet):
is_read = django_filters.BooleanFilter(
"is_read", widget=django_filters.widgets.BooleanWidget()
)
before = django_filters.NumberFilter(method="filter_before")
class Meta:
model = models.InboxItem
fields = ["is_read", "activity__type", "activity__actor"]
def filter_before(self, queryset, field_name, value):
return queryset.filter(pk__lte=value)
# Generated by Django 2.0.8 on 2018-09-10 19:02
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('federation', '0010_auto_20180904_2011'),
]
operations = [
migrations.AddField(
model_name='activity',
name='object_content_type',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objecting_activities', to='contenttypes.ContentType'),
),
migrations.AddField(
model_name='activity',
name='object_id',
field=models.IntegerField(null=True),
),
migrations.AddField(
model_name='activity',
name='related_object_content_type',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_objecting_activities', to='contenttypes.ContentType'),
),
migrations.AddField(
model_name='activity',
name='related_object_id',
field=models.IntegerField(null=True),
),
migrations.AddField(
model_name='activity',
name='target_content_type',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='targeting_activities', to='contenttypes.ContentType'),
),
migrations.AddField(
model_name='activity',
name='target_id',
field=models.IntegerField(null=True),
),
migrations.AddField(
model_name='activity',
name='type',
field=models.CharField(db_index=True, max_length=100, null=True),
),
migrations.AddField(
model_name='inboxitem',
name='is_read',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='activity',
name='creation_date',
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now),
),
]
......@@ -3,6 +3,8 @@ import uuid
from django.conf import settings
from django.contrib.postgres.fields import JSONField
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
......@@ -173,6 +175,7 @@ class InboxItem(models.Model):
type = models.CharField(max_length=10, choices=[("to", "to"), ("cc", "cc")])
last_delivery_date = models.DateTimeField(null=True, blank=True)
delivery_attempts = models.PositiveIntegerField(default=0)
is_read = models.BooleanField(default=False)
objects = InboxItemQuerySet.as_manager()
......@@ -188,7 +191,36 @@ class Activity(models.Model):
fid = models.URLField(unique=True, max_length=500, null=True, blank=True)
url = models.URLField(max_length=500, null=True, blank=True)
payload = JSONField(default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder)
creation_date = models.DateTimeField(default=timezone.now)
creation_date = models.DateTimeField(default=timezone.now, db_index=True)
type = models.CharField(db_index=True, null=True, max_length=100)
# generic relations
object_id = models.IntegerField(null=True)
object_content_type = models.ForeignKey(
ContentType,
null=True,
on_delete=models.SET_NULL,
related_name="objecting_activities",
)
object = GenericForeignKey("object_content_type", "object_id")
target_id = models.IntegerField(null=True)
target_content_type = models.ForeignKey(
ContentType,
null=True,
on_delete=models.SET_NULL,
related_name="targeting_activities",
)
target = GenericForeignKey("target_content_type", "target_id")
related_object_id = models.IntegerField(null=True)
related_object_content_type = models.ForeignKey(
ContentType,
null=True,
on_delete=models.SET_NULL,
related_name="related_objecting_activities",
)
related_object = GenericForeignKey(
"related_object_content_type", "related_object_id"
)
class AbstractFollow(models.Model):
......
......@@ -33,10 +33,10 @@ def inbox_follow(payload, context):
autoapprove = serializer.validated_data["object"].should_autoapprove_follow(
context["actor"]
)
follow = serializer.save(approved=autoapprove)
if autoapprove:
activity.accept_follow(follow)
follow = serializer.save(approved=True if autoapprove else None)
if follow.approved:
outbox.dispatch({"type": "Accept"}, context={"follow": follow})
return {"object": follow.target, "related_object": follow}
@inbox.register({"type": "Accept"})
......@@ -54,6 +54,8 @@ def inbox_accept(payload, context):
return
serializer.save()
obj = serializer.validated_data["follow"]
return {"object": obj, "related_object": obj.target}
@outbox.register({"type": "Accept"})
......@@ -64,7 +66,13 @@ def outbox_accept(context):
else:
actor = follow.target
payload = serializers.AcceptFollowSerializer(follow, context={"actor": actor}).data
yield {"actor": actor, "payload": with_recipients(payload, to=[follow.actor])}
yield {
"actor": actor,
"type": "Accept",
"payload": with_recipients(payload, to=[follow.actor]),
"object": follow,
"related_object": follow.target,
}
@outbox.register({"type": "Follow"})
......@@ -75,4 +83,10 @@ def outbox_follow(context):
else:
target = follow.target
payload = serializers.FollowSerializer(follow, context={"actor": follow.actor}).data
yield {"actor": follow.actor, "payload": with_recipients(payload, to=[target])}
yield {
"type": "Follow",
"actor": follow.actor,
"payload": with_recipients(payload, to=[target]),
"object": follow.target,
"related_object": follow,
}
......@@ -155,6 +155,7 @@ class BaseActivitySerializer(serializers.Serializer):
fid=validated_data.get("id"),
actor=validated_data["actor"],
payload=self.initial_data,
type=validated_data["type"],
)
def validate(self, data):
......
......@@ -74,8 +74,14 @@ def dispatch_inbox(activity):
routes.inbox.dispatch(
activity.payload,
context={
"activity": activity,
"actor": activity.actor,
"inbox_items": list(activity.inbox_items.local().select_related()),
"inbox_items": (
activity.inbox_items.local()
.select_related()
.select_related("actor__user")
.prefetch_related("activity__object", "activity__target")
),
},
)
except Exception:
......
......@@ -37,15 +37,12 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
@detail_route(methods=["get", "post"])
def inbox(self, request, *args, **kwargs):
actor = self.get_object()
if request.method.lower() == "post" and request.actor is None:
raise exceptions.AuthenticationFailed(
"You need a valid signature to send an activity"
)
if request.method.lower() == "post":
activity.receive(
activity=request.data, on_behalf_of=request.actor, recipient=actor
)
activity.receive(activity=request.data, on_behalf_of=request.actor)
return response.Response({}, status=200)
@detail_route(methods=["get", "post"])
......
......@@ -18,6 +18,7 @@ from taggit.models import Tag
from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.federation.authentication import SignatureAuthentication
from funkwhale_api.federation import api_serializers as federation_api_serializers
from . import filters, models, serializers, tasks, utils
......@@ -94,6 +95,25 @@ class LibraryViewSet(
def perform_create(self, serializer):
serializer.save(actor=self.request.user.actor)
@detail_route(methods=["get"])
@transaction.non_atomic_requests
def follows(self, request, *args, **kwargs):
library = self.get_object()
queryset = (
library.received_follows.filter(target__actor=self.request.user.actor)
.select_related("actor", "target__actor")
.order_by("-creation_date")
)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = federation_api_serializers.LibraryFollowSerializer(
page, many=True
)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet):
"""
......
......@@ -206,7 +206,7 @@ class User(AbstractUser):
}
def get_channels_groups(self):
groups = ["imports"]
groups = ["imports", "inbox"]
return ["user.{}.{}".format(self.pk, g) for g in groups]
......
......@@ -15,6 +15,7 @@ from django.core.cache import cache as django_cache
from django.core.files import uploadedfile
from django.utils import timezone
from django.test import client
from django.db.models import QuerySet
from dynamic_preferences.registries import global_preferences_registry
from rest_framework import fields as rest_fields
from rest_framework.test import APIClient, APIRequestFactory
......@@ -23,6 +24,43 @@ from funkwhale_api.activity import record
from funkwhale_api.users.permissions import HasUserPermission
@pytest.fixture
def queryset_equal_queries():
"""
Unitting querysets is hard because we have to compare queries
by hand. Let's monkey patch querysets to do that for us.
"""
def __eq__(self, other):
if isinstance(other, QuerySet):
return str(other.query) == str(self.query)
else:
return False
setattr(QuerySet, "__eq__", __eq__)
yield __eq__
delattr(QuerySet, "__eq__")
@pytest.fixture
def queryset_equal_list():
"""
Unitting querysets is hard because we usually simply wants to ensure
a querysets contains the same objects as a list, let's monkey patch
querysets to to that for us.
"""
def __eq__(self, other):
if isinstance(other, (list, tuple)):
return list(self) == list(other)
else:
return False
setattr(QuerySet, "__eq__", __eq__)
yield __eq__
delattr(QuerySet, "__eq__")
@pytest.fixture(scope="session", autouse=True)
def factories_autodiscover():
from django.apps import apps
......
import pytest
from funkwhale_api.federation import activity, serializers, tasks
def test_accept_follow(mocker, factories):
deliver = mocker.patch("funkwhale_api.federation.activity.deliver")
follow = factories["federation.Follow"](approved=None)
expected_accept = serializers.AcceptFollowSerializer(follow).data
activity.accept_follow(follow)
deliver.assert_called_once_with(
expected_accept, to=[follow.actor.fid], on_behalf_of=follow.target
)
from funkwhale_api.federation import activity, api_serializers, serializers, tasks
def test_receive_validates_basic_attributes_and_stores_activity(factories, now, mocker):
mocked_dispatch = mocker.patch(
"funkwhale_api.federation.tasks.dispatch_inbox.delay"
)
mocked_dispatch = mocker.patch("funkwhale_api.common.utils.on_commit")
local_actor = factories["users.User"]().create_actor()
remote_actor = factories["federation.Actor"]()
another_actor = factories["federation.Actor"]()
......@@ -36,7 +24,10 @@ def test_receive_validates_basic_attributes_and_stores_activity(factories, now,
assert copy.creation_date >= now
assert copy.actor == remote_actor
assert copy.fid == a["id"]
mocked_dispatch.assert_called_once_with(activity_id=copy.pk)
assert copy.type == "Noop"
mocked_dispatch.assert_called_once_with(
tasks.dispatch_inbox.delay, activity_id=copy.pk
)
inbox_item = copy.inbox_items.get(actor__fid=local_actor.fid)
assert inbox_item.is_delivered is False
......@@ -63,16 +54,62 @@ def test_receive_actor_mismatch(factories):
activity.receive(activity=a, on_behalf_of=remote_actor)
def test_inbox_routing(mocker):
def test_inbox_routing(factories, mocker):
object = factories["music.Artist"]()
target = factories["music.Artist"]()
router = activity.InboxRouter()
a = factories["federation.Activity"](type="Follow")
handler_payload = {}
handler_context = {}
def handler(payload, context):
handler_payload.update(payload)
handler_context.update(context)
return {"target": target, "object": object}
handler = mocker.stub(name="handler")
router.connect({"type": "Follow"}, handler)
good_message = {"type": "Follow"}
router.dispatch(good_message, context={})
router.dispatch(good_message, context={"activity": a})
assert handler_payload == good_message
assert handler_context == {"activity": a}
a.refresh_from_db()
assert a.object == object
assert a.target == target
handler.assert_called_once_with(good_message, context={})
def test_inbox_routing_send_to_channel(factories, mocker):
group_send = mocker.patch("funkwhale_api.common.channels.group_send")
a = factories["federation.Activity"](type="Follow")
ii = factories["federation.InboxItem"](actor__local=True)
router = activity.InboxRouter()
handler = mocker.stub()
router.connect({"type": "Follow"}, handler)
good_message = {"type": "Follow"}
router.dispatch(
good_message, context={"activity": a, "inbox_items": ii.__class__.objects.all()}
)
ii.refresh_from_db()
assert ii.is_delivered is True
group_send.assert_called_once_with(
"user.{}.inbox".format(ii.actor.user.pk),
{
"type": "event.send",
"text": "",
"data": {
"type": "inbox.item_added",
"item": api_serializers.InboxItemSerializer(ii).data,
},
},
)
@pytest.mark.parametrize(
......@@ -101,10 +138,10 @@ def test_outbox_router_dispatch(mocker, factories, now):
"type": "Noop",
"actor": actor.fid,
"summary": context["summary"],
"to": [r1],
"cc": [r2, activity.PUBLIC_ADDRESS],
},
"actor": actor,
"to": [r1],
"cc": [r2, activity.PUBLIC_ADDRESS],
}
router.connect({"type": "Noop"}, handler)
......
import pytest
from django.urls import reverse
from django.utils import timezone
from rest_framework import exceptions
from funkwhale_api.federation import actors, models, serializers, utils
......@@ -120,58 +119,6 @@ def test_test_post_outbox_validates_actor(nodb_factories):
assert msg in exc_info.value
def test_test_post_inbox_handles_create_note(settings, mocker, factories):
deliver = mocker.patch("funkwhale_api.federation.activity.deliver")
actor = factories["federation.Actor"]()
now = timezone.now()
mocker.patch("django.utils.timezone.now", return_value=now)
data = {
"actor": actor.fid,
"type": "Create",
"id": "http://test.federation/activity",
"object": {
"type": "Note",
"id": "http://test.federation/object",
"content": "<p><a>@mention</a> /ping</p>",
},
}
test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance()
expected_note = factories["federation.Note"](
id="https://test.federation/activities/note/{}".format(now.timestamp()),
content="Pong!",
published=now.isoformat(),
inReplyTo=data["object"]["id"],
cc=[],
summary=None,
sensitive=False,
attributedTo=test_actor.fid,
attachment=[],
to=[actor.fid],
url="https://{}/activities/note/{}".format(
settings.FEDERATION_HOSTNAME, now.timestamp()
),
tag=[{"href": actor.fid, "name": actor.full_username, "type": "Mention"}],
)
expected_activity = {
"@context": serializers.AP_CONTEXT,
"actor": test_actor.fid,
"id": "https://{}/activities/note/{}/activity".format(
settings.FEDERATION_HOSTNAME, now.timestamp()
),
"to": actor.fid,
"type": "Create",
"published": now.isoformat(),
"object": expected_note,
"cc": [],
}
actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor)
deliver.assert_called_once_with(
expected_activity,
to=[actor.fid],
on_behalf_of=actors.SYSTEM_ACTORS["test"].get_actor_instance(),
)
def test_getting_actor_instance_persists_in_db(db):
test = actors.SYSTEM_ACTORS["test"].get_actor_instance()
from_db = models.Actor.objects.get(fid=test.fid)
......@@ -220,57 +167,3 @@ def test_system_actor_handle(mocker, nodb_factories):
assert serializer.is_valid()
actors.SYSTEM_ACTORS["test"].handle(activity, actor)
handler.assert_called_once_with(activity, actor)
def test_test_actor_handles_follow(settings, mocker, factories):
deliver = mocker.patch("funkwhale_api.federation.activity.deliver")
actor = factories["federation.Actor"]()
accept_follow = mocker.patch("funkwhale_api.federation.activity.accept_follow")
test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance()
data = {
"actor": actor.fid,
"type": "Follow",
"id": "http://test.federation/user#follows/267",
"object": test_actor.fid,
}
actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor)
follow = models.Follow.objects.get(target=test_actor, approved=True)
follow_back = models.Follow.objects.get(actor=test_actor, approved=None)
accept_follow.assert_called_once_with(follow)
deliver.assert_called_once_with(
serializers.FollowSerializer(follow_back).data,
on_behalf_of=test_actor,
to=[actor.fid],
)
def test_test_actor_handles_undo_follow(settings, mocker, factories):
deliver = mocker.patch("funkwhale_api.federation.activity.deliver")
test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance()
follow = factories["federation.Follow"](target=test_actor)
reverse_follow = factories["federation.Follow"](
actor=test_actor, target=follow.actor
)
follow_serializer = serializers.FollowSerializer(follow)
reverse_follow_serializer = serializers.FollowSerializer(reverse_follow)
undo = {
"@context": serializers.AP_CONTEXT,
"type": "Undo",
"id": follow_serializer.data["id"] + "/undo",
"actor": follow.actor.fid,
"object": follow_serializer.data,
}
expected_undo = {
"@context": serializers.AP_CONTEXT,
"type": "Undo",
"id": reverse_follow_serializer.data["id"] + "/undo",
"actor": reverse_follow.actor.fid,
"object": reverse_follow_serializer.data,
}
actors.SYSTEM_ACTORS["test"].post_inbox(undo, actor=follow.actor)
deliver.assert_called_once_with(
expected_undo, to=[follow.actor.fid], on_behalf_of=test_actor
)
assert models.Follow.objects.count() == 0
from funkwhale_api.federation import filters
from funkwhale_api.federation import models
def test_inbox_item_filter_before(factories):
expected = models.InboxItem.objects.filter(pk__lte=12)
f = filters.InboxItemFilter({"before": 12}, queryset=models.InboxItem.objects.all())
assert str(f.qs.query) == str(expected.query)
......@@ -51,3 +51,12 @@ def test_library_serializer_validates_existing_follow(factories):
assert serializer.is_valid() is False
assert "target" in serializer.errors
def test_manage_track_file_action_read(factories):
ii = factories["federation.InboxItem"]()
s = api_serializers.InboxItemActionSerializer(queryset=None)
s.handle_read(ii.__class__.objects.all())
assert ii.__class__.objects.filter(is_read=False).count() == 0
import pytest
from django.urls import reverse
from funkwhale_api.federation import api_serializers
......@@ -49,3 +51,82 @@ def test_can_follow_library(factories, logged_in_api_client, mocker):
assert follow.actor == actor
dispatch.assert_called_once_with({"type": "Follow"}, context={"follow": follow})
@pytest.mark.parametrize("action", ["accept", "reject"])
def test_user_cannot_edit_someone_else_library_follow(
factories, logged_in_api_client, action
):
logged_in_api_client.user.create_actor()
follow = factories["federation.LibraryFollow"]()
url = reverse(
"api:v1:federation:library-follows-{}".format(action),
kwargs={"uuid": follow.uuid},
)
response = logged_in_api_client.post(url)
assert response.status_code == 404
@pytest.mark.parametrize("action,expected", [("accept", True), ("reject", False)])
def test_user_can_accept_or_reject_own_follows(
factories, logged_in_api_client, action, expected, mocker
):
mocked_dispatch = mocker.patch(
"funkwhale_api.federation.activity.OutboxRouter.dispatch"
)
actor = logged_in_api_client.user.create_actor()
follow = factories["federation.LibraryFollow"](target__actor=actor)
url = reverse(
"api:v1:federation:library-follows-{}".format(action),
kwargs={"uuid": follow.uuid},
)
response = logged_in_api_client.post(url)
assert response.status_code == 204
follow.refresh_from_db()
assert follow.approved is expected
mocked_dispatch.assert_called_once_with(
{"type": "Accept"}, context={"follow": follow}
)
def test_user_can_list_inbox_items(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
ii = factories["federation.InboxItem"](
activity__type="Follow", actor=actor, type="to"
)
factories["federation.InboxItem"](activity__type="Follow", actor=actor, type="cc")
factories["federation.InboxItem"](activity__type="Follow", type="to")
url = reverse("api:v1:federation:inbox-list")
response = logged_in_api_client.get(url)
assert response.status_code == 200
assert response.data == {
"count": 1,
"results": [api_serializers.InboxItemSerializer(ii).data],
"next": None,
"previous": None,
}
def test_user_can_update_read_status_of_inbox_item(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
ii = factories["federation.InboxItem"](
activity__type="Follow", actor=actor, type="to"
)
url = reverse("api:v1:federation:inbox-detail", kwargs={"pk": ii.pk})
response = logged_in_api_client.patch(url, {"is_read": True})
assert response.status_code == 200
ii.refresh_from_db()
assert ii.is_read is True
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment