Verified Commit 9ecb2bdf authored by Agate's avatar Agate 💬

Merge branch 'release/0.3.3'

parents 3a19505c 56c22027
Pipeline #365 passed with stages
in 3 minutes and 33 seconds
......@@ -11,11 +11,14 @@ stages:
- deploy
test_api:
services:
- postgres:9.4
stage: test
image: funkwhale/funkwhale:base
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/pip-cache"
DATABASE_URL: "sqlite://"
DATABASE_URL: "postgresql://postgres@postgres/postgres"
before_script:
- python3 -m venv --copies virtualenv
- source virtualenv/bin/activate
......
......@@ -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
------------------
......
from .common import * # noqa
SECRET_KEY = env("DJANGO_SECRET_KEY", default='test')
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
# Mail settings
# ------------------------------------------------------------------------------
......
# -*- 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('.')])
......@@ -262,6 +262,16 @@ class Lyrics(models.Model):
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):
title = models.CharField(max_length=255)
artist = models.ForeignKey(
......@@ -302,6 +312,7 @@ class Track(APIModelMixin):
import_hooks = [
import_tags
]
objects = TrackQuerySet.as_manager()
tags = TaggableManager()
class Meta:
......
......@@ -116,13 +116,7 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
"""
A simple ViewSet for viewing and editing accounts.
"""
queryset = (models.Track.objects.all()
.select_related()
.select_related('album__artist')
.prefetch_related(
'tags',
'files',
'artist__albums__tracks__tags'))
queryset = (models.Track.objects.all().for_nested_serialization())
serializer_class = serializers.TrackSerializerNested
permission_classes = [ConditionalAuthentication]
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.utils import timezone
from django.core.exceptions import ValidationError
from django.contrib.postgres.fields import JSONField
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
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):
user = models.ForeignKey(
'users.User',
......@@ -15,6 +38,12 @@ class RadioSession(models.Model):
on_delete=models.CASCADE)
session_key = models.CharField(max_length=100, null=True, blank=True)
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)
related_object_content_type = models.ForeignKey(
ContentType,
......@@ -51,6 +80,7 @@ class RadioSession(models.Model):
from . import radios
return registry[self.radio_type](session=self)
class RadioSessionTrack(models.Model):
session = models.ForeignKey(
RadioSession, related_name='session_tracks', on_delete=models.CASCADE)
......
import random
from rest_framework import serializers
from django.core.exceptions import ValidationError
from taggit.models import Tag
from funkwhale_api.users.models import User
from funkwhale_api.music.models import Track, Artist
from . import filters
from . import models
from .registries import registry
class SimpleRadio(object):
def clean(self, instance):
......@@ -50,7 +54,7 @@ class SessionRadio(SimpleRadio):
def filter_from_session(self, queryset):
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
def pick(self, **kwargs):
......@@ -64,6 +68,10 @@ class SessionRadio(SimpleRadio):
self.session.add(choice)
return picked_choices
def validate_session(self, data, **context):
return data
@registry.register(name='random')
class RandomRadio(SessionRadio):
def get_queryset(self, **kwargs):
......@@ -83,6 +91,37 @@ class FavoritesRadio(SessionRadio):
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):
"""Abstract radio related to an object (tag, artist, user...)"""
......
from rest_framework import serializers
from funkwhale_api.music.serializers import TrackSerializerNested
from . import filters
from . import models
from .radios import registry
class FilterSerializer(serializers.Serializer):
type = serializers.CharField(source='code')
label = serializers.CharField()
help_text = serializers.CharField()
fields = serializers.ReadOnlyField()
class RadioSerializer(serializers.ModelSerializer):
class Meta:
model = models.Radio
fields = (
'id',
'is_public',
'name',
'creation_date',
'user',
'config',
'description')
read_only_fields = ('user', 'creation_date')
def save(self, **kwargs):
kwargs['config'] = [
filters.registry[f['type']].clean_config(f)
for f in self.validated_data['config']
]
return super().save(**kwargs)
class RadioSessionTrackSerializerCreate(serializers.ModelSerializer):
class Meta:
......@@ -21,7 +52,18 @@ class RadioSessionTrackSerializer(serializers.ModelSerializer):
class RadioSessionSerializer(serializers.ModelSerializer):
class Meta:
model = models.RadioSession
fields = ('id', 'radio_type', 'related_object_id', 'user', 'creation_date', 'session_key')
fields = (
'id',
'radio_type',
'related_object_id',
'user',
'creation_date',
'custom_radio',
'session_key')
def validate(self, data):
registry[data['radio_type']]().validate_session(data, **self.context)
return data
def create(self, validated_data):
if self.context.get('user'):
......@@ -29,7 +71,6 @@ class RadioSessionSerializer(serializers.ModelSerializer):
else:
validated_data['session_key'] = self.context['session_key']
if validated_data.get('related_object_id'):
from . import radios
radio = radios.registry[validated_data['radio_type']]()
radio = registry[validated_data['radio_type']]()
validated_data['related_object'] = radio.get_related_object(validated_data['related_object_id'])
return super().create(validated_data)
......@@ -4,6 +4,7 @@ from . import views
from rest_framework import routers
router = routers.SimpleRouter()
router.register(r'sessions', views.RadioSessionViewSet, 'sessions')
router.register(r'radios', views.RadioViewSet, 'radios')
router.register(r'tracks', views.RadioSessionTrackViewSet, 'tracks')
......
from django.db.models import Q
from django.http import Http404
from rest_framework import generics, mixins, viewsets
from rest_framework import status
from rest_framework.response import Response
from rest_framework.decorators import detail_route
from rest_framework.decorators import detail_route, list_route
from funkwhale_api.music.serializers import TrackSerializerNested
from funkwhale_api.common.permissions import ConditionalAuthentication
from . import models
from . import filters
from . import filtersets
from . import serializers
class RadioViewSet(
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet):
serializer_class = serializers.RadioSerializer
permission_classes = [ConditionalAuthentication]
filter_class = filtersets.RadioFilter
def get_queryset(self):
query = Q(is_public=True)
if self.request.user.is_authenticated:
query |= Q(user=self.request.user)
return models.Radio.objects.filter(query)
def perform_create(