diff --git a/api/config/api_urls.py b/api/config/api_urls.py index c7ebc4ed3668196ccd11d6844ba14d704e3ba43e..ff6db0d069395c316d207a640e0187ccf92b12df 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -52,6 +52,10 @@ v1_patterns += [ include( ('funkwhale_api.users.api_urls', 'users'), namespace='users')), + url(r'^requests/', + include( + ('funkwhale_api.requests.api_urls', 'requests'), + namespace='requests')), url(r'^token/$', jwt_views.obtain_jwt_token, name='token'), url(r'^token/refresh/$', jwt_views.refresh_jwt_token, name='token_refresh'), ] diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 6d02cbbc1038999cc7a3de46448369aa00e030bf..5fe55e53a33fdc58384d858370b4e2a5ba646cb9 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -80,10 +80,12 @@ if RAVEN_ENABLED: # Apps specific for this project go here. LOCAL_APPS = ( + 'funkwhale_api.common', 'funkwhale_api.users', # custom users app # Your stuff: custom apps go here 'funkwhale_api.instance', 'funkwhale_api.music', + 'funkwhale_api.requests', 'funkwhale_api.favorites', 'funkwhale_api.radios', 'funkwhale_api.history', diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py index 32bad6060273e53abad7de942c285b48b16c252e..59dcbd26b8449abef324273219a961d44f39f489 100644 --- a/api/funkwhale_api/history/views.py +++ b/api/funkwhale_api/history/views.py @@ -17,9 +17,6 @@ class ListeningViewSet(mixins.CreateModelMixin, queryset = models.Listening.objects.all() permission_classes = [ConditionalAuthentication] - def create(self, request, *args, **kwargs): - return super().create(request, *args, **kwargs) - def get_queryset(self): queryset = super().get_queryset() if self.request.user.is_authenticated: diff --git a/api/funkwhale_api/music/migrations/0020_importbatch_status.py b/api/funkwhale_api/music/migrations/0020_importbatch_status.py new file mode 100644 index 0000000000000000000000000000000000000000..265d1ba5d5312d086f25d0861039b3a94a5df4e5 --- /dev/null +++ b/api/funkwhale_api/music/migrations/0020_importbatch_status.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.2 on 2018-02-20 19:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('music', '0019_populate_mimetypes'), + ] + + operations = [ + migrations.AddField( + model_name='importbatch', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('finished', 'Finished'), ('errored', 'Errored'), ('skipped', 'Skipped')], default='pending', max_length=30), + ), + ] diff --git a/api/funkwhale_api/music/migrations/0021_populate_batch_status.py b/api/funkwhale_api/music/migrations/0021_populate_batch_status.py new file mode 100644 index 0000000000000000000000000000000000000000..061d649b06115d4871b6c6eb7ae57b25c27661e8 --- /dev/null +++ b/api/funkwhale_api/music/migrations/0021_populate_batch_status.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +import os + +from django.db import migrations, models + + +def populate_status(apps, schema_editor): + from funkwhale_api.music.utils import compute_status + ImportBatch = apps.get_model("music", "ImportBatch") + + for ib in ImportBatch.objects.prefetch_related('jobs'): + ib.status = compute_status(ib.jobs.all()) + ib.save(update_fields=['status']) + + +def rewind(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('music', '0020_importbatch_status'), + ] + + operations = [ + migrations.RunPython(populate_status, rewind), + ] diff --git a/api/funkwhale_api/music/migrations/0022_importbatch_import_request.py b/api/funkwhale_api/music/migrations/0022_importbatch_import_request.py new file mode 100644 index 0000000000000000000000000000000000000000..d9f6f01d9121f1148e1f3d5a729b1fc476a89f42 --- /dev/null +++ b/api/funkwhale_api/music/migrations/0022_importbatch_import_request.py @@ -0,0 +1,20 @@ +# Generated by Django 2.0.2 on 2018-02-20 22:48 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('requests', '__first__'), + ('music', '0021_populate_batch_status'), + ] + + operations = [ + migrations.AddField( + model_name='importbatch', + name='import_request', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='import_batches', to='requests.ImportRequest'), + ), + ] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 3ebd07419e6d0fc35608fd4e63f7c2b990e83657..97992fc8f12cafa7af7cb99c7cbfb1cee2b42192 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -10,8 +10,11 @@ from django.conf import settings from django.db import models from django.core.files.base import ContentFile from django.core.files import File +from django.db.models.signals import post_save +from django.dispatch import receiver from django.urls import reverse from django.utils import timezone + from taggit.managers import TaggableManager from versatileimagefield.fields import VersatileImageField @@ -400,6 +403,14 @@ class TrackFile(models.Model): self.mimetype = utils.guess_mimetype(self.audio_file) return super().save(**kwargs) + +IMPORT_STATUS_CHOICES = ( + ('pending', 'Pending'), + ('finished', 'Finished'), + ('errored', 'Errored'), + ('skipped', 'Skipped'), +) + class ImportBatch(models.Model): IMPORT_BATCH_SOURCES = [ ('api', 'api'), @@ -412,22 +423,24 @@ class ImportBatch(models.Model): 'users.User', related_name='imports', on_delete=models.CASCADE) - + status = models.CharField( + choices=IMPORT_STATUS_CHOICES, default='pending', max_length=30) + import_request = models.ForeignKey( + 'requests.ImportRequest', + related_name='import_batches', + null=True, + blank=True, + on_delete=models.CASCADE) class Meta: ordering = ['-creation_date'] def __str__(self): return str(self.pk) - @property - def status(self): - pending = any([job.status == 'pending' for job in self.jobs.all()]) - errored = any([job.status == 'errored' for job in self.jobs.all()]) - if pending: - return 'pending' - if errored: - return 'errored' - return 'finished' + def update_status(self): + self.status = utils.compute_status(self.jobs.all()) + self.save(update_fields=['status']) + class ImportJob(models.Model): batch = models.ForeignKey( @@ -440,15 +453,39 @@ class ImportJob(models.Model): on_delete=models.CASCADE) source = models.CharField(max_length=500) mbid = models.UUIDField(editable=False, null=True, blank=True) - STATUS_CHOICES = ( - ('pending', 'Pending'), - ('finished', 'Finished'), - ('errored', 'Errored'), - ('skipped', 'Skipped'), - ) - status = models.CharField(choices=STATUS_CHOICES, default='pending', max_length=30) + + status = models.CharField( + choices=IMPORT_STATUS_CHOICES, default='pending', max_length=30) audio_file = models.FileField( upload_to='imports/%Y/%m/%d', max_length=255, null=True, blank=True) class Meta: ordering = ('id', ) + + +@receiver(post_save, sender=ImportJob) +def update_batch_status(sender, instance, **kwargs): + instance.batch.update_status() + + +@receiver(post_save, sender=ImportBatch) +def update_request_status(sender, instance, created, **kwargs): + update_fields = kwargs.get('update_fields', []) or [] + if not instance.import_request: + return + + if not created and not 'status' in update_fields: + return + + r_status = instance.import_request.status + status = instance.status + + if status == 'pending' and r_status == 'pending': + # let's mark the request as accepted since we started an import + instance.import_request.status = 'accepted' + return instance.import_request.save(update_fields=['status']) + + if status == 'finished' and r_status == 'accepted': + # let's mark the request as imported since the import is over + instance.import_request.status = 'imported' + return instance.import_request.save(update_fields=['status']) diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 41de30f1026e54e0f98a50926934f23ce2fd0c84..db6298a9e446eddc64bc17f95e3a6b75bf126573 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -125,5 +125,5 @@ class ImportBatchSerializer(serializers.ModelSerializer): jobs = ImportJobSerializer(many=True, read_only=True) class Meta: model = models.ImportBatch - fields = ('id', 'jobs', 'status', 'creation_date') + fields = ('id', 'jobs', 'status', 'creation_date', 'import_request') read_only_fields = ('creation_date',) diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py index 0e4318e563ee5341e0d8b9d0aa32291eea867e7a..a75cf5de8508efaa5df587eb17b01a2e87f1214f 100644 --- a/api/funkwhale_api/music/utils.py +++ b/api/funkwhale_api/music/utils.py @@ -43,3 +43,13 @@ def get_query(query_string, search_fields): def guess_mimetype(f): b = min(100000, f.size) return magic.from_buffer(f.read(b), mime=True) + + +def compute_status(jobs): + errored = any([job.status == 'errored' for job in jobs]) + if errored: + return 'errored' + pending = any([job.status == 'pending' for job in jobs]) + if pending: + return 'pending' + return 'finished' diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 8e46cbd71612f6b6d65563d136408efd4e0401ac..bf9d39b1d507c7122cb5e6d2eeb70b45e8946fee 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -19,6 +19,7 @@ from musicbrainzngs import ResponseError from django.contrib.auth.decorators import login_required from django.utils.decorators import method_decorator +from funkwhale_api.requests.models import ImportRequest from funkwhale_api.musicbrainz import api from funkwhale_api.common.permissions import ( ConditionalAuthentication, HasModelPermission) @@ -314,14 +315,28 @@ class SubmitViewSet(viewsets.ViewSet): serializer = serializers.ImportBatchSerializer(batch) return Response(serializer.data) + def get_import_request(self, data): + try: + raw = data['importRequest'] + except KeyError: + return + + pk = int(raw) + try: + return ImportRequest.objects.get(pk=pk) + except ImportRequest.DoesNotExist: + pass + @list_route(methods=['post']) @transaction.non_atomic_requests def album(self, request, *args, **kwargs): data = json.loads(request.body.decode('utf-8')) - import_data, batch = self._import_album(data, request, batch=None) + import_request = self.get_import_request(data) + import_data, batch = self._import_album( + data, request, batch=None, import_request=import_request) return Response(import_data) - def _import_album(self, data, request, batch=None): + def _import_album(self, data, request, batch=None, import_request=None): # we import the whole album here to prevent race conditions that occurs # when using get_or_create_from_api in tasks album_data = api.releases.get(id=data['releaseId'], includes=models.Album.api_includes)['release'] @@ -332,7 +347,9 @@ class SubmitViewSet(viewsets.ViewSet): except ResponseError: pass if not batch: - batch = models.ImportBatch.objects.create(submitted_by=request.user) + batch = models.ImportBatch.objects.create( + submitted_by=request.user, + import_request=import_request) for row in data['tracks']: try: models.TrackFile.objects.get(track__mbid=row['mbid']) @@ -346,6 +363,7 @@ class SubmitViewSet(viewsets.ViewSet): @transaction.non_atomic_requests def artist(self, request, *args, **kwargs): data = json.loads(request.body.decode('utf-8')) + import_request = self.get_import_request(data) artist_data = api.artists.get(id=data['artistId'])['artist'] cleaned_data = models.Artist.clean_musicbrainz_data(artist_data) artist = importers.load(models.Artist, cleaned_data, artist_data, import_hooks=[]) @@ -353,7 +371,8 @@ class SubmitViewSet(viewsets.ViewSet): import_data = [] batch = None for row in data['albums']: - row_data, batch = self._import_album(row, request, batch=batch) + row_data, batch = self._import_album( + row, request, batch=batch, import_request=import_request) import_data.append(row_data) return Response(import_data[0]) diff --git a/api/funkwhale_api/requests/__init__.py b/api/funkwhale_api/requests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/requests/api_urls.py b/api/funkwhale_api/requests/api_urls.py new file mode 100644 index 0000000000000000000000000000000000000000..37459a664a4d7e1fd9bc229fbb678daa02adb012 --- /dev/null +++ b/api/funkwhale_api/requests/api_urls.py @@ -0,0 +1,11 @@ +from django.conf.urls import include, url +from . import views + +from rest_framework import routers +router = routers.SimpleRouter() +router.register( + r'import-requests', + views.ImportRequestViewSet, + 'import-requests') + +urlpatterns = router.urls diff --git a/api/funkwhale_api/requests/factories.py b/api/funkwhale_api/requests/factories.py new file mode 100644 index 0000000000000000000000000000000000000000..2bcdeb6a9699ee47a16cc4fb2e05ebe9b19286e0 --- /dev/null +++ b/api/funkwhale_api/requests/factories.py @@ -0,0 +1,15 @@ +import factory + +from funkwhale_api.factories import registry +from funkwhale_api.users.factories import UserFactory + + +@registry.register +class ImportRequestFactory(factory.django.DjangoModelFactory): + artist_name = factory.Faker('name') + albums = factory.Faker('sentence') + user = factory.SubFactory(UserFactory) + comment = factory.Faker('paragraph') + + class Meta: + model = 'requests.ImportRequest' diff --git a/api/funkwhale_api/requests/filters.py b/api/funkwhale_api/requests/filters.py new file mode 100644 index 0000000000000000000000000000000000000000..bf353e8ad075abb76f3a4fdc846d9d25b0a105f4 --- /dev/null +++ b/api/funkwhale_api/requests/filters.py @@ -0,0 +1,14 @@ +import django_filters + +from . import models + + +class ImportRequestFilter(django_filters.FilterSet): + + class Meta: + model = models.ImportRequest + fields = { + 'artist_name': ['exact', 'iexact', 'startswith', 'icontains'], + 'status': ['exact'], + 'user__username': ['exact'], + } diff --git a/api/funkwhale_api/requests/migrations/0001_initial.py b/api/funkwhale_api/requests/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..7c239b3c079234ec130be85c300e3a9956a7ad41 --- /dev/null +++ b/api/funkwhale_api/requests/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 2.0.2 on 2018-02-20 22:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ImportRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), + ('imported_date', models.DateTimeField(blank=True, null=True)), + ('artist_name', models.CharField(max_length=250)), + ('albums', models.CharField(blank=True, max_length=3000, null=True)), + ('status', models.CharField(choices=[('pending', 'pending'), ('accepted', 'accepted'), ('imported', 'imported'), ('closed', 'closed')], default='pending', max_length=50)), + ('comment', models.TextField(blank=True, max_length=3000, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='import_requests', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/api/funkwhale_api/requests/migrations/__init__.py b/api/funkwhale_api/requests/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/requests/models.py b/api/funkwhale_api/requests/models.py new file mode 100644 index 0000000000000000000000000000000000000000..c298524306caa9bc7f4cd6e9eb1f4abbb2d4bde0 --- /dev/null +++ b/api/funkwhale_api/requests/models.py @@ -0,0 +1,29 @@ +from django.db import models + +from django.utils import timezone + +NATURE_CHOICES = [ + ('artist', 'artist'), + ('album', 'album'), + ('track', 'track'), +] + +STATUS_CHOICES = [ + ('pending', 'pending'), + ('accepted', 'accepted'), + ('imported', 'imported'), + ('closed', 'closed'), +] + +class ImportRequest(models.Model): + creation_date = models.DateTimeField(default=timezone.now) + imported_date = models.DateTimeField(null=True, blank=True) + user = models.ForeignKey( + 'users.User', + related_name='import_requests', + on_delete=models.CASCADE) + artist_name = models.CharField(max_length=250) + albums = models.CharField(max_length=3000, null=True, blank=True) + status = models.CharField( + choices=STATUS_CHOICES, max_length=50, default='pending') + comment = models.TextField(null=True, blank=True, max_length=3000) diff --git a/api/funkwhale_api/requests/serializers.py b/api/funkwhale_api/requests/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..51a709514e0cb95a2f03289dd981df918734c795 --- /dev/null +++ b/api/funkwhale_api/requests/serializers.py @@ -0,0 +1,30 @@ +from rest_framework import serializers + +from funkwhale_api.users.serializers import UserBasicSerializer + +from . import models + + +class ImportRequestSerializer(serializers.ModelSerializer): + user = UserBasicSerializer(read_only=True) + + class Meta: + model = models.ImportRequest + fields = ( + 'id', + 'status', + 'albums', + 'artist_name', + 'user', + 'creation_date', + 'imported_date', + 'comment') + read_only_fields = ( + 'creation_date', + 'imported_date', + 'user', + 'status') + + def create(self, validated_data): + validated_data['user'] = self.context['user'] + return super().create(validated_data) diff --git a/api/funkwhale_api/requests/views.py b/api/funkwhale_api/requests/views.py new file mode 100644 index 0000000000000000000000000000000000000000..395fac66cff33e3a01fdcc33a56adfcea0aa0c7e --- /dev/null +++ b/api/funkwhale_api/requests/views.py @@ -0,0 +1,36 @@ +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 funkwhale_api.music.views import SearchMixin + +from . import filters +from . import models +from . import serializers + + +class ImportRequestViewSet( + SearchMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet): + + serializer_class = serializers.ImportRequestSerializer + queryset = ( + models.ImportRequest.objects.all() + .select_related() + .order_by('-creation_date')) + search_fields = ['artist_name', 'album_name', 'comment'] + filter_class = filters.ImportRequestFilter + ordering_fields = ('id', 'artist_name', 'creation_date', 'status') + + def perform_create(self, serializer): + return serializer.save(user=self.request.user) + + def get_serializer_context(self): + context = super().get_serializer_context() + if self.request.user.is_authenticated: + context['user'] = self.request.user + return context diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index 261873bdbec1a34ee0c69e30a002161e69e423f7..8c218b1c28acd2811b208e0a3f4b4f0d788c1aea 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -3,6 +3,12 @@ from rest_framework import serializers from . import models +class UserBasicSerializer(serializers.ModelSerializer): + class Meta: + model = models.User + fields = ['id', 'username', 'name', 'date_joined'] + + class UserSerializer(serializers.ModelSerializer): permissions = serializers.SerializerMethodField() diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 4d7a6fa981d1431cd01a97d4578d2a992b4cb749..10d7c323512c684ff495bda2a2a7a7ae581213f8 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -56,6 +56,24 @@ def api_client(client): return APIClient() +@pytest.fixture +def logged_in_api_client(db, factories, api_client): + user = factories['users.User']() + assert api_client.login(username=user.username, password='test') + setattr(api_client, 'user', user) + yield api_client + delattr(api_client, 'user') + + +@pytest.fixture +def superuser_api_client(db, factories, api_client): + user = factories['users.SuperUser']() + assert api_client.login(username=user.username, password='test') + setattr(api_client, 'user', user) + yield api_client + delattr(api_client, 'user') + + @pytest.fixture def superuser_client(db, factories, client): user = factories['users.SuperUser']() diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py new file mode 100644 index 0000000000000000000000000000000000000000..f2ca1abbd04a562764194f09653e17c4724f3cc4 --- /dev/null +++ b/api/tests/music/test_import.py @@ -0,0 +1,37 @@ +import json + +from django.urls import reverse + +from . import data as api_data + + +def test_create_import_can_bind_to_request( + mocker, factories, superuser_api_client): + request = factories['requests.ImportRequest']() + + mocker.patch('funkwhale_api.music.tasks.import_job_run') + mocker.patch( + 'funkwhale_api.musicbrainz.api.artists.get', + return_value=api_data.artists['get']['soad']) + mocker.patch( + 'funkwhale_api.musicbrainz.api.images.get_front', + return_value=b'') + mocker.patch( + 'funkwhale_api.musicbrainz.api.releases.get', + return_value=api_data.albums['get_with_includes']['hypnotize']) + payload = { + 'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94', + 'importRequest': request.pk, + 'tracks': [ + { + 'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed', + 'source': 'https://www.youtube.com/watch?v=1111111111', + } + ] + } + url = reverse('api:v1:submit-album') + response = superuser_api_client.post( + url, json.dumps(payload), content_type='application/json') + batch = request.import_batches.latest('id') + + assert batch.import_request == request diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py index 2eb1f276332fc32bcd01e947fac72c3774cfaea8..9f52ba8874e50c10ea0216cacc8363ab767d6396 100644 --- a/api/tests/music/test_models.py +++ b/api/tests/music/test_models.py @@ -52,6 +52,20 @@ def test_import_job_is_bound_to_track_file(factories, mocker): job.refresh_from_db() assert job.track_file.track == track + +@pytest.mark.parametrize('status', ['pending', 'errored', 'finished']) +def test_saving_job_updates_batch_status(status,factories, mocker): + batch = factories['music.ImportBatch']() + + assert batch.status == 'pending' + + job = factories['music.ImportJob'](batch=batch, status=status) + + batch.refresh_from_db() + + assert batch.status == status + + @pytest.mark.parametrize('extention,mimetype', [ ('ogg', 'audio/ogg'), ('mp3', 'audio/mpeg'), diff --git a/api/tests/requests/test_models.py b/api/tests/requests/test_models.py new file mode 100644 index 0000000000000000000000000000000000000000..797656bd70c5ab916c650c589bd70ea5fc41be87 --- /dev/null +++ b/api/tests/requests/test_models.py @@ -0,0 +1,23 @@ +import pytest + +from django.forms import ValidationError + + +def test_can_bind_import_batch_to_request(factories): + request = factories['requests.ImportRequest']() + + assert request.status == 'pending' + + # when we create the import, we consider the request as accepted + batch = factories['music.ImportBatch'](import_request=request) + request.refresh_from_db() + + assert request.status == 'accepted' + + # now, the batch is finished, therefore the request status should be + # imported + batch.status = 'finished' + batch.save(update_fields=['status']) + request.refresh_from_db() + + assert request.status == 'imported' diff --git a/api/tests/requests/test_views.py b/api/tests/requests/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..6c34f9ad19fcc8ae7ec10ba6563cb44d45cb3461 --- /dev/null +++ b/api/tests/requests/test_views.py @@ -0,0 +1,26 @@ +from django.urls import reverse + + +def test_request_viewset_requires_auth(db, api_client): + url = reverse('api:v1:requests:import-requests-list') + response = api_client.get(url) + assert response.status_code == 401 + + +def test_user_can_create_request(logged_in_api_client): + url = reverse('api:v1:requests:import-requests-list') + user = logged_in_api_client.user + data = { + 'artist_name': 'System of a Down', + 'albums': 'All please!', + 'comment': 'Please, they rock!', + } + response = logged_in_api_client.post(url, data) + + assert response.status_code == 201 + + ir = user.import_requests.latest('id') + assert ir.status == 'pending' + assert ir.creation_date is not None + for field, value in data.items(): + assert getattr(ir, field) == value diff --git a/front/package.json b/front/package.json index ac3895f6d65655fd198699c92cd55300df301b1e..042e332d0eb91d81df9c27fe13b82b0e16013d3a 100644 --- a/front/package.json +++ b/front/package.json @@ -20,9 +20,11 @@ "js-logger": "^1.3.0", "jwt-decode": "^2.2.0", "lodash": "^4.17.4", + "moment": "^2.20.1", "moxios": "^0.4.0", "raven-js": "^3.22.3", "semantic-ui-css": "^2.2.10", + "showdown": "^1.8.6", "vue": "^2.3.3", "vue-lazyload": "^1.1.4", "vue-router": "^2.3.1", diff --git a/front/src/components/Pagination.vue b/front/src/components/Pagination.vue index 3ac7c59af5817413c13a97e569065ca48cb95b91..83b386fdeb57f72700b97abacf37c4512bcff5e0 100644 --- a/front/src/components/Pagination.vue +++ b/front/src/components/Pagination.vue @@ -49,4 +49,8 @@ export default { </script> <style scoped> +.ui.menu { + border: none; + box-shadow: none; +} </style> diff --git a/front/src/components/common/HumanDate.vue b/front/src/components/common/HumanDate.vue new file mode 100644 index 0000000000000000000000000000000000000000..ff6ff5c71efa2df05e1467751235ddb15dd67222 --- /dev/null +++ b/front/src/components/common/HumanDate.vue @@ -0,0 +1,8 @@ +<template> + <time :datetime="date" :title="date | moment">{{ date | ago }}</time> +</template> +<script> +export default { + props: ['date'] +} +</script> diff --git a/front/src/components/discussion/Comment.vue b/front/src/components/discussion/Comment.vue new file mode 100644 index 0000000000000000000000000000000000000000..a3c5176ecb9b908817b72f62f884c6f87d2b9660 --- /dev/null +++ b/front/src/components/discussion/Comment.vue @@ -0,0 +1,49 @@ +<template> + <div class="comment"> + <div class="content"> + <a class="author">{{ user.username }}</a> + <div class="metadata"> + <div class="date"><human-date :date="date"></human-date></div> + </div> + <div class="text" v-html="comment"></div> + </div> + <div class="actions"> + <span + @click="collapsed = false" + v-if="truncated && collapsed" + class="expand">Expand</span> + <span + @click="collapsed = true" + v-if="truncated && !collapsed" + class="collapse">Collapse</span> + </div> + </div> + </div> +</template> +<script> + export default { + props: { + user: {type: Object, required: true}, + date: {required: true}, + content: {type: String, required: true} + }, + data () { + return { + collapsed: true, + length: 50 + } + }, + computed: { + comment () { + let text = this.content + if (this.collapsed) { + text = this.$options.filters.truncate(text, this.length) + } + return this.$options.filters.markdown(text) + }, + truncated () { + return this.content.length > this.length + } + } + } +</script> diff --git a/front/src/components/globals.js b/front/src/components/globals.js new file mode 100644 index 0000000000000000000000000000000000000000..40315bc47d8efa9731c7133354181e09bbd6cb59 --- /dev/null +++ b/front/src/components/globals.js @@ -0,0 +1,7 @@ +import Vue from 'vue' + +import HumanDate from '@/components/common/HumanDate' + +Vue.component('human-date', HumanDate) + +export default {} diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue index cdcbe4b72ee7153d757da33eddb393d1d67a568e..e4e22fc09919f815038795762f408ac0a760aa58 100644 --- a/front/src/components/library/Home.vue +++ b/front/src/components/library/Home.vue @@ -4,7 +4,7 @@ <search :autofocus="true"></search> </div> <div class="ui vertical stripe segment"> - <div class="ui stackable two column grid"> + <div class="ui stackable three column grid"> <div class="column"> <h2 class="ui header">Latest artists</h2> <div :class="['ui', {'active': isLoadingArtists}, 'inline', 'loader']"></div> @@ -18,6 +18,10 @@ <radio-card :type="'random'"></radio-card> <radio-card :type="'less-listened'"></radio-card> </div> + <div class="column"> + <h2 class="ui header">Music requests</h2> + <request-form></request-form> + </div> </div> </div> </div> @@ -30,6 +34,7 @@ import backend from '@/audio/backend' import logger from '@/logging' import ArtistCard from '@/components/audio/artist/Card' import RadioCard from '@/components/radios/Card' +import RequestForm from '@/components/requests/Form' const ARTISTS_URL = 'artists/' @@ -38,7 +43,8 @@ export default { components: { Search, ArtistCard, - RadioCard + RadioCard, + RequestForm }, data () { return { diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue index 5fe192022c8e34416259118f262fb87076e98458..6cd156493f4487683df91fd9314f31fb8807de1d 100644 --- a/front/src/components/library/Library.vue +++ b/front/src/components/library/Library.vue @@ -5,8 +5,13 @@ <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 class="ui item" to="/library/requests/" exact> + Requests + <div class="ui teal label">{{ requestsCount }}</div> + </router-link> <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> + <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches + </router-link> </div> </div> <router-view :key="$route.fullPath"></router-view> @@ -14,9 +19,25 @@ </template> <script> - +import axios from 'axios' export default { - name: 'library' + name: 'library', + data () { + return { + requestsCount: 0 + } + }, + created () { + this.fetchRequestsCount() + }, + methods: { + fetchRequestsCount () { + let self = this + axios.get('requests/import-requests', {params: {status: 'pending'}}).then(response => { + self.requestsCount = response.data.count + }) + } + } } </script> diff --git a/front/src/components/library/import/ImportMixin.vue b/front/src/components/library/import/ImportMixin.vue index 33c6193bd7c0a95afa6e59847dc6056a0ca38145..8b0757dcc9256afa7d09fc4e61eb091668ed7582 100644 --- a/front/src/components/library/import/ImportMixin.vue +++ b/front/src/components/library/import/ImportMixin.vue @@ -13,7 +13,8 @@ export default { defaultEnabled: {type: Boolean, default: true}, backends: {type: Array}, defaultBackendId: {type: String}, - queryTemplate: {type: String, default: '$artist $title'} + queryTemplate: {type: String, default: '$artist $title'}, + request: {type: Object, required: false} }, data () { return { @@ -32,6 +33,9 @@ export default { this.isImporting = true let url = 'submit/' + self.importType + '/' let payload = self.importData + if (this.request) { + payload.importRequest = this.request.id + } axios.post(url, payload).then((response) => { logger.default.info('launched import for', self.type, self.metadata.id) self.isImporting = false diff --git a/front/src/components/library/import/Main.vue b/front/src/components/library/import/Main.vue index 66ba8c66144db35f4d4c17dfcada5e4c89fcc2e4..0a1cc6df9480366a7105166045e39ffbf91249de 100644 --- a/front/src/components/library/import/Main.vue +++ b/front/src/components/library/import/Main.vue @@ -92,6 +92,7 @@ <component ref="import" v-if="currentSource == 'external'" + :request="currentRequest" :metadata="metadata" :is="importComponent" :backends="backends" @@ -113,7 +114,10 @@ </div> </div> </div> - <div class="ui vertical stripe segment"> + <div class="ui vertical stripe segment" v-if="currentRequest"> + <h3 class="ui header">Music request</h3> + <p>This import will be associated with the music request below. After the import is finished, the request will be marked as fulfilled.</p> + <request-card :request="currentRequest" :import-action="false"></request-card> </div> </div> @@ -121,6 +125,7 @@ <script> +import RequestCard from '@/components/requests/Card' import MetadataSearch from '@/components/metadata/Search' import ReleaseCard from '@/components/metadata/ReleaseCard' import ArtistCard from '@/components/metadata/ArtistCard' @@ -128,6 +133,7 @@ import ReleaseImport from './ReleaseImport' import FileUpload from './FileUpload' import ArtistImport from './ArtistImport' +import axios from 'axios' import router from '@/router' import $ from 'jquery' @@ -138,19 +144,22 @@ export default { ReleaseCard, ArtistImport, ReleaseImport, - FileUpload + FileUpload, + RequestCard }, props: { mbType: {type: String, required: false}, + request: {type: String, required: false}, source: {type: String, required: false}, mbId: {type: String, required: false} }, data: function () { return { + currentRequest: null, currentType: this.mbType || 'artist', currentId: this.mbId, currentStep: 0, - currentSource: '', + currentSource: this.source, metadata: {}, isImporting: false, importData: { @@ -166,6 +175,9 @@ export default { } }, created () { + if (this.request) { + this.fetchRequest(this.request) + } if (this.currentSource) { this.currentStep = 1 } @@ -179,7 +191,8 @@ export default { query: { source: this.currentSource, type: this.currentType, - id: this.currentId + id: this.currentId, + request: this.request } }) }, @@ -197,6 +210,12 @@ export default { }, updateId (newValue) { this.currentId = newValue + }, + fetchRequest (id) { + let self = this + axios.get(`requests/import-requests/${id}`).then((response) => { + self.currentRequest = response.data + }) } }, computed: { diff --git a/front/src/components/requests/Card.vue b/front/src/components/requests/Card.vue new file mode 100644 index 0000000000000000000000000000000000000000..deb9c3fe093a58cc6913805ad4fcbe9bed4da58d --- /dev/null +++ b/front/src/components/requests/Card.vue @@ -0,0 +1,61 @@ +<template> + <div :class="['ui', {collapsed: collapsed}, 'card']"> + <div class="content"> + <div class="header">{{ request.artist_name }}</div> + <div class="description"> + <div + v-if="request.albums" v-html="$options.filters.markdown(request.albums)"></div> + <div v-if="request.comment" class="ui comments"> + <comment + :user="request.user" + :content="request.comment" + :date="request.creation_date"></comment> + </div> + </div> + </div> + <div class="extra content"> + <span > + <i v-if="request.status === 'pending'" class="hourglass start icon"></i> + <i v-if="request.status === 'accepted'" class="hourglass half icon"></i> + <i v-if="request.status === 'imported'" class="check icon"></i> + {{ request.status | capitalize }} + </span> + <button + @click="createImport" + v-if="request.status === 'pending' && importAction && $store.state.auth.availablePermissions['import.launch']" + class="ui mini basic green right floated button">Create import</button> + + </div> + </div> +</template> + +<script> +import Comment from '@/components/discussion/Comment' + +export default { + props: { + request: {type: Object, required: true}, + importAction: {type: Boolean, default: true} + }, + components: { + Comment + }, + data () { + return { + collapsed: true + } + }, + methods: { + createImport () { + this.$router.push({ + name: 'library.import.launch', + query: {request: this.request.id}}) + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> + +</style> diff --git a/front/src/components/requests/Form.vue b/front/src/components/requests/Form.vue new file mode 100644 index 0000000000000000000000000000000000000000..68c725ba7adfe903c3ecdf971067b1abb2d5baef --- /dev/null +++ b/front/src/components/requests/Form.vue @@ -0,0 +1,115 @@ +<template> + <div> + <form v-if="!over" class="ui form" @submit.prevent="submit"> + <p>Something's missing in the library? Let us know what you would like to listen!</p> + <div class="required field"> + <label>Artist name</label> + <input v-model="currentArtistName" placeholder="The Beatles, Mickael Jackson…" required maxlength="200"> + </div> + <div class="field"> + <label>Albums</label> + <p>Leave this field empty if you're requesting the whole discography.</p> + <input v-model="currentAlbums" placeholder="The White Album, Thriller…" maxlength="2000"> + </div> + <div class="field"> + <label>Comment</label> + <textarea v-model="currentComment" rows="3" placeholder="Use this comment box to add details to your request if needed" maxlength="2000"></textarea> + </div> + <button class="ui submit button" type="submit">Submit</button> + </form> + <div v-else class="ui success message"> + <div class="header">Request submitted!</div> + <p>We've received your request, you'll get some groove soon ;)</p> + <button @click="reset" class="ui button">Submit another request</button> + </div> + <div v-if="requests.length > 0"> + <div class="ui divider"></div> + <h3 class="ui header">Pending requests</h3> + <div class="ui list"> + <div v-for="request in requests" class="item"> + <div class="content"> + <div class="header">{{ request.artist_name }}</div> + <div v-if="request.albums" class="description"> + {{ request.albums|truncate }}</div> + <div v-if="request.comment" class="description"> + {{ request.comment|truncate }}</div> + </div> + </div> + </div> + </div> + </div> +</template> + +<script> +import $ from 'jquery' +import axios from 'axios' + +import logger from '@/logging' + +export default { + props: { + defaultArtistName: {type: String, default: ''}, + defaultAlbums: {type: String, default: ''}, + defaultComment: {type: String, default: ''} + }, + created () { + this.fetchRequests() + }, + mounted () { + $('.ui.radio.checkbox').checkbox() + }, + data () { + return { + currentArtistName: this.defaultArtistName, + currentAlbums: this.defaultAlbums, + currentComment: this.defaultComment, + isLoading: false, + over: false, + requests: [] + } + }, + methods: { + fetchRequests () { + let self = this + let url = 'requests/import-requests/' + axios.get(url, {}).then((response) => { + self.requests = response.data.results + }) + }, + submit () { + let self = this + this.isLoading = true + let url = 'requests/import-requests/' + let payload = { + artist_name: this.currentArtistName, + albums: this.currentAlbums, + comment: this.currentComment + } + axios.post(url, payload).then((response) => { + logger.default.info('Submitted request!') + self.isLoading = false + self.over = true + self.requests.unshift(response.data) + }, (response) => { + logger.default.error('error while submitting request') + self.isLoading = false + }) + }, + reset () { + this.over = false + this.currentArtistName = '' + this.currentAlbums = '' + this.currentComment = '' + }, + truncate (string, length) { + if (string.length > length) { + return string.substring(0, length) + '…' + } + return string + } + } +} +</script> + +<style scoped> +</style> diff --git a/front/src/components/requests/RequestsList.vue b/front/src/components/requests/RequestsList.vue new file mode 100644 index 0000000000000000000000000000000000000000..cb3e9af00bc40d0301b23dcb446576a567e7f153 --- /dev/null +++ b/front/src/components/requests/RequestsList.vue @@ -0,0 +1,163 @@ +<template> + <div> + <div class="ui vertical stripe segment"> + <h2 class="ui header">Music requests</h2> + <div :class="['ui', {'loading': isLoading}, 'form']"> + <div class="fields"> + <div class="field"> + <label>Search</label> + <input type="text" v-model="query" placeholder="Enter an artist name, a username..."/> + </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="request in result.results" + :key="request.id" + class="column"> + <request-card class="fluid" :request="request"></request-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 axios from 'axios' +import _ from 'lodash' +import $ from 'jquery' + +import logger from '@/logging' + +import OrderingMixin from '@/components/mixins/Ordering' +import PaginationMixin from '@/components/mixins/Pagination' +import RequestCard from '@/components/requests/Card' +import Pagination from '@/components/Pagination' + +const FETCH_URL = 'requests/import-requests/' + +export default { + mixins: [OrderingMixin, PaginationMixin], + props: { + defaultQuery: {type: String, required: false, default: ''} + }, + components: { + RequestCard, + 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'], + ['artist_name', 'Artist name'], + ['user__username', 'User'] + ] + } + }, + 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, + search: this.query, + ordering: this.getOrderingAsString() + } + logger.default.debug('Fetching request...') + axios.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/filters.js b/front/src/filters.js new file mode 100644 index 0000000000000000000000000000000000000000..22d93149bb402657e56d37d9ef68cf2959d9c767 --- /dev/null +++ b/front/src/filters.js @@ -0,0 +1,44 @@ +import Vue from 'vue' + +import moment from 'moment' +import showdown from 'showdown' + +export function truncate (str, max, ellipsis) { + max = max || 100 + ellipsis = ellipsis || '…' + if (str.length <= max) { + return str + } + return str.slice(0, max) + ellipsis +} + +Vue.filter('truncate', truncate) + +export function markdown (str) { + const converter = new showdown.Converter() + return converter.makeHtml(str) +} + +Vue.filter('markdown', markdown) + +export function ago (date) { + const m = moment(date) + return m.fromNow() +} + +Vue.filter('ago', ago) + +export function momentFormat (date, format) { + format = format || 'lll' + return moment(date).format(format) +} + +Vue.filter('moment', momentFormat) + +export function capitalize (str) { + return str.charAt(0).toUpperCase() + str.slice(1) +} + +Vue.filter('capitalize', capitalize) + +export default {} diff --git a/front/src/main.js b/front/src/main.js index d1ff90c3256846b65848021e1abf0f0b3fb19bc7..2e351310a15e218d1e9438c12d7c8e8ba1b37426 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -13,6 +13,8 @@ import VueLazyload from 'vue-lazyload' import store from './store' import config from './config' import { sync } from 'vuex-router-sync' +import filters from '@/filters' // eslint-disable-line +import globals from '@/components/globals' // eslint-disable-line sync(store, router) diff --git a/front/src/router/index.js b/front/src/router/index.js index 971ef05cd82f034b9513716a42f35b02a5291054..ea8854bbe48e4f06ec4369a2853b3e98271df733 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -17,6 +17,7 @@ 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' +import RequestsList from '@/components/requests/RequestsList' import Favorites from '@/components/favorites/List' @@ -98,7 +99,11 @@ export default new Router({ path: 'import/launch', name: 'library.import.launch', component: LibraryImport, - props: (route) => ({ mbType: route.query.type, mbId: route.query.id }) + props: (route) => ({ + source: route.query.source, + request: route.query.request, + mbType: route.query.type, + mbId: route.query.id }) }, { path: 'import/batches', @@ -107,7 +112,21 @@ export default new Router({ children: [ ] }, - { path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true } + { path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true }, + { + path: 'requests/', + name: 'library.requests', + component: RequestsList, + props: (route) => ({ + defaultOrdering: route.query.ordering, + defaultQuery: route.query.query, + defaultPaginateBy: route.query.paginateBy, + defaultPage: route.query.page, + defaultStatus: route.query.status || 'pending' + }), + children: [ + ] + } ] }, { path: '*', component: PageNotFound } diff --git a/front/test/unit/specs/filters/filters.spec.js b/front/test/unit/specs/filters/filters.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c2b43da44a83eb0a983d981c7891853a634fb31a --- /dev/null +++ b/front/test/unit/specs/filters/filters.spec.js @@ -0,0 +1,42 @@ +import {truncate, markdown, ago, capitalize} from '@/filters' + +describe('filters', () => { + describe('truncate', () => { + it('leave strings as it if correct size', () => { + const input = 'Hello world' + let output = truncate(input, 100) + expect(output).to.equal(input) + }) + it('returns shorter string with character', () => { + const input = 'Hello world' + let output = truncate(input, 5) + expect(output).to.equal('Hello…') + }) + it('custom ellipsis', () => { + const input = 'Hello world' + let output = truncate(input, 5, ' pouet') + expect(output).to.equal('Hello pouet') + }) + }) + describe('markdown', () => { + it('renders markdown', () => { + const input = 'Hello world' + let output = markdown(input) + expect(output).to.equal('<p>Hello world</p>') + }) + }) + describe('ago', () => { + it('works', () => { + const input = new Date() + let output = ago(input) + expect(output).to.equal('a few seconds ago') + }) + }) + describe('capitalize', () => { + it('works', () => { + const input = 'hello world' + let output = capitalize(input) + expect(output).to.equal('Hello world') + }) + }) +})