Merge branch 'feature/music-requests' into 'develop'

Feature/music requests

Closes #9 and #25

See merge request funkwhale/funkwhale!48
merge-requests/154/head
Eliot Berriot 2018-02-24 11:10:22 +00:00
commit 3c1e76e95d
41 zmienionych plików z 1029 dodań i 37 usunięć

Wyświetl plik

@ -52,6 +52,10 @@ v1_patterns += [
include(
('funkwhale_api.users.api_urls', 'users'),
namespace='users')),
url(r'^requests/',
include(
('funkwhale_api.requests.api_urls', 'requests'),
namespace='requests')),
url(r'^token/$', jwt_views.obtain_jwt_token, name='token'),
url(r'^token/refresh/$', jwt_views.refresh_jwt_token, name='token_refresh'),
]

Wyświetl plik

@ -80,10 +80,12 @@ if RAVEN_ENABLED:
# Apps specific for this project go here.
LOCAL_APPS = (
'funkwhale_api.common',
'funkwhale_api.users', # custom users app
# Your stuff: custom apps go here
'funkwhale_api.instance',
'funkwhale_api.music',
'funkwhale_api.requests',
'funkwhale_api.favorites',
'funkwhale_api.radios',
'funkwhale_api.history',

Wyświetl plik

@ -17,9 +17,6 @@ class ListeningViewSet(mixins.CreateModelMixin,
queryset = models.Listening.objects.all()
permission_classes = [ConditionalAuthentication]
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
def get_queryset(self):
queryset = super().get_queryset()
if self.request.user.is_authenticated:

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -10,8 +10,11 @@ from django.conf import settings
from django.db import models
from django.core.files.base import ContentFile
from django.core.files import File
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone
from taggit.managers import TaggableManager
from versatileimagefield.fields import VersatileImageField
@ -400,6 +403,14 @@ class TrackFile(models.Model):
self.mimetype = utils.guess_mimetype(self.audio_file)
return super().save(**kwargs)
IMPORT_STATUS_CHOICES = (
('pending', 'Pending'),
('finished', 'Finished'),
('errored', 'Errored'),
('skipped', 'Skipped'),
)
class ImportBatch(models.Model):
IMPORT_BATCH_SOURCES = [
('api', 'api'),
@ -412,22 +423,24 @@ class ImportBatch(models.Model):
'users.User',
related_name='imports',
on_delete=models.CASCADE)
status = models.CharField(
choices=IMPORT_STATUS_CHOICES, default='pending', max_length=30)
import_request = models.ForeignKey(
'requests.ImportRequest',
related_name='import_batches',
null=True,
blank=True,
on_delete=models.CASCADE)
class Meta:
ordering = ['-creation_date']
def __str__(self):
return str(self.pk)
@property
def status(self):
pending = any([job.status == 'pending' for job in self.jobs.all()])
errored = any([job.status == 'errored' for job in self.jobs.all()])
if pending:
return 'pending'
if errored:
return 'errored'
return 'finished'
def update_status(self):
self.status = utils.compute_status(self.jobs.all())
self.save(update_fields=['status'])
class ImportJob(models.Model):
batch = models.ForeignKey(
@ -440,15 +453,39 @@ class ImportJob(models.Model):
on_delete=models.CASCADE)
source = models.CharField(max_length=500)
mbid = models.UUIDField(editable=False, null=True, blank=True)
STATUS_CHOICES = (
('pending', 'Pending'),
('finished', 'Finished'),
('errored', 'Errored'),
('skipped', 'Skipped'),
)
status = models.CharField(choices=STATUS_CHOICES, default='pending', max_length=30)
status = models.CharField(
choices=IMPORT_STATUS_CHOICES, default='pending', max_length=30)
audio_file = models.FileField(
upload_to='imports/%Y/%m/%d', max_length=255, null=True, blank=True)
class Meta:
ordering = ('id', )
@receiver(post_save, sender=ImportJob)
def update_batch_status(sender, instance, **kwargs):
instance.batch.update_status()
@receiver(post_save, sender=ImportBatch)
def update_request_status(sender, instance, created, **kwargs):
update_fields = kwargs.get('update_fields', []) or []
if not instance.import_request:
return
if not created and not 'status' in update_fields:
return
r_status = instance.import_request.status
status = instance.status
if status == 'pending' and r_status == 'pending':
# let's mark the request as accepted since we started an import
instance.import_request.status = 'accepted'
return instance.import_request.save(update_fields=['status'])
if status == 'finished' and r_status == 'accepted':
# let's mark the request as imported since the import is over
instance.import_request.status = 'imported'
return instance.import_request.save(update_fields=['status'])

Wyświetl plik

@ -125,5 +125,5 @@ class ImportBatchSerializer(serializers.ModelSerializer):
jobs = ImportJobSerializer(many=True, read_only=True)
class Meta:
model = models.ImportBatch
fields = ('id', 'jobs', 'status', 'creation_date')
fields = ('id', 'jobs', 'status', 'creation_date', 'import_request')
read_only_fields = ('creation_date',)

Wyświetl plik

@ -43,3 +43,13 @@ def get_query(query_string, search_fields):
def guess_mimetype(f):
b = min(100000, f.size)
return magic.from_buffer(f.read(b), mime=True)
def compute_status(jobs):
errored = any([job.status == 'errored' for job in jobs])
if errored:
return 'errored'
pending = any([job.status == 'pending' for job in jobs])
if pending:
return 'pending'
return 'finished'

Wyświetl plik

@ -19,6 +19,7 @@ from musicbrainzngs import ResponseError
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from funkwhale_api.requests.models import ImportRequest
from funkwhale_api.musicbrainz import api
from funkwhale_api.common.permissions import (
ConditionalAuthentication, HasModelPermission)
@ -314,14 +315,28 @@ class SubmitViewSet(viewsets.ViewSet):
serializer = serializers.ImportBatchSerializer(batch)
return Response(serializer.data)
def get_import_request(self, data):
try:
raw = data['importRequest']
except KeyError:
return
pk = int(raw)
try:
return ImportRequest.objects.get(pk=pk)
except ImportRequest.DoesNotExist:
pass
@list_route(methods=['post'])
@transaction.non_atomic_requests
def album(self, request, *args, **kwargs):
data = json.loads(request.body.decode('utf-8'))
import_data, batch = self._import_album(data, request, batch=None)
import_request = self.get_import_request(data)
import_data, batch = self._import_album(
data, request, batch=None, import_request=import_request)
return Response(import_data)
def _import_album(self, data, request, batch=None):
def _import_album(self, data, request, batch=None, import_request=None):
# we import the whole album here to prevent race conditions that occurs
# when using get_or_create_from_api in tasks
album_data = api.releases.get(id=data['releaseId'], includes=models.Album.api_includes)['release']
@ -332,7 +347,9 @@ class SubmitViewSet(viewsets.ViewSet):
except ResponseError:
pass
if not batch:
batch = models.ImportBatch.objects.create(submitted_by=request.user)
batch = models.ImportBatch.objects.create(
submitted_by=request.user,
import_request=import_request)
for row in data['tracks']:
try:
models.TrackFile.objects.get(track__mbid=row['mbid'])
@ -346,6 +363,7 @@ class SubmitViewSet(viewsets.ViewSet):
@transaction.non_atomic_requests
def artist(self, request, *args, **kwargs):
data = json.loads(request.body.decode('utf-8'))
import_request = self.get_import_request(data)
artist_data = api.artists.get(id=data['artistId'])['artist']
cleaned_data = models.Artist.clean_musicbrainz_data(artist_data)
artist = importers.load(models.Artist, cleaned_data, artist_data, import_hooks=[])
@ -353,7 +371,8 @@ class SubmitViewSet(viewsets.ViewSet):
import_data = []
batch = None
for row in data['albums']:
row_data, batch = self._import_album(row, request, batch=batch)
row_data, batch = self._import_album(
row, request, batch=batch, import_request=import_request)
import_data.append(row_data)
return Response(import_data[0])

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -3,6 +3,12 @@ from rest_framework import serializers
from . import models
class UserBasicSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ['id', 'username', 'name', 'date_joined']
class UserSerializer(serializers.ModelSerializer):
permissions = serializers.SerializerMethodField()

Wyświetl plik

@ -56,6 +56,24 @@ def api_client(client):
return APIClient()
@pytest.fixture
def logged_in_api_client(db, factories, api_client):
user = factories['users.User']()
assert api_client.login(username=user.username, password='test')
setattr(api_client, 'user', user)
yield api_client
delattr(api_client, 'user')
@pytest.fixture
def superuser_api_client(db, factories, api_client):
user = factories['users.SuperUser']()
assert api_client.login(username=user.username, password='test')
setattr(api_client, 'user', user)
yield api_client
delattr(api_client, 'user')
@pytest.fixture
def superuser_client(db, factories, client):
user = factories['users.SuperUser']()

Wyświetl plik

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

Wyświetl plik

@ -52,6 +52,20 @@ def test_import_job_is_bound_to_track_file(factories, mocker):
job.refresh_from_db()
assert job.track_file.track == track
@pytest.mark.parametrize('status', ['pending', 'errored', 'finished'])
def test_saving_job_updates_batch_status(status,factories, mocker):
batch = factories['music.ImportBatch']()
assert batch.status == 'pending'
job = factories['music.ImportJob'](batch=batch, status=status)
batch.refresh_from_db()
assert batch.status == status
@pytest.mark.parametrize('extention,mimetype', [
('ogg', 'audio/ogg'),
('mp3', 'audio/mpeg'),

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -20,9 +20,11 @@
"js-logger": "^1.3.0",
"jwt-decode": "^2.2.0",
"lodash": "^4.17.4",
"moment": "^2.20.1",
"moxios": "^0.4.0",
"raven-js": "^3.22.3",
"semantic-ui-css": "^2.2.10",
"showdown": "^1.8.6",
"vue": "^2.3.3",
"vue-lazyload": "^1.1.4",
"vue-router": "^2.3.1",

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -4,7 +4,7 @@
<search :autofocus="true"></search>
</div>
<div class="ui vertical stripe segment">
<div class="ui stackable two column grid">
<div class="ui stackable three column grid">
<div class="column">
<h2 class="ui header">Latest artists</h2>
<div :class="['ui', {'active': isLoadingArtists}, 'inline', 'loader']"></div>
@ -18,6 +18,10 @@
<radio-card :type="'random'"></radio-card>
<radio-card :type="'less-listened'"></radio-card>
</div>
<div class="column">
<h2 class="ui header">Music requests</h2>
<request-form></request-form>
</div>
</div>
</div>
</div>
@ -30,6 +34,7 @@ import backend from '@/audio/backend'
import logger from '@/logging'
import ArtistCard from '@/components/audio/artist/Card'
import RadioCard from '@/components/radios/Card'
import RequestForm from '@/components/requests/Form'
const ARTISTS_URL = 'artists/'
@ -38,7 +43,8 @@ export default {
components: {
Search,
ArtistCard,
RadioCard
RadioCard,
RequestForm
},
data () {
return {

Wyświetl plik

@ -5,8 +5,13 @@
<router-link class="ui item" to="/library/artists" exact>Artists</router-link>
<router-link class="ui item" to="/library/radios" exact>Radios</router-link>
<div class="ui secondary right menu">
<router-link class="ui item" to="/library/requests/" exact>
Requests
<div class="ui teal label">{{ requestsCount }}</div>
</router-link>
<router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>Import</router-link>
<router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches</router-link>
<router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches
</router-link>
</div>
</div>
<router-view :key="$route.fullPath"></router-view>
@ -14,9 +19,25 @@
</template>
<script>
import axios from 'axios'
export default {
name: 'library'
name: 'library',
data () {
return {
requestsCount: 0
}
},
created () {
this.fetchRequestsCount()
},
methods: {
fetchRequestsCount () {
let self = this
axios.get('requests/import-requests', {params: {status: 'pending'}}).then(response => {
self.requestsCount = response.data.count
})
}
}
}
</script>

Wyświetl plik

@ -13,7 +13,8 @@ export default {
defaultEnabled: {type: Boolean, default: true},
backends: {type: Array},
defaultBackendId: {type: String},
queryTemplate: {type: String, default: '$artist $title'}
queryTemplate: {type: String, default: '$artist $title'},
request: {type: Object, required: false}
},
data () {
return {
@ -32,6 +33,9 @@ export default {
this.isImporting = true
let url = 'submit/' + self.importType + '/'
let payload = self.importData
if (this.request) {
payload.importRequest = this.request.id
}
axios.post(url, payload).then((response) => {
logger.default.info('launched import for', self.type, self.metadata.id)
self.isImporting = false

Wyświetl plik

@ -92,6 +92,7 @@
<component
ref="import"
v-if="currentSource == 'external'"
:request="currentRequest"
:metadata="metadata"
:is="importComponent"
:backends="backends"
@ -113,7 +114,10 @@
</div>
</div>
</div>
<div class="ui vertical stripe segment">
<div class="ui vertical stripe segment" v-if="currentRequest">
<h3 class="ui header">Music request</h3>
<p>This import will be associated with the music request below. After the import is finished, the request will be marked as fulfilled.</p>
<request-card :request="currentRequest" :import-action="false"></request-card>
</div>
</div>
@ -121,6 +125,7 @@
<script>
import RequestCard from '@/components/requests/Card'
import MetadataSearch from '@/components/metadata/Search'
import ReleaseCard from '@/components/metadata/ReleaseCard'
import ArtistCard from '@/components/metadata/ArtistCard'
@ -128,6 +133,7 @@ import ReleaseImport from './ReleaseImport'
import FileUpload from './FileUpload'
import ArtistImport from './ArtistImport'
import axios from 'axios'
import router from '@/router'
import $ from 'jquery'
@ -138,19 +144,22 @@ export default {
ReleaseCard,
ArtistImport,
ReleaseImport,
FileUpload
FileUpload,
RequestCard
},
props: {
mbType: {type: String, required: false},
request: {type: String, required: false},
source: {type: String, required: false},
mbId: {type: String, required: false}
},
data: function () {
return {
currentRequest: null,
currentType: this.mbType || 'artist',
currentId: this.mbId,
currentStep: 0,
currentSource: '',
currentSource: this.source,
metadata: {},
isImporting: false,
importData: {
@ -166,6 +175,9 @@ export default {
}
},
created () {
if (this.request) {
this.fetchRequest(this.request)
}
if (this.currentSource) {
this.currentStep = 1
}
@ -179,7 +191,8 @@ export default {
query: {
source: this.currentSource,
type: this.currentType,
id: this.currentId
id: this.currentId,
request: this.request
}
})
},
@ -197,6 +210,12 @@ export default {
},
updateId (newValue) {
this.currentId = newValue
},
fetchRequest (id) {
let self = this
axios.get(`requests/import-requests/${id}`).then((response) => {
self.currentRequest = response.data
})
}
},
computed: {

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -13,6 +13,8 @@ import VueLazyload from 'vue-lazyload'
import store from './store'
import config from './config'
import { sync } from 'vuex-router-sync'
import filters from '@/filters' // eslint-disable-line
import globals from '@/components/globals' // eslint-disable-line
sync(store, router)

Wyświetl plik

@ -17,6 +17,7 @@ import LibraryRadios from '@/components/library/Radios'
import RadioBuilder from '@/components/library/radios/Builder'
import BatchList from '@/components/library/import/BatchList'
import BatchDetail from '@/components/library/import/BatchDetail'
import RequestsList from '@/components/requests/RequestsList'
import Favorites from '@/components/favorites/List'
@ -98,7 +99,11 @@ export default new Router({
path: 'import/launch',
name: 'library.import.launch',
component: LibraryImport,
props: (route) => ({ mbType: route.query.type, mbId: route.query.id })
props: (route) => ({
source: route.query.source,
request: route.query.request,
mbType: route.query.type,
mbId: route.query.id })
},
{
path: 'import/batches',
@ -107,7 +112,21 @@ export default new Router({
children: [
]
},
{ path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true }
{ path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true },
{
path: 'requests/',
name: 'library.requests',
component: RequestsList,
props: (route) => ({
defaultOrdering: route.query.ordering,
defaultQuery: route.query.query,
defaultPaginateBy: route.query.paginateBy,
defaultPage: route.query.page,
defaultStatus: route.query.status || 'pending'
}),
children: [
]
}
]
},
{ path: '*', component: PageNotFound }

Wyświetl plik

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