Commit 7093214b authored by Agate's avatar Agate 💬

Merge branch '3-playlists' into 'develop'

Resolve "Playlists integration"

Closes #3, #93, and #94

See merge request funkwhale/funkwhale!98
parents 02741182 529114c8
......@@ -57,7 +57,6 @@ THIRD_PARTY_APPS = (
'taggit',
'rest_auth',
'rest_auth.registration',
'mptt',
'dynamic_preferences',
'django_filters',
'cacheops',
......@@ -383,3 +382,6 @@ CACHEOPS = {
# Custom Admin URL, use {% url 'admin:index' %}
ADMIN_URL = env('DJANGO_ADMIN_URL', default='^api/admin/')
CSRF_USE_SESSIONS = True
# Playlist settings
PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250)
from django.db import models
PRIVACY_LEVEL_CHOICES = [
('me', 'Only me'),
('followers', 'Me and my followers'),
('instance', 'Everyone on my instance, and my followers'),
('everyone', 'Everyone, including people on other instances'),
]
def get_privacy_field():
return models.CharField(
max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance')
def privacy_level_query(user, lookup_field='privacy_level'):
if user.is_anonymous:
return models.Q(**{
lookup_field: 'everyone',
})
return models.Q(**{
'{}__in'.format(lookup_field): [
'me', 'followers', 'instance', 'everyone'
]
})
import operator
from django.conf import settings
from django.http import Http404
from rest_framework.permissions import BasePermission, DjangoModelPermissions
......@@ -20,3 +23,39 @@ class HasModelPermission(DjangoModelPermissions):
"""
def get_required_permissions(self, method, model_cls):
return super().get_required_permissions(method, self.model)
class OwnerPermission(BasePermission):
"""
Ensure the request user is the owner of the object.
Usage:
class MyView(APIView):
model = MyModel
permission_classes = [OwnerPermission]
owner_field = 'owner'
owner_checks = ['read', 'write']
"""
perms_map = {
'GET': 'read',
'OPTIONS': 'read',
'HEAD': 'read',
'POST': 'write',
'PUT': 'write',
'PATCH': 'write',
'DELETE': 'write',
}
def has_object_permission(self, request, view, obj):
method_check = self.perms_map[request.method]
owner_checks = getattr(view, 'owner_checks', ['read', 'write'])
if method_check not in owner_checks:
# check not enabled
return True
owner_field = getattr(view, 'owner_field', 'user')
owner = operator.attrgetter(owner_field)(obj)
if owner != request.user:
raise Http404
return True
......@@ -5,13 +5,13 @@ from . import models
@admin.register(models.Playlist)
class PlaylistAdmin(admin.ModelAdmin):
list_display = ['name', 'user', 'is_public', 'creation_date']
list_display = ['name', 'user', 'privacy_level', 'creation_date']
search_fields = ['name', ]
list_select_related = True
@admin.register(models.PlaylistTrack)
class PlaylistTrackAdmin(admin.ModelAdmin):
list_display = ['playlist', 'track', 'position', ]
list_display = ['playlist', 'track', 'index']
search_fields = ['track__name', 'playlist__name']
list_select_related = True
import factory
from funkwhale_api.factories import registry
from funkwhale_api.music.factories import TrackFactory
from funkwhale_api.users.factories import UserFactory
......@@ -11,3 +12,12 @@ class PlaylistFactory(factory.django.DjangoModelFactory):
class Meta:
model = 'playlists.Playlist'
@registry.register
class PlaylistTrackFactory(factory.django.DjangoModelFactory):
playlist = factory.SubFactory(PlaylistFactory)
track = factory.SubFactory(TrackFactory)
class Meta:
model = 'playlists.PlaylistTrack'
from django_filters import rest_framework as filters
from funkwhale_api.music import utils
from . import models
class PlaylistFilter(filters.FilterSet):
q = filters.CharFilter(name='_', method='filter_q')
class Meta:
model = models.Playlist
fields = {
'user': ['exact'],
'name': ['exact', 'icontains'],
'q': 'exact',
}
def filter_q(self, queryset, name, value):
query = utils.get_query(value, ['name', 'user__username'])
return queryset.filter(query)
......@@ -4,7 +4,6 @@ from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
import django.utils.timezone
import mptt.fields
class Migration(migrations.Migration):
......@@ -34,7 +33,7 @@ class Migration(migrations.Migration):
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('position', models.PositiveIntegerField(db_index=True, editable=False)),
('playlist', models.ForeignKey(to='playlists.Playlist', related_name='playlist_tracks', on_delete=models.CASCADE)),
('previous', mptt.fields.TreeOneToOneField(null=True, to='playlists.PlaylistTrack', related_name='next', blank=True, on_delete=models.CASCADE)),
('previous', models.OneToOneField(null=True, to='playlists.PlaylistTrack', related_name='next', blank=True, on_delete=models.CASCADE)),
('track', models.ForeignKey(to='music.Track', related_name='playlist_tracks', on_delete=models.CASCADE)),
],
options={
......
# Generated by Django 2.0.3 on 2018-03-16 22:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlists', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='playlist',
name='is_public',
),
migrations.AddField(
model_name='playlist',
name='privacy_level',
field=models.CharField(choices=[('me', 'Only me'), ('followers', 'Me and my followers'), ('instance', 'Everyone on my instance, and my followers'), ('everyone', 'Everyone, including people on other instances')], default='instance', max_length=30),
),
]
# Generated by Django 2.0.3 on 2018-03-19 12:14
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('playlists', '0002_auto_20180316_2217'),
]
operations = [
migrations.AlterModelOptions(
name='playlisttrack',
options={'ordering': ('-playlist', 'index')},
),
migrations.AddField(
model_name='playlisttrack',
name='creation_date',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name='playlisttrack',
name='index',
field=models.PositiveIntegerField(null=True),
),
migrations.RemoveField(
model_name='playlisttrack',
name='lft',
),
migrations.RemoveField(
model_name='playlisttrack',
name='position',
),
migrations.RemoveField(
model_name='playlisttrack',
name='previous',
),
migrations.RemoveField(
model_name='playlisttrack',
name='rght',
),
migrations.RemoveField(
model_name='playlisttrack',
name='tree_id',
),
migrations.AlterUniqueTogether(
name='playlisttrack',
unique_together={('playlist', 'index')},
),
]
# Generated by Django 2.0.3 on 2018-03-20 17:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlists', '0003_auto_20180319_1214'),
]
operations = [
migrations.AddField(
model_name='playlist',
name='modification_date',
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name='playlisttrack',
name='index',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AlterUniqueTogether(
name='playlisttrack',
unique_together=set(),
),
]
from django.conf import settings
from django.db import models
from django.db import transaction
from django.utils import timezone
from mptt.models import MPTTModel, TreeOneToOneField
from rest_framework import exceptions
from funkwhale_api.common import fields
class Playlist(models.Model):
name = models.CharField(max_length=50)
is_public = models.BooleanField(default=False)
user = models.ForeignKey(
'users.User', related_name="playlists", on_delete=models.CASCADE)
creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField(
auto_now=True)
privacy_level = fields.get_privacy_field()
def __str__(self):
return self.name
def add_track(self, track, previous=None):
plt = PlaylistTrack(previous=previous, track=track, playlist=self)
plt.save()
@transaction.atomic
def insert(self, plt, index=None):
"""
Given a PlaylistTrack, insert it at the correct index in the playlist,
and update other tracks index if necessary.
"""
old_index = plt.index
move = old_index is not None
if index is not None and index == old_index:
# moving at same position, just skip
return index
existing = self.playlist_tracks.select_for_update()
if move:
existing = existing.exclude(pk=plt.pk)
total = existing.filter(index__isnull=False).count()
if index is None:
# we simply increment the last track index by 1
index = total
if index > total:
raise exceptions.ValidationError('Index is not continuous')
if index < 0:
raise exceptions.ValidationError('Index must be zero or positive')
if move:
# we remove the index temporarily, to avoid integrity errors
plt.index = None
plt.save(update_fields=['index'])
if index > old_index:
# new index is higher than current, we decrement previous tracks
to_update = existing.filter(
index__gt=old_index, index__lte=index)
to_update.update(index=models.F('index') - 1)
if index < old_index:
# new index is lower than current, we increment next tracks
to_update = existing.filter(index__lt=old_index, index__gte=index)
to_update.update(index=models.F('index') + 1)
else:
to_update = existing.filter(index__gte=index)
to_update.update(index=models.F('index') + 1)
return plt
plt.index = index
plt.save(update_fields=['index'])
self.save(update_fields=['modification_date'])
return index
@transaction.atomic
def remove(self, index):
existing = self.playlist_tracks.select_for_update()
self.save(update_fields=['modification_date'])
to_update = existing.filter(index__gt=index)
return to_update.update(index=models.F('index') - 1)
class PlaylistTrack(MPTTModel):
@transaction.atomic
def insert_many(self, tracks):
existing = self.playlist_tracks.select_for_update()
now = timezone.now()
total = existing.filter(index__isnull=False).count()
if existing.count() + len(tracks) > settings.PLAYLISTS_MAX_TRACKS:
raise exceptions.ValidationError(
'Playlist would reach the maximum of {} tracks'.format(
settings.PLAYLISTS_MAX_TRACKS))
self.save(update_fields=['modification_date'])
start = total
plts = [
PlaylistTrack(
creation_date=now, playlist=self, track=track, index=start+i)
for i, track in enumerate(tracks)
]
return PlaylistTrack.objects.bulk_create(plts)
class PlaylistTrackQuerySet(models.QuerySet):
def for_nested_serialization(self):
return (self.select_related()
.select_related('track__album__artist')
.prefetch_related(
'track__tags',
'track__files',
'track__artist__albums__tracks__tags'))
class PlaylistTrack(models.Model):
track = models.ForeignKey(
'music.Track',
related_name='playlist_tracks',
on_delete=models.CASCADE)
previous = TreeOneToOneField(
'self',
blank=True,
null=True,
related_name='next',
on_delete=models.CASCADE)
index = models.PositiveIntegerField(null=True, blank=True)
playlist = models.ForeignKey(
Playlist, related_name='playlist_tracks', on_delete=models.CASCADE)
creation_date = models.DateTimeField(default=timezone.now)
class MPTTMeta:
level_attr = 'position'
parent_attr = 'previous'
objects = PlaylistTrackQuerySet.as_manager()
class Meta:
ordering = ('-playlist', 'position')
ordering = ('-playlist', 'index')
unique_together = ('playlist', 'index')
def delete(self, *args, **kwargs):
playlist = self.playlist
index = self.index
update_indexes = kwargs.pop('update_indexes', False)
r = super().delete(*args, **kwargs)
if index is not None and update_indexes:
playlist.remove(index)
return r
from django.conf import settings
from django.db import transaction
from rest_framework import serializers
from taggit.models import Tag
from funkwhale_api.music.models import Track
from funkwhale_api.music.serializers import TrackSerializerNested
from funkwhale_api.users.serializers import UserBasicSerializer
from . import models
......@@ -11,20 +14,81 @@ class PlaylistTrackSerializer(serializers.ModelSerializer):
class Meta:
model = models.PlaylistTrack
fields = ('id', 'track', 'playlist', 'position')
fields = ('id', 'track', 'playlist', 'index', 'creation_date')
class PlaylistTrackCreateSerializer(serializers.ModelSerializer):
class PlaylistTrackWriteSerializer(serializers.ModelSerializer):
index = serializers.IntegerField(
required=False, min_value=0, allow_null=True)
class Meta:
model = models.PlaylistTrack
fields = ('id', 'track', 'playlist', 'position')
fields = ('id', 'track', 'playlist', 'index')
def validate_playlist(self, value):
if self.context.get('request'):
# validate proper ownership on the playlist
if self.context['request'].user != value.user:
raise serializers.ValidationError(
'You do not have the permission to edit this playlist')
existing = value.playlist_tracks.count()
if existing >= settings.PLAYLISTS_MAX_TRACKS:
raise serializers.ValidationError(
'Playlist has reached the maximum of {} tracks'.format(
settings.PLAYLISTS_MAX_TRACKS))
return value
@transaction.atomic
def create(self, validated_data):
index = validated_data.pop('index', None)
instance = super().create(validated_data)
instance.playlist.insert(instance, index)
return instance
@transaction.atomic
def update(self, instance, validated_data):
update_index = 'index' in validated_data
index = validated_data.pop('index', None)
super().update(instance, validated_data)
if update_index:
instance.playlist.insert(instance, index)
return instance
def get_unique_together_validators(self):
"""
We explicitely disable unique together validation here
because it collides with our internal logic
"""
return []
class PlaylistSerializer(serializers.ModelSerializer):
playlist_tracks = PlaylistTrackSerializer(many=True, read_only=True)
tracks_count = serializers.SerializerMethodField(read_only=True)
user = UserBasicSerializer(read_only=True)
class Meta:
model = models.Playlist
fields = ('id', 'name', 'is_public', 'creation_date', 'playlist_tracks')
read_only_fields = ['id', 'playlist_tracks', 'creation_date']
fields = (
'id',
'name',
'tracks_count',
'user',
'modification_date',
'creation_date',
'privacy_level',)
read_only_fields = [
'id',
'modification_date',
'creation_date',]
def get_tracks_count(self, obj):
try:
return obj.tracks_count
except AttributeError:
# no annotation?
return obj.playlist_tracks.count()
class PlaylistAddManySerializer(serializers.Serializer):
tracks = serializers.PrimaryKeyRelatedField(
many=True, queryset=Track.objects.for_nested_serialization())
from django.db.models import Count
from django.db import transaction
from rest_framework import exceptions
from rest_framework import generics, mixins, viewsets
from rest_framework import status
from rest_framework.decorators import detail_route
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from funkwhale_api.common import permissions
from funkwhale_api.common import fields
from funkwhale_api.music.models import Track
from funkwhale_api.common.permissions import ConditionalAuthentication
from . import filters
from . import models
from . import serializers
class PlaylistViewSet(
mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet):
serializer_class = serializers.PlaylistSerializer
queryset = (models.Playlist.objects.all())
permission_classes = [ConditionalAuthentication]
queryset = (
models.Playlist.objects.all().select_related('user')
.annotate(tracks_count=Count('playlist_tracks'))
)
permission_classes = [
permissions.ConditionalAuthentication,
permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
]
owner_checks = ['write']
filter_class = filters.PlaylistFilter
ordering_fields = ('id', 'name', 'creation_date', 'modification_date')
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
@detail_route(methods=['get'])
def tracks(self, request, *args, **kwargs):
playlist = self.get_object()
plts = playlist.playlist_tracks.all().for_nested_serialization()
serializer = serializers.PlaylistTrackSerializer(plts, many=True)
data = {
'count': len(plts),
'results': serializer.data
}
return Response(data, status=200)
@detail_route(methods=['post'])
@transaction.atomic
def add(self, request, *args, **kwargs):
playlist = self.get_object()
serializer = serializers.PlaylistAddManySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
instance = self.perform_create(serializer)
serializer = self.get_serializer(instance=instance)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
try:
plts = playlist.insert_many(serializer.validated_data['tracks'])
except exceptions.ValidationError as e:
payload = {'playlist': e.detail}
return Response(payload, status=400)
ids = [p.id for p in plts]
plts = models.PlaylistTrack.objects.filter(
pk__in=ids).order_by('index').for_nested_serialization()
serializer = serializers.PlaylistTrackSerializer(plts, many=True)
data = {
'count': len(plts),
'results': serializer.data
}
return Response(data, status=201)
@detail_route(methods=['delete'])
@transaction.atomic
def clear(self, request, *args, **kwargs):
playlist = self.get_object()
playlist.playlist_tracks.all().delete()
playlist.save(update_fields=['modification_date'])
return Response(status=204)
def get_queryset(self):
return self.queryset.filter(user=self.request.user)
return self.queryset.filter(
fields.privacy_level_query(self.request.user))
def perform_create(self, serializer):
return serializer.save(user=self.request.user)
return serializer.save(
user=self.request.user,
privacy_level=serializer.validated_data.get(
'privacy_level', self.request.user.privacy_level)
)
class PlaylistTrackViewSet(
mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet):
serializer_class = serializers.PlaylistTrackSerializer
queryset = (models.PlaylistTrack.objects.all())
permission_classes = [ConditionalAuthentication]
queryset = (models.PlaylistTrack.objects.all().for_nested_serialization())
permission_classes = [
permissions.ConditionalAuthentication,
permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
]
owner_field = 'playlist.user'