diff --git a/api/config/api_urls.py b/api/config/api_urls.py
index c7ebc4ed3..ff6db0d06 100644
--- a/api/config/api_urls.py
+++ b/api/config/api_urls.py
@@ -52,6 +52,10 @@ v1_patterns += [
include(
('funkwhale_api.users.api_urls', 'users'),
namespace='users')),
+ url(r'^requests/',
+ include(
+ ('funkwhale_api.requests.api_urls', 'requests'),
+ namespace='requests')),
url(r'^token/$', jwt_views.obtain_jwt_token, name='token'),
url(r'^token/refresh/$', jwt_views.refresh_jwt_token, name='token_refresh'),
]
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 6d02cbbc1..5fe55e53a 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -80,10 +80,12 @@ if RAVEN_ENABLED:
# Apps specific for this project go here.
LOCAL_APPS = (
+ 'funkwhale_api.common',
'funkwhale_api.users', # custom users app
# Your stuff: custom apps go here
'funkwhale_api.instance',
'funkwhale_api.music',
+ 'funkwhale_api.requests',
'funkwhale_api.favorites',
'funkwhale_api.radios',
'funkwhale_api.history',
diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py
index 32bad6060..59dcbd26b 100644
--- a/api/funkwhale_api/history/views.py
+++ b/api/funkwhale_api/history/views.py
@@ -17,9 +17,6 @@ class ListeningViewSet(mixins.CreateModelMixin,
queryset = models.Listening.objects.all()
permission_classes = [ConditionalAuthentication]
- def create(self, request, *args, **kwargs):
- return super().create(request, *args, **kwargs)
-
def get_queryset(self):
queryset = super().get_queryset()
if self.request.user.is_authenticated:
diff --git a/api/funkwhale_api/music/migrations/0020_importbatch_status.py b/api/funkwhale_api/music/migrations/0020_importbatch_status.py
new file mode 100644
index 000000000..265d1ba5d
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0020_importbatch_status.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.2 on 2018-02-20 19:12
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('music', '0019_populate_mimetypes'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='importbatch',
+ name='status',
+ field=models.CharField(choices=[('pending', 'Pending'), ('finished', 'Finished'), ('errored', 'Errored'), ('skipped', 'Skipped')], default='pending', max_length=30),
+ ),
+ ]
diff --git a/api/funkwhale_api/music/migrations/0021_populate_batch_status.py b/api/funkwhale_api/music/migrations/0021_populate_batch_status.py
new file mode 100644
index 000000000..061d649b0
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0021_populate_batch_status.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+import os
+
+from django.db import migrations, models
+
+
+def populate_status(apps, schema_editor):
+ from funkwhale_api.music.utils import compute_status
+ ImportBatch = apps.get_model("music", "ImportBatch")
+
+ for ib in ImportBatch.objects.prefetch_related('jobs'):
+ ib.status = compute_status(ib.jobs.all())
+ ib.save(update_fields=['status'])
+
+
+def rewind(apps, schema_editor):
+ pass
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('music', '0020_importbatch_status'),
+ ]
+
+ operations = [
+ migrations.RunPython(populate_status, rewind),
+ ]
diff --git a/api/funkwhale_api/music/migrations/0022_importbatch_import_request.py b/api/funkwhale_api/music/migrations/0022_importbatch_import_request.py
new file mode 100644
index 000000000..d9f6f01d9
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0022_importbatch_import_request.py
@@ -0,0 +1,20 @@
+# Generated by Django 2.0.2 on 2018-02-20 22:48
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('requests', '__first__'),
+ ('music', '0021_populate_batch_status'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='importbatch',
+ name='import_request',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='import_batches', to='requests.ImportRequest'),
+ ),
+ ]
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index 3ebd07419..97992fc8f 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -10,8 +10,11 @@ from django.conf import settings
from django.db import models
from django.core.files.base import ContentFile
from django.core.files import File
+from django.db.models.signals import post_save
+from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone
+
from taggit.managers import TaggableManager
from versatileimagefield.fields import VersatileImageField
@@ -400,6 +403,14 @@ class TrackFile(models.Model):
self.mimetype = utils.guess_mimetype(self.audio_file)
return super().save(**kwargs)
+
+IMPORT_STATUS_CHOICES = (
+ ('pending', 'Pending'),
+ ('finished', 'Finished'),
+ ('errored', 'Errored'),
+ ('skipped', 'Skipped'),
+)
+
class ImportBatch(models.Model):
IMPORT_BATCH_SOURCES = [
('api', 'api'),
@@ -412,22 +423,24 @@ class ImportBatch(models.Model):
'users.User',
related_name='imports',
on_delete=models.CASCADE)
-
+ status = models.CharField(
+ choices=IMPORT_STATUS_CHOICES, default='pending', max_length=30)
+ import_request = models.ForeignKey(
+ 'requests.ImportRequest',
+ related_name='import_batches',
+ null=True,
+ blank=True,
+ on_delete=models.CASCADE)
class Meta:
ordering = ['-creation_date']
def __str__(self):
return str(self.pk)
- @property
- def status(self):
- pending = any([job.status == 'pending' for job in self.jobs.all()])
- errored = any([job.status == 'errored' for job in self.jobs.all()])
- if pending:
- return 'pending'
- if errored:
- return 'errored'
- return 'finished'
+ def update_status(self):
+ self.status = utils.compute_status(self.jobs.all())
+ self.save(update_fields=['status'])
+
class ImportJob(models.Model):
batch = models.ForeignKey(
@@ -440,15 +453,39 @@ class ImportJob(models.Model):
on_delete=models.CASCADE)
source = models.CharField(max_length=500)
mbid = models.UUIDField(editable=False, null=True, blank=True)
- STATUS_CHOICES = (
- ('pending', 'Pending'),
- ('finished', 'Finished'),
- ('errored', 'Errored'),
- ('skipped', 'Skipped'),
- )
- status = models.CharField(choices=STATUS_CHOICES, default='pending', max_length=30)
+
+ status = models.CharField(
+ choices=IMPORT_STATUS_CHOICES, default='pending', max_length=30)
audio_file = models.FileField(
upload_to='imports/%Y/%m/%d', max_length=255, null=True, blank=True)
class Meta:
ordering = ('id', )
+
+
+@receiver(post_save, sender=ImportJob)
+def update_batch_status(sender, instance, **kwargs):
+ instance.batch.update_status()
+
+
+@receiver(post_save, sender=ImportBatch)
+def update_request_status(sender, instance, created, **kwargs):
+ update_fields = kwargs.get('update_fields', []) or []
+ if not instance.import_request:
+ return
+
+ if not created and not 'status' in update_fields:
+ return
+
+ r_status = instance.import_request.status
+ status = instance.status
+
+ if status == 'pending' and r_status == 'pending':
+ # let's mark the request as accepted since we started an import
+ instance.import_request.status = 'accepted'
+ return instance.import_request.save(update_fields=['status'])
+
+ if status == 'finished' and r_status == 'accepted':
+ # let's mark the request as imported since the import is over
+ instance.import_request.status = 'imported'
+ return instance.import_request.save(update_fields=['status'])
diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py
index 41de30f10..db6298a9e 100644
--- a/api/funkwhale_api/music/serializers.py
+++ b/api/funkwhale_api/music/serializers.py
@@ -125,5 +125,5 @@ class ImportBatchSerializer(serializers.ModelSerializer):
jobs = ImportJobSerializer(many=True, read_only=True)
class Meta:
model = models.ImportBatch
- fields = ('id', 'jobs', 'status', 'creation_date')
+ fields = ('id', 'jobs', 'status', 'creation_date', 'import_request')
read_only_fields = ('creation_date',)
diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py
index 0e4318e56..a75cf5de8 100644
--- a/api/funkwhale_api/music/utils.py
+++ b/api/funkwhale_api/music/utils.py
@@ -43,3 +43,13 @@ def get_query(query_string, search_fields):
def guess_mimetype(f):
b = min(100000, f.size)
return magic.from_buffer(f.read(b), mime=True)
+
+
+def compute_status(jobs):
+ errored = any([job.status == 'errored' for job in jobs])
+ if errored:
+ return 'errored'
+ pending = any([job.status == 'pending' for job in jobs])
+ if pending:
+ return 'pending'
+ return 'finished'
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index 8e46cbd71..bf9d39b1d 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -19,6 +19,7 @@ from musicbrainzngs import ResponseError
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
+from funkwhale_api.requests.models import ImportRequest
from funkwhale_api.musicbrainz import api
from funkwhale_api.common.permissions import (
ConditionalAuthentication, HasModelPermission)
@@ -314,14 +315,28 @@ class SubmitViewSet(viewsets.ViewSet):
serializer = serializers.ImportBatchSerializer(batch)
return Response(serializer.data)
+ def get_import_request(self, data):
+ try:
+ raw = data['importRequest']
+ except KeyError:
+ return
+
+ pk = int(raw)
+ try:
+ return ImportRequest.objects.get(pk=pk)
+ except ImportRequest.DoesNotExist:
+ pass
+
@list_route(methods=['post'])
@transaction.non_atomic_requests
def album(self, request, *args, **kwargs):
data = json.loads(request.body.decode('utf-8'))
- import_data, batch = self._import_album(data, request, batch=None)
+ import_request = self.get_import_request(data)
+ import_data, batch = self._import_album(
+ data, request, batch=None, import_request=import_request)
return Response(import_data)
- def _import_album(self, data, request, batch=None):
+ def _import_album(self, data, request, batch=None, import_request=None):
# we import the whole album here to prevent race conditions that occurs
# when using get_or_create_from_api in tasks
album_data = api.releases.get(id=data['releaseId'], includes=models.Album.api_includes)['release']
@@ -332,7 +347,9 @@ class SubmitViewSet(viewsets.ViewSet):
except ResponseError:
pass
if not batch:
- batch = models.ImportBatch.objects.create(submitted_by=request.user)
+ batch = models.ImportBatch.objects.create(
+ submitted_by=request.user,
+ import_request=import_request)
for row in data['tracks']:
try:
models.TrackFile.objects.get(track__mbid=row['mbid'])
@@ -346,6 +363,7 @@ class SubmitViewSet(viewsets.ViewSet):
@transaction.non_atomic_requests
def artist(self, request, *args, **kwargs):
data = json.loads(request.body.decode('utf-8'))
+ import_request = self.get_import_request(data)
artist_data = api.artists.get(id=data['artistId'])['artist']
cleaned_data = models.Artist.clean_musicbrainz_data(artist_data)
artist = importers.load(models.Artist, cleaned_data, artist_data, import_hooks=[])
@@ -353,7 +371,8 @@ class SubmitViewSet(viewsets.ViewSet):
import_data = []
batch = None
for row in data['albums']:
- row_data, batch = self._import_album(row, request, batch=batch)
+ row_data, batch = self._import_album(
+ row, request, batch=batch, import_request=import_request)
import_data.append(row_data)
return Response(import_data[0])
diff --git a/api/funkwhale_api/requests/__init__.py b/api/funkwhale_api/requests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/funkwhale_api/requests/api_urls.py b/api/funkwhale_api/requests/api_urls.py
new file mode 100644
index 000000000..37459a664
--- /dev/null
+++ b/api/funkwhale_api/requests/api_urls.py
@@ -0,0 +1,11 @@
+from django.conf.urls import include, url
+from . import views
+
+from rest_framework import routers
+router = routers.SimpleRouter()
+router.register(
+ r'import-requests',
+ views.ImportRequestViewSet,
+ 'import-requests')
+
+urlpatterns = router.urls
diff --git a/api/funkwhale_api/requests/factories.py b/api/funkwhale_api/requests/factories.py
new file mode 100644
index 000000000..2bcdeb6a9
--- /dev/null
+++ b/api/funkwhale_api/requests/factories.py
@@ -0,0 +1,15 @@
+import factory
+
+from funkwhale_api.factories import registry
+from funkwhale_api.users.factories import UserFactory
+
+
+@registry.register
+class ImportRequestFactory(factory.django.DjangoModelFactory):
+ artist_name = factory.Faker('name')
+ albums = factory.Faker('sentence')
+ user = factory.SubFactory(UserFactory)
+ comment = factory.Faker('paragraph')
+
+ class Meta:
+ model = 'requests.ImportRequest'
diff --git a/api/funkwhale_api/requests/filters.py b/api/funkwhale_api/requests/filters.py
new file mode 100644
index 000000000..bf353e8ad
--- /dev/null
+++ b/api/funkwhale_api/requests/filters.py
@@ -0,0 +1,14 @@
+import django_filters
+
+from . import models
+
+
+class ImportRequestFilter(django_filters.FilterSet):
+
+ class Meta:
+ model = models.ImportRequest
+ fields = {
+ 'artist_name': ['exact', 'iexact', 'startswith', 'icontains'],
+ 'status': ['exact'],
+ 'user__username': ['exact'],
+ }
diff --git a/api/funkwhale_api/requests/migrations/0001_initial.py b/api/funkwhale_api/requests/migrations/0001_initial.py
new file mode 100644
index 000000000..7c239b3c0
--- /dev/null
+++ b/api/funkwhale_api/requests/migrations/0001_initial.py
@@ -0,0 +1,31 @@
+# Generated by Django 2.0.2 on 2018-02-20 22:49
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ImportRequest',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
+ ('imported_date', models.DateTimeField(blank=True, null=True)),
+ ('artist_name', models.CharField(max_length=250)),
+ ('albums', models.CharField(blank=True, max_length=3000, null=True)),
+ ('status', models.CharField(choices=[('pending', 'pending'), ('accepted', 'accepted'), ('imported', 'imported'), ('closed', 'closed')], default='pending', max_length=50)),
+ ('comment', models.TextField(blank=True, max_length=3000, null=True)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='import_requests', to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/api/funkwhale_api/requests/migrations/__init__.py b/api/funkwhale_api/requests/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/funkwhale_api/requests/models.py b/api/funkwhale_api/requests/models.py
new file mode 100644
index 000000000..c29852430
--- /dev/null
+++ b/api/funkwhale_api/requests/models.py
@@ -0,0 +1,29 @@
+from django.db import models
+
+from django.utils import timezone
+
+NATURE_CHOICES = [
+ ('artist', 'artist'),
+ ('album', 'album'),
+ ('track', 'track'),
+]
+
+STATUS_CHOICES = [
+ ('pending', 'pending'),
+ ('accepted', 'accepted'),
+ ('imported', 'imported'),
+ ('closed', 'closed'),
+]
+
+class ImportRequest(models.Model):
+ creation_date = models.DateTimeField(default=timezone.now)
+ imported_date = models.DateTimeField(null=True, blank=True)
+ user = models.ForeignKey(
+ 'users.User',
+ related_name='import_requests',
+ on_delete=models.CASCADE)
+ artist_name = models.CharField(max_length=250)
+ albums = models.CharField(max_length=3000, null=True, blank=True)
+ status = models.CharField(
+ choices=STATUS_CHOICES, max_length=50, default='pending')
+ comment = models.TextField(null=True, blank=True, max_length=3000)
diff --git a/api/funkwhale_api/requests/serializers.py b/api/funkwhale_api/requests/serializers.py
new file mode 100644
index 000000000..51a709514
--- /dev/null
+++ b/api/funkwhale_api/requests/serializers.py
@@ -0,0 +1,30 @@
+from rest_framework import serializers
+
+from funkwhale_api.users.serializers import UserBasicSerializer
+
+from . import models
+
+
+class ImportRequestSerializer(serializers.ModelSerializer):
+ user = UserBasicSerializer(read_only=True)
+
+ class Meta:
+ model = models.ImportRequest
+ fields = (
+ 'id',
+ 'status',
+ 'albums',
+ 'artist_name',
+ 'user',
+ 'creation_date',
+ 'imported_date',
+ 'comment')
+ read_only_fields = (
+ 'creation_date',
+ 'imported_date',
+ 'user',
+ 'status')
+
+ def create(self, validated_data):
+ validated_data['user'] = self.context['user']
+ return super().create(validated_data)
diff --git a/api/funkwhale_api/requests/views.py b/api/funkwhale_api/requests/views.py
new file mode 100644
index 000000000..395fac66c
--- /dev/null
+++ b/api/funkwhale_api/requests/views.py
@@ -0,0 +1,36 @@
+from rest_framework import generics, mixins, viewsets
+from rest_framework import status
+from rest_framework.response import Response
+from rest_framework.decorators import detail_route
+
+from funkwhale_api.music.views import SearchMixin
+
+from . import filters
+from . import models
+from . import serializers
+
+
+class ImportRequestViewSet(
+ SearchMixin,
+ mixins.CreateModelMixin,
+ mixins.RetrieveModelMixin,
+ mixins.ListModelMixin,
+ viewsets.GenericViewSet):
+
+ serializer_class = serializers.ImportRequestSerializer
+ queryset = (
+ models.ImportRequest.objects.all()
+ .select_related()
+ .order_by('-creation_date'))
+ search_fields = ['artist_name', 'album_name', 'comment']
+ filter_class = filters.ImportRequestFilter
+ ordering_fields = ('id', 'artist_name', 'creation_date', 'status')
+
+ def perform_create(self, serializer):
+ return serializer.save(user=self.request.user)
+
+ def get_serializer_context(self):
+ context = super().get_serializer_context()
+ if self.request.user.is_authenticated:
+ context['user'] = self.request.user
+ return context
diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py
index 261873bdb..8c218b1c2 100644
--- a/api/funkwhale_api/users/serializers.py
+++ b/api/funkwhale_api/users/serializers.py
@@ -3,6 +3,12 @@ from rest_framework import serializers
from . import models
+class UserBasicSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = models.User
+ fields = ['id', 'username', 'name', 'date_joined']
+
+
class UserSerializer(serializers.ModelSerializer):
permissions = serializers.SerializerMethodField()
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index 4d7a6fa98..10d7c3235 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -56,6 +56,24 @@ def api_client(client):
return APIClient()
+@pytest.fixture
+def logged_in_api_client(db, factories, api_client):
+ user = factories['users.User']()
+ assert api_client.login(username=user.username, password='test')
+ setattr(api_client, 'user', user)
+ yield api_client
+ delattr(api_client, 'user')
+
+
+@pytest.fixture
+def superuser_api_client(db, factories, api_client):
+ user = factories['users.SuperUser']()
+ assert api_client.login(username=user.username, password='test')
+ setattr(api_client, 'user', user)
+ yield api_client
+ delattr(api_client, 'user')
+
+
@pytest.fixture
def superuser_client(db, factories, client):
user = factories['users.SuperUser']()
diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py
new file mode 100644
index 000000000..f2ca1abbd
--- /dev/null
+++ b/api/tests/music/test_import.py
@@ -0,0 +1,37 @@
+import json
+
+from django.urls import reverse
+
+from . import data as api_data
+
+
+def test_create_import_can_bind_to_request(
+ mocker, factories, superuser_api_client):
+ request = factories['requests.ImportRequest']()
+
+ mocker.patch('funkwhale_api.music.tasks.import_job_run')
+ mocker.patch(
+ 'funkwhale_api.musicbrainz.api.artists.get',
+ return_value=api_data.artists['get']['soad'])
+ mocker.patch(
+ 'funkwhale_api.musicbrainz.api.images.get_front',
+ return_value=b'')
+ mocker.patch(
+ 'funkwhale_api.musicbrainz.api.releases.get',
+ return_value=api_data.albums['get_with_includes']['hypnotize'])
+ payload = {
+ 'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
+ 'importRequest': request.pk,
+ 'tracks': [
+ {
+ 'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed',
+ 'source': 'https://www.youtube.com/watch?v=1111111111',
+ }
+ ]
+ }
+ url = reverse('api:v1:submit-album')
+ response = superuser_api_client.post(
+ url, json.dumps(payload), content_type='application/json')
+ batch = request.import_batches.latest('id')
+
+ assert batch.import_request == request
diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py
index 2eb1f2763..9f52ba887 100644
--- a/api/tests/music/test_models.py
+++ b/api/tests/music/test_models.py
@@ -52,6 +52,20 @@ def test_import_job_is_bound_to_track_file(factories, mocker):
job.refresh_from_db()
assert job.track_file.track == track
+
+@pytest.mark.parametrize('status', ['pending', 'errored', 'finished'])
+def test_saving_job_updates_batch_status(status,factories, mocker):
+ batch = factories['music.ImportBatch']()
+
+ assert batch.status == 'pending'
+
+ job = factories['music.ImportJob'](batch=batch, status=status)
+
+ batch.refresh_from_db()
+
+ assert batch.status == status
+
+
@pytest.mark.parametrize('extention,mimetype', [
('ogg', 'audio/ogg'),
('mp3', 'audio/mpeg'),
diff --git a/api/tests/requests/test_models.py b/api/tests/requests/test_models.py
new file mode 100644
index 000000000..797656bd7
--- /dev/null
+++ b/api/tests/requests/test_models.py
@@ -0,0 +1,23 @@
+import pytest
+
+from django.forms import ValidationError
+
+
+def test_can_bind_import_batch_to_request(factories):
+ request = factories['requests.ImportRequest']()
+
+ assert request.status == 'pending'
+
+ # when we create the import, we consider the request as accepted
+ batch = factories['music.ImportBatch'](import_request=request)
+ request.refresh_from_db()
+
+ assert request.status == 'accepted'
+
+ # now, the batch is finished, therefore the request status should be
+ # imported
+ batch.status = 'finished'
+ batch.save(update_fields=['status'])
+ request.refresh_from_db()
+
+ assert request.status == 'imported'
diff --git a/api/tests/requests/test_views.py b/api/tests/requests/test_views.py
new file mode 100644
index 000000000..6c34f9ad1
--- /dev/null
+++ b/api/tests/requests/test_views.py
@@ -0,0 +1,26 @@
+from django.urls import reverse
+
+
+def test_request_viewset_requires_auth(db, api_client):
+ url = reverse('api:v1:requests:import-requests-list')
+ response = api_client.get(url)
+ assert response.status_code == 401
+
+
+def test_user_can_create_request(logged_in_api_client):
+ url = reverse('api:v1:requests:import-requests-list')
+ user = logged_in_api_client.user
+ data = {
+ 'artist_name': 'System of a Down',
+ 'albums': 'All please!',
+ 'comment': 'Please, they rock!',
+ }
+ response = logged_in_api_client.post(url, data)
+
+ assert response.status_code == 201
+
+ ir = user.import_requests.latest('id')
+ assert ir.status == 'pending'
+ assert ir.creation_date is not None
+ for field, value in data.items():
+ assert getattr(ir, field) == value
diff --git a/front/package.json b/front/package.json
index ac3895f6d..042e332d0 100644
--- a/front/package.json
+++ b/front/package.json
@@ -20,9 +20,11 @@
"js-logger": "^1.3.0",
"jwt-decode": "^2.2.0",
"lodash": "^4.17.4",
+ "moment": "^2.20.1",
"moxios": "^0.4.0",
"raven-js": "^3.22.3",
"semantic-ui-css": "^2.2.10",
+ "showdown": "^1.8.6",
"vue": "^2.3.3",
"vue-lazyload": "^1.1.4",
"vue-router": "^2.3.1",
diff --git a/front/src/components/Pagination.vue b/front/src/components/Pagination.vue
index 3ac7c59af..83b386fde 100644
--- a/front/src/components/Pagination.vue
+++ b/front/src/components/Pagination.vue
@@ -49,4 +49,8 @@ export default {
diff --git a/front/src/components/common/HumanDate.vue b/front/src/components/common/HumanDate.vue
new file mode 100644
index 000000000..ff6ff5c71
--- /dev/null
+++ b/front/src/components/common/HumanDate.vue
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/front/src/components/discussion/Comment.vue b/front/src/components/discussion/Comment.vue
new file mode 100644
index 000000000..a3c5176ec
--- /dev/null
+++ b/front/src/components/discussion/Comment.vue
@@ -0,0 +1,49 @@
+
+
This import will be associated with the music request below. After the import is finished, the request will be marked as fulfilled.
+Hello world
') + }) + }) + describe('ago', () => { + it('works', () => { + const input = new Date() + let output = ago(input) + expect(output).to.equal('a few seconds ago') + }) + }) + describe('capitalize', () => { + it('works', () => { + const input = 'hello world' + let output = capitalize(input) + expect(output).to.equal('Hello world') + }) + }) +})