From 13f36beec3f6b210a9bdc0f09042a13d081160d9 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 24 Jul 2019 10:24:30 +0200 Subject: [PATCH 1/8] See #432: added admin API endpoints to retrieve and delete tags --- api/funkwhale_api/manage/filters.py | 9 ++++++ api/funkwhale_api/manage/serializers.py | 28 ++++++++++++++++ api/funkwhale_api/manage/urls.py | 1 + api/funkwhale_api/manage/views.py | 43 ++++++++++++++++++++++++- api/tests/manage/test_serializers.py | 19 +++++++++++ api/tests/manage/test_views.py | 28 ++++++++++++++++ 6 files changed, 127 insertions(+), 1 deletion(-) diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index de12ab1ab..c8b8e60a5 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -13,6 +13,7 @@ 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 +from funkwhale_api.tags import models as tags_models class ActorField(forms.CharField): @@ -340,3 +341,11 @@ class ManageInstancePolicyFilterSet(filters.FilterSet): "silence_notifications", "reject_media", ] + + +class ManageTagFilterSet(filters.FilterSet): + q = fields.SearchFilter(search_fields=["name"]) + + class Meta: + model = tags_models.Tag + fields = ["q"] diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 67e0178e0..eab3b87f0 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -10,6 +10,7 @@ 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.tags import models as tags_models from funkwhale_api.users import models as users_models from . import filters @@ -564,3 +565,30 @@ class ManageUploadSerializer(serializers.ModelSerializer): "track", "library", ) + + +class ManageTagSerializer(ManageBaseAlbumSerializer): + + tracks_count = serializers.SerializerMethodField() + albums_count = serializers.SerializerMethodField() + artists_count = serializers.SerializerMethodField() + + class Meta: + model = tags_models.Tag + fields = [ + "id", + "name", + "creation_date", + "tracks_count", + "albums_count", + "artists_count", + ] + + def get_tracks_count(self, obj): + return getattr(obj, "_tracks_count", None) + + def get_albums_count(self, obj): + return getattr(obj, "_albums_count", None) + + def get_artists_count(self, obj): + return getattr(obj, "_artists_count", None) diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index 2af18f5b7..b830f0023 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -24,6 +24,7 @@ users_router.register(r"invitations", views.ManageInvitationViewSet, "invitation other_router = routers.OptionalSlashRouter() other_router.register(r"accounts", views.ManageActorViewSet, "accounts") +other_router.register(r"tags", views.ManageTagViewSet, "tags") urlpatterns = [ url( diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index c788dd96b..09737a7a8 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -2,7 +2,7 @@ from rest_framework import mixins, response, viewsets from rest_framework import decorators as rest_decorators from django.db.models import Count, Prefetch, Q, Sum, OuterRef, Subquery -from django.db.models.functions import Coalesce +from django.db.models.functions import Coalesce, Length from django.shortcuts import get_object_or_404 from funkwhale_api.common import models as common_models @@ -14,6 +14,7 @@ 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.tags import models as tags_models from funkwhale_api.users import models as users_models @@ -452,3 +453,43 @@ class ManageInstancePolicyViewSet( def perform_create(self, serializer): serializer.save(actor=self.request.user.actor) + + +class ManageTagViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.CreateModelMixin, + viewsets.GenericViewSet, +): + lookup_field = "name" + queryset = ( + tags_models.Tag.objects.all() + .order_by("-creation_date") + .annotate(items_count=Count("tagged_items")) + .annotate(length=Length("name")) + ) + serializer_class = serializers.ManageTagSerializer + filterset_class = filters.ManageTagFilterSet + required_scope = "instance:libraries" + ordering_fields = ["id", "creation_date", "name", "items_count", "length"] + + def get_queryset(self): + queryset = super().get_queryset() + from django.contrib.contenttypes.models import ContentType + + album_ct = ContentType.objects.get_for_model(music_models.Album) + track_ct = ContentType.objects.get_for_model(music_models.Track) + artist_ct = ContentType.objects.get_for_model(music_models.Artist) + queryset = queryset.annotate( + _albums_count=Count( + "tagged_items", filter=Q(tagged_items__content_type=album_ct) + ), + _tracks_count=Count( + "tagged_items", filter=Q(tagged_items__content_type=track_ct) + ), + _artists_count=Count( + "tagged_items", filter=Q(tagged_items__content_type=artist_ct) + ), + ) + return queryset diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index e621bf14c..c1c532fac 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -496,3 +496,22 @@ def test_action_serializer_delete(factory, serializer_class, factories): s.handle_delete(objects[0].__class__.objects.all()) assert objects[0].__class__.objects.count() == 0 + + +def test_manage_tag_serializer(factories): + tag = factories["tags.Tag"]() + + setattr(tag, "_tracks_count", 42) + setattr(tag, "_albums_count", 54) + setattr(tag, "_artists_count", 66) + expected = { + "id": tag.id, + "name": tag.name, + "creation_date": tag.creation_date.isoformat().split("+")[0] + "Z", + "tracks_count": 42, + "albums_count": 54, + "artists_count": 66, + } + s = serializers.ManageTagSerializer(tag) + + assert s.data == expected diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index 72394052c..710722b90 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -377,3 +377,31 @@ def test_upload_delete(factories, superuser_api_client): response = superuser_api_client.delete(url) assert response.status_code == 204 + + +def test_tag_detail(factories, superuser_api_client): + tag = factories["tags.Tag"]() + url = reverse("api:v1:manage:tags-detail", kwargs={"name": tag.name}) + response = superuser_api_client.get(url) + + assert response.status_code == 200 + assert response.data["name"] == tag.name + + +def test_tag_list(factories, superuser_api_client, settings): + tag = factories["tags.Tag"]() + url = reverse("api:v1:manage:tags-list") + response = superuser_api_client.get(url) + + assert response.status_code == 200 + + assert response.data["count"] == 1 + assert response.data["results"][0]["name"] == tag.name + + +def test_tag_delete(factories, superuser_api_client): + tag = factories["tags.Tag"]() + url = reverse("api:v1:manage:tags-detail", kwargs={"name": tag.name}) + response = superuser_api_client.delete(url) + + assert response.status_code == 204 From aaced60ec1ee0436992647c951d71e4293ed9efa Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 25 Jul 2019 10:17:31 +0200 Subject: [PATCH 2/8] See #432: added /action endpoint to manage tags in batch --- api/funkwhale_api/manage/serializers.py | 10 ++++++++++ api/funkwhale_api/manage/views.py | 10 ++++++++++ api/tests/manage/test_serializers.py | 1 + 3 files changed, 21 insertions(+) diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index eab3b87f0..6eecdd900 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -592,3 +592,13 @@ class ManageTagSerializer(ManageBaseAlbumSerializer): def get_artists_count(self, obj): return getattr(obj, "_artists_count", None) + + +class ManageTagActionSerializer(common_serializers.ActionSerializer): + actions = [common_serializers.Action("delete", allow_all=False)] + filterset_class = filters.ManageTagFilterSet + pk_field = "name" + + @transaction.atomic + def handle_delete(self, objects): + return objects.delete() diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index 09737a7a8..9ecd1fee2 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -493,3 +493,13 @@ class ManageTagViewSet( ), ) return queryset + + @rest_decorators.action(methods=["post"], detail=False) + def action(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = serializers.ManageTagActionSerializer( + request.data, queryset=queryset + ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return response.Response(result, status=200) diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index c1c532fac..af2a214a6 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -487,6 +487,7 @@ def test_manage_upload_serializer(factories, now): ("music.Artist", serializers.ManageArtistActionSerializer), ("music.Library", serializers.ManageLibraryActionSerializer), ("music.Upload", serializers.ManageUploadActionSerializer), + ("tags.Tag", serializers.ManageTagActionSerializer), ], ) def test_action_serializer_delete(factory, serializer_class, factories): From 1674c771ca6baff17bb7b8e7ee2028580934c320 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 25 Jul 2019 10:40:34 +0200 Subject: [PATCH 3/8] See #432: added tag admin UI (list, detail) --- front/src/components/library/TagDetail.vue | 5 + .../components/manage/library/TagsTable.vue | 209 +++++++ front/src/components/mixins/Translations.vue | 2 + front/src/router/index.js | 569 +++++++++++------- front/src/views/admin/library/Base.vue | 3 + front/src/views/admin/library/TagDetail.vue | 215 +++++++ front/src/views/admin/library/TagsList.vue | 29 + 7 files changed, 802 insertions(+), 230 deletions(-) create mode 100644 front/src/components/manage/library/TagsTable.vue create mode 100644 front/src/views/admin/library/TagDetail.vue create mode 100644 front/src/views/admin/library/TagsList.vue diff --git a/front/src/components/library/TagDetail.vue b/front/src/components/library/TagDetail.vue index 904bce7ae..e928caa29 100644 --- a/front/src/components/library/TagDetail.vue +++ b/front/src/components/library/TagDetail.vue @@ -7,6 +7,11 @@ + + + Open in moderation interface + +
diff --git a/front/src/components/manage/library/TagsTable.vue b/front/src/components/manage/library/TagsTable.vue new file mode 100644 index 000000000..b26e86859 --- /dev/null +++ b/front/src/components/manage/library/TagsTable.vue @@ -0,0 +1,209 @@ + + + diff --git a/front/src/components/mixins/Translations.vue b/front/src/components/mixins/Translations.vue index 56ea3ed15..e6e809252 100644 --- a/front/src/components/mixins/Translations.vue +++ b/front/src/components/mixins/Translations.vue @@ -52,6 +52,8 @@ export default { album_title: this.$pgettext('Content/*/Dropdown/Noun', 'Album name'), artist_name: this.$pgettext('Content/*/Dropdown/Noun', 'Artist name'), name: this.$pgettext('*/*/*/Noun', 'Name'), + length: this.$pgettext('*/*/*/Noun', 'Length'), + items_count: this.$pgettext('*/*/*/Noun', 'Items'), size: this.$pgettext('Content/Library/*/in MB', 'Size'), bitrate: this.$pgettext('Content/Track/*/Noun', 'Bitrate'), duration: this.$pgettext('Content/*/*', 'Duration'), diff --git a/front/src/router/index.js b/front/src/router/index.js index 1d4996746..af21b168b 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -1,430 +1,508 @@ -import Vue from 'vue' -import Router from 'vue-router' +import Vue from "vue" +import Router from "vue-router" Vue.use(Router) -console.log('PROCESS', process.env) +console.log("PROCESS", process.env) export default new Router({ - mode: 'history', - linkActiveClass: 'active', - base: process.env.VUE_APP_ROUTER_BASE_URL || '/', + mode: "history", + linkActiveClass: "active", + base: process.env.VUE_APP_ROUTER_BASE_URL || "/", routes: [ { - path: '/', - name: 'index', + path: "/", + name: "index", component: () => - import(/* webpackChunkName: "core" */ "@/components/Home"), + import(/* webpackChunkName: "core" */ "@/components/Home") }, { - path: '/front', - name: 'front', - redirect: '/' + path: "/front", + name: "front", + redirect: "/" }, { - path: '/about', - name: 'about', + path: "/about", + name: "about", component: () => - import(/* webpackChunkName: "core" */ "@/components/About"), + import(/* webpackChunkName: "core" */ "@/components/About") }, { - path: '/login', - name: 'login', + path: "/login", + name: "login", component: () => import(/* webpackChunkName: "core" */ "@/components/auth/Login"), - props: (route) => ({ next: route.query.next || '/library' }) + props: route => ({ next: route.query.next || "/library" }) }, { - path: '/notifications', - name: 'notifications', + path: "/notifications", + name: "notifications", component: () => - import(/* webpackChunkName: "core" */ "@/views/Notifications"), + import(/* webpackChunkName: "core" */ "@/views/Notifications") }, { - path: '/auth/password/reset', - name: 'auth.password-reset', + path: "/auth/password/reset", + name: "auth.password-reset", component: () => import(/* webpackChunkName: "core" */ "@/views/auth/PasswordReset"), - props: (route) => ({ + props: route => ({ defaultEmail: route.query.email }) }, { - path: '/auth/email/confirm', - name: 'auth.email-confirm', + path: "/auth/email/confirm", + name: "auth.email-confirm", component: () => import(/* webpackChunkName: "core" */ "@/views/auth/EmailConfirm"), - props: (route) => ({ + props: route => ({ defaultKey: route.query.key }) }, { - path: '/auth/password/reset/confirm', - name: 'auth.password-reset-confirm', + path: "/auth/password/reset/confirm", + name: "auth.password-reset-confirm", component: () => - import(/* webpackChunkName: "core" */ "@/views/auth/PasswordResetConfirm"), - props: (route) => ({ + import( + /* webpackChunkName: "core" */ "@/views/auth/PasswordResetConfirm" + ), + props: route => ({ defaultUid: route.query.uid, defaultToken: route.query.token }) }, { - path: '/authorize', - name: 'authorize', + path: "/authorize", + name: "authorize", component: () => import(/* webpackChunkName: "core" */ "@/components/auth/Authorize"), - props: (route) => ({ + props: route => ({ clientId: route.query.client_id, redirectUri: route.query.redirect_uri, scope: route.query.scope, responseType: route.query.response_type, nonce: route.query.nonce, - state: route.query.state, + state: route.query.state }) }, { - path: '/signup', - name: 'signup', + path: "/signup", + name: "signup", component: () => import(/* webpackChunkName: "core" */ "@/components/auth/Signup"), - props: (route) => ({ + props: route => ({ defaultInvitation: route.query.invitation }) }, { - path: '/logout', - name: 'logout', + path: "/logout", + name: "logout", component: () => - import(/* webpackChunkName: "core" */ "@/components/auth/Logout"), - + import(/* webpackChunkName: "core" */ "@/components/auth/Logout") }, { - path: '/settings', - name: 'settings', + path: "/settings", + name: "settings", component: () => - import(/* webpackChunkName: "core" */ "@/components/auth/Settings"), + import(/* webpackChunkName: "core" */ "@/components/auth/Settings") }, { - path: '/settings/applications/new', - name: 'settings.applications.new', - props: (route) => ({ + path: "/settings/applications/new", + name: "settings.applications.new", + props: route => ({ scopes: route.query.scopes, name: route.query.name, - redirect_uris: route.query.redirect_uris, + redirect_uris: route.query.redirect_uris }), component: () => - import(/* webpackChunkName: "core" */ "@/components/auth/ApplicationNew"), + import( + /* webpackChunkName: "core" */ "@/components/auth/ApplicationNew" + ) }, { - path: '/settings/applications/:id/edit', - name: 'settings.applications.edit', + path: "/settings/applications/:id/edit", + name: "settings.applications.edit", component: () => - import(/* webpackChunkName: "core" */ "@/components/auth/ApplicationEdit"), + import( + /* webpackChunkName: "core" */ "@/components/auth/ApplicationEdit" + ), props: true }, { - path: '/@:username', - name: 'profile', + path: "/@:username", + name: "profile", component: () => import(/* webpackChunkName: "core" */ "@/components/auth/Profile"), props: true }, { - path: '/favorites', + path: "/favorites", component: () => import(/* webpackChunkName: "core" */ "@/components/favorites/List"), - props: (route) => ({ + props: route => ({ defaultOrdering: route.query.ordering, defaultPage: route.query.page, defaultPaginateBy: route.query.paginateBy }) }, { - path: '/content', + path: "/content", component: () => import(/* webpackChunkName: "core" */ "@/views/content/Base"), children: [ { - path: '', - name: 'content.index', + path: "", + name: "content.index", component: () => - import(/* webpackChunkName: "core" */ "@/views/content/Home"), + import(/* webpackChunkName: "core" */ "@/views/content/Home") } ] }, { - path: '/content/libraries/tracks', + path: "/content/libraries/tracks", component: () => import(/* webpackChunkName: "core" */ "@/views/content/Base"), children: [ { - path: '', - name: 'content.libraries.files', + path: "", + name: "content.libraries.files", component: () => - import(/* webpackChunkName: "core" */ "@/views/content/libraries/Files"), - props: (route) => ({ + import( + /* webpackChunkName: "core" */ "@/views/content/libraries/Files" + ), + props: route => ({ query: route.query.q }) } ] }, { - path: '/content/libraries', + path: "/content/libraries", component: () => import(/* webpackChunkName: "core" */ "@/views/content/Base"), children: [ { - path: '', - name: 'content.libraries.index', + path: "", + name: "content.libraries.index", component: () => - import(/* webpackChunkName: "core" */ "@/views/content/libraries/Home"), + import( + /* webpackChunkName: "core" */ "@/views/content/libraries/Home" + ) }, { - path: ':id/upload', - name: 'content.libraries.detail.upload', + path: ":id/upload", + name: "content.libraries.detail.upload", component: () => - import(/* webpackChunkName: "core" */ "@/views/content/libraries/Upload"), - props: (route) => ({ + import( + /* webpackChunkName: "core" */ "@/views/content/libraries/Upload" + ), + props: route => ({ id: route.params.id, defaultImportReference: route.query.import }) }, { - path: ':id', - name: 'content.libraries.detail', + path: ":id", + name: "content.libraries.detail", component: () => - import(/* webpackChunkName: "core" */ "@/views/content/libraries/Detail"), + import( + /* webpackChunkName: "core" */ "@/views/content/libraries/Detail" + ), props: true } ] }, { - path: '/content/remote', + path: "/content/remote", component: () => import(/* webpackChunkName: "core" */ "@/views/content/Base"), children: [ { - path: '', - name: 'content.remote.index', + path: "", + name: "content.remote.index", component: () => - import(/* webpackChunkName: "core" */ "@/views/content/remote/Home"), + import(/* webpackChunkName: "core" */ "@/views/content/remote/Home") } ] }, { - path: '/manage/settings', - name: 'manage.settings', + path: "/manage/settings", + name: "manage.settings", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/Settings"), + import(/* webpackChunkName: "admin" */ "@/views/admin/Settings") }, { - path: '/manage/library', + path: "/manage/library", component: () => import(/* webpackChunkName: "admin" */ "@/views/admin/library/Base"), children: [ { - path: 'edits', - name: 'manage.library.edits', + path: "edits", + name: "manage.library.edits", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/EditsList"), - props: (route) => { + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/EditsList" + ), + props: route => { return { - defaultQuery: route.query.q, + defaultQuery: route.query.q } } }, { - path: 'artists', - name: 'manage.library.artists', + path: "artists", + name: "manage.library.artists", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/ArtistsList"), - props: (route) => { + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/ArtistsList" + ), + props: route => { return { - defaultQuery: route.query.q, + defaultQuery: route.query.q } } }, { - path: 'artists/:id', - name: 'manage.library.artists.detail', + path: "artists/:id", + name: "manage.library.artists.detail", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/ArtistDetail"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/ArtistDetail" + ), props: true }, { - path: 'albums', - name: 'manage.library.albums', + path: "albums", + name: "manage.library.albums", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/AlbumsList"), - props: (route) => { + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/AlbumsList" + ), + props: route => { return { - defaultQuery: route.query.q, + defaultQuery: route.query.q } } }, { - path: 'albums/:id', - name: 'manage.library.albums.detail', + path: "albums/:id", + name: "manage.library.albums.detail", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/AlbumDetail"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/AlbumDetail" + ), props: true }, { - path: 'tracks', - name: 'manage.library.tracks', + path: "tracks", + name: "manage.library.tracks", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/TracksList"), - props: (route) => { + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/TracksList" + ), + props: route => { return { - defaultQuery: route.query.q, + defaultQuery: route.query.q } } }, { - path: 'tracks/:id', - name: 'manage.library.tracks.detail', + path: "tracks/:id", + name: "manage.library.tracks.detail", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/TrackDetail"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/TrackDetail" + ), props: true }, { - path: 'libraries', - name: 'manage.library.libraries', + path: "libraries", + name: "manage.library.libraries", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/LibrariesList"), - props: (route) => { + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/LibrariesList" + ), + props: route => { return { - defaultQuery: route.query.q, + defaultQuery: route.query.q } } }, { - path: 'libraries/:id', - name: 'manage.library.libraries.detail', + path: "libraries/:id", + name: "manage.library.libraries.detail", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/LibraryDetail"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/LibraryDetail" + ), props: true }, { - path: 'uploads', - name: 'manage.library.uploads', + path: "uploads", + name: "manage.library.uploads", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/UploadsList"), - props: (route) => { + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/UploadsList" + ), + props: route => { return { - defaultQuery: route.query.q, + defaultQuery: route.query.q } } }, { - path: 'uploads/:id', - name: 'manage.library.uploads.detail', + path: "uploads/:id", + name: "manage.library.uploads.detail", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/UploadDetail"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/UploadDetail" + ), props: true }, + { + path: "tags", + name: "manage.library.tags", + component: () => + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/TagsList" + ), + props: route => { + return { + defaultQuery: route.query.q + } + } + }, + { + path: "tags/:id", + name: "manage.library.tags.detail", + component: () => + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/TagDetail" + ), + props: true + } ] }, { - path: '/manage/users', + path: "/manage/users", component: () => import(/* webpackChunkName: "admin" */ "@/views/admin/users/Base"), children: [ { - path: 'users', - name: 'manage.users.users.list', + path: "users", + name: "manage.users.users.list", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/users/UsersList"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/users/UsersList" + ) }, { - path: 'invitations', - name: 'manage.users.invitations.list', + path: "invitations", + name: "manage.users.invitations.list", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/users/InvitationsList"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/users/InvitationsList" + ) } ] }, { - path: '/manage/moderation', + path: "/manage/moderation", component: () => import(/* webpackChunkName: "admin" */ "@/views/admin/moderation/Base"), children: [ { - path: 'domains', - name: 'manage.moderation.domains.list', + path: "domains", + name: "manage.moderation.domains.list", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/moderation/DomainsList"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/moderation/DomainsList" + ) }, { - path: 'domains/:id', - name: 'manage.moderation.domains.detail', + path: "domains/:id", + name: "manage.moderation.domains.detail", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/moderation/DomainsDetail"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/moderation/DomainsDetail" + ), props: true }, { - path: 'accounts', - name: 'manage.moderation.accounts.list', + path: "accounts", + name: "manage.moderation.accounts.list", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsList"), - props: (route) => { + import( + /* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsList" + ), + props: route => { return { - defaultQuery: route.query.q, - + defaultQuery: route.query.q } } }, { - path: 'accounts/:id', - name: 'manage.moderation.accounts.detail', + path: "accounts/:id", + name: "manage.moderation.accounts.detail", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsDetail"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsDetail" + ), props: true } ] }, { - path: '/library', + path: "/library", component: () => import(/* webpackChunkName: "core" */ "@/components/library/Library"), children: [ { - path: '', + path: "", component: () => import(/* webpackChunkName: "core" */ "@/components/library/Home"), - name: 'library.index' + name: "library.index" }, { - path: 'artists/', - name: 'library.artists.browse', + path: "artists/", + name: "library.artists.browse", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/Artists"), - props: (route) => ({ + import( + /* webpackChunkName: "core" */ "@/components/library/Artists" + ), + props: route => ({ defaultOrdering: route.query.ordering, defaultQuery: route.query.query, - defaultTags: Array.isArray(route.query.tag || []) ? route.query.tag : [route.query.tag], + defaultTags: Array.isArray(route.query.tag || []) + ? route.query.tag + : [route.query.tag], defaultPaginateBy: route.query.paginateBy, defaultPage: route.query.page }) }, { - path: 'albums/', - name: 'library.albums.browse', + path: "albums/", + name: "library.albums.browse", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/Albums"), - props: (route) => ({ + import( + /* webpackChunkName: "core" */ "@/components/library/Albums" + ), + props: route => ({ defaultOrdering: route.query.ordering, defaultQuery: route.query.query, - defaultTags: Array.isArray(route.query.tag || []) ? route.query.tag : [route.query.tag], + defaultTags: Array.isArray(route.query.tag || []) + ? route.query.tag + : [route.query.tag], defaultPaginateBy: route.query.paginateBy, defaultPage: route.query.page }) }, { - path: 'radios/', - name: 'library.radios.browse', + path: "radios/", + name: "library.radios.browse", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/Radios"), - props: (route) => ({ + import( + /* webpackChunkName: "core" */ "@/components/library/Radios" + ), + props: route => ({ defaultOrdering: route.query.ordering, defaultQuery: route.query.query, defaultPaginateBy: route.query.paginateBy, @@ -432,32 +510,36 @@ export default new Router({ }) }, { - path: 'radios/build', - name: 'library.radios.build', + path: "radios/build", + name: "library.radios.build", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/radios/Builder"), + import( + /* webpackChunkName: "core" */ "@/components/library/radios/Builder" + ), props: true }, { - path: 'radios/build/:id', - name: 'library.radios.edit', + path: "radios/build/:id", + name: "library.radios.edit", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/radios/Builder"), + import( + /* webpackChunkName: "core" */ "@/components/library/radios/Builder" + ), props: true }, { - path: 'radios/:id', - name: 'library.radios.detail', + path: "radios/:id", + name: "library.radios.detail", component: () => import(/* webpackChunkName: "core" */ "@/views/radios/Detail"), props: true }, { - path: 'playlists/', - name: 'library.playlists.browse', + path: "playlists/", + name: "library.playlists.browse", component: () => import(/* webpackChunkName: "core" */ "@/views/playlists/List"), - props: (route) => ({ + props: route => ({ defaultOrdering: route.query.ordering, defaultQuery: route.query.query, defaultPaginateBy: route.query.paginateBy, @@ -465,112 +547,139 @@ export default new Router({ }) }, { - path: 'playlists/:id', - name: 'library.playlists.detail', + path: "playlists/:id", + name: "library.playlists.detail", component: () => import(/* webpackChunkName: "core" */ "@/views/playlists/Detail"), - props: (route) => ({ + props: route => ({ id: route.params.id, - defaultEdit: route.query.mode === 'edit' }) + defaultEdit: route.query.mode === "edit" + }) }, { - path: 'tags/:id', - name: 'library.tags.detail', + path: "tags/:id", + name: "library.tags.detail", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/TagDetail"), - props: true, + import( + /* webpackChunkName: "core" */ "@/components/library/TagDetail" + ), + props: true }, { - path: 'artists/:id', + path: "artists/:id", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/ArtistBase"), + import( + /* webpackChunkName: "core" */ "@/components/library/ArtistBase" + ), props: true, children: [ { - path: '', - name: 'library.artists.detail', + path: "", + name: "library.artists.detail", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/ArtistDetail"), + import( + /* webpackChunkName: "core" */ "@/components/library/ArtistDetail" + ) }, { - path: 'edit', - name: 'library.artists.edit', + path: "edit", + name: "library.artists.edit", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/ArtistEdit"), + import( + /* webpackChunkName: "core" */ "@/components/library/ArtistEdit" + ) }, { - path: 'edit/:editId', - name: 'library.artists.edit.detail', + path: "edit/:editId", + name: "library.artists.edit.detail", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/EditDetail"), - props: true, + import( + /* webpackChunkName: "core" */ "@/components/library/EditDetail" + ), + props: true } ] }, { - path: 'albums/:id', + path: "albums/:id", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/AlbumBase"), + import( + /* webpackChunkName: "core" */ "@/components/library/AlbumBase" + ), props: true, children: [ { - path: '', - name: 'library.albums.detail', + path: "", + name: "library.albums.detail", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/AlbumDetail"), + import( + /* webpackChunkName: "core" */ "@/components/library/AlbumDetail" + ) }, { - path: 'edit', - name: 'library.albums.edit', + path: "edit", + name: "library.albums.edit", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/AlbumEdit"), + import( + /* webpackChunkName: "core" */ "@/components/library/AlbumEdit" + ) }, { - path: 'edit/:editId', - name: 'library.albums.edit.detail', + path: "edit/:editId", + name: "library.albums.edit.detail", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/EditDetail"), - props: true, + import( + /* webpackChunkName: "core" */ "@/components/library/EditDetail" + ), + props: true } ] }, { - path: 'tracks/:id', + path: "tracks/:id", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/TrackBase"), + import( + /* webpackChunkName: "core" */ "@/components/library/TrackBase" + ), props: true, children: [ { - path: '', - name: 'library.tracks.detail', + path: "", + name: "library.tracks.detail", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/TrackDetail"), + import( + /* webpackChunkName: "core" */ "@/components/library/TrackDetail" + ) }, { - path: 'edit', - name: 'library.tracks.edit', + path: "edit", + name: "library.tracks.edit", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/TrackEdit"), + import( + /* webpackChunkName: "core" */ "@/components/library/TrackEdit" + ) }, { - path: 'edit/:editId', - name: 'library.tracks.edit.detail', + path: "edit/:editId", + name: "library.tracks.edit.detail", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/EditDetail"), - props: true, + import( + /* webpackChunkName: "core" */ "@/components/library/EditDetail" + ), + props: true } ] - }, + } ] }, { - path: '*/index.html', - redirect: '/' + path: "*/index.html", + redirect: "/" }, { - path: '*', + path: "*", component: () => - import(/* webpackChunkName: "core" */ "@/components/PageNotFound"), + import(/* webpackChunkName: "core" */ "@/components/PageNotFound") } ] }) diff --git a/front/src/views/admin/library/Base.vue b/front/src/views/admin/library/Base.vue index 009e1ca95..9538f9cd2 100644 --- a/front/src/views/admin/library/Base.vue +++ b/front/src/views/admin/library/Base.vue @@ -19,6 +19,9 @@ Uploads + Tags
diff --git a/front/src/views/admin/library/TagDetail.vue b/front/src/views/admin/library/TagDetail.vue new file mode 100644 index 000000000..24c45dea4 --- /dev/null +++ b/front/src/views/admin/library/TagDetail.vue @@ -0,0 +1,215 @@ + + + diff --git a/front/src/views/admin/library/TagsList.vue b/front/src/views/admin/library/TagsList.vue new file mode 100644 index 000000000..2ac4e4e14 --- /dev/null +++ b/front/src/views/admin/library/TagsList.vue @@ -0,0 +1,29 @@ + + + From a48a32c7e9fb4ca0b00b242b3fdeb67c161127e5 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 25 Jul 2019 10:43:34 +0200 Subject: [PATCH 4/8] See #432: can now filter with tags in Track/album/artist admin --- api/funkwhale_api/manage/filters.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index c8b8e60a5..3ce55e039 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -62,6 +62,10 @@ class ManageArtistFilterSet(filters.FilterSet): "field": forms.IntegerField(), "distinct": True, }, + "tag": { + "to": "tagged_items__tag__name", + "distinct": True, + }, }, ) ) @@ -91,6 +95,10 @@ class ManageAlbumFilterSet(filters.FilterSet): "field": forms.IntegerField(), "distinct": True, }, + "tag": { + "to": "tagged_items__tag__name", + "distinct": True, + }, }, ) ) @@ -129,6 +137,10 @@ class ManageTrackFilterSet(filters.FilterSet): "field": forms.IntegerField(), "distinct": True, }, + "tag": { + "to": "tagged_items__tag__name", + "distinct": True, + }, }, ) ) From 84f830829fa52ce6882443b1c7b0e22166f26cf8 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 25 Jul 2019 10:50:23 +0200 Subject: [PATCH 5/8] See #432: include tags in admin API representation of tracks, albums and artists --- api/funkwhale_api/manage/filters.py | 15 +++------------ api/funkwhale_api/manage/serializers.py | 18 ++++++++++++++++++ api/funkwhale_api/manage/views.py | 5 ++++- api/tests/manage/test_serializers.py | 3 +++ 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index 3ce55e039..af9ded746 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -62,10 +62,7 @@ class ManageArtistFilterSet(filters.FilterSet): "field": forms.IntegerField(), "distinct": True, }, - "tag": { - "to": "tagged_items__tag__name", - "distinct": True, - }, + "tag": {"to": "tagged_items__tag__name", "distinct": True}, }, ) ) @@ -95,10 +92,7 @@ class ManageAlbumFilterSet(filters.FilterSet): "field": forms.IntegerField(), "distinct": True, }, - "tag": { - "to": "tagged_items__tag__name", - "distinct": True, - }, + "tag": {"to": "tagged_items__tag__name", "distinct": True}, }, ) ) @@ -137,10 +131,7 @@ class ManageTrackFilterSet(filters.FilterSet): "field": forms.IntegerField(), "distinct": True, }, - "tag": { - "to": "tagged_items__tag__name", - "distinct": True, - }, + "tag": {"to": "tagged_items__tag__name", "distinct": True}, }, ) ) diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 6eecdd900..3ea390b87 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -377,6 +377,7 @@ class ManageArtistSerializer(ManageBaseArtistSerializer): albums = ManageNestedAlbumSerializer(many=True) tracks = ManageNestedTrackSerializer(many=True) attributed_to = ManageBaseActorSerializer() + tags = serializers.SerializerMethodField() class Meta: model = music_models.Artist @@ -384,8 +385,13 @@ class ManageArtistSerializer(ManageBaseArtistSerializer): "albums", "tracks", "attributed_to", + "tags", ] + def get_tags(self, obj): + tagged_items = getattr(obj, "_prefetched_tagged_items", []) + return [ti.tag.name for ti in tagged_items] + class ManageNestedArtistSerializer(ManageBaseArtistSerializer): pass @@ -395,6 +401,7 @@ class ManageAlbumSerializer(ManageBaseAlbumSerializer): tracks = ManageNestedTrackSerializer(many=True) attributed_to = ManageBaseActorSerializer() artist = ManageNestedArtistSerializer() + tags = serializers.SerializerMethodField() class Meta: model = music_models.Album @@ -402,8 +409,13 @@ class ManageAlbumSerializer(ManageBaseAlbumSerializer): "artist", "tracks", "attributed_to", + "tags", ] + def get_tags(self, obj): + tagged_items = getattr(obj, "_prefetched_tagged_items", []) + return [ti.tag.name for ti in tagged_items] + class ManageTrackAlbumSerializer(ManageBaseAlbumSerializer): artist = ManageNestedArtistSerializer() @@ -418,6 +430,7 @@ class ManageTrackSerializer(ManageNestedTrackSerializer): album = ManageTrackAlbumSerializer() attributed_to = ManageBaseActorSerializer() uploads_count = serializers.SerializerMethodField() + tags = serializers.SerializerMethodField() class Meta: model = music_models.Track @@ -426,11 +439,16 @@ class ManageTrackSerializer(ManageNestedTrackSerializer): "album", "attributed_to", "uploads_count", + "tags", ] def get_uploads_count(self, obj): return getattr(obj, "uploads_count", None) + def get_tags(self, obj): + tagged_items = getattr(obj, "_prefetched_tagged_items", []) + return [ti.tag.name for ti in tagged_items] + class ManageTrackActionSerializer(common_serializers.ActionSerializer): actions = [common_serializers.Action("delete", allow_all=False)] diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index 9ecd1fee2..ab6c0c9f2 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -12,6 +12,7 @@ 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.music import views as music_views from funkwhale_api.moderation import models as moderation_models from funkwhale_api.playlists import models as playlists_models from funkwhale_api.tags import models as tags_models @@ -71,6 +72,7 @@ class ManageArtistViewSet( tracks_count=Count("tracks") ), ), + music_views.TAG_PREFETCH, ) ) serializer_class = serializers.ManageArtistSerializer @@ -108,7 +110,7 @@ class ManageAlbumViewSet( music_models.Album.objects.all() .order_by("-id") .select_related("attributed_to", "artist") - .prefetch_related("tracks") + .prefetch_related("tracks", music_views.TAG_PREFETCH) ) serializer_class = serializers.ManageAlbumSerializer filterset_class = filters.ManageAlbumFilterSet @@ -152,6 +154,7 @@ class ManageTrackViewSet( .order_by("-id") .select_related("attributed_to", "artist", "album__artist") .annotate(uploads_count=Coalesce(Subquery(uploads_subquery), 0)) + .prefetch_related(music_views.TAG_PREFETCH) ) serializer_class = serializers.ManageTrackSerializer filterset_class = filters.ManageTrackFilterSet diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index af2a214a6..201f14ede 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -297,6 +297,7 @@ def test_manage_artist_serializer(factories, now): "attributed_to": serializers.ManageBaseActorSerializer( artist.attributed_to ).data, + "tags": [], } s = serializers.ManageArtistSerializer(artist) @@ -387,6 +388,7 @@ def test_manage_album_serializer(factories, now): "attributed_to": serializers.ManageBaseActorSerializer( album.attributed_to ).data, + "tags": [], } s = serializers.ManageAlbumSerializer(album) @@ -414,6 +416,7 @@ def test_manage_track_serializer(factories, now): track.attributed_to ).data, "uploads_count": 44, + "tags": [], } s = serializers.ManageTrackSerializer(track) From 7f41aabef8198d3ed2d7e1bec4aa93897f304c9a Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 25 Jul 2019 10:57:59 +0200 Subject: [PATCH 6/8] See #432: display tags on artist/album/track admin detail page --- front/src/components/tags/List.vue | 3 ++- front/src/views/admin/library/AlbumDetail.vue | 11 +++++++++-- front/src/views/admin/library/ArtistDetail.vue | 9 ++++++++- front/src/views/admin/library/TrackDetail.vue | 10 +++++++++- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/front/src/components/tags/List.vue b/front/src/components/tags/List.vue index 6c280822b..e5bba5b58 100644 --- a/front/src/components/tags/List.vue +++ b/front/src/components/tags/List.vue @@ -1,7 +1,7 @@