Library follows and user notifications

This commit is contained in:
Eliot Berriot 2018-09-13 15:18:23 +00:00
parent a879993280
commit ecd395d6b0
41 changed files with 1191 additions and 347 deletions

View File

@ -1,7 +1,13 @@
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 = [
] + 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):
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):
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:
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):
# 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):
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:

View File

@ -1,6 +1,38 @@
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):
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]

View File

@ -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):
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)

View File

@ -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

View File

@ -1,5 +1,6 @@
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
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
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(
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):
) )
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):
) )
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)

View File

@ -1,4 +1,4 @@
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):
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)

View File

@ -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),
),
]

View File

@ -3,6 +3,8 @@ import uuid
from django.conf import settings from django.conf import settings
from django.contrib.postgres.fields import JSONField 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.exceptions import ObjectDoesNotExist
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.db import models from django.db import models
@ -173,6 +175,7 @@ class InboxItem(models.Model):
type = models.CharField(max_length=10, choices=[("to", "to"), ("cc", "cc")]) type = models.CharField(max_length=10, choices=[("to", "to"), ("cc", "cc")])
last_delivery_date = models.DateTimeField(null=True, blank=True) last_delivery_date = models.DateTimeField(null=True, blank=True)
delivery_attempts = models.PositiveIntegerField(default=0) delivery_attempts = models.PositiveIntegerField(default=0)
is_read = models.BooleanField(default=False)
objects = InboxItemQuerySet.as_manager() objects = InboxItemQuerySet.as_manager()
@ -188,7 +191,36 @@ class Activity(models.Model):
fid = models.URLField(unique=True, max_length=500, null=True, blank=True) fid = models.URLField(unique=True, max_length=500, null=True, blank=True)
url = models.URLField(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) 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): class AbstractFollow(models.Model):

View File

@ -33,10 +33,10 @@ def inbox_follow(payload, context):
autoapprove = serializer.validated_data["object"].should_autoapprove_follow( autoapprove = serializer.validated_data["object"].should_autoapprove_follow(
context["actor"] context["actor"]
) )
follow = serializer.save(approved=autoapprove) follow = serializer.save(approved=True if autoapprove else None)
if follow.approved:
if autoapprove: outbox.dispatch({"type": "Accept"}, context={"follow": follow})
activity.accept_follow(follow) return {"object": follow.target, "related_object": follow}
@inbox.register({"type": "Accept"}) @inbox.register({"type": "Accept"})
@ -54,6 +54,8 @@ def inbox_accept(payload, context):
return return
serializer.save() serializer.save()
obj = serializer.validated_data["follow"]
return {"object": obj, "related_object": obj.target}
@outbox.register({"type": "Accept"}) @outbox.register({"type": "Accept"})
@ -64,7 +66,13 @@ def outbox_accept(context):
else: else:
actor = follow.target actor = follow.target
payload = serializers.AcceptFollowSerializer(follow, context={"actor": actor}).data 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"}) @outbox.register({"type": "Follow"})
@ -75,4 +83,10 @@ def outbox_follow(context):
else: else:
target = follow.target target = follow.target
payload = serializers.FollowSerializer(follow, context={"actor": follow.actor}).data 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,
}

View File

@ -155,6 +155,7 @@ class BaseActivitySerializer(serializers.Serializer):
fid=validated_data.get("id"), fid=validated_data.get("id"),
actor=validated_data["actor"], actor=validated_data["actor"],
payload=self.initial_data, payload=self.initial_data,
type=validated_data["type"],
) )
def validate(self, data): def validate(self, data):

View File

@ -74,8 +74,14 @@ def dispatch_inbox(activity):
routes.inbox.dispatch( routes.inbox.dispatch(
activity.payload, activity.payload,
context={ context={
"activity": activity,
"actor": activity.actor, "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: except Exception:

View File

@ -37,15 +37,12 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
@detail_route(methods=["get", "post"]) @detail_route(methods=["get", "post"])
def inbox(self, request, *args, **kwargs): def inbox(self, request, *args, **kwargs):
actor = self.get_object()
if request.method.lower() == "post" and request.actor is None: if request.method.lower() == "post" and request.actor is None:
raise exceptions.AuthenticationFailed( raise exceptions.AuthenticationFailed(
"You need a valid signature to send an activity" "You need a valid signature to send an activity"
) )
if request.method.lower() == "post": if request.method.lower() == "post":
activity.receive( activity.receive(activity=request.data, on_behalf_of=request.actor)
activity=request.data, on_behalf_of=request.actor, recipient=actor
)
return response.Response({}, status=200) return response.Response({}, status=200)
@detail_route(methods=["get", "post"]) @detail_route(methods=["get", "post"])

View File

@ -18,6 +18,7 @@ from taggit.models import Tag
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import permissions as common_permissions from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.federation.authentication import SignatureAuthentication 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 from . import filters, models, serializers, tasks, utils
@ -94,6 +95,25 @@ class LibraryViewSet(
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(actor=self.request.user.actor) 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): class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet):
""" """

View File

@ -206,7 +206,7 @@ class User(AbstractUser):
} }
def get_channels_groups(self): def get_channels_groups(self):
groups = ["imports"] groups = ["imports", "inbox"]
return ["user.{}.{}".format(self.pk, g) for g in groups] return ["user.{}.{}".format(self.pk, g) for g in groups]

View File

@ -15,6 +15,7 @@ from django.core.cache import cache as django_cache
from django.core.files import uploadedfile from django.core.files import uploadedfile
from django.utils import timezone from django.utils import timezone
from django.test import client from django.test import client
from django.db.models import QuerySet
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from rest_framework import fields as rest_fields from rest_framework import fields as rest_fields
from rest_framework.test import APIClient, APIRequestFactory from rest_framework.test import APIClient, APIRequestFactory
@ -23,6 +24,43 @@ from funkwhale_api.activity import record
from funkwhale_api.users.permissions import HasUserPermission 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) @pytest.fixture(scope="session", autouse=True)
def factories_autodiscover(): def factories_autodiscover():
from django.apps import apps from django.apps import apps

View File

@ -1,23 +1,11 @@
import pytest import pytest
from funkwhale_api.federation import activity, serializers, tasks from funkwhale_api.federation import activity, api_serializers, 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
)
def test_receive_validates_basic_attributes_and_stores_activity(factories, now, mocker): def test_receive_validates_basic_attributes_and_stores_activity(factories, now, mocker):
mocked_dispatch = mocker.patch( mocked_dispatch = mocker.patch("funkwhale_api.common.utils.on_commit")
"funkwhale_api.federation.tasks.dispatch_inbox.delay"
)
local_actor = factories["users.User"]().create_actor() local_actor = factories["users.User"]().create_actor()
remote_actor = factories["federation.Actor"]() remote_actor = factories["federation.Actor"]()
another_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.creation_date >= now
assert copy.actor == remote_actor assert copy.actor == remote_actor
assert copy.fid == a["id"] 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) inbox_item = copy.inbox_items.get(actor__fid=local_actor.fid)
assert inbox_item.is_delivered is False 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) 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() 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) router.connect({"type": "Follow"}, handler)
good_message = {"type": "Follow"} 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( @pytest.mark.parametrize(
@ -101,10 +138,10 @@ def test_outbox_router_dispatch(mocker, factories, now):
"type": "Noop", "type": "Noop",
"actor": actor.fid, "actor": actor.fid,
"summary": context["summary"], "summary": context["summary"],
"to": [r1],
"cc": [r2, activity.PUBLIC_ADDRESS],
}, },
"actor": actor, "actor": actor,
"to": [r1],
"cc": [r2, activity.PUBLIC_ADDRESS],
} }
router.connect({"type": "Noop"}, handler) router.connect({"type": "Noop"}, handler)

View File

@ -1,6 +1,5 @@
import pytest import pytest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from rest_framework import exceptions from rest_framework import exceptions
from funkwhale_api.federation import actors, models, serializers, utils 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 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): def test_getting_actor_instance_persists_in_db(db):
test = actors.SYSTEM_ACTORS["test"].get_actor_instance() test = actors.SYSTEM_ACTORS["test"].get_actor_instance()
from_db = models.Actor.objects.get(fid=test.fid) 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() assert serializer.is_valid()
actors.SYSTEM_ACTORS["test"].handle(activity, actor) actors.SYSTEM_ACTORS["test"].handle(activity, actor)
handler.assert_called_once_with(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

View File

@ -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)

View File

@ -51,3 +51,12 @@ def test_library_serializer_validates_existing_follow(factories):
assert serializer.is_valid() is False assert serializer.is_valid() is False
assert "target" in serializer.errors 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

View File

@ -1,3 +1,5 @@
import pytest
from django.urls import reverse from django.urls import reverse
from funkwhale_api.federation import api_serializers 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 assert follow.actor == actor
dispatch.assert_called_once_with({"type": "Follow"}, context={"follow": follow}) 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

View File

@ -36,8 +36,8 @@ def test_outbox_routes(route, handler):
def test_inbox_follow_library_autoapprove(factories, mocker): def test_inbox_follow_library_autoapprove(factories, mocker):
mocked_accept_follow = mocker.patch( mocked_outbox_dispatch = mocker.patch(
"funkwhale_api.federation.activity.accept_follow" "funkwhale_api.federation.activity.OutboxRouter.dispatch"
) )
local_actor = factories["users.User"]().create_actor() local_actor = factories["users.User"]().create_actor()
@ -52,23 +52,27 @@ def test_inbox_follow_library_autoapprove(factories, mocker):
"object": library.fid, "object": library.fid,
} }
routes.inbox_follow( result = routes.inbox_follow(
payload, payload,
context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True}, context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True},
) )
follow = library.received_follows.latest("id") follow = library.received_follows.latest("id")
assert result["object"] == library
assert result["related_object"] == follow
assert follow.fid == payload["id"] assert follow.fid == payload["id"]
assert follow.actor == remote_actor assert follow.actor == remote_actor
assert follow.approved is True 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): def test_inbox_follow_library_manual_approve(factories, mocker):
mocked_accept_follow = mocker.patch( mocked_outbox_dispatch = mocker.patch(
"funkwhale_api.federation.activity.accept_follow" "funkwhale_api.federation.activity.OutboxRouter.dispatch"
) )
local_actor = factories["users.User"]().create_actor() local_actor = factories["users.User"]().create_actor()
@ -83,18 +87,20 @@ def test_inbox_follow_library_manual_approve(factories, mocker):
"object": library.fid, "object": library.fid,
} }
routes.inbox_follow( result = routes.inbox_follow(
payload, payload,
context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True}, context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True},
) )
follow = library.received_follows.latest("id") follow = library.received_follows.latest("id")
assert result["object"] == library
assert result["related_object"] == follow
assert follow.fid == payload["id"] assert follow.fid == payload["id"]
assert follow.actor == remote_actor 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): def test_outbox_accept(factories, mocker):
@ -111,6 +117,7 @@ def test_outbox_accept(factories, mocker):
assert activity["payload"] == expected assert activity["payload"] == expected
assert activity["actor"] == follow.target.actor assert activity["actor"] == follow.target.actor
assert activity["object"] == follow
def test_inbox_accept(factories, mocker): def test_inbox_accept(factories, mocker):
@ -125,10 +132,12 @@ def test_inbox_accept(factories, mocker):
follow, context={"actor": remote_actor} follow, context={"actor": remote_actor}
) )
ii = factories["federation.InboxItem"](actor=local_actor) ii = factories["federation.InboxItem"](actor=local_actor)
routes.inbox_accept( result = routes.inbox_accept(
serializer.data, serializer.data,
context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True}, context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True},
) )
assert result["object"] == follow
assert result["related_object"] == follow.target
follow.refresh_from_db() follow.refresh_from_db()
@ -145,3 +154,4 @@ def test_outbox_follow_library(factories, mocker):
assert activity["payload"] == expected assert activity["payload"] == expected
assert activity["actor"] == follow.actor assert activity["actor"] == follow.actor
assert activity["object"] == follow.target

View File

@ -59,7 +59,7 @@ def test_clean_federation_music_cache_orphaned(settings, preferences, factories)
assert os.path.exists(remove_path) is False 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") mocked_dispatch = mocker.patch("funkwhale_api.federation.routes.inbox.dispatch")
r1 = factories["users.User"](with_actor=True).actor 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) tasks.dispatch_inbox(activity_id=a.pk)
mocked_dispatch.assert_called_once_with( 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() ii1.refresh_from_db()

View File

@ -105,9 +105,7 @@ def test_local_actor_inbox_post(factories, api_client, mocker, authenticated_act
assert response.status_code == 200 assert response.status_code == 200
patched_receive.assert_called_once_with( patched_receive.assert_called_once_with(
activity={"hello": "world"}, activity={"hello": "world"}, on_behalf_of=authenticated_actor
on_behalf_of=authenticated_actor,
recipient=user.actor,
) )

View File

@ -6,6 +6,7 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from funkwhale_api.music import serializers, tasks, views 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__)) 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.import_reference == "test"
assert tf.track is None assert tf.track is None
m.assert_called_once_with(tasks.import_track_file.delay, track_file_id=tf.pk) 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],
}

View File

@ -170,7 +170,10 @@ def test_creating_actor_from_user(factories, settings):
def test_get_channels_groups(factories): def test_get_channels_groups(factories):
user = factories["users.User"]() 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): def test_user_quota_default_to_preference(factories, preferences):

View File

@ -94,6 +94,8 @@
import axios from 'axios' import axios from 'axios'
import _ from 'lodash' import _ from 'lodash'
import {mapState} from 'vuex' import {mapState} from 'vuex'
import { WebSocketBridge } from 'django-channels'
import translations from '@/translations' import translations from '@/translations'
@ -113,11 +115,13 @@ export default {
}, },
data () { data () {
return { return {
bridge: null,
nodeinfo: null, nodeinfo: null,
instanceUrl: null instanceUrl: null
} }
}, },
created () { created () {
this.openWebsocket()
let self = this let self = this
this.autodetectLanguage() this.autodetectLanguage()
setInterval(() => { setInterval(() => {
@ -134,8 +138,23 @@ export default {
this.$store.dispatch('auth/check') this.$store.dispatch('auth/check')
this.$store.dispatch('instance/fetchSettings') this.$store.dispatch('instance/fetchSettings')
this.fetchNodeInfo() 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: { methods: {
incrementNotificationCountInSidebar (event) {
this.$store.commit('ui/incrementNotifications', {type: 'inbox', count: 1})
},
fetchNodeInfo () { fetchNodeInfo () {
let self = this let self = this
axios.get('instance/nodeinfo/2.0/').then(response => { axios.get('instance/nodeinfo/2.0/').then(response => {
@ -162,6 +181,36 @@ export default {
} else if (almostMatching.length > 0) { } else if (almostMatching.length > 0) {
this.$language.current = almostMatching[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: { computed: {
@ -189,6 +238,13 @@ export default {
this.$store.dispatch('instance/fetchSettings') this.$store.dispatch('instance/fetchSettings')
this.fetchNodeInfo() this.fetchNodeInfo()
}, },
'$store.state.auth.authenticated' (newValue) {
if (!newValue) {
this.disconnect()
} else {
this.openWebsocket()
}
},
'$language.current' (newValue) { '$language.current' (newValue) {
this.$store.commit('ui/currentLanguage', newValue) this.$store.commit('ui/currentLanguage', newValue)
} }
@ -299,9 +355,11 @@ html, body {
} }
} }
.discrete.link { .discrete {
color: rgba(0, 0, 0, 0.87); color: rgba(0, 0, 0, 0.87);
cursor: pointer; }
.link {
cursor: pointer;
} }
.floated.buttons .button ~ .dropdown { .floated.buttons .button ~ .dropdown {
@ -321,5 +379,8 @@ html, body {
a { a {
cursor: pointer; cursor: pointer;
} }
.segment.hidden {
display: none;
}
</style> </style>

View File

@ -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)" /> <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>
<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="{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> <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> <template v-else>
<router-link class="item" :to="{name: 'login'}"><i class="sign in icon"></i><translate>Login</translate></router-link> <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="item" v-if="showAdmin">
<div class="header"><translate>Administration</translate></div> <div class="header"><translate>Administration</translate></div>
<div class="menu"> <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 <router-link
class="item" class="item"
v-if="$store.state.auth.availablePermissions['settings']" v-if="$store.state.auth.availablePermissions['settings']"
@ -207,11 +189,6 @@ export default {
mounted () { mounted () {
$(this.$el).find('.menu .item').tab() $(this.$el).find('.menu .item').tab()
}, },
created () {
this.fetchNotificationsCount()
this.fetchInterval = setInterval(
this.fetchNotificationsCount, 1000 * 60 * 15)
},
destroy () { destroy () {
if (this.fetchInterval) { if (this.fetchInterval) {
clearInterval(this.fetchInterval) clearInterval(this.fetchInterval)
@ -260,11 +237,6 @@ export default {
return e return e
}).length > 0 }).length > 0
}, },
fetchNotificationsCount () {
this.$store.dispatch('ui/fetchFederationNotificationsCount')
this.$store.dispatch('ui/fetchImportRequestsCount')
},
reorder: function (event) { reorder: function (event) {
this.$store.commit('queue/reorder', { this.$store.commit('queue/reorder', {
tracks: this.tracksChangeBuffer, oldIndex: event.oldIndex, newIndex: event.newIndex}) tracks: this.tracksChangeBuffer, oldIndex: event.oldIndex, newIndex: event.newIndex})
@ -301,7 +273,6 @@ export default {
'$store.state.auth.availablePermissions': { '$store.state.auth.availablePermissions': {
handler () { handler () {
this.showAdmin = this.getShowAdmin() this.showAdmin = this.getShowAdmin()
this.fetchNotificationsCount()
}, },
deep: true deep: true
} }

View File

@ -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>

View File

@ -1,6 +1,6 @@
<template> <template>
<span :title="actor.full_username"> <span :title="actor.full_username">
<span :style="defaultAvatarStyle" class="ui circular label">{{ actor.preferred_username[0]}}</span> <actor-avatar v-if="avatar" :actor="actor" />
&nbsp;{{ actor.full_username | truncate(30) }} &nbsp;{{ actor.full_username | truncate(30) }}
</span> </span>
</template> </template>
@ -9,22 +9,9 @@
import {hashCode, intToRGB} from '@/utils/color' import {hashCode, intToRGB} from '@/utils/color'
export default { export default {
props: ['actor'], props: {
computed: { actor: {type: Object},
actorColor () { avatar: {type: Boolean, default: true}
return intToRGB(hashCode(this.actor.full_username))
},
defaultAvatarStyle () {
return {
'background-color': `#${this.actorColor}`
}
}
} }
} }
</script> </script>
<style scoped>
.tiny.circular.avatar {
width: 1.7em;
height: 1.7em;
}
</style>

View File

@ -16,6 +16,10 @@ import ActorLink from '@/components/common/ActorLink'
Vue.component('actor-link', ActorLink) Vue.component('actor-link', ActorLink)
import ActorAvatar from '@/components/common/ActorAvatar'
Vue.component('actor-avatar', ActorAvatar)
import Duration from '@/components/common/Duration' import Duration from '@/components/common/Duration'
Vue.component('duration', Duration) Vue.component('duration', Duration)

View File

@ -1,24 +1,7 @@
<template> <template>
<div> <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"> <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'"> <a :class="['item', {active: currentTab === 'uploads'}]" @click="currentTab = 'uploads'">
<translate>Uploading</translate> <translate>Uploading</translate>
<div v-if="files.length === 0" class="ui label"> <div v-if="files.length === 0" class="ui label">
@ -44,6 +27,37 @@
</div> </div>
</a> </a>
</div> </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', 'bottom', 'attached', 'segment', {hidden: currentTab != 'uploads'}]">
<div class="ui container"> <div class="ui container">
<file-upload-widget <file-upload-widget
@ -114,7 +128,6 @@ import logger from '@/logging'
import FileUploadWidget from './FileUploadWidget' import FileUploadWidget from './FileUploadWidget'
import LibraryFilesTable from '@/views/content/libraries/FilesTable' import LibraryFilesTable from '@/views/content/libraries/FilesTable'
import moment from 'moment' import moment from 'moment'
import { WebSocketBridge } from 'django-channels'
export default { export default {
props: ['library', 'defaultImportReference'], props: ['library', 'defaultImportReference'],
@ -127,7 +140,7 @@ export default {
this.$router.replace({query: {import: importReference}}) this.$router.replace({query: {import: importReference}})
return { return {
files: [], files: [],
currentTab: 'uploads', currentTab: 'summary',
uploadUrl: '/api/v1/track-files/', uploadUrl: '/api/v1/track-files/',
importReference, importReference,
trackFiles: { trackFiles: {
@ -137,18 +150,23 @@ export default {
errored: 0, errored: 0,
objects: {}, objects: {},
}, },
bridge: null,
processTimestamp: new Date() processTimestamp: new Date()
} }
}, },
created () { created () {
this.openWebsocket()
this.fetchStatus() this.fetchStatus()
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'import.status_updated',
id: 'fileUpload',
handler: this.handleImportEvent
})
}, },
destroyed () { destroyed () {
this.disconnect() this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'import.status_updated',
id: 'fileUpload',
})
}, },
methods: { methods: {
inputFilter (newFile, oldFile, prevent) { inputFilter (newFile, oldFile, prevent) {
if (newFile && !oldFile) { if (newFile && !oldFile) {
@ -199,20 +217,17 @@ export default {
console.log('Connected to WebSocket') console.log('Connected to WebSocket')
}) })
}, },
handleEvent (event) { handleImportEvent (event) {
console.log('Received event', event.type, event)
let self = this let self = this
if (event.type === 'import.status_updated') { if (event.track_file.import_reference != self.importReference) {
if (event.track_file.import_reference != self.importReference) { return
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()
})
} }
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 () { triggerReload: _.throttle(function () {
this.processTimestamp = new Date() this.processTimestamp = new Date()
@ -298,7 +313,4 @@ export default {
border: 3px solid rgba(50, 50, 50, 0.5); border: 3px solid rgba(50, 50, 50, 0.5);
font-size: 1.5em; font-size: 1.5em;
} }
.segment.hidden {
display: none;
}
</style> </style>

View File

@ -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">&nbsp;
<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>

View File

@ -46,6 +46,7 @@ import LibrariesUpload from '@/views/content/libraries/Upload'
import LibrariesDetail from '@/views/content/libraries/Detail' import LibrariesDetail from '@/views/content/libraries/Detail'
import LibrariesFiles from '@/views/content/libraries/Files' import LibrariesFiles from '@/views/content/libraries/Files'
import RemoteLibrariesHome from '@/views/content/remote/Home' import RemoteLibrariesHome from '@/views/content/remote/Home'
import Notifications from '@/views/Notifications'
Vue.use(Router) Vue.use(Router)
@ -74,6 +75,11 @@ export default new Router({
component: Login, component: Login,
props: (route) => ({ next: route.query.next || '/library' }) props: (route) => ({ next: route.query.next || '/library' })
}, },
{
path: '/notifications',
name: 'notifications',
component: Notifications
},
{ {
path: '/auth/password/reset', path: '/auth/password/reset',
name: 'auth.password-reset', name: 'auth.password-reset',

View File

@ -117,6 +117,7 @@ export default {
commit('authenticated', true) commit('authenticated', true)
commit('profile', data) commit('profile', data)
commit('username', data.username) commit('username', data.username)
dispatch('ui/fetchUnreadNotifications', null, { root: true })
dispatch('favorites/fetch', null, { root: true }) dispatch('favorites/fetch', null, { root: true })
dispatch('playlists/fetchOwn', null, { root: true }) dispatch('playlists/fetchOwn', null, { root: true })
Object.keys(data.permissions).forEach(function (key) { Object.keys(data.permissions).forEach(function (key) {

View File

@ -9,11 +9,20 @@ export default {
messageDisplayDuration: 10000, messageDisplayDuration: 10000,
messages: [], messages: [],
notifications: { notifications: {
federation: 0, inbox: 0,
importRequests: 0 },
websocketEventsHandlers: {
'inbox.item_added': {},
'import.status_updated': {},
} }
}, },
mutations: { mutations: {
addWebsocketEventHandler: (state, {eventName, id, handler}) => {
state.websocketEventsHandlers[eventName][id] = handler
},
removeWebsocketEventHandler: (state, {eventName, id}) => {
delete state.websocketEventsHandlers[eventName][id]
},
currentLanguage: (state, value) => { currentLanguage: (state, value) => {
state.currentLanguage = value state.currentLanguage = value
}, },
@ -28,23 +37,27 @@ export default {
}, },
notifications (state, {type, count}) { notifications (state, {type, count}) {
state.notifications[type] = count state.notifications[type] = count
},
incrementNotifications (state, {type, count}) {
state.notifications[type] = Math.max(0, state.notifications[type] + count)
} }
}, },
actions: { actions: {
fetchFederationNotificationsCount ({rootState, commit}) { fetchUnreadNotifications ({commit}, payload) {
if (!rootState.auth.availablePermissions['federation']) { axios.get('federation/inbox/', {params: {is_read: false, page_size: 1}}).then((response) => {
return commit('notifications', {type: 'inbox', count: response.data.count})
}
axios.get('federation/libraries/followers/', {params: {pending: true}}).then(response => {
commit('notifications', {type: 'federation', count: response.data.count})
}) })
}, },
fetchImportRequestsCount ({rootState, commit}) { websocketEvent ({state}, event) {
if (!rootState.auth.availablePermissions['library']) { console.log('Dispatching websocket event', event)
let handlers = state.websocketEventsHandlers[event.type]
if (!handlers) {
return return
} }
axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => { let names = Object.keys(handlers)
commit('notifications', {type: 'importRequests', count: response.data.count}) names.forEach((k) => {
let handler = handlers[k]
handler(event)
}) })
} }
} }

View File

@ -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>

View File

@ -4,14 +4,12 @@
<div class="ui text loader"><translate>Loading library data...</translate></div> <div class="ui text loader"><translate>Loading library data...</translate></div>
</div> </div>
<detail-area v-else :library="library"> <detail-area v-else :library="library">
<div slot="header"> <div class="ui top attached tabular menu">
<h2 class="ui header"><translate>Manage</translate></h2> <a :class="['item', {active: currentTab === 'follows'}]" @click="currentTab = 'follows'"><translate>Followers</translate></a>
<p><a @click="hiddenForm = !hiddenForm"> <a :class="['item', {active: currentTab === 'tracks'}]" @click="currentTab = 'tracks'"><translate>Tracks</translate></a>
<i class="pencil icon" /> <a :class="['item', {active: currentTab === 'edit'}]" @click="currentTab = 'edit'"><translate>Edit</translate></a>
<translate>Edit library</translate> </div>
</a></p> <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'follows'}]">
<library-form v-if="!hiddenForm" :library="library" @updated="libraryUpdated" @deleted="libraryDeleted" />
<div class="ui hidden divider"></div>
<div class="ui form"> <div class="ui form">
<div class="field"> <div class="field">
<label><translate>Sharing link</translate></label> <label><translate>Sharing link</translate></label>
@ -19,14 +17,58 @@
<copy-input :value="library.fid" /> <copy-input :value="library.fid" />
</div> </div>
</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> </div>
<h2><translate>Tracks</translate></h2>
<library-files-table :filters="{library: library.uuid}"></library-files-table>
</detail-area> </detail-area>
</div> </div>
</template> </template>
<script> <script>
import axios from 'axios'
import DetailMixin from './DetailMixin' import DetailMixin from './DetailMixin'
import DetailArea from './DetailArea' import DetailArea from './DetailArea'
import LibraryForm from './Form' import LibraryForm from './Form'
@ -41,9 +83,14 @@ export default {
}, },
data () { data () {
return { return {
hiddenForm: true currentTab: 'follows',
isLoadingFollows: false,
follows: null
} }
}, },
created () {
this.fetchFollows()
},
methods: { methods: {
libraryUpdated () { libraryUpdated () {
this.hiddenForm = true this.hiddenForm = true
@ -53,6 +100,27 @@ export default {
this.$router.push({ this.$router.push({
name: 'content.libraries.index' 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
})
} }
} }
} }

View File

@ -1,15 +1,12 @@
<template> <template>
<div> <div>
<div class="ui stackable grid"> <div class="ui stackable grid">
<div class="eleven wide stretched column">
<slot name="header"></slot>
</div>
<div class="five wide column"> <div class="five wide column">
<h3 class="ui header"><translate>Current library</translate></h3> <h3 class="ui header"><translate>Current library</translate></h3>
<library-card :library="library" /> <library-card :library="library" />
</div> </div>
</div> </div>
<div class="ui divider"></div> <div class="ui hidden divider"></div>
<slot></slot> <slot></slot>
</div> </div>
</template> </template>

View File

@ -4,24 +4,6 @@
<div class="ui text loader"><translate>Loading library data...</translate></div> <div class="ui text loader"><translate>Loading library data...</translate></div>
</div> </div>
<detail-area v-else :library="library"> <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" /> <file-upload :default-import-reference="defaultImportReference" :library="library" />
</detail-area> </detail-area>
</div> </div>

View File

@ -47,6 +47,11 @@
class="ui disabled button"><i class="check icon"></i> class="ui disabled button"><i class="check icon"></i>
<translate>Following</translate> <translate>Following</translate>
</button> </button>
<button
v-else-if="library.follow.approved"
class="ui button"><i class="x icon"></i>
<translate>Unfollow</translate>
</button>
</div> </div>
</div> </div>
</template> </template>

View File

@ -11,6 +11,12 @@
<div v-if="scanResult && scanResult.results.length > 0" class="ui two cards"> <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" /> <library-card :library="library" v-for="library in scanResult.results" :key="library.fid" />
</div> </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>
</div> </div>
</template> </template>
@ -24,11 +30,12 @@ export default {
data () { data () {
return { return {
isLoading: false, isLoading: false,
scanResult: null scanResult: null,
existingFollows: null
} }
}, },
created () { created () {
// this.fetch() this.fetch()
}, },
components: { components: {
ScanForm, ScanForm,
@ -38,13 +45,17 @@ export default {
fetch () { fetch () {
this.isLoading = true this.isLoading = true
let self = this 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.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
} }
} }
} }