From e87e2654e8932bdefe4b738e0b5c353f8c18c441 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Mon, 19 Mar 2018 15:28:33 +0100
Subject: [PATCH] Permissions and db state fixes with new index field

---
 api/funkwhale_api/playlists/models.py      | 16 +++++-
 api/funkwhale_api/playlists/serializers.py | 35 +++++++++++-
 api/funkwhale_api/playlists/views.py       | 25 ++++-----
 api/tests/playlists/test_models.py         | 15 ++++++
 api/tests/playlists/test_serializers.py    | 62 +++++++++++++++++++++-
 api/tests/playlists/test_views.py          | 15 ++++++
 6 files changed, 148 insertions(+), 20 deletions(-)

diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py
index b400934722..d8c90aef9a 100644
--- a/api/funkwhale_api/playlists/models.py
+++ b/api/funkwhale_api/playlists/models.py
@@ -67,13 +67,18 @@ class Playlist(models.Model):
         plt.save(update_fields=['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):
     track = models.ForeignKey(
         'music.Track',
         related_name='playlist_tracks',
         on_delete=models.CASCADE)
-    index = models.PositiveIntegerField(null=True)
+    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)
@@ -81,3 +86,12 @@ class PlaylistTrack(models.Model):
     class Meta:
         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
diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py
index 55603ee318..43e2414d40 100644
--- a/api/funkwhale_api/playlists/serializers.py
+++ b/api/funkwhale_api/playlists/serializers.py
@@ -1,4 +1,5 @@
 from django.conf import settings
+from django.db import transaction
 from rest_framework import serializers
 from taggit.models import Tag
 
@@ -12,16 +13,23 @@ 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', '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(
@@ -29,6 +37,29 @@ class PlaylistTrackCreateSerializer(serializers.ModelSerializer):
                     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):
 
diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py
index 3307b52765..02e53cb169 100644
--- a/api/funkwhale_api/playlists/views.py
+++ b/api/funkwhale_api/playlists/views.py
@@ -27,6 +27,7 @@ class PlaylistViewSet(
         permissions.OwnerPermission,
         IsAuthenticatedOrReadOnly,
     ]
+    owner_checks = ['write']
 
     @detail_route(methods=['get'])
     def tracks(self, request, *args, **kwargs):
@@ -66,25 +67,19 @@ class PlaylistTrackViewSet(
         permissions.OwnerPermission,
         IsAuthenticatedOrReadOnly,
     ]
+    owner_field = 'playlist.user'
+    owner_checks = ['write']
 
-    def create(self, request, *args, **kwargs):
-        serializer = serializers.PlaylistTrackCreateSerializer(
-            data=request.data)
-        serializer.is_valid(raise_exception=True)
-        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_serializer_class(self):
+        if self.request.method in ['PUT', 'PATCH', 'DELETE', 'POST']:
+            return serializers.PlaylistTrackWriteSerializer
+        return self.serializer_class
 
     def get_queryset(self):
         return self.queryset.filter(
             fields.privacy_level_query(
                 self.request.user,
                 lookup_field='playlist__privacy_level'))
+
+    def perform_destroy(self, instance):
+        instance.delete(update_indexes=True)
diff --git a/api/tests/playlists/test_models.py b/api/tests/playlists/test_models.py
index ab60cdb849..dfe40187d8 100644
--- a/api/tests/playlists/test_models.py
+++ b/api/tests/playlists/test_models.py
@@ -72,3 +72,18 @@ def test_cannot_insert_at_negative_index(factories):
     new = factories['playlists.PlaylistTrack'](playlist=plt.playlist)
     with pytest.raises(forms.ValidationError):
         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
diff --git a/api/tests/playlists/test_serializers.py b/api/tests/playlists/test_serializers.py
index 6daff8e609..8e30919e6e 100644
--- a/api/tests/playlists/test_serializers.py
+++ b/api/tests/playlists/test_serializers.py
@@ -1,16 +1,74 @@
+from funkwhale_api.playlists import models
 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
     playlist = factories['playlists.Playlist']()
     plts = factories['playlists.PlaylistTrack'].create_batch(
         size=2, playlist=playlist)
     track = factories['music.Track']()
-    serializer = serializers.PlaylistTrackCreateSerializer(data={
+    serializer = serializers.PlaylistTrackWriteSerializer(data={
         'playlist': playlist.pk,
         'track': track.pk,
     })
 
     assert serializer.is_valid() is False
     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
diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py
index 3c62dcda65..192aad3824 100644
--- a/api/tests/playlists/test_views.py
+++ b/api/tests/playlists/test_views.py
@@ -80,6 +80,21 @@ def test_only_can_add_track_on_own_playlist_via_api(
     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'])
 def test_playlist_privacy_respected_in_list_anon(level, factories, api_client):
     factories['playlists.Playlist'](privacy_level=level)
-- 
GitLab