diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py index 47e673cb5..b8e217ba4 100644 --- a/api/funkwhale_api/common/fields.py +++ b/api/funkwhale_api/common/fields.py @@ -49,6 +49,6 @@ class SmartSearchFilter(django_filters.CharFilter): return qs try: cleaned = self.config.clean(value) - except forms.ValidationError: + except (forms.ValidationError): return qs.none() return search.apply(qs, cleaned) diff --git a/api/funkwhale_api/common/filters.py b/api/funkwhale_api/common/filters.py index 4825d3b5d..364a1fba1 100644 --- a/api/funkwhale_api/common/filters.py +++ b/api/funkwhale_api/common/filters.py @@ -104,6 +104,31 @@ class MultipleQueryFilter(filters.TypedMultipleChoiceFilter): self.lookup_expr = "in" +def filter_target(value): + + config = { + "artist": ["artist", "target_id", int], + "album": ["album", "target_id", int], + "track": ["track", "target_id", int], + } + parts = value.lower().split(" ") + if parts[0].strip() not in config: + raise forms.ValidationError("Improper target") + + conf = config[parts[0].strip()] + + query = Q(target_content_type__model=conf[0]) + if len(parts) > 1: + _, lookup_field, validator = conf + try: + lookup_value = validator(parts[1].strip()) + except TypeError: + raise forms.ValidationError("Imparsable target id") + return query & Q(**{lookup_field: lookup_value}) + + return query + + class MutationFilter(filters.FilterSet): is_approved = NullBooleanFilter("is_approved") q = fields.SmartSearchFilter( @@ -116,6 +141,7 @@ class MutationFilter(filters.FilterSet): filter_fields={ "domain": {"to": "created_by__domain__name__iexact"}, "is_approved": get_null_boolean_filter("is_approved"), + "target": {"handler": filter_target}, "is_applied": {"to": "is_applied"}, }, ) diff --git a/api/funkwhale_api/common/search.py b/api/funkwhale_api/common/search.py index 622cb29dd..cc046f758 100644 --- a/api/funkwhale_api/common/search.py +++ b/api/funkwhale_api/common/search.py @@ -77,12 +77,15 @@ class SearchConfig: def clean(self, query): tokens = parse_query(query) cleaned_data = {} - cleaned_data["types"] = self.clean_types(filter_tokens(tokens, ["is"])) cleaned_data["search_query"] = self.clean_search_query( - filter_tokens(tokens, [None, "in"]) + filter_tokens(tokens, [None, "in"] + list(self.search_fields.keys())) ) - unhandled_tokens = [t for t in tokens if t["key"] not in [None, "is", "in"]] + unhandled_tokens = [ + t + for t in tokens + if t["key"] not in [None, "is", "in"] + list(self.search_fields.keys()) + ] cleaned_data["filter_query"] = self.clean_filter_query(unhandled_tokens) return cleaned_data @@ -95,8 +98,33 @@ class SearchConfig: } or set(self.search_fields.keys()) fields_subset = set(self.search_fields.keys()) & fields_subset to_fields = [self.search_fields[k]["to"] for k in fields_subset] + + specific_field_query = None + for token in tokens: + if token["key"] not in self.search_fields: + continue + to = self.search_fields[token["key"]]["to"] + try: + field = token["field"] + value = field.clean(token["value"]) + except KeyError: + # no cleaning to apply + value = token["value"] + q = Q(**{"{}__icontains".format(to): value}) + if not specific_field_query: + specific_field_query = q + else: + specific_field_query &= q query_string = " ".join([t["value"] for t in filter_tokens(tokens, [None])]) - return get_query(query_string, sorted(to_fields)) + unhandled_tokens_query = get_query(query_string, sorted(to_fields)) + + if specific_field_query and unhandled_tokens_query: + return unhandled_tokens_query & specific_field_query + elif specific_field_query: + return specific_field_query + elif unhandled_tokens_query: + return unhandled_tokens_query + return None def clean_filter_query(self, tokens): if not self.filter_fields or not tokens: diff --git a/api/funkwhale_api/common/views.py b/api/funkwhale_api/common/views.py index 743c95095..db39c56d1 100644 --- a/api/funkwhale_api/common/views.py +++ b/api/funkwhale_api/common/views.py @@ -36,6 +36,7 @@ class MutationViewSet( lookup_field = "uuid" queryset = ( models.Mutation.objects.all() + .exclude(target_id=None) .order_by("-creation_date") .select_related("created_by", "approved_by") .prefetch_related("target") diff --git a/api/funkwhale_api/federation/fields.py b/api/funkwhale_api/federation/fields.py index 3523396db..8a8a1eb2d 100644 --- a/api/funkwhale_api/federation/fields.py +++ b/api/funkwhale_api/federation/fields.py @@ -1,6 +1,9 @@ +import django_filters + from rest_framework import serializers from . import models +from . import utils class ActorRelatedField(serializers.EmailField): @@ -16,3 +19,15 @@ class ActorRelatedField(serializers.EmailField): ) except models.Actor.DoesNotExist: raise serializers.ValidationError("Invalid actor name") + + +class DomainFromURLFilter(django_filters.CharFilter): + def __init__(self, *args, **kwargs): + self.url_field = kwargs.pop("url_field", "fid") + super().__init__(*args, **kwargs) + + def filter(self, qs, value): + if not value: + return qs + query = utils.get_domain_query_from_url(value, self.url_field) + return qs.filter(query) diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py index a32256921..2bbfdf7fa 100644 --- a/api/funkwhale_api/federation/utils.py +++ b/api/funkwhale_api/federation/utils.py @@ -1,6 +1,7 @@ import unicodedata import re from django.conf import settings +from django.db.models import Q from funkwhale_api.common import session from funkwhale_api.moderation import models as moderation_models @@ -107,3 +108,16 @@ def retrieve_ap_object( serializer = serializer_class(data=data, context={"fetch_actor": actor}) serializer.is_valid(raise_exception=True) return serializer.save() + + +def get_domain_query_from_url(domain, url_field="fid"): + """ + Given a domain name and a field, will return a Q() object + to match objects that have this domain in the given field. + """ + + query = Q(**{"{}__startswith".format(url_field): "http://{}/".format(domain)}) + query = query | Q( + **{"{}__startswith".format(url_field): "https://{}/".format(domain)} + ) + return query diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index edae49f99..64a6473e0 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -1,9 +1,11 @@ +from django import forms from django_filters import rest_framework as filters from funkwhale_api.common import fields from funkwhale_api.common import search from funkwhale_api.federation import models as federation_models +from funkwhale_api.federation import utils as federation_utils from funkwhale_api.moderation import models as moderation_models from funkwhale_api.music import models as music_models from funkwhale_api.users import models as users_models @@ -24,6 +26,82 @@ class ManageUploadFilterSet(filters.FilterSet): fields = ["q", "track__album", "track__artist", "track"] +class ManageArtistFilterSet(filters.FilterSet): + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={ + "name": {"to": "name"}, + "fid": {"to": "fid"}, + "mbid": {"to": "mbid"}, + }, + filter_fields={ + "domain": { + "handler": lambda v: federation_utils.get_domain_query_from_url(v) + } + }, + ) + ) + + class Meta: + model = music_models.Artist + fields = ["q", "name", "mbid", "fid"] + + +class ManageAlbumFilterSet(filters.FilterSet): + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={ + "title": {"to": "title"}, + "fid": {"to": "fid"}, + "artist": {"to": "artist__name"}, + "mbid": {"to": "mbid"}, + }, + filter_fields={ + "artist_id": {"to": "artist_id", "field": forms.IntegerField()}, + "domain": { + "handler": lambda v: federation_utils.get_domain_query_from_url(v) + }, + }, + ) + ) + + class Meta: + model = music_models.Album + fields = ["q", "title", "mbid", "fid", "artist"] + + +class ManageTrackFilterSet(filters.FilterSet): + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={ + "title": {"to": "title"}, + "fid": {"to": "fid"}, + "mbid": {"to": "mbid"}, + "artist": {"to": "artist__name"}, + "album": {"to": "album__title"}, + "album_artist": {"to": "album__artist__name"}, + "copyright": {"to": "copyright"}, + }, + filter_fields={ + "album_id": {"to": "album_id", "field": forms.IntegerField()}, + "album_artist_id": { + "to": "album__artist_id", + "field": forms.IntegerField(), + }, + "artist_id": {"to": "artist_id", "field": forms.IntegerField()}, + "license": {"to": "license"}, + "domain": { + "handler": lambda v: federation_utils.get_domain_query_from_url(v) + }, + }, + ) + ) + + class Meta: + model = music_models.Track + fields = ["q", "title", "mbid", "fid", "artist", "album", "license"] + + class ManageDomainFilterSet(filters.FilterSet): q = fields.SearchFilter(search_fields=["name"]) @@ -60,7 +138,15 @@ class ManageActorFilterSet(filters.FilterSet): class ManageUserFilterSet(filters.FilterSet): - q = fields.SearchFilter(search_fields=["username", "email", "name"]) + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={ + "name": {"to": "name"}, + "username": {"to": "username"}, + "email": {"to": "email"}, + } + ) + ) class Meta: model = users_models.User diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index ed50d8677..cf6a1eab4 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -9,6 +9,7 @@ from funkwhale_api.federation import fields as federation_fields from funkwhale_api.federation import tasks as federation_tasks from funkwhale_api.moderation import models as moderation_models from funkwhale_api.music import models as music_models +from funkwhale_api.music import serializers as music_serializers from funkwhale_api.users import models as users_models from . import filters @@ -216,10 +217,7 @@ class ManageDomainActionSerializer(common_serializers.ActionSerializer): common_utils.on_commit(federation_tasks.purge_actors.delay, domains=list(ids)) -class ManageActorSerializer(serializers.ModelSerializer): - uploads_count = serializers.SerializerMethodField() - user = ManageUserSerializer() - +class ManageBaseActorSerializer(serializers.ModelSerializer): class Meta: model = federation_models.Actor fields = [ @@ -238,6 +236,17 @@ class ManageActorSerializer(serializers.ModelSerializer): "outbox_url", "shared_inbox_url", "manually_approves_followers", + ] + read_only_fields = ["creation_date", "instance_policy"] + + +class ManageActorSerializer(ManageBaseActorSerializer): + uploads_count = serializers.SerializerMethodField() + user = ManageUserSerializer() + + class Meta: + model = federation_models.Actor + fields = ManageBaseActorSerializer.Meta.fields + [ "uploads_count", "user", "instance_policy", @@ -339,3 +348,148 @@ class ManageInstancePolicySerializer(serializers.ModelSerializer): ) return instance + + +class ManageBaseArtistSerializer(serializers.ModelSerializer): + domain = serializers.CharField(source="domain_name") + + class Meta: + model = music_models.Artist + fields = ["id", "fid", "mbid", "name", "creation_date", "domain", "is_local"] + + +class ManageBaseAlbumSerializer(serializers.ModelSerializer): + cover = music_serializers.cover_field + domain = serializers.CharField(source="domain_name") + + class Meta: + model = music_models.Album + fields = [ + "id", + "fid", + "mbid", + "title", + "creation_date", + "release_date", + "cover", + "domain", + "is_local", + ] + + +class ManageNestedTrackSerializer(serializers.ModelSerializer): + domain = serializers.CharField(source="domain_name") + + class Meta: + model = music_models.Track + fields = [ + "id", + "fid", + "mbid", + "title", + "creation_date", + "position", + "disc_number", + "domain", + "is_local", + "copyright", + "license", + ] + + +class ManageNestedAlbumSerializer(ManageBaseAlbumSerializer): + + tracks_count = serializers.SerializerMethodField() + + class Meta: + model = music_models.Album + fields = ManageBaseAlbumSerializer.Meta.fields + ["tracks_count"] + + def get_tracks_count(self, obj): + return getattr(obj, "tracks_count", None) + + +class ManageArtistSerializer(ManageBaseArtistSerializer): + albums = ManageNestedAlbumSerializer(many=True) + tracks = ManageNestedTrackSerializer(many=True) + attributed_to = ManageBaseActorSerializer() + + class Meta: + model = music_models.Artist + fields = ManageBaseArtistSerializer.Meta.fields + [ + "albums", + "tracks", + "attributed_to", + ] + + +class ManageNestedArtistSerializer(ManageBaseArtistSerializer): + pass + + +class ManageAlbumSerializer(ManageBaseAlbumSerializer): + tracks = ManageNestedTrackSerializer(many=True) + attributed_to = ManageBaseActorSerializer() + artist = ManageNestedArtistSerializer() + + class Meta: + model = music_models.Album + fields = ManageBaseAlbumSerializer.Meta.fields + [ + "artist", + "tracks", + "attributed_to", + ] + + +class ManageTrackAlbumSerializer(ManageBaseAlbumSerializer): + artist = ManageNestedArtistSerializer() + + class Meta: + model = music_models.Album + fields = ManageBaseAlbumSerializer.Meta.fields + ["artist"] + + +class ManageTrackSerializer(ManageNestedTrackSerializer): + artist = ManageNestedArtistSerializer() + album = ManageTrackAlbumSerializer() + attributed_to = ManageBaseActorSerializer() + uploads_count = serializers.SerializerMethodField() + + class Meta: + model = music_models.Track + fields = ManageNestedTrackSerializer.Meta.fields + [ + "artist", + "album", + "attributed_to", + "uploads_count", + ] + + def get_uploads_count(self, obj): + return getattr(obj, "uploads_count", None) + + +class ManageTrackActionSerializer(common_serializers.ActionSerializer): + actions = [common_serializers.Action("delete", allow_all=False)] + filterset_class = filters.ManageTrackFilterSet + + @transaction.atomic + def handle_delete(self, objects): + return objects.delete() + + +class ManageAlbumActionSerializer(common_serializers.ActionSerializer): + actions = [common_serializers.Action("delete", allow_all=False)] + filterset_class = filters.ManageAlbumFilterSet + + @transaction.atomic + def handle_delete(self, objects): + return objects.delete() + + +class ManageArtistActionSerializer(common_serializers.ActionSerializer): + actions = [common_serializers.Action("delete", allow_all=False)] + filterset_class = filters.ManageArtistFilterSet + + @transaction.atomic + def handle_delete(self, objects): + return objects.delete() diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index 4c220fe0e..f93667725 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -8,6 +8,9 @@ federation_router.register(r"domains", views.ManageDomainViewSet, "domains") library_router = routers.SimpleRouter() library_router.register(r"uploads", views.ManageUploadViewSet, "uploads") +library_router.register(r"artists", views.ManageArtistViewSet, "artists") +library_router.register(r"albums", views.ManageAlbumViewSet, "albums") +library_router.register(r"tracks", views.ManageTrackViewSet, "tracks") moderation_router = routers.SimpleRouter() moderation_router.register( diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index 588e66c58..6fc1a2f1e 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -1,12 +1,18 @@ from rest_framework import mixins, response, viewsets from rest_framework import decorators as rest_decorators + +from django.db.models import Count, Prefetch, Q, Sum from django.shortcuts import get_object_or_404 +from funkwhale_api.common import models as common_models from funkwhale_api.common import preferences, decorators +from funkwhale_api.favorites import models as favorites_models from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import tasks as federation_tasks +from funkwhale_api.history import models as history_models from funkwhale_api.music import models as music_models from funkwhale_api.moderation import models as moderation_models +from funkwhale_api.playlists import models as playlists_models from funkwhale_api.users import models as users_models @@ -45,6 +51,151 @@ class ManageUploadViewSet( return response.Response(result, status=200) +def get_stats(tracks, target): + data = {} + tracks = list(tracks.values_list("pk", flat=True)) + uploads = music_models.Upload.objects.filter(track__in=tracks) + data["listenings"] = history_models.Listening.objects.filter( + track__in=tracks + ).count() + data["mutations"] = common_models.Mutation.objects.get_for_target(target).count() + data["playlists"] = ( + playlists_models.PlaylistTrack.objects.filter(track__in=tracks) + .values_list("playlist", flat=True) + .distinct() + .count() + ) + data["track_favorites"] = favorites_models.TrackFavorite.objects.filter( + track__in=tracks + ).count() + data["libraries"] = uploads.values_list("library", flat=True).distinct().count() + data["uploads"] = uploads.count() + data["media_total_size"] = uploads.aggregate(v=Sum("size"))["v"] or 0 + data["media_downloaded_size"] = ( + uploads.with_file().aggregate(v=Sum("size"))["v"] or 0 + ) + return data + + +class ManageArtistViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + queryset = ( + music_models.Artist.objects.all() + .order_by("-id") + .select_related("attributed_to") + .prefetch_related( + "tracks", + Prefetch( + "albums", + queryset=music_models.Album.objects.annotate( + tracks_count=Count("tracks") + ), + ), + ) + ) + serializer_class = serializers.ManageArtistSerializer + filterset_class = filters.ManageArtistFilterSet + required_scope = "instance:libraries" + ordering_fields = ["creation_date", "name"] + + @rest_decorators.action(methods=["get"], detail=True) + def stats(self, request, *args, **kwargs): + artist = self.get_object() + tracks = music_models.Track.objects.filter( + Q(artist=artist) | Q(album__artist=artist) + ) + data = get_stats(tracks, artist) + return response.Response(data, status=200) + + @rest_decorators.action(methods=["post"], detail=False) + def action(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = serializers.ManageArtistActionSerializer( + request.data, queryset=queryset + ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return response.Response(result, status=200) + + +class ManageAlbumViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + queryset = ( + music_models.Album.objects.all() + .order_by("-id") + .select_related("attributed_to", "artist") + .prefetch_related("tracks") + ) + serializer_class = serializers.ManageAlbumSerializer + filterset_class = filters.ManageAlbumFilterSet + required_scope = "instance:libraries" + ordering_fields = ["creation_date", "title", "release_date"] + + @rest_decorators.action(methods=["get"], detail=True) + def stats(self, request, *args, **kwargs): + album = self.get_object() + data = get_stats(album.tracks.all(), album) + return response.Response(data, status=200) + + @rest_decorators.action(methods=["post"], detail=False) + def action(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = serializers.ManageAlbumActionSerializer( + request.data, queryset=queryset + ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return response.Response(result, status=200) + + +class ManageTrackViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + queryset = ( + music_models.Track.objects.all() + .order_by("-id") + .select_related("attributed_to", "artist", "album__artist") + .annotate(uploads_count=Count("uploads")) + ) + serializer_class = serializers.ManageTrackSerializer + filterset_class = filters.ManageTrackFilterSet + required_scope = "instance:libraries" + ordering_fields = [ + "creation_date", + "title", + "album__release_date", + "position", + "disc_number", + ] + + @rest_decorators.action(methods=["get"], detail=True) + def stats(self, request, *args, **kwargs): + track = self.get_object() + data = get_stats(track.__class__.objects.filter(pk=track.pk), track) + return response.Response(data, status=200) + + @rest_decorators.action(methods=["post"], detail=False) + def action(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = serializers.ManageTrackActionSerializer( + request.data, queryset=queryset + ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return response.Response(result, status=200) + + class ManageUserViewSet( mixins.ListModelMixin, mixins.RetrieveModelMixin, diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 9e0268504..4b166be7c 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -3,6 +3,7 @@ import logging import mimetypes import os import tempfile +import urllib.parse import uuid import markdown @@ -124,6 +125,14 @@ class APIModelMixin(models.Model): "https://{}/".format(d) ) + @property + def domain_name(self): + if not self.fid: + return + + parsed = urllib.parse.urlparse(self.fid) + return parsed.hostname + class License(models.Model): code = models.CharField(primary_key=True, max_length=100) diff --git a/api/tests/common/test_views.py b/api/tests/common/test_views.py index 9a03fb429..d2b53b41f 100644 --- a/api/tests/common/test_views.py +++ b/api/tests/common/test_views.py @@ -7,7 +7,9 @@ from funkwhale_api.common import tasks def test_can_detail_mutation(logged_in_api_client, factories): - mutation = factories["common.Mutation"](payload={}) + mutation = factories["common.Mutation"]( + payload={}, target=factories["music.Artist"]() + ) url = reverse("api:v1:mutations-detail", kwargs={"uuid": mutation.uuid}) response = logged_in_api_client.get(url) @@ -19,7 +21,9 @@ def test_can_detail_mutation(logged_in_api_client, factories): def test_can_list_mutations(logged_in_api_client, factories): - mutation = factories["common.Mutation"](payload={}) + mutation = factories["common.Mutation"]( + payload={}, target=factories["music.Artist"]() + ) url = reverse("api:v1:mutations-list") response = logged_in_api_client.get(url) diff --git a/api/tests/federation/test_api_filters.py b/api/tests/federation/test_api_filters.py index c6e70b617..4cbf4293a 100644 --- a/api/tests/federation/test_api_filters.py +++ b/api/tests/federation/test_api_filters.py @@ -1,3 +1,4 @@ +from funkwhale_api.federation import fields from funkwhale_api.federation import filters from funkwhale_api.federation import models @@ -7,3 +8,17 @@ def test_inbox_item_filter_before(factories): f = filters.InboxItemFilter({"before": 12}, queryset=models.InboxItem.objects.all()) assert str(f.qs.query) == str(expected.query) + + +def test_domain_from_url_filter(factories): + found = [ + factories["music.Artist"](fid="http://domain/test1"), + factories["music.Artist"](fid="https://domain/test2"), + ] + factories["music.Artist"](fid="http://domain2/test1") + factories["music.Artist"](fid="https://otherdomain/test2") + + queryset = found[0].__class__.objects.all().order_by("id") + field = fields.DomainFromURLFilter() + result = field.filter(queryset, "domain") + assert list(result) == found diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index aef8dc4ea..64a26538f 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -257,3 +257,160 @@ def test_instance_policy_serializer_purges_target_actor( assert getattr(policy, param) is False assert on_commit.call_count == 0 + + +def test_manage_artist_serializer(factories, now): + artist = factories["music.Artist"](attributed=True) + track = factories["music.Track"](artist=artist) + album = factories["music.Album"](artist=artist) + expected = { + "id": artist.id, + "domain": artist.domain_name, + "is_local": artist.is_local, + "fid": artist.fid, + "name": artist.name, + "mbid": artist.mbid, + "creation_date": artist.creation_date.isoformat().split("+")[0] + "Z", + "albums": [serializers.ManageNestedAlbumSerializer(album).data], + "tracks": [serializers.ManageNestedTrackSerializer(track).data], + "attributed_to": serializers.ManageBaseActorSerializer( + artist.attributed_to + ).data, + } + s = serializers.ManageArtistSerializer(artist) + + assert s.data == expected + + +def test_manage_nested_track_serializer(factories, now): + track = factories["music.Track"]() + expected = { + "id": track.id, + "domain": track.domain_name, + "is_local": track.is_local, + "fid": track.fid, + "title": track.title, + "mbid": track.mbid, + "creation_date": track.creation_date.isoformat().split("+")[0] + "Z", + "position": track.position, + "disc_number": track.disc_number, + "copyright": track.copyright, + "license": track.license, + } + s = serializers.ManageNestedTrackSerializer(track) + + assert s.data == expected + + +def test_manage_nested_album_serializer(factories, now): + album = factories["music.Album"]() + setattr(album, "tracks_count", 44) + expected = { + "id": album.id, + "domain": album.domain_name, + "is_local": album.is_local, + "fid": album.fid, + "title": album.title, + "mbid": album.mbid, + "creation_date": album.creation_date.isoformat().split("+")[0] + "Z", + "release_date": album.release_date.isoformat(), + "cover": { + "original": album.cover.url, + "square_crop": album.cover.crop["400x400"].url, + "medium_square_crop": album.cover.crop["200x200"].url, + "small_square_crop": album.cover.crop["50x50"].url, + }, + "tracks_count": 44, + } + s = serializers.ManageNestedAlbumSerializer(album) + + assert s.data == expected + + +def test_manage_nested_artist_serializer(factories, now): + artist = factories["music.Artist"]() + expected = { + "id": artist.id, + "domain": artist.domain_name, + "is_local": artist.is_local, + "fid": artist.fid, + "name": artist.name, + "mbid": artist.mbid, + "creation_date": artist.creation_date.isoformat().split("+")[0] + "Z", + } + s = serializers.ManageNestedArtistSerializer(artist) + + assert s.data == expected + + +def test_manage_album_serializer(factories, now): + album = factories["music.Album"](attributed=True) + track = factories["music.Track"](album=album) + expected = { + "id": album.id, + "domain": album.domain_name, + "is_local": album.is_local, + "fid": album.fid, + "title": album.title, + "mbid": album.mbid, + "creation_date": album.creation_date.isoformat().split("+")[0] + "Z", + "release_date": album.release_date.isoformat(), + "cover": { + "original": album.cover.url, + "square_crop": album.cover.crop["400x400"].url, + "medium_square_crop": album.cover.crop["200x200"].url, + "small_square_crop": album.cover.crop["50x50"].url, + }, + "artist": serializers.ManageNestedArtistSerializer(album.artist).data, + "tracks": [serializers.ManageNestedTrackSerializer(track).data], + "attributed_to": serializers.ManageBaseActorSerializer( + album.attributed_to + ).data, + } + s = serializers.ManageAlbumSerializer(album) + + assert s.data == expected + + +def test_manage_track_serializer(factories, now): + track = factories["music.Track"](attributed=True) + setattr(track, "uploads_count", 44) + expected = { + "id": track.id, + "domain": track.domain_name, + "is_local": track.is_local, + "fid": track.fid, + "title": track.title, + "mbid": track.mbid, + "disc_number": track.disc_number, + "position": track.position, + "copyright": track.copyright, + "license": track.license, + "creation_date": track.creation_date.isoformat().split("+")[0] + "Z", + "artist": serializers.ManageNestedArtistSerializer(track.artist).data, + "album": serializers.ManageTrackAlbumSerializer(track.album).data, + "attributed_to": serializers.ManageBaseActorSerializer( + track.attributed_to + ).data, + "uploads_count": 44, + } + s = serializers.ManageTrackSerializer(track) + + assert s.data == expected + + +@pytest.mark.parametrize( + "factory, serializer_class", + [ + ("music.Track", serializers.ManageTrackActionSerializer), + ("music.Album", serializers.ManageAlbumActionSerializer), + ("music.Artist", serializers.ManageArtistActionSerializer), + ], +) +def test_action_serializer_delete(factory, serializer_class, factories): + objects = factories[factory].create_batch(size=5) + s = serializer_class(queryset=None) + + s.handle_delete(objects[0].__class__.objects.all()) + + assert objects[0].__class__.objects.count() == 0 diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index 673c39cbc..923d331d8 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -148,3 +148,144 @@ def test_instance_policy_create(superuser_api_client, factories): policy = domain.instance_policy assert policy.actor == actor + + +def test_artist_list(factories, superuser_api_client, settings): + artist = factories["music.Artist"]() + url = reverse("api:v1:manage:library:artists-list") + response = superuser_api_client.get(url) + + assert response.status_code == 200 + + assert response.data["count"] == 1 + assert response.data["results"][0]["id"] == artist.id + + +def test_artist_detail(factories, superuser_api_client): + artist = factories["music.Artist"]() + url = reverse("api:v1:manage:library:artists-detail", kwargs={"pk": artist.pk}) + response = superuser_api_client.get(url) + + assert response.status_code == 200 + assert response.data["id"] == artist.id + + +def test_artist_detail_stats(factories, superuser_api_client): + artist = factories["music.Artist"]() + url = reverse("api:v1:manage:library:artists-stats", kwargs={"pk": artist.pk}) + response = superuser_api_client.get(url) + expected = { + "libraries": 0, + "uploads": 0, + "listenings": 0, + "playlists": 0, + "mutations": 0, + "track_favorites": 0, + "media_total_size": 0, + "media_downloaded_size": 0, + } + assert response.status_code == 200 + assert response.data == expected + + +def test_artist_delete(factories, superuser_api_client): + artist = factories["music.Artist"]() + url = reverse("api:v1:manage:library:artists-detail", kwargs={"pk": artist.pk}) + response = superuser_api_client.delete(url) + + assert response.status_code == 204 + + +def test_album_list(factories, superuser_api_client, settings): + album = factories["music.Album"]() + factories["music.Album"]() + url = reverse("api:v1:manage:library:albums-list") + response = superuser_api_client.get( + url, {"q": 'artist:"{}"'.format(album.artist.name)} + ) + + assert response.status_code == 200 + + assert response.data["count"] == 1 + assert response.data["results"][0]["id"] == album.id + + +def test_album_detail(factories, superuser_api_client): + album = factories["music.Album"]() + url = reverse("api:v1:manage:library:albums-detail", kwargs={"pk": album.pk}) + response = superuser_api_client.get(url) + + assert response.status_code == 200 + assert response.data["id"] == album.id + + +def test_album_detail_stats(factories, superuser_api_client): + album = factories["music.Album"]() + url = reverse("api:v1:manage:library:albums-stats", kwargs={"pk": album.pk}) + response = superuser_api_client.get(url) + expected = { + "libraries": 0, + "uploads": 0, + "listenings": 0, + "playlists": 0, + "mutations": 0, + "track_favorites": 0, + "media_total_size": 0, + "media_downloaded_size": 0, + } + assert response.status_code == 200 + assert response.data == expected + + +def test_album_delete(factories, superuser_api_client): + album = factories["music.Album"]() + url = reverse("api:v1:manage:library:albums-detail", kwargs={"pk": album.pk}) + response = superuser_api_client.delete(url) + + assert response.status_code == 204 + + +def test_track_list(factories, superuser_api_client, settings): + track = factories["music.Track"]() + url = reverse("api:v1:manage:library:tracks-list") + response = superuser_api_client.get(url) + + assert response.status_code == 200 + + assert response.data["count"] == 1 + assert response.data["results"][0]["id"] == track.id + + +def test_track_detail(factories, superuser_api_client): + track = factories["music.Track"]() + url = reverse("api:v1:manage:library:tracks-detail", kwargs={"pk": track.pk}) + response = superuser_api_client.get(url) + + assert response.status_code == 200 + assert response.data["id"] == track.id + + +def test_track_detail_stats(factories, superuser_api_client): + track = factories["music.Track"]() + url = reverse("api:v1:manage:library:tracks-stats", kwargs={"pk": track.pk}) + response = superuser_api_client.get(url) + expected = { + "libraries": 0, + "uploads": 0, + "listenings": 0, + "playlists": 0, + "mutations": 0, + "track_favorites": 0, + "media_total_size": 0, + "media_downloaded_size": 0, + } + assert response.status_code == 200 + assert response.data == expected + + +def test_track_delete(factories, superuser_api_client): + track = factories["music.Track"]() + url = reverse("api:v1:manage:library:tracks-detail", kwargs={"pk": track.pk}) + response = superuser_api_client.delete(url) + + assert response.status_code == 204 diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py index 4446de7dd..5aa29b3cc 100644 --- a/api/tests/music/test_models.py +++ b/api/tests/music/test_models.py @@ -548,3 +548,9 @@ def test_api_model_mixin_is_local(federation_hostname, fid, expected, settings): settings.FEDERATION_HOSTNAME = federation_hostname obj = models.Track(fid=fid) assert obj.is_local is expected + + +def test_api_model_mixin_domain_name(): + obj = models.Track(fid="https://test.domain:543/something") + + assert obj.domain_name == "test.domain" diff --git a/api/tests/users/oauth/test_api_permissions.py b/api/tests/users/oauth/test_api_permissions.py index aaac8430b..e73d3a3f9 100644 --- a/api/tests/users/oauth/test_api_permissions.py +++ b/api/tests/users/oauth/test_api_permissions.py @@ -53,6 +53,7 @@ from funkwhale_api.users.oauth import scopes "read:instance:policies", "get", ), + ("api:v1:manage:library:artists-list", {}, "read:instance:libraries", "get"), ], ) def test_views_permissions( diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 213ab92ce..8b183b215 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -79,24 +79,6 @@
Administration
+ + Moderation + + + Users + + + Settings +
diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue index 9bbf18f85..1a43a5f9f 100644 --- a/front/src/components/common/ActionTable.vue +++ b/front/src/components/common/ActionTable.vue @@ -30,7 +30,7 @@
Go @@ -44,7 +44,8 @@

- This may affect a lot of elements or have irreversible consequences, please double check this is really what you want. + + This may affect a lot of elements or have irreversible consequences, please double check this is really what you want.

Launch
diff --git a/front/src/components/common/EmptyState.vue b/front/src/components/common/EmptyState.vue index cc9f32ca1..862700679 100644 --- a/front/src/components/common/EmptyState.vue +++ b/front/src/components/common/EmptyState.vue @@ -29,7 +29,7 @@ export default { } -