refactor playlist duplicate error structure

- use non_field_errors struct when writing duplicate track errors
- generalize frontend error handler and update frontend error parsing
This commit is contained in:
Qasim Ali 2019-04-24 11:31:46 +02:00 committed by Eliot Berriot
parent 31d990499d
commit 22f0235045
9 changed files with 285 additions and 22 deletions

View File

@ -70,7 +70,7 @@ class Playlist(models.Model):
return self.name return self.name
@transaction.atomic @transaction.atomic
def insert(self, plt, index=None): def insert(self, plt, index=None, allow_duplicates=True):
""" """
Given a PlaylistTrack, insert it at the correct index in the playlist, Given a PlaylistTrack, insert it at the correct index in the playlist,
and update other tracks index if necessary. and update other tracks index if necessary.
@ -96,6 +96,10 @@ class Playlist(models.Model):
if index < 0: if index < 0:
raise exceptions.ValidationError("Index must be zero or positive") raise exceptions.ValidationError("Index must be zero or positive")
if not allow_duplicates:
existing_without_current_plt = existing.exclude(pk=plt.pk)
self._check_duplicate_add(existing_without_current_plt, [plt.track])
if move: if move:
# we remove the index temporarily, to avoid integrity errors # we remove the index temporarily, to avoid integrity errors
plt.index = None plt.index = None
@ -125,7 +129,7 @@ class Playlist(models.Model):
return to_update.update(index=models.F("index") - 1) return to_update.update(index=models.F("index") - 1)
@transaction.atomic @transaction.atomic
def insert_many(self, tracks): def insert_many(self, tracks, allow_duplicates=True):
existing = self.playlist_tracks.select_for_update() existing = self.playlist_tracks.select_for_update()
now = timezone.now() now = timezone.now()
total = existing.filter(index__isnull=False).count() total = existing.filter(index__isnull=False).count()
@ -134,6 +138,10 @@ class Playlist(models.Model):
raise exceptions.ValidationError( raise exceptions.ValidationError(
"Playlist would reach the maximum of {} tracks".format(max_tracks) "Playlist would reach the maximum of {} tracks".format(max_tracks)
) )
if not allow_duplicates:
self._check_duplicate_add(existing, tracks)
self.save(update_fields=["modification_date"]) self.save(update_fields=["modification_date"])
start = total start = total
plts = [ plts = [
@ -144,6 +152,26 @@ class Playlist(models.Model):
] ]
return PlaylistTrack.objects.bulk_create(plts) return PlaylistTrack.objects.bulk_create(plts)
def _check_duplicate_add(self, existing_playlist_tracks, tracks_to_add):
track_ids = [t.pk for t in tracks_to_add]
duplicates = existing_playlist_tracks.filter(
track__pk__in=track_ids
).values_list("track__pk", flat=True)
if duplicates:
duplicate_tracks = [t for t in tracks_to_add if t.pk in duplicates]
raise exceptions.ValidationError(
{
"non_field_errors": [
{
"tracks": duplicate_tracks,
"playlist_name": self.name,
"code": "tracks_already_exist_in_playlist",
}
]
}
)
class PlaylistTrackQuerySet(models.QuerySet): class PlaylistTrackQuerySet(models.QuerySet):
def for_nested_serialization(self, actor=None): def for_nested_serialization(self, actor=None):

View File

@ -24,10 +24,11 @@ class PlaylistTrackSerializer(serializers.ModelSerializer):
class PlaylistTrackWriteSerializer(serializers.ModelSerializer): class PlaylistTrackWriteSerializer(serializers.ModelSerializer):
index = serializers.IntegerField(required=False, min_value=0, allow_null=True) index = serializers.IntegerField(required=False, min_value=0, allow_null=True)
allow_duplicates = serializers.BooleanField(required=False)
class Meta: class Meta:
model = models.PlaylistTrack model = models.PlaylistTrack
fields = ("id", "track", "playlist", "index") fields = ("id", "track", "playlist", "index", "allow_duplicates")
def validate_playlist(self, value): def validate_playlist(self, value):
if self.context.get("request"): if self.context.get("request"):
@ -47,17 +48,21 @@ class PlaylistTrackWriteSerializer(serializers.ModelSerializer):
@transaction.atomic @transaction.atomic
def create(self, validated_data): def create(self, validated_data):
index = validated_data.pop("index", None) index = validated_data.pop("index", None)
allow_duplicates = validated_data.pop("allow_duplicates", True)
instance = super().create(validated_data) instance = super().create(validated_data)
instance.playlist.insert(instance, index)
instance.playlist.insert(instance, index, allow_duplicates)
return instance return instance
@transaction.atomic @transaction.atomic
def update(self, instance, validated_data): def update(self, instance, validated_data):
update_index = "index" in validated_data update_index = "index" in validated_data
index = validated_data.pop("index", None) index = validated_data.pop("index", None)
allow_duplicates = validated_data.pop("allow_duplicates", True)
super().update(instance, validated_data) super().update(instance, validated_data)
if update_index: if update_index:
instance.playlist.insert(instance, index) instance.playlist.insert(instance, index, allow_duplicates)
return instance return instance
def get_unique_together_validators(self): def get_unique_together_validators(self):
@ -151,3 +156,7 @@ class PlaylistAddManySerializer(serializers.Serializer):
tracks = serializers.PrimaryKeyRelatedField( tracks = serializers.PrimaryKeyRelatedField(
many=True, queryset=Track.objects.for_nested_serialization() many=True, queryset=Track.objects.for_nested_serialization()
) )
allow_duplicates = serializers.BooleanField(required=False)
class Meta:
fields = "allow_duplicates"

View File

@ -55,7 +55,10 @@ class PlaylistViewSet(
serializer = serializers.PlaylistAddManySerializer(data=request.data) serializer = serializers.PlaylistAddManySerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
try: try:
plts = playlist.insert_many(serializer.validated_data["tracks"]) plts = playlist.insert_many(
serializer.validated_data["tracks"],
serializer.validated_data["allow_duplicates"],
)
except exceptions.ValidationError as e: except exceptions.ValidationError as e:
payload = {"playlist": e.detail} payload = {"playlist": e.detail}
return Response(payload, status=400) return Response(payload, status=400)

View File

@ -124,6 +124,139 @@ def test_insert_many_honor_max_tracks(preferences, factories):
playlist.insert_many([track, track, track]) playlist.insert_many([track, track, track])
def test_can_insert_duplicate_by_default(factories):
playlist = factories["playlists.Playlist"]()
track = factories["music.Track"]()
factories["playlists.PlaylistTrack"](playlist=playlist, index=0, track=track)
new = factories["playlists.PlaylistTrack"](playlist=playlist, index=1, track=track)
playlist.insert(new)
new.refresh_from_db()
assert new.index == 1
def test_cannot_insert_duplicate(factories):
playlist = factories["playlists.Playlist"](name="playlist")
track = factories["music.Track"]()
factories["playlists.PlaylistTrack"](playlist=playlist, index=0, track=track)
with pytest.raises(exceptions.ValidationError) as e:
new = factories["playlists.PlaylistTrack"](
playlist=playlist, index=1, track=track
)
playlist.insert(new, allow_duplicates=False)
errors = e.value.detail["non_field_errors"]
assert len(errors) == 1
err = errors[0]
assert err["code"] == "tracks_already_exist_in_playlist"
assert err["playlist_name"] == "playlist"
assert err["tracks"] == [track.title]
def test_can_insert_track_to_playlist_with_existing_duplicates(factories):
playlist = factories["playlists.Playlist"]()
existing_duplicate = factories["music.Track"]()
factories["playlists.PlaylistTrack"](
playlist=playlist, index=0, track=existing_duplicate
)
factories["playlists.PlaylistTrack"](
playlist=playlist, index=1, track=existing_duplicate
)
factories["playlists.PlaylistTrack"](
playlist=playlist, index=2, track=existing_duplicate
)
new_track = factories["music.Track"]()
new_plt = factories["playlists.PlaylistTrack"](
playlist=playlist, index=3, track=new_track
)
# no error
playlist.insert(new_plt, allow_duplicates=False)
def test_can_insert_duplicate_with_override(factories):
playlist = factories["playlists.Playlist"]()
track = factories["music.Track"]()
factories["playlists.PlaylistTrack"](playlist=playlist, index=0, track=track)
new = factories["playlists.PlaylistTrack"](playlist=playlist, index=1, track=track)
playlist.insert(new, allow_duplicates=True)
new.refresh_from_db()
assert new.index == 1
def test_can_insert_many_duplicates_by_default(factories):
playlist = factories["playlists.Playlist"]()
t1 = factories["music.Track"]()
factories["playlists.PlaylistTrack"](playlist=playlist, index=0, track=t1)
t2 = factories["music.Track"]()
factories["playlists.PlaylistTrack"](playlist=playlist, index=1, track=t2)
t4 = factories["music.Track"]()
tracks = [t1, t4, t2]
plts = playlist.insert_many(tracks)
assert len(plts) == 3
assert plts[0].track == t1
assert plts[1].track == t4
assert plts[2].track == t2
def test_cannot_insert_many_duplicates(factories):
playlist = factories["playlists.Playlist"](name="playlist")
t1 = factories["music.Track"]()
factories["playlists.PlaylistTrack"](playlist=playlist, index=0, track=t1)
t2 = factories["music.Track"]()
factories["playlists.PlaylistTrack"](playlist=playlist, index=1, track=t2)
t4 = factories["music.Track"]()
with pytest.raises(exceptions.ValidationError) as e:
tracks = [t1, t4, t2]
playlist.insert_many(tracks, allow_duplicates=False)
errors = e.value.detail["non_field_errors"]
assert len(errors) == 1
err = errors[0]
assert err["code"] == "tracks_already_exist_in_playlist"
assert err["playlist_name"] == "playlist"
assert err["tracks"] == [t1.title, t2.title]
def test_can_insert_many_duplicates_with_override(factories):
playlist = factories["playlists.Playlist"]()
t1 = factories["music.Track"]()
factories["playlists.PlaylistTrack"](playlist=playlist, index=0, track=t1)
t2 = factories["music.Track"]()
factories["playlists.PlaylistTrack"](playlist=playlist, index=1, track=t2)
t4 = factories["music.Track"]()
tracks = [t1, t4, t2]
plts = playlist.insert_many(tracks, allow_duplicates=True)
assert len(plts) == 3
assert plts[0].track == t1
assert plts[1].track == t4
assert plts[2].track == t2
@pytest.mark.parametrize( @pytest.mark.parametrize(
"privacy_level,expected", [("me", False), ("instance", False), ("everyone", True)] "privacy_level,expected", [("me", False), ("instance", False), ("everyone", True)]
) )

View File

@ -24,7 +24,7 @@ def test_create_insert_is_called_when_index_is_None(factories, mocker):
assert serializer.is_valid() is True assert serializer.is_valid() is True
plt = serializer.save() plt = serializer.save()
insert.assert_called_once_with(playlist, plt, None) insert.assert_called_once_with(playlist, plt, None, True)
assert plt.index == 0 assert plt.index == 0
@ -41,7 +41,7 @@ def test_create_insert_is_called_when_index_is_provided(factories, mocker):
plt = serializer.save() plt = serializer.save()
first.refresh_from_db() first.refresh_from_db()
insert.assert_called_once_with(playlist, plt, 0) insert.assert_called_once_with(playlist, plt, 0, True)
assert plt.index == 0 assert plt.index == 0
assert first.index == 1 assert first.index == 1
@ -60,11 +60,35 @@ def test_update_insert_is_called_when_index_is_provided(factories, mocker):
plt = serializer.save() plt = serializer.save()
first.refresh_from_db() first.refresh_from_db()
insert.assert_called_once_with(playlist, plt, 0) insert.assert_called_once_with(playlist, plt, 0, True)
assert plt.index == 0 assert plt.index == 0
assert first.index == 1 assert first.index == 1
def test_update_insert_is_called_with_duplicate_override_when_duplicates_allowed(
factories, mocker
):
playlist = factories["playlists.Playlist"]()
plt = factories["playlists.PlaylistTrack"](playlist=playlist, index=0)
insert = mocker.spy(models.Playlist, "insert")
factories["playlists.Playlist"]()
factories["music.Track"]()
serializer = serializers.PlaylistTrackWriteSerializer(
plt,
data={
"playlist": playlist.pk,
"track": plt.track.pk,
"index": 0,
"allow_duplicates": True,
},
)
assert serializer.is_valid() is True
plt = serializer.save()
insert.assert_called_once_with(playlist, plt, 0, True)
def test_playlist_serializer_include_covers(factories, api_request): def test_playlist_serializer_include_covers(factories, api_request):
playlist = factories["playlists.Playlist"]() playlist = factories["playlists.Playlist"]()
t1 = factories["music.Track"]() t1 = factories["music.Track"]()

View File

@ -0,0 +1 @@
Display a confirmation dialog when adding duplicate songs to a playlist (#784)

View File

@ -18,13 +18,23 @@
</ul> </ul>
</div> </div>
</template> </template>
<div v-else-if="status === 'confirmDuplicateAdd'" class="ui warning message">
<p translate-context="Content/Playlist/Paragraph"
v-translate="{playlist: playlist.name}">Some tracks in your queue are already in this playlist:</p>
<ul id="duplicateTrackList" class="ui relaxed divided list">
<li v-for="track in duplicateTrackAddInfo.tracks" class="ui item">{{ track }}</li>
</ul>
<button
class="ui small green button"
@click="insertMany(queueTracks, true)"><translate translate-context="*/Playlist/Button.Label/Verb">Add anyways</translate></button>
</div>
<template v-else-if="status === 'saved'"> <template v-else-if="status === 'saved'">
<i class="green check icon"></i> <translate translate-context="Content/Playlist/Paragraph">Changes synced with server</translate> <i class="green check icon"></i> <translate translate-context="Content/Playlist/Paragraph">Changes synced with server</translate>
</template> </template>
</div> </div>
<div class="ui bottom attached segment"> <div class="ui bottom attached segment">
<div <div
@click="insertMany(queueTracks)" @click="insertMany(queueTracks, false)"
:disabled="queueTracks.length === 0" :disabled="queueTracks.length === 0"
:class="['ui', {disabled: queueTracks.length === 0}, 'labeled', 'icon', 'button']" :class="['ui', {disabled: queueTracks.length === 0}, 'labeled', 'icon', 'button']"
:title="labels.copyTitle"> :title="labels.copyTitle">
@ -91,17 +101,25 @@ export default {
return { return {
plts: this.playlistTracks, plts: this.playlistTracks,
isLoading: false, isLoading: false,
errors: [] errors: [],
duplicateTrackAddInfo: {},
showDuplicateTrackAddConfirmation: false
} }
}, },
methods: { methods: {
success () { success () {
this.isLoading = false this.isLoading = false
this.errors = [] this.errors = []
this.showDuplicateTrackAddConfirmation = false
}, },
errored (errors) { errored (errors) {
this.isLoading = false this.isLoading = false
if (errors.length == 1 && errors[0].code == 'tracks_already_exist_in_playlist') {
this.duplicateTrackAddInfo = errors[0]
this.showDuplicateTrackAddConfirmation = true
} else {
this.errors = errors this.errors = errors
}
}, },
reorder ({oldIndex, newIndex}) { reorder ({oldIndex, newIndex}) {
let self = this let self = this
@ -139,21 +157,31 @@ export default {
self.errored(error.backendErrors) self.errored(error.backendErrors)
}) })
}, },
insertMany (tracks) { insertMany (tracks, allowDuplicates) {
let self = this let self = this
let ids = tracks.map(t => { let ids = tracks.map(t => {
return t.id return t.id
}) })
let payload = {
tracks: ids,
allow_duplicates: allowDuplicates
}
self.isLoading = true self.isLoading = true
let url = 'playlists/' + this.playlist.id + '/add/' let url = 'playlists/' + this.playlist.id + '/add/'
axios.post(url, {tracks: ids}).then((response) => { axios.post(url, payload).then((response) => {
response.data.results.forEach(r => { response.data.results.forEach(r => {
self.plts.push(r) self.plts.push(r)
}) })
self.success() self.success()
self.$store.dispatch('playlists/fetchOwn') self.$store.dispatch('playlists/fetchOwn')
}, error => { }, error => {
// if backendErrors isn't populated (e.g. duplicate track exceptions raised by
// the playlist model), read directly from the response
if (error.rawPayload.playlist) {
self.errored(error.rawPayload.playlist.non_field_errors)
} else {
self.errored(error.backendErrors) self.errored(error.backendErrors)
}
}) })
} }
}, },
@ -173,6 +201,9 @@ export default {
if (this.errors.length > 0) { if (this.errors.length > 0) {
return 'errored' return 'errored'
} }
if (this.showDuplicateTrackAddConfirmation) {
return 'confirmDuplicateAdd'
}
return 'saved' return 'saved'
} }
}, },
@ -192,4 +223,8 @@ export default {
<!-- Add "scoped" attribute to limit CSS to this component only --> <!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped> <style scoped>
#duplicateTrackList {
max-height: 10em;
overflow-y: auto;
}
</style> </style>

View File

@ -18,6 +18,19 @@
<playlist-form :key="formKey"></playlist-form> <playlist-form :key="formKey"></playlist-form>
<div class="ui divider"></div> <div class="ui divider"></div>
<div v-if="showDuplicateTrackAddConfirmation" class="ui warning message">
<p translate-context="Popup/Playlist/Paragraph"
v-translate="{track: track.title, playlist: duplicateTrackAddInfo.playlist_name}"
:translate-params="{track: track.title, playlist: duplicateTrackAddInfo.playlist_name}"><strong>%{ track }</strong> is already in <strong>%{ playlist }</strong>.</p>
<button
@click="update(false)"
class="ui small cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</button>
<button
class="ui small green button"
@click="addToPlaylist(lastSelectedPlaylist, true)">
<translate translate-context="*/Playlist/Button.Label/Verb">Add anyways</translate></button>
</div>
<div v-if="errors.length > 0" class="ui negative message"> <div v-if="errors.length > 0" class="ui negative message">
<div class="header"><translate translate-context="Popup/Playlist/Error message.Title">The track can't be added to a playlist</translate></div> <div class="header"><translate translate-context="Popup/Playlist/Error message.Title">The track can't be added to a playlist</translate></div>
<ul class="list"> <ul class="list">
@ -52,7 +65,7 @@
v-if="track" v-if="track"
class="ui green icon basic small right floated button" class="ui green icon basic small right floated button"
:title="labels.addToPlaylist" :title="labels.addToPlaylist"
@click="addToPlaylist(playlist.id)"> @click="addToPlaylist(playlist.id, false)">
<i class="plus icon"></i> <translate translate-context="Popup/Playlist/Table.Button.Label/Verb">Add track</translate> <i class="plus icon"></i> <translate translate-context="Popup/Playlist/Table.Button.Label/Verb">Add track</translate>
</div> </div>
</td> </td>
@ -84,26 +97,38 @@ export default {
data () { data () {
return { return {
formKey: String(new Date()), formKey: String(new Date()),
errors: [] errors: [],
duplicateTrackAddInfo: {},
showDuplicateTrackAddConfirmation: false,
lastSelectedPlaylist: -1,
} }
}, },
methods: { methods: {
update (v) { update (v) {
this.$store.commit('playlists/showModal', v) this.$store.commit('playlists/showModal', v)
}, },
addToPlaylist (playlistId) { addToPlaylist (playlistId, allowDuplicate) {
let self = this let self = this
let payload = { let payload = {
track: this.track.id, track: this.track.id,
playlist: playlistId playlist: playlistId,
allow_duplicates: allowDuplicate
} }
self.lastSelectedPlaylist = playlistId
return axios.post('playlist-tracks/', payload).then(response => { return axios.post('playlist-tracks/', payload).then(response => {
logger.default.info('Successfully added track to playlist') logger.default.info('Successfully added track to playlist')
self.update(false) self.update(false)
self.$store.dispatch('playlists/fetchOwn') self.$store.dispatch('playlists/fetchOwn')
}, error => { }, error => {
logger.default.error('Error while adding track to playlist') if (error.backendErrors.length == 1 && error.backendErrors[0].code == 'tracks_already_exist_in_playlist') {
self.duplicateTrackAddInfo = error.backendErrors[0]
self.showDuplicateTrackAddConfirmation = true
} else {
self.errors = error.backendErrors self.errors = error.backendErrors
self.showDuplicateTrackAddConfirmation = false
}
}) })
} }
}, },
@ -126,9 +151,11 @@ export default {
watch: { watch: {
'$store.state.route.path' () { '$store.state.route.path' () {
this.$store.commit('playlists/showModal', false) this.$store.commit('playlists/showModal', false)
this.showDuplicateTrackAddConfirmation = false
}, },
'$store.state.playlists.showModal' () { '$store.state.playlists.showModal' () {
this.formKey = String(new Date()) this.formKey = String(new Date())
this.showDuplicateTrackAddConfirmation = false
} }
} }
} }

View File

@ -97,8 +97,11 @@ axios.interceptors.response.use(function (response) {
if (error.response.data.detail) { if (error.response.data.detail) {
error.backendErrors.push(error.response.data.detail) error.backendErrors.push(error.response.data.detail)
} else { } else {
error.rawPayload = error.response.data
for (var field in error.response.data) { for (var field in error.response.data) {
if (error.response.data.hasOwnProperty(field)) { // some views (e.g. v1/playlists/{id}/add) have deeper nested data (e.g. data[field]
// is another object), so don't try to unpack non-array fields
if (error.response.data.hasOwnProperty(field) && error.response.data[field].forEach) {
error.response.data[field].forEach(e => { error.response.data[field].forEach(e => {
error.backendErrors.push(e) error.backendErrors.push(e)
}) })