diff --git a/api/config/api_urls.py b/api/config/api_urls.py index c7ebc4ed3..ff6db0d06 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 6d02cbbc1..5fe55e53a 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 32bad6060..59dcbd26b 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 000000000..265d1ba5d --- /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 000000000..061d649b0 --- /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 000000000..d9f6f01d9 --- /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 3ebd07419..97992fc8f 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 41de30f10..db6298a9e 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 0e4318e56..a75cf5de8 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 8e46cbd71..bf9d39b1d 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 000000000..e69de29bb diff --git a/api/funkwhale_api/requests/api_urls.py b/api/funkwhale_api/requests/api_urls.py new file mode 100644 index 000000000..37459a664 --- /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 000000000..2bcdeb6a9 --- /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 000000000..bf353e8ad --- /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 000000000..7c239b3c0 --- /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 000000000..e69de29bb diff --git a/api/funkwhale_api/requests/models.py b/api/funkwhale_api/requests/models.py new file mode 100644 index 000000000..c29852430 --- /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 000000000..51a709514 --- /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 000000000..395fac66c --- /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 261873bdb..8c218b1c2 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 4d7a6fa98..10d7c3235 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 000000000..f2ca1abbd --- /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 2eb1f2763..9f52ba887 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 000000000..797656bd7 --- /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 000000000..6c34f9ad1 --- /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 ac3895f6d..042e332d0 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 3ac7c59af..83b386fde 100644 --- a/front/src/components/Pagination.vue +++ b/front/src/components/Pagination.vue @@ -49,4 +49,8 @@ export default { diff --git a/front/src/components/common/HumanDate.vue b/front/src/components/common/HumanDate.vue new file mode 100644 index 000000000..ff6ff5c71 --- /dev/null +++ b/front/src/components/common/HumanDate.vue @@ -0,0 +1,8 @@ + + diff --git a/front/src/components/discussion/Comment.vue b/front/src/components/discussion/Comment.vue new file mode 100644 index 000000000..a3c5176ec --- /dev/null +++ b/front/src/components/discussion/Comment.vue @@ -0,0 +1,49 @@ + + diff --git a/front/src/components/globals.js b/front/src/components/globals.js new file mode 100644 index 000000000..40315bc47 --- /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 cdcbe4b72..e4e22fc09 100644 --- a/front/src/components/library/Home.vue +++ b/front/src/components/library/Home.vue @@ -4,7 +4,7 @@
-
+

Latest artists

@@ -18,6 +18,10 @@
+
+

Music requests

+ +
@@ -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 5fe192022..6cd156493 100644 --- a/front/src/components/library/Library.vue +++ b/front/src/components/library/Library.vue @@ -5,8 +5,13 @@ Artists Radios @@ -14,9 +19,25 @@ diff --git a/front/src/components/library/import/ImportMixin.vue b/front/src/components/library/import/ImportMixin.vue index 33c6193bd..8b0757dcc 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 66ba8c661..0a1cc6df9 100644 --- a/front/src/components/library/import/Main.vue +++ b/front/src/components/library/import/Main.vue @@ -92,6 +92,7 @@ -
+
+

Music request

+

This import will be associated with the music request below. After the import is finished, the request will be marked as fulfilled.

+
@@ -121,6 +125,7 @@ + + + diff --git a/front/src/components/requests/Form.vue b/front/src/components/requests/Form.vue new file mode 100644 index 000000000..68c725ba7 --- /dev/null +++ b/front/src/components/requests/Form.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/front/src/components/requests/RequestsList.vue b/front/src/components/requests/RequestsList.vue new file mode 100644 index 000000000..cb3e9af00 --- /dev/null +++ b/front/src/components/requests/RequestsList.vue @@ -0,0 +1,163 @@ + + + + + + diff --git a/front/src/filters.js b/front/src/filters.js new file mode 100644 index 000000000..22d93149b --- /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 d1ff90c32..2e351310a 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 971ef05cd..ea8854bbe 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 000000000..c2b43da44 --- /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('

Hello world

') + }) + }) + 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') + }) + }) +})