From 102c90d499996955ae82c01a8b2dfb53b6f7a1b8 Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Tue, 31 Mar 2020 10:45:41 +0200 Subject: [PATCH] See #170: admin UI for channels, reporting channels --- api/funkwhale_api/audio/models.py | 17 +- api/funkwhale_api/federation/models.py | 2 + api/funkwhale_api/federation/routes.py | 2 + api/funkwhale_api/federation/utils.py | 10 +- api/funkwhale_api/manage/filters.py | 32 +- api/funkwhale_api/manage/serializers.py | 42 +- api/funkwhale_api/manage/urls.py | 1 + api/funkwhale_api/manage/views.py | 139 +++++-- api/funkwhale_api/moderation/serializers.py | 63 ++- api/tests/federation/test_models.py | 2 + api/tests/federation/test_routes.py | 2 + api/tests/federation/test_utils.py | 6 + api/tests/manage/test_serializers.py | 32 +- api/tests/manage/test_views.py | 47 +++ api/tests/moderation/test_serializers.py | 2 + front/src/App.vue | 6 +- front/src/components/audio/ChannelCard.vue | 6 +- front/src/components/audio/PlayButton.vue | 3 +- front/src/components/manage/ChannelsTable.vue | 224 +++++++++++ .../manage/library/ArtistsTable.vue | 23 +- front/src/components/mixins/Report.vue | 15 +- front/src/components/mixins/Translations.vue | 8 + .../src/components/moderation/ReportModal.vue | 3 + front/src/entities.js | 28 ++ front/src/router/index.js | 22 ++ front/src/views/admin/ChannelDetail.vue | 369 ++++++++++++++++++ front/src/views/admin/ChannelsList.vue | 29 ++ .../src/views/admin/library/ArtistDetail.vue | 22 +- front/src/views/admin/library/Base.vue | 3 + .../views/admin/moderation/AccountsDetail.vue | 11 +- .../views/admin/moderation/DomainsDetail.vue | 10 + front/src/views/channels/DetailBase.vue | 4 +- 32 files changed, 1107 insertions(+), 78 deletions(-) create mode 100644 front/src/components/manage/ChannelsTable.vue create mode 100644 front/src/views/admin/ChannelDetail.vue create mode 100644 front/src/views/admin/ChannelsList.vue diff --git a/api/funkwhale_api/audio/models.py b/api/funkwhale_api/audio/models.py index f97442d74..37800962c 100644 --- a/api/funkwhale_api/audio/models.py +++ b/api/funkwhale_api/audio/models.py @@ -69,6 +69,15 @@ class Channel(models.Model): objects = ChannelQuerySet.as_manager() + @property + def fid(self): + if not self.is_external_rss: + return self.actor.fid + + @property + def is_external_rss(self): + return self.actor.preferred_username.startswith("rssfeed-") + def get_absolute_url(self): suffix = self.uuid if self.actor.is_local: @@ -78,9 +87,7 @@ class Channel(models.Model): return federation_utils.full_url("/channels/{}".format(suffix)) def get_rss_url(self): - if not self.artist.is_local or self.actor.preferred_username.startswith( - "rssfeed-" - ): + if not self.artist.is_local or self.is_external_rss: return self.rss_url return federation_utils.full_url( @@ -90,10 +97,6 @@ class Channel(models.Model): ) ) - @property - def fid(self): - return self.actor.fid - def generate_actor(username, **kwargs): actor_data = user_models.get_actor_data(username, **kwargs) diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 50e8eef1a..7f8121641 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -145,6 +145,7 @@ class Domain(models.Model): actors=models.Count("actors", distinct=True), outbox_activities=models.Count("actors__outbox_activities", distinct=True), libraries=models.Count("actors__libraries", distinct=True), + channels=models.Count("actors__owned_channels", distinct=True), received_library_follows=models.Count( "actors__libraries__received_follows", distinct=True ), @@ -283,6 +284,7 @@ class Actor(models.Model): data = Actor.objects.filter(pk=self.pk).aggregate( outbox_activities=models.Count("outbox_activities", distinct=True), libraries=models.Count("libraries", distinct=True), + channels=models.Count("owned_channels", distinct=True), received_library_follows=models.Count( "libraries__received_follows", distinct=True ), diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py index 56eae7f12..4177f76d5 100644 --- a/api/funkwhale_api/federation/routes.py +++ b/api/funkwhale_api/federation/routes.py @@ -482,6 +482,8 @@ def inbox_flag(payload, context): @outbox.register({"type": "Flag"}) def outbox_flag(context): report = context["report"] + if not report.target or not report.target.fid: + return actor = actors.get_service_actor() serializer = serializers.FlagSerializer(report) yield { diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py index b1f3fdb06..7f2f346e1 100644 --- a/api/funkwhale_api/federation/utils.py +++ b/api/funkwhale_api/federation/utils.py @@ -266,5 +266,11 @@ def get_object_by_fid(fid, local=None): if not result: raise ObjectDoesNotExist() - - return apps.get_model(*result["__type"].split(".")).objects.get(fid=fid) + model = apps.get_model(*result["__type"].split(".")) + instance = model.objects.get(fid=fid) + if model._meta.label == "federation.Actor": + channel = instance.get_channel() + if channel: + return channel + + return instance diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index eb86a59f0..727911995 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -8,6 +8,7 @@ from funkwhale_api.common import fields from funkwhale_api.common import filters as common_filters from funkwhale_api.common import search +from funkwhale_api.audio import models as audio_models 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 @@ -34,6 +35,34 @@ def get_actor_filter(actor_field): return {"field": ActorField(), "handler": handler} +class ManageChannelFilterSet(filters.FilterSet): + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={ + "name": {"to": "artist__name"}, + "username": {"to": "artist__name"}, + "fid": {"to": "artist__fid"}, + "rss": {"to": "rss_url"}, + }, + filter_fields={ + "uuid": {"to": "uuid"}, + "category": {"to": "artist__content_category"}, + "domain": { + "handler": lambda v: federation_utils.get_domain_query_from_url( + v, url_field="attributed_to__fid" + ) + }, + "tag": {"to": "artist__tagged_items__tag__name", "distinct": True}, + "account": get_actor_filter("attributed_to"), + }, + ) + ) + + class Meta: + model = audio_models.Channel + fields = ["q"] + + class ManageArtistFilterSet(filters.FilterSet): q = fields.SmartSearchFilter( config=search.SearchConfig( @@ -52,6 +81,7 @@ class ManageArtistFilterSet(filters.FilterSet): "field": forms.IntegerField(), "distinct": True, }, + "category": {"to": "content_category"}, "tag": {"to": "tagged_items__tag__name", "distinct": True}, }, ) @@ -59,7 +89,7 @@ class ManageArtistFilterSet(filters.FilterSet): class Meta: model = music_models.Artist - fields = ["q", "name", "mbid", "fid"] + fields = ["q", "name", "mbid", "fid", "content_category"] class ManageAlbumFilterSet(filters.FilterSet): diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index c6966f108..d29433e56 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -3,6 +3,7 @@ from django.db import transaction from rest_framework import serializers +from funkwhale_api.audio import models as audio_models from funkwhale_api.common import fields as common_fields from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import utils as common_utils @@ -386,26 +387,39 @@ class ManageNestedAlbumSerializer(ManageBaseAlbumSerializer): class ManageArtistSerializer( music_serializers.OptionalDescriptionMixin, ManageBaseArtistSerializer ): - albums = ManageNestedAlbumSerializer(many=True) - tracks = ManageNestedTrackSerializer(many=True) attributed_to = ManageBaseActorSerializer() tags = serializers.SerializerMethodField() + tracks_count = serializers.SerializerMethodField() + albums_count = serializers.SerializerMethodField() + channel = serializers.SerializerMethodField() cover = music_serializers.cover_field class Meta: model = music_models.Artist fields = ManageBaseArtistSerializer.Meta.fields + [ - "albums", - "tracks", + "tracks_count", + "albums_count", "attributed_to", "tags", "cover", + "channel", + "content_category", ] + 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_tags(self, obj): tagged_items = getattr(obj, "_prefetched_tagged_items", []) return [ti.tag.name for ti in tagged_items] + def get_channel(self, obj): + if "channel" in obj._state.fields_cache and obj.get_channel(): + return str(obj.channel.uuid) + class ManageNestedArtistSerializer(ManageBaseArtistSerializer): pass @@ -743,3 +757,23 @@ class ManageUserRequestSerializer(serializers.ModelSerializer): def get_notes(self, o): notes = getattr(o, "_prefetched_notes", []) return ManageBaseNoteSerializer(notes, many=True).data + + +class ManageChannelSerializer(serializers.ModelSerializer): + attributed_to = ManageBaseActorSerializer() + actor = ManageBaseActorSerializer() + artist = ManageArtistSerializer() + + class Meta: + model = audio_models.Channel + fields = [ + "id", + "uuid", + "creation_date", + "artist", + "attributed_to", + "actor", + "rss_url", + "metadata", + ] + read_only_fields = fields diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index 8af692d7a..760e24c8d 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -27,6 +27,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"channels", views.ManageChannelViewSet, "channels") other_router.register(r"tags", views.ManageTagViewSet, "tags") urlpatterns = [ diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index 5a0f81a39..0f0f16ce0 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -6,12 +6,15 @@ from django.db.models import Count, Prefetch, Q, Sum, OuterRef, Subquery from django.db.models.functions import Coalesce, Length from django.shortcuts import get_object_or_404 +from funkwhale_api.audio import models as audio_models +from funkwhale_api.common.mixins import MultipleLookupDetailMixin from funkwhale_api.common import models as common_models from funkwhale_api.common import preferences, decorators from funkwhale_api.common import utils as common_utils 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.federation import utils as federation_utils 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 @@ -25,37 +28,39 @@ from funkwhale_api.users import models as users_models from . import filters, serializers -def get_stats(tracks, target): - data = {} +def get_stats(tracks, target, ignore_fields=[]): 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.filter(library__channel=None) - .values_list("library", flat=True) - .distinct() - .count() - ) - data["channels"] = ( - uploads.exclude(library__channel=None) - .values_list("library", flat=True) - .distinct() - .count() - ) - data["uploads"] = uploads.count() - data["reports"] = moderation_models.Report.objects.get_for_target(target).count() + fields = { + "listenings": history_models.Listening.objects.filter(track__in=tracks), + "mutations": common_models.Mutation.objects.get_for_target(target), + "playlists": ( + playlists_models.PlaylistTrack.objects.filter(track__in=tracks) + .values_list("playlist", flat=True) + .distinct() + ), + "track_favorites": ( + favorites_models.TrackFavorite.objects.filter(track__in=tracks) + ), + "libraries": ( + uploads.filter(library__channel=None) + .values_list("library", flat=True) + .distinct() + ), + "channels": ( + uploads.exclude(library__channel=None) + .values_list("library", flat=True) + .distinct() + ), + "uploads": uploads, + "reports": moderation_models.Report.objects.get_for_target(target), + } + data = {} + for key, qs in fields.items(): + if key in ignore_fields: + continue + data[key] = qs.count() + data.update(get_media_stats(uploads)) return data @@ -78,17 +83,10 @@ class ManageArtistViewSet( queryset = ( music_models.Artist.objects.all() .order_by("-id") - .select_related("attributed_to", "attachment_cover",) - .prefetch_related( - "tracks", - Prefetch( - "albums", - queryset=music_models.Album.objects.select_related( - "attachment_cover" - ).annotate(tracks_count=Count("tracks")), - ), - music_views.TAG_PREFETCH, - ) + .select_related("attributed_to", "attachment_cover", "channel") + .annotate(_tracks_count=Count("tracks")) + .annotate(_albums_count=Count("albums")) + .prefetch_related(music_views.TAG_PREFETCH) ) serializer_class = serializers.ManageArtistSerializer filterset_class = filters.ManageArtistFilterSet @@ -661,3 +659,64 @@ class ManageUserRequestViewSet( ) else: serializer.save() + + +class ManageChannelViewSet( + MultipleLookupDetailMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + + url_lookups = [ + { + "lookup_field": "uuid", + "validator": serializers.serializers.UUIDField().to_internal_value, + }, + { + "lookup_field": "username", + "validator": federation_utils.get_actor_data_from_username, + "get_query": lambda v: Q( + actor__domain=v["domain"], + actor__preferred_username__iexact=v["username"], + ), + }, + ] + queryset = ( + audio_models.Channel.objects.all() + .order_by("-id") + .select_related("attributed_to", "actor",) + .prefetch_related( + Prefetch( + "artist", + queryset=( + music_models.Artist.objects.all() + .order_by("-id") + .select_related("attributed_to", "attachment_cover", "channel") + .annotate(_tracks_count=Count("tracks")) + .annotate(_albums_count=Count("albums")) + .prefetch_related(music_views.TAG_PREFETCH) + ), + ) + ) + ) + serializer_class = serializers.ManageChannelSerializer + filterset_class = filters.ManageChannelFilterSet + required_scope = "instance:libraries" + ordering_fields = ["creation_date", "name"] + + @rest_decorators.action(methods=["get"], detail=True) + def stats(self, request, *args, **kwargs): + channel = self.get_object() + tracks = music_models.Track.objects.filter( + Q(artist=channel.artist) | Q(album__artist=channel.artist) + ) + data = get_stats(tracks, channel, ignore_fields=["libraries", "channels"]) + data["follows"] = channel.actor.received_follows.count() + return response.Response(data, status=200) + + def get_serializer_context(self): + context = super().get_serializer_context() + context["description"] = self.action in ["retrieve", "create", "update"] + return context diff --git a/api/funkwhale_api/moderation/serializers.py b/api/funkwhale_api/moderation/serializers.py index 7d772d39e..4b099a1b7 100644 --- a/api/funkwhale_api/moderation/serializers.py +++ b/api/funkwhale_api/moderation/serializers.py @@ -6,6 +6,7 @@ from django.core.serializers.json import DjangoJSONEncoder import persisting_theory from rest_framework import serializers +from funkwhale_api.audio import models as audio_models from funkwhale_api.common import fields as common_fields from funkwhale_api.common import preferences from funkwhale_api.federation import models as federation_models @@ -61,20 +62,36 @@ class UserFilterSerializer(serializers.ModelSerializer): state_serializers = persisting_theory.Registry() +class DescriptionStateMixin(object): + def get_description(self, o): + if o.description: + return o.description.text + + TAGS_FIELD = serializers.ListField(source="get_tags") @state_serializers.register(name="music.Artist") -class ArtistStateSerializer(serializers.ModelSerializer): +class ArtistStateSerializer(DescriptionStateMixin, serializers.ModelSerializer): tags = TAGS_FIELD class Meta: model = music_models.Artist - fields = ["id", "name", "mbid", "fid", "creation_date", "uuid", "tags"] + fields = [ + "id", + "name", + "mbid", + "fid", + "creation_date", + "uuid", + "tags", + "content_category", + "description", + ] @state_serializers.register(name="music.Album") -class AlbumStateSerializer(serializers.ModelSerializer): +class AlbumStateSerializer(DescriptionStateMixin, serializers.ModelSerializer): tags = TAGS_FIELD artist = ArtistStateSerializer() @@ -90,11 +107,12 @@ class AlbumStateSerializer(serializers.ModelSerializer): "artist", "release_date", "tags", + "description", ] @state_serializers.register(name="music.Track") -class TrackStateSerializer(serializers.ModelSerializer): +class TrackStateSerializer(DescriptionStateMixin, serializers.ModelSerializer): tags = TAGS_FIELD artist = ArtistStateSerializer() album = AlbumStateSerializer() @@ -115,6 +133,7 @@ class TrackStateSerializer(serializers.ModelSerializer): "license", "copyright", "tags", + "description", ] @@ -156,6 +175,36 @@ class ActorStateSerializer(serializers.ModelSerializer): ] +@state_serializers.register(name="audio.Channel") +class ChannelStateSerializer(serializers.ModelSerializer): + rss_url = serializers.CharField(source="get_rss_url") + name = serializers.CharField(source="artist.name") + full_username = serializers.CharField(source="actor.full_username") + domain = serializers.CharField(source="actor.domain_id") + description = serializers.SerializerMethodField() + tags = serializers.ListField(source="artist.get_tags") + content_category = serializers.CharField(source="artist.content_category") + + class Meta: + model = audio_models.Channel + fields = [ + "uuid", + "name", + "rss_url", + "metadata", + "full_username", + "description", + "domain", + "creation_date", + "tags", + "content_category", + ] + + def get_description(self, o): + if o.artist.description: + return o.artist.description.text + + def get_actor_query(attr, value): data = federation_utils.get_actor_data_from_username(value) return federation_utils.get_actor_from_username_data_query(None, data) @@ -163,6 +212,7 @@ def get_actor_query(attr, value): def get_target_owner(target): mapping = { + audio_models.Channel: lambda t: t.attributed_to, music_models.Artist: lambda t: t.attributed_to, music_models.Album: lambda t: t.attributed_to, music_models.Track: lambda t: t.attributed_to, @@ -175,6 +225,11 @@ def get_target_owner(target): TARGET_CONFIG = { + "channel": { + "queryset": audio_models.Channel.objects.all(), + "id_attr": "uuid", + "id_field": serializers.UUIDField(), + }, "artist": {"queryset": music_models.Artist.objects.all()}, "album": {"queryset": music_models.Album.objects.all()}, "track": {"queryset": music_models.Track.objects.all()}, diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py index a0895d44a..b6c8dc0ad 100644 --- a/api/tests/federation/test_models.py +++ b/api/tests/federation/test_models.py @@ -126,6 +126,7 @@ def test_domain_stats(factories): "libraries": 0, "tracks": 0, "albums": 0, + "channels": 0, "uploads": 0, "artists": 0, "outbox_activities": 0, @@ -148,6 +149,7 @@ def test_actor_stats(factories): "uploads": 0, "artists": 0, "reports": 0, + "channels": 0, "requests": 0, "outbox_activities": 0, "received_library_follows": 0, diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py index 0ae18bd15..63d8905ca 100644 --- a/api/tests/federation/test_routes.py +++ b/api/tests/federation/test_routes.py @@ -844,6 +844,7 @@ def test_inbox_delete_actor_doesnt_delete_local_actor(factories): @pytest.mark.parametrize( "factory_name, factory_kwargs", [ + ("audio.Channel", {"local": True}), ("federation.Actor", {"local": True}), ("music.Artist", {"local": True}), ("music.Album", {"local": True}), @@ -885,6 +886,7 @@ def test_inbox_flag(factory_name, factory_kwargs, factories, mocker): @pytest.mark.parametrize( "factory_name, factory_kwargs", [ + ("audio.Channel", {"local": True}), ("federation.Actor", {"local": True}), ("music.Artist", {"local": True}), ("music.Album", {"local": True}), diff --git a/api/tests/federation/test_utils.py b/api/tests/federation/test_utils.py index 6ba9ccfae..ea3b43f51 100644 --- a/api/tests/federation/test_utils.py +++ b/api/tests/federation/test_utils.py @@ -207,3 +207,9 @@ def test_get_obj_by_fid(factory_name, factories): obj = factories[factory_name]() factories[factory_name]() assert utils.get_object_by_fid(obj.fid) == obj + + +def test_get_channel_by_fid(factories): + obj = factories["audio.Channel"]() + factories["audio.Channel"]() + assert utils.get_object_by_fid(obj.actor.fid) == obj diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index d2d00b058..c4dbaa45e 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -287,8 +287,11 @@ def test_instance_policy_serializer_purges_target_actor( def test_manage_artist_serializer(factories, now, to_api_date): artist = factories["music.Artist"](attributed=True, with_cover=True) - track = factories["music.Track"](artist=artist) - album = factories["music.Album"](artist=artist) + channel = factories["audio.Channel"](artist=artist) + # put channel in cache + artist.get_channel() + setattr(artist, "_tracks_count", 12) + setattr(artist, "_albums_count", 13) expected = { "id": artist.id, "domain": artist.domain_name, @@ -297,12 +300,14 @@ def test_manage_artist_serializer(factories, now, to_api_date): "name": artist.name, "mbid": artist.mbid, "creation_date": to_api_date(artist.creation_date), - "albums": [serializers.ManageNestedAlbumSerializer(album).data], - "tracks": [serializers.ManageNestedTrackSerializer(track).data], + "tracks_count": 12, + "albums_count": 13, "attributed_to": serializers.ManageBaseActorSerializer( artist.attributed_to ).data, "tags": [], + "channel": str(channel.uuid), + "content_category": artist.content_category, "cover": common_serializers.AttachmentSerializer(artist.attachment_cover).data, } s = serializers.ManageArtistSerializer(artist) @@ -585,3 +590,22 @@ def test_manage_user_request_serializer(factories, to_api_date): s = serializers.ManageUserRequestSerializer(user_request) assert s.data == expected + + +def test_manage_channel_serializer(factories, now, to_api_date): + channel = factories["audio.Channel"]() + expected = { + "id": channel.id, + "uuid": channel.uuid, + "artist": serializers.ManageArtistSerializer(channel.artist).data, + "actor": serializers.ManageBaseActorSerializer(channel.actor).data, + "attributed_to": serializers.ManageBaseActorSerializer( + channel.attributed_to + ).data, + "creation_date": to_api_date(channel.creation_date), + "rss_url": channel.get_rss_url(), + "metadata": channel.metadata, + } + s = serializers.ManageChannelSerializer(channel) + + assert s.data == expected diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index 2482e0d80..bf4f62bb9 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -599,3 +599,50 @@ def test_user_request_update_status_assigns(factories, superuser_api_client, moc new_status="refused", old_status="pending", ) + + +def test_channel_list(factories, superuser_api_client, settings): + channel = factories["audio.Channel"]() + url = reverse("api:v1:manage:channels-list") + response = superuser_api_client.get(url) + + assert response.status_code == 200 + + assert response.data["count"] == 1 + assert response.data["results"][0]["id"] == channel.id + + +def test_channel_detail(factories, superuser_api_client): + channel = factories["audio.Channel"]() + url = reverse("api:v1:manage:channels-detail", kwargs={"composite": channel.uuid}) + response = superuser_api_client.get(url) + + assert response.status_code == 200 + assert response.data["id"] == channel.id + + +def test_channel_delete(factories, superuser_api_client, mocker): + channel = factories["audio.Channel"]() + url = reverse("api:v1:manage:channels-detail", kwargs={"composite": channel.uuid}) + response = superuser_api_client.delete(url) + + assert response.status_code == 204 + + +def test_channel_detail_stats(factories, superuser_api_client): + channel = factories["audio.Channel"]() + url = reverse("api:v1:manage:channels-stats", kwargs={"composite": channel.uuid}) + response = superuser_api_client.get(url) + expected = { + "uploads": 0, + "playlists": 0, + "listenings": 0, + "mutations": 0, + "reports": 0, + "follows": 0, + "track_favorites": 0, + "media_total_size": 0, + "media_downloaded_size": 0, + } + assert response.status_code == 200 + assert response.data == expected diff --git a/api/tests/moderation/test_serializers.py b/api/tests/moderation/test_serializers.py index 01cb323ee..9089dc371 100644 --- a/api/tests/moderation/test_serializers.py +++ b/api/tests/moderation/test_serializers.py @@ -52,6 +52,7 @@ def test_user_filter_serializer_save(factories): "full_username", serializers.ActorStateSerializer, ), + ("audio.Channel", "channel", "uuid", serializers.ChannelStateSerializer), ], ) def test_report_federated_entity_serializer_save( @@ -161,6 +162,7 @@ def test_report_serializer_save_anonymous(factories, mocker): ("music.Library", {}, "actor"), ("playlists.Playlist", {"user__with_actor": True}, "user.actor"), ("federation.Actor", {}, "self"), + ("audio.Channel", {}, "attributed_to"), ], ) def test_get_target_owner(factory_name, factory_kwargs, owner_field, factories): diff --git a/front/src/App.vue b/front/src/App.vue index 6e8f34dbb..f85d8e920 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -406,9 +406,9 @@ export default { }, 'serviceWorker.updateAvailable': { handler (v) { - // if (!v) { - // return - // } + if (!v) { + return + } let self = this this.$store.commit('ui/addMessage', { content: this.$pgettext("App/Message/Paragraph", "A new version of the app is available."), diff --git a/front/src/components/audio/ChannelCard.vue b/front/src/components/audio/ChannelCard.vue index 37db4805e..c7490023e 100644 --- a/front/src/components/audio/ChannelCard.vue +++ b/front/src/components/audio/ChannelCard.vue @@ -30,7 +30,11 @@ :title="updatedTitle"> {{ object.artist.modification_date | fromNow }} </time> - <play-button class="right floated basic icon" :dropdown-only="true" :is-playable="true" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :artist="object.artist"></play-button> + <play-button + class="right floated basic icon" + :dropdown-only="true" + :is-playable="true" + :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :artist="object.artist" :channel="object" :account="object.attributed_to"></play-button> </div> </div> </template> diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 62f72bc95..906dafee3 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -35,7 +35,7 @@ <i class="eye slash outline icon"></i><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Hide content from this artist</translate> </button> <button - v-for="obj in getReportableObjs({track, album, artist, playlist, account})" + v-for="obj in getReportableObjs({track, album, artist, playlist, account, channel})" :key="obj.target.type + obj.target.id" class="item basic" @click.stop.prevent="$store.dispatch('moderation/report', obj.target)"> @@ -69,6 +69,7 @@ export default { artist: {type: Object, required: false}, album: {type: Object, required: false}, library: {type: Object, required: false}, + channel: {type: Object, required: false}, isPlayable: {type: Boolean, required: false, default: null} }, data () { diff --git a/front/src/components/manage/ChannelsTable.vue b/front/src/components/manage/ChannelsTable.vue new file mode 100644 index 000000000..c8f87bd71 --- /dev/null +++ b/front/src/components/manage/ChannelsTable.vue @@ -0,0 +1,224 @@ +<template> + <div> + <div class="ui inline form"> + <div class="fields"> + <div class="ui six wide field"> + <label><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> + <form @submit.prevent="search.query = $refs.search.value"> + <input name="search" ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" /> + </form> + </div> + <div class="field"> + <label><translate translate-context="*/*/*">Category</translate></label> + <select class="ui dropdown" @change="addSearchToken('category', $event.target.value)" :value="getTokenValue('category', '')"> + <option value=""><translate translate-context="Content/*/Dropdown">All</translate></option> + <option value="podcast">{{ sharedLabels.fields.content_category.choices.podcast }}</option> + <option value="music">{{ sharedLabels.fields.content_category.choices.music }}</option> + <option value="other">{{ sharedLabels.fields.content_category.choices.other }}</option> + </select> + </div> + <div class="field"> + <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ sharedLabels.filters[option[1]] }} + </option> + </select> + </div> + <div class="field"> + <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering direction</translate></label> + <select class="ui dropdown" v-model="orderingDirection"> + <option value="+"><translate translate-context="Content/Search/Dropdown">Ascending</translate></option> + <option value="-"><translate translate-context="Content/Search/Dropdown">Descending</translate></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/library/artists/action/" + :filters="actionFilters"> + <template slot="header-cells"> + <th><translate translate-context="*/*/*/Noun">Name</translate></th> + <th><translate translate-context="*/*/*/Noun">Account</translate></th> + <th><translate translate-context="Content/Moderation/*/Noun">Domain</translate></th> + <th><translate translate-context="*/*/*">Albums</translate></th> + <th><translate translate-context="*/*/*">Tracks</translate></th> + <th><translate translate-context="Content/*/*/Noun">Creation date</translate></th> + </template> + <template slot="row-cells" slot-scope="scope"> + <td> + <router-link :to="{name: 'manage.channels.detail', params: {id: scope.obj.actor.full_username }}">{{ scope.obj.artist.name }}</router-link> + </td> + <td> + <router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: scope.obj.attributed_to.full_username }}"> + <i class="wrench icon"></i> + </router-link> + <span role="button" class="discrete link" @click="addSearchToken('account', scope.obj.attributed_to.full_username)" :title="scope.obj.attributed_to.full_username">{{ scope.obj.attributed_to.preferred_username }}</span> + </td> + <td> + <template v-if="!scope.obj.is_local"> + <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: scope.obj.attributed_to.domain }}"> + <i class="wrench icon"></i> + </router-link> + <span role="button" class="discrete link" @click="addSearchToken('domain', scope.obj.attributed_to.domain)" :title="scope.obj.attributed_to.domain">{{ scope.obj.attributed_to.domain }}</span> + </template> + <span role="button" v-else class="ui tiny teal icon link label" @click="addSearchToken('domain', scope.obj.attributed_to.domain)"> + <i class="home icon"></i> + <translate translate-context="Content/Moderation/*/Short, Noun">Local</translate> + </span> + </td> + <td> + {{ scope.obj.artist.albums_count }} + </td> + <td> + {{ scope.obj.artist.tracks_count }} + </td> + <td> + <human-date :date="scope.obj.creation_date"></human-date> + </td> + </template> + </action-table> + </div> + <div> + <pagination + v-if="result && result.count > paginateBy" + @page-changed="selectPage" + :compact="true" + :current="page" + :paginate-by="paginateBy" + :total="result.count" + ></pagination> + + <span v-if="result && result.results.length > 0"> + <translate translate-context="Content/*/Paragraph" + :translate-params="{start: ((page-1) * paginateBy) + 1, end: ((page-1) * paginateBy) + result.results.length, total: result.count}"> + Showing results %{ start }-%{ end } on %{ total } + </translate> + </span> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import _ from '@/lodash' +import time from '@/utils/time' +import {normalizeQuery, parseTokens} from '@/search' +import Pagination from '@/components/Pagination' +import ActionTable from '@/components/common/ActionTable' +import OrderingMixin from '@/components/mixins/Ordering' +import TranslationsMixin from '@/components/mixins/Translations' +import SmartSearchMixin from '@/components/mixins/SmartSearch' + + +export default { + mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], + 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: 50, + search: { + query: this.defaultQuery, + tokens: parseTokens(normalizeQuery(this.defaultQuery)) + }, + orderingDirection: defaultOrdering.direction || '+', + ordering: defaultOrdering.field, + orderingOptions: [ + ['creation_date', 'creation_date'], + ["name", "name"], + ] + } + }, + created () { + this.fetchData() + }, + methods: { + fetchData () { + let params = _.merge({ + 'page': this.page, + 'page_size': this.paginateBy, + 'q': this.search.query, + 'ordering': this.getOrderingAsString() + }, this.filters) + let self = this + self.isLoading = true + self.checked = [] + axios.get('/manage/channels/', {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: { + labels () { + return { + searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by domain, name, account…') + } + }, + actionFilters () { + var currentFilters = { + q: this.search.query + } + if (this.filters) { + return _.merge(currentFilters, this.filters) + } else { + return currentFilters + } + }, + actions () { + // let deleteLabel = this.$pgettext('*/*/*/Verb', 'Delete') + // let confirmationMessage = this.$pgettext('Popup/*/Paragraph', 'The selected artist will be removed, as well as associated uploads, tracks, albums, favorites and listening history. This action is irreversible.') + return [ + // { + // name: 'delete', + // label: deleteLabel, + // confirmationMessage: confirmationMessage, + // isDangerous: true, + // allowAll: false, + // confirmColor: 'red', + // }, + ] + } + }, + watch: { + search (newValue) { + this.page = 1 + this.fetchData() + }, + page () { + this.fetchData() + }, + ordering () { + this.fetchData() + }, + orderingDirection () { + this.fetchData() + } + } +} +</script> diff --git a/front/src/components/manage/library/ArtistsTable.vue b/front/src/components/manage/library/ArtistsTable.vue index 84c873832..1bc69e02a 100644 --- a/front/src/components/manage/library/ArtistsTable.vue +++ b/front/src/components/manage/library/ArtistsTable.vue @@ -8,6 +8,15 @@ <input name="search" ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" /> </form> </div> + <div class="field"> + <label><translate translate-context="*/*/*">Category</translate></label> + <select class="ui dropdown" @change="addSearchToken('category', $event.target.value)" :value="getTokenValue('category', '')"> + <option value=""><translate translate-context="Content/*/Dropdown">All</translate></option> + <option value="podcast">{{ sharedLabels.fields.content_category.choices.podcast }}</option> + <option value="music">{{ sharedLabels.fields.content_category.choices.music }}</option> + <option value="other">{{ sharedLabels.fields.content_category.choices.other }}</option> + </select> + </div> <div class="field"> <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label> <select class="ui dropdown" v-model="ordering"> @@ -45,7 +54,9 @@ </template> <template slot="row-cells" slot-scope="scope"> <td> - <router-link :to="{name: 'manage.library.artists.detail', params: {id: scope.obj.id }}">{{ scope.obj.name }}</router-link> + <router-link :to="getUrl(scope.obj)"> + {{ scope.obj.name }} + </router-link> </td> <td> <template v-if="!scope.obj.is_local"> @@ -60,10 +71,10 @@ </span> </td> <td> - {{ scope.obj.albums.length }} + {{ scope.obj.albums_count }} </td> <td> - {{ scope.obj.tracks.length }} + {{ scope.obj.tracks_count }} </td> <td> <human-date :date="scope.obj.creation_date"></human-date> @@ -136,6 +147,12 @@ export default { this.fetchData() }, methods: { + getUrl (artist) { + if (artist.channel) { + return {name: 'manage.channels.detail', params: {id: artist.channel }} + } + return {name: 'manage.library.artists.detail', params: {id: artist.id }} + }, fetchData () { let params = _.merge({ 'page': this.page, diff --git a/front/src/components/mixins/Report.vue b/front/src/components/mixins/Report.vue index 058c4b5cb..403b89f24 100644 --- a/front/src/components/mixins/Report.vue +++ b/front/src/components/mixins/Report.vue @@ -49,7 +49,20 @@ export default { artist = album.artist } } - if (artist) { + + if (channel) { + reportableObjs.push({ + label: this.$pgettext('*/Moderation/*/Verb', "Report this channel…"), + target: { + type: 'channel', + uuid: channel.uuid, + label: channel.artist.name, + _obj: channel, + typeLabel: this.$pgettext("*/*/*", 'Channel'), + } + }) + } + else if (artist) { reportableObjs.push({ label: this.$pgettext('*/Moderation/*/Verb', "Report this artist…"), target: { diff --git a/front/src/components/mixins/Translations.vue b/front/src/components/mixins/Translations.vue index 47eae2b66..1648a830a 100644 --- a/front/src/components/mixins/Translations.vue +++ b/front/src/components/mixins/Translations.vue @@ -56,6 +56,14 @@ export default { summary: { label: this.$pgettext('Content/Account/*', 'Bio'), }, + content_category: { + label: this.$pgettext('Content/*/Dropdown.Label/Noun', 'Content category'), + choices: { + podcast: this.$pgettext('Content/*/Dropdown', 'Podcast'), + music: this.$pgettext('*/*/*', 'Music'), + other: this.$pgettext('*/*/*', 'Other'), + }, + } }, filters: { creation_date: this.$pgettext('Content/*/*/Noun', 'Creation date'), diff --git a/front/src/components/moderation/ReportModal.vue b/front/src/components/moderation/ReportModal.vue index f53f6e998..103cb203d 100644 --- a/front/src/components/moderation/ReportModal.vue +++ b/front/src/components/moderation/ReportModal.vue @@ -140,6 +140,9 @@ export default { return } let fid = this.target._obj.fid + if (this.target.type === 'channel' && this.target._obj.actor ) { + fid = this.target._obj.actor.fid + } if (!fid) { return this.$store.getters['instance/domain'] } diff --git a/front/src/entities.js b/front/src/entities.js index 83ed3dba2..c13d7c6e2 100644 --- a/front/src/entities.js +++ b/front/src/entities.js @@ -179,6 +179,7 @@ export default { label: this.$pgettext('*/*/*/Noun', 'Account'), icon: 'user', urls: { + getDetail: (obj) => { return {name: 'profile.full.overview', params: {username: obj.preferred_username, domain: obj.domain}}}, getAdminDetail: (obj) => { return {name: 'manage.moderation.accounts.detail', params: {id: `${obj.preferred_username}@${obj.domain}`}}} }, moderatedFields: [ @@ -194,6 +195,33 @@ export default { }, ] }, + channel: { + label: this.$pgettext('*/*/*', 'Channel'), + icon: 'stream', + urls: { + getDetail: (obj) => { return {name: 'channels.detail', params: {id: obj.uuid}}}, + getAdminDetail: (obj) => { return {name: 'manage.channels.detail', params: {id: obj.uuid}}} + }, + moderatedFields: [ + { + id: 'name', + label: this.$pgettext('*/*/*/Noun', 'Name'), + getValue: (obj) => { return obj.name } + }, + { + id: 'creation_date', + label: this.$pgettext('Content/*/*/Noun', 'Creation date'), + getValue: (obj) => { return obj.creation_date } + }, + { + id: 'tags', + type: 'tags', + label: this.$pgettext('*/*/*/Noun', 'Tags'), + getValue: (obj) => { return obj.tags }, + getValueRepr: getTagsValueRepr + }, + ] + }, } }, diff --git a/front/src/router/index.js b/front/src/router/index.js index cc5e166db..4581dd0d7 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -298,6 +298,28 @@ export default new Router({ }, { path: "channels", + name: "manage.channels", + component: () => + import( + /* webpackChunkName: "admin" */ "@/views/admin/ChannelsList" + ), + props: route => { + return { + defaultQuery: route.query.q + } + } + }, + { + path: "channels/:id", + name: "manage.channels.detail", + component: () => + import( + /* webpackChunkName: "admin" */ "@/views/admin/ChannelDetail" + ), + props: true + }, + { + path: "albums", name: "manage.library.albums", component: () => import( diff --git a/front/src/views/admin/ChannelDetail.vue b/front/src/views/admin/ChannelDetail.vue new file mode 100644 index 000000000..d5de3a7cb --- /dev/null +++ b/front/src/views/admin/ChannelDetail.vue @@ -0,0 +1,369 @@ +<template> + <main> + <div v-if="isLoading" class="ui vertical segment"> + <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> + </div> + <template v-if="object"> + <section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="object.artist.name"> + <div class="ui stackable one column grid"> + <div class="ui column"> + <div class="segment-content"> + <h2 class="ui header"> + <img v-if="object.artist.cover && object.artist.cover.square_crop" v-lazy="$store.getters['instance/absoluteUrl'](object.artist.cover.square_crop)"> + <img v-else src="../../assets/audio/default-cover.png"> + <div class="content"> + {{ object.artist.name | truncate(100) }} + <div class="sub header"> + <template v-if="object.artist.is_local"> + <span class="ui tiny teal label"> + <i class="home icon"></i> + <translate translate-context="Content/Moderation/*/Short, Noun">Local</translate> + </span> + + </template> + </div> + </div> + </h2> + <template v-if="object.artist.tags && object.artist.tags.length > 0"> + <tags-list :limit="5" detail-route="manage.library.tags.detail" :tags="object.artist.tags"></tags-list> + <div class="ui hidden divider"></div> + </template> + + <div class="header-buttons"> + + <div class="ui icon buttons"> + <router-link class="ui labeled icon button" :to="{name: 'channels.detail', params: {id: object.uuid }}"> + <i class="info icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">Open local profile</translate> + </router-link> + <div class="ui floating dropdown icon button" v-dropdown> + <i class="dropdown icon"></i> + <div class="menu"> + <a + v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser" + class="basic item" + :href="$store.getters['instance/absoluteUrl'](`/api/admin/audio/channel/${object.id}`)" + target="_blank" rel="noopener noreferrer"> + <i class="wrench icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate> + </a> + <fetch-button @refresh="fetchData" v-if="!object.actor.is_local" class="basic item" :url="`channels/${object.uuid}/fetches/`"> + <i class="refresh icon"></i> + <translate translate-context="Content/Moderation/Button/Verb">Refresh from remote server</translate> + </fetch-button> + <a class="basic item" :href="object.actor.url || object.actor.fid" target="_blank" rel="noopener noreferrer"> + <i class="external icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">Open remote profile</translate> + </a> + </div> + </div> + </div> + <div class="ui buttons"> + <dangerous-button + :class="['ui', {loading: isLoading}, 'basic red button']" + :action="remove"> + <translate translate-context="*/*/*/Verb">Delete</translate> + <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this channel?</translate></p> + <div slot="modal-content"> + <p><translate translate-context="Content/Moderation/Paragraph">The channel will be removed, as well as associated uploads, tracks, and albums. This action is irreversible.</translate></p> + </div> + <p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p> + </dangerous-button> + </div> + </div> + </div> + </div> + </div> + </section> + <div class="ui vertical stripe segment"> + <div class="ui stackable three column grid"> + <div class="column"> + <section> + <h3 class="ui header"> + <i class="info icon"></i> + <div class="content"> + <translate translate-context="Content/Moderation/Title">Channel data</translate> + </div> + </h3> + <table class="ui very basic table"> + <tbody> + <tr> + <td> + <translate translate-context="*/*/*/Noun">Name</translate> + </td> + <td> + {{ object.artist.name }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.channels', query: {q: getQuery('category', object.artist.content_category) }}"> + <translate translate-context="*/*/*">Category</translate> + </router-link> + </td> + <td> + {{ object.artist.content_category }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: object.attributed_to.full_username }}"> + <translate translate-context="*/*/*/Noun">Account</translate> + </router-link> + </td> + <td> + {{ object.attributed_to.preferred_username }} + </td> + </tr> + <tr v-if="!object.actor.is_local"> + <td> + <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.actor.domain }}"> + <translate translate-context="Content/Moderation/*/Noun">Domain</translate> + </router-link> + </td> + <td> + {{ object.actor.domain }} + </td> + </tr> + <tr v-if="object.artist.description"> + <td> + <translate translate-context="'*/*/*/Noun">Description</translate> + </td> + <td v-html="object.artist.description.html"></td> + </tr> + <tr v-if="object.actor.url"> + <td> + <translate translate-context="'Content/*/*/Noun">URL</translate> + </td> + <td> + <a :href="object.actor.url" rel="noreferrer noopener" target="_blank">{{ object.actor.url }}</a> + </td> + </tr> + <tr v-if="object.rss_url"> + <td> + <translate translate-context="'*/*/*">RSS Feed</translate> + </td> + <td> + <a :href="object.rss_url" rel="noreferrer noopener" target="_blank">{{ object.rss_url }}</a> + </td> + </tr> + </tbody> + </table> + </section> + </div> + <div class="column"> + <section> + <h3 class="ui header"> + <i class="feed icon"></i> + <div class="content"> + <translate translate-context="Content/Moderation/Title">Activity</translate> + <span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span> + + </div> + </h3> + <div v-if="isLoadingStats" class="ui placeholder"> + <div class="full line"></div> + <div class="short line"></div> + <div class="medium line"></div> + <div class="long line"></div> + </div> + <table v-else class="ui very basic table"> + <tbody> + <tr> + <td> + <translate translate-context="Content/Moderation/Table.Label/Short (Value is a date)">First seen</translate> + </td> + <td> + <human-date :date="object.creation_date"></human-date> + </td> + </tr> + <tr> + <td> + <translate translate-context="*/*/*/Noun">Listenings</translate> + </td> + <td> + {{ stats.listenings }} + </td> + </tr> + <tr> + <td> + <translate translate-context="*/*/*">Favorited tracks</translate> + </td> + <td> + {{ stats.track_favorites }} + </td> + </tr> + <tr> + <td> + <translate translate-context="*/*/*">Playlists</translate> + </td> + <td> + {{ stats.playlists }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.moderation.reports.list', query: {q: getQuery('target', `channel:${object.uuid}`) }}"> + <translate translate-context="Content/Moderation/Table.Label/Noun">Linked reports</translate> + </router-link> + </td> + <td> + {{ stats.reports }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.edits', query: {q: getQuery('target', 'artist ' + object.artist.id)}}"> + <translate translate-context="*/Admin/*/Noun">Edits</translate> + </router-link> + </td> + <td> + {{ stats.mutations }} + </td> + </tr> + </tbody> + </table> + </section> + </div> + <div class="column"> + <section> + <h3 class="ui header"> + <i class="music icon"></i> + <div class="content"> + <translate translate-context="Content/Moderation/Title">Audio content</translate> + <span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span> + + </div> + </h3> + <div v-if="isLoadingStats" class="ui placeholder"> + <div class="full line"></div> + <div class="short line"></div> + <div class="medium line"></div> + <div class="long line"></div> + </div> + <table v-else class="ui very basic table"> + <tbody> + + <tr> + <td> + <translate translate-context="Content/Moderation/Table.Label/Noun">Cached size</translate> + </td> + <td> + {{ stats.media_downloaded_size | humanSize }} + </td> + </tr> + <tr> + <td> + <translate translate-context="Content/Moderation/Table.Label">Total size</translate> + </td> + <td> + {{ stats.media_total_size | humanSize }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('channel_id', object.uuid) }}"> + <translate translate-context="*/*/*">Uploads</translate> + </router-link> + </td> + <td> + {{ stats.uploads }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.albums', query: {q: getQuery('channel_id', object.uuid) }}"> + <translate translate-context="*/*/*">Albums</translate> + </router-link> + </td> + <td> + {{ object.artist.albums_count }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('channel_id', object.uuid) }}"> + <translate translate-context="*/*/*">Tracks</translate> + </router-link> + </td> + <td> + {{ object.artist.tracks_count }} + </td> + </tr> + </tbody> + </table> + + </section> + </div> + </div> + </div> + + </template> + </main> +</template> + +<script> +import axios from "axios" +import logger from "@/logging" + +import TagsList from "@/components/tags/List" +import FetchButton from "@/components/federation/FetchButton" + +export default { + props: ["id"], + components: { + FetchButton, + TagsList + }, + data() { + return { + isLoading: true, + isLoadingStats: false, + object: null, + stats: null, + } + }, + created() { + this.fetchData() + this.fetchStats() + }, + methods: { + fetchData() { + var self = this + this.isLoading = true + let url = `manage/channels/${this.id}/` + axios.get(url).then(response => { + self.object = response.data + self.isLoading = false + }) + }, + fetchStats() { + var self = this + this.isLoadingStats = true + let url = `manage/channels/${this.id}/stats/` + axios.get(url).then(response => { + self.stats = response.data + self.isLoadingStats = false + }) + }, + remove () { + var self = this + this.isLoading = true + let url = `manage/channels/${this.id}/` + axios.delete(url).then(response => { + self.$router.push({name: 'manage.channels'}) + }) + }, + getQuery (field, value) { + return `${field}:"${value}"` + } + }, + computed: { + labels() { + return { + statsWarning: this.$pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object'), + } + }, + } +} +</script> diff --git a/front/src/views/admin/ChannelsList.vue b/front/src/views/admin/ChannelsList.vue new file mode 100644 index 000000000..74cfb56cc --- /dev/null +++ b/front/src/views/admin/ChannelsList.vue @@ -0,0 +1,29 @@ +<template> + <main v-title="labels.title"> + <section class="ui vertical stripe segment"> + <h2 class="ui header">{{ labels.title }}</h2> + <div class="ui hidden divider"></div> + <channels-table :update-url="true" :default-query="defaultQuery"></channels-table> + </section> + </main> +</template> + +<script> +import ChannelsTable from "@/components/manage/ChannelsTable" + +export default { + components: { + ChannelsTable + }, + props: { + defaultQuery: {type: String, required: false}, + }, + computed: { + labels() { + return { + title: this.$pgettext('*/*/*', 'Channels') + } + } + } +} +</script> diff --git a/front/src/views/admin/library/ArtistDetail.vue b/front/src/views/admin/library/ArtistDetail.vue index 5544445bb..f3db10de2 100644 --- a/front/src/views/admin/library/ArtistDetail.vue +++ b/front/src/views/admin/library/ArtistDetail.vue @@ -108,6 +108,16 @@ {{ object.name }} </td> </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.artists', query: {q: getQuery('category', object.content_category) }}"> + <translate translate-context="*/*/*">Category</translate> + </router-link> + </td> + <td> + {{ object.content_category }} + </td> + </tr> <tr v-if="!object.is_local"> <td> <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}"> @@ -265,7 +275,7 @@ </router-link> </td> <td> - {{ object.albums.length }} + {{ object.albums_count }} </td> </tr> <tr> @@ -275,7 +285,7 @@ </router-link> </td> <td> - {{ object.tracks.length }} + {{ object.tracks_count }} </td> </tr> </tbody> @@ -321,8 +331,12 @@ export default { this.isLoading = true let url = `manage/library/artists/${this.id}/` axios.get(url).then(response => { - self.object = response.data - self.isLoading = false + if (response.data.channel) { + self.$router.push({name: "manage.channels.detail", params: {id: response.data.channel}}) + } else { + self.object = response.data + self.isLoading = false + } }) }, fetchStats() { diff --git a/front/src/views/admin/library/Base.vue b/front/src/views/admin/library/Base.vue index 8b99b273b..311fe9e77 100644 --- a/front/src/views/admin/library/Base.vue +++ b/front/src/views/admin/library/Base.vue @@ -4,6 +4,9 @@ <router-link class="ui item" :to="{name: 'manage.library.edits'}"><translate translate-context="*/Admin/*/Noun">Edits</translate></router-link> + <router-link + class="ui item" + :to="{name: 'manage.channels'}"><translate translate-context="*/*/*">Channels</translate></router-link> <router-link class="ui item" :to="{name: 'manage.library.artists'}"><translate translate-context="*/*/*/Noun">Artists</translate></router-link> diff --git a/front/src/views/admin/moderation/AccountsDetail.vue b/front/src/views/admin/moderation/AccountsDetail.vue index e8f65dca8..5dbacb18e 100644 --- a/front/src/views/admin/moderation/AccountsDetail.vue +++ b/front/src/views/admin/moderation/AccountsDetail.vue @@ -343,7 +343,16 @@ {{ stats.media_total_size | humanSize }} </td> </tr> - + <tr> + <td> + <router-link :to="{name: 'manage.channels', query: {q: getQuery('account', object.full_username) }}"> + <translate translate-context="*/*/*">Channels</translate> + </router-link> + </td> + <td> + {{ stats.channels }} + </td> + </tr> <tr> <td> <router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('account', object.full_username) }}"> diff --git a/front/src/views/admin/moderation/DomainsDetail.vue b/front/src/views/admin/moderation/DomainsDetail.vue index 65434f2ec..651a4063e 100644 --- a/front/src/views/admin/moderation/DomainsDetail.vue +++ b/front/src/views/admin/moderation/DomainsDetail.vue @@ -266,6 +266,16 @@ {{ stats.media_total_size | humanSize }} </td> </tr> + <tr> + <td> + <router-link :to="{name: 'manage.channels', query: {q: getQuery('domain', object.name) }}"> + <translate translate-context="*/*/*">Channels</translate> + </router-link> + </td> + <td> + {{ stats.channels }} + </td> + </tr> <tr> <td> <router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('domain', object.name) }}"> diff --git a/front/src/views/channels/DetailBase.vue b/front/src/views/channels/DetailBase.vue index 0fa345a2d..4e5dabc23 100644 --- a/front/src/views/channels/DetailBase.vue +++ b/front/src/views/channels/DetailBase.vue @@ -84,7 +84,7 @@ <div role="button" class="basic item" - v-for="obj in getReportableObjs({channel: object})" + v-for="obj in getReportableObjs({account: object.attributed_to, channel: object})" :key="obj.target.type + obj.target.id" @click.stop.prevent="$store.dispatch('moderation/report', obj.target)"> <i class="share icon" /> {{ obj.label }} @@ -112,7 +112,7 @@ </template> <template v-if="$store.state.auth.availablePermissions['library']" > <div class="divider"></div> - <router-link class="basic item" :to="{name: 'manage.library.channels.detail', params: {id: object.uuid}}"> + <router-link class="basic item" :to="{name: 'manage.channels.detail', params: {id: object.uuid}}"> <i class="wrench icon"></i> <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> </router-link> -- GitLab