From 95497e76accea196cb31cf94cabaf4a74abca51f Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 5 Feb 2020 15:06:07 +0100 Subject: [PATCH] See #170: channels ui (listeners) --- api/config/api_urls.py | 3 + api/funkwhale_api/audio/serializers.py | 11 +- api/funkwhale_api/audio/views.py | 30 ++- api/funkwhale_api/common/filters.py | 13 ++ api/funkwhale_api/common/models.py | 2 +- api/funkwhale_api/common/utils.py | 1 + api/funkwhale_api/common/views.py | 12 ++ .../federation/api_serializers.py | 27 +++ api/funkwhale_api/federation/api_urls.py | 1 + api/funkwhale_api/federation/api_views.py | 32 +++ api/funkwhale_api/federation/serializers.py | 1 - api/funkwhale_api/music/models.py | 6 + api/funkwhale_api/music/serializers.py | 13 ++ api/funkwhale_api/subsonic/renderers.py | 25 ++- api/funkwhale_api/users/models.py | 20 +- api/funkwhale_api/users/serializers.py | 19 +- api/tests/audio/test_serializers.py | 7 +- api/tests/audio/test_views.py | 25 ++- api/tests/common/test_filters.py | 11 +- api/tests/common/test_views.py | 11 + api/tests/favorites/test_favorites.py | 3 + api/tests/federation/test_api_serializers.py | 30 +++ api/tests/federation/test_api_views.py | 12 ++ api/tests/music/test_serializers.py | 2 + api/tests/music/test_views.py | 7 + api/tests/playlists/test_views.py | 2 + api/tests/radios/test_api.py | 3 + front/package.json | 1 - front/src/App.vue | 5 +- front/src/EmbedFrame.vue | 2 +- front/src/components/Queue.vue | 8 +- front/src/components/Sidebar.vue | 6 +- front/src/components/audio/ChannelCard.vue | 55 +++++ front/src/components/audio/ChannelEntries.vue | 74 +++++++ .../src/components/audio/ChannelEntryCard.vue | 63 ++++++ .../src/components/audio/ChannelSerieCard.vue | 69 ++++++ front/src/components/audio/ChannelSeries.vue | 73 +++++++ front/src/components/audio/ChannelsWidget.vue | 76 +++++++ front/src/components/audio/Player.vue | 37 ++-- front/src/components/audio/SearchBar.vue | 6 +- front/src/components/audio/album/Widget.vue | 11 +- front/src/components/audio/artist/Card.vue | 2 - front/src/components/audio/artist/Widget.vue | 11 +- front/src/components/audio/track/Row.vue | 4 +- front/src/components/audio/track/Table.vue | 2 - front/src/components/audio/track/Widget.vue | 17 +- front/src/components/auth/Profile.vue | 70 ------ front/src/components/auth/Settings.vue | 12 +- front/src/components/auth/SignupForm.vue | 2 +- .../components/channels/SubscribeButton.vue | 43 ++++ front/src/components/common/ActorLink.vue | 26 ++- front/src/components/common/ContentForm.vue | 118 ++++++++++ front/src/components/common/HumanDuration.vue | 20 ++ .../components/common/RenderedDescription.vue | 77 +++++++ .../components/federation/LibraryWidget.vue | 12 +- front/src/components/globals.js | 3 + front/src/components/library/ArtistBase.vue | 17 +- front/src/components/library/ArtistDetail.vue | 1 - front/src/components/library/TrackBase.vue | 27 ++- .../components/manage/library/TracksTable.vue | 10 +- front/src/components/mixins/Report.vue | 2 +- front/src/components/mixins/Translations.vue | 3 + front/src/components/playlists/Editor.vue | 2 +- front/src/components/playlists/Widget.vue | 13 +- front/src/filters.js | 11 +- front/src/router/index.js | 62 +++++- front/src/store/auth.js | 1 + front/src/store/channels.js | 65 ++++++ front/src/store/index.js | 13 +- front/src/store/instance.js | 6 + front/src/style/_main.scss | 77 +++++++ front/src/views/admin/library/TrackDetail.vue | 6 +- front/src/views/auth/ProfileActivity.vue | 34 +++ front/src/views/auth/ProfileBase.vue | 130 +++++++++++ front/src/views/auth/ProfileOverview.vue | 35 +++ front/src/views/channels/DetailBase.vue | 203 ++++++++++++++++++ front/src/views/channels/DetailEpisodes.vue | 18 ++ front/src/views/channels/DetailOverview.vue | 29 +++ .../views/content/libraries/FilesTable.vue | 1 + 79 files changed, 1768 insertions(+), 232 deletions(-) create mode 100644 front/src/components/audio/ChannelCard.vue create mode 100644 front/src/components/audio/ChannelEntries.vue create mode 100644 front/src/components/audio/ChannelEntryCard.vue create mode 100644 front/src/components/audio/ChannelSerieCard.vue create mode 100644 front/src/components/audio/ChannelSeries.vue create mode 100644 front/src/components/audio/ChannelsWidget.vue delete mode 100644 front/src/components/auth/Profile.vue create mode 100644 front/src/components/channels/SubscribeButton.vue create mode 100644 front/src/components/common/ContentForm.vue create mode 100644 front/src/components/common/HumanDuration.vue create mode 100644 front/src/components/common/RenderedDescription.vue create mode 100644 front/src/store/channels.js create mode 100644 front/src/views/auth/ProfileActivity.vue create mode 100644 front/src/views/auth/ProfileBase.vue create mode 100644 front/src/views/auth/ProfileOverview.vue create mode 100644 front/src/views/channels/DetailBase.vue create mode 100644 front/src/views/channels/DetailEpisodes.vue create mode 100644 front/src/views/channels/DetailOverview.vue diff --git a/api/config/api_urls.py b/api/config/api_urls.py index 7339df445..b50066f3d 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -88,6 +88,9 @@ v1_patterns += [ url(r"^token/?$", jwt_views.obtain_jwt_token, name="token"), url(r"^token/refresh/?$", jwt_views.refresh_jwt_token, name="token_refresh"), url(r"^rate-limit/?$", common_views.RateLimitView.as_view(), name="rate-limit"), + url( + r"^text-preview/?$", common_views.TextPreviewView.as_view(), name="text-preview" + ), ] urlpatterns = [ diff --git a/api/funkwhale_api/audio/serializers.py b/api/funkwhale_api/audio/serializers.py index 0c9732efe..2d46b8cce 100644 --- a/api/funkwhale_api/audio/serializers.py +++ b/api/funkwhale_api/audio/serializers.py @@ -70,6 +70,8 @@ class ChannelCreateSerializer(serializers.Serializer): @transaction.atomic def create(self, validated_data): + from . import views + description = validated_data.get("description") artist = music_models.Artist.objects.create( attributed_to=validated_data["attributed_to"], @@ -99,10 +101,11 @@ class ChannelCreateSerializer(serializers.Serializer): actor=validated_data["attributed_to"], ) channel.save() + channel = views.ChannelViewSet.queryset.get(pk=channel.pk) return channel def to_representation(self, obj): - return ChannelSerializer(obj).data + return ChannelSerializer(obj, context=self.context).data NOOP = object() @@ -181,7 +184,7 @@ class ChannelUpdateSerializer(serializers.Serializer): return obj def to_representation(self, obj): - return ChannelSerializer(obj).data + return ChannelSerializer(obj, context=self.context).data class ChannelSerializer(serializers.ModelSerializer): @@ -261,7 +264,8 @@ def rss_serialize_item(upload): "link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}], "enclosure": [ { - "url": upload.listen_url, + # we enforce MP3, since it's the only format supported everywhere + "url": federation_utils.full_url(upload.get_listen_url(to="mp3")), "length": upload.size or 0, "type": upload.mimetype or "audio/mpeg", } @@ -271,7 +275,6 @@ def rss_serialize_item(upload): data["itunes:subtitle"] = [{"value": upload.track.description.truncate(255)}] data["itunes:summary"] = [{"cdata_value": upload.track.description.rendered}] data["description"] = [{"value": upload.track.description.as_plain_text}] - data["content:encoded"] = data["itunes:summary"] if upload.track.attachment_cover: data["itunes:image"] = [ diff --git a/api/funkwhale_api/audio/views.py b/api/funkwhale_api/audio/views.py index 1f40dd0a6..09ae6d0cf 100644 --- a/api/funkwhale_api/audio/views.py +++ b/api/funkwhale_api/audio/views.py @@ -6,7 +6,7 @@ from rest_framework import response from rest_framework import viewsets from django import http -from django.db.models import Prefetch +from django.db.models import Count, Prefetch from django.db.utils import IntegrityError from funkwhale_api.common import permissions @@ -18,6 +18,12 @@ from funkwhale_api.users.oauth import permissions as oauth_permissions from . import filters, models, renderers, serializers +ARTIST_PREFETCH_QS = ( + music_models.Artist.objects.select_related("description", "attachment_cover",) + .prefetch_related(music_views.TAG_PREFETCH) + .annotate(_tracks_count=Count("tracks")) +) + class ChannelsMixin(object): def dispatch(self, request, *args, **kwargs): @@ -44,12 +50,7 @@ class ChannelViewSet( "library", "attributed_to", "actor", - Prefetch( - "artist", - queryset=music_models.Artist.objects.select_related( - "attachment_cover", "description" - ).prefetch_related(music_views.TAG_PREFETCH,), - ), + Prefetch("artist", queryset=ARTIST_PREFETCH_QS), ) .order_by("-creation_date") ) @@ -131,7 +132,12 @@ class ChannelViewSet( def get_serializer_context(self): context = super().get_serializer_context() - context["subscriptions_count"] = self.action in ["retrieve", "create", "update"] + context["subscriptions_count"] = self.action in [ + "retrieve", + "create", + "update", + "partial_update", + ] return context @@ -148,8 +154,8 @@ class SubscriptionsViewSet( .prefetch_related( "target__channel__library", "target__channel__attributed_to", - "target__channel__artist__description", "actor", + Prefetch("target__channel__artist", queryset=ARTIST_PREFETCH_QS), ) .order_by("-creation_date") ) @@ -171,10 +177,12 @@ class SubscriptionsViewSet( to have a performant endpoint and avoid lots of queries just to display subscription status in the UI """ - subscriptions = list(self.get_queryset().values_list("uuid", flat=True)) + subscriptions = list( + self.get_queryset().values_list("uuid", "target__channel__uuid") + ) payload = { - "results": [str(u) for u in subscriptions], + "results": [{"uuid": str(u[0]), "channel": u[1]} for u in subscriptions], "count": len(subscriptions), } return response.Response(payload, status=200) diff --git a/api/funkwhale_api/common/filters.py b/api/funkwhale_api/common/filters.py index 953904bfa..df27a312a 100644 --- a/api/funkwhale_api/common/filters.py +++ b/api/funkwhale_api/common/filters.py @@ -176,6 +176,8 @@ class ActorScopeFilter(filters.CharFilter): super().__init__(*args, **kwargs) def filter(self, queryset, value): + from funkwhale_api.federation import models as federation_models + if not value: return queryset @@ -189,6 +191,17 @@ class ActorScopeFilter(filters.CharFilter): qs = self.filter_me(user=user, queryset=queryset) elif value.lower() == "all": return queryset + elif value.lower().startswith("actor:"): + full_username = value.split("actor:", 1)[1] + username, domain = full_username.split("@") + try: + actor = federation_models.Actor.objects.get( + preferred_username=username, domain_id=domain, + ) + except federation_models.Actor.DoesNotExist: + return queryset.none() + + return queryset.filter(**{self.actor_field: actor}) else: return queryset.none() diff --git a/api/funkwhale_api/common/models.py b/api/funkwhale_api/common/models.py index 902fb0f5c..ec2c1d1f8 100644 --- a/api/funkwhale_api/common/models.py +++ b/api/funkwhale_api/common/models.py @@ -201,7 +201,7 @@ class AttachmentQuerySet(models.QuerySet): class Attachment(models.Model): # Remote URL where the attachment can be fetched - url = models.URLField(max_length=500, null=True) + url = models.URLField(max_length=500, null=True, blank=True) uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) # Actor associated with the attachment actor = models.ForeignKey( diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py index 51efce019..5b9b5bf2d 100644 --- a/api/funkwhale_api/common/utils.py +++ b/api/funkwhale_api/common/utils.py @@ -303,6 +303,7 @@ def attach_content(obj, field, content_data): if existing: getattr(obj, field).delete() + setattr(obj, field, None) if not content_data: return diff --git a/api/funkwhale_api/common/views.py b/api/funkwhale_api/common/views.py index 58758773d..1611d8e63 100644 --- a/api/funkwhale_api/common/views.py +++ b/api/funkwhale_api/common/views.py @@ -181,3 +181,15 @@ class AttachmentViewSet( if instance.actor is None or instance.actor != self.request.user.actor: raise exceptions.PermissionDenied() instance.delete() + + +class TextPreviewView(views.APIView): + permission_classes = [] + + def post(self, request, *args, **kwargs): + payload = request.data + if "text" not in payload: + return response.Response({"detail": "Invalid input"}, status=400) + + data = {"rendered": utils.render_html(payload["text"], "text/markdown")} + return response.Response(data, status=200) diff --git a/api/funkwhale_api/federation/api_serializers.py b/api/funkwhale_api/federation/api_serializers.py index 2a1143ec7..bd8bfcf01 100644 --- a/api/funkwhale_api/federation/api_serializers.py +++ b/api/funkwhale_api/federation/api_serializers.py @@ -1,7 +1,10 @@ +from django.core.exceptions import ObjectDoesNotExist + from rest_framework import serializers from funkwhale_api.common import serializers as common_serializers from funkwhale_api.music import models as music_models +from funkwhale_api.users import serializers as users_serializers from . import filters from . import models @@ -169,3 +172,27 @@ class FetchSerializer(serializers.ModelSerializer): "creation_date", "fetch_date", ] + + +class FullActorSerializer(serializers.Serializer): + fid = serializers.URLField() + url = serializers.URLField() + domain = serializers.CharField(source="domain_id") + creation_date = serializers.DateTimeField() + last_fetch_date = serializers.DateTimeField() + name = serializers.CharField() + preferred_username = serializers.CharField() + full_username = serializers.CharField() + type = serializers.CharField() + is_local = serializers.BooleanField() + is_channel = serializers.SerializerMethodField() + manually_approves_followers = serializers.BooleanField() + user = users_serializers.UserBasicSerializer() + summary = common_serializers.ContentSerializer(source="summary_obj") + icon = common_serializers.AttachmentSerializer(source="attachment_icon") + + def get_is_channel(self, o): + try: + return bool(o.channel) + except ObjectDoesNotExist: + return False diff --git a/api/funkwhale_api/federation/api_urls.py b/api/funkwhale_api/federation/api_urls.py index 4f0471b17..df5bfb2f0 100644 --- a/api/funkwhale_api/federation/api_urls.py +++ b/api/funkwhale_api/federation/api_urls.py @@ -8,5 +8,6 @@ router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-fol router.register(r"inbox", api_views.InboxItemViewSet, "inbox") router.register(r"libraries", api_views.LibraryViewSet, "libraries") router.register(r"domains", api_views.DomainViewSet, "domains") +router.register(r"actors", api_views.ActorViewSet, "actors") urlpatterns = router.urls diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py index 395290be9..7a39218f9 100644 --- a/api/funkwhale_api/federation/api_views.py +++ b/api/funkwhale_api/federation/api_views.py @@ -12,6 +12,7 @@ from rest_framework import viewsets from funkwhale_api.common import preferences from funkwhale_api.common.permissions import ConditionalAuthentication from funkwhale_api.music import models as music_models +from funkwhale_api.music import views as music_views from funkwhale_api.users.oauth import permissions as oauth_permissions from . import activity @@ -218,3 +219,34 @@ class DomainViewSet( if preferences.get("moderation__allow_list_enabled"): qs = qs.filter(allowed=True) return qs + + +class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): + queryset = models.Actor.objects.select_related( + "user", "channel", "summary_obj", "attachment_icon" + ) + permission_classes = [ConditionalAuthentication] + serializer_class = api_serializers.FullActorSerializer + lookup_field = "full_username" + lookup_value_regex = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)" + + def get_object(self): + queryset = self.get_queryset() + username, domain = self.kwargs["full_username"].split("@", 1) + return queryset.get(preferred_username=username, domain_id=domain) + + def get_queryset(self): + qs = super().get_queryset() + qs = qs.exclude( + domain__instance_policy__is_active=True, + domain__instance_policy__block_all=True, + ) + if preferences.get("moderation__allow_list_enabled"): + qs = qs.filter(domain__allowed=True) + return qs + + libraries = decorators.action(methods=["get"], detail=True)( + music_views.get_libraries( + filter_uploads=lambda o, uploads: uploads.filter(library__actor=o) + ) + ) diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index c057799a6..6501e93cf 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -253,7 +253,6 @@ class APIActorSerializer(serializers.ModelSerializer): class Meta: model = models.Actor fields = [ - "id", "fid", "url", "creation_date", diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index c44175e35..a5bc37275 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -876,6 +876,12 @@ class Upload(models.Model): def listen_url(self): return self.track.listen_url + "?upload={}".format(self.uuid) + def get_listen_url(self, to=None): + url = self.listen_url + if to: + url += "&to={}".format(to) + return url + @property def listen_url_no_download(self): # Not using reverse because this is slow diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 7b367857e..4113c3d7a 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -156,6 +156,19 @@ def serialize_artist_simple(artist): else None ) + if "attachment_cover" in artist._state.fields_cache: + data["cover"] = ( + cover_field.to_representation(artist.attachment_cover) + if artist.attachment_cover + else None + ) + + if getattr(artist, "_tracks_count", None) is not None: + data["tracks_count"] = artist._tracks_count + + if getattr(artist, "_prefetched_tagged_items", None) is not None: + data["tags"] = [ti.tag.name for ti in artist._prefetched_tagged_items] + return data diff --git a/api/funkwhale_api/subsonic/renderers.py b/api/funkwhale_api/subsonic/renderers.py index 92c4508d4..7390def8b 100644 --- a/api/funkwhale_api/subsonic/renderers.py +++ b/api/funkwhale_api/subsonic/renderers.py @@ -5,6 +5,29 @@ from rest_framework import renderers import funkwhale_api +# from https://stackoverflow.com/a/8915039 +# because I want to avoid a lxml dependency just for outputting cdata properly +# in a RSS feed +def CDATA(text=None): + element = ET.Element("![CDATA[") + element.text = text + return element + + +ET._original_serialize_xml = ET._serialize_xml + + +def _serialize_xml(write, elem, qnames, namespaces, **kwargs): + if elem.tag == "![CDATA[": + write("<%s%s]]>" % (elem.tag, elem.text)) + return + return ET._original_serialize_xml(write, elem, qnames, namespaces, **kwargs) + + +ET._serialize_xml = ET._serialize["xml"] = _serialize_xml +# end of tweaks + + def structure_payload(data): payload = { "funkwhaleVersion": funkwhale_api.__version__, @@ -56,7 +79,7 @@ def dict_to_xml_tree(root_tag, d, parent=None): if key == "value": root.text = str(value) elif key == "cdata_value": - root.text = "".format(str(value)) + root.append(CDATA(value)) else: root.set(key, str(value)) return root diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 72ab2afd6..16212e975 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -229,8 +229,8 @@ class User(AbstractUser): self.last_activity = now self.save(update_fields=["last_activity"]) - def create_actor(self): - self.actor = create_actor(self) + def create_actor(self, **kwargs): + self.actor = create_actor(self, **kwargs) self.save(update_fields=["actor"]) return self.actor @@ -264,15 +264,10 @@ class User(AbstractUser): def full_username(self): return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME) - @property - def avatar_path(self): - if not self.avatar: - return None - try: - return self.avatar.path - except NotImplementedError: - # external storage - return self.avatar.name + def get_avatar(self): + if not self.actor: + return + return self.actor.attachment_icon def generate_code(length=10): @@ -399,8 +394,9 @@ def get_actor_data(username, **kwargs): } -def create_actor(user): +def create_actor(user, **kwargs): args = get_actor_data(user.username) + args.update(kwargs) private, public = keys.get_key_pair() args["private_key"] = private.decode("utf-8") args["public_key"] = public.decode("utf-8") diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index 59986fdaa..1e919f7b7 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -90,17 +90,12 @@ class UserActivitySerializer(activity_serializers.ModelSerializer): class UserBasicSerializer(serializers.ModelSerializer): - avatar = serializers.SerializerMethodField() + avatar = common_serializers.AttachmentSerializer(source="get_avatar") class Meta: model = models.User fields = ["id", "username", "name", "date_joined", "avatar"] - def get_avatar(self, o): - return common_serializers.AttachmentSerializer( - o.actor.attachment_icon if o.actor else None - ).data - class UserWriteSerializer(serializers.ModelSerializer): summary = common_serializers.ContentSerializer(required=False, allow_null=True) @@ -140,19 +135,12 @@ class UserWriteSerializer(serializers.ModelSerializer): obj.actor.save(update_fields=["attachment_icon"]) return obj - def to_representation(self, obj): - repr = super().to_representation(obj) - repr["avatar"] = common_serializers.AttachmentSerializer( - obj.actor.attachment_icon - ).data - return repr - class UserReadSerializer(serializers.ModelSerializer): permissions = serializers.SerializerMethodField() full_username = serializers.SerializerMethodField() - avatar = serializers.SerializerMethodField() + avatar = common_serializers.AttachmentSerializer(source="get_avatar") class Meta: model = models.User @@ -170,9 +158,6 @@ class UserReadSerializer(serializers.ModelSerializer): "avatar", ] - def get_avatar(self, o): - return common_serializers.AttachmentSerializer(o.actor.attachment_icon).data - def get_permissions(self, o): return o.get_permissions() diff --git a/api/tests/audio/test_serializers.py b/api/tests/audio/test_serializers.py index f0979c0e0..9ef01c908 100644 --- a/api/tests/audio/test_serializers.py +++ b/api/tests/audio/test_serializers.py @@ -185,7 +185,6 @@ def test_rss_item_serializer(factories): "itunes:subtitle": [{"value": description.truncate(255)}], "itunes:summary": [{"cdata_value": description.rendered}], "description": [{"value": description.as_plain_text}], - "content:encoded": [{"cdata_value": description.rendered}], "guid": [{"cdata_value": str(upload.uuid), "isPermalink": "false"}], "pubDate": [{"value": serializers.rss_date(upload.creation_date)}], "itunes:duration": [{"value": serializers.rss_duration(upload.duration)}], @@ -197,7 +196,11 @@ def test_rss_item_serializer(factories): "itunes:image": [{"href": upload.track.attachment_cover.download_url_original}], "link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}], "enclosure": [ - {"url": upload.listen_url, "length": upload.size, "type": upload.mimetype} + { + "url": federation_utils.full_url(upload.get_listen_url("mp3")), + "length": upload.size, + "type": upload.mimetype, + } ], } diff --git a/api/tests/audio/test_views.py b/api/tests/audio/test_views.py index 7a9fb477b..9b9cf51f1 100644 --- a/api/tests/audio/test_views.py +++ b/api/tests/audio/test_views.py @@ -1,8 +1,10 @@ +import uuid import pytest from django.urls import reverse from funkwhale_api.audio import serializers +from funkwhale_api.audio import views def test_channel_create(logged_in_api_client): @@ -23,8 +25,10 @@ def test_channel_create(logged_in_api_client): assert response.status_code == 201 - channel = actor.owned_channels.select_related("artist__description").latest("id") - expected = serializers.ChannelSerializer(channel).data + channel = views.ChannelViewSet.queryset.get(attributed_to=actor) + expected = serializers.ChannelSerializer( + channel, context={"subscriptions_count": True} + ).data assert response.data == expected assert channel.artist.name == data["name"] @@ -43,6 +47,9 @@ def test_channel_create(logged_in_api_client): def test_channel_detail(factories, logged_in_api_client): channel = factories["audio.Channel"](artist__description=None) url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + setattr(channel.artist, "_tracks_count", 0) + setattr(channel.artist, "_prefetched_tagged_items", []) + expected = serializers.ChannelSerializer( channel, context={"subscriptions_count": True} ).data @@ -54,6 +61,8 @@ def test_channel_detail(factories, logged_in_api_client): def test_channel_list(factories, logged_in_api_client): channel = factories["audio.Channel"](artist__description=None) + setattr(channel.artist, "_tracks_count", 0) + setattr(channel.artist, "_prefetched_tagged_items", []) url = reverse("api:v1:channels-list") expected = serializers.ChannelSerializer(channel).data response = logged_in_api_client.get(url) @@ -142,8 +151,11 @@ def test_channel_subscribe(factories, logged_in_api_client): assert response.status_code == 201 subscription = actor.emitted_follows.select_related( - "target__channel__artist__description" + "target__channel__artist__description", + "target__channel__artist__attachment_cover", ).latest("id") + setattr(subscription.target.channel.artist, "_tracks_count", 0) + setattr(subscription.target.channel.artist, "_prefetched_tagged_items", []) assert subscription.fid == subscription.get_federation_id() expected = serializers.SubscriptionSerializer(subscription).data assert response.data == expected @@ -168,6 +180,8 @@ def test_subscriptions_list(factories, logged_in_api_client): actor = logged_in_api_client.user.create_actor() channel = factories["audio.Channel"](artist__description=None) subscription = factories["audio.Subscription"](target=channel.actor, actor=actor) + setattr(subscription.target.channel.artist, "_tracks_count", 0) + setattr(subscription.target.channel.artist, "_prefetched_tagged_items", []) factories["audio.Subscription"](target=channel.actor) url = reverse("api:v1:subscriptions-list") expected = serializers.SubscriptionSerializer(subscription).data @@ -192,7 +206,10 @@ def test_subscriptions_all(factories, logged_in_api_client): response = logged_in_api_client.get(url) assert response.status_code == 200 - assert response.data == {"results": [subscription.uuid], "count": 1} + assert response.data == { + "results": [{"uuid": subscription.uuid, "channel": uuid.UUID(channel.uuid)}], + "count": 1, + } def test_channel_rss_feed(factories, api_client): diff --git a/api/tests/common/test_filters.py b/api/tests/common/test_filters.py index 138f6ca5d..b45dcf111 100644 --- a/api/tests/common/test_filters.py +++ b/api/tests/common/test_filters.py @@ -50,6 +50,8 @@ def test_mutation_filter_is_approved(value, expected, factories): ("noop", 0, []), ("noop", 1, []), ("noop", 2, []), + ("actor:actor1@domain.test", 0, [0]), + ("actor:actor2@domain.test", 0, [1]), ], ) def test_actor_scope_filter( @@ -61,8 +63,13 @@ def test_actor_scope_filter( mocker, anonymous_user, ): - actor1 = factories["users.User"]().create_actor() - actor2 = factories["users.User"]().create_actor() + domain = factories["federation.Domain"](name="domain.test") + actor1 = factories["users.User"]().create_actor( + preferred_username="actor1", domain=domain + ) + actor2 = factories["users.User"]().create_actor( + preferred_username="actor2", domain=domain + ) users = [actor1.user, actor2.user, anonymous_user] tracks = [ factories["music.Upload"](library__actor=actor1, playable=True).track, diff --git a/api/tests/common/test_views.py b/api/tests/common/test_views.py index 358d85736..761a2940e 100644 --- a/api/tests/common/test_views.py +++ b/api/tests/common/test_views.py @@ -7,6 +7,7 @@ from funkwhale_api.common import serializers from funkwhale_api.common import signals from funkwhale_api.common import tasks from funkwhale_api.common import throttling +from funkwhale_api.common import utils def test_can_detail_mutation(logged_in_api_client, factories): @@ -270,3 +271,13 @@ def test_attachment_destroy_not_owner(factories, logged_in_api_client): assert response.status_code == 403 attachment.refresh_from_db() + + +def test_can_render_text_preview(api_client, db): + payload = {"text": "Hello world"} + url = reverse("api:v1:text-preview") + response = api_client.post(url, payload) + + expected = {"rendered": utils.render_html(payload["text"], "text/markdown")} + assert response.status_code == 200 + assert response.data == expected diff --git a/api/tests/favorites/test_favorites.py b/api/tests/favorites/test_favorites.py index b81006386..06a309e15 100644 --- a/api/tests/favorites/test_favorites.py +++ b/api/tests/favorites/test_favorites.py @@ -29,6 +29,9 @@ def test_user_can_get_his_favorites( favorite, context={"request": request} ).data ] + expected[0]["track"]["artist"].pop("cover") + expected[0]["track"]["album"]["artist"].pop("cover") + assert response.status_code == 200 assert response.data["results"] == expected diff --git a/api/tests/federation/test_api_serializers.py b/api/tests/federation/test_api_serializers.py index ab621abde..5ac4d278b 100644 --- a/api/tests/federation/test_api_serializers.py +++ b/api/tests/federation/test_api_serializers.py @@ -1,7 +1,9 @@ import pytest +from funkwhale_api.common import serializers as common_serializers from funkwhale_api.federation import api_serializers from funkwhale_api.federation import serializers +from funkwhale_api.users import serializers as users_serializers def test_library_serializer(factories, to_api_date): @@ -111,3 +113,31 @@ def test_serialize_generic_relation(factory_name, factory_kwargs, expected, fact obj = factories[factory_name](**factory_kwargs) expected["type"] = factory_name assert api_serializers.serialize_generic_relation({}, obj) == expected + + +def test_api_full_actor_serializer(factories, to_api_date): + summary = factories["common.Content"]() + icon = factories["common.Attachment"]() + user = factories["users.User"]() + actor = user.create_actor(summary_obj=summary, attachment_icon=icon) + expected = { + "fid": actor.fid, + "url": actor.url, + "creation_date": to_api_date(actor.creation_date), + "last_fetch_date": to_api_date(actor.last_fetch_date), + "user": users_serializers.UserBasicSerializer(user).data, + "is_channel": False, + "domain": actor.domain_id, + "type": actor.type, + "manually_approves_followers": actor.manually_approves_followers, + "full_username": actor.full_username, + "name": actor.name, + "preferred_username": actor.preferred_username, + "is_local": actor.is_local, + "summary": common_serializers.ContentSerializer(summary).data, + "icon": common_serializers.AttachmentSerializer(icon).data, + } + + serializer = api_serializers.FullActorSerializer(actor) + + assert serializer.data == expected diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py index 1795a65e4..73dd6b80a 100644 --- a/api/tests/federation/test_api_views.py +++ b/api/tests/federation/test_api_views.py @@ -197,3 +197,15 @@ def test_user_can_list_domains(factories, api_client, preferences): "results": [api_serializers.DomainSerializer(allowed).data], } assert response.data == expected + + +def test_can_retrieve_actor(factories, api_client, preferences): + preferences["common__api_authentication_required"] = False + actor = factories["federation.Actor"]() + url = reverse( + "api:v1:federation:actors-detail", kwargs={"full_username": actor.full_username} + ) + response = api_client.get(url) + + expected = api_serializers.FullActorSerializer(actor).data + assert response.data == expected diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index ca7d47d6f..f70313f3f 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -215,6 +215,8 @@ def test_album_serializer(factories, to_api_date): } serializer = serializers.AlbumSerializer(album) + for t in expected["tracks"]: + t["artist"].pop("cover") assert serializer.data == expected diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 6ecea6306..6df25ea63 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -1249,6 +1249,13 @@ def test_search_get(use_fts, settings, logged_in_api_client, factories): "tracks": [serializers.TrackSerializer(track).data], "tags": [views.TagSerializer(tag).data], } + for album in expected["albums"]: + album["artist"].pop("cover") + + for track in expected["tracks"]: + track["artist"].pop("cover") + track["album"]["artist"].pop("cover") + response = logged_in_api_client.get(url, {"q": "foo"}) assert response.status_code == 200 diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py index 2be64b2bb..1b7fca928 100644 --- a/api/tests/playlists/test_views.py +++ b/api/tests/playlists/test_views.py @@ -157,6 +157,8 @@ def test_can_list_tracks_from_playlist(level, factories, logged_in_api_client): url = reverse("api:v1:playlists-tracks", kwargs={"pk": plt.playlist.pk}) response = logged_in_api_client.get(url) serialized_plt = serializers.PlaylistTrackSerializer(plt).data + serialized_plt["track"]["artist"].pop("cover") + serialized_plt["track"]["album"]["artist"].pop("cover") assert response.data["count"] == 1 assert response.data["results"][0] == serialized_plt diff --git a/api/tests/radios/test_api.py b/api/tests/radios/test_api.py index 02d0dc954..0b0ee6182 100644 --- a/api/tests/radios/test_api.py +++ b/api/tests/radios/test_api.py @@ -36,6 +36,9 @@ def test_can_validate_config(logged_in_api_client, factories): "count": candidates.count(), "sample": TrackSerializer(candidates, many=True).data, } + for s in expected["sample"]: + s["artist"].pop("cover") + assert payload["filters"][0]["candidates"] == expected assert payload["filters"][0]["errors"] == [] diff --git a/front/package.json b/front/package.json index b16b83de0..d05c57d10 100644 --- a/front/package.json +++ b/front/package.json @@ -14,7 +14,6 @@ }, "dependencies": { "axios": "^0.18.0", - "dateformat": "^3.0.3", "diff": "^4.0.1", "django-channels": "^1.1.6", "fomantic-ui-css": "^2.7", diff --git a/front/src/App.vue b/front/src/App.vue index b2de3597e..b5b6eaa0f 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -24,7 +24,7 @@ - +
+
@@ -68,7 +71,7 @@ export default { self.previousPage = response.data.previous self.nextPage = response.data.next self.isLoading = false - self.albums = response.data.results + self.albums = [...self.albums, ...response.data.results] self.count = response.data.count }, error => { self.isLoading = false diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue index bcb66e95f..f73b7dbb1 100644 --- a/front/src/components/audio/artist/Card.vue +++ b/front/src/components/audio/artist/Card.vue @@ -22,7 +22,6 @@ - - - diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index 269e7ac29..ff1e78b3d 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -23,6 +23,7 @@ +