Commit ecd395d6 authored by Eliot Berriot's avatar Eliot Berriot 💬

Library follows and user notifications

parent a8799932
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