Commit 8af459ff authored by Agate's avatar Agate 💬

Merge branch 'library-follow' into 'develop'

Library follows and user notifications

See merge request funkwhale/funkwhale!407
parents a8799932 ecd395d6
import uuid 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 from funkwhale_api.common import utils as funkwhale_utils
logger = logging.getLogger(__name__)
PUBLIC_ADDRESS = "https://www.w3.org/ns/activitystreams#Public" PUBLIC_ADDRESS = "https://www.w3.org/ns/activitystreams#Public"
ACTIVITY_TYPES = [ ACTIVITY_TYPES = [
...@@ -54,19 +60,10 @@ OBJECT_TYPES = [ ...@@ -54,19 +60,10 @@ OBJECT_TYPES = [
] + ACTIVITY_TYPES ] + ACTIVITY_TYPES
def deliver(activity, on_behalf_of, to=[]): BROADCAST_TO_USER_ACTIVITIES = ["Follow", "Accept"]
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)
@transaction.atomic
def receive(activity, on_behalf_of): def receive(activity, on_behalf_of):
from . import models from . import models
from . import serializers from . import serializers
...@@ -78,7 +75,14 @@ def receive(activity, on_behalf_of): ...@@ -78,7 +75,14 @@ def receive(activity, on_behalf_of):
data=activity, context={"actor": on_behalf_of, "local_recipients": True} data=activity, context={"actor": on_behalf_of, "local_recipients": True}
) )
serializer.is_valid(raise_exception=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 # we create inbox items for further delivery
items = [ items = [
models.InboxItem(activity=copy, actor=r, type="to") models.InboxItem(activity=copy, actor=r, type="to")
...@@ -93,7 +97,7 @@ def receive(activity, on_behalf_of): ...@@ -93,7 +97,7 @@ def receive(activity, on_behalf_of):
models.InboxItem.objects.bulk_create(items) models.InboxItem.objects.bulk_create(items)
# at this point, we have the activity in database. Even if we crash, it's # at this point, we have the activity in database. Even if we crash, it's
# okay, as we can retry later # 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 return copy
...@@ -113,17 +117,64 @@ class Router: ...@@ -113,17 +117,64 @@ class Router:
class InboxRouter(Router): class InboxRouter(Router):
@transaction.atomic
def dispatch(self, payload, context): def dispatch(self, payload, context):
""" """
Receives an Activity payload and some context and trigger our Receives an Activity payload and some context and trigger our
business logic business logic
""" """
from . import api_serializers
from . import models
for route, handler in self.routes: for route, handler in self.routes:
if match_route(route, payload): 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): class OutboxRouter(Router):
@transaction.atomic
def dispatch(self, routing, context): def dispatch(self, routing, context):
""" """
Receives a routing payload and some business objects in the context Receives a routing payload and some business objects in the context
...@@ -140,12 +191,11 @@ class OutboxRouter(Router): ...@@ -140,12 +191,11 @@ class OutboxRouter(Router):
# a route can yield zero, one or more activity payloads # a route can yield zero, one or more activity payloads
if e: if e:
activities_data.append(e) activities_data.append(e)
inbox_items_by_activity_uuid = {} inbox_items_by_activity_uuid = {}
prepared_activities = [] prepared_activities = []
for activity_data in activities_data: for activity_data in activities_data:
to = activity_data.pop("to", []) to = activity_data["payload"].pop("to", [])
cc = activity_data.pop("cc", []) cc = activity_data["payload"].pop("cc", [])
a = models.Activity(**activity_data) a = models.Activity(**activity_data)
a.uuid = uuid.uuid4() a.uuid = uuid.uuid4()
to_items, new_to = prepare_inbox_items(to, "to") to_items, new_to = prepare_inbox_items(to, "to")
...@@ -160,7 +210,6 @@ class OutboxRouter(Router): ...@@ -160,7 +210,6 @@ class OutboxRouter(Router):
prepared_activities.append(a) prepared_activities.append(a)
activities = models.Activity.objects.bulk_create(prepared_activities) activities = models.Activity.objects.bulk_create(prepared_activities)
activities = [a for a in activities if a]
final_inbox_items = [] final_inbox_items = []
for a in activities: for a in activities:
......
from django.contrib import admin from django.contrib import admin
from . import models 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) @admin.register(models.Actor)
...@@ -25,24 +57,24 @@ class FollowAdmin(admin.ModelAdmin): ...@@ -25,24 +57,24 @@ class FollowAdmin(admin.ModelAdmin):
list_select_related = True list_select_related = True
@admin.register(models.Library) @admin.register(models.LibraryFollow)
class LibraryAdmin(admin.ModelAdmin): class LibraryFollowAdmin(admin.ModelAdmin):
list_display = ["actor", "url", "creation_date", "fetched_date", "tracks_count"] list_display = ["actor", "target", "approved", "creation_date"]
search_fields = ["actor__fid", "url"] list_filter = ["approved"]
list_filter = ["federation_enabled", "download_files", "autoimport"] search_fields = ["actor__fid", "target__fid"]
list_select_related = True list_select_related = True
@admin.register(models.LibraryTrack) @admin.register(models.InboxItem)
class LibraryTrackAdmin(admin.ModelAdmin): class InboxItemAdmin(admin.ModelAdmin):
list_display = [ list_display = [
"title", "actor",
"artist_name", "activity",
"album_title", "type",
"url", "last_delivery_date",
"library", "delivery_attempts",
"creation_date",
"published_date",
] ]
search_fields = ["library__url", "url", "artist_name", "title", "album_title"] list_filter = ["type"]
search_fields = ["actor__fid", "activity__fid"]
list_select_related = True list_select_related = True
actions = [redeliver_inbox_items]
...@@ -3,8 +3,9 @@ from rest_framework import serializers ...@@ -3,8 +3,9 @@ from rest_framework import serializers
from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from . import serializers as federation_serializers from . import filters
from . import models from . import models
from . import serializers as federation_serializers
class NestedLibraryFollowSerializer(serializers.ModelSerializer): class NestedLibraryFollowSerializer(serializers.ModelSerializer):
...@@ -44,14 +45,79 @@ class LibrarySerializer(serializers.ModelSerializer): ...@@ -44,14 +45,79 @@ class LibrarySerializer(serializers.ModelSerializer):
class LibraryFollowSerializer(serializers.ModelSerializer): class LibraryFollowSerializer(serializers.ModelSerializer):
target = common_serializers.RelatedField("uuid", LibrarySerializer(), required=True) target = common_serializers.RelatedField("uuid", LibrarySerializer(), required=True)
actor = serializers.SerializerMethodField()
class Meta: class Meta:
model = models.LibraryFollow model = models.LibraryFollow
fields = ["creation_date", "uuid", "target", "approved"] fields = ["creation_date", "actor", "uuid", "target", "approved"]
read_only_fields = ["uuid", "approved", "creation_date"] read_only_fields = ["uuid", "actor", "approved", "creation_date"]
def validate_target(self, v): def validate_target(self, v):
actor = self.context["actor"] actor = self.context["actor"]
if v.received_follows.filter(actor=actor).exists(): if v.received_follows.filter(actor=actor).exists():
raise serializers.ValidationError("You are already following this library") raise serializers.ValidationError("You are already following this library")
return v 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 ...@@ -4,6 +4,7 @@ from . import api_views
router = routers.SimpleRouter() router = routers.SimpleRouter()
router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-follows") 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") router.register(r"libraries", api_views.LibraryViewSet, "libraries")
urlpatterns = router.urls urlpatterns = router.urls
import requests.exceptions import requests.exceptions
from django.db import transaction
from django.db.models import Count from django.db.models import Count
from rest_framework import decorators from rest_framework import decorators
...@@ -10,6 +11,7 @@ from rest_framework import viewsets ...@@ -10,6 +11,7 @@ from rest_framework import viewsets
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from . import activity
from . import api_serializers from . import api_serializers
from . import filters from . import filters
from . import models from . import models
...@@ -18,6 +20,13 @@ from . import serializers ...@@ -18,6 +20,13 @@ from . import serializers
from . import utils 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( class LibraryFollowViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
mixins.ListModelMixin, mixins.ListModelMixin,
...@@ -48,6 +57,29 @@ class LibraryFollowViewSet( ...@@ -48,6 +57,29 @@ class LibraryFollowViewSet(
context["actor"] = self.request.user.actor context["actor"] = self.request.user.actor
return context 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): class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
lookup_field = "uuid" lookup_field = "uuid"
...@@ -59,8 +91,6 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): ...@@ -59,8 +91,6 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
) )
serializer_class = api_serializers.LibrarySerializer serializer_class = api_serializers.LibrarySerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
filter_class = filters.LibraryFollowFilter
ordering_fields = ("creation_date",)
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()
...@@ -90,3 +120,36 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): ...@@ -90,3 +120,36 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
) )
serializer = self.serializer_class(library) serializer = self.serializer_class(library)
return response.Response({"count": 1, "results": [serializer.data]}) 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 from funkwhale_api.common import fields
...@@ -32,3 +32,17 @@ class LibraryFollowFilter(django_filters.FilterSet): ...@@ -32,3 +32,17 @@ class LibraryFollowFilter(django_filters.FilterSet):
class Meta: class Meta:
model = models.LibraryFollow model = models.LibraryFollow
fields = ["approved"] 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',