kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
Merge branch 'feature/music-requests' into 'develop'
Feature/music requests Closes #9 and #25 See merge request funkwhale/funkwhale!48merge-requests/154/head
commit
3c1e76e95d
|
@ -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'),
|
||||
]
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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'])
|
||||
|
|
|
@ -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',)
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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
|
|
@ -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'
|
|
@ -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'],
|
||||
}
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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()
|
||||
|
|
|
@ -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']()
|
||||
|
|
|
@ -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
|
|
@ -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'),
|
||||
|
|
|
@ -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'
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
@ -49,4 +49,8 @@ export default {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ui.menu {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
<template>
|
||||
<time :datetime="date" :title="date | moment">{{ date | ago }}</time>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: ['date']
|
||||
}
|
||||
</script>
|
|
@ -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>
|
|
@ -0,0 +1,7 @@
|
|||
import Vue from 'vue'
|
||||
|
||||
import HumanDate from '@/components/common/HumanDate'
|
||||
|
||||
Vue.component('human-date', HumanDate)
|
||||
|
||||
export default {}
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 {}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
Ładowanie…
Reference in New Issue