Merge branch '170-track-description' into 'develop'

See #170: add a description field on tracks, albums, tracks

See merge request funkwhale/funkwhale!993
This commit is contained in:
Eliot Berriot 2020-01-14 14:00:09 +01:00
commit e6f8b6e406
38 changed files with 653 additions and 59 deletions

View File

@ -26,3 +26,12 @@ class AttachmentFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
class Meta:
model = "common.Attachment"
@registry.register
class CommonFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
text = factory.Faker("paragraph")
content_type = "text/plain"
class Meta:
model = "common.Content"

View File

@ -0,0 +1,21 @@
# Generated by Django 2.2.7 on 2020-01-13 10:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0005_auto_20191125_1421'),
]
operations = [
migrations.CreateModel(
name='Content',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.CharField(blank=True, max_length=5000, null=True)),
('content_type', models.CharField(max_length=100)),
],
),
]

View File

@ -24,6 +24,14 @@ from . import utils
from . import validators
CONTENT_TEXT_MAX_LENGTH = 5000
CONTENT_TEXT_SUPPORTED_TYPES = [
"text/html",
"text/markdown",
"text/plain",
]
@Field.register_lookup
class NotEqual(Lookup):
lookup_name = "ne"
@ -273,6 +281,15 @@ class MutationAttachment(models.Model):
unique_together = ("attachment", "mutation")
class Content(models.Model):
"""
A text content that can be associated to other models, like a description, a summary, etc.
"""
text = models.CharField(max_length=CONTENT_TEXT_MAX_LENGTH, blank=True, null=True)
content_type = models.CharField(max_length=100)
@receiver(models.signals.post_save, sender=Attachment)
def warm_attachment_thumbnails(sender, instance, **kwargs):
if not instance.file or not settings.CREATE_IMAGE_THUMBNAILS:
@ -302,3 +319,18 @@ def trigger_mutation_post_init(sender, instance, created, **kwargs):
except AttributeError:
return
handler(instance)
CONTENT_FKS = {
"music.Track": ["description"],
"music.Album": ["description"],
"music.Artist": ["description"],
}
@receiver(models.signals.post_delete, sender=None)
def remove_attached_content(sender, instance, **kwargs):
fk_fields = CONTENT_FKS.get(instance._meta.label, [])
for field in fk_fields:
if getattr(instance, "{}_id".format(field)):
getattr(instance, field).delete()

View File

@ -86,7 +86,6 @@ class MutationSerializer(serializers.Serializer):
class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer):
serialized_relations = {}
previous_state_handlers = {}
def __init__(self, *args, **kwargs):
# we force partial mode, because update mutations are partial
@ -141,9 +140,12 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer):
obj,
*list(validated_data.keys()),
serialized_relations=self.serialized_relations,
handlers=self.previous_state_handlers,
handlers=self.get_previous_state_handlers(),
)
def get_previous_state_handlers(self):
return {}
def get_update_previous_state(obj, *fields, serialized_relations={}, handlers={}):
if not fields:

View File

@ -11,6 +11,7 @@ from django.utils.encoding import smart_text
from django.utils.translation import ugettext_lazy as _
from . import models
from . import utils
class RelatedField(serializers.RelatedField):
@ -308,3 +309,12 @@ class AttachmentSerializer(serializers.Serializer):
return models.Attachment.objects.create(
file=validated_data["file"], actor=validated_data["actor"]
)
class ContentSerializer(serializers.Serializer):
text = serializers.CharField(max_length=models.CONTENT_TEXT_MAX_LENGTH)
content_type = serializers.ChoiceField(choices=models.CONTENT_TEXT_SUPPORTED_TYPES,)
html = serializers.SerializerMethodField()
def get_html(self, o):
return utils.render_html(o.text, o.content_type)

View File

@ -1,5 +1,7 @@
from django.utils.deconstruct import deconstructible
import bleach.sanitizer
import markdown
import os
import shutil
import uuid
@ -241,3 +243,65 @@ def join_queries_or(left, right):
return left | right
else:
return right
def render_markdown(text):
return markdown.markdown(text, extensions=["nl2br"])
HTMl_CLEANER = bleach.sanitizer.Cleaner(
strip=True,
tags=[
"p",
"a",
"abbr",
"acronym",
"b",
"blockquote",
"code",
"em",
"i",
"li",
"ol",
"strong",
"ul",
],
)
HTML_LINKER = bleach.linkifier.Linker()
def clean_html(html):
return HTMl_CLEANER.clean(html)
def render_html(text, content_type):
rendered = render_markdown(text)
if content_type == "text/html":
rendered = text
elif content_type == "text/markdown":
rendered = render_markdown(text)
else:
rendered = render_markdown(text)
rendered = HTML_LINKER.linkify(rendered)
return clean_html(rendered).strip().replace("\n", "")
@transaction.atomic
def attach_content(obj, field, content_data):
from . import models
existing = getattr(obj, "{}_id".format(field))
if existing:
getattr(obj, field).delete()
if not content_data:
return
content_obj = models.Content.objects.create(
text=content_data["text"][: models.CONTENT_TEXT_MAX_LENGTH],
content_type=content_data["content_type"],
)
setattr(obj, field, content_obj)
obj.save(update_fields=[field])

View File

@ -9,7 +9,7 @@ from django.db import transaction
from rest_framework import serializers
from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import models as common_models
from funkwhale_api.music import licenses
from funkwhale_api.music import models as music_models
@ -611,9 +611,9 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
def to_representation(self, conf):
paginator = Paginator(conf["items"], conf.get("page_size", 20))
first = funkwhale_utils.set_query_parameter(conf["id"], page=1)
first = common_utils.set_query_parameter(conf["id"], page=1)
current = first
last = funkwhale_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
d = {
"id": conf["id"],
# XXX Stable release: remove the obsolete actor field
@ -646,7 +646,7 @@ class LibrarySerializer(PaginatedCollectionSerializer):
)
class Meta:
jsonld_mapping = funkwhale_utils.concat_dicts(
jsonld_mapping = common_utils.concat_dicts(
PAGINATED_COLLECTION_JSONLD_MAPPING,
{
"name": jsonld.first_val(contexts.AS.name),
@ -740,11 +740,11 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer):
def to_representation(self, conf):
page = conf["page"]
first = funkwhale_utils.set_query_parameter(conf["id"], page=1)
last = funkwhale_utils.set_query_parameter(
first = common_utils.set_query_parameter(conf["id"], page=1)
last = common_utils.set_query_parameter(
conf["id"], page=page.paginator.num_pages
)
id = funkwhale_utils.set_query_parameter(conf["id"], page=page.number)
id = common_utils.set_query_parameter(conf["id"], page=page.number)
d = {
"id": id,
"partOf": conf["id"],
@ -764,12 +764,12 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer):
}
if page.has_previous():
d["prev"] = funkwhale_utils.set_query_parameter(
d["prev"] = common_utils.set_query_parameter(
conf["id"], page=page.previous_page_number()
)
if page.has_next():
d["next"] = funkwhale_utils.set_query_parameter(
d["next"] = common_utils.set_query_parameter(
conf["id"], page=page.next_page_number()
)
d.update(get_additional_fields(conf))
@ -784,6 +784,8 @@ MUSIC_ENTITY_JSONLD_MAPPING = {
"musicbrainzId": jsonld.first_val(contexts.FW.musicbrainzId),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
"tags": jsonld.raw(contexts.AS.tag),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
"content": jsonld.first_val(contexts.AS.content),
}
@ -805,6 +807,28 @@ def repr_tag(tag_name):
return {"type": "Hashtag", "name": "#{}".format(tag_name)}
def include_content(repr, content_obj):
if not content_obj:
return
repr["content"] = common_utils.render_html(
content_obj.text, content_obj.content_type
)
repr["mediaType"] = "text/html"
class TruncatedCharField(serializers.CharField):
def __init__(self, *args, **kwargs):
self.truncate_length = kwargs.pop("truncate_length")
super().__init__(*args, **kwargs)
def to_internal_value(self, v):
v = super().to_internal_value(v)
if v:
v = v[: self.truncate_length]
return v
class MusicEntitySerializer(jsonld.JsonLdSerializer):
id = serializers.URLField(max_length=500)
published = serializers.DateTimeField()
@ -815,13 +839,23 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
tags = serializers.ListField(
child=TagSerializer(), min_length=0, required=False, allow_null=True
)
mediaType = serializers.ChoiceField(
choices=common_models.CONTENT_TEXT_SUPPORTED_TYPES,
default="text/html",
required=False,
)
content = TruncatedCharField(
truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
required=False,
allow_null=True,
)
@transaction.atomic
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(
updated_fields = common_utils.get_updated_fields(
self.updateable_fields, validated_data, instance
)
updated_fields = self.validate_updated_data(instance, updated_fields)
@ -831,6 +865,9 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(instance, *tags)
common_utils.attach_content(
instance, "description", validated_data.get("description")
)
return instance
def get_tags_repr(self, instance):
@ -842,6 +879,15 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
def validate_updated_data(self, instance, validated_data):
return validated_data
def validate(self, data):
validated_data = super().validate(data)
if data.get("content"):
validated_data["description"] = {
"content_type": data["mediaType"],
"text": data["content"],
}
return validated_data
class ArtistSerializer(MusicEntitySerializer):
updateable_fields = [
@ -866,7 +912,7 @@ class ArtistSerializer(MusicEntitySerializer):
else None,
"tag": self.get_tags_repr(instance),
}
include_content(d, instance.description)
if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context()
return d
@ -888,7 +934,7 @@ class AlbumSerializer(MusicEntitySerializer):
class Meta:
model = music_models.Album
jsonld_mapping = funkwhale_utils.concat_dicts(
jsonld_mapping = common_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING,
{
"released": jsonld.first_val(contexts.FW.released),
@ -917,6 +963,7 @@ class AlbumSerializer(MusicEntitySerializer):
else None,
"tag": self.get_tags_repr(instance),
}
include_content(d, instance.description)
if instance.attachment_cover:
d["cover"] = {
"type": "Link",
@ -968,7 +1015,7 @@ class TrackSerializer(MusicEntitySerializer):
class Meta:
model = music_models.Track
jsonld_mapping = funkwhale_utils.concat_dicts(
jsonld_mapping = common_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING,
{
"album": jsonld.first_obj(contexts.FW.album),
@ -1006,7 +1053,7 @@ class TrackSerializer(MusicEntitySerializer):
else None,
"tag": self.get_tags_repr(instance),
}
include_content(d, instance.description)
if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context()
return d
@ -1017,23 +1064,21 @@ class TrackSerializer(MusicEntitySerializer):
references = {}
actors_to_fetch = set()
actors_to_fetch.add(
funkwhale_utils.recursive_getattr(
common_utils.recursive_getattr(
validated_data, "attributedTo", permissive=True
)
)
actors_to_fetch.add(
funkwhale_utils.recursive_getattr(
common_utils.recursive_getattr(
validated_data, "album.attributedTo", permissive=True
)
)
artists = (
funkwhale_utils.recursive_getattr(
validated_data, "artists", permissive=True
)
common_utils.recursive_getattr(validated_data, "artists", permissive=True)
or []
)
album_artists = (
funkwhale_utils.recursive_getattr(
common_utils.recursive_getattr(
validated_data, "album.artists", permissive=True
)
or []
@ -1244,6 +1289,7 @@ class ChannelUploadSerializer(serializers.Serializer):
},
],
}
include_content(data, upload.track.description)
tags = [item.tag.name for item in upload.get_all_tagged_items()]
if tags:
data["tag"] = [repr_tag(name) for name in tags]

View File

@ -225,11 +225,14 @@ class MusicLibraryViewSet(
"album__attributed_to",
"attributed_to",
"album__attachment_cover",
"description",
).prefetch_related(
"tagged_items__tag",
"album__tagged_items__tag",
"album__artist__tagged_items__tag",
"artist__tagged_items__tag",
"artist__description",
"album__description",
),
)
),
@ -278,6 +281,7 @@ class MusicUploadViewSet(
"library__actor",
"track__artist",
"track__album__artist",
"track__description",
"track__album__attachment_cover",
)
serializer_class = serializers.UploadSerializer
@ -299,7 +303,7 @@ class MusicArtistViewSet(
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Artist.objects.local()
queryset = music_models.Artist.objects.local().select_related("description")
serializer_class = serializers.ArtistSerializer
lookup_field = "uuid"
@ -309,7 +313,9 @@ class MusicAlbumViewSet(
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Album.objects.local().select_related("artist")
queryset = music_models.Album.objects.local().select_related(
"artist__description", "description"
)
serializer_class = serializers.AlbumSerializer
lookup_field = "uuid"
@ -320,7 +326,7 @@ class MusicTrackViewSet(
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Track.objects.local().select_related(
"album__artist", "artist"
"album__artist", "album__description", "artist__description", "description"
)
serializer_class = serializers.TrackSerializer
lookup_field = "uuid"

View File

@ -383,7 +383,9 @@ class ManageNestedAlbumSerializer(ManageBaseAlbumSerializer):
return getattr(obj, "tracks_count", None)
class ManageArtistSerializer(ManageBaseArtistSerializer):
class ManageArtistSerializer(
music_serializers.OptionalDescriptionMixin, ManageBaseArtistSerializer
):
albums = ManageNestedAlbumSerializer(many=True)
tracks = ManageNestedTrackSerializer(many=True)
attributed_to = ManageBaseActorSerializer()
@ -407,7 +409,9 @@ class ManageNestedArtistSerializer(ManageBaseArtistSerializer):
pass
class ManageAlbumSerializer(ManageBaseAlbumSerializer):
class ManageAlbumSerializer(
music_serializers.OptionalDescriptionMixin, ManageBaseAlbumSerializer
):
tracks = ManageNestedTrackSerializer(many=True)
attributed_to = ManageBaseActorSerializer()
artist = ManageNestedArtistSerializer()
@ -435,7 +439,9 @@ class ManageTrackAlbumSerializer(ManageBaseAlbumSerializer):
fields = ManageBaseAlbumSerializer.Meta.fields + ["artist"]
class ManageTrackSerializer(ManageNestedTrackSerializer):
class ManageTrackSerializer(
music_serializers.OptionalDescriptionMixin, ManageNestedTrackSerializer
):
artist = ManageNestedArtistSerializer()
album = ManageTrackAlbumSerializer()
attributed_to = ManageBaseActorSerializer()

View File

@ -100,6 +100,11 @@ class ManageArtistViewSet(
result = serializer.save()
return response.Response(result, status=200)
def get_serializer_context(self):
context = super().get_serializer_context()
context["description"] = self.action in ["retrieve", "create", "update"]
return context
class ManageAlbumViewSet(
mixins.ListModelMixin,
@ -134,6 +139,11 @@ class ManageAlbumViewSet(
result = serializer.save()
return response.Response(result, status=200)
def get_serializer_context(self):
context = super().get_serializer_context()
context["description"] = self.action in ["retrieve", "create", "update"]
return context
uploads_subquery = (
music_models.Upload.objects.filter(track_id=OuterRef("pk"))
@ -186,6 +196,11 @@ class ManageTrackViewSet(
result = serializer.save()
return response.Response(result, status=200)
def get_serializer_context(self):
context = super().get_serializer_context()
context["description"] = self.action in ["retrieve", "create", "update"]
return context
uploads_subquery = (
music_models.Upload.objects.filter(library_id=OuterRef("pk"))

View File

@ -168,6 +168,17 @@ def get_mp3_recording_id(f, k):
raise TagNotFound(k)
def get_mp3_comment(f, k):
keys_to_try = ["COMM", "COMM::eng"]
for key in keys_to_try:
try:
return get_id3_tag(f, key)
except TagNotFound:
pass
raise TagNotFound("COMM")
VALIDATION = {}
CONF = {
@ -192,6 +203,7 @@ CONF = {
"field": "metadata_block_picture",
"to_application": clean_ogg_pictures,
},
"comment": {"field": "comment"},
},
},
"OggVorbis": {
@ -215,6 +227,7 @@ CONF = {
"field": "metadata_block_picture",
"to_application": clean_ogg_pictures,
},
"comment": {"field": "comment"},
},
},
"OggTheora": {
@ -234,6 +247,7 @@ CONF = {
"license": {},
"copyright": {},
"genre": {},
"comment": {"field": "comment"},
},
},
"MP3": {
@ -255,6 +269,7 @@ CONF = {
"pictures": {},
"license": {"field": "WCOP"},
"copyright": {"field": "TCOP"},
"comment": {"field": "COMM", "getter": get_mp3_comment},
},
},
"MP4": {
@ -282,6 +297,7 @@ CONF = {
"pictures": {},
"license": {"field": "----:com.apple.iTunes:LICENSE"},
"copyright": {"field": "cprt"},
"comment": {"field": "©cmt"},
},
},
"FLAC": {
@ -304,6 +320,7 @@ CONF = {
"pictures": {},
"license": {},
"copyright": {},
"comment": {},
},
},
}
@ -322,6 +339,7 @@ ALL_FIELDS = [
"mbid",
"license",
"copyright",
"comment",
]
@ -657,6 +675,21 @@ class PositionField(serializers.CharField):
pass
class DescriptionField(serializers.CharField):
def get_value(self, data):
return data
def to_internal_value(self, data):
try:
value = data.get("comment") or None
except TagNotFound:
return None
if not value:
return None
value = super().to_internal_value(value)
return {"text": value, "content_type": "text/plain"}
class TrackMetadataSerializer(serializers.Serializer):
title = serializers.CharField()
position = PositionField(allow_blank=True, allow_null=True, required=False)
@ -665,6 +698,7 @@ class TrackMetadataSerializer(serializers.Serializer):
license = serializers.CharField(allow_blank=True, allow_null=True, required=False)
mbid = MBIDField()
tags = TagsField(allow_blank=True, allow_null=True, required=False)
description = DescriptionField(allow_null=True, allow_blank=True, required=False)
album = AlbumField()
artists = ArtistField()
@ -672,6 +706,7 @@ class TrackMetadataSerializer(serializers.Serializer):
remove_blank_null_fields = [
"copyright",
"description",
"license",
"position",
"disc_number",

View File

@ -0,0 +1,30 @@
# Generated by Django 2.2.7 on 2020-01-13 10:18
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('common', '0006_content'),
('music', '0045_full_text_search_stop_words'),
]
operations = [
migrations.AddField(
model_name='album',
name='description',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.Content'),
),
migrations.AddField(
model_name='artist',
name='description',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.Content'),
),
migrations.AddField(
model_name='track',
name='description',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.Content'),
),
]

View File

@ -227,6 +227,9 @@ class Artist(APIModelMixin):
content_type_field="object_content_type",
object_id_field="object_id",
)
description = models.ForeignKey(
"common.Content", null=True, blank=True, on_delete=models.SET_NULL
)
api = musicbrainz.api.artists
objects = ArtistQuerySet.as_manager()
@ -327,6 +330,10 @@ class Album(APIModelMixin):
object_id_field="object_id",
)
description = models.ForeignKey(
"common.Content", null=True, blank=True, on_delete=models.SET_NULL
)
api_includes = ["artist-credits", "recordings", "media", "release-groups"]
api = musicbrainz.api.releases
federation_namespace = "albums"
@ -508,6 +515,10 @@ class Track(APIModelMixin):
copyright = models.CharField(
max_length=MAX_LENGTHS["COPYRIGHT"], null=True, blank=True
)
description = models.ForeignKey(
"common.Content", null=True, blank=True, on_delete=models.SET_NULL
)
federation_namespace = "tracks"
musicbrainz_model = "recording"
api = musicbrainz.api.recordings

View File

@ -1,6 +1,8 @@
from funkwhale_api.common import models as common_models
from funkwhale_api.common import mutations
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import routes
from funkwhale_api.tags import models as tags_models
from funkwhale_api.tags import serializers as tags_serializers
@ -23,11 +25,13 @@ def can_approve(obj, actor):
class TagMutation(mutations.UpdateMutationSerializer):
tags = tags_serializers.TagsListField()
previous_state_handlers = {
"tags": lambda obj: list(
def get_previous_state_handlers(self):
handlers = super().get_previous_state_handlers()
handlers["tags"] = lambda obj: list(
sorted(obj.tagged_items.values_list("tag__name", flat=True))
)
}
return handlers
def update(self, instance, validated_data):
tags = validated_data.pop("tags", [])
@ -36,17 +40,36 @@ class TagMutation(mutations.UpdateMutationSerializer):
return r
class DescriptionMutation(mutations.UpdateMutationSerializer):
description = common_serializers.ContentSerializer()
def get_previous_state_handlers(self):
handlers = super().get_previous_state_handlers()
handlers["description"] = (
lambda obj: common_serializers.ContentSerializer(obj.description).data
if obj.description_id
else None
)
return handlers
def update(self, instance, validated_data):
description = validated_data.pop("description", None)
r = super().update(instance, validated_data)
common_utils.attach_content(instance, "description", description)
return r
@mutations.registry.connect(
"update",
models.Track,
perm_checkers={"suggest": can_suggest, "approve": can_approve},
)
class TrackMutationSerializer(TagMutation):
class TrackMutationSerializer(TagMutation, DescriptionMutation):
serialized_relations = {"license": "code"}
class Meta:
model = models.Track
fields = ["license", "title", "position", "copyright", "tags"]
fields = ["license", "title", "position", "copyright", "tags", "description"]
def post_apply(self, obj, validated_data):
routes.outbox.dispatch(
@ -59,10 +82,10 @@ class TrackMutationSerializer(TagMutation):
models.Artist,
perm_checkers={"suggest": can_suggest, "approve": can_approve},
)
class ArtistMutationSerializer(TagMutation):
class ArtistMutationSerializer(TagMutation, DescriptionMutation):
class Meta:
model = models.Artist
fields = ["name", "tags"]
fields = ["name", "tags", "description"]
def post_apply(self, obj, validated_data):
routes.outbox.dispatch(
@ -75,27 +98,23 @@ class ArtistMutationSerializer(TagMutation):
models.Album,
perm_checkers={"suggest": can_suggest, "approve": can_approve},
)
class AlbumMutationSerializer(TagMutation):
class AlbumMutationSerializer(TagMutation, DescriptionMutation):
cover = common_serializers.RelatedField(
"uuid", queryset=common_models.Attachment.objects.all().local(), serializer=None
)
serialized_relations = {"cover": "uuid"}
previous_state_handlers = dict(
list(TagMutation.previous_state_handlers.items())
+ [
(
"cover",
lambda obj: str(obj.attachment_cover.uuid)
if obj.attachment_cover
else None,
),
]
)
class Meta:
model = models.Album
fields = ["title", "release_date", "tags", "cover"]
fields = ["title", "release_date", "tags", "cover", "description"]
def get_previous_state_handlers(self):
handlers = super().get_previous_state_handlers()
handlers["cover"] = (
lambda obj: str(obj.attachment_cover.uuid) if obj.attachment_cover else None
)
return handlers
def post_apply(self, obj, validated_data):
routes.outbox.dispatch(

View File

@ -49,6 +49,20 @@ def serialize_attributed_to(self, obj):
return federation_serializers.APIActorSerializer(obj.attributed_to).data
class OptionalDescriptionMixin(object):
def to_representation(self, obj):
repr = super().to_representation(obj)
if self.context.get("description", False):
description = obj.description
repr["description"] = (
common_serializers.ContentSerializer(description).data
if description
else None
)
return repr
class LicenseSerializer(serializers.Serializer):
id = serializers.SerializerMethodField()
url = serializers.URLField()
@ -96,7 +110,7 @@ class ArtistAlbumSerializer(serializers.Serializer):
DATETIME_FIELD = serializers.DateTimeField()
class ArtistWithAlbumsSerializer(serializers.Serializer):
class ArtistWithAlbumsSerializer(OptionalDescriptionMixin, serializers.Serializer):
albums = ArtistAlbumSerializer(many=True)
tags = serializers.SerializerMethodField()
attributed_to = serializers.SerializerMethodField()
@ -152,7 +166,7 @@ def serialize_album_track(track):
}
class AlbumSerializer(serializers.Serializer):
class AlbumSerializer(OptionalDescriptionMixin, serializers.Serializer):
tracks = serializers.SerializerMethodField()
artist = serializers.SerializerMethodField()
cover = cover_field
@ -225,7 +239,7 @@ def serialize_upload(upload):
}
class TrackSerializer(serializers.Serializer):
class TrackSerializer(OptionalDescriptionMixin, serializers.Serializer):
artist = serializers.SerializerMethodField()
album = TrackAlbumSerializer(read_only=True)
uploads = serializers.SerializerMethodField()

View File

@ -12,6 +12,7 @@ from musicbrainzngs import ResponseError
from requests.exceptions import RequestException
from funkwhale_api.common import channels, preferences
from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import routes
from funkwhale_api.federation import library as lb
from funkwhale_api.federation import utils as federation_utils
@ -309,6 +310,7 @@ def federation_audio_track_to_metadata(payload, references):
"disc_number": payload.get("disc"),
"license": payload.get("license"),
"copyright": payload.get("copyright"),
"description": payload.get("description"),
"attributed_to": references.get(payload.get("attributedTo")),
"mbid": str(payload.get("musicbrainzId"))
if payload.get("musicbrainzId")
@ -317,6 +319,7 @@ def federation_audio_track_to_metadata(payload, references):
"title": payload["album"]["name"],
"fdate": payload["album"]["published"],
"fid": payload["album"]["id"],
"description": payload["album"].get("description"),
"attributed_to": references.get(payload["album"].get("attributedTo")),
"mbid": str(payload["album"]["musicbrainzId"])
if payload["album"].get("musicbrainzId")
@ -328,6 +331,7 @@ def federation_audio_track_to_metadata(payload, references):
"fid": a["id"],
"name": a["name"],
"fdate": a["published"],
"description": a.get("description"),
"attributed_to": references.get(a.get("attributedTo")),
"mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None,
"tags": [t["name"] for t in a.get("tags", []) or []],
@ -340,6 +344,7 @@ def federation_audio_track_to_metadata(payload, references):
"fid": a["id"],
"name": a["name"],
"fdate": a["published"],
"description": a.get("description"),
"attributed_to": references.get(a.get("attributedTo")),
"mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None,
"tags": [t["name"] for t in a.get("tags", []) or []],
@ -505,6 +510,9 @@ def _get_track(data, attributed_to=None, **forced_values):
)
if created:
tags_models.add_tags(artist, *artist_data.get("tags", []))
common_utils.attach_content(
artist, "description", artist_data.get("description")
)
if "album" in forced_values:
album = forced_values["album"]
@ -539,6 +547,9 @@ def _get_track(data, attributed_to=None, **forced_values):
)
if created:
tags_models.add_tags(album_artist, *album_artist_data.get("tags", []))
common_utils.attach_content(
album_artist, "description", album_artist_data.get("description")
)
# get / create album
album_data = data["album"]
@ -569,6 +580,9 @@ def _get_track(data, attributed_to=None, **forced_values):
)
if created:
tags_models.add_tags(album, *album_data.get("tags", []))
common_utils.attach_content(
album, "description", album_data.get("description")
)
# get / create track
track_title = (
@ -602,6 +616,7 @@ def _get_track(data, attributed_to=None, **forced_values):
query |= Q(mbid=track_mbid)
if track_fid:
query |= Q(fid=track_fid)
defaults = {
"title": track_title,
"album": album,
@ -627,6 +642,8 @@ def _get_track(data, attributed_to=None, **forced_values):
forced_values["tags"] if "tags" in forced_values else data.get("tags", [])
)
tags_models.add_tags(track, *tags)
common_utils.attach_content(track, "description", data.get("description"))
return track

View File

@ -143,6 +143,11 @@ class ArtistViewSet(
obj = refetch_obj(obj, self.get_queryset())
return obj
def get_serializer_context(self):
context = super().get_serializer_context()
context["description"] = self.action in ["retrieve", "create", "update"]
return context
def get_queryset(self):
queryset = super().get_queryset()
albums = models.Album.objects.with_tracks_count().select_related(
@ -194,6 +199,11 @@ class AlbumViewSet(
obj = refetch_obj(obj, self.get_queryset())
return obj
def get_serializer_context(self):
context = super().get_serializer_context()
context["description"] = self.action in ["retrieve", "create", "update"]
return context
def get_queryset(self):
queryset = super().get_queryset()
tracks = (
@ -332,6 +342,11 @@ class TrackViewSet(
get_libraries(filter_uploads=lambda o, uploads: uploads.filter(track=o))
)
def get_serializer_context(self):
context = super().get_serializer_context()
context["description"] = self.action in ["retrieve", "create", "update"]
return context
def strip_absolute_media_url(path):
if (

View File

@ -76,3 +76,5 @@ django-cacheops==4.2
click>=7,<8
service_identity==18.1.0
markdown>=3,<4
bleach>=3,<4

View File

@ -71,3 +71,17 @@ def test_attachment_queryset_attached(args, expected, factories, queryset_equal_
queryset = attachments[0].__class__.objects.attached(*args).order_by("id")
expected_objs = [attachments[i] for i in expected]
assert queryset == expected_objs
def test_removing_obj_removes_content(factories):
kept_content = factories["common.Content"]()
removed_content = factories["common.Content"]()
track1 = factories["music.Track"](description=removed_content)
factories["music.Track"](description=kept_content)
track1.delete()
with pytest.raises(removed_content.DoesNotExist):
removed_content.refresh_from_db()
kept_content.refresh_from_db()

View File

@ -7,6 +7,7 @@ from django.urls import reverse
import django_filters
from funkwhale_api.common import serializers
from funkwhale_api.common import utils
from funkwhale_api.users import models
from funkwhale_api.federation import utils as federation_utils
@ -252,3 +253,17 @@ def test_attachment_serializer_remote_file(factories, to_api_date):
serializer = serializers.AttachmentSerializer(attachment)
assert serializer.data == expected
def test_content_serializer(factories):
content = factories["common.Content"]()
expected = {
"text": content.text,
"content_type": content.content_type,
"html": utils.render_html(content.text, content.content_type),
}
serializer = serializers.ContentSerializer(content)
assert serializer.data == expected

View File

@ -99,3 +99,28 @@ def test_get_updated_fields(conf, mock_args, data, expected, mocker):
)
def test_join_url(start, end, expected):
assert utils.join_url(start, end) == expected
@pytest.mark.parametrize(
"text, content_type, expected",
[
("hello world", "text/markdown", "<p>hello world</p>"),
("hello world", "text/plain", "<p>hello world</p>"),
("<strong>hello world</strong>", "text/html", "<strong>hello world</strong>"),
# images and other non whitelisted html should be removed
("hello world\n![img](src)", "text/markdown", "<p>hello world</p>"),
(
"hello world\n\n<script></script>\n\n<style></style>",
"text/markdown",
"<p>hello world</p>",
),
(
"<p>hello world</p><script></script>\n\n<style></style>",
"text/html",
"<p>hello world</p>",
),
],
)
def test_render_html(text, content_type, expected):
result = utils.render_html(text, content_type)
assert result == expected

View File

@ -5,6 +5,7 @@ import uuid
from django.core.paginator import Paginator
from django.utils import timezone
from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import contexts
from funkwhale_api.federation import keys
from funkwhale_api.federation import jsonld
@ -560,7 +561,10 @@ def test_music_library_serializer_from_private(factories, mocker):
def test_activity_pub_artist_serializer_to_ap(factories):
artist = factories["music.Artist"](attributed=True, set_tags=["Punk", "Rock"])
content = factories["common.Content"]()
artist = factories["music.Artist"](
description=content, attributed=True, set_tags=["Punk", "Rock"]
)
expected = {
"@context": jsonld.get_default_context(),
"type": "Artist",
@ -569,6 +573,8 @@ def test_activity_pub_artist_serializer_to_ap(factories):
"musicbrainzId": artist.mbid,
"published": artist.creation_date.isoformat(),
"attributedTo": artist.attributed_to.fid,
"mediaType": "text/html",
"content": common_utils.render_html(content.text, content.content_type),
"tag": [
{"type": "Hashtag", "name": "#Punk"},
{"type": "Hashtag", "name": "#Rock"},
@ -580,7 +586,10 @@ def test_activity_pub_artist_serializer_to_ap(factories):
def test_activity_pub_album_serializer_to_ap(factories):
album = factories["music.Album"](attributed=True, set_tags=["Punk", "Rock"])
content = factories["common.Content"]()
album = factories["music.Album"](
description=content, attributed=True, set_tags=["Punk", "Rock"]
)
expected = {
"@context": jsonld.get_default_context(),
@ -601,6 +610,8 @@ def test_activity_pub_album_serializer_to_ap(factories):
).data
],
"attributedTo": album.attributed_to.fid,
"mediaType": "text/html",
"content": common_utils.render_html(content.text, content.content_type),
"tag": [
{"type": "Hashtag", "name": "#Punk"},
{"type": "Hashtag", "name": "#Rock"},
@ -653,7 +664,9 @@ def test_activity_pub_album_serializer_from_ap_update(factories, faker):
def test_activity_pub_track_serializer_to_ap(factories):
content = factories["common.Content"]()
track = factories["music.Track"](
description=content,
license="cc-by-4.0",
copyright="test",
disc_number=3,
@ -680,6 +693,8 @@ def test_activity_pub_track_serializer_to_ap(factories):
track.album, context={"include_ap_context": False}
).data,
"attributedTo": track.attributed_to.fid,
"mediaType": "text/html",
"content": common_utils.render_html(content.text, content.content_type),
"tag": [
{"type": "Hashtag", "name": "#Punk"},
{"type": "Hashtag", "name": "#Rock"},
@ -709,6 +724,7 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
"name": "Black in back",
"position": 5,
"disc": 1,
"content": "Hello there",
"attributedTo": track_attributed_to.fid,
"album": {
"type": "Album",
@ -717,6 +733,8 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
"musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(),
"released": released.isoformat(),
"content": "Album summary",
"mediaType": "text/markdown",
"attributedTo": album_attributed_to.fid,
"cover": {
"type": "Link",
@ -727,6 +745,8 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
"artists": [
{
"type": "Artist",
"mediaType": "text/plain",
"content": "Artist summary",
"id": "http://hello.artist",
"name": "John Smith",
"musicbrainzId": str(uuid.uuid4()),
@ -741,6 +761,8 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
"type": "Artist",
"id": "http://hello.trackartist",
"name": "Bob Smith",
"mediaType": "text/plain",
"content": "Other artist summary",
"musicbrainzId": str(uuid.uuid4()),
"attributedTo": artist_attributed_to.fid,
"published": published.isoformat(),
@ -769,6 +791,8 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
assert track.creation_date == published
assert track.attributed_to == track_attributed_to
assert str(track.mbid) == data["musicbrainzId"]
assert track.description.text == data["content"]
assert track.description.content_type == "text/html"
assert album.from_activity == activity
assert album.attachment_cover.file.read() == b"coucou"
@ -779,6 +803,8 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
assert album.creation_date == published
assert album.release_date == released
assert album.attributed_to == album_attributed_to
assert album.description.text == data["album"]["content"]
assert album.description.content_type == data["album"]["mediaType"]
assert artist.from_activity == activity
assert artist.name == data["artists"][0]["name"]
@ -786,6 +812,8 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
assert str(artist.mbid) == data["artists"][0]["musicbrainzId"]
assert artist.creation_date == published
assert artist.attributed_to == artist_attributed_to
assert artist.description.text == data["artists"][0]["content"]
assert artist.description.content_type == data["artists"][0]["mediaType"]
assert album_artist.from_activity == activity
assert album_artist.name == data["album"]["artists"][0]["name"]
@ -793,6 +821,11 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
assert str(album_artist.mbid) == data["album"]["artists"][0]["musicbrainzId"]
assert album_artist.creation_date == published
assert album_artist.attributed_to == album_artist_attributed_to
assert album_artist.description.text == data["album"]["artists"][0]["content"]
assert (
album_artist.description.content_type
== data["album"]["artists"][0]["mediaType"]
)
add_tags.assert_any_call(track, *["Hello", "World"])
add_tags.assert_any_call(album, *["AlbumTag"])
@ -802,8 +835,9 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
def test_activity_pub_track_serializer_from_ap_update(factories, r_mock, mocker):
set_tags = mocker.patch("funkwhale_api.tags.models.set_tags")
content = factories["common.Content"]()
track_attributed_to = factories["federation.Actor"]()
track = factories["music.Track"]()
track = factories["music.Track"](description=content)
published = timezone.now()
data = {
@ -815,6 +849,7 @@ def test_activity_pub_track_serializer_from_ap_update(factories, r_mock, mocker)
"name": "Black in back",
"position": 5,
"disc": 2,
"content": "hello there",
"attributedTo": track_attributed_to.fid,
"album": serializers.AlbumSerializer(track.album).data,
"artists": [serializers.ArtistSerializer(track.artist).data],
@ -835,10 +870,15 @@ def test_activity_pub_track_serializer_from_ap_update(factories, r_mock, mocker)
assert track.position == data["position"]
assert track.disc_number == data["disc"]
assert track.attributed_to == track_attributed_to
assert track.description.content_type == "text/html"
assert track.description.text == "hello there"
assert str(track.mbid) == data["musicbrainzId"]
set_tags.assert_called_once_with(track, *["Hello", "World"])
with pytest.raises(content.DoesNotExist):
content.refresh_from_db()
def test_activity_pub_upload_serializer_from_ap(factories, mocker, r_mock):
activity = factories["federation.Activity"]()
@ -1083,11 +1123,13 @@ def test_channel_actor_outbox_serializer(factories):
def test_channel_upload_serializer(factories):
channel = factories["audio.Channel"]()
content = factories["common.Content"]()
upload = factories["music.Upload"](
playable=True,
library=channel.library,
import_status="finished",
track__set_tags=["Punk"],
track__description=content,
track__album__set_tags=["Rock"],
track__artist__set_tags=["Indie"],
)
@ -1100,6 +1142,8 @@ def test_channel_upload_serializer(factories):
"summary": "#Indie #Punk #Rock",
"attributedTo": channel.actor.fid,
"published": upload.creation_date.isoformat(),
"mediaType": "text/html",
"content": common_utils.render_html(content.text, content.content_type),
"to": "https://www.w3.org/ns/activitystreams#Public",
"url": [
{

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -30,6 +30,7 @@ DATA_DIR = os.path.dirname(os.path.abspath(__file__))
),
("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"),
("copyright", "Someone"),
("comment", "Hello there"),
],
)
def test_can_get_metadata_from_ogg_file(field, value):
@ -58,6 +59,7 @@ def test_can_get_metadata_all():
"license": "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/",
"copyright": "Someone",
"genre": "Classical",
"comment": "Hello there",
}
assert data.all() == expected
@ -81,6 +83,7 @@ def test_can_get_metadata_all():
),
("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"),
("copyright", "Someone"),
("comment", "Hello there"),
],
)
def test_can_get_metadata_from_opus_file(field, value):
@ -104,6 +107,7 @@ def test_can_get_metadata_from_opus_file(field, value):
("mbid", "124d0150-8627-46bc-bc14-789a3bc960c8"),
("musicbrainz_artistid", "c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1"),
("musicbrainz_albumartistid", "c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1"),
("comment", "Hello there"),
# somehow, I cannot successfully create an ogg theora file
# with the proper license field
# ("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"),
@ -132,6 +136,7 @@ def test_can_get_metadata_from_ogg_theora_file(field, value):
("musicbrainz_albumartistid", "9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"),
("license", "https://creativecommons.org/licenses/by-nc-nd/2.5/"),
("copyright", "Someone"),
("comment", "Hello there"),
],
)
def test_can_get_metadata_from_id3_mp3_file(field, value):
@ -181,6 +186,7 @@ def test_can_get_pictures(name):
("musicbrainz_albumartistid", "b7ffd2af-418f-4be2-bdd1-22f8b48613da"),
("license", "http://creativecommons.org/licenses/by-nc-sa/3.0/us/"),
("copyright", "2008 nin"),
("comment", "Hello there"),
],
)
def test_can_get_metadata_from_flac_file(field, value):
@ -210,6 +216,7 @@ def test_can_get_metadata_from_flac_file(field, value):
("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"),
("copyright", "Someone"),
("genre", "Dubstep"),
("comment", "Hello there"),
],
)
def test_can_get_metadata_from_m4a_file(field, value):
@ -294,6 +301,7 @@ def test_metadata_fallback_ogg_theora(mocker):
"license": "https://creativecommons.org/licenses/by-nc-nd/2.5/",
"copyright": "Someone",
"tags": ["Funk"],
"description": {"text": "Hello there", "content_type": "text/plain"},
},
),
(
@ -327,6 +335,7 @@ def test_metadata_fallback_ogg_theora(mocker):
"license": "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/",
"copyright": "Someone",
"tags": ["Classical"],
"description": {"text": "Hello there", "content_type": "text/plain"},
},
),
(
@ -360,6 +369,7 @@ def test_metadata_fallback_ogg_theora(mocker):
"license": "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/",
"copyright": "Someone",
"tags": ["Classical"],
"description": {"text": "Hello there", "content_type": "text/plain"},
},
),
(
@ -391,6 +401,7 @@ def test_metadata_fallback_ogg_theora(mocker):
# with the proper license field
# ("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"),
"copyright": "℗ 2012 JKP GmbH & Co. KG",
"description": {"text": "Hello there", "content_type": "text/plain"},
},
),
(
@ -420,6 +431,7 @@ def test_metadata_fallback_ogg_theora(mocker):
"license": "http://creativecommons.org/licenses/by-nc-sa/3.0/us/",
"copyright": "2008 nin",
"tags": ["Industrial"],
"description": {"text": "Hello there", "content_type": "text/plain"},
},
),
],
@ -528,10 +540,12 @@ def test_fake_metadata_with_serializer():
"musicbrainz_albumartistid": "013c8e5b-d72a-4cd3-8dee-6c64d6125823;5b4d7d2d-36df-4b38-95e3-a964234f520f",
"license": "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/",
"copyright": "Someone",
"comment": "hello there",
}
expected = {
"title": "Peer Gynt Suite no. 1, op. 46: I. Morning",
"description": {"text": "hello there", "content_type": "text/plain"},
"artists": [
{
"name": "Edvard Grieg",

View File

@ -1,6 +1,7 @@
import datetime
import pytest
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.music import licenses
from funkwhale_api.music import mutations
@ -195,3 +196,26 @@ def test_mutation_set_attachment_cover(factories, now, mocker):
assert obj.attachment_cover == new_attachment
assert mutation.previous_state["cover"] == old_attachment.uuid
@pytest.mark.parametrize(
"factory_name", ["music.Track", "music.Album", "music.Artist"],
)
def test_album_mutation_description(factory_name, factories, mocker):
mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
content = factories["common.Content"]()
obj = factories[factory_name](description=content)
mutation = factories["common.Mutation"](
type="update",
target=obj,
payload={"description": {"content_type": "text/plain", "text": "hello there"}},
)
mutation.apply()
obj.refresh_from_db()
assert obj.description.content_type == "text/plain"
assert obj.description.text == "hello there"
assert (
mutation.previous_state["description"]
== common_serializers.ContentSerializer(content).data
)

View File

@ -501,3 +501,21 @@ def test_upload_with_channel_validates_import_metadata(factories, uploaded_audio
)
with pytest.raises(serializers.serializers.ValidationError):
assert serializer.is_valid(raise_exception=True)
@pytest.mark.parametrize(
"factory_name, serializer_class",
[
("music.Artist", serializers.ArtistWithAlbumsSerializer),
("music.Album", serializers.AlbumSerializer),
("music.Track", serializers.TrackSerializer),
],
)
def test_detail_serializers_with_description_description(
factory_name, serializer_class, factories
):
content = factories["common.Content"]()
obj = factories[factory_name](description=content)
expected = common_serializers.ContentSerializer(content).data
serializer = serializer_class(obj, context={"description": True})
assert serializer.data["description"] == expected

View File

@ -128,6 +128,21 @@ def test_can_create_track_from_file_metadata_featuring(factories):
assert track.artist.name == "Santana feat. Chris Cornell"
def test_can_create_track_from_file_metadata_description(factories):
metadata = {
"title": "Whole Lotta Love",
"position": 1,
"disc_number": 1,
"description": {"text": "hello there", "content_type": "text/plain"},
"album": {"title": "Test album"},
"artists": [{"name": "Santana"}],
}
track = tasks.get_track_from_import_metadata(metadata)
assert track.description.text == "hello there"
assert track.description.content_type == "text/plain"
def test_can_create_track_from_file_metadata_mbid(factories, mocker):
metadata = {
"title": "Test track",
@ -607,6 +622,7 @@ def test_federation_audio_track_to_metadata(now, mocker):
"copyright": "2018 Someone",
"attributedTo": "http://track.attributed",
"tag": [{"type": "Hashtag", "name": "TrackTag"}],
"content": "hello there",
"album": {
"published": published.isoformat(),
"type": "Album",
@ -616,12 +632,16 @@ def test_federation_audio_track_to_metadata(now, mocker):
"released": released.isoformat(),
"tag": [{"type": "Hashtag", "name": "AlbumTag"}],
"attributedTo": "http://album.attributed",
"content": "album desc",
"mediaType": "text/plain",
"artists": [
{
"type": "Artist",
"published": published.isoformat(),
"id": "http://hello.artist",
"name": "John Smith",
"content": "album artist desc",
"mediaType": "text/markdown",
"musicbrainzId": str(uuid.uuid4()),
"attributedTo": "http://album-artist.attributed",
"tag": [{"type": "Hashtag", "name": "AlbumArtistTag"}],
@ -639,6 +659,8 @@ def test_federation_audio_track_to_metadata(now, mocker):
"type": "Artist",
"id": "http://hello.trackartist",
"name": "Bob Smith",
"content": "artist desc",
"mediaType": "text/html",
"musicbrainzId": str(uuid.uuid4()),
"attributedTo": "http://artist.attributed",
"tag": [{"type": "Hashtag", "name": "ArtistTag"}],
@ -658,6 +680,7 @@ def test_federation_audio_track_to_metadata(now, mocker):
"fid": payload["id"],
"attributed_to": references["http://track.attributed"],
"tags": ["TrackTag"],
"description": {"content_type": "text/html", "text": "hello there"},
"album": {
"title": payload["album"]["name"],
"attributed_to": references["http://album.attributed"],
@ -666,6 +689,7 @@ def test_federation_audio_track_to_metadata(now, mocker):
"fid": payload["album"]["id"],
"fdate": serializer.validated_data["album"]["published"],
"tags": ["AlbumTag"],
"description": {"content_type": "text/plain", "text": "album desc"},
"artists": [
{
"name": a["name"],
@ -675,6 +699,10 @@ def test_federation_audio_track_to_metadata(now, mocker):
"fdate": serializer.validated_data["album"]["artists"][i][
"published"
],
"description": {
"content_type": "text/markdown",
"text": "album artist desc",
},
"tags": ["AlbumArtistTag"],
}
for i, a in enumerate(payload["album"]["artists"])
@ -690,6 +718,7 @@ def test_federation_audio_track_to_metadata(now, mocker):
"fdate": serializer.validated_data["artists"][i]["published"],
"attributed_to": references["http://artist.attributed"],
"tags": ["ArtistTag"],
"description": {"content_type": "text/html", "text": "artist desc"},
}
for i, a in enumerate(payload["artists"])
],

Binary file not shown.

View File

@ -1256,3 +1256,22 @@ def test_search_get_fts_stop_words(settings, logged_in_api_client, factories):
assert response.status_code == 200
assert response.data == expected
@pytest.mark.parametrize(
"route, factory_name",
[
("api:v1:artists-detail", "music.Artist"),
("api:v1:albums-detail", "music.Album"),
("api:v1:tracks-detail", "music.Track"),
],
)
def test_detail_includes_description_key(
route, factory_name, logged_in_api_client, factories
):
obj = factories[factory_name]()
url = reverse(route, kwargs={"pk": obj.pk})
response = logged_in_api_client.get(url)
assert response.data["description"] is None

View File

@ -77,6 +77,10 @@
</button>
</template>
<template v-else-if="fieldConfig.type === 'content'">
<label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
<textarea v-model="values[fieldConfig.id].text" :name="fieldConfig.id" :id="fieldConfig.id" rows="3"></textarea>
</template>
<template v-else-if="fieldConfig.type === 'attachment'">
<label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
<attachment-input
@ -100,8 +104,8 @@
<translate translate-context="Content/Library/Button.Label">Clear</translate>
</button>
</template>
<div v-if="values[fieldConfig.id] != initialValues[fieldConfig.id]">
<button class="ui tiny basic right floated reset button" form="noop" @click.prevent="values[fieldConfig.id] = initialValues[fieldConfig.id]">
<div v-if="!lodash.isEqual(values[fieldConfig.id], initialValues[fieldConfig.id])">
<button class="ui tiny basic right floated reset button" form="noop" @click.prevent="values[fieldConfig.id] = lodash.clone(initialValues[fieldConfig.id])">
<i class="undo icon"></i>
<translate translate-context="Content/Library/Button.Label">Reset to initial value</translate>
</button>
@ -156,6 +160,7 @@ export default {
summary: '',
submittedMutation: null,
showPendingReview: true,
lodash,
}
},
created () {
@ -216,8 +221,8 @@ export default {
setValues () {
let self = this
this.config.fields.forEach(f => {
self.$set(self.values, f.id, f.getValue(self.object))
self.$set(self.initialValues, f.id, self.values[f.id])
self.$set(self.values, f.id, lodash.clone(f.getValue(self.object)))
self.$set(self.initialValues, f.id, lodash.clone(self.values[f.id]))
})
},
submit() {

View File

@ -5,8 +5,20 @@ function getTagsValueRepr (val) {
return val.slice().sort().join('\n')
}
function getContentValueRepr (val) {
return val.text
}
export default {
getConfigs () {
const description = {
id: 'description',
type: 'content',
required: true,
label: this.$pgettext('*/*/*/Noun', 'Description'),
getValue: (obj) => { return obj.description || {text: null, content_type: 'text/markdown'}},
getValueRepr: getContentValueRepr
}
return {
artist: {
fields: [
@ -17,6 +29,7 @@ export default {
label: this.$pgettext('*/*/*/Noun', 'Name'),
getValue: (obj) => { return obj.name }
},
description,
{
id: 'tags',
type: 'tags',
@ -24,7 +37,7 @@ export default {
label: this.$pgettext('*/*/*/Noun', 'Tags'),
getValue: (obj) => { return obj.tags },
getValueRepr: getTagsValueRepr
}
},
]
},
album: {
@ -36,6 +49,7 @@ export default {
label: this.$pgettext('*/*/*/Noun', 'Title'),
getValue: (obj) => { return obj.title }
},
description,
{
id: 'release_date',
type: 'text',
@ -75,6 +89,7 @@ export default {
label: this.$pgettext('*/*/*/Noun', 'Title'),
getValue: (obj) => { return obj.title }
},
description,
{
id: 'position',
type: 'text',

View File

@ -129,6 +129,12 @@
{{ object.domain }}
</td>
</tr>
<tr v-if="object.description">
<td>
<translate translate-context="'*/*/*/Noun">Description</translate>
</td>
<td v-html="object.description.html"></td>
</tr>
</tbody>
</table>
</section>

View File

@ -117,6 +117,12 @@
{{ object.domain }}
</td>
</tr>
<tr v-if="object.description">
<td>
<translate translate-context="'*/*/*/Noun">Description</translate>
</td>
<td v-html="object.description.html"></td>
</tr>
</tbody>
</table>
</section>

View File

@ -181,6 +181,12 @@
{{ object.domain }}
</td>
</tr>
<tr v-if="object.description">
<td>
<translate translate-context="'*/*/*/Noun">Description</translate>
</td>
<td v-html="object.description.html"></td>
</tr>
</tbody>
</table>
</section>