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:
Eliot Berriot 2018-02-24 11:10:22 +00:00
commit 3c1e76e95d
41 changed files with 1029 additions and 37 deletions

View File

@ -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'),
] ]

View File

@ -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',

View File

@ -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:

View File

@ -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),
),
]

View File

@ -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),
]

View File

@ -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'),
),
]

View File

@ -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'])

View File

@ -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',)

View File

@ -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'

View File

@ -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])

View File

View File

@ -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

View File

@ -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'

View File

@ -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'],
}

View File

@ -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)),
],
),
]

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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']()

View File

@ -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

View File

@ -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'),

View File

@ -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'

View File

@ -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

View File

@ -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",

View File

@ -49,4 +49,8 @@ export default {
</script> </script>
<style scoped> <style scoped>
.ui.menu {
border: none;
box-shadow: none;
}
</style> </style>

View File

@ -0,0 +1,8 @@
<template>
<time :datetime="date" :title="date | moment">{{ date | ago }}</time>
</template>
<script>
export default {
props: ['date']
}
</script>

View File

@ -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>

View File

@ -0,0 +1,7 @@
import Vue from 'vue'
import HumanDate from '@/components/common/HumanDate'
Vue.component('human-date', HumanDate)
export default {}

View File

@ -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 {

View File

@ -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>

View File

@ -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

View File

@ -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: {

View File

@ -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>

View File

@ -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>

View File

@ -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>

44
front/src/filters.js Normal file
View File

@ -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 {}

View File

@ -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)

View File

@ -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 }

View File

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