Broadcast library updates (name, description, visibility) over federation
This commit is contained in:
parent
51457aa889
commit
4976981926
|
@ -159,3 +159,34 @@ class ActionSerializer(serializers.Serializer):
|
||||||
"result": result,
|
"result": result,
|
||||||
}
|
}
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def track_fields_for_update(*fields):
|
||||||
|
"""
|
||||||
|
Apply this decorator to serializer to call function when specific values
|
||||||
|
are updated on an object:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
@track_fields_for_update('privacy_level')
|
||||||
|
class LibrarySerializer(serializers.ModelSerializer):
|
||||||
|
def on_updated_privacy_level(self, obj, old_value, new_value):
|
||||||
|
print('Do someting')
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(serializer_class):
|
||||||
|
original_update = serializer_class.update
|
||||||
|
|
||||||
|
def new_update(self, obj, validated_data):
|
||||||
|
tracked_fields_before = {f: getattr(obj, f) for f in fields}
|
||||||
|
obj = original_update(self, obj, validated_data)
|
||||||
|
tracked_fields_after = {f: getattr(obj, f) for f in fields}
|
||||||
|
|
||||||
|
if tracked_fields_before != tracked_fields_after:
|
||||||
|
self.on_updated_fields(obj, tracked_fields_before, tracked_fields_after)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
serializer_class.update = new_update
|
||||||
|
return serializer_class
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
|
@ -195,6 +195,45 @@ def outbox_delete_library(context):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@outbox.register({"type": "Update", "object.type": "Library"})
|
||||||
|
def outbox_update_library(context):
|
||||||
|
library = context["library"]
|
||||||
|
serializer = serializers.ActivitySerializer(
|
||||||
|
{"type": "Update", "object": serializers.LibrarySerializer(library).data}
|
||||||
|
)
|
||||||
|
|
||||||
|
yield {
|
||||||
|
"type": "Update",
|
||||||
|
"actor": library.actor,
|
||||||
|
"payload": with_recipients(
|
||||||
|
serializer.data, to=[{"type": "followers", "target": library}]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@inbox.register({"type": "Update", "object.type": "Library"})
|
||||||
|
def inbox_update_library(payload, context):
|
||||||
|
actor = context["actor"]
|
||||||
|
library_id = payload["object"].get("id")
|
||||||
|
if not library_id:
|
||||||
|
logger.debug("Discarding deletion of empty library")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not actor.libraries.filter(fid=library_id).exists():
|
||||||
|
logger.debug("Discarding deletion of unkwnown library %s", library_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
serializer = serializers.LibrarySerializer(data=payload["object"])
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Discarding update of library %s because of payload errors: %s",
|
||||||
|
library_id,
|
||||||
|
serializer.errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@inbox.register({"type": "Delete", "object.type": "Audio"})
|
@inbox.register({"type": "Delete", "object.type": "Audio"})
|
||||||
def inbox_delete_audio(payload, context):
|
def inbox_delete_audio(payload, context):
|
||||||
actor = context["actor"]
|
actor = context["actor"]
|
||||||
|
|
|
@ -85,7 +85,7 @@ def verify_django(django_request, public_key):
|
||||||
def get_auth(private_key, private_key_id):
|
def get_auth(private_key, private_key_id):
|
||||||
return requests_http_signature.HTTPSignatureAuth(
|
return requests_http_signature.HTTPSignatureAuth(
|
||||||
use_auth_header=False,
|
use_auth_header=False,
|
||||||
headers=["(request-target)", "user-agent", "host", "date", "content-type"],
|
headers=["(request-target)", "user-agent", "host", "date"],
|
||||||
algorithm="rsa-sha256",
|
algorithm="rsa-sha256",
|
||||||
key=private_key.encode("utf-8"),
|
key=private_key.encode("utf-8"),
|
||||||
key_id=private_key_id,
|
key_id=private_key_id,
|
||||||
|
|
|
@ -192,6 +192,7 @@ class TrackSerializer(serializers.ModelSerializer):
|
||||||
return TrackUploadSerializer(uploads, many=True).data
|
return TrackUploadSerializer(uploads, many=True).data
|
||||||
|
|
||||||
|
|
||||||
|
@common_serializers.track_fields_for_update("name", "description", "privacy_level")
|
||||||
class LibraryForOwnerSerializer(serializers.ModelSerializer):
|
class LibraryForOwnerSerializer(serializers.ModelSerializer):
|
||||||
uploads_count = serializers.SerializerMethodField()
|
uploads_count = serializers.SerializerMethodField()
|
||||||
size = serializers.SerializerMethodField()
|
size = serializers.SerializerMethodField()
|
||||||
|
@ -216,6 +217,11 @@ class LibraryForOwnerSerializer(serializers.ModelSerializer):
|
||||||
def get_size(self, o):
|
def get_size(self, o):
|
||||||
return getattr(o, "_size", 0)
|
return getattr(o, "_size", 0)
|
||||||
|
|
||||||
|
def on_updated_fields(self, obj, before, after):
|
||||||
|
routes.outbox.dispatch(
|
||||||
|
{"type": "Update", "object": {"type": "Library"}}, context={"library": obj}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UploadSerializer(serializers.ModelSerializer):
|
class UploadSerializer(serializers.ModelSerializer):
|
||||||
track = TrackSerializer(required=False, allow_null=True)
|
track = TrackSerializer(required=False, allow_null=True)
|
||||||
|
|
|
@ -134,3 +134,32 @@ def test_action_serializers_can_require_filter(factories):
|
||||||
|
|
||||||
assert serializer.is_valid(raise_exception=True) is True
|
assert serializer.is_valid(raise_exception=True) is True
|
||||||
assert list(serializer.validated_data["objects"]) == [user1]
|
assert list(serializer.validated_data["objects"]) == [user1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_track_fields_for_update(mocker):
|
||||||
|
@serializers.track_fields_for_update("field1", "field2")
|
||||||
|
class S(serializers.serializers.Serializer):
|
||||||
|
field1 = serializers.serializers.CharField()
|
||||||
|
field2 = serializers.serializers.CharField()
|
||||||
|
|
||||||
|
def update(self, obj, validated_data):
|
||||||
|
for key, value in validated_data.items():
|
||||||
|
setattr(obj, key, value)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
on_updated_fields = mocker.stub()
|
||||||
|
|
||||||
|
class Obj(object):
|
||||||
|
field1 = "value1"
|
||||||
|
field2 = "value2"
|
||||||
|
|
||||||
|
obj = Obj()
|
||||||
|
serializer = S(obj, data={"field1": "newvalue1", "field2": "newvalue2"})
|
||||||
|
assert serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
serializer.on_updated_fields.assert_called_once_with(
|
||||||
|
obj,
|
||||||
|
{"field1": "value1", "field2": "value2"},
|
||||||
|
{"field1": "newvalue1", "field2": "newvalue2"},
|
||||||
|
)
|
||||||
|
|
|
@ -9,6 +9,7 @@ from funkwhale_api.federation import routes, serializers
|
||||||
({"type": "Follow"}, routes.inbox_follow),
|
({"type": "Follow"}, routes.inbox_follow),
|
||||||
({"type": "Accept"}, routes.inbox_accept),
|
({"type": "Accept"}, routes.inbox_accept),
|
||||||
({"type": "Create", "object.type": "Audio"}, routes.inbox_create_audio),
|
({"type": "Create", "object.type": "Audio"}, routes.inbox_create_audio),
|
||||||
|
({"type": "Update", "object.type": "Library"}, routes.inbox_update_library),
|
||||||
({"type": "Delete", "object.type": "Library"}, routes.inbox_delete_library),
|
({"type": "Delete", "object.type": "Library"}, routes.inbox_delete_library),
|
||||||
({"type": "Delete", "object.type": "Audio"}, routes.inbox_delete_audio),
|
({"type": "Delete", "object.type": "Audio"}, routes.inbox_delete_audio),
|
||||||
({"type": "Undo", "object.type": "Follow"}, routes.inbox_undo_follow),
|
({"type": "Undo", "object.type": "Follow"}, routes.inbox_undo_follow),
|
||||||
|
@ -29,6 +30,7 @@ def test_inbox_routes(route, handler):
|
||||||
({"type": "Accept"}, routes.outbox_accept),
|
({"type": "Accept"}, routes.outbox_accept),
|
||||||
({"type": "Follow"}, routes.outbox_follow),
|
({"type": "Follow"}, routes.outbox_follow),
|
||||||
({"type": "Create", "object.type": "Audio"}, routes.outbox_create_audio),
|
({"type": "Create", "object.type": "Audio"}, routes.outbox_create_audio),
|
||||||
|
({"type": "Update", "object.type": "Library"}, routes.outbox_update_library),
|
||||||
({"type": "Delete", "object.type": "Library"}, routes.outbox_delete_library),
|
({"type": "Delete", "object.type": "Library"}, routes.outbox_delete_library),
|
||||||
({"type": "Delete", "object.type": "Audio"}, routes.outbox_delete_audio),
|
({"type": "Delete", "object.type": "Audio"}, routes.outbox_delete_audio),
|
||||||
({"type": "Undo", "object.type": "Follow"}, routes.outbox_undo_follow),
|
({"type": "Undo", "object.type": "Follow"}, routes.outbox_undo_follow),
|
||||||
|
@ -262,6 +264,55 @@ def test_outbox_delete_library(factories):
|
||||||
assert activity["actor"] == library.actor
|
assert activity["actor"] == library.actor
|
||||||
|
|
||||||
|
|
||||||
|
def test_outbox_update_library(factories):
|
||||||
|
library = factories["music.Library"]()
|
||||||
|
activity = list(routes.outbox_update_library({"library": library}))[0]
|
||||||
|
expected = serializers.ActivitySerializer(
|
||||||
|
{"type": "Update", "object": serializers.LibrarySerializer(library).data}
|
||||||
|
).data
|
||||||
|
|
||||||
|
expected["to"] = [{"type": "followers", "target": library}]
|
||||||
|
|
||||||
|
assert dict(activity["payload"]) == dict(expected)
|
||||||
|
assert activity["actor"] == library.actor
|
||||||
|
|
||||||
|
|
||||||
|
def test_inbox_update_library(factories):
|
||||||
|
activity = factories["federation.Activity"]()
|
||||||
|
|
||||||
|
library = factories["music.Library"]()
|
||||||
|
data = serializers.LibrarySerializer(library).data
|
||||||
|
data["name"] = "New name"
|
||||||
|
payload = {"type": "Update", "actor": library.actor.fid, "object": data}
|
||||||
|
|
||||||
|
routes.inbox_update_library(
|
||||||
|
payload,
|
||||||
|
context={"actor": library.actor, "raise_exception": True, "activity": activity},
|
||||||
|
)
|
||||||
|
|
||||||
|
library.refresh_from_db()
|
||||||
|
assert library.name == "New name"
|
||||||
|
|
||||||
|
|
||||||
|
# def test_inbox_update_library_impostor(factories):
|
||||||
|
# activity = factories["federation.Activity"]()
|
||||||
|
# impostor = factories["federation.Actor"]()
|
||||||
|
# library = factories["music.Library"]()
|
||||||
|
# payload = {
|
||||||
|
# "type": "Delete",
|
||||||
|
# "actor": library.actor.fid,
|
||||||
|
# "object": {"type": "Library", "id": library.fid},
|
||||||
|
# }
|
||||||
|
|
||||||
|
# routes.inbox_update_library(
|
||||||
|
# payload,
|
||||||
|
# context={"actor": impostor, "raise_exception": True, "activity": activity},
|
||||||
|
# )
|
||||||
|
|
||||||
|
# # not deleted, should still be here
|
||||||
|
# library.refresh_from_db()
|
||||||
|
|
||||||
|
|
||||||
def test_inbox_delete_audio(factories):
|
def test_inbox_delete_audio(factories):
|
||||||
activity = factories["federation.Activity"]()
|
activity = factories["federation.Activity"]()
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
from funkwhale_api.music import models
|
from funkwhale_api.music import models
|
||||||
from funkwhale_api.music import serializers
|
from funkwhale_api.music import serializers
|
||||||
from funkwhale_api.music import tasks
|
from funkwhale_api.music import tasks
|
||||||
|
@ -274,3 +276,28 @@ def test_track_upload_serializer(factories):
|
||||||
|
|
||||||
serializer = serializers.TrackUploadSerializer(upload)
|
serializer = serializers.TrackUploadSerializer(upload)
|
||||||
assert serializer.data == expected
|
assert serializer.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"field,before,after",
|
||||||
|
[
|
||||||
|
("privacy_level", "me", "everyone"),
|
||||||
|
("name", "Before", "After"),
|
||||||
|
("description", "Before", "After"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_update_library_privacy_level_broadcasts_to_followers(
|
||||||
|
factories, field, before, after, mocker
|
||||||
|
):
|
||||||
|
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
|
||||||
|
library = factories["music.Library"](**{field: before})
|
||||||
|
|
||||||
|
serializer = serializers.LibraryForOwnerSerializer(
|
||||||
|
library, data={field: after}, partial=True
|
||||||
|
)
|
||||||
|
assert serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
dispatch.assert_called_once_with(
|
||||||
|
{"type": "Update", "object": {"type": "Library"}}, context={"library": library}
|
||||||
|
)
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Broadcast library updates (name, description, visibility) over federation
|
Loading…
Reference in New Issue