Permissions and db state fixes with new index field
This commit is contained in:
parent
257e67b5a6
commit
e87e2654e8
|
@ -67,13 +67,18 @@ class Playlist(models.Model):
|
||||||
plt.save(update_fields=['index'])
|
plt.save(update_fields=['index'])
|
||||||
return index
|
return index
|
||||||
|
|
||||||
|
def remove(self, index):
|
||||||
|
existing = self.playlist_tracks.select_for_update()
|
||||||
|
to_update = existing.filter(index__gt=index)
|
||||||
|
return to_update.update(index=models.F('index') - 1)
|
||||||
|
|
||||||
|
|
||||||
class PlaylistTrack(models.Model):
|
class PlaylistTrack(models.Model):
|
||||||
track = models.ForeignKey(
|
track = models.ForeignKey(
|
||||||
'music.Track',
|
'music.Track',
|
||||||
related_name='playlist_tracks',
|
related_name='playlist_tracks',
|
||||||
on_delete=models.CASCADE)
|
on_delete=models.CASCADE)
|
||||||
index = models.PositiveIntegerField(null=True)
|
index = models.PositiveIntegerField(null=True, blank=True)
|
||||||
playlist = models.ForeignKey(
|
playlist = models.ForeignKey(
|
||||||
Playlist, related_name='playlist_tracks', on_delete=models.CASCADE)
|
Playlist, related_name='playlist_tracks', on_delete=models.CASCADE)
|
||||||
creation_date = models.DateTimeField(default=timezone.now)
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
|
@ -81,3 +86,12 @@ class PlaylistTrack(models.Model):
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('-playlist', 'index')
|
ordering = ('-playlist', 'index')
|
||||||
unique_together = ('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
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
|
|
||||||
|
@ -12,16 +13,23 @@ class PlaylistTrackSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PlaylistTrack
|
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:
|
class Meta:
|
||||||
model = models.PlaylistTrack
|
model = models.PlaylistTrack
|
||||||
fields = ('id', 'track', 'playlist', 'index')
|
fields = ('id', 'track', 'playlist', 'index')
|
||||||
|
|
||||||
def validate_playlist(self, value):
|
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()
|
existing = value.playlist_tracks.count()
|
||||||
if existing >= settings.PLAYLISTS_MAX_TRACKS:
|
if existing >= settings.PLAYLISTS_MAX_TRACKS:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
|
@ -29,6 +37,29 @@ class PlaylistTrackCreateSerializer(serializers.ModelSerializer):
|
||||||
settings.PLAYLISTS_MAX_TRACKS))
|
settings.PLAYLISTS_MAX_TRACKS))
|
||||||
return value
|
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):
|
class PlaylistSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
|
|
|
@ -27,6 +27,7 @@ class PlaylistViewSet(
|
||||||
permissions.OwnerPermission,
|
permissions.OwnerPermission,
|
||||||
IsAuthenticatedOrReadOnly,
|
IsAuthenticatedOrReadOnly,
|
||||||
]
|
]
|
||||||
|
owner_checks = ['write']
|
||||||
|
|
||||||
@detail_route(methods=['get'])
|
@detail_route(methods=['get'])
|
||||||
def tracks(self, request, *args, **kwargs):
|
def tracks(self, request, *args, **kwargs):
|
||||||
|
@ -66,25 +67,19 @@ class PlaylistTrackViewSet(
|
||||||
permissions.OwnerPermission,
|
permissions.OwnerPermission,
|
||||||
IsAuthenticatedOrReadOnly,
|
IsAuthenticatedOrReadOnly,
|
||||||
]
|
]
|
||||||
|
owner_field = 'playlist.user'
|
||||||
|
owner_checks = ['write']
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def get_serializer_class(self):
|
||||||
serializer = serializers.PlaylistTrackCreateSerializer(
|
if self.request.method in ['PUT', 'PATCH', 'DELETE', 'POST']:
|
||||||
data=request.data)
|
return serializers.PlaylistTrackWriteSerializer
|
||||||
serializer.is_valid(raise_exception=True)
|
return self.serializer_class
|
||||||
if serializer.validated_data['playlist'].user != request.user:
|
|
||||||
return Response(
|
|
||||||
{'playlist': [
|
|
||||||
'This playlist does not exists or you do not have the'
|
|
||||||
'permission to edit it']
|
|
||||||
},
|
|
||||||
status=400)
|
|
||||||
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)
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.queryset.filter(
|
return self.queryset.filter(
|
||||||
fields.privacy_level_query(
|
fields.privacy_level_query(
|
||||||
self.request.user,
|
self.request.user,
|
||||||
lookup_field='playlist__privacy_level'))
|
lookup_field='playlist__privacy_level'))
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
instance.delete(update_indexes=True)
|
||||||
|
|
|
@ -72,3 +72,18 @@ def test_cannot_insert_at_negative_index(factories):
|
||||||
new = factories['playlists.PlaylistTrack'](playlist=plt.playlist)
|
new = factories['playlists.PlaylistTrack'](playlist=plt.playlist)
|
||||||
with pytest.raises(forms.ValidationError):
|
with pytest.raises(forms.ValidationError):
|
||||||
plt.playlist.insert(new, -1)
|
plt.playlist.insert(new, -1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_update_indexes(factories):
|
||||||
|
playlist = factories['playlists.Playlist']()
|
||||||
|
first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
|
||||||
|
second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1)
|
||||||
|
third = factories['playlists.PlaylistTrack'](playlist=playlist, index=2)
|
||||||
|
|
||||||
|
second.delete(update_indexes=True)
|
||||||
|
|
||||||
|
first.refresh_from_db()
|
||||||
|
third.refresh_from_db()
|
||||||
|
|
||||||
|
assert first.index == 0
|
||||||
|
assert third.index == 1
|
||||||
|
|
|
@ -1,16 +1,74 @@
|
||||||
|
from funkwhale_api.playlists import models
|
||||||
from funkwhale_api.playlists import serializers
|
from funkwhale_api.playlists import serializers
|
||||||
|
|
||||||
|
|
||||||
def test_cannot_max_500_tracks_per_playlist(mocker, factories, settings):
|
def test_cannot_max_500_tracks_per_playlist(factories, settings):
|
||||||
settings.PLAYLISTS_MAX_TRACKS = 2
|
settings.PLAYLISTS_MAX_TRACKS = 2
|
||||||
playlist = factories['playlists.Playlist']()
|
playlist = factories['playlists.Playlist']()
|
||||||
plts = factories['playlists.PlaylistTrack'].create_batch(
|
plts = factories['playlists.PlaylistTrack'].create_batch(
|
||||||
size=2, playlist=playlist)
|
size=2, playlist=playlist)
|
||||||
track = factories['music.Track']()
|
track = factories['music.Track']()
|
||||||
serializer = serializers.PlaylistTrackCreateSerializer(data={
|
serializer = serializers.PlaylistTrackWriteSerializer(data={
|
||||||
'playlist': playlist.pk,
|
'playlist': playlist.pk,
|
||||||
'track': track.pk,
|
'track': track.pk,
|
||||||
})
|
})
|
||||||
|
|
||||||
assert serializer.is_valid() is False
|
assert serializer.is_valid() is False
|
||||||
assert 'playlist' in serializer.errors
|
assert 'playlist' in serializer.errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_insert_is_called_when_index_is_None(factories, mocker):
|
||||||
|
insert = mocker.spy(models.Playlist, 'insert')
|
||||||
|
playlist = factories['playlists.Playlist']()
|
||||||
|
track = factories['music.Track']()
|
||||||
|
serializer = serializers.PlaylistTrackWriteSerializer(data={
|
||||||
|
'playlist': playlist.pk,
|
||||||
|
'track': track.pk,
|
||||||
|
'index': None,
|
||||||
|
})
|
||||||
|
assert serializer.is_valid() is True
|
||||||
|
|
||||||
|
plt = serializer.save()
|
||||||
|
insert.assert_called_once_with(playlist, plt, None)
|
||||||
|
assert plt.index == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_insert_is_called_when_index_is_provided(factories, mocker):
|
||||||
|
playlist = factories['playlists.Playlist']()
|
||||||
|
first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
|
||||||
|
insert = mocker.spy(models.Playlist, 'insert')
|
||||||
|
factories['playlists.Playlist']()
|
||||||
|
track = factories['music.Track']()
|
||||||
|
serializer = serializers.PlaylistTrackWriteSerializer(data={
|
||||||
|
'playlist': playlist.pk,
|
||||||
|
'track': track.pk,
|
||||||
|
'index': 0,
|
||||||
|
})
|
||||||
|
assert serializer.is_valid() is True
|
||||||
|
|
||||||
|
plt = serializer.save()
|
||||||
|
first.refresh_from_db()
|
||||||
|
insert.assert_called_once_with(playlist, plt, 0)
|
||||||
|
assert plt.index == 0
|
||||||
|
assert first.index == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_insert_is_called_when_index_is_provided(factories, mocker):
|
||||||
|
playlist = factories['playlists.Playlist']()
|
||||||
|
first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
|
||||||
|
second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1)
|
||||||
|
insert = mocker.spy(models.Playlist, 'insert')
|
||||||
|
factories['playlists.Playlist']()
|
||||||
|
track = factories['music.Track']()
|
||||||
|
serializer = serializers.PlaylistTrackWriteSerializer(second, data={
|
||||||
|
'playlist': playlist.pk,
|
||||||
|
'track': second.track.pk,
|
||||||
|
'index': 0,
|
||||||
|
})
|
||||||
|
assert serializer.is_valid() is True
|
||||||
|
|
||||||
|
plt = serializer.save()
|
||||||
|
first.refresh_from_db()
|
||||||
|
insert.assert_called_once_with(playlist, plt, 0)
|
||||||
|
assert plt.index == 0
|
||||||
|
assert first.index == 1
|
||||||
|
|
|
@ -80,6 +80,21 @@ def test_only_can_add_track_on_own_playlist_via_api(
|
||||||
assert playlist.playlist_tracks.count() == 0
|
assert playlist.playlist_tracks.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_plt_updates_indexes(
|
||||||
|
mocker, factories, logged_in_api_client):
|
||||||
|
remove = mocker.spy(models.Playlist, 'remove')
|
||||||
|
track = factories['music.Track']()
|
||||||
|
plt = factories['playlists.PlaylistTrack'](
|
||||||
|
index=0,
|
||||||
|
playlist__user=logged_in_api_client.user)
|
||||||
|
url = reverse('api:v1:playlist-tracks-detail', kwargs={'pk': plt.pk})
|
||||||
|
|
||||||
|
response = logged_in_api_client.delete(url)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
remove.assert_called_once_with(plt.playlist, 0)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
|
@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
|
||||||
def test_playlist_privacy_respected_in_list_anon(level, factories, api_client):
|
def test_playlist_privacy_respected_in_list_anon(level, factories, api_client):
|
||||||
factories['playlists.Playlist'](privacy_level=level)
|
factories['playlists.Playlist'](privacy_level=level)
|
||||||
|
|
Loading…
Reference in New Issue