Merge branch 'library-follow' into 'develop'
Library follows and user notifications See merge request funkwhale/funkwhale!407
This commit is contained in:
commit
8af459ff43
|
@ -1,7 +1,13 @@
|
|||
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:
|
||||
|
|
|
@ -1,6 +1,38 @@
|
|||
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
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
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)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
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)
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
# 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
|
||||
|
|
|
@ -1,23 +1,11 @@
|
|||
|
||||
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})
|
||||
|
||||
handler.assert_called_once_with(good_message, context={})
|
||||
assert handler_payload == good_message
|
||||
assert handler_context == {"activity": a}
|
||||
|
||||
a.refresh_from_db()
|
||||
|
||||
assert a.object == object
|
||||
assert a.target == target
|
||||
|
||||
|
||||
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)
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
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
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
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
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
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
|
||||
|
|
|
@ -36,8 +36,8 @@ def test_outbox_routes(route, handler):
|
|||
|
||||
|
||||
def test_inbox_follow_library_autoapprove(factories, mocker):
|
||||
mocked_accept_follow = mocker.patch(
|
||||
"funkwhale_api.federation.activity.accept_follow"
|
||||
mocked_outbox_dispatch = mocker.patch(
|
||||
"funkwhale_api.federation.activity.OutboxRouter.dispatch"
|
||||
)
|
||||
|
||||
local_actor = factories["users.User"]().create_actor()
|
||||
|
@ -52,23 +52,27 @@ def test_inbox_follow_library_autoapprove(factories, mocker):
|
|||
"object": library.fid,
|
||||
}
|
||||
|
||||
routes.inbox_follow(
|
||||
result = routes.inbox_follow(
|
||||
payload,
|
||||
context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True},
|
||||
)
|
||||
|
||||
follow = library.received_follows.latest("id")
|
||||
|
||||
assert result["object"] == library
|
||||
assert result["related_object"] == follow
|
||||
|
||||
assert follow.fid == payload["id"]
|
||||
assert follow.actor == remote_actor
|
||||
assert follow.approved is True
|
||||
|
||||
mocked_accept_follow.assert_called_once_with(follow)
|
||||
mocked_outbox_dispatch.assert_called_once_with(
|
||||
{"type": "Accept"}, context={"follow": follow}
|
||||
)
|
||||
|
||||
|
||||
def test_inbox_follow_library_manual_approve(factories, mocker):
|
||||
mocked_accept_follow = mocker.patch(
|
||||
"funkwhale_api.federation.activity.accept_follow"
|
||||
mocked_outbox_dispatch = mocker.patch(
|
||||
"funkwhale_api.federation.activity.OutboxRouter.dispatch"
|
||||
)
|
||||
|
||||
local_actor = factories["users.User"]().create_actor()
|
||||
|
@ -83,18 +87,20 @@ def test_inbox_follow_library_manual_approve(factories, mocker):
|
|||
"object": library.fid,
|
||||
}
|
||||
|
||||
routes.inbox_follow(
|
||||
result = routes.inbox_follow(
|
||||
payload,
|
||||
context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True},
|
||||
)
|
||||
|
||||
follow = library.received_follows.latest("id")
|
||||
|
||||
assert result["object"] == library
|
||||
assert result["related_object"] == follow
|
||||
|
||||
assert follow.fid == payload["id"]
|
||||
assert follow.actor == remote_actor
|
||||
assert follow.approved is False
|
||||
assert follow.approved is None
|
||||
|
||||
mocked_accept_follow.assert_not_called()
|
||||
mocked_outbox_dispatch.assert_not_called()
|
||||
|
||||
|
||||
def test_outbox_accept(factories, mocker):
|
||||
|
@ -111,6 +117,7 @@ def test_outbox_accept(factories, mocker):
|
|||
|
||||
assert activity["payload"] == expected
|
||||
assert activity["actor"] == follow.target.actor
|
||||
assert activity["object"] == follow
|
||||
|
||||
|
||||
def test_inbox_accept(factories, mocker):
|
||||
|
@ -125,10 +132,12 @@ def test_inbox_accept(factories, mocker):
|
|||
follow, context={"actor": remote_actor}
|
||||
)
|
||||
ii = factories["federation.InboxItem"](actor=local_actor)
|
||||
routes.inbox_accept(
|
||||
result = routes.inbox_accept(
|
||||
serializer.data,
|
||||
context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True},
|
||||
)
|
||||
assert result["object"] == follow
|
||||
assert result["related_object"] == follow.target
|
||||
|
||||
follow.refresh_from_db()
|
||||
|
||||
|
@ -145,3 +154,4 @@ def test_outbox_follow_library(factories, mocker):
|
|||
|
||||
assert activity["payload"] == expected
|
||||
assert activity["actor"] == follow.actor
|
||||
assert activity["object"] == follow.target
|
||||
|
|
|
@ -59,7 +59,7 @@ def test_clean_federation_music_cache_orphaned(settings, preferences, factories)
|
|||
assert os.path.exists(remove_path) is False
|
||||
|
||||
|
||||
def test_handle_in(factories, mocker, now):
|
||||
def test_handle_in(factories, mocker, now, queryset_equal_list):
|
||||
mocked_dispatch = mocker.patch("funkwhale_api.federation.routes.inbox.dispatch")
|
||||
|
||||
r1 = factories["users.User"](with_actor=True).actor
|
||||
|
@ -70,7 +70,7 @@ def test_handle_in(factories, mocker, now):
|
|||
tasks.dispatch_inbox(activity_id=a.pk)
|
||||
|
||||
mocked_dispatch.assert_called_once_with(
|
||||
a.payload, context={"actor": a.actor, "inbox_items": [ii1, ii2]}
|
||||
a.payload, context={"actor": a.actor, "activity": a, "inbox_items": [ii1, ii2]}
|
||||
)
|
||||
|
||||
ii1.refresh_from_db()
|
||||
|
|
|
@ -105,9 +105,7 @@ def test_local_actor_inbox_post(factories, api_client, mocker, authenticated_act
|
|||
|
||||
assert response.status_code == 200
|
||||
patched_receive.assert_called_once_with(
|
||||
activity={"hello": "world"},
|
||||
on_behalf_of=authenticated_actor,
|
||||
recipient=user.actor,
|
||||
activity={"hello": "world"}, on_behalf_of=authenticated_actor
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ from django.urls import reverse
|
|||
from django.utils import timezone
|
||||
|
||||
from funkwhale_api.music import serializers, tasks, views
|
||||
from funkwhale_api.federation import api_serializers as federation_api_serializers
|
||||
|
||||
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
@ -384,3 +385,22 @@ def test_user_can_create_track_file(
|
|||
assert tf.import_reference == "test"
|
||||
assert tf.track is None
|
||||
m.assert_called_once_with(tasks.import_track_file.delay, track_file_id=tf.pk)
|
||||
|
||||
|
||||
def test_user_can_list_own_library_follows(factories, logged_in_api_client):
|
||||
actor = logged_in_api_client.user.create_actor()
|
||||
library = factories["music.Library"](actor=actor)
|
||||
another_library = factories["music.Library"](actor=actor)
|
||||
follow = factories["federation.LibraryFollow"](target=library)
|
||||
factories["federation.LibraryFollow"](target=another_library)
|
||||
|
||||
url = reverse("api:v1:libraries-follows", kwargs={"uuid": library.uuid})
|
||||
|
||||
response = logged_in_api_client.get(url)
|
||||
|
||||
assert response.data == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [federation_api_serializers.LibraryFollowSerializer(follow).data],
|
||||
}
|
||||
|
|
|
@ -170,7 +170,10 @@ def test_creating_actor_from_user(factories, settings):
|
|||
def test_get_channels_groups(factories):
|
||||
user = factories["users.User"]()
|
||||
|
||||
assert user.get_channels_groups() == ["user.{}.imports".format(user.pk)]
|
||||
assert user.get_channels_groups() == [
|
||||
"user.{}.imports".format(user.pk),
|
||||
"user.{}.inbox".format(user.pk),
|
||||
]
|
||||
|
||||
|
||||
def test_user_quota_default_to_preference(factories, preferences):
|
||||
|
|
|
@ -94,6 +94,8 @@
|
|||
import axios from 'axios'
|
||||
import _ from 'lodash'
|
||||
import {mapState} from 'vuex'
|
||||
import { WebSocketBridge } from 'django-channels'
|
||||
|
||||
|
||||
import translations from '@/translations'
|
||||
|
||||
|
@ -113,11 +115,13 @@ export default {
|
|||
},
|
||||
data () {
|
||||
return {
|
||||
bridge: null,
|
||||
nodeinfo: null,
|
||||
instanceUrl: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.openWebsocket()
|
||||
let self = this
|
||||
this.autodetectLanguage()
|
||||
setInterval(() => {
|
||||
|
@ -134,8 +138,23 @@ export default {
|
|||
this.$store.dispatch('auth/check')
|
||||
this.$store.dispatch('instance/fetchSettings')
|
||||
this.fetchNodeInfo()
|
||||
this.$store.commit('ui/addWebsocketEventHandler', {
|
||||
eventName: 'inbox.item_added',
|
||||
id: 'sidebarCount',
|
||||
handler: this.incrementNotificationCountInSidebar
|
||||
})
|
||||
},
|
||||
destroyed () {
|
||||
this.$store.commit('ui/removeWebsocketEventHandler', {
|
||||
eventName: 'inbox.item_added',
|
||||
id: 'sidebarCount',
|
||||
})
|
||||
this.disconnect()
|
||||
},
|
||||
methods: {
|
||||
incrementNotificationCountInSidebar (event) {
|
||||
this.$store.commit('ui/incrementNotifications', {type: 'inbox', count: 1})
|
||||
},
|
||||
fetchNodeInfo () {
|
||||
let self = this
|
||||
axios.get('instance/nodeinfo/2.0/').then(response => {
|
||||
|
@ -162,6 +181,36 @@ export default {
|
|||
} else if (almostMatching.length > 0) {
|
||||
this.$language.current = almostMatching[0]
|
||||
}
|
||||
},
|
||||
disconnect () {
|
||||
if (!this.bridge) {
|
||||
return
|
||||
}
|
||||
this.bridge.socket.close(1000, 'goodbye', {keepClosed: true})
|
||||
},
|
||||
openWebsocket () {
|
||||
if (!this.$store.state.auth.authenticated) {
|
||||
return
|
||||
}
|
||||
this.disconnect()
|
||||
let self = this
|
||||
let token = this.$store.state.auth.token
|
||||
// let token = 'test'
|
||||
const bridge = new WebSocketBridge()
|
||||
this.bridge = bridge
|
||||
let url = this.$store.getters['instance/absoluteUrl'](`api/v1/activity?token=${token}`)
|
||||
url = url.replace('http://', 'ws://')
|
||||
url = url.replace('https://', 'wss://')
|
||||
bridge.connect(
|
||||
url,
|
||||
null,
|
||||
{reconnectInterval: 5000})
|
||||
bridge.listen(function (event) {
|
||||
self.$store.dispatch('ui/websocketEvent', event)
|
||||
})
|
||||
bridge.socket.addEventListener('open', function () {
|
||||
console.log('Connected to WebSocket')
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
@ -189,6 +238,13 @@ export default {
|
|||
this.$store.dispatch('instance/fetchSettings')
|
||||
this.fetchNodeInfo()
|
||||
},
|
||||
'$store.state.auth.authenticated' (newValue) {
|
||||
if (!newValue) {
|
||||
this.disconnect()
|
||||
} else {
|
||||
this.openWebsocket()
|
||||
}
|
||||
},
|
||||
'$language.current' (newValue) {
|
||||
this.$store.commit('ui/currentLanguage', newValue)
|
||||
}
|
||||
|
@ -299,9 +355,11 @@ html, body {
|
|||
}
|
||||
}
|
||||
|
||||
.discrete.link {
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
cursor: pointer;
|
||||
.discrete {
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
}
|
||||
.link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.floated.buttons .button ~ .dropdown {
|
||||
|
@ -321,5 +379,8 @@ html, body {
|
|||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
.segment.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -42,6 +42,15 @@
|
|||
<img class="ui right floated circular tiny avatar image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
|
||||
</router-link>
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/settings'}"><i class="setting icon"></i><translate>Settings</translate></router-link>
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'notifications'}">
|
||||
<i class="feed icon"></i>
|
||||
<translate>Notifications</translate>
|
||||
<div
|
||||
v-if="$store.state.ui.notifications.inbox > 0"
|
||||
:class="['ui', 'teal', 'label']">
|
||||
{{ $store.state.ui.notifications.inbox }}</div>
|
||||
<img class="ui right floated circular tiny avatar image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
|
||||
</router-link>
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i><translate>Logout</translate></router-link>
|
||||
<template v-else>
|
||||
<router-link class="item" :to="{name: 'login'}"><i class="sign in icon"></i><translate>Login</translate></router-link>
|
||||
|
@ -73,33 +82,6 @@
|
|||
<div class="item" v-if="showAdmin">
|
||||
<div class="header"><translate>Administration</translate></div>
|
||||
<div class="menu">
|
||||
<router-link
|
||||
class="item"
|
||||
v-if="$store.state.auth.availablePermissions['library']"
|
||||
:to="{name: 'manage.library.files'}">
|
||||
<i class="book icon"></i><translate>Library</translate>
|
||||
<div
|
||||
:class="['ui', {'teal': $store.state.ui.notifications.importRequests > 0}, 'label']"
|
||||
:title="labels.pendingRequests">
|
||||
{{ $store.state.ui.notifications.importRequests }}</div>
|
||||
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
v-else-if="$store.state.auth.availablePermissions['upload']"
|
||||
to="/library/import/launch">
|
||||
<i class="download icon"></i><translate>Import music</translate>
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
v-if="$store.state.auth.availablePermissions['federation']"
|
||||
:to="{path: '/manage/federation/libraries'}">
|
||||
<i class="sitemap icon"></i><translate>Federation</translate>
|
||||
<div
|
||||
:class="['ui', {'teal': $store.state.ui.notifications.federation > 0}, 'label']"
|
||||
:title="labels.pendingFollows">
|
||||
{{ $store.state.ui.notifications.federation }}</div>
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
v-if="$store.state.auth.availablePermissions['settings']"
|
||||
|
@ -207,11 +189,6 @@ export default {
|
|||
mounted () {
|
||||
$(this.$el).find('.menu .item').tab()
|
||||
},
|
||||
created () {
|
||||
this.fetchNotificationsCount()
|
||||
this.fetchInterval = setInterval(
|
||||
this.fetchNotificationsCount, 1000 * 60 * 15)
|
||||
},
|
||||
destroy () {
|
||||
if (this.fetchInterval) {
|
||||
clearInterval(this.fetchInterval)
|
||||
|
@ -260,11 +237,6 @@ export default {
|
|||
return e
|
||||
}).length > 0
|
||||
},
|
||||
|
||||
fetchNotificationsCount () {
|
||||
this.$store.dispatch('ui/fetchFederationNotificationsCount')
|
||||
this.$store.dispatch('ui/fetchImportRequestsCount')
|
||||
},
|
||||
reorder: function (event) {
|
||||
this.$store.commit('queue/reorder', {
|
||||
tracks: this.tracksChangeBuffer, oldIndex: event.oldIndex, newIndex: event.newIndex})
|
||||
|
@ -301,7 +273,6 @@ export default {
|
|||
'$store.state.auth.availablePermissions': {
|
||||
handler () {
|
||||
this.showAdmin = this.getShowAdmin()
|
||||
this.fetchNotificationsCount()
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<span :style="defaultAvatarStyle" class="ui avatar circular label">{{ actor.preferred_username[0]}}</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {hashCode, intToRGB} from '@/utils/color'
|
||||
|
||||
export default {
|
||||
props: ['actor'],
|
||||
computed: {
|
||||
actorColor () {
|
||||
return intToRGB(hashCode(this.actor.full_username))
|
||||
},
|
||||
defaultAvatarStyle () {
|
||||
return {
|
||||
'background-color': `#${this.actorColor}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<span :title="actor.full_username">
|
||||
<span :style="defaultAvatarStyle" class="ui circular label">{{ actor.preferred_username[0]}}</span>
|
||||
<actor-avatar v-if="avatar" :actor="actor" />
|
||||
{{ actor.full_username | truncate(30) }}
|
||||
</span>
|
||||
</template>
|
||||
|
@ -9,22 +9,9 @@
|
|||
import {hashCode, intToRGB} from '@/utils/color'
|
||||
|
||||
export default {
|
||||
props: ['actor'],
|
||||
computed: {
|
||||
actorColor () {
|
||||
return intToRGB(hashCode(this.actor.full_username))
|
||||
},
|
||||
defaultAvatarStyle () {
|
||||
return {
|
||||
'background-color': `#${this.actorColor}`
|
||||
}
|
||||
}
|
||||
props: {
|
||||
actor: {type: Object},
|
||||
avatar: {type: Boolean, default: true}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.tiny.circular.avatar {
|
||||
width: 1.7em;
|
||||
height: 1.7em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -16,6 +16,10 @@ import ActorLink from '@/components/common/ActorLink'
|
|||
|
||||
Vue.component('actor-link', ActorLink)
|
||||
|
||||
import ActorAvatar from '@/components/common/ActorAvatar'
|
||||
|
||||
Vue.component('actor-avatar', ActorAvatar)
|
||||
|
||||
import Duration from '@/components/common/Duration'
|
||||
|
||||
Vue.component('duration', Duration)
|
||||
|
|
|
@ -1,24 +1,7 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui hidden clearing divider"></div>
|
||||
<!-- <div v-if="files.length > 0" class="ui indicating progress">
|
||||
<div class="bar"></div>
|
||||
<div class="label">
|
||||
{{ uploadedFilesCount }}/{{ files.length }} files uploaded,
|
||||
{{ processedFilesCount }}/{{ processableFiles }} files processed
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="ui form">
|
||||
<div class="fields">
|
||||
<div class="ui four wide field">
|
||||
<label><translate>Import reference</translate></label>
|
||||
<input type="text" v-model="importReference" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<p><translate>This reference will be used to group imported files together.</translate></p>
|
||||
<div class="ui top attached tabular menu">
|
||||
<a :class="['item', {active: currentTab === 'summary'}]" @click="currentTab = 'summary'"><translate>Summary</translate></a>
|
||||
<a :class="['item', {active: currentTab === 'uploads'}]" @click="currentTab = 'uploads'">
|
||||
<translate>Uploading</translate>
|
||||
<div v-if="files.length === 0" class="ui label">
|
||||
|
@ -44,6 +27,37 @@
|
|||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'summary'}]">
|
||||
<h2 class="ui header"><translate>Upload new tracks</translate></h2>
|
||||
<div class="ui message">
|
||||
<p><translate>You are about to upload music to your library. Before proceeding, please ensure that:</translate></p>
|
||||
<ul>
|
||||
<li v-if="library.privacy_level != 'me'">
|
||||
You are not uploading copyrighted content in a public library, otherwise you may be infringing the law
|
||||
</li>
|
||||
<li>
|
||||
<translate>The music files you are uploading are tagged properly:</translate>
|
||||
<a href="http://picard.musicbrainz.org/" target='_blank'><translate>we recommend using Picard for that purpose</translate></a>
|
||||
</li>
|
||||
<li>
|
||||
<translate>The uploaded music files are in OGG, Flac or MP3 format</translate>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="ui form">
|
||||
<div class="fields">
|
||||
<div class="ui four wide field">
|
||||
<label><translate>Import reference</translate></label>
|
||||
<p><translate>This reference will be used to group imported files together.</translate></p>
|
||||
<input type="text" v-model="importReference" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="ui green button" @click="currentTab = 'uploads'"><translate>Proceed</translate></div>
|
||||
</div>
|
||||
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'uploads'}]">
|
||||
<div class="ui container">
|
||||
<file-upload-widget
|
||||
|
@ -114,7 +128,6 @@ import logger from '@/logging'
|
|||
import FileUploadWidget from './FileUploadWidget'
|
||||
import LibraryFilesTable from '@/views/content/libraries/FilesTable'
|
||||
import moment from 'moment'
|
||||
import { WebSocketBridge } from 'django-channels'
|
||||
|
||||
export default {
|
||||
props: ['library', 'defaultImportReference'],
|
||||
|
@ -127,7 +140,7 @@ export default {
|
|||
this.$router.replace({query: {import: importReference}})
|
||||
return {
|
||||
files: [],
|
||||
currentTab: 'uploads',
|
||||
currentTab: 'summary',
|
||||
uploadUrl: '/api/v1/track-files/',
|
||||
importReference,
|
||||
trackFiles: {
|
||||
|
@ -137,18 +150,23 @@ export default {
|
|||
errored: 0,
|
||||
objects: {},
|
||||
},
|
||||
bridge: null,
|
||||
processTimestamp: new Date()
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.openWebsocket()
|
||||
this.fetchStatus()
|
||||
this.$store.commit('ui/addWebsocketEventHandler', {
|
||||
eventName: 'import.status_updated',
|
||||
id: 'fileUpload',
|
||||
handler: this.handleImportEvent
|
||||
})
|
||||
},
|
||||
destroyed () {
|
||||
this.disconnect()
|
||||
this.$store.commit('ui/removeWebsocketEventHandler', {
|
||||
eventName: 'import.status_updated',
|
||||
id: 'fileUpload',
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
inputFilter (newFile, oldFile, prevent) {
|
||||
if (newFile && !oldFile) {
|
||||
|
@ -199,20 +217,17 @@ export default {
|
|||
console.log('Connected to WebSocket')
|
||||
})
|
||||
},
|
||||
handleEvent (event) {
|
||||
console.log('Received event', event.type, event)
|
||||
handleImportEvent (event) {
|
||||
let self = this
|
||||
if (event.type === 'import.status_updated') {
|
||||
if (event.track_file.import_reference != self.importReference) {
|
||||
return
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
self.trackFiles[event.old_status] -= 1
|
||||
self.trackFiles[event.new_status] += 1
|
||||
self.trackFiles.objects[event.track_file.uuid] = event.track_file
|
||||
self.triggerReload()
|
||||
})
|
||||
if (event.track_file.import_reference != self.importReference) {
|
||||
return
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
self.trackFiles[event.old_status] -= 1
|
||||
self.trackFiles[event.new_status] += 1
|
||||
self.trackFiles.objects[event.track_file.uuid] = event.track_file
|
||||
self.triggerReload()
|
||||
})
|
||||
},
|
||||
triggerReload: _.throttle(function () {
|
||||
this.processTimestamp = new Date()
|
||||
|
@ -298,7 +313,4 @@ export default {
|
|||
border: 3px solid rgba(50, 50, 50, 0.5);
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.segment.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,126 @@
|
|||
<template>
|
||||
<tr :class="[{'disabled-row': item.is_read}]">
|
||||
<td>
|
||||
<actor-link class="user" :actor="item.activity.actor" />
|
||||
</td>
|
||||
<td>
|
||||
<router-link tag="span" class="link" v-if="notificationData.detailUrl" :to="notificationData.detailUrl">
|
||||
{{ notificationData.message }}
|
||||
</router-link>
|
||||
<template v-else>{{ notificationData.message }}</template>
|
||||
<template v-if="notificationData.action">
|
||||
<div @click="handleAction(notificationData.action.handler)" :class="['ui', 'basic', 'tiny', notificationData.action.buttonClass || '', 'button']">
|
||||
<i v-if="notificationData.action.icon" :class="[notificationData.action.icon, 'icon']" />
|
||||
{{ notificationData.action.label }}
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
<td><human-date :date="item.activity.creation_date" /></td>
|
||||
<td class="read collapsing">
|
||||
<span @click="markRead(false)" v-if="item.is_read" :title="labels.markUnread">
|
||||
<i class="redo icon" />
|
||||
</span>
|
||||
<span @click="markRead(true)" v-else :title="labels.markRead">
|
||||
<i class="check icon" />
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
props: ['item'],
|
||||
computed: {
|
||||
message () {
|
||||
return 'plop'
|
||||
},
|
||||
labels () {
|
||||
let libraryFollowMessage = this.$gettext('%{ username } followed your library "%{ library }"')
|
||||
let libraryAcceptFollowMessage = this.$gettext('%{ username } accepted your follow on library "%{ library }"')
|
||||
return {
|
||||
libraryFollowMessage,
|
||||
libraryAcceptFollowMessage,
|
||||
markRead: this.$gettext('Mark as read'),
|
||||
markUnread: this.$gettext('Mark as unread'),
|
||||
|
||||
}
|
||||
},
|
||||
username () {
|
||||
return this.item.activity.actor.preferred_username
|
||||
},
|
||||
notificationData () {
|
||||
let self = this
|
||||
let a = this.item.activity
|
||||
if (a.type === 'Follow') {
|
||||
if (a.object && a.object.type === 'music.Library') {
|
||||
let action = null
|
||||
if (!a.related_object.approved) {
|
||||
action = {
|
||||
buttonClass: 'green',
|
||||
icon: 'check',
|
||||
label: this.$gettext('Approve'),
|
||||
handler: () => { self.approveLibraryFollow(a.related_object) }
|
||||
}
|
||||
}
|
||||
return {
|
||||
action,
|
||||
detailUrl: {name: 'content.libraries.detail', params: {id: a.object.uuid}},
|
||||
message: this.$gettextInterpolate(
|
||||
this.labels.libraryFollowMessage,
|
||||
{username: this.username, library: a.object.name}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (a.type === 'Accept') {
|
||||
if (a.object && a.object.type === 'federation.LibraryFollow') {
|
||||
return {
|
||||
detailUrl: {name: 'content.remote.index'},
|
||||
message: this.$gettextInterpolate(
|
||||
this.labels.libraryAcceptFollowMessage,
|
||||
{username: this.username, library: a.related_object.name}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleAction (handler) {
|
||||
// call handler then mark notification as read
|
||||
handler()
|
||||
this.markRead(true)
|
||||
},
|
||||
approveLibraryFollow (follow) {
|
||||
let self = this
|
||||
let action = 'accept'
|
||||
axios.post(`federation/follows/library/${follow.uuid}/${action}/`).then((response) => {
|
||||
follow.isLoading = false
|
||||
follow.approved = true
|
||||
})
|
||||
},
|
||||
markRead (value) {
|
||||
let self = this
|
||||
let action = 'accept'
|
||||
axios.patch(`federation/inbox/${this.item.id}/`, {is_read: value}).then((response) => {
|
||||
self.item.is_read = value
|
||||
if (value) {
|
||||
self.$store.commit('ui/incrementNotifications', {type: 'inbox', count: -1})
|
||||
} else {
|
||||
self.$store.commit('ui/incrementNotifications', {type: 'inbox', count: 1})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.read > span {
|
||||
cursor: pointer;
|
||||
}
|
||||
.disabled-row {
|
||||
color: rgba(40, 40, 40, 0.3);
|
||||
}
|
||||
</style>
|
|
@ -46,6 +46,7 @@ import LibrariesUpload from '@/views/content/libraries/Upload'
|
|||
import LibrariesDetail from '@/views/content/libraries/Detail'
|
||||
import LibrariesFiles from '@/views/content/libraries/Files'
|
||||
import RemoteLibrariesHome from '@/views/content/remote/Home'
|
||||
import Notifications from '@/views/Notifications'
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
|
@ -74,6 +75,11 @@ export default new Router({
|
|||
component: Login,
|
||||
props: (route) => ({ next: route.query.next || '/library' })
|
||||
},
|
||||
{
|
||||
path: '/notifications',
|
||||
name: 'notifications',
|
||||
component: Notifications
|
||||
},
|
||||
{
|
||||
path: '/auth/password/reset',
|
||||
name: 'auth.password-reset',
|
||||
|
|
|
@ -117,6 +117,7 @@ export default {
|
|||
commit('authenticated', true)
|
||||
commit('profile', data)
|
||||
commit('username', data.username)
|
||||
dispatch('ui/fetchUnreadNotifications', null, { root: true })
|
||||
dispatch('favorites/fetch', null, { root: true })
|
||||
dispatch('playlists/fetchOwn', null, { root: true })
|
||||
Object.keys(data.permissions).forEach(function (key) {
|
||||
|
|
|
@ -9,11 +9,20 @@ export default {
|
|||
messageDisplayDuration: 10000,
|
||||
messages: [],
|
||||
notifications: {
|
||||
federation: 0,
|
||||
importRequests: 0
|
||||
inbox: 0,
|
||||
},
|
||||
websocketEventsHandlers: {
|
||||
'inbox.item_added': {},
|
||||
'import.status_updated': {},
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
addWebsocketEventHandler: (state, {eventName, id, handler}) => {
|
||||
state.websocketEventsHandlers[eventName][id] = handler
|
||||
},
|
||||
removeWebsocketEventHandler: (state, {eventName, id}) => {
|
||||
delete state.websocketEventsHandlers[eventName][id]
|
||||
},
|
||||
currentLanguage: (state, value) => {
|
||||
state.currentLanguage = value
|
||||
},
|
||||
|
@ -28,23 +37,27 @@ export default {
|
|||
},
|
||||
notifications (state, {type, count}) {
|
||||
state.notifications[type] = count
|
||||
},
|
||||
incrementNotifications (state, {type, count}) {
|
||||
state.notifications[type] = Math.max(0, state.notifications[type] + count)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
fetchFederationNotificationsCount ({rootState, commit}) {
|
||||
if (!rootState.auth.availablePermissions['federation']) {
|
||||
return
|
||||
}
|
||||
axios.get('federation/libraries/followers/', {params: {pending: true}}).then(response => {
|
||||
commit('notifications', {type: 'federation', count: response.data.count})
|
||||
fetchUnreadNotifications ({commit}, payload) {
|
||||
axios.get('federation/inbox/', {params: {is_read: false, page_size: 1}}).then((response) => {
|
||||
commit('notifications', {type: 'inbox', count: response.data.count})
|
||||
})
|
||||
},
|
||||
fetchImportRequestsCount ({rootState, commit}) {
|
||||
if (!rootState.auth.availablePermissions['library']) {
|
||||
websocketEvent ({state}, event) {
|
||||
console.log('Dispatching websocket event', event)
|
||||
let handlers = state.websocketEventsHandlers[event.type]
|
||||
if (!handlers) {
|
||||
return
|
||||
}
|
||||
axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => {
|
||||
commit('notifications', {type: 'importRequests', count: response.data.count})
|
||||
let names = Object.keys(handlers)
|
||||
names.forEach((k) => {
|
||||
let handler = handlers[k]
|
||||
handler(event)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,125 @@
|
|||
<template>
|
||||
<div class="main pusher" v-title="labels.title">
|
||||
<div class="ui vertical aligned stripe segment">
|
||||
<div v-if="isLoading" :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']">
|
||||
<div class="ui text loader"><translate>Loading notifications...</translate></div>
|
||||
</div>
|
||||
<div v-else class="ui container">
|
||||
<h1 class="ui header"><translate>Your notifications</translate></h1>
|
||||
<div class="ui toggle checkbox">
|
||||
<input v-model="filters.is_read" type="checkbox">
|
||||
<label><translate>Show read notifications</translate></label>
|
||||
</div>
|
||||
<div
|
||||
v-if="filters.is_read === false && notifications.count > 0"
|
||||
@click="markAllAsRead"
|
||||
class="ui basic labeled icon right floated button">
|
||||
<i class="ui check icon" />
|
||||
<translate>Mark all as read</translate>
|
||||
</div>
|
||||
<div class="ui hidden divider" />
|
||||
<table v-if="notifications.count > 0" class="ui table">
|
||||
<tbody>
|
||||
<notification-row :item="item" v-for="item in notifications.results" :key="item.id" />
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else>
|
||||
<translate>We don't have any notification to display!</translate>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from 'vuex'
|
||||
import axios from 'axios'
|
||||
import logger from '@/logging'
|
||||
|
||||
import NotificationRow from '@/components/notifications/NotificationRow'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
notifications: null,
|
||||
filters: {
|
||||
is_read: false
|
||||
}
|
||||
}
|
||||
},
|
||||
components: {
|
||||
NotificationRow
|
||||
},
|
||||
created () {
|
||||
this.fetch(this.filters)
|
||||
this.$store.commit('ui/addWebsocketEventHandler', {
|
||||
eventName: 'inbox.item_added',
|
||||
id: 'notificationPage',
|
||||
handler: this.handleNewNotification
|
||||
})
|
||||
},
|
||||
destroyed () {
|
||||
this.$store.commit('ui/removeWebsocketEventHandler', {
|
||||
eventName: 'inbox.item_added',
|
||||
id: 'notificationPage',
|
||||
})
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
events: state => state.instance.events
|
||||
}),
|
||||
labels () {
|
||||
return {
|
||||
title: this.$gettext('Notifications'),
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleNewNotification (event) {
|
||||
this.notifications.results.unshift(event.item)
|
||||
},
|
||||
fetch (params) {
|
||||
this.isLoading = true
|
||||
let self = this
|
||||
axios.get('federation/inbox/', {params: params}).then((response) => {
|
||||
self.isLoading = false
|
||||
self.notifications = response.data
|
||||
})
|
||||
},
|
||||
markAllAsRead () {
|
||||
let self = this
|
||||
let before = this.notifications.results[0].id
|
||||
let payload = {
|
||||
action: 'read',
|
||||
objects: 'all',
|
||||
filters: {
|
||||
is_read: false,
|
||||
before
|
||||
}
|
||||
}
|
||||
axios.post('federation/inbox/action/', payload).then((response) => {
|
||||
self.$store.commit('ui/notifications', {type: 'inbox', count: 0})
|
||||
self.notifications.results.forEach(n => {
|
||||
n.is_read = true
|
||||
})
|
||||
|
||||
})
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'filters.is_read' () {
|
||||
this.fetch(this.filters)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style>
|
||||
.event .ui.label.avatar {
|
||||
font-size: 1.5em;
|
||||
position: relative;
|
||||
top: 0.35em;
|
||||
}
|
||||
</style>
|
|
@ -4,14 +4,12 @@
|
|||
<div class="ui text loader"><translate>Loading library data...</translate></div>
|
||||
</div>
|
||||
<detail-area v-else :library="library">
|
||||
<div slot="header">
|
||||
<h2 class="ui header"><translate>Manage</translate></h2>
|
||||
<p><a @click="hiddenForm = !hiddenForm">
|
||||
<i class="pencil icon" />
|
||||
<translate>Edit library</translate>
|
||||
</a></p>
|
||||
<library-form v-if="!hiddenForm" :library="library" @updated="libraryUpdated" @deleted="libraryDeleted" />
|
||||
<div class="ui hidden divider"></div>
|
||||
<div class="ui top attached tabular menu">
|
||||
<a :class="['item', {active: currentTab === 'follows'}]" @click="currentTab = 'follows'"><translate>Followers</translate></a>
|
||||
<a :class="['item', {active: currentTab === 'tracks'}]" @click="currentTab = 'tracks'"><translate>Tracks</translate></a>
|
||||
<a :class="['item', {active: currentTab === 'edit'}]" @click="currentTab = 'edit'"><translate>Edit</translate></a>
|
||||
</div>
|
||||
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'follows'}]">
|
||||
<div class="ui form">
|
||||
<div class="field">
|
||||
<label><translate>Sharing link</translate></label>
|
||||
|
@ -19,14 +17,58 @@
|
|||
<copy-input :value="library.fid" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui hidden divider"></div>
|
||||
<div v-if="isLoadingFollows" :class="['ui', {'active': isLoadingFollows}, 'inverted', 'dimmer']">
|
||||
<div class="ui text loader"><translate>Loading followers...</translate></div>
|
||||
</div>
|
||||
<table v-else-if="follows && follows.count > 0" class="ui table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><translate>User</translate></th>
|
||||
<th><translate>Date</translate></th>
|
||||
<th><translate>Status</translate></th>
|
||||
<th><translate>Action</translate></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr v-for="follow in follows.results" :key="follow.fid">
|
||||
<td><actor-link :actor="follow.actor" /></td>
|
||||
<td><human-date :date="follow.creation_date" /></td>
|
||||
<td>
|
||||
<span :class="['ui', 'yellow', 'basic', 'label']" v-if="follow.approved === null">
|
||||
<translate>Pending approval</translate>
|
||||
</span>
|
||||
<span :class="['ui', 'green', 'basic', 'label']" v-else-if="follow.approved === true">
|
||||
<translate>Accepted</translate>
|
||||
</span>
|
||||
<span :class="['ui', 'red', 'basic', 'label']" v-else-if="follow.approved === false">
|
||||
<translate>Rejected</translate>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div @click="updateApproved(follow, true)" :class="['ui', 'mini', 'icon', 'labeled', 'green', 'button']" v-if="follow.approved === null || follow.approved === false">
|
||||
<i class="ui check icon"></i> <translate>Accept</translate>
|
||||
</div>
|
||||
<div @click="updateApproved(follow, false)" :class="['ui', 'mini', 'icon', 'labeled', 'red', 'button']" v-if="follow.approved === null || follow.approved === true">
|
||||
<i class="ui x icon"></i> <translate>Reject</translate>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<p v-else><translate>Nobody is following this library</translate></p>
|
||||
</div>
|
||||
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'tracks'}]">
|
||||
<library-files-table :filters="{library: library.uuid}"></library-files-table>
|
||||
</div>
|
||||
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'edit'}]">
|
||||
<library-form :library="library" @updated="libraryUpdated" @deleted="libraryDeleted" />
|
||||
</div>
|
||||
<h2><translate>Tracks</translate></h2>
|
||||
<library-files-table :filters="{library: library.uuid}"></library-files-table>
|
||||
</detail-area>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import DetailMixin from './DetailMixin'
|
||||
import DetailArea from './DetailArea'
|
||||
import LibraryForm from './Form'
|
||||
|
@ -41,9 +83,14 @@ export default {
|
|||
},
|
||||
data () {
|
||||
return {
|
||||
hiddenForm: true
|
||||
currentTab: 'follows',
|
||||
isLoadingFollows: false,
|
||||
follows: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchFollows()
|
||||
},
|
||||
methods: {
|
||||
libraryUpdated () {
|
||||
this.hiddenForm = true
|
||||
|
@ -53,6 +100,27 @@ export default {
|
|||
this.$router.push({
|
||||
name: 'content.libraries.index'
|
||||
})
|
||||
},
|
||||
fetchFollows () {
|
||||
let self = this
|
||||
self.isLoadingLibrary = true
|
||||
axios.get(`libraries/${this.id}/follows/`).then((response) => {
|
||||
self.follows = response.data
|
||||
self.isLoadingFollows = false
|
||||
})
|
||||
},
|
||||
updateApproved (follow, value) {
|
||||
let self = this
|
||||
let action
|
||||
if (value) {
|
||||
action = 'accept'
|
||||
} else {
|
||||
action = 'reject'
|
||||
}
|
||||
axios.post(`federation/follows/library/${follow.uuid}/${action}/`).then((response) => {
|
||||
follow.isLoading = false
|
||||
follow.approved = value
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui stackable grid">
|
||||
<div class="eleven wide stretched column">
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
<div class="five wide column">
|
||||
<h3 class="ui header"><translate>Current library</translate></h3>
|
||||
<library-card :library="library" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui divider"></div>
|
||||
<div class="ui hidden divider"></div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -4,24 +4,6 @@
|
|||
<div class="ui text loader"><translate>Loading library data...</translate></div>
|
||||
</div>
|
||||
<detail-area v-else :library="library">
|
||||
<div slot="header">
|
||||
<h2 class="ui header"><translate>Upload new tracks</translate></h2>
|
||||
<div class="ui message">
|
||||
<p><translate>You are about to upload music to your library. Before proceeding, please ensure that:</translate></p>
|
||||
<ul>
|
||||
<li v-if="library.privacy_level != 'me'">
|
||||
You are not uploading copyrighted content in a public library, otherwise you may be infringing the law
|
||||
</li>
|
||||
<li>
|
||||
<translate>The music files you are uploading are tagged properly:</translate>
|
||||
<a href="http://picard.musicbrainz.org/" target='_blank'><translate>we recommend using Picard for that purpose</translate></a>
|
||||
</li>
|
||||
<li>
|
||||
<translate>The uploaded music files are in OGG, Flac or MP3 format</translate>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<file-upload :default-import-reference="defaultImportReference" :library="library" />
|
||||
</detail-area>
|
||||
</div>
|
||||
|
|
|
@ -47,6 +47,11 @@
|
|||
class="ui disabled button"><i class="check icon"></i>
|
||||
<translate>Following</translate>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="library.follow.approved"
|
||||
class="ui button"><i class="x icon"></i>
|
||||
<translate>Unfollow</translate>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -11,6 +11,12 @@
|
|||
<div v-if="scanResult && scanResult.results.length > 0" class="ui two cards">
|
||||
<library-card :library="library" v-for="library in scanResult.results" :key="library.fid" />
|
||||
</div>
|
||||
<template v-if="existingFollows && existingFollows.count > 0">
|
||||
<h2><translate>Known libraries</translate></h2>
|
||||
<div class="ui two cards">
|
||||
<library-card :library="getLibraryFromFollow(follow)" v-for="follow in existingFollows.results" :key="follow.fid" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -24,11 +30,12 @@ export default {
|
|||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
scanResult: null
|
||||
scanResult: null,
|
||||
existingFollows: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
// this.fetch()
|
||||
this.fetch()
|
||||
},
|
||||
components: {
|
||||
ScanForm,
|
||||
|
@ -38,13 +45,17 @@ export default {
|
|||
fetch () {
|
||||
this.isLoading = true
|
||||
let self = this
|
||||
axios.get('libraries/').then((response) => {
|
||||
axios.get('federation/follows/library/', {params: {'page_size': 100, 'ordering': '-creation_date'}}).then((response) => {
|
||||
self.existingFollows = response.data
|
||||
self.isLoading = false
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
self.libraries = response.data.results
|
||||
if (self.libraries.length === 0) {
|
||||
self.hiddenForm = false
|
||||
}
|
||||
})
|
||||
},
|
||||
getLibraryFromFollow (follow) {
|
||||
let d = follow.target
|
||||
d.follow = follow
|
||||
return d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue