Merge branch 'feature/music-requests' into 'develop'
Feature/music requests Closes #9 and #25 See merge request funkwhale/funkwhale!48
This commit is contained in:
commit
3c1e76e95d
|
@ -52,6 +52,10 @@ v1_patterns += [
|
||||||
include(
|
include(
|
||||||
('funkwhale_api.users.api_urls', 'users'),
|
('funkwhale_api.users.api_urls', 'users'),
|
||||||
namespace='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/$', jwt_views.obtain_jwt_token, name='token'),
|
||||||
url(r'^token/refresh/$', jwt_views.refresh_jwt_token, name='token_refresh'),
|
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.
|
# Apps specific for this project go here.
|
||||||
LOCAL_APPS = (
|
LOCAL_APPS = (
|
||||||
|
'funkwhale_api.common',
|
||||||
'funkwhale_api.users', # custom users app
|
'funkwhale_api.users', # custom users app
|
||||||
# Your stuff: custom apps go here
|
# Your stuff: custom apps go here
|
||||||
'funkwhale_api.instance',
|
'funkwhale_api.instance',
|
||||||
'funkwhale_api.music',
|
'funkwhale_api.music',
|
||||||
|
'funkwhale_api.requests',
|
||||||
'funkwhale_api.favorites',
|
'funkwhale_api.favorites',
|
||||||
'funkwhale_api.radios',
|
'funkwhale_api.radios',
|
||||||
'funkwhale_api.history',
|
'funkwhale_api.history',
|
||||||
|
|
|
@ -17,9 +17,6 @@ class ListeningViewSet(mixins.CreateModelMixin,
|
||||||
queryset = models.Listening.objects.all()
|
queryset = models.Listening.objects.all()
|
||||||
permission_classes = [ConditionalAuthentication]
|
permission_classes = [ConditionalAuthentication]
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
|
||||||
return super().create(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
if self.request.user.is_authenticated:
|
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.db import models
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files import File
|
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.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
from versatileimagefield.fields import VersatileImageField
|
from versatileimagefield.fields import VersatileImageField
|
||||||
|
|
||||||
|
@ -400,6 +403,14 @@ class TrackFile(models.Model):
|
||||||
self.mimetype = utils.guess_mimetype(self.audio_file)
|
self.mimetype = utils.guess_mimetype(self.audio_file)
|
||||||
return super().save(**kwargs)
|
return super().save(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
IMPORT_STATUS_CHOICES = (
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('finished', 'Finished'),
|
||||||
|
('errored', 'Errored'),
|
||||||
|
('skipped', 'Skipped'),
|
||||||
|
)
|
||||||
|
|
||||||
class ImportBatch(models.Model):
|
class ImportBatch(models.Model):
|
||||||
IMPORT_BATCH_SOURCES = [
|
IMPORT_BATCH_SOURCES = [
|
||||||
('api', 'api'),
|
('api', 'api'),
|
||||||
|
@ -412,22 +423,24 @@ class ImportBatch(models.Model):
|
||||||
'users.User',
|
'users.User',
|
||||||
related_name='imports',
|
related_name='imports',
|
||||||
on_delete=models.CASCADE)
|
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:
|
class Meta:
|
||||||
ordering = ['-creation_date']
|
ordering = ['-creation_date']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return str(self.pk)
|
return str(self.pk)
|
||||||
|
|
||||||
@property
|
def update_status(self):
|
||||||
def status(self):
|
self.status = utils.compute_status(self.jobs.all())
|
||||||
pending = any([job.status == 'pending' for job in self.jobs.all()])
|
self.save(update_fields=['status'])
|
||||||
errored = any([job.status == 'errored' for job in self.jobs.all()])
|
|
||||||
if pending:
|
|
||||||
return 'pending'
|
|
||||||
if errored:
|
|
||||||
return 'errored'
|
|
||||||
return 'finished'
|
|
||||||
|
|
||||||
class ImportJob(models.Model):
|
class ImportJob(models.Model):
|
||||||
batch = models.ForeignKey(
|
batch = models.ForeignKey(
|
||||||
|
@ -440,15 +453,39 @@ class ImportJob(models.Model):
|
||||||
on_delete=models.CASCADE)
|
on_delete=models.CASCADE)
|
||||||
source = models.CharField(max_length=500)
|
source = models.CharField(max_length=500)
|
||||||
mbid = models.UUIDField(editable=False, null=True, blank=True)
|
mbid = models.UUIDField(editable=False, null=True, blank=True)
|
||||||
STATUS_CHOICES = (
|
|
||||||
('pending', 'Pending'),
|
status = models.CharField(
|
||||||
('finished', 'Finished'),
|
choices=IMPORT_STATUS_CHOICES, default='pending', max_length=30)
|
||||||
('errored', 'Errored'),
|
|
||||||
('skipped', 'Skipped'),
|
|
||||||
)
|
|
||||||
status = models.CharField(choices=STATUS_CHOICES, default='pending', max_length=30)
|
|
||||||
audio_file = models.FileField(
|
audio_file = models.FileField(
|
||||||
upload_to='imports/%Y/%m/%d', max_length=255, null=True, blank=True)
|
upload_to='imports/%Y/%m/%d', max_length=255, null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('id', )
|
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)
|
jobs = ImportJobSerializer(many=True, read_only=True)
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.ImportBatch
|
model = models.ImportBatch
|
||||||
fields = ('id', 'jobs', 'status', 'creation_date')
|
fields = ('id', 'jobs', 'status', 'creation_date', 'import_request')
|
||||||
read_only_fields = ('creation_date',)
|
read_only_fields = ('creation_date',)
|
||||||
|
|
|
@ -43,3 +43,13 @@ def get_query(query_string, search_fields):
|
||||||
def guess_mimetype(f):
|
def guess_mimetype(f):
|
||||||
b = min(100000, f.size)
|
b = min(100000, f.size)
|
||||||
return magic.from_buffer(f.read(b), mime=True)
|
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.contrib.auth.decorators import login_required
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
|
|
||||||
|
from funkwhale_api.requests.models import ImportRequest
|
||||||
from funkwhale_api.musicbrainz import api
|
from funkwhale_api.musicbrainz import api
|
||||||
from funkwhale_api.common.permissions import (
|
from funkwhale_api.common.permissions import (
|
||||||
ConditionalAuthentication, HasModelPermission)
|
ConditionalAuthentication, HasModelPermission)
|
||||||
|
@ -314,14 +315,28 @@ class SubmitViewSet(viewsets.ViewSet):
|
||||||
serializer = serializers.ImportBatchSerializer(batch)
|
serializer = serializers.ImportBatchSerializer(batch)
|
||||||
return Response(serializer.data)
|
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'])
|
@list_route(methods=['post'])
|
||||||
@transaction.non_atomic_requests
|
@transaction.non_atomic_requests
|
||||||
def album(self, request, *args, **kwargs):
|
def album(self, request, *args, **kwargs):
|
||||||
data = json.loads(request.body.decode('utf-8'))
|
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)
|
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
|
# we import the whole album here to prevent race conditions that occurs
|
||||||
# when using get_or_create_from_api in tasks
|
# when using get_or_create_from_api in tasks
|
||||||
album_data = api.releases.get(id=data['releaseId'], includes=models.Album.api_includes)['release']
|
album_data = api.releases.get(id=data['releaseId'], includes=models.Album.api_includes)['release']
|
||||||
|
@ -332,7 +347,9 @@ class SubmitViewSet(viewsets.ViewSet):
|
||||||
except ResponseError:
|
except ResponseError:
|
||||||
pass
|
pass
|
||||||
if not batch:
|
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']:
|
for row in data['tracks']:
|
||||||
try:
|
try:
|
||||||
models.TrackFile.objects.get(track__mbid=row['mbid'])
|
models.TrackFile.objects.get(track__mbid=row['mbid'])
|
||||||
|
@ -346,6 +363,7 @@ class SubmitViewSet(viewsets.ViewSet):
|
||||||
@transaction.non_atomic_requests
|
@transaction.non_atomic_requests
|
||||||
def artist(self, request, *args, **kwargs):
|
def artist(self, request, *args, **kwargs):
|
||||||
data = json.loads(request.body.decode('utf-8'))
|
data = json.loads(request.body.decode('utf-8'))
|
||||||
|
import_request = self.get_import_request(data)
|
||||||
artist_data = api.artists.get(id=data['artistId'])['artist']
|
artist_data = api.artists.get(id=data['artistId'])['artist']
|
||||||
cleaned_data = models.Artist.clean_musicbrainz_data(artist_data)
|
cleaned_data = models.Artist.clean_musicbrainz_data(artist_data)
|
||||||
artist = importers.load(models.Artist, cleaned_data, artist_data, import_hooks=[])
|
artist = importers.load(models.Artist, cleaned_data, artist_data, import_hooks=[])
|
||||||
|
@ -353,7 +371,8 @@ class SubmitViewSet(viewsets.ViewSet):
|
||||||
import_data = []
|
import_data = []
|
||||||
batch = None
|
batch = None
|
||||||
for row in data['albums']:
|
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)
|
import_data.append(row_data)
|
||||||
|
|
||||||
return Response(import_data[0])
|
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
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
class UserBasicSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = ['id', 'username', 'name', 'date_joined']
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
permissions = serializers.SerializerMethodField()
|
permissions = serializers.SerializerMethodField()
|
||||||
|
|
|
@ -56,6 +56,24 @@ def api_client(client):
|
||||||
return APIClient()
|
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
|
@pytest.fixture
|
||||||
def superuser_client(db, factories, client):
|
def superuser_client(db, factories, client):
|
||||||
user = factories['users.SuperUser']()
|
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()
|
job.refresh_from_db()
|
||||||
assert job.track_file.track == track
|
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', [
|
@pytest.mark.parametrize('extention,mimetype', [
|
||||||
('ogg', 'audio/ogg'),
|
('ogg', 'audio/ogg'),
|
||||||
('mp3', 'audio/mpeg'),
|
('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",
|
"js-logger": "^1.3.0",
|
||||||
"jwt-decode": "^2.2.0",
|
"jwt-decode": "^2.2.0",
|
||||||
"lodash": "^4.17.4",
|
"lodash": "^4.17.4",
|
||||||
|
"moment": "^2.20.1",
|
||||||
"moxios": "^0.4.0",
|
"moxios": "^0.4.0",
|
||||||
"raven-js": "^3.22.3",
|
"raven-js": "^3.22.3",
|
||||||
"semantic-ui-css": "^2.2.10",
|
"semantic-ui-css": "^2.2.10",
|
||||||
|
"showdown": "^1.8.6",
|
||||||
"vue": "^2.3.3",
|
"vue": "^2.3.3",
|
||||||
"vue-lazyload": "^1.1.4",
|
"vue-lazyload": "^1.1.4",
|
||||||
"vue-router": "^2.3.1",
|
"vue-router": "^2.3.1",
|
||||||
|
|
|
@ -49,4 +49,8 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.ui.menu {
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
</style>
|
</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>
|
<search :autofocus="true"></search>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui vertical stripe segment">
|
<div class="ui vertical stripe segment">
|
||||||
<div class="ui stackable two column grid">
|
<div class="ui stackable three column grid">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<h2 class="ui header">Latest artists</h2>
|
<h2 class="ui header">Latest artists</h2>
|
||||||
<div :class="['ui', {'active': isLoadingArtists}, 'inline', 'loader']"></div>
|
<div :class="['ui', {'active': isLoadingArtists}, 'inline', 'loader']"></div>
|
||||||
|
@ -18,6 +18,10 @@
|
||||||
<radio-card :type="'random'"></radio-card>
|
<radio-card :type="'random'"></radio-card>
|
||||||
<radio-card :type="'less-listened'"></radio-card>
|
<radio-card :type="'less-listened'"></radio-card>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<h2 class="ui header">Music requests</h2>
|
||||||
|
<request-form></request-form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -30,6 +34,7 @@ import backend from '@/audio/backend'
|
||||||
import logger from '@/logging'
|
import logger from '@/logging'
|
||||||
import ArtistCard from '@/components/audio/artist/Card'
|
import ArtistCard from '@/components/audio/artist/Card'
|
||||||
import RadioCard from '@/components/radios/Card'
|
import RadioCard from '@/components/radios/Card'
|
||||||
|
import RequestForm from '@/components/requests/Form'
|
||||||
|
|
||||||
const ARTISTS_URL = 'artists/'
|
const ARTISTS_URL = 'artists/'
|
||||||
|
|
||||||
|
@ -38,7 +43,8 @@ export default {
|
||||||
components: {
|
components: {
|
||||||
Search,
|
Search,
|
||||||
ArtistCard,
|
ArtistCard,
|
||||||
RadioCard
|
RadioCard,
|
||||||
|
RequestForm
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -5,8 +5,13 @@
|
||||||
<router-link class="ui item" to="/library/artists" exact>Artists</router-link>
|
<router-link class="ui item" to="/library/artists" exact>Artists</router-link>
|
||||||
<router-link class="ui item" to="/library/radios" exact>Radios</router-link>
|
<router-link class="ui item" to="/library/radios" exact>Radios</router-link>
|
||||||
<div class="ui secondary right menu">
|
<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/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>
|
||||||
</div>
|
</div>
|
||||||
<router-view :key="$route.fullPath"></router-view>
|
<router-view :key="$route.fullPath"></router-view>
|
||||||
|
@ -14,9 +19,25 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import axios from 'axios'
|
||||||
export default {
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,8 @@ export default {
|
||||||
defaultEnabled: {type: Boolean, default: true},
|
defaultEnabled: {type: Boolean, default: true},
|
||||||
backends: {type: Array},
|
backends: {type: Array},
|
||||||
defaultBackendId: {type: String},
|
defaultBackendId: {type: String},
|
||||||
queryTemplate: {type: String, default: '$artist $title'}
|
queryTemplate: {type: String, default: '$artist $title'},
|
||||||
|
request: {type: Object, required: false}
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
|
@ -32,6 +33,9 @@ export default {
|
||||||
this.isImporting = true
|
this.isImporting = true
|
||||||
let url = 'submit/' + self.importType + '/'
|
let url = 'submit/' + self.importType + '/'
|
||||||
let payload = self.importData
|
let payload = self.importData
|
||||||
|
if (this.request) {
|
||||||
|
payload.importRequest = this.request.id
|
||||||
|
}
|
||||||
axios.post(url, payload).then((response) => {
|
axios.post(url, payload).then((response) => {
|
||||||
logger.default.info('launched import for', self.type, self.metadata.id)
|
logger.default.info('launched import for', self.type, self.metadata.id)
|
||||||
self.isImporting = false
|
self.isImporting = false
|
||||||
|
|
|
@ -92,6 +92,7 @@
|
||||||
<component
|
<component
|
||||||
ref="import"
|
ref="import"
|
||||||
v-if="currentSource == 'external'"
|
v-if="currentSource == 'external'"
|
||||||
|
:request="currentRequest"
|
||||||
:metadata="metadata"
|
:metadata="metadata"
|
||||||
:is="importComponent"
|
:is="importComponent"
|
||||||
:backends="backends"
|
:backends="backends"
|
||||||
|
@ -113,7 +114,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
@ -121,6 +125,7 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
|
import RequestCard from '@/components/requests/Card'
|
||||||
import MetadataSearch from '@/components/metadata/Search'
|
import MetadataSearch from '@/components/metadata/Search'
|
||||||
import ReleaseCard from '@/components/metadata/ReleaseCard'
|
import ReleaseCard from '@/components/metadata/ReleaseCard'
|
||||||
import ArtistCard from '@/components/metadata/ArtistCard'
|
import ArtistCard from '@/components/metadata/ArtistCard'
|
||||||
|
@ -128,6 +133,7 @@ import ReleaseImport from './ReleaseImport'
|
||||||
import FileUpload from './FileUpload'
|
import FileUpload from './FileUpload'
|
||||||
import ArtistImport from './ArtistImport'
|
import ArtistImport from './ArtistImport'
|
||||||
|
|
||||||
|
import axios from 'axios'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import $ from 'jquery'
|
import $ from 'jquery'
|
||||||
|
|
||||||
|
@ -138,19 +144,22 @@ export default {
|
||||||
ReleaseCard,
|
ReleaseCard,
|
||||||
ArtistImport,
|
ArtistImport,
|
||||||
ReleaseImport,
|
ReleaseImport,
|
||||||
FileUpload
|
FileUpload,
|
||||||
|
RequestCard
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
mbType: {type: String, required: false},
|
mbType: {type: String, required: false},
|
||||||
|
request: {type: String, required: false},
|
||||||
source: {type: String, required: false},
|
source: {type: String, required: false},
|
||||||
mbId: {type: String, required: false}
|
mbId: {type: String, required: false}
|
||||||
},
|
},
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
|
currentRequest: null,
|
||||||
currentType: this.mbType || 'artist',
|
currentType: this.mbType || 'artist',
|
||||||
currentId: this.mbId,
|
currentId: this.mbId,
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
currentSource: '',
|
currentSource: this.source,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
isImporting: false,
|
isImporting: false,
|
||||||
importData: {
|
importData: {
|
||||||
|
@ -166,6 +175,9 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created () {
|
created () {
|
||||||
|
if (this.request) {
|
||||||
|
this.fetchRequest(this.request)
|
||||||
|
}
|
||||||
if (this.currentSource) {
|
if (this.currentSource) {
|
||||||
this.currentStep = 1
|
this.currentStep = 1
|
||||||
}
|
}
|
||||||
|
@ -179,7 +191,8 @@ export default {
|
||||||
query: {
|
query: {
|
||||||
source: this.currentSource,
|
source: this.currentSource,
|
||||||
type: this.currentType,
|
type: this.currentType,
|
||||||
id: this.currentId
|
id: this.currentId,
|
||||||
|
request: this.request
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
@ -197,6 +210,12 @@ export default {
|
||||||
},
|
},
|
||||||
updateId (newValue) {
|
updateId (newValue) {
|
||||||
this.currentId = newValue
|
this.currentId = newValue
|
||||||
|
},
|
||||||
|
fetchRequest (id) {
|
||||||
|
let self = this
|
||||||
|
axios.get(`requests/import-requests/${id}`).then((response) => {
|
||||||
|
self.currentRequest = response.data
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
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 store from './store'
|
||||||
import config from './config'
|
import config from './config'
|
||||||
import { sync } from 'vuex-router-sync'
|
import { sync } from 'vuex-router-sync'
|
||||||
|
import filters from '@/filters' // eslint-disable-line
|
||||||
|
import globals from '@/components/globals' // eslint-disable-line
|
||||||
|
|
||||||
sync(store, router)
|
sync(store, router)
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@ import LibraryRadios from '@/components/library/Radios'
|
||||||
import RadioBuilder from '@/components/library/radios/Builder'
|
import RadioBuilder from '@/components/library/radios/Builder'
|
||||||
import BatchList from '@/components/library/import/BatchList'
|
import BatchList from '@/components/library/import/BatchList'
|
||||||
import BatchDetail from '@/components/library/import/BatchDetail'
|
import BatchDetail from '@/components/library/import/BatchDetail'
|
||||||
|
import RequestsList from '@/components/requests/RequestsList'
|
||||||
|
|
||||||
import Favorites from '@/components/favorites/List'
|
import Favorites from '@/components/favorites/List'
|
||||||
|
|
||||||
|
@ -98,7 +99,11 @@ export default new Router({
|
||||||
path: 'import/launch',
|
path: 'import/launch',
|
||||||
name: 'library.import.launch',
|
name: 'library.import.launch',
|
||||||
component: LibraryImport,
|
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',
|
path: 'import/batches',
|
||||||
|
@ -107,7 +112,21 @@ export default new Router({
|
||||||
children: [
|
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 }
|
{ 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in New Issue