mirror of
https://code.eliotberriot.com/funkwhale/funkwhale.git
synced 2025-10-05 21:21:57 +02:00
See #170: exclude by default all channels-related entities from /artists, /albums and /tracks endpoints results, for backward compatibility
This commit is contained in:
parent
32c0afab4f
commit
6bbe48598e
23 changed files with 649 additions and 9 deletions
0
api/funkwhale_api/audio/__init__.py
Normal file
0
api/funkwhale_api/audio/__init__.py
Normal file
15
api/funkwhale_api/audio/admin.py
Normal file
15
api/funkwhale_api/audio/admin.py
Normal file
|
@ -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",
|
||||
]
|
16
api/funkwhale_api/audio/dynamic_preferences_registry.py
Normal file
16
api/funkwhale_api/audio/dynamic_preferences_registry.py
Normal file
|
@ -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."
|
||||
)
|
32
api/funkwhale_api/audio/factories.py
Normal file
32
api/funkwhale_api/audio/factories.py
Normal file
|
@ -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,
|
||||
)
|
65
api/funkwhale_api/audio/filters.py
Normal file
65
api/funkwhale_api/audio/filters.py
Normal file
|
@ -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)
|
31
api/funkwhale_api/audio/migrations/0001_initial.py
Normal file
31
api/funkwhale_api/audio/migrations/0001_initial.py
Normal file
|
@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
0
api/funkwhale_api/audio/migrations/__init__.py
Normal file
0
api/funkwhale_api/audio/migrations/__init__.py
Normal file
39
api/funkwhale_api/audio/models.py
Normal file
39
api/funkwhale_api/audio/models.py
Normal file
|
@ -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)
|
88
api/funkwhale_api/audio/serializers.py
Normal file
88
api/funkwhale_api/audio/serializers.py
Normal file
|
@ -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)
|
54
api/funkwhale_api/audio/views.py
Normal file
54
api/funkwhale_api/audio/views.py
Normal file
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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"]},
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue