diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py index b995afcaa0e28cf32f1746a198a46e05d0e5e466..161c581025da4c68d33de0277d956ed710f1b4ed 100644 --- a/api/funkwhale_api/common/serializers.py +++ b/api/funkwhale_api/common/serializers.py @@ -2,10 +2,10 @@ from rest_framework import serializers class Action(object): - def __init__(self, name, allow_all=False, filters=None): + def __init__(self, name, allow_all=False, qs_filter=None): self.name = name self.allow_all = allow_all - self.filters = filters or {} + self.qs_filter = qs_filter def __repr__(self): return "<Action {}>".format(self.name) @@ -65,7 +65,6 @@ class ActionSerializer(serializers.Serializer): "You cannot apply this action on all objects" ) final_filters = data.get("filters", {}) or {} - final_filters.update(data["action"].filters) if self.filterset_class and final_filters: qs_filterset = self.filterset_class(final_filters, queryset=data["objects"]) try: @@ -74,6 +73,9 @@ class ActionSerializer(serializers.Serializer): raise serializers.ValidationError("Invalid filters") data["objects"] = qs_filterset.qs + if data["action"].qs_filter: + data["objects"] = data["action"].qs_filter(data["objects"]) + data["count"] = data["objects"].count() if data["count"] < 1: raise serializers.ValidationError("No object matching your request") diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index 5f83ebf1a3143e201891448e2f43206c14015d7c..8098ef1a2f49ee8b6275351a56faae77eb7e8dd6 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -2,6 +2,7 @@ from django_filters import rest_framework as filters from funkwhale_api.common import fields from funkwhale_api.music import models as music_models +from funkwhale_api.requests import models as requests_models from funkwhale_api.users import models as users_models @@ -50,3 +51,13 @@ class ManageInvitationFilterSet(filters.FilterSet): if value is None: return queryset return queryset.open(value) + + +class ManageImportRequestFilterSet(filters.FilterSet): + q = fields.SearchFilter( + search_fields=["user__username", "albums", "artist_name", "comment"] + ) + + class Meta: + model = requests_models.ImportRequest + fields = ["q", "status"] diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index c639d3a3c2ad26301c203df9c2f393df56f8e926..db5b9272675a6ec8fc3461ebaf69da3ef1cd5088 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -1,8 +1,10 @@ from django.db import transaction +from django.utils import timezone from rest_framework import serializers from funkwhale_api.common import serializers as common_serializers from funkwhale_api.music import models as music_models +from funkwhale_api.requests import models as requests_models from funkwhale_api.users import models as users_models from . import filters @@ -154,9 +156,79 @@ class ManageInvitationSerializer(serializers.ModelSerializer): class ManageInvitationActionSerializer(common_serializers.ActionSerializer): - actions = [common_serializers.Action("delete", allow_all=False)] + actions = [ + common_serializers.Action( + "delete", allow_all=False, qs_filter=lambda qs: qs.open() + ) + ] filterset_class = filters.ManageInvitationFilterSet @transaction.atomic def handle_delete(self, objects): return objects.delete() + + +class ManageImportRequestSerializer(serializers.ModelSerializer): + user = ManageUserSimpleSerializer(required=False) + + class Meta: + model = requests_models.ImportRequest + fields = [ + "id", + "status", + "creation_date", + "imported_date", + "user", + "albums", + "artist_name", + "comment", + ] + read_only_fields = [ + "id", + "status", + "creation_date", + "imported_date", + "user", + "albums", + "artist_name", + "comment", + ] + + def validate_code(self, value): + if not value: + return value + if users_models.Invitation.objects.filter(code__iexact=value).exists(): + raise serializers.ValidationError( + "An invitation with this code already exists" + ) + return value + + +class ManageImportRequestActionSerializer(common_serializers.ActionSerializer): + actions = [ + common_serializers.Action( + "mark_closed", + allow_all=True, + qs_filter=lambda qs: qs.filter(status__in=["pending", "accepted"]), + ), + common_serializers.Action( + "mark_imported", + allow_all=True, + qs_filter=lambda qs: qs.filter(status__in=["pending", "accepted"]), + ), + common_serializers.Action("delete", allow_all=False), + ] + filterset_class = filters.ManageImportRequestFilterSet + + @transaction.atomic + def handle_delete(self, objects): + return objects.delete() + + @transaction.atomic + def handle_mark_closed(self, objects): + return objects.update(status="closed") + + @transaction.atomic + def handle_mark_imported(self, objects): + now = timezone.now() + return objects.update(status="imported", imported_date=now) diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index 3d4e15db9327855ff4df5f983fe8a70dba26d452..8285ade0699b45e49cc45e654bfa1baa467406ee 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -5,6 +5,10 @@ from . import views library_router = routers.SimpleRouter() library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files") +requests_router = routers.SimpleRouter() +requests_router.register( + r"import-requests", views.ManageImportRequestViewSet, "import-requests" +) users_router = routers.SimpleRouter() users_router.register(r"users", views.ManageUserViewSet, "users") users_router.register(r"invitations", views.ManageInvitationViewSet, "invitations") @@ -12,4 +16,7 @@ users_router.register(r"invitations", views.ManageInvitationViewSet, "invitation urlpatterns = [ url(r"^library/", include((library_router.urls, "instance"), namespace="library")), url(r"^users/", include((users_router.urls, "instance"), namespace="users")), + url( + r"^requests/", include((requests_router.urls, "instance"), namespace="requests") + ), ] diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index ae3c08a57c829dbc8330c37568a3043fa5f8484e..89d2afe4593f5fe0c34118af9976e43307feaf05 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -3,6 +3,7 @@ from rest_framework.decorators import list_route from funkwhale_api.common import preferences from funkwhale_api.music import models as music_models +from funkwhale_api.requests import models as requests_models from funkwhale_api.users import models as users_models from funkwhale_api.users.permissions import HasUserPermission @@ -10,10 +11,7 @@ from . import filters, serializers class ManageTrackFileViewSet( - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - viewsets.GenericViewSet, + mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet ): queryset = ( music_models.TrackFile.objects.all() @@ -69,7 +67,6 @@ class ManageInvitationViewSet( mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, - mixins.DestroyModelMixin, viewsets.GenericViewSet, ): queryset = ( @@ -96,3 +93,31 @@ class ManageInvitationViewSet( serializer.is_valid(raise_exception=True) result = serializer.save() return response.Response(result, status=200) + + +class ManageImportRequestViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + queryset = ( + requests_models.ImportRequest.objects.all() + .order_by("-id") + .select_related("user") + ) + serializer_class = serializers.ManageImportRequestSerializer + filter_class = filters.ManageImportRequestFilterSet + permission_classes = (HasUserPermission,) + required_permissions = ["library"] + ordering_fields = ["creation_date", "imported_date"] + + @list_route(methods=["post"]) + def action(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = serializers.ManageImportRequestActionSerializer( + request.data, queryset=queryset + ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return response.Response(result, status=200) diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 8b638ce7daff025cd7d68eaba2d93dcb7ec1f562..4f5e3dfc66b7b83247d9dc628176739c29498caf 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -539,7 +539,7 @@ class ImportBatch(models.Model): related_name="import_batches", null=True, blank=True, - on_delete=models.CASCADE, + on_delete=models.SET_NULL, ) class Meta: diff --git a/api/tests/common/test_serializers.py b/api/tests/common/test_serializers.py index dbbd38a0dc442bc04358eef8e14ff4e2a89310a9..e07bf8e826bfec8ebe77de5be57b1b7ce9f2d553 100644 --- a/api/tests/common/test_serializers.py +++ b/api/tests/common/test_serializers.py @@ -32,7 +32,11 @@ class TestDangerousSerializer(serializers.ActionSerializer): class TestDeleteOnlyInactiveSerializer(serializers.ActionSerializer): - actions = [serializers.Action("test", allow_all=True, filters={"is_active": False})] + actions = [ + serializers.Action( + "test", allow_all=True, qs_filter=lambda qs: qs.filter(is_active=False) + ) + ] filterset_class = TestActionFilterSet def handle_test(self, objects): diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index 2f0c6bc2568e4a2a7c9e9755a00eda50ecd9dd92..9742b098d2026bd5c0da09b810bc29bea9f91a50 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -31,3 +31,44 @@ def test_user_update_permission(factories): assert user.permission_upload is True assert user.permission_library is False assert user.permission_settings is True + + +def test_manage_import_request_mark_closed(factories): + affected = factories["requests.ImportRequest"].create_batch( + size=5, status="pending" + ) + # we do not update imported requests + factories["requests.ImportRequest"].create_batch(size=5, status="imported") + s = serializers.ManageImportRequestActionSerializer( + queryset=affected[0].__class__.objects.all(), + data={"objects": "all", "action": "mark_closed"}, + ) + + assert s.is_valid(raise_exception=True) is True + s.save() + + assert affected[0].__class__.objects.filter(status="imported").count() == 5 + for ir in affected: + ir.refresh_from_db() + assert ir.status == "closed" + + +def test_manage_import_request_mark_imported(factories, now): + affected = factories["requests.ImportRequest"].create_batch( + size=5, status="pending" + ) + # we do not update closed requests + factories["requests.ImportRequest"].create_batch(size=5, status="closed") + s = serializers.ManageImportRequestActionSerializer( + queryset=affected[0].__class__.objects.all(), + data={"objects": "all", "action": "mark_imported"}, + ) + + assert s.is_valid(raise_exception=True) is True + s.save() + + assert affected[0].__class__.objects.filter(status="closed").count() == 5 + for ir in affected: + ir.refresh_from_db() + assert ir.status == "imported" + assert ir.imported_date == now diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index d54fca5ddafe3b570bf0d671846587b8132c4f77..baf816fc860ba7d2dbc3b1f6dbdfdc8d2187b541 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -10,6 +10,7 @@ from funkwhale_api.manage import serializers, views (views.ManageTrackFileViewSet, ["library"], "and"), (views.ManageUserViewSet, ["settings"], "and"), (views.ManageInvitationViewSet, ["settings"], "and"), + (views.ManageImportRequestViewSet, ["library"], "and"), ], ) def test_permissions(assert_user_permission, view, permissions, operator): @@ -63,3 +64,15 @@ def test_invitation_view_create(factories, superuser_api_client, mocker): assert response.status_code == 201 assert superuser_api_client.user.invitations.latest("id") is not None + + +def test_music_requests_view(factories, superuser_api_client, mocker): + invitations = factories["requests.ImportRequest"].create_batch(size=5) + qs = invitations[0].__class__.objects.order_by("-id") + url = reverse("api:v1:manage:requests:import-requests-list") + + response = superuser_api_client.get(url, {"sort": "-id"}) + expected = serializers.ManageImportRequestSerializer(qs, many=True).data + + assert response.data["count"] == len(invitations) + assert response.data["results"] == expected diff --git a/changes/changelog.d/190.feature b/changes/changelog.d/190.feature new file mode 100644 index 0000000000000000000000000000000000000000..460fb57d14c7fb1c361bd6153839f83997ddb549 --- /dev/null +++ b/changes/changelog.d/190.feature @@ -0,0 +1 @@ +Added admin interface to manage import requests (#190) diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 87c374a336780d1e2623f85685e581abbabab347..065a0a03a76b039100a1f930e1a346e07a5bfc36 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -58,21 +58,16 @@ <div class="item" v-if="showAdmin"> <div class="header">{{ $t('Administration') }}</div> <div class="menu"> - <router-link - class="item" - v-if="$store.state.auth.availablePermissions['library']" - :to="{name: 'library.requests', query: {status: 'pending' }}"> - <i class="download icon"></i>{{ $t('Import requests') }} - <div - :class="['ui', {'teal': notifications.importRequests > 0}, 'label']" - :title="$t('Pending import requests')"> - {{ notifications.importRequests }}</div> - </router-link> <router-link class="item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.files'}"> <i class="book icon"></i>{{ $t('Library') }} + <div + :class="['ui', {'teal': $store.state.ui.notifications.importRequests > 0}, 'label']" + :title="$t('Pending import requests')"> + {{ $store.state.ui.notifications.importRequests }}</div> + </router-link> <router-link class="item" @@ -86,9 +81,9 @@ :to="{path: '/manage/federation/libraries'}"> <i class="sitemap icon"></i>{{ $t('Federation') }} <div - :class="['ui', {'teal': notifications.federation > 0}, 'label']" + :class="['ui', {'teal': $store.state.ui.notifications.federation > 0}, 'label']" :title="$t('Pending follow requests')"> - {{ notifications.federation }}</div> + {{ $store.state.ui.notifications.federation }}</div> </router-link> <router-link class="item" @@ -160,7 +155,6 @@ <script> import {mapState, mapActions} from 'vuex' -import axios from 'axios' import Player from '@/components/audio/Player' import Logo from '@/components/Logo' @@ -183,11 +177,7 @@ export default { selectedTab: 'library', backend: backend, isCollapsed: true, - fetchInterval: null, - notifications: { - federation: 0, - importRequests: 0 - } + fetchInterval: null } }, mounted () { @@ -224,26 +214,8 @@ export default { cleanTrack: 'queue/cleanTrack' }), fetchNotificationsCount () { - this.fetchFederationNotificationsCount() - this.fetchFederationImportRequestsCount() - }, - fetchFederationNotificationsCount () { - if (!this.$store.state.auth.availablePermissions['federation']) { - return - } - let self = this - axios.get('federation/libraries/followers/', {params: {pending: true}}).then(response => { - self.notifications.federation = response.data.count - }) - }, - fetchFederationImportRequestsCount () { - if (!this.$store.state.auth.availablePermissions['library']) { - return - } - let self = this - axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => { - self.notifications.importRequests = response.data.count - }) + this.$store.dispatch('ui/fetchFederationNotificationsCount') + this.$store.dispatch('ui/fetchImportRequestsCount') }, reorder: function (event) { this.$store.commit('queue/reorder', { diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue index 50337b2291776de826feedad054237957a4855b1..5360de16cd0d959a9dbda011dd5d408ac7f97115 100644 --- a/front/src/components/library/Library.vue +++ b/front/src/components/library/Library.vue @@ -6,13 +6,6 @@ <router-link class="ui item" to="/library/radios" exact><i18next path="Radios"/></router-link> <router-link class="ui item" to="/library/playlists" exact><i18next path="Playlists"/></router-link> <div class="ui secondary right menu"> - <router-link - v-if="$store.state.auth.authenticated" - class="ui item" - :to="{name: 'library.requests', query: {status: 'pending' }}" - exact> - <i18next path="Requests"/> - </router-link> <router-link v-if="showImports" class="ui item" to="/library/import/launch" exact> <i18next path="Import"/> </router-link> diff --git a/front/src/components/manage/library/RequestsTable.vue b/front/src/components/manage/library/RequestsTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..e51b911a762a46504f4783f376e63ac4180c4ce8 --- /dev/null +++ b/front/src/components/manage/library/RequestsTable.vue @@ -0,0 +1,229 @@ +<template> + <div> + <div class="ui inline form"> + <div class="fields"> + <div class="ui field"> + <label>{{ $t('Search') }}</label> + <input type="text" v-model="search" placeholder="Search by artist, username, comment..." /> + </div> + <div class="field"> + <i18next tag="label" path="Ordering"/> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ option[1] }} + </option> + </select> + </div> + <div class="field"> + <i18next tag="label" path="Ordering direction"/> + <select class="ui dropdown" v-model="orderingDirection"> + <option value="+">Ascending</option> + <option value="-">Descending</option> + </select> + </div> + <div class="field"> + <label>{{ $t("Status") }}</label> + <select class="ui dropdown" v-model="status"> + <option :value="null">{{ $t('All') }}</option> + <option :value="'pending'">{{ $t('Pending') }}</option> + <option :value="'accepted'">{{ $t('Accepted') }}</option> + <option :value="'imported'">{{ $t('Imported') }}</option> + <option :value="'closed'">{{ $t('Closed') }}</option> + </select> + </div> + </div> + </div> + <div class="dimmable"> + <div v-if="isLoading" class="ui active inverted dimmer"> + <div class="ui loader"></div> + </div> + <action-table + v-if="result" + @action-launched="fetchData" + :objects-data="result" + :actions="actions" + :action-url="'manage/requests/import-requests/action/'" + :filters="actionFilters"> + <template slot="header-cells"> + <th>{{ $t('User') }}</th> + <th>{{ $t('Status') }}</th> + <th>{{ $t('Artist') }}</th> + <th>{{ $t('Albums') }}</th> + <th>{{ $t('Comment') }}</th> + <th>{{ $t('Creation date') }}</th> + <th>{{ $t('Import date') }}</th> + <th>{{ $t('Actions') }}</th> + </template> + <template slot="row-cells" slot-scope="scope"> + <td> + {{ scope.obj.user.username }} + </td> + <td> + <span class="ui green basic label" v-if="scope.obj.status === 'imported'">{{ $t('Imported') }}</span> + <span class="ui pink basic label" v-else-if="scope.obj.status === 'accepted'">{{ $t('Accepted') }}</span> + <span class="ui yellow basic label" v-else-if="scope.obj.status === 'pending'">{{ $t('Pending') }}</span> + <span class="ui red basic label" v-else-if="scope.obj.status === 'closed'">{{ $t('Closed') }}</span> + </td> + <td> + <span :title="scope.obj.artist_name">{{ scope.obj.artist_name|truncate(30) }}</span> + </td> + <td> + <span v-if="scope.obj.albums" :title="scope.obj.albums">{{ scope.obj.albums|truncate(30) }}</span> + <template v-else>{{Â $t('N/A') }}</template> + </td> + <td> + <span v-if="scope.obj.comment" :title="scope.obj.comment">{{ scope.obj.comment|truncate(30) }}</span> + <template v-else>{{Â $t('N/A') }}</template> + </td> + <td> + <human-date :date="scope.obj.creation_date"></human-date> + </td> + <td> + <human-date v-if="scope.obj.imported_date" :date="scope.obj.creation_date"></human-date> + <template v-else>{{Â $t('N/A') }}</template> + </td> + <td> + <router-link + class="ui tiny basic button" + :to="{name: 'library.import.launch', query: {request: scope.obj.id}}" + v-if="scope.obj.status === 'pending'">{{Â $t('Create import') }}</router-link> + </td> + </template> + </action-table> + </div> + <div> + <pagination + v-if="result && result.results.length > 0" + @page-changed="selectPage" + :compact="true" + :current="page" + :paginate-by="paginateBy" + :total="result.count" + ></pagination> + + <span v-if="result && result.results.length > 0"> + {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}} + </span> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import _ from 'lodash' +import time from '@/utils/time' +import Pagination from '@/components/Pagination' +import ActionTable from '@/components/common/ActionTable' +import OrderingMixin from '@/components/mixins/Ordering' + +export default { + mixins: [OrderingMixin], + props: { + filters: {type: Object, required: false} + }, + components: { + Pagination, + ActionTable + }, + data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') + return { + time, + isLoading: false, + result: null, + page: 1, + paginateBy: 25, + search: '', + status: null, + orderingDirection: defaultOrdering.direction || '+', + ordering: defaultOrdering.field, + orderingOptions: [ + ['creation_date', 'Creation date'], + ['imported_date', 'Imported date'] + ] + + } + }, + created () { + this.fetchData() + }, + methods: { + fetchData () { + let params = _.merge({ + 'page': this.page, + 'page_size': this.paginateBy, + 'q': this.search, + 'status': this.status, + 'ordering': this.getOrderingAsString() + }, this.filters) + let self = this + self.isLoading = true + self.checked = [] + axios.get('/manage/requests/import-requests/', {params: params}).then((response) => { + self.result = response.data + self.isLoading = false + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + selectPage: function (page) { + this.page = page + } + }, + computed: { + actionFilters () { + var currentFilters = { + q: this.search + } + if (this.filters) { + return _.merge(currentFilters, this.filters) + } else { + return currentFilters + } + }, + actions () { + return [ + { + name: 'delete', + label: this.$t('Delete'), + isDangerous: true + }, + { + name: 'mark_imported', + label: this.$t('Mark as imported'), + filterCheckable: (obj) => { return ['pending', 'accepted'].indexOf(obj.status) > -1 }, + isDangerous: true + }, + { + name: 'mark_closed', + label: this.$t('Mark as closed'), + filterCheckable: (obj) => { return ['pending', 'accepted'].indexOf(obj.status) > -1 }, + isDangerous: true + } + ] + } + }, + watch: { + search (newValue) { + this.page = 1 + this.fetchData() + }, + page () { + this.fetchData() + }, + ordering () { + this.page = 1 + this.fetchData() + }, + status () { + this.page = 1 + this.fetchData() + }, + orderingDirection () { + this.page = 1 + this.fetchData() + } + } +} +</script> diff --git a/front/src/components/requests/RequestsList.vue b/front/src/components/requests/RequestsList.vue deleted file mode 100644 index 58b7f5fa9ca65561291588c58f92d7039b357c2a..0000000000000000000000000000000000000000 --- a/front/src/components/requests/RequestsList.vue +++ /dev/null @@ -1,198 +0,0 @@ -<template> - <div v-title="'Import Requests'"> - <div class="ui vertical stripe segment"> - <h2 class="ui header">{{ $t('Music requests') }}</h2> - <div :class="['ui', {'loading': isLoading}, 'form']"> - <div class="fields"> - <div class="field"> - <label>{{ $t('Search') }}</label> - <input type="text" v-model="query" placeholder="Enter an artist name, a username..."/> - </div> - <div class="field"> - <label>{{ $t('Status') }}</label> - <select class="ui dropdown" v-model="status"> - <option :value="'any'">{{ $t('Any') }}</option> - <option :value="'pending'">{{ $t('Pending') }}</option> - <option :value="'accepted'">{{ $t('Accepted') }}</option> - <option :value="'imported'">{{ $t('Imported') }}</option> - <option :value="'closed'">{{ $t('Closed') }}</option> - </select> - </div> - <div class="field"> - <label>{{ $t('Ordering') }}</label> - <select class="ui dropdown" v-model="ordering"> - <option v-for="option in orderingOptions" :value="option[0]"> - {{ option[1] }} - </option> - </select> - </div> - <div class="field"> - <label>{{ $t('Ordering direction') }}</label> - <select class="ui dropdown" v-model="orderingDirection"> - <option value="+">Ascending</option> - <option value="-">Descending</option> - </select> - </div> - <div class="field"> - <label>{{ $t('Results per page') }}</label> - <select class="ui dropdown" v-model="paginateBy"> - <option :value="parseInt(12)">12</option> - <option :value="parseInt(25)">25</option> - <option :value="parseInt(50)">50</option> - </select> - </div> - </div> - </div> - <div class="ui hidden divider"></div> - <div - v-if="result" - v-masonry - transition-duration="0" - item-selector=".column" - percent-position="true" - stagger="0" - class="ui stackable three column doubling grid"> - <div - v-masonry-tile - v-if="result.results.length > 0" - v-for="request in result.results" - :key="request.id" - class="column"> - <request-card class="fluid" :request="request"></request-card> - </div> - </div> - <div class="ui center aligned basic segment"> - <pagination - v-if="result && result.results.length > 0" - @page-changed="selectPage" - :current="page" - :paginate-by="paginateBy" - :total="result.count" - ></pagination> - </div> - </div> - </div> -</template> - -<script> -import axios from 'axios' -import _ from 'lodash' -import $ from 'jquery' - -import logger from '@/logging' - -import OrderingMixin from '@/components/mixins/Ordering' -import PaginationMixin from '@/components/mixins/Pagination' -import RequestCard from '@/components/requests/Card' -import Pagination from '@/components/Pagination' - -const FETCH_URL = 'requests/import-requests/' - -export default { - mixins: [OrderingMixin, PaginationMixin], - props: { - defaultQuery: {type: String, required: false, default: ''}, - defaultStatus: {required: false, default: 'any'} - }, - components: { - RequestCard, - Pagination - }, - data () { - let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') - return { - isLoading: true, - result: null, - page: parseInt(this.defaultPage), - query: this.defaultQuery, - paginateBy: parseInt(this.defaultPaginateBy || 12), - orderingDirection: defaultOrdering.direction || '+', - ordering: defaultOrdering.field, - status: this.defaultStatus || 'any' - } - }, - created () { - this.fetchData() - }, - mounted () { - $('.ui.dropdown').dropdown() - }, - methods: { - updateQueryString: _.debounce(function () { - let query = { - query: { - query: this.query, - page: this.page, - paginateBy: this.paginateBy, - ordering: this.getOrderingAsString() - } - } - if (this.status !== 'any') { - query.query.status = this.status - } - this.$router.replace(query) - }, 500), - fetchData: _.debounce(function () { - var self = this - this.isLoading = true - let url = FETCH_URL - let params = { - page: this.page, - page_size: this.paginateBy, - q: this.query, - ordering: this.getOrderingAsString() - } - if (this.status !== 'any') { - params.status = this.status - } - logger.default.debug('Fetching request...') - axios.get(url, {params: params}).then((response) => { - self.result = response.data - self.isLoading = false - }) - }, 500), - selectPage: function (page) { - this.page = page - } - }, - computed: { - orderingOptions: function () { - return [ - ['creation_date', this.$t('Creation date')], - ['artist_name', this.$t('Artist name')], - ['user__username', this.$t('User')] - ] - } - }, - watch: { - page () { - this.updateQueryString() - this.fetchData() - }, - paginateBy () { - this.updateQueryString() - this.fetchData() - }, - ordering () { - this.updateQueryString() - this.fetchData() - }, - orderingDirection () { - this.updateQueryString() - this.fetchData() - }, - query () { - this.updateQueryString() - this.fetchData() - }, - status () { - this.updateQueryString() - this.fetchData() - } - } -} -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> -</style> diff --git a/front/src/router/index.js b/front/src/router/index.js index 19474376874435c36e735920290061b7111b3fc4..bb59b5348b5cdbc36178c91b81249d5f7f7e19ec 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -24,13 +24,13 @@ import RadioBuilder from '@/components/library/radios/Builder' import RadioDetail from '@/views/radios/Detail' import BatchList from '@/components/library/import/BatchList' import BatchDetail from '@/components/library/import/BatchDetail' -import RequestsList from '@/components/requests/RequestsList' import PlaylistDetail from '@/views/playlists/Detail' import PlaylistList from '@/views/playlists/List' import Favorites from '@/components/favorites/List' import AdminSettings from '@/views/admin/Settings' import AdminLibraryBase from '@/views/admin/library/Base' import AdminLibraryFilesList from '@/views/admin/library/FilesList' +import AdminLibraryRequestsList from '@/views/admin/library/RequestsList' import AdminUsersBase from '@/views/admin/users/Base' import AdminUsersDetail from '@/views/admin/users/UsersDetail' import AdminUsersList from '@/views/admin/users/UsersList' @@ -184,6 +184,11 @@ export default new Router({ path: 'files', name: 'manage.library.files', component: AdminLibraryFilesList + }, + { + path: 'requests', + name: 'manage.library.requests', + component: AdminLibraryRequestsList } ] }, @@ -278,21 +283,7 @@ export default new Router({ children: [ ] }, - { path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true }, - { - path: 'requests/', - name: 'library.requests', - component: RequestsList, - props: (route) => ({ - defaultOrdering: route.query.ordering, - defaultQuery: route.query.query, - defaultPaginateBy: route.query.paginateBy, - defaultPage: route.query.page, - defaultStatus: route.query.status || 'any' - }), - children: [ - ] - } + { path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true } ] }, { path: '*', component: PageNotFound } diff --git a/front/src/store/ui.js b/front/src/store/ui.js index be744afe51ad954a4bae722f9442a9d71ad85730..c336803475c5c6c79776e501dd94a8884a9198c6 100644 --- a/front/src/store/ui.js +++ b/front/src/store/ui.js @@ -1,3 +1,4 @@ +import axios from 'axios' export default { namespaced: true, @@ -5,7 +6,11 @@ export default { lastDate: new Date(), maxMessages: 100, messageDisplayDuration: 10000, - messages: [] + messages: [], + notifications: { + federation: 0, + importRequests: 0 + } }, mutations: { computeLastDate: (state) => { @@ -16,6 +21,27 @@ export default { if (state.messages.length > state.maxMessages) { state.messages.shift() } + }, + notifications (state, {type, count}) { + state.notifications[type] = count + } + }, + actions: { + fetchFederationNotificationsCount ({rootState, commit}) { + if (!rootState.auth.availablePermissions['federation']) { + return + } + axios.get('federation/libraries/followers/', {params: {pending: true}}).then(response => { + commit('notifications', {type: 'federation', count: response.data.count}) + }) + }, + fetchImportRequestsCount ({rootState, commit}) { + if (!rootState.auth.availablePermissions['library']) { + return + } + axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => { + commit('notifications', {type: 'importRequests', count: response.data.count}) + }) } } } diff --git a/front/src/views/admin/library/Base.vue b/front/src/views/admin/library/Base.vue index 834fca920f62f195cb83bbd37c4d65dd47ce946a..cc26c8d6be42f0fe30643ea13bb78bccc1e13838 100644 --- a/front/src/views/admin/library/Base.vue +++ b/front/src/views/admin/library/Base.vue @@ -4,6 +4,15 @@ <router-link class="ui item" :to="{name: 'manage.library.files'}">{{ $t('Files') }}</router-link> + <router-link + class="ui item" + :to="{name: 'manage.library.requests'}"> + {{ $t('Import requests') }} + <div + :class="['ui', {'teal': $store.state.ui.notifications.importRequests > 0}, 'label']" + :title="$t('Pending import requests')"> + {{ $store.state.ui.notifications.importRequests }}</div> + </router-link> </div> <router-view :key="$route.fullPath"></router-view> </div> diff --git a/front/src/views/admin/library/RequestsList.vue b/front/src/views/admin/library/RequestsList.vue new file mode 100644 index 0000000000000000000000000000000000000000..160bf890b99dc42f0cdd07493e06e70f0e3fe93d --- /dev/null +++ b/front/src/views/admin/library/RequestsList.vue @@ -0,0 +1,23 @@ +<template> + <div v-title="$t('Import requests')"> + <div class="ui vertical stripe segment"> + <h2 class="ui header">{{ $t('Import requests') }}</h2> + <div class="ui hidden divider"></div> + <library-requests-table></library-requests-table> + </div> + </div> +</template> + +<script> +import LibraryRequestsTable from '@/components/manage/library/RequestsTable' + +export default { + components: { + LibraryRequestsTable + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style>