Skip to content
GitLab
Menu
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
Menu
Open sidebar
Maxence Bothorel
funkwhale
Commits
7093214b
Commit
7093214b
authored
Mar 21, 2018
by
Eliot Berriot
Browse files
Merge branch '3-playlists' into 'develop'
Resolve "Playlists integration" Closes #3, #93, and #94 See merge request
funkwhale/funkwhale!98
parents
02741182
529114c8
Changes
50
Hide whitespace changes
Inline
Side-by-side
api/config/settings/common.py
View file @
7093214b
...
...
@@ -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
)
api/funkwhale_api/common/fields.py
0 → 100644
View file @
7093214b
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'
]
})
api/funkwhale_api/common/permissions.py
View file @
7093214b
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
api/funkwhale_api/playlists/admin.py
View file @
7093214b
...
...
@@ -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
api/funkwhale_api/playlists/factories.py
View file @
7093214b
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'
api/funkwhale_api/playlists/filters.py
0 → 100644
View file @
7093214b
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
)
api/funkwhale_api/playlists/migrations/0001_initial.py
View file @
7093214b
...
...
@@ -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'
,
m
ptt
.
fi
el
d
s
.
Tree
OneToOneField
(
null
=
True
,
to
=
'playlists.PlaylistTrack'
,
related_name
=
'next'
,
blank
=
True
,
on_delete
=
models
.
CASCADE
)),
(
'previous'
,
m
od
els
.
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
=
{
...
...
api/funkwhale_api/playlists/migrations/0002_auto_20180316_2217.py
0 → 100644
View file @
7093214b
# 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
),
),
]
api/funkwhale_api/playlists/migrations/0003_auto_20180319_1214.py
0 → 100644
View file @
7093214b
# 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'
)},
),
]
api/funkwhale_api/playlists/migrations/0004_auto_20180320_1713.py
0 → 100644
View file @
7093214b
# 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
(),
),
]
api/funkwhale_api/playlists/models.py
View file @
7093214b
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
api/funkwhale_api/playlists/serializers.py
View file @
7093214b
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
())
api/funkwhale_api/playlists/views.py
View file @
7093214b
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'
])