diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d77d91f17284c753c9087de78b840213765da43e..91b11e8bd174a1262d0cc70fa6dc4da077152d6e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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 diff --git a/CHANGELOG b/CHANGELOG index 9f3551d4926a999cb563f1b4ab2545e9d4e48b55..c854bf0e7d3c72746cc859c6d267fe4f5982a73a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -5,6 +5,8 @@ Changelog 0.3.3 (Unreleased) ------------------ +- Users can now create their own dynamic radios (#51) + 0.3.2 ------------------ diff --git a/api/config/settings/test.py b/api/config/settings/test.py index db7b21415c6583a17472a9b84a61d9793e6cc0fa..a0b6b2503a1762295c960cae50c06c65c50a4d76 100644 --- a/api/config/settings/test.py +++ b/api/config/settings/test.py @@ -1,11 +1,5 @@ from .common import * # noqa SECRET_KEY = env("DJANGO_SECRET_KEY", default='test') -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', - } -} # Mail settings # ------------------------------------------------------------------------------ diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index f919ff0eb3b0b0b6619699b5c65967096bbcec18..f8373ab4d0f286dae34da01dfbffe8967ed9f9a0 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -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: diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index dd295ae79f66a2192f9770a24c0b2fb53c5ea49e..2395454c46429a5b48ccee7fe22e25bf39808318 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -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'] diff --git a/api/funkwhale_api/radios/factories.py b/api/funkwhale_api/radios/factories.py new file mode 100644 index 0000000000000000000000000000000000000000..6a80323beaba10032d98aa5bf80f23b86fa619b4 --- /dev/null +++ b/api/funkwhale_api/radios/factories.py @@ -0,0 +1,34 @@ +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' diff --git a/api/funkwhale_api/radios/filters.py b/api/funkwhale_api/radios/filters.py new file mode 100644 index 0000000000000000000000000000000000000000..344a4dabff3fb29b1d0fc7d5b8f4bda480c786ae --- /dev/null +++ b/api/funkwhale_api/radios/filters.py @@ -0,0 +1,201 @@ +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) diff --git a/api/funkwhale_api/radios/filtersets.py b/api/funkwhale_api/radios/filtersets.py new file mode 100644 index 0000000000000000000000000000000000000000..49f471373e55cccdd58d1d574cdd0961e3bb10f7 --- /dev/null +++ b/api/funkwhale_api/radios/filtersets.py @@ -0,0 +1,12 @@ +import django_filters + +from . import models + + +class RadioFilter(django_filters.FilterSet): + + class Meta: + model = models.Radio + fields = { + 'name': ['exact', 'iexact', 'startswith', 'icontains'] + } diff --git a/api/funkwhale_api/radios/migrations/0004_auto_20180107_1813.py b/api/funkwhale_api/radios/migrations/0004_auto_20180107_1813.py new file mode 100644 index 0000000000000000000000000000000000000000..fc768b303365e3146054b7f05749d5dd0fc9d259 --- /dev/null +++ b/api/funkwhale_api/radios/migrations/0004_auto_20180107_1813.py @@ -0,0 +1,36 @@ +# 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'), + ), + ] diff --git a/api/funkwhale_api/radios/models.py b/api/funkwhale_api/radios/models.py index 984b34a1f7d86230c6e9186c123246286f0c6eac..d9c12534c03f779d497a1a9e7ae43e12cc3bb32f 100644 --- a/api/funkwhale_api/radios/models.py +++ b/api/funkwhale_api/radios/models.py @@ -1,11 +1,34 @@ 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) diff --git a/api/funkwhale_api/radios/radios.py b/api/funkwhale_api/radios/radios.py index 43819b9c4e1079a98e0bc47a1ab50855f6a12b07..585bbbe334f0c3ca297d9ada8883b48bb848354c 100644 --- a/api/funkwhale_api/radios/radios.py +++ b/api/funkwhale_api/radios/radios.py @@ -1,11 +1,15 @@ 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...)""" diff --git a/api/funkwhale_api/radios/serializers.py b/api/funkwhale_api/radios/serializers.py index c389e06db57b854ac15647bed03a7c4c4b15c328..520e98652f4238de071ae49f419525f745e97785 100644 --- a/api/funkwhale_api/radios/serializers.py +++ b/api/funkwhale_api/radios/serializers.py @@ -1,8 +1,39 @@ 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) diff --git a/api/funkwhale_api/radios/urls.py b/api/funkwhale_api/radios/urls.py index 8c31df0937c748639fcf524bd234ec7a966309b0..d84615ca57ceba484ba5b6580804948294b2fc4f 100644 --- a/api/funkwhale_api/radios/urls.py +++ b/api/funkwhale_api/radios/urls.py @@ -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') diff --git a/api/funkwhale_api/radios/views.py b/api/funkwhale_api/radios/views.py index 9243d6a90cc5c2ed7a8c420d2f1cc5cea5a9d41d..42652644224446ccade4d43b36c995581bb15783 100644 --- a/api/funkwhale_api/radios/views.py +++ b/api/funkwhale_api/radios/views.py @@ -1,14 +1,72 @@ +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(self, serializer): + return serializer.save(user=self.request.user) + + def perform_update(self, serializer): + if serializer.instance.user != self.request.user: + raise Http404 + return serializer.save(user=self.request.user) + + @list_route(methods=['get']) + def filters(self, request, *args, **kwargs): + serializer = serializers.FilterSerializer( + filters.registry.exposed_filters, many=True) + return Response(serializer.data) + + @list_route(methods=['post']) + def validate(self, request, *args, **kwargs): + try: + f_list = request.data['filters'] + except KeyError: + return Response( + {'error': 'You must provide a filters list'}, status=400) + data = { + 'filters': [] + } + for f in f_list: + results = filters.test(f) + if results['candidates']['sample']: + qs = results['candidates']['sample'].for_nested_serialization() + results['candidates']['sample'] = TrackSerializerNested( + qs, many=True).data + data['filters'].append(results) + + return Response(data) + + class RadioSessionViewSet(mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): diff --git a/api/funkwhale_api/taskapp/celery.py b/api/funkwhale_api/taskapp/celery.py index 12e5b24525baf753c56329a3bb12c808ae7f073d..60b09bece8a6bb2e7aa457383602279431b0575e 100644 --- a/api/funkwhale_api/taskapp/celery.py +++ b/api/funkwhale_api/taskapp/celery.py @@ -27,11 +27,12 @@ class CeleryConfig(AppConfig): app.autodiscover_tasks(lambda: settings.INSTALLED_APPS, force=True) -def require_instance(model_or_qs, parameter_name): +def require_instance(model_or_qs, parameter_name, id_kwarg_name=None): def decorator(function): @functools.wraps(function) def inner(*args, **kwargs): - pk = kwargs.pop('_'.join([parameter_name, 'id'])) + kw = id_kwarg_name or '_'.join([parameter_name, 'id']) + pk = kwargs.pop(kw) try: instance = model_or_qs.get(pk=pk) except AttributeError: diff --git a/api/test.yml b/api/test.yml index bd3a98e457de5cf7610caf1ac5cef4dfd520715f..c59ce45bbbbaf2357a2b50782bc004f51b584cce 100644 --- a/api/test.yml +++ b/api/test.yml @@ -1,8 +1,15 @@ -test: - dockerfile: docker/Dockerfile.test - build: . - command: pytest - volumes: - - .:/app - environment: - - "DATABASE_URL=sqlite://" +version: '2' +services: + test: + build: + dockerfile: docker/Dockerfile.test + context: . + command: pytest + depends_on: + - postgres + volumes: + - .:/app + environment: + - "DATABASE_URL=postgresql://postgres@postgres/postgres" + postgres: + image: postgres diff --git a/api/tests/radios/test_api.py b/api/tests/radios/test_api.py new file mode 100644 index 0000000000000000000000000000000000000000..25c099014e91edea53a00850af44ba967480a7bd --- /dev/null +++ b/api/tests/radios/test_api.py @@ -0,0 +1,159 @@ +import json +import pytest + +from django.urls import reverse + +from funkwhale_api.music.serializers import TrackSerializerNested +from funkwhale_api.radios import filters +from funkwhale_api.radios import serializers + + +def test_can_list_config_options(logged_in_client): + url = reverse('api:v1:radios:radios-filters') + response = logged_in_client.get(url) + + assert response.status_code == 200 + + payload = json.loads(response.content.decode('utf-8')) + + expected = [f for f in filters.registry.values() if f.expose_in_api] + assert len(payload) == len(expected) + + +def test_can_validate_config(logged_in_client, factories): + artist1 = factories['music.Artist']() + artist2 = factories['music.Artist']() + factories['music.Track'].create_batch(3, artist=artist1) + factories['music.Track'].create_batch(3, artist=artist2) + candidates = artist1.tracks.order_by('pk') + f = { + 'filters': [ + {'type': 'artist', 'ids': [artist1.pk]} + ] + } + url = reverse('api:v1:radios:radios-validate') + response = logged_in_client.post( + url, + json.dumps(f), + content_type="application/json") + + assert response.status_code == 200 + + payload = json.loads(response.content.decode('utf-8')) + + expected = { + 'count': candidates.count(), + 'sample': TrackSerializerNested(candidates, many=True).data + } + assert payload['filters'][0]['candidates'] == expected + assert payload['filters'][0]['errors'] == [] + + +def test_can_validate_config_with_wrong_config(logged_in_client, factories): + f = { + 'filters': [ + {'type': 'artist', 'ids': [999]} + ] + } + url = reverse('api:v1:radios:radios-validate') + response = logged_in_client.post( + url, + json.dumps(f), + content_type="application/json") + + assert response.status_code == 200 + + payload = json.loads(response.content.decode('utf-8')) + + expected = { + 'count': None, + 'sample': None + } + assert payload['filters'][0]['candidates'] == expected + assert len(payload['filters'][0]['errors']) == 1 + + +def test_saving_radio_sets_user(logged_in_client, factories): + artist = factories['music.Artist']() + f = { + 'name': 'Test', + 'config': [ + {'type': 'artist', 'ids': [artist.pk]} + ] + } + url = reverse('api:v1:radios:radios-list') + response = logged_in_client.post( + url, + json.dumps(f), + content_type="application/json") + + assert response.status_code == 201 + + radio = logged_in_client.user.radios.latest('id') + assert radio.name == 'Test' + assert radio.user == logged_in_client.user + + +def test_user_can_detail_his_radio(logged_in_client, factories): + radio = factories['radios.Radio'](user=logged_in_client.user) + url = reverse('api:v1:radios:radios-detail', kwargs={'pk': radio.pk}) + response = logged_in_client.get(url) + + assert response.status_code == 200 + + +def test_user_can_detail_public_radio(logged_in_client, factories): + radio = factories['radios.Radio'](is_public=True) + url = reverse('api:v1:radios:radios-detail', kwargs={'pk': radio.pk}) + response = logged_in_client.get(url) + + assert response.status_code == 200 + + +def test_user_cannot_detail_someone_else_radio(logged_in_client, factories): + radio = factories['radios.Radio'](is_public=False) + url = reverse('api:v1:radios:radios-detail', kwargs={'pk': radio.pk}) + response = logged_in_client.get(url) + + assert response.status_code == 404 + + +def test_user_can_edit_his_radio(logged_in_client, factories): + radio = factories['radios.Radio'](user=logged_in_client.user) + url = reverse('api:v1:radios:radios-detail', kwargs={'pk': radio.pk}) + response = logged_in_client.put( + url, + json.dumps({'name': 'new', 'config': []}), + content_type="application/json") + + radio.refresh_from_db() + assert response.status_code == 200 + assert radio.name == 'new' + + +def test_user_cannot_edit_someone_else_radio(logged_in_client, factories): + radio = factories['radios.Radio']() + url = reverse('api:v1:radios:radios-detail', kwargs={'pk': radio.pk}) + response = logged_in_client.put( + url, + json.dumps({'name': 'new', 'config': []}), + content_type="application/json") + + assert response.status_code == 404 + + +def test_clean_config_is_called_on_serializer_save(mocker, factories): + user = factories['users.User']() + artist = factories['music.Artist']() + data= { + 'name': 'Test', + 'config': [ + {'type': 'artist', 'ids': [artist.pk]} + ] + } + spied = mocker.spy(filters.registry['artist'], 'clean_config') + serializer = serializers.RadioSerializer(data=data) + assert serializer.is_valid() + instance = serializer.save(user=user) + spied.assert_called_once_with(data['config'][0]) + assert instance.config[0]['names'] == [artist.name] diff --git a/api/tests/radios/test_filters.py b/api/tests/radios/test_filters.py new file mode 100644 index 0000000000000000000000000000000000000000..27166b4ab27d9f6e00dad4da69cea75253844e4e --- /dev/null +++ b/api/tests/radios/test_filters.py @@ -0,0 +1,161 @@ +import pytest + +from django.core.exceptions import ValidationError + +from funkwhale_api.music.models import Track +from funkwhale_api.radios import filters + + +@filters.registry.register +class NoopFilter(filters.RadioFilter): + code = 'noop' + def get_query(self, candidates, **kwargs): + return + + +def test_most_simple_radio_does_not_filter_anything(factories): + tracks = factories['music.Track'].create_batch(3) + radio = factories['radios.Radio'](config=[{'type': 'noop'}]) + + assert radio.version == 0 + assert radio.get_candidates().count() == 3 + + + +def test_filter_can_use_custom_queryset(factories): + tracks = factories['music.Track'].create_batch(3) + candidates = Track.objects.filter(pk=tracks[0].pk) + + qs = filters.run([{'type': 'noop'}], candidates=candidates) + assert qs.count() == 1 + assert qs.first() == tracks[0] + + +def test_filter_on_tag(factories): + tracks = factories['music.Track'].create_batch(3, tags=['metal']) + factories['music.Track'].create_batch(3, tags=['pop']) + expected = tracks + f = [ + {'type': 'tag', 'names': ['metal']} + ] + + candidates = filters.run(f) + assert list(candidates.order_by('pk')) == expected + + +def test_filter_on_artist(factories): + artist1 = factories['music.Artist']() + artist2 = factories['music.Artist']() + factories['music.Track'].create_batch(3, artist=artist1) + factories['music.Track'].create_batch(3, artist=artist2) + expected = list(artist1.tracks.order_by('pk')) + f = [ + {'type': 'artist', 'ids': [artist1.pk]} + ] + + candidates = filters.run(f) + assert list(candidates.order_by('pk')) == expected + + +def test_can_combine_with_or(factories): + artist1 = factories['music.Artist']() + artist2 = factories['music.Artist']() + artist3 = factories['music.Artist']() + factories['music.Track'].create_batch(3, artist=artist1) + factories['music.Track'].create_batch(3, artist=artist2) + factories['music.Track'].create_batch(3, artist=artist3) + expected = Track.objects.exclude(artist=artist3).order_by('pk') + f = [ + {'type': 'artist', 'ids': [artist1.pk]}, + {'type': 'artist', 'ids': [artist2.pk], 'operator': 'or'}, + ] + + candidates = filters.run(f) + assert list(candidates.order_by('pk')) == list(expected) + + +def test_can_combine_with_and(factories): + artist1 = factories['music.Artist']() + artist2 = factories['music.Artist']() + metal_tracks = factories['music.Track'].create_batch( + 2, artist=artist1, tags=['metal']) + factories['music.Track'].create_batch(2, artist=artist1, tags=['pop']) + factories['music.Track'].create_batch(3, artist=artist2) + expected = metal_tracks + f = [ + {'type': 'artist', 'ids': [artist1.pk]}, + {'type': 'tag', 'names': ['metal'], 'operator': 'and'}, + ] + + candidates = filters.run(f) + assert list(candidates.order_by('pk')) == list(expected) + + +def test_can_negate(factories): + artist1 = factories['music.Artist']() + artist2 = factories['music.Artist']() + factories['music.Track'].create_batch(3, artist=artist1) + factories['music.Track'].create_batch(3, artist=artist2) + expected = artist2.tracks.order_by('pk') + f = [ + {'type': 'artist', 'ids': [artist1.pk], 'not': True}, + ] + + candidates = filters.run(f) + assert list(candidates.order_by('pk')) == list(expected) + + +def test_can_group(factories): + artist1 = factories['music.Artist']() + artist2 = factories['music.Artist']() + factories['music.Track'].create_batch(2, artist=artist1) + t1 = factories['music.Track'].create_batch( + 2, artist=artist1, tags=['metal']) + factories['music.Track'].create_batch(2, artist=artist2) + t2 = factories['music.Track'].create_batch( + 2, artist=artist2, tags=['metal']) + factories['music.Track'].create_batch(2, tags=['metal']) + expected = t1 + t2 + f = [ + {'type': 'tag', 'names': ['metal']}, + {'type': 'group', 'operator': 'and', 'filters': [ + {'type': 'artist', 'ids': [artist1.pk], 'operator': 'or'}, + {'type': 'artist', 'ids': [artist2.pk], 'operator': 'or'}, + ]} + ] + + candidates = filters.run(f) + assert list(candidates.order_by('pk')) == list(expected) + + +def test_artist_filter_clean_config(factories): + artist1 = factories['music.Artist']() + artist2 = factories['music.Artist']() + + config = filters.clean_config( + {'type': 'artist', 'ids': [artist2.pk, artist1.pk]}) + + expected = { + 'type': 'artist', + 'ids': [artist1.pk, artist2.pk], + 'names': [artist1.name, artist2.name] + } + assert filters.clean_config(config) == expected + + +def test_can_check_artist_filter(factories): + artist = factories['music.Artist']() + + assert filters.validate({'type': 'artist', 'ids': [artist.pk]}) + with pytest.raises(ValidationError): + filters.validate({'type': 'artist', 'ids': [artist.pk + 1]}) + + +def test_can_check_operator(): + assert filters.validate( + {'type': 'group', 'operator': 'or', 'filters': []}) + assert filters.validate( + {'type': 'group', 'operator': 'and', 'filters': []}) + with pytest.raises(ValidationError): + assert filters.validate( + {'type': 'group', 'operator': 'nope', 'filters': []}) diff --git a/api/tests/test_radios.py b/api/tests/radios/test_radios.py similarity index 77% rename from api/tests/test_radios.py rename to api/tests/radios/test_radios.py index d67611123ce0febb2623345d4fe388304b86f7bf..b00bfcd79ce3b917a26698ac4d868dea39521428 100644 --- a/api/tests/test_radios.py +++ b/api/tests/radios/test_radios.py @@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError from funkwhale_api.radios import radios from funkwhale_api.radios import models +from funkwhale_api.radios import serializers from funkwhale_api.favorites.models import TrackFavorite @@ -50,9 +51,9 @@ def test_can_pick_by_weight(): def test_can_get_choices_for_favorites_radio(factories): - tracks = factories['music.Track'].create_batch(100) + tracks = factories['music.Track'].create_batch(10) user = factories['users.User']() - for i in range(20): + for i in range(5): TrackFavorite.add(track=random.choice(tracks), user=user) radio = radios.FavoritesRadio() @@ -63,11 +64,54 @@ def test_can_get_choices_for_favorites_radio(factories): for favorite in user.track_favorites.all(): assert favorite.track in choices - for i in range(20): + for i in range(5): pick = radio.pick(user=user) assert pick in choices +def test_can_get_choices_for_custom_radio(factories): + artist = factories['music.Artist']() + tracks = factories['music.Track'].create_batch(5, artist=artist) + wrong_tracks = factories['music.Track'].create_batch(5) + session = factories['radios.CustomRadioSession']( + custom_radio__config=[{'type': 'artist', 'ids': [artist.pk]}] + ) + choices = session.radio.get_choices() + + expected = [t.pk for t in tracks] + assert list(choices.values_list('id', flat=True)) == expected + + +def test_cannot_start_custom_radio_if_not_owner_or_not_public(factories): + user = factories['users.User']() + artist = factories['music.Artist']() + radio = factories['radios.Radio']( + config=[{'type': 'artist', 'ids': [artist.pk]}] + ) + serializer = serializers.RadioSessionSerializer( + data={ + 'radio_type': 'custom', 'custom_radio': radio.pk, 'user': user.pk} + ) + message = "You don't have access to this radio" + assert not serializer.is_valid() + assert message in serializer.errors['non_field_errors'] + + +def test_can_start_custom_radio_from_api(logged_in_client, factories): + artist = factories['music.Artist']() + radio = factories['radios.Radio']( + config=[{'type': 'artist', 'ids': [artist.pk]}], + user=logged_in_client.user + ) + url = reverse('api:v1:radios:sessions-list') + response = logged_in_client.post( + url, {'radio_type': 'custom', 'custom_radio': radio.pk}) + assert response.status_code == 201 + session = radio.sessions.latest('id') + assert session.radio_type == 'custom' + assert session.user == logged_in_client.user + + def test_can_use_radio_session_to_filter_choices(factories): tracks = factories['music.Track'].create_batch(30) user = factories['users.User']() diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue index f5303d88c8e14d8a959d541903e23e13ce100528..c27313dc36d2a9d08c0cbba2c0e704c7527c5063 100644 --- a/front/src/components/library/Library.vue +++ b/front/src/components/library/Library.vue @@ -3,6 +3,7 @@ <div class="ui secondary pointing menu"> <router-link class="ui item" to="/library" exact>Browse</router-link> <router-link class="ui item" to="/library/artists" exact>Artists</router-link> + <router-link class="ui item" to="/library/radios" exact>Radios</router-link> <div class="ui secondary right menu"> <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>Import</router-link> <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches</router-link> diff --git a/front/src/components/library/Radios.vue b/front/src/components/library/Radios.vue new file mode 100644 index 0000000000000000000000000000000000000000..409b6b6741137f6fef494cd42a11b33648498658 --- /dev/null +++ b/front/src/components/library/Radios.vue @@ -0,0 +1,164 @@ +<template> + <div> + <div class="ui vertical stripe segment"> + <h2 class="ui header">Browsing radios</h2> + <router-link class="ui green basic button" to="/library/radios/build" exact>Create your own radio</router-link> + <div class="ui hidden divider"></div> + <div :class="['ui', {'loading': isLoading}, 'form']"> + <div class="fields"> + <div class="field"> + <label>Search</label> + <input type="text" v-model="query" placeholder="Enter a radio name..."/> + </div> + <div class="field"> + <label>Ordering</label> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ option[1] }} + </option> + </select> + </div> + <div class="field"> + <label>Ordering direction</label> + <select class="ui dropdown" v-model="orderingDirection"> + <option value="">Ascending</option> + <option value="-">Descending</option> + </select> + </div> + <div class="field"> + <label>Results per page</label> + <select class="ui dropdown" v-model="paginateBy"> + <option :value="parseInt(12)">12</option> + <option :value="parseInt(25)">25</option> + <option :value="parseInt(50)">50</option> + </select> + </div> + </div> + </div> + <div class="ui hidden divider"></div> + <div v-if="result" class="ui stackable three column grid"> + <div + v-if="result.results.length > 0" + v-for="radio in result.results" + :key="radio.id" + class="column"> + <radio-card class="fluid" type="custom" :custom-radio="radio"></radio-card> + </div> + </div> + <div class="ui center aligned basic segment"> + <pagination + v-if="result && result.results.length > 0" + @page-changed="selectPage" + :current="page" + :paginate-by="paginateBy" + :total="result.count" + ></pagination> + </div> + </div> + </div> +</template> + +<script> +import _ from 'lodash' +import $ from 'jquery' + +import config from '@/config' +import logger from '@/logging' + +import OrderingMixin from '@/components/mixins/Ordering' +import PaginationMixin from '@/components/mixins/Pagination' +import RadioCard from '@/components/radios/Card' +import Pagination from '@/components/Pagination' + +const FETCH_URL = config.API_URL + 'radios/radios/' + +export default { + mixins: [OrderingMixin, PaginationMixin], + props: { + defaultQuery: {type: String, required: false, default: ''} + }, + components: { + RadioCard, + Pagination + }, + data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') + return { + isLoading: true, + result: null, + page: parseInt(this.defaultPage), + query: this.defaultQuery, + paginateBy: parseInt(this.defaultPaginateBy || 12), + orderingDirection: defaultOrdering.direction, + ordering: defaultOrdering.field, + orderingOptions: [ + ['creation_date', 'Creation date'], + ['name', 'Name'] + ] + } + }, + created () { + this.fetchData() + }, + mounted () { + $('.ui.dropdown').dropdown() + }, + methods: { + updateQueryString: _.debounce(function () { + this.$router.replace({ + query: { + query: this.query, + page: this.page, + paginateBy: this.paginateBy, + ordering: this.getOrderingAsString() + } + }) + }, 500), + fetchData: _.debounce(function () { + var self = this + this.isLoading = true + let url = FETCH_URL + let params = { + page: this.page, + page_size: this.paginateBy, + name__icontains: this.query, + ordering: this.getOrderingAsString() + } + logger.default.debug('Fetching radios') + this.$http.get(url, {params: params}).then((response) => { + self.result = response.data + self.isLoading = false + }) + }, 500), + selectPage: function (page) { + this.page = page + } + }, + watch: { + page () { + this.updateQueryString() + this.fetchData() + }, + paginateBy () { + this.updateQueryString() + this.fetchData() + }, + ordering () { + this.updateQueryString() + this.fetchData() + }, + orderingDirection () { + this.updateQueryString() + this.fetchData() + }, + query () { + this.updateQueryString() + this.fetchData() + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/components/library/import/FileUpload.vue b/front/src/components/library/import/FileUpload.vue index 4681d79322727f9341682f5b8586bc0685001676..93ca75c3961d445f924bf920df7ef5e1da330ab3 100644 --- a/front/src/components/library/import/FileUpload.vue +++ b/front/src/components/library/import/FileUpload.vue @@ -93,18 +93,15 @@ export default { inputFile (newFile, oldFile) { if (newFile && !oldFile) { // add - console.log('add', newFile) if (!this.batch) { this.createBatch() } } if (newFile && oldFile) { // update - console.log('update', newFile) } if (!newFile && oldFile) { // remove - console.log('remove', oldFile) } }, createBatch () { diff --git a/front/src/components/library/radios/Builder.vue b/front/src/components/library/radios/Builder.vue new file mode 100644 index 0000000000000000000000000000000000000000..f58d5003fd5b7ac21adac9b77142fa96206e0284 --- /dev/null +++ b/front/src/components/library/radios/Builder.vue @@ -0,0 +1,221 @@ +<template> + <div class="ui vertical stripe segment"> + <div> + <div> + <h2 class="ui header">Builder</h2> + <p> + You can use this interface to build your own custom radio, which + will play tracks according to your criteria + </p> + <div class="ui form"> + <div class="inline fields"> + <div class="field"> + <label for="name">Radio name</label> + <input id="name" type="text" v-model="radioName" placeholder="My awesome radio" /> + </div> + <div class="field"> + <input id="public" type="checkbox" v-model="isPublic" /> + <label for="public">Display publicly</label> + </div> + <button :disabled="!canSave" @click="save" class="ui green button">Save</button> + <radio-button v-if="id" type="custom" :custom-radio-id="id"></radio-button> + </div> + </div> + <div class="ui form"> + <p>Add filters to customize your radio</p> + <div class="inline field"> + <select class="ui dropdown" v-model="currentFilterType"> + <option value="">Select a filter</option> + <option v-for="f in availableFilters" :value="f.type">{{ f.label }}</option> + </select> + <button :disabled="!currentFilterType" @click="add" class="ui button">Add filter</button> + </div> + <p v-if="currentFilter"> + {{ currentFilter.help_text }} + </p> + </div> + <table class="ui table"> + <thead> + <tr> + <th class="two wide">Filter name</th> + <th class="one wide">Exclude</th> + <th class="six wide">Config</th> + <th class="five wide">Candidates</th> + <th class="two wide">Actions</th> + </tr> + </thead> + <tbody> + <builder-filter + v-for="(f, index) in filters" + :key="(f, index, f.hash)" + :index="index" + @update-config="updateConfig" + @delete="deleteFilter" + :config="f.config" + :filter="f.filter"> + </builder-filter> + </tbody> + </table> + <template v-if="checkResult"> + <h3 class="ui header"> + {{ checkResult.candidates.count }} tracks matching combined filters + </h3> + <track-table v-if="checkResult.candidates.sample" :tracks="checkResult.candidates.sample"></track-table> + </template> + </div> + </div> + </div> +</template> +<script> +import config from '@/config' +import $ from 'jquery' +import _ from 'lodash' +import BuilderFilter from './Filter' +import TrackTable from '@/components/audio/track/Table' +import RadioButton from '@/components/radios/Button' + +export default { + props: { + id: {required: false} + }, + components: { + BuilderFilter, + TrackTable, + RadioButton + }, + data: function () { + return { + availableFilters: [], + currentFilterType: null, + filters: [], + checkResult: null, + radioName: '', + isPublic: true + } + }, + created: function () { + let self = this + this.fetchFilters().then(() => { + if (self.id) { + self.fetch() + } + }) + }, + mounted () { + $('.ui.dropdown').dropdown() + }, + methods: { + fetchFilters: function () { + let self = this + let url = config.API_URL + 'radios/radios/filters/' + return this.$http.get(url).then((response) => { + self.availableFilters = response.data + }) + }, + add () { + this.filters.push({ + config: {}, + filter: this.currentFilter, + hash: +new Date() + }) + this.fetchCandidates() + }, + updateConfig (index, field, value) { + this.filters[index].config[field] = value + this.fetchCandidates() + }, + deleteFilter (index) { + this.filters.splice(index, 1) + this.fetchCandidates() + }, + fetch: function () { + let self = this + let url = config.API_URL + 'radios/radios/' + this.id + '/' + this.$http.get(url).then((response) => { + self.filters = response.data.config.map(f => { + return { + config: f, + filter: this.availableFilters.filter(e => { return e.type === f.type })[0], + hash: +new Date() + } + }) + self.radioName = response.data.name + self.isPublic = response.data.is_public + }) + }, + fetchCandidates: function () { + let self = this + let url = config.API_URL + 'radios/radios/validate/' + let final = this.filters.map(f => { + let c = _.clone(f.config) + c.type = f.filter.type + return c + }) + final = { + 'filters': [ + {'type': 'group', filters: final} + ] + } + this.$http.post(url, final).then((response) => { + self.checkResult = response.data.filters[0] + }) + }, + save: function () { + let self = this + let final = this.filters.map(f => { + let c = _.clone(f.config) + c.type = f.filter.type + return c + }) + final = { + 'name': this.radioName, + 'is_public': this.isPublic, + 'config': final + } + if (this.id) { + let url = config.API_URL + 'radios/radios/' + this.id + '/' + this.$http.put(url, final).then((response) => { + }) + } else { + let url = config.API_URL + 'radios/radios/' + this.$http.post(url, final).then((response) => { + self.$router.push({ + name: 'library.radios.edit', + params: { + id: response.data.id + } + }) + }) + } + } + }, + computed: { + canSave: function () { + return ( + this.radioName.length > 0 && this.checkErrors.length === 0 + ) + }, + checkErrors: function () { + if (!this.checkResult) { + return [] + } + let errors = this.checkResult.errors + return errors + }, + currentFilter: function () { + let self = this + return this.availableFilters.filter(e => { + return e.type === self.currentFilterType + })[0] + } + }, + watch: { + filters: { + handler: function () { + this.fetchCandidates() + }, + deep: true + } + } +} +</script> diff --git a/front/src/components/library/radios/Filter.vue b/front/src/components/library/radios/Filter.vue new file mode 100644 index 0000000000000000000000000000000000000000..dd170d8b3104da08e4fbd3b93091182fe4a3a2a4 --- /dev/null +++ b/front/src/components/library/radios/Filter.vue @@ -0,0 +1,150 @@ +<template> + <tr> + <td>{{ filter.label }}</td> + <td> + <div class="ui toggle checkbox"> + <input name="public" type="checkbox" v-model="exclude" @change="$emit('update-config', index, 'not', exclude)"> + <label></label> + </div> + </td> + <td> + <div + v-for="(f, index) in filter.fields" + class="ui field" + :key="(f.name, index)" + :ref="f.name"> + <div :class="['ui', 'search', 'selection', 'dropdown', {'autocomplete': f.autocomplete}, {'multiple': f.type === 'list'}]"> + <i class="dropdown icon"></i> + <div class="default text">{{ f.placeholder }}</div> + <input v-if="f.type === 'list' && config[f.name]" :value="config[f.name].join(',')" type="hidden"> + <div v-if="config[f.name]" class="ui menu"> + <div + v-if="f.type === 'list'" + v-for="(v, index) in config[f.name]" + class="ui item" + :data-value="v"> + <template v-if="config.names"> + {{ config.names[index] }} + </template> + <template v-else>{{ v }}</template> + </div> + </div> + </div> + </div> + </div> + </td> + <td> + <span + @click="showCandidadesModal = !showCandidadesModal" + v-if="checkResult" + :class="['ui', {'green': checkResult.candidates.count > 10}, 'label']"> + {{ checkResult.candidates.count }} tracks matching filter + </span> + <modal v-if="checkResult" :show.sync="showCandidadesModal"> + <div class="header"> + Track matching filter + </div> + <div class="content"> + <div class="description"> + <track-table v-if="checkResult.candidates.count > 0" :tracks="checkResult.candidates.sample"></track-table> + </div> + </div> + <div class="actions"> + <div class="ui black deny button"> + Cancel + </div> + </div> + </modal> + </td> + <td> + <button @click="$emit('delete', index)" class="ui basic red button">Remove</button> + </td> + </tr> +</template> +<script> +import config from '@/config' +import $ from 'jquery' +import _ from 'lodash' + +import Modal from '@/components/semantic/Modal' +import TrackTable from '@/components/audio/track/Table' +import BuilderFilter from './Filter' + +export default { + components: { + BuilderFilter, + TrackTable, + Modal + }, + props: { + filter: {type: Object}, + config: {type: Object}, + index: {type: Number} + }, + data: function () { + return { + checkResult: null, + showCandidadesModal: false, + exclude: config.not + } + }, + mounted: function () { + let self = this + this.filter.fields.forEach(f => { + let selector = ['.dropdown'] + let settings = { + onChange: function (value, text, $choice) { + value = $(this).dropdown('get value').split(',') + if (f.type === 'list' && f.subtype === 'number') { + value = value.map(e => { + return parseInt(e) + }) + } + self.value = value + self.$emit('update-config', self.index, f.name, value) + self.fetchCandidates() + } + } + if (f.type === 'list') { + selector.push('.multiple') + } + if (f.autocomplete) { + selector.push('.autocomplete') + settings.fields = f.autocomplete_fields + settings.minCharacters = 1 + settings.apiSettings = { + url: config.BACKEND_URL + f.autocomplete + '?' + f.autocomplete_qs, + beforeXHR: function (xhrObject) { + xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header']) + return xhrObject + }, + onResponse: function (initialResponse) { + if (settings.fields.remoteValues) { + return initialResponse + } + return {results: initialResponse} + } + } + } + $(self.$el).find(selector.join('')).dropdown(settings) + }) + }, + methods: { + fetchCandidates: function () { + let self = this + let url = config.API_URL + 'radios/radios/validate/' + let final = _.clone(this.config) + final.type = this.filter.type + final = {'filters': [final]} + this.$http.post(url, final).then((response) => { + self.checkResult = response.data.filters[0] + }) + } + }, + watch: { + exclude: function () { + this.fetchCandidates() + } + } +} +</script> diff --git a/front/src/components/radios/Button.vue b/front/src/components/radios/Button.vue index 4bf4279890d05f39ac06a350164b9c2101747068..819aa8651f3509827b0460c54c9cec9ded18d23b 100644 --- a/front/src/components/radios/Button.vue +++ b/front/src/components/radios/Button.vue @@ -11,7 +11,8 @@ export default { props: { - type: {type: String, required: true}, + customRadioId: {required: false}, + type: {type: String, required: false}, objectId: {type: Number, default: null} }, methods: { @@ -19,7 +20,7 @@ export default { if (this.running) { this.$store.dispatch('radios/stop') } else { - this.$store.dispatch('radios/start', {type: this.type, objectId: this.objectId}) + this.$store.dispatch('radios/start', {type: this.type, objectId: this.objectId, customRadioId: this.customRadioId}) } } }, diff --git a/front/src/components/radios/Card.vue b/front/src/components/radios/Card.vue index dc8a24ff3c2e31d901ee11fb27eceb976ca7e819..d2c14c37c78dfbc23b858c93a5eddd28b3519fb9 100644 --- a/front/src/components/radios/Card.vue +++ b/front/src/components/radios/Card.vue @@ -1,13 +1,19 @@ <template> <div class="ui card"> <div class="content"> - <div class="header">Radio : {{ radio.name }}</div> + <div class="header">{{ radio.name }}</div> <div class="description"> {{ radio.description }} </div> </div> <div class="extra content"> - <radio-button class="right floated button" :type="type"></radio-button> + <router-link + class="ui basic yellow button" + v-if="$store.state.auth.authenticated && type === 'custom' && customRadio.user === $store.state.auth.profile.id" + :to="{name: 'library.radios.edit', params: {id: customRadioId }}"> + Edit... + </router-link> + <radio-button class="right floated button" :type="type" :custom-radio-id="customRadioId"></radio-button> </div> </div> </template> @@ -17,14 +23,24 @@ import RadioButton from './Button' export default { props: { - type: {type: String, required: true} + type: {type: String, required: true}, + customRadio: {required: false} }, components: { RadioButton }, computed: { radio () { + if (this.customRadio) { + return this.customRadio + } return this.$store.getters['radios/types'][this.type] + }, + customRadioId: function () { + if (this.customRadio) { + return this.customRadio.id + } + return null } } } diff --git a/front/src/router/index.js b/front/src/router/index.js index f4efc723f4abc2fb9dfdca2e062d433c09d4e91e..971ef05cd82f034b9513716a42f35b02a5291054 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -13,6 +13,8 @@ import LibraryArtists from '@/components/library/Artists' import LibraryAlbum from '@/components/library/Album' import LibraryTrack from '@/components/library/Track' import LibraryImport from '@/components/library/import/Main' +import LibraryRadios from '@/components/library/Radios' +import RadioBuilder from '@/components/library/radios/Builder' import BatchList from '@/components/library/import/BatchList' import BatchDetail from '@/components/library/import/BatchDetail' @@ -76,6 +78,19 @@ export default new Router({ defaultPage: route.query.page }) }, + { + path: 'radios/', + name: 'library.radios.browse', + component: LibraryRadios, + props: (route) => ({ + defaultOrdering: route.query.ordering, + defaultQuery: route.query.query, + defaultPaginateBy: route.query.paginateBy, + defaultPage: route.query.page + }) + }, + { path: 'radios/build', name: 'library.radios.build', component: RadioBuilder, props: true }, + { path: 'radios/build/:id', name: 'library.radios.edit', component: RadioBuilder, props: true }, { path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true }, { path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true }, { path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true }, diff --git a/front/src/store/radios.js b/front/src/store/radios.js index a9c429876a4ff974635de9f73062dd2597237209..600b24b31e7fb77eafdda8a0992bdf58879f3a54 100644 --- a/front/src/store/radios.js +++ b/front/src/store/radios.js @@ -38,15 +38,16 @@ export default { } }, actions: { - start ({commit, dispatch}, {type, objectId}) { + start ({commit, dispatch}, {type, objectId, customRadioId}) { let resource = Vue.resource(CREATE_RADIO_URL) var params = { radio_type: type, - related_object_id: objectId + related_object_id: objectId, + custom_radio: customRadioId } resource.save({}, params).then((response) => { logger.default.info('Successfully started radio ', type) - commit('current', {type, objectId, session: response.data.id}) + commit('current', {type, objectId, session: response.data.id, customRadioId}) commit('running', true) dispatch('populateQueue') }, (response) => {