Commit 4e44e4e4 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Attribute artist

parent 8687a648
......@@ -2,7 +2,7 @@ import persisting_theory
from rest_framework import serializers
from django.db import models
from django.db import models, transaction
class ConfNotFound(KeyError):
......@@ -23,6 +23,7 @@ class Registry(persisting_theory.Registry):
return decorator
@transaction.atomic
def apply(self, type, obj, payload):
conf = self.get_conf(type, obj)
serializer = conf["serializer_class"](obj, data=payload)
......@@ -73,6 +74,9 @@ class MutationSerializer(serializers.Serializer):
def apply(self, obj, validated_data):
raise NotImplementedError()
def post_apply(self, obj, validated_data):
pass
def get_previous_state(self, obj, validated_data):
return
......@@ -88,8 +92,11 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer):
kwargs.setdefault("partial", True)
super().__init__(*args, **kwargs)
@transaction.atomic
def apply(self, obj, validated_data):
return self.update(obj, validated_data)
r = self.update(obj, validated_data)
self.post_apply(r, validated_data)
return r
def validate(self, validated_data):
if not validated_data:
......
......@@ -201,3 +201,30 @@ def concat_dicts(*dicts):
n.update(d)
return n
def get_updated_fields(conf, data, obj):
"""
Given a list of fields, a dict and an object, will return the dict keys/values
that differ from the corresponding fields on the object.
"""
final_conf = []
for c in conf:
if isinstance(c, str):
final_conf.append((c, c))
else:
final_conf.append(c)
final_data = {}
for data_field, obj_field in final_conf:
try:
data_value = data[data_field]
except KeyError:
continue
obj_value = getattr(obj, obj_field)
if obj_value != data_value:
final_data[obj_field] = data_value
return final_data
......@@ -2,6 +2,8 @@ import uuid
import factory
import persisting_theory
from django.conf import settings
from faker.providers import internet as internet_provider
......@@ -50,11 +52,11 @@ class FunkwhaleProvider(internet_provider.Provider):
not random enough
"""
def federation_url(self, prefix=""):
def federation_url(self, prefix="", local=False):
def path_generator():
return "{}/{}".format(prefix, uuid.uuid4())
domain = self.domain_name()
domain = settings.FEDERATION_HOSTNAME if local else self.domain_name()
protocol = "https"
path = path_generator()
return "{}://{}/{}".format(protocol, domain, path)
......
......@@ -365,27 +365,6 @@ class OutboxRouter(Router):
return activities
def recursive_getattr(obj, key, permissive=False):
"""
Given a dictionary such as {'user': {'name': 'Bob'}} and
a dotted string such as user.name, returns 'Bob'.
If the value is not present, returns None
"""
v = obj
for k in key.split("."):
try:
v = v.get(k)
except (TypeError, AttributeError):
if not permissive:
raise
return
if v is None:
return
return v
def match_route(route, payload):
for key, value in route.items():
payload_value = recursive_getattr(payload, key, permissive=True)
......@@ -432,6 +411,27 @@ def prepare_deliveries_and_inbox_items(recipient_list, type):
remote_inbox_urls.add(actor.shared_inbox_url or actor.inbox_url)
urls.append(r["target"].followers_url)
elif isinstance(r, dict) and r["type"] == "instances_with_followers":
# we want to broadcast the activity to other instances service actors
# when we have at least one follower from this instance
follows = (
models.LibraryFollow.objects.filter(approved=True)
.exclude(actor__domain_id=settings.FEDERATION_HOSTNAME)
.exclude(actor__domain=None)
.union(
models.Follow.objects.filter(approved=True)
.exclude(actor__domain_id=settings.FEDERATION_HOSTNAME)
.exclude(actor__domain=None)
)
)
actors = models.Actor.objects.filter(
managed_domains__name__in=follows.values_list(
"actor__domain_id", flat=True
)
)
values = actors.values("shared_inbox_url", "inbox_url")
for v in values:
remote_inbox_urls.add(v["shared_inbox_url"] or v["inbox_url"])
deliveries = [models.Delivery(inbox_url=url) for url in remote_inbox_urls]
inbox_items = [
models.InboxItem(actor=actor, type=type) for actor in local_recipients
......
......@@ -75,6 +75,15 @@ class DomainFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
model = "federation.Domain"
django_get_or_create = ("name",)
@factory.post_generation
def with_service_actor(self, create, extracted, **kwargs):
if not create or not extracted:
return
self.service_actor = ActorFactory(domain=self)
self.save(update_fields=["service_actor"])
return self.service_actor
@registry.register
class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
......
......@@ -57,7 +57,9 @@ def insert_context(ctx, doc):
existing = doc["@context"]
if isinstance(existing, list):
if ctx not in existing:
existing = existing[:]
existing.append(ctx)
doc["@context"] = existing
else:
doc["@context"] = [existing, ctx]
return doc
......@@ -215,6 +217,15 @@ def get_default_context():
return ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", {}]
def get_default_context_fw():
return [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{},
"https://funkwhale.audio/ns",
]
class JsonLdSerializer(serializers.Serializer):
def run_validation(self, data=empty):
if data and data is not empty and self.context.get("expand", True):
......
......@@ -264,6 +264,25 @@ class Actor(models.Model):
self.private_key = v[0].decode("utf-8")
self.public_key = v[1].decode("utf-8")
def can_manage(self, obj):
attributed_to = getattr(obj, "attributed_to_id", None)
if attributed_to is not None and attributed_to == self.pk:
# easiest case, the obj is attributed to the actor
return True
if self.domain.service_actor_id != self.pk:
# actor is not system actor, so there is no way the actor can manage
# the object
return False
# actor is service actor of its domain, so if the fid domain
# matches, we consider the actor has the permission to manage
# the object
domain = self.domain_id
return obj.fid.startswith("http://{}/".format(domain)) or obj.fid.startswith(
"https://{}/".format(domain)
)
class InboxItem(models.Model):
"""
......
......@@ -3,6 +3,7 @@ import logging
from funkwhale_api.music import models as music_models
from . import activity
from . import actors
from . import serializers
logger = logging.getLogger(__name__)
......@@ -269,3 +270,79 @@ def outbox_delete_audio(context):
serializer.data, to=[{"type": "followers", "target": library}]
),
}
def handle_library_entry_update(payload, context, queryset, serializer_class):
actor = context["actor"]
obj_id = payload["object"].get("id")
if not obj_id:
logger.debug("Discarding update of empty obj")
return
try:
obj = queryset.select_related("attributed_to").get(fid=obj_id)
except queryset.model.DoesNotExist:
logger.debug("Discarding update of unkwnown obj %s", obj_id)
return
if not actor.can_manage(obj):
logger.debug(
"Discarding unauthorize update of obj %s from %s", obj_id, actor.fid
)
return
serializer = serializer_class(obj, data=payload["object"])
if serializer.is_valid():
serializer.save()
else:
logger.debug(
"Discarding update of obj %s because of payload errors: %s",
obj_id,
serializer.errors,
)
@inbox.register({"type": "Update", "object.type": "Track"})
def inbox_update_track(payload, context):
return handle_library_entry_update(
payload,
context,
queryset=music_models.Track.objects.all(),
serializer_class=serializers.TrackSerializer,
)
@inbox.register({"type": "Update", "object.type": "Artist"})
def inbox_update_artist(payload, context):
return handle_library_entry_update(
payload,
context,
queryset=music_models.Artist.objects.all(),
serializer_class=serializers.ArtistSerializer,
)
@inbox.register({"type": "Update", "object.type": "Album"})
def inbox_update_album(payload, context):
return handle_library_entry_update(
payload,
context,
queryset=music_models.Album.objects.all(),
serializer_class=serializers.AlbumSerializer,
)
@outbox.register({"type": "Update", "object.type": "Track"})
def outbox_update_track(context):
track = context["track"]
serializer = serializers.ActivitySerializer(
{"type": "Update", "object": serializers.TrackSerializer(track).data}
)
yield {
"type": "Update",
"actor": actors.get_service_actor(),
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
......@@ -7,9 +7,11 @@ from django.core.paginator import Paginator
from rest_framework import serializers
from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.music import licenses
from funkwhale_api.music import models as music_models
from funkwhale_api.music import tasks as music_tasks
from . import activity, contexts, jsonld, models, utils
from . import activity, actors, contexts, jsonld, models, utils
AP_CONTEXT = jsonld.get_default_context()
......@@ -670,7 +672,7 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer):
"first": jsonld.first_id(contexts.AS.first),
"last": jsonld.first_id(contexts.AS.last),
"next": jsonld.first_id(contexts.AS.next),
"prev": jsonld.first_id(contexts.AS.next),
"prev": jsonld.first_id(contexts.AS.prev),
"partOf": jsonld.first_id(contexts.AS.partOf),
}
......@@ -731,6 +733,7 @@ MUSIC_ENTITY_JSONLD_MAPPING = {
"name": jsonld.first_val(contexts.AS.name),
"published": jsonld.first_val(contexts.AS.published),
"musicbrainzId": jsonld.first_val(contexts.FW.musicbrainzId),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
}
......@@ -739,9 +742,29 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
published = serializers.DateTimeField()
musicbrainzId = serializers.UUIDField(allow_null=True, required=False)
name = serializers.CharField(max_length=1000)
attributedTo = serializers.URLField(max_length=500, allow_null=True, required=False)
updateable_fields = []
def update(self, instance, validated_data):
attributed_to_fid = validated_data.get("attributedTo")
if attributed_to_fid:
validated_data["attributedTo"] = actors.get_actor(attributed_to_fid)
updated_fields = funkwhale_utils.get_updated_fields(
self.updateable_fields, validated_data, instance
)
if updated_fields:
return music_tasks.update_library_entity(instance, updated_fields)
return instance
class ArtistSerializer(MusicEntitySerializer):
updateable_fields = [
("name", "name"),
("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"),
]
class Meta:
jsonld_mapping = MUSIC_ENTITY_JSONLD_MAPPING
......@@ -752,6 +775,9 @@ class ArtistSerializer(MusicEntitySerializer):
"name": instance.name,
"published": instance.creation_date.isoformat(),
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
"attributedTo": instance.attributed_to.fid
if instance.attributed_to
else None,
}
if self.context.get("include_ap_context", self.parent is None):
......@@ -765,6 +791,12 @@ class AlbumSerializer(MusicEntitySerializer):
cover = LinkSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False
)
updateable_fields = [
("name", "title"),
("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"),
("released", "release_date"),
]
class Meta:
jsonld_mapping = funkwhale_utils.concat_dicts(
......@@ -791,6 +823,9 @@ class AlbumSerializer(MusicEntitySerializer):
instance.artist, context={"include_ap_context": False}
).data
],
"attributedTo": instance.attributed_to.fid
if instance.attributed_to
else None,
}
if instance.cover:
d["cover"] = {
......@@ -812,6 +847,16 @@ class TrackSerializer(MusicEntitySerializer):
license = serializers.URLField(allow_null=True, required=False)
copyright = serializers.CharField(allow_null=True, required=False)
updateable_fields = [
("name", "title"),
("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"),
("disc", "disc_number"),
("position", "position"),
("copyright", "copyright"),
("license", "license"),
]
class Meta:
jsonld_mapping = funkwhale_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING,
......@@ -846,6 +891,9 @@ class TrackSerializer(MusicEntitySerializer):
"album": AlbumSerializer(
instance.album, context={"include_ap_context": False}
).data,
"attributedTo": instance.attributed_to.fid
if instance.attributed_to
else None,
}
if self.context.get("include_ap_context", self.parent is None):
......@@ -855,13 +903,53 @@ class TrackSerializer(MusicEntitySerializer):
def create(self, validated_data):
from funkwhale_api.music import tasks as music_tasks
metadata = music_tasks.federation_audio_track_to_metadata(validated_data)
references = {}
actors_to_fetch = set()
actors_to_fetch.add(
funkwhale_utils.recursive_getattr(
validated_data, "attributedTo", permissive=True
)
)
actors_to_fetch.add(
funkwhale_utils.recursive_getattr(
validated_data, "album.attributedTo", permissive=True
)
)
artists = (
funkwhale_utils.recursive_getattr(
validated_data, "artists", permissive=True
)
or []
)
album_artists = (
funkwhale_utils.recursive_getattr(
validated_data, "album.artists", permissive=True
)
or []
)
for artist in artists + album_artists:
actors_to_fetch.add(artist.get("attributedTo"))
for url in actors_to_fetch:
if not url:
continue
references[url] = actors.get_actor(url)
metadata = music_tasks.federation_audio_track_to_metadata(
validated_data, references
)
from_activity = self.context.get("activity")
if from_activity:
metadata["from_activity_id"] = from_activity.pk
track = music_tasks.get_track_from_import_metadata(metadata, update_cover=True)
return track
def update(self, obj, validated_data):
if validated_data.get("license"):
validated_data["license"] = licenses.match(validated_data["license"])
return super().update(obj, validated_data)
class UploadSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Audio])
......
......@@ -64,6 +64,12 @@ class ArtistFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
class Meta:
model = "music.Artist"
class Params:
attributed = factory.Trait(
attributed_to=factory.SubFactory(federation_factories.ActorFactory)
)
local = factory.Trait(fid=factory.Faker("federation_url", local=True))
@registry.register
class AlbumFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
......@@ -79,6 +85,15 @@ class AlbumFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
class Meta:
model = "music.Album"
class Params:
attributed = factory.Trait(
attributed_to=factory.SubFactory(federation_factories.ActorFactory)
)
local = factory.Trait(
fid=factory.Faker("federation_url", local=True), artist__local=True
)
@registry.register
class TrackFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
......@@ -94,6 +109,15 @@ class TrackFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
class Meta:
model = "music.Track"
class Params:
attributed = factory.Trait(
attributed_to=factory.SubFactory(federation_factories.ActorFactory)
)
local = factory.Trait(
fid=factory.Faker("federation_url", local=True), album__local=True
)
@factory.post_generation
def license(self, created, extracted, **kwargs):
if not created:
......
# Generated by Django 2.1.7 on 2019-04-09 09:33
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("federation", "0017_auto_20190130_0926"),
("music", "0037_auto_20190103_1757"),
]
operations = [
migrations.AddField(
model_name="artist",
name="attributed_to",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="attributed_artists",
to="federation.Actor",
),
),
migrations.AddField(
model_name="album",
name="attributed_to",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="attributed_albums",
to="federation.Actor",
),
),
migrations.AddField(
model_name="track",
name="attributed_to",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="attributed_tracks",
to="federation.Actor",
),
),
]
......@@ -114,6 +114,16 @@ class APIModelMixin(models.Model):
return super().save(**kwargs)
@property
def is_local(self):
if not self.fid:
return True
d = settings.FEDERATION_HOSTNAME
return self.fid.startswith("http://{}/".format(d)) or self.fid.startswith(
"https://{}/".format(d)
)
class License(models.Model):
code = models.CharField(primary_key=True, max_length=100)
......@@ -178,6 +188,16 @@ class Artist(APIModelMixin):
"mbid": {"musicbrainz_field_name": "id"},
"name": {"musicbrainz_field_name": "name"},
}
# Music entities are attributed to actors, to validate that updates occur
# from an authorized account. On top of that, we consider the instance actor
# can update anything under it's own domain
attributed_to = models.ForeignKey(
"federation.Actor",
null=True,
blank=True,