Library follows and user notifications

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

View File

@ -1,7 +1,13 @@
import uuid
import logging
from django.db import transaction, IntegrityError
from django.utils import timezone
from funkwhale_api.common import channels
from funkwhale_api.common import utils as funkwhale_utils
logger = logging.getLogger(__name__)
PUBLIC_ADDRESS = "https://www.w3.org/ns/activitystreams#Public"
ACTIVITY_TYPES = [
@ -54,19 +60,10 @@ OBJECT_TYPES = [
] + ACTIVITY_TYPES
def deliver(activity, on_behalf_of, to=[]):
from . import tasks
return tasks.send.delay(activity=activity, actor_id=on_behalf_of.pk, to=to)
def accept_follow(follow):
from . import serializers
serializer = serializers.AcceptFollowSerializer(follow)
return deliver(serializer.data, to=[follow.actor.fid], on_behalf_of=follow.target)
BROADCAST_TO_USER_ACTIVITIES = ["Follow", "Accept"]
@transaction.atomic
def receive(activity, on_behalf_of):
from . import models
from . import serializers
@ -78,7 +75,14 @@ def receive(activity, on_behalf_of):
data=activity, context={"actor": on_behalf_of, "local_recipients": True}
)
serializer.is_valid(raise_exception=True)
copy = serializer.save()
try:
copy = serializer.save()
except IntegrityError:
logger.warning(
"[federation] Discarding already elivered activity %s",
serializer.validated_data.get("id"),
)
return
# we create inbox items for further delivery
items = [
models.InboxItem(activity=copy, actor=r, type="to")
@ -93,7 +97,7 @@ def receive(activity, on_behalf_of):
models.InboxItem.objects.bulk_create(items)
# at this point, we have the activity in database. Even if we crash, it's
# okay, as we can retry later
tasks.dispatch_inbox.delay(activity_id=copy.pk)
funkwhale_utils.on_commit(tasks.dispatch_inbox.delay, activity_id=copy.pk)
return copy
@ -113,17 +117,64 @@ class Router:
class InboxRouter(Router):
@transaction.atomic
def dispatch(self, payload, context):
"""
Receives an Activity payload and some context and trigger our
business logic
"""
from . import api_serializers
from . import models
for route, handler in self.routes:
if match_route(route, payload):
return handler(payload, context=context)
r = handler(payload, context=context)
activity_obj = context.get("activity")
if activity_obj and r:
# handler returned additional data we can use
# to update the activity target
for key, value in r.items():
setattr(activity_obj, key, value)
update_fields = []
for k in r.keys():
if k in ["object", "target", "related_object"]:
update_fields += [
"{}_id".format(k),
"{}_content_type".format(k),
]
else:
update_fields.append(k)
activity_obj.save(update_fields=update_fields)
if payload["type"] not in BROADCAST_TO_USER_ACTIVITIES:
return
inbox_items = context.get(
"inbox_items", models.InboxItem.objects.none()
)
for ii in inbox_items:
user = ii.actor.get_user()
if not user:
continue
group = "user.{}.inbox".format(user.pk)
channels.group_send(
group,
{
"type": "event.send",
"text": "",
"data": {
"type": "inbox.item_added",
"item": api_serializers.InboxItemSerializer(ii).data,
},
},
)
inbox_items.update(is_delivered=True, last_delivery_date=timezone.now())
return
class OutboxRouter(Router):
@transaction.atomic
def dispatch(self, routing, context):
"""
Receives a routing payload and some business objects in the context
@ -140,12 +191,11 @@ class OutboxRouter(Router):
# a route can yield zero, one or more activity payloads
if e:
activities_data.append(e)
inbox_items_by_activity_uuid = {}
prepared_activities = []
for activity_data in activities_data:
to = activity_data.pop("to", [])
cc = activity_data.pop("cc", [])
to = activity_data["payload"].pop("to", [])
cc = activity_data["payload"].pop("cc", [])
a = models.Activity(**activity_data)
a.uuid = uuid.uuid4()
to_items, new_to = prepare_inbox_items(to, "to")
@ -160,7 +210,6 @@ class OutboxRouter(Router):
prepared_activities.append(a)
activities = models.Activity.objects.bulk_create(prepared_activities)
activities = [a for a in activities if a]
final_inbox_items = []
for a in activities:

View File

@ -1,6 +1,38 @@
from django.contrib import admin
from . import models
from . import tasks
def redeliver_inbox_items(modeladmin, request, queryset):
for id in set(
queryset.filter(activity__actor__user__isnull=False).values_list(
"activity", flat=True
)
):
tasks.dispatch_outbox.delay(activity_id=id)
redeliver_inbox_items.short_description = "Redeliver"
def redeliver_activities(modeladmin, request, queryset):
for id in set(
queryset.filter(actor__user__isnull=False).values_list("id", flat=True)
):
tasks.dispatch_outbox.delay(activity_id=id)
redeliver_activities.short_description = "Redeliver"
@admin.register(models.Activity)
class ActivityAdmin(admin.ModelAdmin):
list_display = ["type", "fid", "url", "actor", "creation_date"]
search_fields = ["payload", "fid", "url", "actor__domain"]
list_filter = ["type", "actor__domain"]
actions = [redeliver_activities]
list_select_related = True
@admin.register(models.Actor)
@ -25,24 +57,24 @@ class FollowAdmin(admin.ModelAdmin):
list_select_related = True
@admin.register(models.Library)
class LibraryAdmin(admin.ModelAdmin):
list_display = ["actor", "url", "creation_date", "fetched_date", "tracks_count"]
search_fields = ["actor__fid", "url"]
list_filter = ["federation_enabled", "download_files", "autoimport"]
@admin.register(models.LibraryFollow)
class LibraryFollowAdmin(admin.ModelAdmin):
list_display = ["actor", "target", "approved", "creation_date"]
list_filter = ["approved"]
search_fields = ["actor__fid", "target__fid"]
list_select_related = True
@admin.register(models.LibraryTrack)
class LibraryTrackAdmin(admin.ModelAdmin):
@admin.register(models.InboxItem)
class InboxItemAdmin(admin.ModelAdmin):
list_display = [
"title",
"artist_name",
"album_title",
"url",
"library",
"creation_date",
"published_date",
"actor",
"activity",
"type",
"last_delivery_date",
"delivery_attempts",
]
search_fields = ["library__url", "url", "artist_name", "title", "album_title"]
list_filter = ["type"]
search_fields = ["actor__fid", "activity__fid"]
list_select_related = True
actions = [redeliver_inbox_items]

View File

@ -3,8 +3,9 @@ from rest_framework import serializers
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.music import models as music_models
from . import serializers as federation_serializers
from . import filters
from . import models
from . import serializers as federation_serializers
class NestedLibraryFollowSerializer(serializers.ModelSerializer):
@ -44,14 +45,79 @@ class LibrarySerializer(serializers.ModelSerializer):
class LibraryFollowSerializer(serializers.ModelSerializer):
target = common_serializers.RelatedField("uuid", LibrarySerializer(), required=True)
actor = serializers.SerializerMethodField()
class Meta:
model = models.LibraryFollow
fields = ["creation_date", "uuid", "target", "approved"]
read_only_fields = ["uuid", "approved", "creation_date"]
fields = ["creation_date", "actor", "uuid", "target", "approved"]
read_only_fields = ["uuid", "actor", "approved", "creation_date"]
def validate_target(self, v):
actor = self.context["actor"]
if v.received_follows.filter(actor=actor).exists():
raise serializers.ValidationError("You are already following this library")
return v
def get_actor(self, o):
return federation_serializers.APIActorSerializer(o.actor).data
def serialize_generic_relation(activity, obj):
data = {"uuid": obj.uuid, "type": obj._meta.label}
if data["type"] == "music.Library":
data["name"] = obj.name
if data["type"] == "federation.LibraryFollow":
data["approved"] = obj.approved
return data
class ActivitySerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer()
object = serializers.SerializerMethodField()
target = serializers.SerializerMethodField()
related_object = serializers.SerializerMethodField()
class Meta:
model = models.Activity
fields = [
"uuid",
"fid",
"actor",
"payload",
"object",
"target",
"related_object",
"actor",
"creation_date",
"type",
]
def get_object(self, o):
if o.object:
return serialize_generic_relation(o, o.object)
def get_related_object(self, o):
if o.related_object:
return serialize_generic_relation(o, o.related_object)
def get_target(self, o):
if o.target:
return serialize_generic_relation(o, o.target)
class InboxItemSerializer(serializers.ModelSerializer):
activity = ActivitySerializer()
class Meta:
model = models.InboxItem
fields = ["id", "type", "activity", "is_read"]
read_only_fields = ["id", "type", "activity"]
class InboxItemActionSerializer(common_serializers.ActionSerializer):
actions = [common_serializers.Action("read", allow_all=True)]
filterset_class = filters.InboxItemFilter
def handle_read(self, objects):
return objects.update(is_read=True)

View File

@ -4,6 +4,7 @@ from . import api_views
router = routers.SimpleRouter()
router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-follows")
router.register(r"inbox", api_views.InboxItemViewSet, "inbox")
router.register(r"libraries", api_views.LibraryViewSet, "libraries")
urlpatterns = router.urls

View File

@ -1,5 +1,6 @@
import requests.exceptions
from django.db import transaction
from django.db.models import Count
from rest_framework import decorators
@ -10,6 +11,7 @@ from rest_framework import viewsets
from funkwhale_api.music import models as music_models
from . import activity
from . import api_serializers
from . import filters
from . import models
@ -18,6 +20,13 @@ from . import serializers
from . import utils
@transaction.atomic
def update_follow(follow, approved):
follow.approved = approved
follow.save(update_fields=["approved"])
routes.outbox.dispatch({"type": "Accept"}, context={"follow": follow})
class LibraryFollowViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
@ -48,6 +57,29 @@ class LibraryFollowViewSet(
context["actor"] = self.request.user.actor
return context
@decorators.detail_route(methods=["post"])
def accept(self, request, *args, **kwargs):
try:
follow = self.queryset.get(
target__actor=self.request.user.actor, uuid=kwargs["uuid"]
)
except models.LibraryFollow.DoesNotExist:
return response.Response({}, status=404)
update_follow(follow, approved=True)
return response.Response(status=204)
@decorators.detail_route(methods=["post"])
def reject(self, request, *args, **kwargs):
try:
follow = self.queryset.get(
target__actor=self.request.user.actor, uuid=kwargs["uuid"]
)
except models.LibraryFollow.DoesNotExist:
return response.Response({}, status=404)
update_follow(follow, approved=False)
return response.Response(status=204)
class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
lookup_field = "uuid"
@ -59,8 +91,6 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
)
serializer_class = api_serializers.LibrarySerializer
permission_classes = [permissions.IsAuthenticated]
filter_class = filters.LibraryFollowFilter
ordering_fields = ("creation_date",)
def get_queryset(self):
qs = super().get_queryset()
@ -90,3 +120,36 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
)
serializer = self.serializer_class(library)
return response.Response({"count": 1, "results": [serializer.data]})
class InboxItemViewSet(
mixins.UpdateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
queryset = (
models.InboxItem.objects.select_related("activity__actor")
.prefetch_related("activity__object", "activity__target")
.filter(activity__type__in=activity.BROADCAST_TO_USER_ACTIVITIES, type="to")
.order_by("-activity__creation_date")
)
serializer_class = api_serializers.InboxItemSerializer
permission_classes = [permissions.IsAuthenticated]
filter_class = filters.InboxItemFilter
ordering_fields = ("activity__creation_date",)
def get_queryset(self):
qs = super().get_queryset()
return qs.filter(actor=self.request.user.actor)
@decorators.list_route(methods=["post"])
def action(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = api_serializers.InboxItemActionSerializer(
request.data, queryset=queryset
)
serializer.is_valid(raise_exception=True)
result = serializer.save()
return response.Response(result, status=200)

View File

@ -1,4 +1,4 @@
import django_filters
import django_filters.widgets
from funkwhale_api.common import fields
@ -32,3 +32,17 @@ class LibraryFollowFilter(django_filters.FilterSet):
class Meta:
model = models.LibraryFollow
fields = ["approved"]
class InboxItemFilter(django_filters.FilterSet):
is_read = django_filters.BooleanFilter(
"is_read", widget=django_filters.widgets.BooleanWidget()
)
before = django_filters.NumberFilter(method="filter_before")
class Meta:
model = models.InboxItem
fields = ["is_read", "activity__type", "activity__actor"]
def filter_before(self, queryset, field_name, value):
return queryset.filter(pk__lte=value)

View File

@ -0,0 +1,61 @@
# Generated by Django 2.0.8 on 2018-09-10 19:02
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('federation', '0010_auto_20180904_2011'),
]
operations = [
migrations.AddField(
model_name='activity',
name='object_content_type',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objecting_activities', to='contenttypes.ContentType'),
),
migrations.AddField(
model_name='activity',
name='object_id',
field=models.IntegerField(null=True),
),
migrations.AddField(
model_name='activity',
name='related_object_content_type',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_objecting_activities', to='contenttypes.ContentType'),
),
migrations.AddField(
model_name='activity',
name='related_object_id',
field=models.IntegerField(null=True),
),
migrations.AddField(
model_name='activity',
name='target_content_type',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='targeting_activities', to='contenttypes.ContentType'),
),
migrations.AddField(
model_name='activity',
name='target_id',
field=models.IntegerField(null=True),
),
migrations.AddField(
model_name='activity',
name='type',
field=models.CharField(db_index=True, max_length=100, null=True),
),
migrations.AddField(
model_name='inboxitem',
name='is_read',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='activity',
name='creation_date',
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now),
),
]

View File

@ -3,6 +3,8 @@ import uuid
from django.conf import settings
from django.contrib.postgres.fields import JSONField
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
@ -173,6 +175,7 @@ class InboxItem(models.Model):
type = models.CharField(max_length=10, choices=[("to", "to"), ("cc", "cc")])
last_delivery_date = models.DateTimeField(null=True, blank=True)
delivery_attempts = models.PositiveIntegerField(default=0)
is_read = models.BooleanField(default=False)
objects = InboxItemQuerySet.as_manager()
@ -188,7 +191,36 @@ class Activity(models.Model):
fid = models.URLField(unique=True, max_length=500, null=True, blank=True)
url = models.URLField(max_length=500, null=True, blank=True)
payload = JSONField(default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder)
creation_date = models.DateTimeField(default=timezone.now)
creation_date = models.DateTimeField(default=timezone.now, db_index=True)
type = models.CharField(db_index=True, null=True, max_length=100)
# generic relations
object_id = models.IntegerField(null=True)
object_content_type = models.ForeignKey(
ContentType,
null=True,
on_delete=models.SET_NULL,
related_name="objecting_activities",
)
object = GenericForeignKey("object_content_type", "object_id")
target_id = models.IntegerField(null=True)
target_content_type = models.ForeignKey(
ContentType,
null=True,
on_delete=models.SET_NULL,
related_name="targeting_activities",
)
target = GenericForeignKey("target_content_type", "target_id")
related_object_id = models.IntegerField(null=True)
related_object_content_type = models.ForeignKey(
ContentType,
null=True,
on_delete=models.SET_NULL,
related_name="related_objecting_activities",
)
related_object = GenericForeignKey(
"related_object_content_type", "related_object_id"
)
class AbstractFollow(models.Model):

View File

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

View File

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

View File

@ -74,8 +74,14 @@ def dispatch_inbox(activity):
routes.inbox.dispatch(
activity.payload,
context={
"activity": activity,
"actor": activity.actor,
"inbox_items": list(activity.inbox_items.local().select_related()),
"inbox_items": (
activity.inbox_items.local()
.select_related()
.select_related("actor__user")
.prefetch_related("activity__object", "activity__target")
),
},
)
except Exception:

View File

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

View File

@ -18,6 +18,7 @@ from taggit.models import Tag
from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.federation.authentication import SignatureAuthentication
from funkwhale_api.federation import api_serializers as federation_api_serializers
from . import filters, models, serializers, tasks, utils
@ -94,6 +95,25 @@ class LibraryViewSet(
def perform_create(self, serializer):
serializer.save(actor=self.request.user.actor)
@detail_route(methods=["get"])
@transaction.non_atomic_requests
def follows(self, request, *args, **kwargs):
library = self.get_object()
queryset = (
library.received_follows.filter(target__actor=self.request.user.actor)
.select_related("actor", "target__actor")
.order_by("-creation_date")
)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = federation_api_serializers.LibraryFollowSerializer(
page, many=True
)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet):
"""

View File

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

View File

@ -15,6 +15,7 @@ from django.core.cache import cache as django_cache
from django.core.files import uploadedfile
from django.utils import timezone
from django.test import client
from django.db.models import QuerySet
from dynamic_preferences.registries import global_preferences_registry
from rest_framework import fields as rest_fields
from rest_framework.test import APIClient, APIRequestFactory
@ -23,6 +24,43 @@ from funkwhale_api.activity import record
from funkwhale_api.users.permissions import HasUserPermission
@pytest.fixture
def queryset_equal_queries():
"""
Unitting querysets is hard because we have to compare queries
by hand. Let's monkey patch querysets to do that for us.
"""
def __eq__(self, other):
if isinstance(other, QuerySet):
return str(other.query) == str(self.query)
else:
return False
setattr(QuerySet, "__eq__", __eq__)
yield __eq__
delattr(QuerySet, "__eq__")
@pytest.fixture
def queryset_equal_list():
"""
Unitting querysets is hard because we usually simply wants to ensure
a querysets contains the same objects as a list, let's monkey patch
querysets to to that for us.
"""
def __eq__(self, other):
if isinstance(other, (list, tuple)):
return list(self) == list(other)
else:
return False
setattr(QuerySet, "__eq__", __eq__)
yield __eq__
delattr(QuerySet, "__eq__")
@pytest.fixture(scope="session", autouse=True)
def factories_autodiscover():
from django.apps import apps

View File

@ -1,23 +1,11 @@
import pytest
from funkwhale_api.federation import activity, serializers, tasks
def test_accept_follow(mocker, factories):
deliver = mocker.patch("funkwhale_api.federation.activity.deliver")
follow = factories["federation.Follow"](approved=None)
expected_accept = serializers.AcceptFollowSerializer(follow).data
activity.accept_follow(follow)
deliver.assert_called_once_with(
expected_accept, to=[follow.actor.fid], on_behalf_of=follow.target
)
from funkwhale_api.federation import activity, api_serializers, serializers, tasks
def test_receive_validates_basic_attributes_and_stores_activity(factories, now, mocker):
mocked_dispatch = mocker.patch(
"funkwhale_api.federation.tasks.dispatch_inbox.delay"
)
mocked_dispatch = mocker.patch("funkwhale_api.common.utils.on_commit")
local_actor = factories["users.User"]().create_actor()
remote_actor = factories["federation.Actor"]()
another_actor = factories["federation.Actor"]()
@ -36,7 +24,10 @@ def test_receive_validates_basic_attributes_and_stores_activity(factories, now,
assert copy.creation_date >= now
assert copy.actor == remote_actor
assert copy.fid == a["id"]
mocked_dispatch.assert_called_once_with(activity_id=copy.pk)
assert copy.type == "Noop"
mocked_dispatch.assert_called_once_with(
tasks.dispatch_inbox.delay, activity_id=copy.pk
)
inbox_item = copy.inbox_items.get(actor__fid=local_actor.fid)
assert inbox_item.is_delivered is False
@ -63,16 +54,62 @@ def test_receive_actor_mismatch(factories):
activity.receive(activity=a, on_behalf_of=remote_actor)
def test_inbox_routing(mocker):
def test_inbox_routing(factories, mocker):
object = factories["music.Artist"]()
target = factories["music.Artist"]()
router = activity.InboxRouter()
a = factories["federation.Activity"](type="Follow")
handler_payload = {}
handler_context = {}
def handler(payload, context):
handler_payload.update(payload)
handler_context.update(context)
return {"target": target, "object": object}
handler = mocker.stub(name="handler")
router.connect({"type": "Follow"}, handler)
good_message = {"type": "Follow"}
router.dispatch(good_message, context={})
router.dispatch(good_message, context={"activity": a})
handler.assert_called_once_with(good_message, context={})
assert handler_payload == good_message
assert handler_context == {"activity": a}
a.refresh_from_db()
assert a.object == object
assert a.target == target
def test_inbox_routing_send_to_channel(factories, mocker):
group_send = mocker.patch("funkwhale_api.common.channels.group_send")
a = factories["federation.Activity"](type="Follow")
ii = factories["federation.InboxItem"](actor__local=True)
router = activity.InboxRouter()
handler = mocker.stub()
router.connect({"type": "Follow"}, handler)
good_message = {"type": "Follow"}
router.dispatch(
good_message, context={"activity": a, "inbox_items": ii.__class__.objects.all()}
)
ii.refresh_from_db()
assert ii.is_delivered is True
group_send.assert_called_once_with(
"user.{}.inbox".format(ii.actor.user.pk),
{
"type": "event.send",
"text": "",
"data": {
"type": "inbox.item_added",
"item": api_serializers.InboxItemSerializer(ii).data,
},
},
)
@pytest.mark.parametrize(
@ -101,10 +138,10 @@ def test_outbox_router_dispatch(mocker, factories, now):
"type": "Noop",
"actor": actor.fid,
"summary": context["summary"],
"to": [r1],
"cc": [r2, activity.PUBLIC_ADDRESS],
},
"actor": actor,
"to": [r1],
"cc": [r2, activity.PUBLIC_ADDRESS],
}
router.connect({"type": "Noop"}, handler)

View File

@ -1,6 +1,5 @@
import pytest
from django.urls import reverse
from django.utils import timezone
from rest_framework import exceptions
from funkwhale_api.federation import actors, models, serializers, utils
@ -120,58 +119,6 @@ def test_test_post_outbox_validates_actor(nodb_factories):
assert msg in exc_info.value
def test_test_post_inbox_handles_create_note(settings, mocker, factories):
deliver = mocker.patch("funkwhale_api.federation.activity.deliver")
actor = factories["federation.Actor"]()
now = timezone.now()
mocker.patch("django.utils.timezone.now", return_value=now)
data = {
"actor": actor.fid,
"type": "Create",
"id": "http://test.federation/activity",
"object": {
"type": "Note",
"id": "http://test.federation/object",
"content": "<p><a>@mention</a> /ping</p>",
},
}
test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance()
expected_note = factories["federation.Note"](
id="https://test.federation/activities/note/{}".format(now.timestamp()),
content="Pong!",
published=now.isoformat(),
inReplyTo=data["object"]["id"],
cc=[],
summary=None,
sensitive=False,
attributedTo=test_actor.fid,
attachment=[],
to=[actor.fid],
url="https://{}/activities/note/{}".format(
settings.FEDERATION_HOSTNAME, now.timestamp()
),
tag=[{"href": actor.fid, "name": actor.full_username, "type": "Mention"}],
)
expected_activity = {
"@context": serializers.AP_CONTEXT,
"actor": test_actor.fid,
"id": "https://{}/activities/note/{}/activity".format(
settings.FEDERATION_HOSTNAME, now.timestamp()
),
"to": actor.fid,
"type": "Create",
"published": now.isoformat(),
"object": expected_note,
"cc": [],
}
actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor)
deliver.assert_called_once_with(
expected_activity,
to=[actor.fid],
on_behalf_of=actors.SYSTEM_ACTORS["test"].get_actor_instance(),
)
def test_getting_actor_instance_persists_in_db(db):
test = actors.SYSTEM_ACTORS["test"].get_actor_instance()
from_db = models.Actor.objects.get(fid=test.fid)
@ -220,57 +167,3 @@ def test_system_actor_handle(mocker, nodb_factories):
assert serializer.is_valid()
actors.SYSTEM_ACTORS["test"].handle(activity, actor)
handler.assert_called_once_with(activity, actor)
def test_test_actor_handles_follow(settings, mocker, factories):
deliver = mocker.patch("funkwhale_api.federation.activity.deliver")
actor = factories["federation.Actor"]()
accept_follow = mocker.patch("funkwhale_api.federation.activity.accept_follow")
test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance()
data = {
"actor": actor.fid,
"type": "Follow",
"id": "http://test.federation/user#follows/267",
"object": test_actor.fid,
}
actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor)
follow = models.Follow.objects.get(target=test_actor, approved=True)
follow_back = models.Follow.objects.get(actor=test_actor, approved=None)
accept_follow.assert_called_once_with(follow)
deliver.assert_called_once_with(
serializers.FollowSerializer(follow_back).data,
on_behalf_of=test_actor,
to=[actor.fid],
)
def test_test_actor_handles_undo_follow(settings, mocker, factories):
deliver = mocker.patch("funkwhale_api.federation.activity.deliver")
test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance()
follow = factories["federation.Follow"](target=test_actor)
reverse_follow = factories["federation.Follow"](
actor=test_actor, target=follow.actor
)
follow_serializer = serializers.FollowSerializer(follow)
reverse_follow_serializer = serializers.FollowSerializer(reverse_follow)
undo = {
"@context": serializers.AP_CONTEXT,
"type": "Undo",
"id": follow_serializer.data["id"] + "/undo",
"actor": follow.actor.fid,
"object": follow_serializer.data,
}
expected_undo = {
"@context": serializers.AP_CONTEXT,
"type": "Undo",
"id": reverse_follow_serializer.data["id"] + "/undo",
"actor": reverse_follow.actor.fid,
"object": reverse_follow_serializer.data,
}
actors.SYSTEM_ACTORS["test"].post_inbox(undo, actor=follow.actor)
deliver.assert_called_once_with(
expected_undo, to=[follow.actor.fid], on_behalf_of=test_actor
)
assert models.Follow.objects.count() == 0

View File

@ -0,0 +1,9 @@
from funkwhale_api.federation import filters
from funkwhale_api.federation import models
def test_inbox_item_filter_before(factories):
expected = models.InboxItem.objects.filter(pk__lte=12)
f = filters.InboxItemFilter({"before": 12}, queryset=models.InboxItem.objects.all())
assert str(f.qs.query) == str(expected.query)

View File

@ -51,3 +51,12 @@ def test_library_serializer_validates_existing_follow(factories):
assert serializer.is_valid() is False
assert "target" in serializer.errors
def test_manage_track_file_action_read(factories):
ii = factories["federation.InboxItem"]()
s = api_serializers.InboxItemActionSerializer(queryset=None)
s.handle_read(ii.__class__.objects.all())
assert ii.__class__.objects.filter(is_read=False).count() == 0

View File

@ -1,3 +1,5 @@
import pytest
from django.urls import reverse
from funkwhale_api.federation import api_serializers
@ -49,3 +51,82 @@ def test_can_follow_library(factories, logged_in_api_client, mocker):
assert follow.actor == actor
dispatch.assert_called_once_with({"type": "Follow"}, context={"follow": follow})
@pytest.mark.parametrize("action", ["accept", "reject"])
def test_user_cannot_edit_someone_else_library_follow(
factories, logged_in_api_client, action
):
logged_in_api_client.user.create_actor()
follow = factories["federation.LibraryFollow"]()
url = reverse(
"api:v1:federation:library-follows-{}".format(action),
kwargs={"uuid": follow.uuid},
)
response = logged_in_api_client.post(url)
assert response.status_code == 404
@pytest.mark.parametrize("action,expected", [("accept", True), ("reject", False)])
def test_user_can_accept_or_reject_own_follows(
factories, logged_in_api_client, action, expected, mocker
):
mocked_dispatch = mocker.patch(
"funkwhale_api.federation.activity.OutboxRouter.dispatch"
)
actor = logged_in_api_client.user.create_actor()
follow = factories["federation.LibraryFollow"](target__actor=actor)
url = reverse(
"api:v1:federation:library-follows-{}".format(action),
kwargs={"uuid": follow.uuid},
)
response = logged_in_api_client.post(url)
assert response.status_code == 204
follow.refresh_from_db()
assert follow.approved is expected
mocked_dispatch.assert_called_once_with(
{"type": "Accept"}, context={"follow": follow}
)
def test_user_can_list_inbox_items(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
ii = factories["federation.InboxItem"](
activity__type="Follow", actor=actor, type="to"
)
factories["federation.InboxItem"](activity__type="Follow", actor=actor, type="cc")
factories["federation.InboxItem"](activity__type="Follow", type="to")
url = reverse("api:v1:federation:inbox-list")
response = logged_in_api_client.get(url)
assert response.status_code == 200
assert response.data == {
"count": 1,
"results": [api_serializers.InboxItemSerializer(ii).data],
"next": None,
"previous": None,
}
def test_user_can_update_read_status_of_inbox_item(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
ii = factories["federation.InboxItem"](
activity__type="Follow", actor=actor, type="to"
)
url = reverse("api:v1:federation:inbox-detail", kwargs={"pk": ii.pk})
response = logged_in_api_client.patch(url, {"is_read": True})
assert response.status_code == 200
ii.refresh_from_db()
assert ii.is_read is True

View File

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

View File

@ -59,7 +59,7 @@ def test_clean_federation_music_cache_orphaned(settings, preferences, factories)
assert os.path.exists(remove_path) is False
def test_handle_in(factories, mocker, now):
def test_handle_in(factories, mocker, now, queryset_equal_list):
mocked_dispatch = mocker.patch("funkwhale_api.federation.routes.inbox.dispatch")
r1 = factories["users.User"](with_actor=True).actor
@ -70,7 +70,7 @@ def test_handle_in(factories, mocker, now):
tasks.dispatch_inbox(activity_id=a.pk)
mocked_dispatch.assert_called_once_with(
a.payload, context={"actor": a.actor, "inbox_items": [ii1, ii2]}
a.payload, context={"actor": a.actor, "activity": a, "inbox_items": [ii1, ii2]}
)
ii1.refresh_from_db()

View File

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

View File

@ -6,6 +6,7 @@ from django.urls import reverse
from django.utils import timezone
from funkwhale_api.music import serializers, tasks, views
from funkwhale_api.federation import api_serializers as federation_api_serializers
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
@ -384,3 +385,22 @@ def test_user_can_create_track_file(
assert tf.import_reference == "test"
assert tf.track is None
m.assert_called_once_with(tasks.import_track_file.delay, track_file_id=tf.pk)
def test_user_can_list_own_library_follows(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
library = factories["music.Library"](actor=actor)
another_library = factories["music.Library"](actor=actor)
follow = factories["federation.LibraryFollow"](target=library)
factories["federation.LibraryFollow"](target=another_library)
url = reverse("api:v1:libraries-follows", kwargs={"uuid": library.uuid})
response = logged_in_api_client.get(url)
assert response.data == {
"count": 1,
"next": None,
"previous": None,
"results": [federation_api_serializers.LibraryFollowSerializer(follow).data],
}

View File

@ -170,7 +170,10 @@ def test_creating_actor_from_user(factories, settings):
def test_get_channels_groups(factories):
user = factories["users.User"]()
assert user.get_channels_groups() == ["user.{}.imports".format(user.pk)]
assert user.get_channels_groups() == [
"user.{}.imports".format(user.pk),
"user.{}.inbox".format(user.pk),
]
def test_user_quota_default_to_preference(factories, preferences):

View File

@ -94,6 +94,8 @@
import axios from 'axios'
import _ from 'lodash'
import {mapState} from 'vuex'
import { WebSocketBridge } from 'django-channels'
import translations from '@/translations'
@ -113,11 +115,13 @@ export default {
},
data () {
return {
bridge: null,
nodeinfo: null,
instanceUrl: null
}
},
created () {
this.openWebsocket()
let self = this
this.autodetectLanguage()
setInterval(() => {
@ -134,8 +138,23 @@ export default {
this.$store.dispatch('auth/check')
this.$store.dispatch('instance/fetchSettings')
this.fetchNodeInfo()
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'inbox.item_added',
id: 'sidebarCount',
handler: this.incrementNotificationCountInSidebar
})
},
destroyed () {
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'inbox.item_added',
id: 'sidebarCount',
})
this.disconnect()
},
methods: {
incrementNotificationCountInSidebar (event) {
this.$store.commit('ui/incrementNotifications', {type: 'inbox', count: 1})
},
fetchNodeInfo () {
let self = this
axios.get('instance/nodeinfo/2.0/').then(response => {
@ -162,6 +181,36 @@ export default {
} else if (almostMatching.length > 0) {
this.$language.current = almostMatching[0]
}
},
disconnect () {
if (!this.bridge) {
return
}
this.bridge.socket.close(1000, 'goodbye', {keepClosed: true})
},
openWebsocket () {
if (!this.$store.state.auth.authenticated) {
return
}
this.disconnect()
let self = this
let token = this.$store.state.auth.token
// let token = 'test'
const bridge = new WebSocketBridge()
this.bridge = bridge
let url = this.$store.getters['instance/absoluteUrl'](`api/v1/activity?token=${token}`)
url = url.replace('http://', 'ws://')
url = url.replace('https://', 'wss://')
bridge.connect(
url,
null,
{reconnectInterval: 5000})
bridge.listen(function (event) {
self.$store.dispatch('ui/websocketEvent', event)
})
bridge.socket.addEventListener('open', function () {
console.log('Connected to WebSocket')
})
}
},
computed: {
@ -189,6 +238,13 @@ export default {
this.$store.dispatch('instance/fetchSettings')
this.fetchNodeInfo()
},
'$store.state.auth.authenticated' (newValue) {
if (!newValue) {
this.disconnect()
} else {
this.openWebsocket()
}
},
'$language.current' (newValue) {
this.$store.commit('ui/currentLanguage', newValue)
}
@ -299,9 +355,11 @@ html, body {
}
}
.discrete.link {
color: rgba(0, 0, 0, 0.87);
cursor: pointer;
.discrete {
color: rgba(0, 0, 0, 0.87);
}
.link {
cursor: pointer;
}
.floated.buttons .button ~ .dropdown {
@ -321,5 +379,8 @@ html, body {
a {
cursor: pointer;
}
.segment.hidden {
display: none;
}
</style>

View File

@ -42,6 +42,15 @@
<img class="ui right floated circular tiny avatar image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
</router-link>
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/settings'}"><i class="setting icon"></i><translate>Settings</translate></router-link>
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'notifications'}">
<i class="feed icon"></i>
<translate>Notifications</translate>
<div
v-if="$store.state.ui.notifications.inbox > 0"
:class="['ui', 'teal', 'label']">
{{ $store.state.ui.notifications.inbox }}</div>
<img class="ui right floated circular tiny avatar image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
</router-link>
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i><translate>Logout</translate></router-link>
<template v-else>
<router-link class="item" :to="{name: 'login'}"><i class="sign in icon"></i><translate>Login</translate></router-link>
@ -73,33 +82,6 @@
<div class="item" v-if="showAdmin">
<div class="header"><translate>Administration</translate></div>
<div class="menu">
<router-link
class="item"
v-if="$store.state.auth.availablePermissions['library']"
:to="{name: 'manage.library.files'}">
<i class="book icon"></i><translate>Library</translate>
<div
:class="['ui', {'teal': $store.state.ui.notifications.importRequests > 0}, 'label']"
:title="labels.pendingRequests">
{{ $store.state.ui.notifications.importRequests }}</div>
</router-link>
<router-link
class="item"
v-else-if="$store.state.auth.availablePermissions['upload']"
to="/library/import/launch">
<i class="download icon"></i><translate>Import music</translate>
</router-link>
<router-link
class="item"
v-if="$store.state.auth.availablePermissions['federation']"
:to="{path: '/manage/federation/libraries'}">
<i class="sitemap icon"></i><translate>Federation</translate>
<div
:class="['ui', {'teal': $store.state.ui.notifications.federation > 0}, 'label']"
:title="labels.pendingFollows">
{{ $store.state.ui.notifications.federation }}</div>
</router-link>
<router-link
class="item"
v-if="$store.state.auth.availablePermissions['settings']"
@ -207,11 +189,6 @@ export default {
mounted () {
$(this.$el).find('.menu .item').tab()
},
created () {
this.fetchNotificationsCount()
this.fetchInterval = setInterval(
this.fetchNotificationsCount, 1000 * 60 * 15)
},
destroy () {
if (this.fetchInterval) {
clearInterval(this.fetchInterval)
@ -260,11 +237,6 @@ export default {
return e
}).length > 0
},
fetchNotificationsCount () {
this.$store.dispatch('ui/fetchFederationNotificationsCount')
this.$store.dispatch('ui/fetchImportRequestsCount')
},
reorder: function (event) {
this.$store.commit('queue/reorder', {
tracks: this.tracksChangeBuffer, oldIndex: event.oldIndex, newIndex: event.newIndex})
@ -301,7 +273,6 @@ export default {
'$store.state.auth.availablePermissions': {
handler () {
this.showAdmin = this.getShowAdmin()
this.fetchNotificationsCount()
},
deep: true
}

View File

@ -0,0 +1,21 @@
<template>
<span :style="defaultAvatarStyle" class="ui avatar circular label">{{ actor.preferred_username[0]}}</span>
</template>
<script>
import {hashCode, intToRGB} from '@/utils/color'
export default {
props: ['actor'],
computed: {
actorColor () {
return intToRGB(hashCode(this.actor.full_username))
},
defaultAvatarStyle () {
return {
'background-color': `#${this.actorColor}`
}
}
}
}
</script>

View File

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

View File

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

View File

@ -1,24 +1,7 @@
<template>
<div>
<div class="ui hidden clearing divider"></div>
<!-- <div v-if="files.length > 0" class="ui indicating progress">
<div class="bar"></div>
<div class="label">
{{ uploadedFilesCount }}/{{ files.length }} files uploaded,
{{ processedFilesCount }}/{{ processableFiles }} files processed
</div>
</div> -->
<div class="ui form">
<div class="fields">
<div class="ui four wide field">
<label><translate>Import reference</translate></label>
<input type="text" v-model="importReference" />
</div>
</div>
</div>
<p><translate>This reference will be used to group imported files together.</translate></p>
<div class="ui top attached tabular menu">
<a :class="['item', {active: currentTab === 'summary'}]" @click="currentTab = 'summary'"><translate>Summary</translate></a>
<a :class="['item', {active: currentTab === 'uploads'}]" @click="currentTab = 'uploads'">
<translate>Uploading</translate>
<div v-if="files.length === 0" class="ui label">
@ -44,6 +27,37 @@
</div>
</a>
</div>
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'summary'}]">
<h2 class="ui header"><translate>Upload new tracks</translate></h2>
<div class="ui message">
<p><translate>You are about to upload music to your library. Before proceeding, please ensure that:</translate></p>
<ul>
<li v-if="library.privacy_level != 'me'">
You are not uploading copyrighted content in a public library, otherwise you may be infringing the law
</li>
<li>
<translate>The music files you are uploading are tagged properly:</translate>
<a href="http://picard.musicbrainz.org/" target='_blank'><translate>we recommend using Picard for that purpose</translate></a>
</li>
<li>
<translate>The uploaded music files are in OGG, Flac or MP3 format</translate>
</li>
</ul>
</div>
<div class="ui form">
<div class="fields">
<div class="ui four wide field">
<label><translate>Import reference</translate></label>
<p><translate>This reference will be used to group imported files together.</translate></p>
<input type="text" v-model="importReference" />
</div>
</div>
</div>
<div class="ui green button" @click="currentTab = 'uploads'"><translate>Proceed</translate></div>
</div>
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'uploads'}]">
<div class="ui container">
<file-upload-widget
@ -114,7 +128,6 @@ import logger from '@/logging'
import FileUploadWidget from './FileUploadWidget'
import LibraryFilesTable from '@/views/content/libraries/FilesTable'
import moment from 'moment'
import { WebSocketBridge } from 'django-channels'
export default {
props: ['library', 'defaultImportReference'],
@ -127,7 +140,7 @@ export default {
this.$router.replace({query: {import: importReference}})
return {
files: [],
currentTab: 'uploads',
currentTab: 'summary',
uploadUrl: '/api/v1/track-files/',
importReference,
trackFiles: {
@ -137,18 +150,23 @@ export default {
errored: 0,
objects: {},
},
bridge: null,
processTimestamp: new Date()
}
},
created () {
this.openWebsocket()
this.fetchStatus()
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'import.status_updated',
id: 'fileUpload',
handler: this.handleImportEvent
})
},
destroyed () {
this.disconnect()
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'import.status_updated',
id: 'fileUpload',
})
},
methods: {
inputFilter (newFile, oldFile, prevent) {
if (newFile && !oldFile) {
@ -199,20 +217,17 @@ export default {
console.log('Connected to WebSocket')
})
},
handleEvent (event) {
console.log('Received event', event.type, event)
handleImportEvent (event) {
let self = this
if (event.type === 'import.status_updated') {
if (event.track_file.import_reference != self.importReference) {
return
}
this.$nextTick(() => {
self.trackFiles[event.old_status] -= 1
self.trackFiles[event.new_status] += 1
self.trackFiles.objects[event.track_file.uuid] = event.track_file
self.triggerReload()
})
if (event.track_file.import_reference != self.importReference) {
return
}
this.$nextTick(() => {
self.trackFiles[event.old_status] -= 1
self.trackFiles[event.new_status] += 1
self.trackFiles.objects[event.track_file.uuid] = event.track_file
self.triggerReload()
})
},
triggerReload: _.throttle(function () {
this.processTimestamp = new Date()
@ -298,7 +313,4 @@ export default {
border: 3px solid rgba(50, 50, 50, 0.5);
font-size: 1.5em;
}
.segment.hidden {
display: none;
}
</style>

View File

@ -0,0 +1,126 @@
<template>
<tr :class="[{'disabled-row': item.is_read}]">
<td>
<actor-link class="user" :actor="item.activity.actor" />
</td>
<td>
<router-link tag="span" class="link" v-if="notificationData.detailUrl" :to="notificationData.detailUrl">
{{ notificationData.message }}
</router-link>
<template v-else>{{ notificationData.message }}</template>
<template v-if="notificationData.action">&nbsp;
<div @click="handleAction(notificationData.action.handler)" :class="['ui', 'basic', 'tiny', notificationData.action.buttonClass || '', 'button']">
<i v-if="notificationData.action.icon" :class="[notificationData.action.icon, 'icon']" />
{{ notificationData.action.label }}
</div>
</template>
</td>
<td><human-date :date="item.activity.creation_date" /></td>
<td class="read collapsing">
<span @click="markRead(false)" v-if="item.is_read" :title="labels.markUnread">
<i class="redo icon" />
</span>
<span @click="markRead(true)" v-else :title="labels.markRead">
<i class="check icon" />
</span>
</td>
</tr>
</template>
<script>
import axios from 'axios'
export default {
props: ['item'],
computed: {
message () {
return 'plop'
},
labels () {
let libraryFollowMessage = this.$gettext('%{ username } followed your library "%{ library }"')
let libraryAcceptFollowMessage = this.$gettext('%{ username } accepted your follow on library "%{ library }"')
return {
libraryFollowMessage,
libraryAcceptFollowMessage,
markRead: this.$gettext('Mark as read'),
markUnread: this.$gettext('Mark as unread'),
}
},
username () {
return this.item.activity.actor.preferred_username
},
notificationData () {
let self = this
let a = this.item.activity
if (a.type === 'Follow') {
if (a.object && a.object.type === 'music.Library') {
let action = null
if (!a.related_object.approved) {
action = {
buttonClass: 'green',
icon: 'check',
label: this.$gettext('Approve'),
handler: () => { self.approveLibraryFollow(a.related_object) }
}
}
return {
action,
detailUrl: {name: 'content.libraries.detail', params: {id: a.object.uuid}},
message: this.$gettextInterpolate(
this.labels.libraryFollowMessage,
{username: this.username, library: a.object.name}
)
}
}
}
if (a.type === 'Accept') {
if (a.object && a.object.type === 'federation.LibraryFollow') {
return {
detailUrl: {name: 'content.remote.index'},
message: this.$gettextInterpolate(
this.labels.libraryAcceptFollowMessage,
{username: this.username, library: a.related_object.name}
)
}
}
}
return {}
}
},
methods: {
handleAction (handler) {
// call handler then mark notification as read
handler()
this.markRead(true)
},
approveLibraryFollow (follow) {
let self = this
let action = 'accept'
axios.post(`federation/follows/library/${follow.uuid}/${action}/`).then((response) => {
follow.isLoading = false
follow.approved = true
})
},
markRead (value) {
let self = this
let action = 'accept'
axios.patch(`federation/inbox/${this.item.id}/`, {is_read: value}).then((response) => {
self.item.is_read = value
if (value) {
self.$store.commit('ui/incrementNotifications', {type: 'inbox', count: -1})
} else {
self.$store.commit('ui/incrementNotifications', {type: 'inbox', count: 1})
}
})
}
}
}
</script>
<style scoped>
.read > span {
cursor: pointer;
}
.disabled-row {
color: rgba(40, 40, 40, 0.3);
}
</style>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,125 @@
<template>
<div class="main pusher" v-title="labels.title">
<div class="ui vertical aligned stripe segment">
<div v-if="isLoading" :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']">
<div class="ui text loader"><translate>Loading notifications...</translate></div>
</div>
<div v-else class="ui container">
<h1 class="ui header"><translate>Your notifications</translate></h1>
<div class="ui toggle checkbox">
<input v-model="filters.is_read" type="checkbox">
<label><translate>Show read notifications</translate></label>
</div>
<div
v-if="filters.is_read === false && notifications.count > 0"
@click="markAllAsRead"
class="ui basic labeled icon right floated button">
<i class="ui check icon" />
<translate>Mark all as read</translate>
</div>
<div class="ui hidden divider" />
<table v-if="notifications.count > 0" class="ui table">
<tbody>
<notification-row :item="item" v-for="item in notifications.results" :key="item.id" />
</tbody>
</table>
<p v-else>
<translate>We don't have any notification to display!</translate>
</p>
</div>
</div>
</div>
</template>
<script>
import {mapState} from 'vuex'
import axios from 'axios'
import logger from '@/logging'
import NotificationRow from '@/components/notifications/NotificationRow'
export default {
data () {
return {
isLoading: false,
notifications: null,
filters: {
is_read: false
}
}
},
components: {
NotificationRow
},
created () {
this.fetch(this.filters)
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'inbox.item_added',
id: 'notificationPage',
handler: this.handleNewNotification
})
},
destroyed () {
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'inbox.item_added',
id: 'notificationPage',
})
},
computed: {
...mapState({
events: state => state.instance.events
}),
labels () {
return {
title: this.$gettext('Notifications'),
}
}
},
methods: {
handleNewNotification (event) {
this.notifications.results.unshift(event.item)
},
fetch (params) {
this.isLoading = true
let self = this
axios.get('federation/inbox/', {params: params}).then((response) => {
self.isLoading = false
self.notifications = response.data
})
},
markAllAsRead () {
let self = this
let before = this.notifications.results[0].id
let payload = {
action: 'read',
objects: 'all',
filters: {
is_read: false,
before
}
}
axios.post('federation/inbox/action/', payload).then((response) => {
self.$store.commit('ui/notifications', {type: 'inbox', count: 0})
self.notifications.results.forEach(n => {
n.is_read = true
})
})
},
},
watch: {
'filters.is_read' () {
this.fetch(this.filters)
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style>
.event .ui.label.avatar {
font-size: 1.5em;
position: relative;
top: 0.35em;
}
</style>

View File

@ -4,14 +4,12 @@
<div class="ui text loader"><translate>Loading library data...</translate></div>
</div>
<detail-area v-else :library="library">
<div slot="header">
<h2 class="ui header"><translate>Manage</translate></h2>
<p><a @click="hiddenForm = !hiddenForm">
<i class="pencil icon" />
<translate>Edit library</translate>
</a></p>
<library-form v-if="!hiddenForm" :library="library" @updated="libraryUpdated" @deleted="libraryDeleted" />
<div class="ui hidden divider"></div>
<div class="ui top attached tabular menu">
<a :class="['item', {active: currentTab === 'follows'}]" @click="currentTab = 'follows'"><translate>Followers</translate></a>
<a :class="['item', {active: currentTab === 'tracks'}]" @click="currentTab = 'tracks'"><translate>Tracks</translate></a>
<a :class="['item', {active: currentTab === 'edit'}]" @click="currentTab = 'edit'"><translate>Edit</translate></a>
</div>
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'follows'}]">
<div class="ui form">
<div class="field">
<label><translate>Sharing link</translate></label>
@ -19,14 +17,58 @@
<copy-input :value="library.fid" />
</div>
</div>
<div class="ui hidden divider"></div>
<div v-if="isLoadingFollows" :class="['ui', {'active': isLoadingFollows}, 'inverted', 'dimmer']">
<div class="ui text loader"><translate>Loading followers...</translate></div>
</div>
<table v-else-if="follows && follows.count > 0" class="ui table">
<thead>
<tr>
<th><translate>User</translate></th>
<th><translate>Date</translate></th>
<th><translate>Status</translate></th>
<th><translate>Action</translate></th>
</tr>
</thead>
<tr v-for="follow in follows.results" :key="follow.fid">
<td><actor-link :actor="follow.actor" /></td>
<td><human-date :date="follow.creation_date" /></td>
<td>
<span :class="['ui', 'yellow', 'basic', 'label']" v-if="follow.approved === null">
<translate>Pending approval</translate>
</span>
<span :class="['ui', 'green', 'basic', 'label']" v-else-if="follow.approved === true">
<translate>Accepted</translate>
</span>
<span :class="['ui', 'red', 'basic', 'label']" v-else-if="follow.approved === false">
<translate>Rejected</translate>
</span>
</td>
<td>
<div @click="updateApproved(follow, true)" :class="['ui', 'mini', 'icon', 'labeled', 'green', 'button']" v-if="follow.approved === null || follow.approved === false">
<i class="ui check icon"></i> <translate>Accept</translate>
</div>
<div @click="updateApproved(follow, false)" :class="['ui', 'mini', 'icon', 'labeled', 'red', 'button']" v-if="follow.approved === null || follow.approved === true">
<i class="ui x icon"></i> <translate>Reject</translate>
</div>
</td>
</tr>
</table>
<p v-else><translate>Nobody is following this library</translate></p>
</div>
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'tracks'}]">
<library-files-table :filters="{library: library.uuid}"></library-files-table>
</div>
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'edit'}]">
<library-form :library="library" @updated="libraryUpdated" @deleted="libraryDeleted" />
</div>
<h2><translate>Tracks</translate></h2>
<library-files-table :filters="{library: library.uuid}"></library-files-table>
</detail-area>
</div>
</template>
<script>
import axios from 'axios'
import DetailMixin from './DetailMixin'
import DetailArea from './DetailArea'
import LibraryForm from './Form'
@ -41,9 +83,14 @@ export default {
},
data () {
return {
hiddenForm: true
currentTab: 'follows',
isLoadingFollows: false,
follows: null
}
},
created () {
this.fetchFollows()
},
methods: {
libraryUpdated () {
this.hiddenForm = true
@ -53,6 +100,27 @@ export default {
this.$router.push({
name: 'content.libraries.index'
})
},
fetchFollows () {
let self = this
self.isLoadingLibrary = true
axios.get(`libraries/${this.id}/follows/`).then((response) => {
self.follows = response.data
self.isLoadingFollows = false
})
},
updateApproved (follow, value) {
let self = this
let action
if (value) {
action = 'accept'
} else {
action = 'reject'
}
axios.post(`federation/follows/library/${follow.uuid}/${action}/`).then((response) => {
follow.isLoading = false
follow.approved = value
})
}
}
}

View File

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

View File

@ -4,24 +4,6 @@
<div class="ui text loader"><translate>Loading library data...</translate></div>
</div>
<detail-area v-else :library="library">
<div slot="header">
<h2 class="ui header"><translate>Upload new tracks</translate></h2>
<div class="ui message">
<p><translate>You are about to upload music to your library. Before proceeding, please ensure that:</translate></p>
<ul>
<li v-if="library.privacy_level != 'me'">
You are not uploading copyrighted content in a public library, otherwise you may be infringing the law
</li>
<li>
<translate>The music files you are uploading are tagged properly:</translate>
<a href="http://picard.musicbrainz.org/" target='_blank'><translate>we recommend using Picard for that purpose</translate></a>
</li>
<li>
<translate>The uploaded music files are in OGG, Flac or MP3 format</translate>
</li>
</ul>
</div>
</div>
<file-upload :default-import-reference="defaultImportReference" :library="library" />
</detail-area>
</div>

View File

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

View File

@ -11,6 +11,12 @@
<div v-if="scanResult && scanResult.results.length > 0" class="ui two cards">
<library-card :library="library" v-for="library in scanResult.results" :key="library.fid" />
</div>
<template v-if="existingFollows && existingFollows.count > 0">
<h2><translate>Known libraries</translate></h2>
<div class="ui two cards">
<library-card :library="getLibraryFromFollow(follow)" v-for="follow in existingFollows.results" :key="follow.fid" />
</div>
</template>
</div>
</div>
</template>
@ -24,11 +30,12 @@ export default {
data () {
return {
isLoading: false,
scanResult: null
scanResult: null,
existingFollows: null
}
},
created () {
// this.fetch()
this.fetch()
},
components: {
ScanForm,
@ -38,13 +45,17 @@ export default {
fetch () {
this.isLoading = true
let self = this
axios.get('libraries/').then((response) => {
axios.get('federation/follows/library/', {params: {'page_size': 100, 'ordering': '-creation_date'}}).then((response) => {
self.existingFollows = response.data
self.isLoading = false
}, error => {
self.isLoading = false
self.libraries = response.data.results
if (self.libraries.length === 0) {
self.hiddenForm = false
}
})
},
getLibraryFromFollow (follow) {
let d = follow.target
d.follow = follow
return d
}
}
}