From 6bbe48598ee02a048ad3b94a935b25e0c29f4a6d Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Mon, 25 Nov 2019 09:49:49 +0100 Subject: [PATCH] See #170: exclude by default all channels-related entities from /artists, /albums and /tracks endpoints results, for backward compatibility --- api/config/api_urls.py | 2 + api/config/settings/common.py | 1 + api/funkwhale_api/audio/__init__.py | 0 api/funkwhale_api/audio/admin.py | 15 ++ .../audio/dynamic_preferences_registry.py | 16 +++ api/funkwhale_api/audio/factories.py | 32 +++++ api/funkwhale_api/audio/filters.py | 65 +++++++++ .../audio/migrations/0001_initial.py | 31 +++++ .../audio/migrations/__init__.py | 0 api/funkwhale_api/audio/models.py | 39 ++++++ api/funkwhale_api/audio/serializers.py | 88 ++++++++++++ api/funkwhale_api/audio/views.py | 54 ++++++++ api/funkwhale_api/common/permissions.py | 11 +- .../migrations/0021_auto_20191029_1257.py | 22 +++ api/funkwhale_api/moderation/filters.py | 1 + api/funkwhale_api/music/filters.py | 19 ++- api/funkwhale_api/users/models.py | 3 +- api/tests/audio/__init__.py | 0 api/tests/audio/test_models.py | 7 + api/tests/audio/test_serializers.py | 74 ++++++++++ api/tests/audio/test_views.py | 128 ++++++++++++++++++ api/tests/music/test_filters.py | 4 +- api/tests/music/test_views.py | 46 +++++++ 23 files changed, 649 insertions(+), 9 deletions(-) create mode 100644 api/funkwhale_api/audio/__init__.py create mode 100644 api/funkwhale_api/audio/admin.py create mode 100644 api/funkwhale_api/audio/dynamic_preferences_registry.py create mode 100644 api/funkwhale_api/audio/factories.py create mode 100644 api/funkwhale_api/audio/filters.py create mode 100644 api/funkwhale_api/audio/migrations/0001_initial.py create mode 100644 api/funkwhale_api/audio/migrations/__init__.py create mode 100644 api/funkwhale_api/audio/models.py create mode 100644 api/funkwhale_api/audio/serializers.py create mode 100644 api/funkwhale_api/audio/views.py create mode 100644 api/funkwhale_api/federation/migrations/0021_auto_20191029_1257.py create mode 100644 api/tests/audio/__init__.py create mode 100644 api/tests/audio/test_models.py create mode 100644 api/tests/audio/test_serializers.py create mode 100644 api/tests/audio/test_views.py diff --git a/api/config/api_urls.py b/api/config/api_urls.py index dc5ef22a3..b0d7eaaff 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -4,6 +4,7 @@ from rest_framework import routers from rest_framework.urlpatterns import format_suffix_patterns from funkwhale_api.activity import views as activity_views +from funkwhale_api.audio import views as audio_views from funkwhale_api.common import views as common_views from funkwhale_api.common import routers as common_routers from funkwhale_api.music import views @@ -21,6 +22,7 @@ router.register(r"uploads", views.UploadViewSet, "uploads") router.register(r"libraries", views.LibraryViewSet, "libraries") router.register(r"listen", views.ListenViewSet, "listen") router.register(r"artists", views.ArtistViewSet, "artists") +router.register(r"channels", audio_views.ChannelViewSet, "channels") router.register(r"albums", views.AlbumViewSet, "albums") router.register(r"licenses", views.LicenseViewSet, "licenses") router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists") diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 1ec11e7ff..7c03b89dc 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -191,6 +191,7 @@ LOCAL_APPS = ( "funkwhale_api.users.oauth", # Your stuff: custom apps go here "funkwhale_api.instance", + "funkwhale_api.audio", "funkwhale_api.music", "funkwhale_api.requests", "funkwhale_api.favorites", diff --git a/api/funkwhale_api/audio/__init__.py b/api/funkwhale_api/audio/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/funkwhale_api/audio/admin.py b/api/funkwhale_api/audio/admin.py new file mode 100644 index 000000000..5cde8cc87 --- /dev/null +++ b/api/funkwhale_api/audio/admin.py @@ -0,0 +1,15 @@ +from funkwhale_api.common import admin + +from . import models + + +@admin.register(models.Channel) +class ChannelAdmin(admin.ModelAdmin): + list_display = [ + "uuid", + "artist", + "attributed_to", + "actor", + "library", + "creation_date", + ] diff --git a/api/funkwhale_api/audio/dynamic_preferences_registry.py b/api/funkwhale_api/audio/dynamic_preferences_registry.py new file mode 100644 index 000000000..8f9b096b0 --- /dev/null +++ b/api/funkwhale_api/audio/dynamic_preferences_registry.py @@ -0,0 +1,16 @@ +from dynamic_preferences import types +from dynamic_preferences.registries import global_preferences_registry + +audio = types.Section("audio") + + +@global_preferences_registry.register +class ChannelsEnabled(types.BooleanPreference): + section = audio + name = "channels_enabled" + default = True + verbose_name = "Enable channels" + help_text = ( + "If disabled, the channels feature will be completely switched off, " + "and users won't be able to create channels or subscribe to them." + ) diff --git a/api/funkwhale_api/audio/factories.py b/api/funkwhale_api/audio/factories.py new file mode 100644 index 000000000..0c57eeb2e --- /dev/null +++ b/api/funkwhale_api/audio/factories.py @@ -0,0 +1,32 @@ +import factory + +from funkwhale_api.factories import registry, NoUpdateOnCreate +from funkwhale_api.federation import factories as federation_factories +from funkwhale_api.music import factories as music_factories + +from . import models + + +def set_actor(o): + return models.generate_actor(str(o.uuid)) + + +@registry.register +class ChannelFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): + uuid = factory.Faker("uuid4") + attributed_to = factory.SubFactory(federation_factories.ActorFactory) + library = factory.SubFactory( + federation_factories.MusicLibraryFactory, + actor=factory.SelfAttribute("..attributed_to"), + ) + actor = factory.LazyAttribute(set_actor) + artist = factory.SubFactory(music_factories.ArtistFactory) + + class Meta: + model = "audio.Channel" + + class Params: + local = factory.Trait( + attributed_to__fid=factory.Faker("federation_url", local=True), + artist__local=True, + ) diff --git a/api/funkwhale_api/audio/filters.py b/api/funkwhale_api/audio/filters.py new file mode 100644 index 000000000..02776e032 --- /dev/null +++ b/api/funkwhale_api/audio/filters.py @@ -0,0 +1,65 @@ +import django_filters + +from funkwhale_api.common import fields +from funkwhale_api.common import filters as common_filters +from funkwhale_api.moderation import filters as moderation_filters + +from . import models + + +def filter_tags(queryset, name, value): + non_empty_tags = [v.lower() for v in value if v] + for tag in non_empty_tags: + queryset = queryset.filter(artist__tagged_items__tag__name=tag).distinct() + return queryset + + +TAG_FILTER = common_filters.MultipleQueryFilter(method=filter_tags) + + +class ChannelFilter(moderation_filters.HiddenContentFilterSet): + q = fields.SearchFilter( + search_fields=["artist__name", "actor__summary", "actor__preferred_username"] + ) + tag = TAG_FILTER + scope = common_filters.ActorScopeFilter(actor_field="attributed_to", distinct=True) + + class Meta: + model = models.Channel + fields = ["q", "scope", "tag"] + hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["CHANNEL"] + + +class IncludeChannelsFilterSet(django_filters.FilterSet): + """ + + A filterset that include a "include_channels" param. Meant for compatibility + with clients that don't support channels yet: + + - include_channels=false : exclude objects associated with a channel + - include_channels=true : don't exclude objects associated with a channel + - not specified: include_channels=false + + Usage: + + class MyFilterSet(IncludeChannelsFilterSet): + class Meta: + include_channels_field = "album__artist__channel" + + """ + + include_channels = django_filters.BooleanFilter( + field_name="_", method="filter_include_channels" + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.data = self.data.copy() + self.data.setdefault("include_channels", False) + + def filter_include_channels(self, queryset, name, value): + if value is True: + return queryset + else: + params = {self.__class__.Meta.include_channels_field: None} + return queryset.filter(**params) diff --git a/api/funkwhale_api/audio/migrations/0001_initial.py b/api/funkwhale_api/audio/migrations/0001_initial.py new file mode 100644 index 000000000..62c271b26 --- /dev/null +++ b/api/funkwhale_api/audio/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 2.2.6 on 2019-10-29 12:57 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('federation', '0021_auto_20191029_1257'), + ('music', '0041_auto_20191021_1705'), + ] + + operations = [ + migrations.CreateModel( + name='Channel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), + ('actor', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='channel', to='federation.Actor')), + ('artist', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='channel', to='music.Artist')), + ('attributed_to', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_channels', to='federation.Actor')), + ('library', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='channel', to='music.Library')), + ], + ), + ] diff --git a/api/funkwhale_api/audio/migrations/__init__.py b/api/funkwhale_api/audio/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/funkwhale_api/audio/models.py b/api/funkwhale_api/audio/models.py new file mode 100644 index 000000000..f3f9db896 --- /dev/null +++ b/api/funkwhale_api/audio/models.py @@ -0,0 +1,39 @@ +import uuid + + +from django.db import models +from django.utils import timezone + +from funkwhale_api.federation import keys +from funkwhale_api.federation import models as federation_models +from funkwhale_api.users import models as user_models + + +class Channel(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, unique=True) + artist = models.OneToOneField( + "music.Artist", on_delete=models.CASCADE, related_name="channel" + ) + # the owner of the channel + attributed_to = models.ForeignKey( + "federation.Actor", on_delete=models.CASCADE, related_name="owned_channels" + ) + # the federation actor created for the channel + # (the one people can follow to receive updates) + actor = models.OneToOneField( + "federation.Actor", on_delete=models.CASCADE, related_name="channel" + ) + + library = models.OneToOneField( + "music.Library", on_delete=models.CASCADE, related_name="channel" + ) + creation_date = models.DateTimeField(default=timezone.now) + + +def generate_actor(username, **kwargs): + actor_data = user_models.get_actor_data(username, **kwargs) + private, public = keys.get_key_pair() + actor_data["private_key"] = private.decode("utf-8") + actor_data["public_key"] = public.decode("utf-8") + + return federation_models.Actor.objects.create(**actor_data) diff --git a/api/funkwhale_api/audio/serializers.py b/api/funkwhale_api/audio/serializers.py new file mode 100644 index 000000000..e2e469b7e --- /dev/null +++ b/api/funkwhale_api/audio/serializers.py @@ -0,0 +1,88 @@ +from django.db import transaction + +from rest_framework import serializers + +from funkwhale_api.federation import serializers as federation_serializers +from funkwhale_api.music import models as music_models +from funkwhale_api.music import serializers as music_serializers +from funkwhale_api.tags import models as tags_models +from funkwhale_api.tags import serializers as tags_serializers + +from . import models + + +class ChannelCreateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"]) + username = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"]) + summary = serializers.CharField(max_length=500, allow_blank=True, allow_null=True) + tags = tags_serializers.TagsListField() + + @transaction.atomic + def create(self, validated_data): + artist = music_models.Artist.objects.create( + attributed_to=validated_data["attributed_to"], name=validated_data["name"] + ) + if validated_data.get("tags", []): + tags_models.set_tags(artist, *validated_data["tags"]) + + channel = models.Channel( + artist=artist, attributed_to=validated_data["attributed_to"] + ) + + channel.actor = models.generate_actor( + validated_data["username"], + summary=validated_data["summary"], + name=validated_data["name"], + ) + + channel.library = music_models.Library.objects.create( + name=channel.actor.preferred_username, + privacy_level="public", + actor=validated_data["attributed_to"], + ) + channel.save() + return channel + + def to_representation(self, obj): + return ChannelSerializer(obj).data + + +class ChannelUpdateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"]) + summary = serializers.CharField(max_length=500, allow_blank=True, allow_null=True) + tags = tags_serializers.TagsListField() + + @transaction.atomic + def update(self, obj, validated_data): + if validated_data.get("tags") is not None: + tags_models.set_tags(obj.artist, *validated_data["tags"]) + actor_update_fields = [] + + if "summary" in validated_data: + actor_update_fields.append(("summary", validated_data["summary"])) + if "name" in validated_data: + obj.artist.name = validated_data["name"] + obj.artist.save(update_fields=["name"]) + actor_update_fields.append(("name", validated_data["name"])) + + if actor_update_fields: + for field, value in actor_update_fields: + setattr(obj.actor, field, value) + obj.actor.save(update_fields=[f for f, _ in actor_update_fields]) + return obj + + def to_representation(self, obj): + return ChannelSerializer(obj).data + + +class ChannelSerializer(serializers.ModelSerializer): + artist = serializers.SerializerMethodField() + actor = federation_serializers.APIActorSerializer() + attributed_to = federation_serializers.APIActorSerializer() + + class Meta: + model = models.Channel + fields = ["uuid", "artist", "attributed_to", "actor", "creation_date"] + + def get_artist(self, obj): + return music_serializers.serialize_artist_simple(obj.artist) diff --git a/api/funkwhale_api/audio/views.py b/api/funkwhale_api/audio/views.py new file mode 100644 index 000000000..856c6b050 --- /dev/null +++ b/api/funkwhale_api/audio/views.py @@ -0,0 +1,54 @@ +from rest_framework import exceptions, mixins, viewsets + +from django import http + +from funkwhale_api.common import permissions +from funkwhale_api.common import preferences +from funkwhale_api.users.oauth import permissions as oauth_permissions + +from . import filters, models, serializers + + +class ChannelsMixin(object): + def dispatch(self, request, *args, **kwargs): + if not preferences.get("audio__channels_enabled"): + return http.HttpResponse(status=405) + return super().dispatch(request, *args, **kwargs) + + +class ChannelViewSet( + ChannelsMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.ListModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + lookup_field = "uuid" + filterset_class = filters.ChannelFilter + serializer_class = serializers.ChannelSerializer + queryset = ( + models.Channel.objects.all() + .prefetch_related("library", "attributed_to", "artist", "actor") + .order_by("-creation_date") + ) + permission_classes = [ + oauth_permissions.ScopePermission, + permissions.OwnerPermission, + ] + required_scope = "libraries" + anonymous_policy = "setting" + owner_checks = ["write"] + owner_field = "attributed_to.user" + owner_exception = exceptions.PermissionDenied + + def get_serializer_class(self): + if self.request.method.lower() in ["head", "get", "options"]: + return serializers.ChannelSerializer + elif self.action in ["update", "partial_update"]: + return serializers.ChannelUpdateSerializer + return serializers.ChannelCreateSerializer + + def perform_create(self, serializer): + return serializer.save(attributed_to=self.request.user.actor) diff --git a/api/funkwhale_api/common/permissions.py b/api/funkwhale_api/common/permissions.py index 237fc4ae4..76d8a7ff3 100644 --- a/api/funkwhale_api/common/permissions.py +++ b/api/funkwhale_api/common/permissions.py @@ -1,6 +1,8 @@ import operator +from django.core.exceptions import ObjectDoesNotExist from django.http import Http404 + from rest_framework.permissions import BasePermission from funkwhale_api.common import preferences @@ -46,7 +48,12 @@ class OwnerPermission(BasePermission): return True owner_field = getattr(view, "owner_field", "user") - owner = operator.attrgetter(owner_field)(obj) + owner_exception = getattr(view, "owner_exception", Http404) + try: + owner = operator.attrgetter(owner_field)(obj) + except ObjectDoesNotExist: + raise owner_exception + if not owner or not request.user.is_authenticated or owner != request.user: - raise Http404 + raise owner_exception return True diff --git a/api/funkwhale_api/federation/migrations/0021_auto_20191029_1257.py b/api/funkwhale_api/federation/migrations/0021_auto_20191029_1257.py new file mode 100644 index 000000000..2aad0df28 --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0021_auto_20191029_1257.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2.6 on 2019-10-29 12:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('federation', '0020_auto_20190730_0846'), + ] + + operations = [ + migrations.AlterModelOptions( + name='actor', + options={'verbose_name': 'Account'}, + ), + migrations.AlterField( + model_name='actor', + name='type', + field=models.CharField(choices=[('Person', 'Person'), ('Tombstone', 'Tombstone'), ('Application', 'Application'), ('Group', 'Group'), ('Organization', 'Organization'), ('Service', 'Service')], default='Person', max_length=25), + ), + ] diff --git a/api/funkwhale_api/moderation/filters.py b/api/funkwhale_api/moderation/filters.py index ddf183045..629ae685f 100644 --- a/api/funkwhale_api/moderation/filters.py +++ b/api/funkwhale_api/moderation/filters.py @@ -5,6 +5,7 @@ from django_filters import rest_framework as filters USER_FILTER_CONFIG = { "ARTIST": {"target_artist": ["pk"]}, + "CHANNEL": {"target_artist": ["artist__pk"]}, "ALBUM": {"target_artist": ["artist__pk"]}, "TRACK": {"target_artist": ["artist__pk", "album__artist__pk"]}, "LISTENING": {"target_artist": ["track__album__artist__pk", "track__artist__pk"]}, diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py index f5bd17e67..75ce9ec85 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -1,5 +1,6 @@ from django_filters import rest_framework as filters +from funkwhale_api.audio import filters as audio_filters from funkwhale_api.common import fields from funkwhale_api.common import filters as common_filters from funkwhale_api.common import search @@ -19,7 +20,9 @@ def filter_tags(queryset, name, value): TAG_FILTER = common_filters.MultipleQueryFilter(method=filter_tags) -class ArtistFilter(moderation_filters.HiddenContentFilterSet): +class ArtistFilter( + audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet +): q = fields.SearchFilter(search_fields=["name"]) playable = filters.BooleanFilter(field_name="_", method="filter_playable") tag = TAG_FILTER @@ -36,13 +39,16 @@ class ArtistFilter(moderation_filters.HiddenContentFilterSet): "mbid": ["exact"], } hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"] + include_channels_field = "channel" def filter_playable(self, queryset, name, value): actor = utils.get_actor_from_request(self.request) return queryset.playable_by(actor, value) -class TrackFilter(moderation_filters.HiddenContentFilterSet): +class TrackFilter( + audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet +): q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"]) playable = filters.BooleanFilter(field_name="_", method="filter_playable") tag = TAG_FILTER @@ -64,13 +70,14 @@ class TrackFilter(moderation_filters.HiddenContentFilterSet): "mbid": ["exact"], } hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"] + include_channels_field = "artist__channel" def filter_playable(self, queryset, name, value): actor = utils.get_actor_from_request(self.request) return queryset.playable_by(actor, value) -class UploadFilter(filters.FilterSet): +class UploadFilter(audio_filters.IncludeChannelsFilterSet): library = filters.CharFilter("library__uuid") track = filters.UUIDFilter("track__uuid") track_artist = filters.UUIDFilter("track__artist__uuid") @@ -109,13 +116,16 @@ class UploadFilter(filters.FilterSet): "import_reference", "scope", ] + include_channels_field = "track__artist__channel" def filter_playable(self, queryset, name, value): actor = utils.get_actor_from_request(self.request) return queryset.playable_by(actor, value) -class AlbumFilter(moderation_filters.HiddenContentFilterSet): +class AlbumFilter( + audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet +): playable = filters.BooleanFilter(field_name="_", method="filter_playable") q = fields.SearchFilter(search_fields=["title", "artist__name"]) tag = TAG_FILTER @@ -127,6 +137,7 @@ class AlbumFilter(moderation_filters.HiddenContentFilterSet): model = models.Album fields = ["playable", "q", "artist", "scope", "mbid"] hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"] + include_channels_field = "artist__channel" def filter_playable(self, queryset, name, value): actor = utils.get_actor_from_request(self.request) diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 2a2f875a6..ca50c047f 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -362,7 +362,8 @@ def get_actor_data(username, **kwargs): "preferred_username": slugified_username, "domain": domain, "type": "Person", - "name": username, + "name": kwargs.get("name", username), + "summary": kwargs.get("summary"), "manually_approves_followers": False, "fid": federation_utils.full_url( reverse( diff --git a/api/tests/audio/__init__.py b/api/tests/audio/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/tests/audio/test_models.py b/api/tests/audio/test_models.py new file mode 100644 index 000000000..5992e41ed --- /dev/null +++ b/api/tests/audio/test_models.py @@ -0,0 +1,7 @@ +def test_channel(factories, now): + channel = factories["audio.Channel"]() + assert channel.artist is not None + assert channel.actor is not None + assert channel.attributed_to is not None + assert channel.library is not None + assert channel.creation_date >= now diff --git a/api/tests/audio/test_serializers.py b/api/tests/audio/test_serializers.py new file mode 100644 index 000000000..02737a852 --- /dev/null +++ b/api/tests/audio/test_serializers.py @@ -0,0 +1,74 @@ +from funkwhale_api.audio import serializers +from funkwhale_api.federation import serializers as federation_serializers +from funkwhale_api.music import serializers as music_serializers + + +def test_channel_serializer_create(factories): + attributed_to = factories["federation.Actor"](local=True) + + data = { + # TODO: cover + "name": "My channel", + "username": "mychannel", + "summary": "This is my channel", + "tags": ["hello", "world"], + } + + serializer = serializers.ChannelCreateSerializer(data=data) + assert serializer.is_valid(raise_exception=True) is True + + channel = serializer.save(attributed_to=attributed_to) + + assert channel.artist.name == data["name"] + assert channel.artist.attributed_to == attributed_to + assert ( + sorted(channel.artist.tagged_items.values_list("tag__name", flat=True)) + == data["tags"] + ) + assert channel.attributed_to == attributed_to + assert channel.actor.summary == data["summary"] + assert channel.actor.preferred_username == data["username"] + assert channel.actor.name == data["name"] + assert channel.library.privacy_level == "public" + assert channel.library.actor == attributed_to + + +def test_channel_serializer_update(factories): + channel = factories["audio.Channel"](artist__set_tags=["rock"]) + + data = { + # TODO: cover + "name": "My channel", + "summary": "This is my channel", + "tags": ["hello", "world"], + } + + serializer = serializers.ChannelUpdateSerializer(channel, data=data) + assert serializer.is_valid(raise_exception=True) is True + + serializer.save() + channel.refresh_from_db() + + assert channel.artist.name == data["name"] + assert ( + sorted(channel.artist.tagged_items.values_list("tag__name", flat=True)) + == data["tags"] + ) + assert channel.actor.summary == data["summary"] + assert channel.actor.name == data["name"] + + +def test_channel_serializer_representation(factories, to_api_date): + channel = factories["audio.Channel"]() + + expected = { + "artist": music_serializers.serialize_artist_simple(channel.artist), + "uuid": str(channel.uuid), + "creation_date": to_api_date(channel.creation_date), + "actor": federation_serializers.APIActorSerializer(channel.actor).data, + "attributed_to": federation_serializers.APIActorSerializer( + channel.attributed_to + ).data, + } + + assert serializers.ChannelSerializer(channel).data == expected diff --git a/api/tests/audio/test_views.py b/api/tests/audio/test_views.py new file mode 100644 index 000000000..d12caa3dd --- /dev/null +++ b/api/tests/audio/test_views.py @@ -0,0 +1,128 @@ +import pytest + +from django.urls import reverse + +from funkwhale_api.audio import serializers + + +def test_channel_create(logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + + data = { + # TODO: cover + "name": "My channel", + "username": "mychannel", + "summary": "This is my channel", + "tags": ["hello", "world"], + } + + url = reverse("api:v1:channels-list") + response = logged_in_api_client.post(url, data) + + assert response.status_code == 201 + + channel = actor.owned_channels.latest("id") + expected = serializers.ChannelSerializer(channel).data + + assert response.data == expected + assert channel.artist.name == data["name"] + assert channel.artist.attributed_to == actor + assert ( + sorted(channel.artist.tagged_items.values_list("tag__name", flat=True)) + == data["tags"] + ) + assert channel.attributed_to == actor + assert channel.actor.summary == data["summary"] + assert channel.actor.preferred_username == data["username"] + assert channel.library.privacy_level == "public" + assert channel.library.actor == actor + + +def test_channel_detail(factories, logged_in_api_client): + channel = factories["audio.Channel"]() + url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + expected = serializers.ChannelSerializer(channel).data + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == expected + + +def test_channel_list(factories, logged_in_api_client): + channel = factories["audio.Channel"]() + url = reverse("api:v1:channels-list") + expected = serializers.ChannelSerializer(channel).data + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == { + "results": [expected], + "count": 1, + "next": None, + "previous": None, + } + + +def test_channel_update(logged_in_api_client, factories): + actor = logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"](attributed_to=actor) + + data = { + # TODO: cover + "name": "new name" + } + + url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + response = logged_in_api_client.patch(url, data) + + assert response.status_code == 200 + + channel.refresh_from_db() + + assert channel.artist.name == data["name"] + + +def test_channel_update_permission(logged_in_api_client, factories): + logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"]() + + data = {"name": "new name"} + + url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + response = logged_in_api_client.patch(url, data) + + assert response.status_code == 403 + + +def test_channel_delete(logged_in_api_client, factories): + actor = logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"](attributed_to=actor) + + url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + + with pytest.raises(channel.DoesNotExist): + channel.refresh_from_db() + + +def test_channel_delete_permission(logged_in_api_client, factories): + logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"]() + + url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + response = logged_in_api_client.patch(url) + + assert response.status_code == 403 + channel.refresh_from_db() + + +@pytest.mark.parametrize("url_name", ["api:v1:channels-list"]) +def test_channel_views_disabled_via_feature_flag( + url_name, logged_in_api_client, preferences +): + preferences["audio__channels_enabled"] = False + url = reverse(url_name) + response = logged_in_api_client.get(url) + assert response.status_code == 405 diff --git a/api/tests/music/test_filters.py b/api/tests/music/test_filters.py index f3ff13e77..7ce7e16a0 100644 --- a/api/tests/music/test_filters.py +++ b/api/tests/music/test_filters.py @@ -60,8 +60,8 @@ def test_artist_filter_track_album_artist(factories, mocker, queryset_equal_list "factory_name, filterset_class", [ ("music.Track", filters.TrackFilter), - ("music.Artist", filters.TrackFilter), - ("music.Album", filters.TrackFilter), + ("music.Artist", filters.ArtistFilter), + ("music.Album", filters.AlbumFilter), ], ) def test_track_filter_tag_single( diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index ad3bf0413..f0cc68f41 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -997,3 +997,49 @@ def test_refetch_obj(mocker, factories, settings, service_actor): views.refetch_obj(obj, obj.__class__.objects.all()) fetch = obj.fetches.filter(actor=service_actor).order_by("-creation_date").first() fetch_task.assert_called_once_with(fetch_id=fetch.pk) + + +@pytest.mark.parametrize( + "params, expected", + [({}, 0), ({"include_channels": "false"}, 0), ({"include_channels": "true"}, 1)], +) +def test_artist_list_exclude_channels( + params, expected, factories, logged_in_api_client +): + factories["audio.Channel"]() + + url = reverse("api:v1:artists-list") + response = logged_in_api_client.get(url, params) + + assert response.status_code == 200 + assert response.data["count"] == expected + + +@pytest.mark.parametrize( + "params, expected", + [({}, 0), ({"include_channels": "false"}, 0), ({"include_channels": "true"}, 1)], +) +def test_album_list_exclude_channels(params, expected, factories, logged_in_api_client): + channel_artist = factories["audio.Channel"]().artist + factories["music.Album"](artist=channel_artist) + + url = reverse("api:v1:albums-list") + response = logged_in_api_client.get(url, params) + + assert response.status_code == 200 + assert response.data["count"] == expected + + +@pytest.mark.parametrize( + "params, expected", + [({}, 0), ({"include_channels": "false"}, 0), ({"include_channels": "true"}, 1)], +) +def test_track_list_exclude_channels(params, expected, factories, logged_in_api_client): + channel_artist = factories["audio.Channel"]().artist + factories["music.Track"](artist=channel_artist) + + url = reverse("api:v1:tracks-list") + response = logged_in_api_client.get(url, params) + + assert response.status_code == 200 + assert response.data["count"] == expected