Verified Commit 9ecb2bdf authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch 'release/0.3.3'

parents 3a19505c 56c22027
...@@ -11,11 +11,14 @@ stages: ...@@ -11,11 +11,14 @@ stages:
- deploy - deploy
test_api: test_api:
services:
- postgres:9.4
stage: test stage: test
image: funkwhale/funkwhale:base image: funkwhale/funkwhale:base
variables: variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/pip-cache" PIP_CACHE_DIR: "$CI_PROJECT_DIR/pip-cache"
DATABASE_URL: "sqlite://" DATABASE_URL: "postgresql://postgres@postgres/postgres"
before_script: before_script:
- python3 -m venv --copies virtualenv - python3 -m venv --copies virtualenv
- source virtualenv/bin/activate - source virtualenv/bin/activate
......
...@@ -2,10 +2,18 @@ Changelog ...@@ -2,10 +2,18 @@ Changelog
========= =========
0.3.3 (Unreleased) 0.3.4 (Unreleased)
------------------ ------------------
0.3.4 (2018-01-07)
------------------
- Users can now create their own dynamic radios (#51)
0.3.2 0.3.2
------------------ ------------------
......
from .common import * # noqa from .common import * # noqa
SECRET_KEY = env("DJANGO_SECRET_KEY", default='test') SECRET_KEY = env("DJANGO_SECRET_KEY", default='test')
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
# Mail settings # Mail settings
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
__version__ = '0.3.2' __version__ = '0.3.3'
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')]) __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
...@@ -262,6 +262,16 @@ class Lyrics(models.Model): ...@@ -262,6 +262,16 @@ class Lyrics(models.Model):
extensions=['markdown.extensions.nl2br']) extensions=['markdown.extensions.nl2br'])
class TrackQuerySet(models.QuerySet):
def for_nested_serialization(self):
return (self.select_related()
.select_related('album__artist')
.prefetch_related(
'tags',
'files',
'artist__albums__tracks__tags'))
class Track(APIModelMixin): class Track(APIModelMixin):
title = models.CharField(max_length=255) title = models.CharField(max_length=255)
artist = models.ForeignKey( artist = models.ForeignKey(
...@@ -302,6 +312,7 @@ class Track(APIModelMixin): ...@@ -302,6 +312,7 @@ class Track(APIModelMixin):
import_hooks = [ import_hooks = [
import_tags import_tags
] ]
objects = TrackQuerySet.as_manager()
tags = TaggableManager() tags = TaggableManager()
class Meta: class Meta:
......
...@@ -116,13 +116,7 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet): ...@@ -116,13 +116,7 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
""" """
A simple ViewSet for viewing and editing accounts. A simple ViewSet for viewing and editing accounts.
""" """
queryset = (models.Track.objects.all() queryset = (models.Track.objects.all().for_nested_serialization())
.select_related()
.select_related('album__artist')
.prefetch_related(
'tags',
'files',
'artist__albums__tracks__tags'))
serializer_class = serializers.TrackSerializerNested serializer_class = serializers.TrackSerializerNested
permission_classes = [ConditionalAuthentication] permission_classes = [ConditionalAuthentication]
search_fields = ['title', 'artist__name'] search_fields = ['title', 'artist__name']
......
import factory
from funkwhale_api.factories import registry
from funkwhale_api.users.factories import UserFactory
@registry.register
class RadioFactory(factory.django.DjangoModelFactory):
name = factory.Faker('name')
description = factory.Faker('paragraphs')
user = factory.SubFactory(UserFactory)
config = []
class Meta:
model = 'radios.Radio'
@registry.register
class RadioSessionFactory(factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory)
class Meta:
model = 'radios.RadioSession'
@registry.register(name='radios.CustomRadioSession')
class RadioSessionFactory(factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory)
radio_type = 'custom'
custom_radio = factory.SubFactory(
RadioFactory, user=factory.SelfAttribute('..user'))
class Meta:
model = 'radios.RadioSession'
import collections
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.urls import reverse_lazy
import persisting_theory
from funkwhale_api.music import models
from funkwhale_api.taskapp.celery import require_instance
class RadioFilterRegistry(persisting_theory.Registry):
def prepare_data(self, data):
return data()
def prepare_name(self, data, name=None):
return data.code
@property
def exposed_filters(self):
return [
f for f in self.values() if f.expose_in_api
]
registry = RadioFilterRegistry()
def run(filters, **kwargs):
candidates = kwargs.pop('candidates', models.Track.objects.all())
final_query = None
final_query = registry['group'].get_query(
candidates, filters=filters, **kwargs)
if final_query:
candidates = candidates.filter(final_query)
return candidates.order_by('pk')
def validate(filter_config):
try:
f = registry[filter_config['type']]
except KeyError:
raise ValidationError(
'Invalid type "{}"'.format(filter_config['type']))
f.validate(filter_config)
return True
def test(filter_config, **kwargs):
"""
Run validation and also gather the candidates for the given config
"""
data = {
'errors': [],
'candidates': {
'count': None,
'sample': None,
}
}
try:
validate(filter_config)
except ValidationError as e:
data['errors'] = [e.message]
return data
candidates = run([filter_config], **kwargs)
data['candidates']['count'] = candidates.count()
data['candidates']['sample'] = candidates[:10]
return data
def clean_config(filter_config):
f = registry[filter_config['type']]
return f.clean_config(filter_config)
class RadioFilter(object):
help_text = None
label = None
fields = []
expose_in_api = True
def get_query(self, candidates, **kwargs):
return candidates
def clean_config(self, filter_config):
return filter_config
def validate(self, config):
operator = config.get('operator', 'and')
try:
assert operator in ['or', 'and']
except AssertionError:
raise ValidationError(
'Invalid operator "{}"'.format(config['operator']))
@registry.register
class GroupFilter(RadioFilter):
code = 'group'
expose_in_api = False
def get_query(self, candidates, filters, **kwargs):
if not filters:
return
final_query = None
for filter_config in filters:
f = registry[filter_config['type']]
conf = collections.ChainMap(filter_config, kwargs)
query = f.get_query(candidates, **conf)
if filter_config.get('not', False):
query = ~query
if not final_query:
final_query = query
else:
operator = filter_config.get('operator', 'and')
if operator == 'and':
final_query &= query
elif operator == 'or':
final_query |= query
else:
raise ValueError(
'Invalid query operator "{}"'.format(operator))
return final_query
def validate(self, config):
super().validate(config)
for fc in config['filters']:
registry[fc['type']].validate(fc)
@registry.register
class ArtistFilter(RadioFilter):
code = 'artist'
label = 'Artist'
help_text = 'Select tracks for a given artist'
fields = [
{
'name': 'ids',
'type': 'list',
'subtype': 'number',
'autocomplete': reverse_lazy('api:v1:artists-search'),
'autocomplete_qs': 'query={query}',
'autocomplete_fields': {'name': 'name', 'value': 'id'},
'label': 'Artist',
'placeholder': 'Select artists'
}
]
def clean_config(self, filter_config):
filter_config = super().clean_config(filter_config)
filter_config['ids'] = sorted(filter_config['ids'])
names = models.Artist.objects.filter(
pk__in=filter_config['ids']
).order_by('id').values_list('name', flat=True)
filter_config['names'] = list(names)
return filter_config
def get_query(self, candidates, ids, **kwargs):
return Q(artist__pk__in=ids)
def validate(self, config):
super().validate(config)
try:
pks = models.Artist.objects.filter(
pk__in=config['ids']).values_list('pk', flat=True)
diff = set(config['ids']) - set(pks)
assert len(diff) == 0
except KeyError:
raise ValidationError('You must provide an id')
except AssertionError:
raise ValidationError(
'No artist matching ids "{}"'.format(diff))
@registry.register
class TagFilter(RadioFilter):
code = 'tag'
fields = [
{
'name': 'names',
'type': 'list',
'subtype': 'string',
'autocomplete': reverse_lazy('api:v1:tags-list'),
'autocomplete_qs': '',
'autocomplete_fields': {'remoteValues': 'results', 'name': 'name', 'value': 'slug'},
'autocomplete_qs': 'query={query}',
'label': 'Tags',
'placeholder': 'Select tags'
}
]
help_text = 'Select tracks with a given tag'
label = 'Tag'
def get_query(self, candidates, names, **kwargs):
return Q(tags__slug__in=names)
import django_filters
from . import models
class RadioFilter(django_filters.FilterSet):
class Meta:
model = models.Radio
fields = {
'name': ['exact', 'iexact', 'startswith', 'icontains']
}
# Generated by Django 2.0 on 2018-01-07 18:13
from django.conf import settings
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('radios', '0003_auto_20160521_1708'),
]
operations = [
migrations.CreateModel(
name='Radio',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('description', models.TextField(blank=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('is_public', models.BooleanField(default=False)),
('version', models.PositiveIntegerField(default=0)),
('config', django.contrib.postgres.fields.jsonb.JSONField()),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='radios', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='radiosession',
name='custom_radio',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='radios.Radio'),
),
]
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.contrib.postgres.fields import JSONField
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from funkwhale_api.music.models import Track from funkwhale_api.music.models import Track
from . import filters
class Radio(models.Model):
CONFIG_VERSION = 0
user = models.ForeignKey(
'users.User',
related_name='radios',
null=True,
blank=True,
on_delete=models.CASCADE)
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
creation_date = models.DateTimeField(default=timezone.now)
is_public = models.BooleanField(default=False)
version = models.PositiveIntegerField(default=0)
config = JSONField()
def get_candidates(self):
return filters.run(self.config)
class RadioSession(models.Model): class RadioSession(models.Model):
user = models.ForeignKey( user = models.ForeignKey(
'users.User', 'users.User',
...@@ -15,6 +38,12 @@ class RadioSession(models.Model): ...@@ -15,6 +38,12 @@ class RadioSession(models.Model):
on_delete=models.CASCADE) on_delete=models.CASCADE)
session_key = models.CharField(max_length=100, null=True, blank=True) session_key = models.CharField(max_length=100, null=True, blank=True)
radio_type = models.CharField(max_length=50) radio_type = models.CharField(max_length=50)
custom_radio = models.ForeignKey(
Radio,
related_name='sessions',
null=True,
blank=True,
on_delete=models.CASCADE)
creation_date = models.DateTimeField(default=timezone.now) creation_date = models.DateTimeField(default=timezone.now)
related_object_content_type = models.ForeignKey( related_object_content_type = models.ForeignKey(
ContentType, ContentType,
...@@ -51,6 +80,7 @@ class RadioSession(models.Model): ...@@ -51,6 +80,7 @@ class RadioSession(models.Model):
from . import radios from . import radios
return registry[self.radio_type](session=self) return registry[self.radio_type](session=self)
class RadioSessionTrack(models.Model): class RadioSessionTrack(models.Model):
session = models.ForeignKey( session = models.ForeignKey(
RadioSession, related_name='session_tracks', on_delete=models.CASCADE) RadioSession, related_name='session_tracks', on_delete=models.CASCADE)
......
import random import random
from rest_framework import serializers
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from taggit.models import Tag from taggit.models import Tag
from funkwhale_api.users.models import User from funkwhale_api.users.models import User
from funkwhale_api.music.models import Track, Artist from funkwhale_api.music.models import Track, Artist
from . import filters
from . import models from . import models
from .registries import registry from .registries import registry
class SimpleRadio(object): class SimpleRadio(object):
def clean(self, instance): def clean(self, instance):
...@@ -50,7 +54,7 @@ class SessionRadio(SimpleRadio): ...@@ -50,7 +54,7 @@ class SessionRadio(SimpleRadio):
def filter_from_session(self, queryset): def filter_from_session(self, queryset):
already_played = self.session.session_tracks.all().values_list('track', flat=True) already_played = self.session.session_tracks.all().values_list('track', flat=True)
queryset = queryset.exclude(pk__in=list(already_played)) queryset = queryset.exclude(pk__in=already_played)
return queryset return queryset
def pick(self, **kwargs): def pick(self, **kwargs):
...@@ -64,6 +68,10 @@ class SessionRadio(SimpleRadio): ...@@ -64,6 +68,10 @@ class SessionRadio(SimpleRadio):
self.session.add(choice) self.session.add(choice)
return picked_choices return picked_choices
def validate_session(self, data, **context):
return data
@registry.register(name='random') @registry.register(name='random')
class RandomRadio(SessionRadio): class RandomRadio(SessionRadio):
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
...@@ -83,6 +91,37 @@ class FavoritesRadio(SessionRadio): ...@@ -83,6 +91,37 @@ class FavoritesRadio(SessionRadio):
return Track.objects.filter(pk__in=track_ids) return Track.objects.filter(pk__in=track_ids)
@registry.register(name='custom')
class CustomRadio(SessionRadio):
def get_queryset_kwargs(self):
kwargs = super().get_queryset_kwargs()
kwargs['user'] = self.session.user
kwargs['custom_radio'] = self.session.custom_radio
return kwargs
def get_queryset(self, **kwargs):
return filters.run(kwargs['custom_radio'].config)
def validate_session(self, data, **context):
data = super().validate_session(data, **context)
try:
user = data['user']
except KeyError:
user = context['user']
try:
assert (
data['custom_radio'].user == user or
data['custom_radio'].is_public)
except KeyError:
raise serializers.ValidationError(
'You must provide a custom radio')
except AssertionError:
raise serializers.ValidationError(
"You don't have access to this radio")
return data
class RelatedObjectRadio(SessionRadio): class RelatedObjectRadio(SessionRadio):
"""Abstract radio related to an object (tag, artist, user...)""" """Abstract radio related to an object (tag, artist, user...)"""
......
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.music.serializers import TrackSerializerNested from funkwhale_api.music.serializers import TrackSerializerNested
from . import filters
from . import models from . import models
from .radios import registry
class FilterSerializer(serializers.Serializer):
type = serializers.CharField(source='code')
label = serializers.CharField()
help_text = serializers.CharField()