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 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:
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.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):
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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"])
|
||||||
|
|
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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 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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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],
|
||||||
|
}
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
<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" />
|
||||||
{{ actor.full_username | truncate(30) }}
|
{{ 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>
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 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',
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue