Merge branch '432-tags-admin' into 'develop'

See #432: admin for tags

See merge request funkwhale/funkwhale!838
This commit is contained in:
Eliot Berriot 2019-07-29 10:48:43 +02:00
commit 5672563c6b
24 changed files with 1063 additions and 287 deletions

View File

@ -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.moderation import models as moderation_models
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.users import models as users_models from funkwhale_api.users import models as users_models
from funkwhale_api.tags import models as tags_models
class ActorField(forms.CharField): class ActorField(forms.CharField):
@ -61,6 +62,7 @@ class ManageArtistFilterSet(filters.FilterSet):
"field": forms.IntegerField(), "field": forms.IntegerField(),
"distinct": True, "distinct": True,
}, },
"tag": {"to": "tagged_items__tag__name", "distinct": True},
}, },
) )
) )
@ -90,6 +92,7 @@ class ManageAlbumFilterSet(filters.FilterSet):
"field": forms.IntegerField(), "field": forms.IntegerField(),
"distinct": True, "distinct": True,
}, },
"tag": {"to": "tagged_items__tag__name", "distinct": True},
}, },
) )
) )
@ -128,6 +131,7 @@ class ManageTrackFilterSet(filters.FilterSet):
"field": forms.IntegerField(), "field": forms.IntegerField(),
"distinct": True, "distinct": True,
}, },
"tag": {"to": "tagged_items__tag__name", "distinct": True},
}, },
) )
) )
@ -340,3 +344,11 @@ class ManageInstancePolicyFilterSet(filters.FilterSet):
"silence_notifications", "silence_notifications",
"reject_media", "reject_media",
] ]
class ManageTagFilterSet(filters.FilterSet):
q = fields.SearchFilter(search_fields=["name"])
class Meta:
model = tags_models.Tag
fields = ["q"]

View File

@ -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.moderation import models as moderation_models
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.music import serializers as music_serializers 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 funkwhale_api.users import models as users_models
from . import filters from . import filters
@ -376,6 +377,7 @@ class ManageArtistSerializer(ManageBaseArtistSerializer):
albums = ManageNestedAlbumSerializer(many=True) albums = ManageNestedAlbumSerializer(many=True)
tracks = ManageNestedTrackSerializer(many=True) tracks = ManageNestedTrackSerializer(many=True)
attributed_to = ManageBaseActorSerializer() attributed_to = ManageBaseActorSerializer()
tags = serializers.SerializerMethodField()
class Meta: class Meta:
model = music_models.Artist model = music_models.Artist
@ -383,8 +385,13 @@ class ManageArtistSerializer(ManageBaseArtistSerializer):
"albums", "albums",
"tracks", "tracks",
"attributed_to", "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): class ManageNestedArtistSerializer(ManageBaseArtistSerializer):
pass pass
@ -394,6 +401,7 @@ class ManageAlbumSerializer(ManageBaseAlbumSerializer):
tracks = ManageNestedTrackSerializer(many=True) tracks = ManageNestedTrackSerializer(many=True)
attributed_to = ManageBaseActorSerializer() attributed_to = ManageBaseActorSerializer()
artist = ManageNestedArtistSerializer() artist = ManageNestedArtistSerializer()
tags = serializers.SerializerMethodField()
class Meta: class Meta:
model = music_models.Album model = music_models.Album
@ -401,8 +409,13 @@ class ManageAlbumSerializer(ManageBaseAlbumSerializer):
"artist", "artist",
"tracks", "tracks",
"attributed_to", "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): class ManageTrackAlbumSerializer(ManageBaseAlbumSerializer):
artist = ManageNestedArtistSerializer() artist = ManageNestedArtistSerializer()
@ -417,6 +430,7 @@ class ManageTrackSerializer(ManageNestedTrackSerializer):
album = ManageTrackAlbumSerializer() album = ManageTrackAlbumSerializer()
attributed_to = ManageBaseActorSerializer() attributed_to = ManageBaseActorSerializer()
uploads_count = serializers.SerializerMethodField() uploads_count = serializers.SerializerMethodField()
tags = serializers.SerializerMethodField()
class Meta: class Meta:
model = music_models.Track model = music_models.Track
@ -425,11 +439,16 @@ class ManageTrackSerializer(ManageNestedTrackSerializer):
"album", "album",
"attributed_to", "attributed_to",
"uploads_count", "uploads_count",
"tags",
] ]
def get_uploads_count(self, obj): def get_uploads_count(self, obj):
return getattr(obj, "uploads_count", None) 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): class ManageTrackActionSerializer(common_serializers.ActionSerializer):
actions = [common_serializers.Action("delete", allow_all=False)] actions = [common_serializers.Action("delete", allow_all=False)]
@ -564,3 +583,40 @@ class ManageUploadSerializer(serializers.ModelSerializer):
"track", "track",
"library", "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)
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()

View File

@ -24,6 +24,7 @@ users_router.register(r"invitations", views.ManageInvitationViewSet, "invitation
other_router = routers.OptionalSlashRouter() other_router = routers.OptionalSlashRouter()
other_router.register(r"accounts", views.ManageActorViewSet, "accounts") other_router.register(r"accounts", views.ManageActorViewSet, "accounts")
other_router.register(r"tags", views.ManageTagViewSet, "tags")
urlpatterns = [ urlpatterns = [
url( url(

View File

@ -2,7 +2,7 @@ from rest_framework import mixins, response, viewsets
from rest_framework import decorators as rest_decorators from rest_framework import decorators as rest_decorators
from django.db.models import Count, Prefetch, Q, Sum, OuterRef, Subquery 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 django.shortcuts import get_object_or_404
from funkwhale_api.common import models as common_models from funkwhale_api.common import models as common_models
@ -12,8 +12,10 @@ from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import tasks as federation_tasks from funkwhale_api.federation import tasks as federation_tasks
from funkwhale_api.history import models as history_models from funkwhale_api.history import models as history_models
from funkwhale_api.music import models as music_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.moderation import models as moderation_models
from funkwhale_api.playlists import models as playlists_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 from funkwhale_api.users import models as users_models
@ -70,6 +72,7 @@ class ManageArtistViewSet(
tracks_count=Count("tracks") tracks_count=Count("tracks")
), ),
), ),
music_views.TAG_PREFETCH,
) )
) )
serializer_class = serializers.ManageArtistSerializer serializer_class = serializers.ManageArtistSerializer
@ -107,7 +110,7 @@ class ManageAlbumViewSet(
music_models.Album.objects.all() music_models.Album.objects.all()
.order_by("-id") .order_by("-id")
.select_related("attributed_to", "artist") .select_related("attributed_to", "artist")
.prefetch_related("tracks") .prefetch_related("tracks", music_views.TAG_PREFETCH)
) )
serializer_class = serializers.ManageAlbumSerializer serializer_class = serializers.ManageAlbumSerializer
filterset_class = filters.ManageAlbumFilterSet filterset_class = filters.ManageAlbumFilterSet
@ -151,6 +154,7 @@ class ManageTrackViewSet(
.order_by("-id") .order_by("-id")
.select_related("attributed_to", "artist", "album__artist") .select_related("attributed_to", "artist", "album__artist")
.annotate(uploads_count=Coalesce(Subquery(uploads_subquery), 0)) .annotate(uploads_count=Coalesce(Subquery(uploads_subquery), 0))
.prefetch_related(music_views.TAG_PREFETCH)
) )
serializer_class = serializers.ManageTrackSerializer serializer_class = serializers.ManageTrackSerializer
filterset_class = filters.ManageTrackFilterSet filterset_class = filters.ManageTrackFilterSet
@ -452,3 +456,53 @@ class ManageInstancePolicyViewSet(
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(actor=self.request.user.actor) 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
@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)

View File

@ -1,5 +1,4 @@
import contextlib import contextlib
import datetime
import io import io
import os import os
import PIL import PIL
@ -22,7 +21,6 @@ from django.db.models import QuerySet
from aioresponses import aioresponses from aioresponses import aioresponses
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from rest_framework import fields as rest_fields
from rest_framework.test import APIClient, APIRequestFactory from rest_framework.test import APIClient, APIRequestFactory
from funkwhale_api.activity import record from funkwhale_api.activity import record
@ -30,6 +28,7 @@ from funkwhale_api.federation import actors
from funkwhale_api.moderation import mrf from funkwhale_api.moderation import mrf
from funkwhale_api.music import licenses from funkwhale_api.music import licenses
from . import utils as test_utils
pytest_plugins = "aiohttp.pytest_plugin" pytest_plugins = "aiohttp.pytest_plugin"
@ -297,18 +296,9 @@ def authenticated_actor(factories, mocker):
yield actor yield actor
@pytest.fixture @pytest.fixture(scope="session")
def to_api_date(): def to_api_date():
def inner(value): return test_utils.to_api_date
if isinstance(value, datetime.datetime):
f = rest_fields.DateTimeField()
return f.to_representation(value)
if isinstance(value, datetime.date):
f = rest_fields.DateField()
return f.to_representation(value)
raise ValueError("Invalid value: {}".format(value))
return inner
@pytest.fixture() @pytest.fixture()

View File

@ -4,7 +4,7 @@ from funkwhale_api.federation import api_serializers
from funkwhale_api.federation import serializers from funkwhale_api.federation import serializers
def test_library_serializer(factories): def test_library_serializer(factories, to_api_date):
library = factories["music.Library"](uploads_count=5678) library = factories["music.Library"](uploads_count=5678)
expected = { expected = {
"fid": library.fid, "fid": library.fid,
@ -12,7 +12,7 @@ def test_library_serializer(factories):
"actor": serializers.APIActorSerializer(library.actor).data, "actor": serializers.APIActorSerializer(library.actor).data,
"name": library.name, "name": library.name,
"description": library.description, "description": library.description,
"creation_date": library.creation_date.isoformat().split("+")[0] + "Z", "creation_date": to_api_date(library.creation_date),
"uploads_count": library.uploads_count, "uploads_count": library.uploads_count,
"privacy_level": library.privacy_level, "privacy_level": library.privacy_level,
"follow": None, "follow": None,
@ -34,7 +34,7 @@ def test_library_serializer_latest_scan(factories):
assert serializer.data["latest_scan"] == expected assert serializer.data["latest_scan"] == expected
def test_library_serializer_with_follow(factories): def test_library_serializer_with_follow(factories, to_api_date):
library = factories["music.Library"](uploads_count=5678) library = factories["music.Library"](uploads_count=5678)
follow = factories["federation.LibraryFollow"](target=library) follow = factories["federation.LibraryFollow"](target=library)
@ -45,7 +45,7 @@ def test_library_serializer_with_follow(factories):
"actor": serializers.APIActorSerializer(library.actor).data, "actor": serializers.APIActorSerializer(library.actor).data,
"name": library.name, "name": library.name,
"description": library.description, "description": library.description,
"creation_date": library.creation_date.isoformat().split("+")[0] + "Z", "creation_date": to_api_date(library.creation_date),
"uploads_count": library.uploads_count, "uploads_count": library.uploads_count,
"privacy_level": library.privacy_level, "privacy_level": library.privacy_level,
"follow": api_serializers.NestedLibraryFollowSerializer(follow).data, "follow": api_serializers.NestedLibraryFollowSerializer(follow).data,

View File

@ -39,13 +39,13 @@ def test_user_update_permission(factories):
assert user.permission_settings is False assert user.permission_settings is False
def test_manage_domain_serializer(factories, now): def test_manage_domain_serializer(factories, now, to_api_date):
domain = factories["federation.Domain"](nodeinfo_fetch_date=None) domain = factories["federation.Domain"](nodeinfo_fetch_date=None)
setattr(domain, "actors_count", 42) setattr(domain, "actors_count", 42)
setattr(domain, "outbox_activities_count", 23) setattr(domain, "outbox_activities_count", 23)
expected = { expected = {
"name": domain.name, "name": domain.name,
"creation_date": domain.creation_date.isoformat().split("+")[0] + "Z", "creation_date": to_api_date(domain.creation_date),
"actors_count": 42, "actors_count": 42,
"outbox_activities_count": 23, "outbox_activities_count": 23,
"nodeinfo": {}, "nodeinfo": {},
@ -65,14 +65,14 @@ def test_manage_domain_serializer_validates_hostname(db):
s.is_valid(raise_exception=True) s.is_valid(raise_exception=True)
def test_manage_actor_serializer(factories, now): def test_manage_actor_serializer(factories, now, to_api_date):
actor = factories["federation.Actor"]() actor = factories["federation.Actor"]()
setattr(actor, "uploads_count", 66) setattr(actor, "uploads_count", 66)
expected = { expected = {
"id": actor.id, "id": actor.id,
"name": actor.name, "name": actor.name,
"creation_date": actor.creation_date.isoformat().split("+")[0] + "Z", "creation_date": to_api_date(actor.creation_date),
"last_fetch_date": actor.last_fetch_date.isoformat().split("+")[0] + "Z", "last_fetch_date": to_api_date(actor.last_fetch_date),
"uploads_count": 66, "uploads_count": 66,
"fid": actor.fid, "fid": actor.fid,
"url": actor.url, "url": actor.url,
@ -110,13 +110,15 @@ def test_manage_actor_serializer(factories, now):
), ),
], ],
) )
def test_instance_policy_serializer_repr(factories, factory_kwargs, expected): def test_instance_policy_serializer_repr(
factories, factory_kwargs, expected, to_api_date
):
policy = factories["moderation.InstancePolicy"](block_all=True, **factory_kwargs) policy = factories["moderation.InstancePolicy"](block_all=True, **factory_kwargs)
e = { e = {
"id": policy.id, "id": policy.id,
"uuid": str(policy.uuid), "uuid": str(policy.uuid),
"creation_date": policy.creation_date.isoformat().split("+")[0] + "Z", "creation_date": to_api_date(policy.creation_date),
"actor": policy.actor.full_username, "actor": policy.actor.full_username,
"block_all": True, "block_all": True,
"silence_activity": False, "silence_activity": False,
@ -280,7 +282,7 @@ def test_instance_policy_serializer_purges_target_actor(
assert on_commit.call_count == 0 assert on_commit.call_count == 0
def test_manage_artist_serializer(factories, now): def test_manage_artist_serializer(factories, now, to_api_date):
artist = factories["music.Artist"](attributed=True) artist = factories["music.Artist"](attributed=True)
track = factories["music.Track"](artist=artist) track = factories["music.Track"](artist=artist)
album = factories["music.Album"](artist=artist) album = factories["music.Album"](artist=artist)
@ -291,19 +293,20 @@ def test_manage_artist_serializer(factories, now):
"fid": artist.fid, "fid": artist.fid,
"name": artist.name, "name": artist.name,
"mbid": artist.mbid, "mbid": artist.mbid,
"creation_date": artist.creation_date.isoformat().split("+")[0] + "Z", "creation_date": to_api_date(artist.creation_date),
"albums": [serializers.ManageNestedAlbumSerializer(album).data], "albums": [serializers.ManageNestedAlbumSerializer(album).data],
"tracks": [serializers.ManageNestedTrackSerializer(track).data], "tracks": [serializers.ManageNestedTrackSerializer(track).data],
"attributed_to": serializers.ManageBaseActorSerializer( "attributed_to": serializers.ManageBaseActorSerializer(
artist.attributed_to artist.attributed_to
).data, ).data,
"tags": [],
} }
s = serializers.ManageArtistSerializer(artist) s = serializers.ManageArtistSerializer(artist)
assert s.data == expected assert s.data == expected
def test_manage_nested_track_serializer(factories, now): def test_manage_nested_track_serializer(factories, now, to_api_date):
track = factories["music.Track"]() track = factories["music.Track"]()
expected = { expected = {
"id": track.id, "id": track.id,
@ -312,7 +315,7 @@ def test_manage_nested_track_serializer(factories, now):
"fid": track.fid, "fid": track.fid,
"title": track.title, "title": track.title,
"mbid": track.mbid, "mbid": track.mbid,
"creation_date": track.creation_date.isoformat().split("+")[0] + "Z", "creation_date": to_api_date(track.creation_date),
"position": track.position, "position": track.position,
"disc_number": track.disc_number, "disc_number": track.disc_number,
"copyright": track.copyright, "copyright": track.copyright,
@ -323,7 +326,7 @@ def test_manage_nested_track_serializer(factories, now):
assert s.data == expected assert s.data == expected
def test_manage_nested_album_serializer(factories, now): def test_manage_nested_album_serializer(factories, now, to_api_date):
album = factories["music.Album"]() album = factories["music.Album"]()
setattr(album, "tracks_count", 44) setattr(album, "tracks_count", 44)
expected = { expected = {
@ -333,7 +336,7 @@ def test_manage_nested_album_serializer(factories, now):
"fid": album.fid, "fid": album.fid,
"title": album.title, "title": album.title,
"mbid": album.mbid, "mbid": album.mbid,
"creation_date": album.creation_date.isoformat().split("+")[0] + "Z", "creation_date": to_api_date(album.creation_date),
"release_date": album.release_date.isoformat(), "release_date": album.release_date.isoformat(),
"cover": { "cover": {
"original": album.cover.url, "original": album.cover.url,
@ -348,7 +351,7 @@ def test_manage_nested_album_serializer(factories, now):
assert s.data == expected assert s.data == expected
def test_manage_nested_artist_serializer(factories, now): def test_manage_nested_artist_serializer(factories, now, to_api_date):
artist = factories["music.Artist"]() artist = factories["music.Artist"]()
expected = { expected = {
"id": artist.id, "id": artist.id,
@ -357,14 +360,14 @@ def test_manage_nested_artist_serializer(factories, now):
"fid": artist.fid, "fid": artist.fid,
"name": artist.name, "name": artist.name,
"mbid": artist.mbid, "mbid": artist.mbid,
"creation_date": artist.creation_date.isoformat().split("+")[0] + "Z", "creation_date": to_api_date(artist.creation_date),
} }
s = serializers.ManageNestedArtistSerializer(artist) s = serializers.ManageNestedArtistSerializer(artist)
assert s.data == expected assert s.data == expected
def test_manage_album_serializer(factories, now): def test_manage_album_serializer(factories, now, to_api_date):
album = factories["music.Album"](attributed=True) album = factories["music.Album"](attributed=True)
track = factories["music.Track"](album=album) track = factories["music.Track"](album=album)
expected = { expected = {
@ -374,7 +377,7 @@ def test_manage_album_serializer(factories, now):
"fid": album.fid, "fid": album.fid,
"title": album.title, "title": album.title,
"mbid": album.mbid, "mbid": album.mbid,
"creation_date": album.creation_date.isoformat().split("+")[0] + "Z", "creation_date": to_api_date(album.creation_date),
"release_date": album.release_date.isoformat(), "release_date": album.release_date.isoformat(),
"cover": { "cover": {
"original": album.cover.url, "original": album.cover.url,
@ -387,13 +390,14 @@ def test_manage_album_serializer(factories, now):
"attributed_to": serializers.ManageBaseActorSerializer( "attributed_to": serializers.ManageBaseActorSerializer(
album.attributed_to album.attributed_to
).data, ).data,
"tags": [],
} }
s = serializers.ManageAlbumSerializer(album) s = serializers.ManageAlbumSerializer(album)
assert s.data == expected assert s.data == expected
def test_manage_track_serializer(factories, now): def test_manage_track_serializer(factories, now, to_api_date):
track = factories["music.Track"](attributed=True) track = factories["music.Track"](attributed=True)
setattr(track, "uploads_count", 44) setattr(track, "uploads_count", 44)
expected = { expected = {
@ -407,20 +411,21 @@ def test_manage_track_serializer(factories, now):
"position": track.position, "position": track.position,
"copyright": track.copyright, "copyright": track.copyright,
"license": track.license, "license": track.license,
"creation_date": track.creation_date.isoformat().split("+")[0] + "Z", "creation_date": to_api_date(track.creation_date),
"artist": serializers.ManageNestedArtistSerializer(track.artist).data, "artist": serializers.ManageNestedArtistSerializer(track.artist).data,
"album": serializers.ManageTrackAlbumSerializer(track.album).data, "album": serializers.ManageTrackAlbumSerializer(track.album).data,
"attributed_to": serializers.ManageBaseActorSerializer( "attributed_to": serializers.ManageBaseActorSerializer(
track.attributed_to track.attributed_to
).data, ).data,
"uploads_count": 44, "uploads_count": 44,
"tags": [],
} }
s = serializers.ManageTrackSerializer(track) s = serializers.ManageTrackSerializer(track)
assert s.data == expected assert s.data == expected
def test_manage_library_serializer(factories, now): def test_manage_library_serializer(factories, now, to_api_date):
library = factories["music.Library"]() library = factories["music.Library"]()
setattr(library, "followers_count", 42) setattr(library, "followers_count", 42)
setattr(library, "_uploads_count", 44) setattr(library, "_uploads_count", 44)
@ -435,7 +440,7 @@ def test_manage_library_serializer(factories, now):
"name": library.name, "name": library.name,
"description": library.description, "description": library.description,
"privacy_level": library.privacy_level, "privacy_level": library.privacy_level,
"creation_date": library.creation_date.isoformat().split("+")[0] + "Z", "creation_date": to_api_date(library.creation_date),
"actor": serializers.ManageBaseActorSerializer(library.actor).data, "actor": serializers.ManageBaseActorSerializer(library.actor).data,
"uploads_count": 44, "uploads_count": 44,
"followers_count": 42, "followers_count": 42,
@ -445,7 +450,7 @@ def test_manage_library_serializer(factories, now):
assert s.data == expected assert s.data == expected
def test_manage_upload_serializer(factories, now): def test_manage_upload_serializer(factories, now, to_api_date):
upload = factories["music.Upload"]() upload = factories["music.Upload"]()
expected = { expected = {
@ -463,8 +468,8 @@ def test_manage_upload_serializer(factories, now):
"source": upload.source, "source": upload.source,
"filename": upload.filename, "filename": upload.filename,
"metadata": upload.metadata, "metadata": upload.metadata,
"creation_date": upload.creation_date.isoformat().split("+")[0] + "Z", "creation_date": to_api_date(upload.creation_date),
"modification_date": upload.modification_date.isoformat().split("+")[0] + "Z", "modification_date": to_api_date(upload.modification_date),
"accessed_date": None, "accessed_date": None,
"import_date": None, "import_date": None,
"import_metadata": upload.import_metadata, "import_metadata": upload.import_metadata,
@ -487,6 +492,7 @@ def test_manage_upload_serializer(factories, now):
("music.Artist", serializers.ManageArtistActionSerializer), ("music.Artist", serializers.ManageArtistActionSerializer),
("music.Library", serializers.ManageLibraryActionSerializer), ("music.Library", serializers.ManageLibraryActionSerializer),
("music.Upload", serializers.ManageUploadActionSerializer), ("music.Upload", serializers.ManageUploadActionSerializer),
("tags.Tag", serializers.ManageTagActionSerializer),
], ],
) )
def test_action_serializer_delete(factory, serializer_class, factories): def test_action_serializer_delete(factory, serializer_class, factories):
@ -496,3 +502,22 @@ def test_action_serializer_delete(factory, serializer_class, factories):
s.handle_delete(objects[0].__class__.objects.all()) s.handle_delete(objects[0].__class__.objects.all())
assert objects[0].__class__.objects.count() == 0 assert objects[0].__class__.objects.count() == 0
def test_manage_tag_serializer(factories, to_api_date):
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": to_api_date(tag.creation_date),
"tracks_count": 42,
"albums_count": 54,
"artists_count": 66,
}
s = serializers.ManageTagSerializer(tag)
assert s.data == expected

View File

@ -377,3 +377,31 @@ def test_upload_delete(factories, superuser_api_client):
response = superuser_api_client.delete(url) response = superuser_api_client.delete(url)
assert response.status_code == 204 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

View File

@ -118,7 +118,7 @@ def test_upload_serializer(factories, to_api_date):
"bitrate": upload.bitrate, "bitrate": upload.bitrate,
"size": upload.size, "size": upload.size,
"library": serializers.LibraryForOwnerSerializer(upload.library).data, "library": serializers.LibraryForOwnerSerializer(upload.library).data,
"creation_date": upload.creation_date.isoformat().split("+")[0] + "Z", "creation_date": to_api_date(upload.creation_date),
"import_date": None, "import_date": None,
"import_status": "pending", "import_status": "pending",
} }
@ -145,7 +145,7 @@ def test_upload_owner_serializer(factories, to_api_date):
"bitrate": upload.bitrate, "bitrate": upload.bitrate,
"size": upload.size, "size": upload.size,
"library": serializers.LibraryForOwnerSerializer(upload.library).data, "library": serializers.LibraryForOwnerSerializer(upload.library).data,
"creation_date": upload.creation_date.isoformat().split("+")[0] + "Z", "creation_date": to_api_date(upload.creation_date),
"metadata": {"test": "metadata"}, "metadata": {"test": "metadata"},
"import_metadata": {"import": "metadata"}, "import_metadata": {"import": "metadata"},
"import_date": None, "import_date": None,

View File

@ -29,7 +29,7 @@ def test_create_artist_radio(factories):
assert session.related_object == artist assert session.related_object == artist
def test_tag_radio_repr(factories): def test_tag_radio_repr(factories, to_api_date):
tag = factories["tags.Tag"]() tag = factories["tags.Tag"]()
session = factories["radios.RadioSession"](related_object=tag, radio_type="tag") session = factories["radios.RadioSession"](related_object=tag, radio_type="tag")
@ -39,6 +39,6 @@ def test_tag_radio_repr(factories):
"custom_radio": None, "custom_radio": None,
"user": session.user.pk, "user": session.user.pk,
"related_object_id": tag.name, "related_object_id": tag.name,
"creation_date": session.creation_date.isoformat().split("+")[0] + "Z", "creation_date": to_api_date(session.creation_date),
} }
assert serializers.RadioSessionSerializer(session).data == expected assert serializers.RadioSessionSerializer(session).data == expected

View File

@ -3,15 +3,12 @@ import pytest
from funkwhale_api.tags import serializers from funkwhale_api.tags import serializers
def test_tag_serializer(factories): def test_tag_serializer(factories, to_api_date):
tag = factories["tags.Tag"]() tag = factories["tags.Tag"]()
serializer = serializers.TagSerializer(tag) serializer = serializers.TagSerializer(tag)
expected = { expected = {"name": tag.name, "creation_date": to_api_date(tag.creation_date)}
"name": tag.name,
"creation_date": tag.creation_date.isoformat().split("+")[0] + "Z",
}
assert serializer.data == expected assert serializer.data == expected

View File

@ -0,0 +1,6 @@
from . import utils as test_utils
def test_to_api_date(now):
assert test_utils.to_api_date(now) == now.isoformat().split("+")[0] + "Z"

12
api/tests/utils.py Normal file
View File

@ -0,0 +1,12 @@
import datetime
from rest_framework import fields as rest_fields
def to_api_date(value):
if isinstance(value, datetime.datetime):
f = rest_fields.DateTimeField()
return f.to_representation(value)
if isinstance(value, datetime.date):
f = rest_fields.DateField()
return f.to_representation(value)
raise ValueError("Invalid value: {}".format(value))

View File

@ -7,6 +7,11 @@
</span> </span>
</h2> </h2>
<radio-button type="tag" :object-id="id"></radio-button> <radio-button type="tag" :object-id="id"></radio-button>
<router-link class="ui right floated button" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.tags.detail', params: {id: id}}">
<i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
</router-link>
<div class="ui hidden divider"></div> <div class="ui hidden divider"></div>
<div class="ui row"> <div class="ui row">
<artist-widget :controls="false" :filters="{playable: true, ordering: '-creation_date', tag: id}"> <artist-widget :controls="false" :filters="{playable: true, ordering: '-creation_date', tag: id}">

View File

@ -0,0 +1,209 @@
<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="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>
<import-status-modal :upload="detailedUpload" :show.sync="showUploadDetailModal" />
<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/tags/action/"
idField="name"
:filters="actionFilters">
<template slot="header-cells">
<th><translate translate-context="*/*/*">Name</translate></th>
<th><translate translate-context="*/*/*">Artists</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.library.tags.detail', params: {id: scope.obj.name }}" :title="scope.obj.name">
{{ scope.obj.name|truncate(30, "…", true) }}
</router-link>
</td>
<td>
{{ scope.obj.artists_count }}
</td>
<td>
{{ scope.obj.albums_count }}
</td>
<td>
{{ scope.obj.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'
import ImportStatusModal from '@/components/library/ImportStatusModal'
export default {
mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin],
props: {
filters: {type: Object, required: false},
},
components: {
Pagination,
ActionTable,
ImportStatusModal
},
data () {
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
return {
detailedUpload: null,
showUploadDetailModal: false,
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'],
['length', 'length'],
['items_count', 'items_count'],
]
}
},
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/tags/', {params: params}).then((response) => {
self.isLoading = false
self.result = response.data
}, 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 name')
}
},
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 tag will be removed and unlinked with existing content, if any. 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>

View File

@ -52,6 +52,8 @@ export default {
album_title: this.$pgettext('Content/*/Dropdown/Noun', 'Album name'), album_title: this.$pgettext('Content/*/Dropdown/Noun', 'Album name'),
artist_name: this.$pgettext('Content/*/Dropdown/Noun', 'Artist name'), artist_name: this.$pgettext('Content/*/Dropdown/Noun', 'Artist name'),
name: this.$pgettext('*/*/*/Noun', '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'), size: this.$pgettext('Content/Library/*/in MB', 'Size'),
bitrate: this.$pgettext('Content/Track/*/Noun', 'Bitrate'), bitrate: this.$pgettext('Content/Track/*/Noun', 'Bitrate'),
duration: this.$pgettext('Content/*/*', 'Duration'), duration: this.$pgettext('Content/*/*', 'Duration'),

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="tag-list"> <div class="tag-list">
<router-link <router-link
:to="{name: 'library.tags.detail', params: {id: tag}}" :to="{name: detailRoute, params: {id: tag}}"
:class="['ui', 'circular', 'hashtag', 'label', labelClasses]" :class="['ui', 'circular', 'hashtag', 'label', labelClasses]"
v-for="tag in toDisplay" v-for="tag in toDisplay"
:title="tag" :title="tag"
@ -21,6 +21,7 @@ export default {
truncateSize: {type: Number, default: 25}, truncateSize: {type: Number, default: 25},
limit: {type: Number, default: 5}, limit: {type: Number, default: 5},
labelClasses: {type: String, default: ''}, labelClasses: {type: String, default: ''},
detailRoute: {type: String, default: 'library.tags.detail'},
}, },
data () { data () {
return { return {

View File

@ -1,430 +1,508 @@
import Vue from 'vue' import Vue from "vue"
import Router from 'vue-router' import Router from "vue-router"
Vue.use(Router) Vue.use(Router)
console.log('PROCESS', process.env) console.log("PROCESS", process.env)
export default new Router({ export default new Router({
mode: 'history', mode: "history",
linkActiveClass: 'active', linkActiveClass: "active",
base: process.env.VUE_APP_ROUTER_BASE_URL || '/', base: process.env.VUE_APP_ROUTER_BASE_URL || "/",
routes: [ routes: [
{ {
path: '/', path: "/",
name: 'index', name: "index",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/Home"), import(/* webpackChunkName: "core" */ "@/components/Home")
}, },
{ {
path: '/front', path: "/front",
name: 'front', name: "front",
redirect: '/' redirect: "/"
}, },
{ {
path: '/about', path: "/about",
name: 'about', name: "about",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/About"), import(/* webpackChunkName: "core" */ "@/components/About")
}, },
{ {
path: '/login', path: "/login",
name: 'login', name: "login",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/auth/Login"), import(/* webpackChunkName: "core" */ "@/components/auth/Login"),
props: (route) => ({ next: route.query.next || '/library' }) props: route => ({ next: route.query.next || "/library" })
}, },
{ {
path: '/notifications', path: "/notifications",
name: 'notifications', name: "notifications",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/Notifications"), import(/* webpackChunkName: "core" */ "@/views/Notifications")
}, },
{ {
path: '/auth/password/reset', path: "/auth/password/reset",
name: 'auth.password-reset', name: "auth.password-reset",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/auth/PasswordReset"), import(/* webpackChunkName: "core" */ "@/views/auth/PasswordReset"),
props: (route) => ({ props: route => ({
defaultEmail: route.query.email defaultEmail: route.query.email
}) })
}, },
{ {
path: '/auth/email/confirm', path: "/auth/email/confirm",
name: 'auth.email-confirm', name: "auth.email-confirm",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/auth/EmailConfirm"), import(/* webpackChunkName: "core" */ "@/views/auth/EmailConfirm"),
props: (route) => ({ props: route => ({
defaultKey: route.query.key defaultKey: route.query.key
}) })
}, },
{ {
path: '/auth/password/reset/confirm', path: "/auth/password/reset/confirm",
name: 'auth.password-reset-confirm', name: "auth.password-reset-confirm",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/auth/PasswordResetConfirm"), import(
props: (route) => ({ /* webpackChunkName: "core" */ "@/views/auth/PasswordResetConfirm"
),
props: route => ({
defaultUid: route.query.uid, defaultUid: route.query.uid,
defaultToken: route.query.token defaultToken: route.query.token
}) })
}, },
{ {
path: '/authorize', path: "/authorize",
name: 'authorize', name: "authorize",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/auth/Authorize"), import(/* webpackChunkName: "core" */ "@/components/auth/Authorize"),
props: (route) => ({ props: route => ({
clientId: route.query.client_id, clientId: route.query.client_id,
redirectUri: route.query.redirect_uri, redirectUri: route.query.redirect_uri,
scope: route.query.scope, scope: route.query.scope,
responseType: route.query.response_type, responseType: route.query.response_type,
nonce: route.query.nonce, nonce: route.query.nonce,
state: route.query.state, state: route.query.state
}) })
}, },
{ {
path: '/signup', path: "/signup",
name: 'signup', name: "signup",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/auth/Signup"), import(/* webpackChunkName: "core" */ "@/components/auth/Signup"),
props: (route) => ({ props: route => ({
defaultInvitation: route.query.invitation defaultInvitation: route.query.invitation
}) })
}, },
{ {
path: '/logout', path: "/logout",
name: 'logout', name: "logout",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/auth/Logout"), import(/* webpackChunkName: "core" */ "@/components/auth/Logout")
}, },
{ {
path: '/settings', path: "/settings",
name: 'settings', name: "settings",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/auth/Settings"), import(/* webpackChunkName: "core" */ "@/components/auth/Settings")
}, },
{ {
path: '/settings/applications/new', path: "/settings/applications/new",
name: 'settings.applications.new', name: "settings.applications.new",
props: (route) => ({ props: route => ({
scopes: route.query.scopes, scopes: route.query.scopes,
name: route.query.name, name: route.query.name,
redirect_uris: route.query.redirect_uris, redirect_uris: route.query.redirect_uris
}), }),
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/auth/ApplicationNew"), import(
/* webpackChunkName: "core" */ "@/components/auth/ApplicationNew"
)
}, },
{ {
path: '/settings/applications/:id/edit', path: "/settings/applications/:id/edit",
name: 'settings.applications.edit', name: "settings.applications.edit",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/auth/ApplicationEdit"), import(
/* webpackChunkName: "core" */ "@/components/auth/ApplicationEdit"
),
props: true props: true
}, },
{ {
path: '/@:username', path: "/@:username",
name: 'profile', name: "profile",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/auth/Profile"), import(/* webpackChunkName: "core" */ "@/components/auth/Profile"),
props: true props: true
}, },
{ {
path: '/favorites', path: "/favorites",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/favorites/List"), import(/* webpackChunkName: "core" */ "@/components/favorites/List"),
props: (route) => ({ props: route => ({
defaultOrdering: route.query.ordering, defaultOrdering: route.query.ordering,
defaultPage: route.query.page, defaultPage: route.query.page,
defaultPaginateBy: route.query.paginateBy defaultPaginateBy: route.query.paginateBy
}) })
}, },
{ {
path: '/content', path: "/content",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/content/Base"), import(/* webpackChunkName: "core" */ "@/views/content/Base"),
children: [ children: [
{ {
path: '', path: "",
name: 'content.index', name: "content.index",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/content/Home"), import(/* webpackChunkName: "core" */ "@/views/content/Home")
} }
] ]
}, },
{ {
path: '/content/libraries/tracks', path: "/content/libraries/tracks",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/content/Base"), import(/* webpackChunkName: "core" */ "@/views/content/Base"),
children: [ children: [
{ {
path: '', path: "",
name: 'content.libraries.files', name: "content.libraries.files",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/content/libraries/Files"), import(
props: (route) => ({ /* webpackChunkName: "core" */ "@/views/content/libraries/Files"
),
props: route => ({
query: route.query.q query: route.query.q
}) })
} }
] ]
}, },
{ {
path: '/content/libraries', path: "/content/libraries",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/content/Base"), import(/* webpackChunkName: "core" */ "@/views/content/Base"),
children: [ children: [
{ {
path: '', path: "",
name: 'content.libraries.index', name: "content.libraries.index",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/content/libraries/Home"), import(
/* webpackChunkName: "core" */ "@/views/content/libraries/Home"
)
}, },
{ {
path: ':id/upload', path: ":id/upload",
name: 'content.libraries.detail.upload', name: "content.libraries.detail.upload",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/content/libraries/Upload"), import(
props: (route) => ({ /* webpackChunkName: "core" */ "@/views/content/libraries/Upload"
),
props: route => ({
id: route.params.id, id: route.params.id,
defaultImportReference: route.query.import defaultImportReference: route.query.import
}) })
}, },
{ {
path: ':id', path: ":id",
name: 'content.libraries.detail', name: "content.libraries.detail",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/content/libraries/Detail"), import(
/* webpackChunkName: "core" */ "@/views/content/libraries/Detail"
),
props: true props: true
} }
] ]
}, },
{ {
path: '/content/remote', path: "/content/remote",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/content/Base"), import(/* webpackChunkName: "core" */ "@/views/content/Base"),
children: [ children: [
{ {
path: '', path: "",
name: 'content.remote.index', name: "content.remote.index",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/content/remote/Home"), import(/* webpackChunkName: "core" */ "@/views/content/remote/Home")
} }
] ]
}, },
{ {
path: '/manage/settings', path: "/manage/settings",
name: 'manage.settings', name: "manage.settings",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/Settings"), import(/* webpackChunkName: "admin" */ "@/views/admin/Settings")
}, },
{ {
path: '/manage/library', path: "/manage/library",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/library/Base"), import(/* webpackChunkName: "admin" */ "@/views/admin/library/Base"),
children: [ children: [
{ {
path: 'edits', path: "edits",
name: 'manage.library.edits', name: "manage.library.edits",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/library/EditsList"), import(
props: (route) => { /* webpackChunkName: "admin" */ "@/views/admin/library/EditsList"
),
props: route => {
return { return {
defaultQuery: route.query.q, defaultQuery: route.query.q
} }
} }
}, },
{ {
path: 'artists', path: "artists",
name: 'manage.library.artists', name: "manage.library.artists",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/library/ArtistsList"), import(
props: (route) => { /* webpackChunkName: "admin" */ "@/views/admin/library/ArtistsList"
),
props: route => {
return { return {
defaultQuery: route.query.q, defaultQuery: route.query.q
} }
} }
}, },
{ {
path: 'artists/:id', path: "artists/:id",
name: 'manage.library.artists.detail', name: "manage.library.artists.detail",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/library/ArtistDetail"), import(
/* webpackChunkName: "admin" */ "@/views/admin/library/ArtistDetail"
),
props: true props: true
}, },
{ {
path: 'albums', path: "albums",
name: 'manage.library.albums', name: "manage.library.albums",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/library/AlbumsList"), import(
props: (route) => { /* webpackChunkName: "admin" */ "@/views/admin/library/AlbumsList"
),
props: route => {
return { return {
defaultQuery: route.query.q, defaultQuery: route.query.q
} }
} }
}, },
{ {
path: 'albums/:id', path: "albums/:id",
name: 'manage.library.albums.detail', name: "manage.library.albums.detail",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/library/AlbumDetail"), import(
/* webpackChunkName: "admin" */ "@/views/admin/library/AlbumDetail"
),
props: true props: true
}, },
{ {
path: 'tracks', path: "tracks",
name: 'manage.library.tracks', name: "manage.library.tracks",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/library/TracksList"), import(
props: (route) => { /* webpackChunkName: "admin" */ "@/views/admin/library/TracksList"
),
props: route => {
return { return {
defaultQuery: route.query.q, defaultQuery: route.query.q
} }
} }
}, },
{ {
path: 'tracks/:id', path: "tracks/:id",
name: 'manage.library.tracks.detail', name: "manage.library.tracks.detail",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/library/TrackDetail"), import(
/* webpackChunkName: "admin" */ "@/views/admin/library/TrackDetail"
),
props: true props: true
}, },
{ {
path: 'libraries', path: "libraries",
name: 'manage.library.libraries', name: "manage.library.libraries",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/library/LibrariesList"), import(
props: (route) => { /* webpackChunkName: "admin" */ "@/views/admin/library/LibrariesList"
),
props: route => {
return { return {
defaultQuery: route.query.q, defaultQuery: route.query.q
} }
} }
}, },
{ {
path: 'libraries/:id', path: "libraries/:id",
name: 'manage.library.libraries.detail', name: "manage.library.libraries.detail",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/library/LibraryDetail"), import(
/* webpackChunkName: "admin" */ "@/views/admin/library/LibraryDetail"
),
props: true props: true
}, },
{ {
path: 'uploads', path: "uploads",
name: 'manage.library.uploads', name: "manage.library.uploads",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/library/UploadsList"), import(
props: (route) => { /* webpackChunkName: "admin" */ "@/views/admin/library/UploadsList"
),
props: route => {
return { return {
defaultQuery: route.query.q, defaultQuery: route.query.q
} }
} }
}, },
{ {
path: 'uploads/:id', path: "uploads/:id",
name: 'manage.library.uploads.detail', name: "manage.library.uploads.detail",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/library/UploadDetail"), import(
/* webpackChunkName: "admin" */ "@/views/admin/library/UploadDetail"
),
props: true 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: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/users/Base"), import(/* webpackChunkName: "admin" */ "@/views/admin/users/Base"),
children: [ children: [
{ {
path: 'users', path: "users",
name: 'manage.users.users.list', name: "manage.users.users.list",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/users/UsersList"), import(
/* webpackChunkName: "admin" */ "@/views/admin/users/UsersList"
)
}, },
{ {
path: 'invitations', path: "invitations",
name: 'manage.users.invitations.list', name: "manage.users.invitations.list",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/users/InvitationsList"), import(
/* webpackChunkName: "admin" */ "@/views/admin/users/InvitationsList"
)
} }
] ]
}, },
{ {
path: '/manage/moderation', path: "/manage/moderation",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/moderation/Base"), import(/* webpackChunkName: "admin" */ "@/views/admin/moderation/Base"),
children: [ children: [
{ {
path: 'domains', path: "domains",
name: 'manage.moderation.domains.list', name: "manage.moderation.domains.list",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/moderation/DomainsList"), import(
/* webpackChunkName: "admin" */ "@/views/admin/moderation/DomainsList"
)
}, },
{ {
path: 'domains/:id', path: "domains/:id",
name: 'manage.moderation.domains.detail', name: "manage.moderation.domains.detail",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/moderation/DomainsDetail"), import(
/* webpackChunkName: "admin" */ "@/views/admin/moderation/DomainsDetail"
),
props: true props: true
}, },
{ {
path: 'accounts', path: "accounts",
name: 'manage.moderation.accounts.list', name: "manage.moderation.accounts.list",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsList"), import(
props: (route) => { /* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsList"
),
props: route => {
return { return {
defaultQuery: route.query.q, defaultQuery: route.query.q
} }
} }
}, },
{ {
path: 'accounts/:id', path: "accounts/:id",
name: 'manage.moderation.accounts.detail', name: "manage.moderation.accounts.detail",
component: () => component: () =>
import(/* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsDetail"), import(
/* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsDetail"
),
props: true props: true
} }
] ]
}, },
{ {
path: '/library', path: "/library",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/Library"), import(/* webpackChunkName: "core" */ "@/components/library/Library"),
children: [ children: [
{ {
path: '', path: "",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/Home"), import(/* webpackChunkName: "core" */ "@/components/library/Home"),
name: 'library.index' name: "library.index"
}, },
{ {
path: 'artists/', path: "artists/",
name: 'library.artists.browse', name: "library.artists.browse",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/Artists"), import(
props: (route) => ({ /* webpackChunkName: "core" */ "@/components/library/Artists"
),
props: route => ({
defaultOrdering: route.query.ordering, defaultOrdering: route.query.ordering,
defaultQuery: route.query.query, 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, defaultPaginateBy: route.query.paginateBy,
defaultPage: route.query.page defaultPage: route.query.page
}) })
}, },
{ {
path: 'albums/', path: "albums/",
name: 'library.albums.browse', name: "library.albums.browse",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/Albums"), import(
props: (route) => ({ /* webpackChunkName: "core" */ "@/components/library/Albums"
),
props: route => ({
defaultOrdering: route.query.ordering, defaultOrdering: route.query.ordering,
defaultQuery: route.query.query, 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, defaultPaginateBy: route.query.paginateBy,
defaultPage: route.query.page defaultPage: route.query.page
}) })
}, },
{ {
path: 'radios/', path: "radios/",
name: 'library.radios.browse', name: "library.radios.browse",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/Radios"), import(
props: (route) => ({ /* webpackChunkName: "core" */ "@/components/library/Radios"
),
props: route => ({
defaultOrdering: route.query.ordering, defaultOrdering: route.query.ordering,
defaultQuery: route.query.query, defaultQuery: route.query.query,
defaultPaginateBy: route.query.paginateBy, defaultPaginateBy: route.query.paginateBy,
@ -432,32 +510,36 @@ export default new Router({
}) })
}, },
{ {
path: 'radios/build', path: "radios/build",
name: 'library.radios.build', name: "library.radios.build",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/radios/Builder"), import(
/* webpackChunkName: "core" */ "@/components/library/radios/Builder"
),
props: true props: true
}, },
{ {
path: 'radios/build/:id', path: "radios/build/:id",
name: 'library.radios.edit', name: "library.radios.edit",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/radios/Builder"), import(
/* webpackChunkName: "core" */ "@/components/library/radios/Builder"
),
props: true props: true
}, },
{ {
path: 'radios/:id', path: "radios/:id",
name: 'library.radios.detail', name: "library.radios.detail",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/radios/Detail"), import(/* webpackChunkName: "core" */ "@/views/radios/Detail"),
props: true props: true
}, },
{ {
path: 'playlists/', path: "playlists/",
name: 'library.playlists.browse', name: "library.playlists.browse",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/playlists/List"), import(/* webpackChunkName: "core" */ "@/views/playlists/List"),
props: (route) => ({ props: route => ({
defaultOrdering: route.query.ordering, defaultOrdering: route.query.ordering,
defaultQuery: route.query.query, defaultQuery: route.query.query,
defaultPaginateBy: route.query.paginateBy, defaultPaginateBy: route.query.paginateBy,
@ -465,112 +547,139 @@ export default new Router({
}) })
}, },
{ {
path: 'playlists/:id', path: "playlists/:id",
name: 'library.playlists.detail', name: "library.playlists.detail",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/views/playlists/Detail"), import(/* webpackChunkName: "core" */ "@/views/playlists/Detail"),
props: (route) => ({ props: route => ({
id: route.params.id, id: route.params.id,
defaultEdit: route.query.mode === 'edit' }) defaultEdit: route.query.mode === "edit"
})
}, },
{ {
path: 'tags/:id', path: "tags/:id",
name: 'library.tags.detail', name: "library.tags.detail",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/TagDetail"), import(
props: true, /* webpackChunkName: "core" */ "@/components/library/TagDetail"
),
props: true
}, },
{ {
path: 'artists/:id', path: "artists/:id",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/ArtistBase"), import(
/* webpackChunkName: "core" */ "@/components/library/ArtistBase"
),
props: true, props: true,
children: [ children: [
{ {
path: '', path: "",
name: 'library.artists.detail', name: "library.artists.detail",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/ArtistDetail"), import(
/* webpackChunkName: "core" */ "@/components/library/ArtistDetail"
)
}, },
{ {
path: 'edit', path: "edit",
name: 'library.artists.edit', name: "library.artists.edit",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/ArtistEdit"), import(
/* webpackChunkName: "core" */ "@/components/library/ArtistEdit"
)
}, },
{ {
path: 'edit/:editId', path: "edit/:editId",
name: 'library.artists.edit.detail', name: "library.artists.edit.detail",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/EditDetail"), import(
props: true, /* webpackChunkName: "core" */ "@/components/library/EditDetail"
),
props: true
} }
] ]
}, },
{ {
path: 'albums/:id', path: "albums/:id",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/AlbumBase"), import(
/* webpackChunkName: "core" */ "@/components/library/AlbumBase"
),
props: true, props: true,
children: [ children: [
{ {
path: '', path: "",
name: 'library.albums.detail', name: "library.albums.detail",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/AlbumDetail"), import(
/* webpackChunkName: "core" */ "@/components/library/AlbumDetail"
)
}, },
{ {
path: 'edit', path: "edit",
name: 'library.albums.edit', name: "library.albums.edit",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/AlbumEdit"), import(
/* webpackChunkName: "core" */ "@/components/library/AlbumEdit"
)
}, },
{ {
path: 'edit/:editId', path: "edit/:editId",
name: 'library.albums.edit.detail', name: "library.albums.edit.detail",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/EditDetail"), import(
props: true, /* webpackChunkName: "core" */ "@/components/library/EditDetail"
),
props: true
} }
] ]
}, },
{ {
path: 'tracks/:id', path: "tracks/:id",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/TrackBase"), import(
/* webpackChunkName: "core" */ "@/components/library/TrackBase"
),
props: true, props: true,
children: [ children: [
{ {
path: '', path: "",
name: 'library.tracks.detail', name: "library.tracks.detail",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/TrackDetail"), import(
/* webpackChunkName: "core" */ "@/components/library/TrackDetail"
)
}, },
{ {
path: 'edit', path: "edit",
name: 'library.tracks.edit', name: "library.tracks.edit",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/TrackEdit"), import(
/* webpackChunkName: "core" */ "@/components/library/TrackEdit"
)
}, },
{ {
path: 'edit/:editId', path: "edit/:editId",
name: 'library.tracks.edit.detail', name: "library.tracks.edit.detail",
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/library/EditDetail"), import(
props: true, /* webpackChunkName: "core" */ "@/components/library/EditDetail"
),
props: true
}
]
} }
] ]
}, },
] {
path: "*/index.html",
redirect: "/"
}, },
{ {
path: '*/index.html', path: "*",
redirect: '/'
},
{
path: '*',
component: () => component: () =>
import(/* webpackChunkName: "core" */ "@/components/PageNotFound"), import(/* webpackChunkName: "core" */ "@/components/PageNotFound")
} }
] ]
}) })

View File

@ -24,6 +24,12 @@
</div> </div>
</div> </div>
</h2> </h2>
<template v-if="object.tags && object.tags.length > 0">
<tags-list :limit="5" detail-route="manage.library.tags.detail" :tags="object.tags"></tags-list>
<div class="ui hidden divider"></div>
</template>
<div class="header-buttons"> <div class="header-buttons">
<div class="ui icon buttons"> <div class="ui icon buttons">
@ -273,12 +279,13 @@
import axios from "axios" import axios from "axios"
import logger from "@/logging" import logger from "@/logging"
import FetchButton from "@/components/federation/FetchButton" import FetchButton from "@/components/federation/FetchButton"
import TagsList from "@/components/tags/List"
export default { export default {
props: ["id"], props: ["id"],
components: { components: {
FetchButton FetchButton,
TagsList
}, },
data() { data() {
return { return {

View File

@ -23,6 +23,11 @@
</div> </div>
</div> </div>
</h2> </h2>
<template v-if="object.tags && object.tags.length > 0">
<tags-list :limit="5" detail-route="manage.library.tags.detail" :tags="object.tags"></tags-list>
<div class="ui hidden divider"></div>
</template>
<div class="header-buttons"> <div class="header-buttons">
<div class="ui icon buttons"> <div class="ui icon buttons">
@ -272,12 +277,14 @@
import axios from "axios" import axios from "axios"
import logger from "@/logging" import logger from "@/logging"
import TagsList from "@/components/tags/List"
import FetchButton from "@/components/federation/FetchButton" import FetchButton from "@/components/federation/FetchButton"
export default { export default {
props: ["id"], props: ["id"],
components: { components: {
FetchButton FetchButton,
TagsList
}, },
data() { data() {
return { return {

View File

@ -19,6 +19,9 @@
<router-link <router-link
class="ui item" class="ui item"
:to="{name: 'manage.library.uploads'}"><translate translate-context="*/*/*">Uploads</translate></router-link> :to="{name: 'manage.library.uploads'}"><translate translate-context="*/*/*">Uploads</translate></router-link>
<router-link
class="ui item"
:to="{name: 'manage.library.tags'}"><translate translate-context="*/*/*">Tags</translate></router-link>
</nav> </nav>
<router-view :key="$route.fullPath"></router-view> <router-view :key="$route.fullPath"></router-view>
</div> </div>

View File

@ -0,0 +1,215 @@
<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.name">
<div class="ui stackable one column grid">
<div class="ui column">
<div class="segment-content">
<h2 class="ui header">
<i class="circular inverted hashtag icon"></i>
<div class="content">
{{ object.name | truncate(100) }}
</div>
</h2>
<div class="header-buttons">
<div class="ui icon buttons">
<router-link class="ui labeled icon button" :to="{name: 'library.tags.detail', params: {id: object.name }}">
<i class="info icon"></i>
<translate translate-context="Content/Moderation/Link/Verb">Open local profile</translate>&nbsp;
</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/tags/tag/${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>&nbsp;
</a>
</div>
</div>
</div>
<div class="ui buttons">
<dangerous-button
:class="['ui', {loading: isLoading}, 'basic button']"
:action="remove">
<translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this tag?</translate></p>
<div slot="modal-content">
<p><translate translate-context="Content/Moderation/Paragraph">The tag will be removed and unlinked from any existing entity. 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">Tag data</translate>
</div>
</h3>
<table class="ui very basic table">
<tbody>
<tr>
<td>
<translate translate-context="*/*/*/Noun">Name</translate>
</td>
<td>
{{ object.name }}
</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>&nbsp;
<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>
</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>&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span>
</div>
</h3>
<table class="ui very basic table">
<tbody>
<tr>
<td>
<router-link :to="{name: 'manage.library.artists', query: {q: getQuery('tag', object.name) }}">
<translate translate-context="*/*/*">Artists</translate>
</router-link>
</td>
<td>
{{ object.artists_count }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.albums', query: {q: getQuery('tag', object.name) }}">
<translate translate-context="*/*/*">Albums</translate>
</router-link>
</td>
<td>
{{ object.albums_count }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('tag', object.name) }}">
<translate translate-context="*/*/*">Tracks</translate>
</router-link>
</td>
<td>
{{ object.tracks_count }}
</td>
</tr>
</tbody>
</table>
</section>
</div>
</div>
</div>
</template>
</main>
</template>
<script>
import axios from "axios"
import logger from "@/logging"
import FetchButton from "@/components/federation/FetchButton"
export default {
props: ["id"],
components: {
FetchButton
},
data() {
return {
isLoading: true,
isLoadingStats: false,
object: null,
stats: null,
}
},
created() {
this.fetchData()
},
methods: {
fetchData() {
var self = this
this.isLoading = true
let url = `manage/tags/${this.id}/`
axios.get(url).then(response => {
self.object = response.data
self.isLoading = false
})
},
remove () {
var self = this
this.isLoading = true
let url = `manage/tags/${this.id}/`
axios.delete(url).then(response => {
self.$router.push({name: 'manage.library.tags'})
})
},
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>

View File

@ -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>
<tags-table :update-url="true" :default-query="defaultQuery"></tags-table>
</section>
</main>
</template>
<script>
import TagsTable from "@/components/manage/library/TagsTable"
export default {
components: {
TagsTable
},
props: {
defaultQuery: {type: String, required: false},
},
computed: {
labels() {
return {
title: this.$pgettext('*/*/*', 'Tags')
}
}
}
}
</script>

View File

@ -23,6 +23,12 @@
</div> </div>
</div> </div>
</h2> </h2>
<template v-if="object.tags && object.tags.length > 0">
<tags-list :limit="5" detail-route="manage.library.tags.detail" :tags="object.tags"></tags-list>
<div class="ui hidden divider"></div>
</template>
<div class="header-buttons"> <div class="header-buttons">
<div class="ui icon buttons"> <div class="ui icon buttons">
@ -315,12 +321,14 @@
import axios from "axios" import axios from "axios"
import logger from "@/logging" import logger from "@/logging"
import FetchButton from "@/components/federation/FetchButton" import FetchButton from "@/components/federation/FetchButton"
import TagsList from "@/components/tags/List"
export default { export default {
props: ["id"], props: ["id"],
components: { components: {
FetchButton FetchButton,
TagsList
}, },
data() { data() {
return { return {