Merge branch 'release/0.2.5'

This commit is contained in:
Eliot Berriot 2017-12-16 16:15:40 +01:00
commit 20eaa5e615
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
58 changed files with 406 additions and 196 deletions

View File

@ -13,14 +13,22 @@ stages:
test_api:
stage: test
image: funkwhale/funkwhale:base
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/pip-cache"
DATABASE_URL: "sqlite://"
before_script:
- python3 -m venv --copies virtualenv
- source virtualenv/bin/activate
- cd api
- pip install -r requirements/base.txt
- pip install -r requirements/local.txt
- pip install -r requirements/test.txt
script:
- pytest
variables:
DATABASE_URL: "sqlite://"
cache:
key: "$CI_JOB_NAME-$CI_COMMIT_REF_NAME"
paths:
- "$CI_PROJECT_DIR/pip-cache"
tags:
- docker

View File

@ -2,10 +2,30 @@ Changelog
=========
0.2.5 (unreleased)
0.2.6 (Unreleased)
------------------
0.2.5 (2017-12-15)
------------------
Features:
- Import: can now specify search template when querying import sources (#45)
- Login form: now redirect to previous page after login (#2)
- 404: a decent 404 template, at least (#48)
Bugfixes:
- Player: better handling of errors when fetching the audio file (#46)
- Csrf: default CSRF_TRUSTED_ORIGINS to ALLOWED_HOSTS to avoid Csrf issues on admin (#49)
Tech:
- Django 2 compatibility, lot of packages upgrades (#47)
0.2.4 (2017-12-14)
------------------

View File

@ -8,7 +8,9 @@ COPY ./requirements.apt /requirements.apt
RUN apt-get update -qq && grep "^[^#;]" requirements.apt | xargs apt-get install -y
COPY ./requirements /requirements
COPY ./requirements/base.txt /requirements
RUN pip install -r /requirements/base.txt
COPY ./requirements/production.txt /requirements
RUN pip install -r /requirements/production.txt
COPY . /app

View File

@ -25,22 +25,32 @@ v1_patterns = router.urls
v1_patterns += [
url(r'^providers/',
include('funkwhale_api.providers.urls', namespace='providers')),
include(
('funkwhale_api.providers.urls', 'providers'),
namespace='providers')),
url(r'^favorites/',
include('funkwhale_api.favorites.urls', namespace='favorites')),
include(
('funkwhale_api.favorites.urls', 'favorites'),
namespace='favorites')),
url(r'^search$',
views.Search.as_view(), name='search'),
url(r'^radios/',
include('funkwhale_api.radios.urls', namespace='radios')),
include(
('funkwhale_api.radios.urls', 'radios'),
namespace='radios')),
url(r'^history/',
include('funkwhale_api.history.urls', namespace='history')),
include(
('funkwhale_api.history.urls', 'history'),
namespace='history')),
url(r'^users/',
include('funkwhale_api.users.api_urls', namespace='users')),
include(
('funkwhale_api.users.api_urls', 'users'),
namespace='users')),
url(r'^token/',
jwt_views.obtain_jwt_token),
url(r'^token/refresh/', jwt_views.refresh_jwt_token),
]
urlpatterns = [
url(r'^v1/', include(v1_patterns, namespace='v1'))
url(r'^v1/', include((v1_patterns, 'v1'), namespace='v1'))
]

View File

@ -75,7 +75,7 @@ INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
# MIDDLEWARE CONFIGURATION
# ------------------------------------------------------------------------------
MIDDLEWARE_CLASSES = (
MIDDLEWARE = (
# Make sure djangosecure.middleware.SecurityMiddleware is listed first
'django.contrib.sessions.middleware.SessionMiddleware',
'funkwhale_api.users.middleware.AnonymousSessionMiddleware',

View File

@ -31,7 +31,7 @@ EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND',
# django-debug-toolbar
# ------------------------------------------------------------------------------
MIDDLEWARE_CLASSES += ('debug_toolbar.middleware.DebugToolbarMiddleware',)
MIDDLEWARE += ('debug_toolbar.middleware.DebugToolbarMiddleware',)
# INTERNAL_IPS = ('127.0.0.1', '10.0.2.2',)

View File

@ -36,7 +36,7 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
#
#
# # Make sure djangosecure.middleware.SecurityMiddleware is listed first
# MIDDLEWARE_CLASSES = SECURITY_MIDDLEWARE + MIDDLEWARE_CLASSES
# MIDDLEWARE = SECURITY_MIDDLEWARE + MIDDLEWARE
#
# # set this to 60 seconds and then to 518400 when you can prove it works
# SECURE_HSTS_SECONDS = 60
@ -55,6 +55,8 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Hosts/domain names that are valid for this site
# See https://docs.djangoproject.com/en/1.6/ref/settings/#allowed-hosts
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
# END SITE CONFIGURATION
INSTALLED_APPS += ("gunicorn", )

View File

@ -22,8 +22,8 @@ CACHES = {
'LOCATION': ''
}
}
INSTALLED_APPS += ('kombu.transport.django',)
BROKER_URL = 'django://'
BROKER_URL = 'memory://'
# TESTING
# ------------------------------------------------------------------------------

View File

@ -10,9 +10,9 @@ from django.views import defaults as default_views
urlpatterns = [
# Django Admin, use {% url 'admin:index' %}
url(settings.ADMIN_URL, include(admin.site.urls)),
url(settings.ADMIN_URL, admin.site.urls),
url(r'^api/', include("config.api_urls", namespace="api")),
url(r'^api/', include(("config.api_urls", 'api'), namespace="api")),
url(r'^api/auth/', include('rest_auth.urls')),
url(r'^api/auth/registration/', include('funkwhale_api.users.rest_auth_urls')),
url(r'^accounts/', include('allauth.urls')),

View File

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
__version__ = '0.2.4'
__version__ = '0.2.5'
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])

View File

@ -7,5 +7,5 @@ class ConditionalAuthentication(BasePermission):
def has_permission(self, request, view):
if settings.API_AUTHENTICATION_REQUIRED:
return request.user and request.user.is_authenticated()
return request.user and request.user.is_authenticated
return True

View File

@ -25,7 +25,7 @@ class Migration(migrations.Migration):
'ordering': ('domain',),
},
managers=[
(b'objects', django.contrib.sites.models.SiteManager()),
('objects', django.contrib.sites.models.SiteManager()),
],
),
]

View File

@ -19,8 +19,8 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('track', models.ForeignKey(related_name='track_favorites', to='music.Track')),
('user', models.ForeignKey(related_name='track_favorites', to=settings.AUTH_USER_MODEL)),
('track', models.ForeignKey(related_name='track_favorites', to='music.Track', on_delete=models.CASCADE)),
('user', models.ForeignKey(related_name='track_favorites', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
],
options={
'ordering': ('-creation_date',),

View File

@ -5,8 +5,10 @@ from funkwhale_api.music.models import Track
class TrackFavorite(models.Model):
creation_date = models.DateTimeField(default=timezone.now)
user = models.ForeignKey('users.User', related_name='track_favorites')
track = models.ForeignKey(Track, related_name='track_favorites')
user = models.ForeignKey(
'users.User', related_name='track_favorites', on_delete=models.CASCADE)
track = models.ForeignKey(
Track, related_name='track_favorites', on_delete=models.CASCADE)
class Meta:
unique_together = ('track', 'user')

View File

@ -1,6 +1,6 @@
import json
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from django.urls import reverse
from funkwhale_api.music.models import Track, Artist
from funkwhale_api.favorites.models import TrackFavorite

View File

@ -20,8 +20,8 @@ class Migration(migrations.Migration):
('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)),
('end_date', models.DateTimeField(null=True, blank=True, default=django.utils.timezone.now)),
('session_key', models.CharField(null=True, blank=True, max_length=100)),
('track', models.ForeignKey(related_name='listenings', to='music.Track')),
('user', models.ForeignKey(blank=True, null=True, related_name='listenings', to=settings.AUTH_USER_MODEL)),
('track', models.ForeignKey(related_name='listenings', to='music.Track', on_delete=models.CASCADE)),
('user', models.ForeignKey(blank=True, null=True, related_name='listenings', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
],
options={
'ordering': ('-end_date',),

View File

@ -7,8 +7,14 @@ from funkwhale_api.music.models import Track
class Listening(models.Model):
end_date = models.DateTimeField(default=timezone.now, null=True, blank=True)
track = models.ForeignKey(Track, related_name="listenings")
user = models.ForeignKey('users.User', related_name="listenings", null=True, blank=True)
track = models.ForeignKey(
Track, related_name="listenings", on_delete=models.CASCADE)
user = models.ForeignKey(
'users.User',
related_name="listenings",
null=True,
blank=True,
on_delete=models.CASCADE)
session_key = models.CharField(max_length=100, null=True, blank=True)
class Meta:

View File

@ -0,0 +1,12 @@
import factory
from funkwhale_api.music.tests import factories
from funkwhale_api.users.tests.factories import UserFactory
class ListeningFactory(factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory)
track = factory.SubFactory(factories.TrackFactory)
class Meta:
model = 'history.Listening'

View File

@ -1,15 +1,16 @@
import random
import json
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from django.urls import reverse
from django.core.exceptions import ValidationError
from django.utils import timezone
from model_mommy import mommy
from funkwhale_api.music.tests.factories import TrackFactory
from funkwhale_api.users.models import User
from funkwhale_api.history import models
class TestHistory(TestCase):
def setUp(self):
@ -17,12 +18,12 @@ class TestHistory(TestCase):
self.user = User.objects.create_user(username='test', email='test@test.com', password='test')
def test_can_create_listening(self):
track = mommy.make('music.Track')
track = TrackFactory()
now = timezone.now()
l = models.Listening.objects.create(user=self.user, track=track)
def test_anonymous_user_can_create_listening_via_api(self):
track = mommy.make('music.Track')
track = TrackFactory()
url = self.reverse('api:v1:history:listenings-list')
response = self.client.post(url, {
'track': track.pk,
@ -34,7 +35,7 @@ class TestHistory(TestCase):
self.assertIsNotNone(listening.session_key)
def test_logged_in_user_can_create_listening_via_api(self):
track = mommy.make('music.Track')
track = TrackFactory()
self.client.login(username=self.user.username, password='test')

View File

@ -22,14 +22,14 @@ class ListeningViewSet(mixins.CreateModelMixin,
def get_queryset(self):
queryset = super().get_queryset()
if self.request.user.is_authenticated():
if self.request.user.is_authenticated:
return queryset.filter(user=self.request.user)
else:
return queryset.filter(session_key=self.request.session.session_key)
def get_serializer_context(self):
context = super().get_serializer_context()
if self.request.user.is_authenticated():
if self.request.user.is_authenticated:
context['user'] = self.request.user
else:
context['session_key'] = self.request.session.session_key

View File

@ -44,7 +44,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('submitted_by', models.ForeignKey(related_name='imports', to=settings.AUTH_USER_MODEL)),
('submitted_by', models.ForeignKey(related_name='imports', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
],
),
migrations.CreateModel(
@ -54,7 +54,7 @@ class Migration(migrations.Migration):
('source', models.URLField()),
('mbid', models.UUIDField(editable=False)),
('status', models.CharField(default='pending', choices=[('pending', 'Pending'), ('finished', 'finished')], max_length=30)),
('batch', models.ForeignKey(related_name='jobs', to='music.ImportBatch')),
('batch', models.ForeignKey(related_name='jobs', to='music.ImportBatch', on_delete=models.CASCADE)),
],
),
migrations.CreateModel(
@ -64,8 +64,8 @@ class Migration(migrations.Migration):
('mbid', models.UUIDField(editable=False, blank=True, null=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('title', models.CharField(max_length=255)),
('album', models.ForeignKey(related_name='tracks', blank=True, null=True, to='music.Album')),
('artist', models.ForeignKey(related_name='tracks', to='music.Artist')),
('album', models.ForeignKey(related_name='tracks', blank=True, null=True, to='music.Album', on_delete=models.CASCADE)),
('artist', models.ForeignKey(related_name='tracks', to='music.Artist', on_delete=models.CASCADE)),
],
options={
'abstract': False,
@ -78,12 +78,12 @@ class Migration(migrations.Migration):
('audio_file', models.FileField(upload_to='tracks')),
('source', models.URLField(blank=True, null=True)),
('duration', models.IntegerField(blank=True, null=True)),
('track', models.ForeignKey(related_name='files', to='music.Track')),
('track', models.ForeignKey(related_name='files', to='music.Track', on_delete=models.CASCADE)),
],
),
migrations.AddField(
model_name='album',
name='artist',
field=models.ForeignKey(related_name='albums', to='music.Artist'),
field=models.ForeignKey(related_name='albums', to='music.Artist', on_delete=models.CASCADE),
),
]

View File

@ -39,11 +39,11 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='lyrics',
name='work',
field=models.ForeignKey(related_name='lyrics', to='music.Work', blank=True, null=True),
field=models.ForeignKey(related_name='lyrics', to='music.Work', blank=True, null=True, on_delete=models.CASCADE),
),
migrations.AddField(
model_name='track',
name='work',
field=models.ForeignKey(related_name='tracks', to='music.Work', blank=True, null=True),
field=models.ForeignKey(related_name='tracks', to='music.Work', blank=True, null=True, on_delete=models.CASCADE),
),
]

View File

@ -10,7 +10,7 @@ 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.core.urlresolvers import reverse
from django.urls import reverse
from django.utils import timezone
from taggit.managers import TaggableManager
from versatileimagefield.fields import VersatileImageField
@ -108,7 +108,8 @@ def import_tracks(instance, cleaned_data, raw_data):
class Album(APIModelMixin):
title = models.CharField(max_length=255)
artist = models.ForeignKey(Artist, related_name='albums')
artist = models.ForeignKey(
Artist, related_name='albums', on_delete=models.CASCADE)
release_date = models.DateField(null=True)
release_group_id = models.UUIDField(null=True, blank=True)
cover = VersatileImageField(upload_to='albums/covers/%Y/%m/%d', null=True, blank=True)
@ -245,7 +246,12 @@ class Work(APIModelMixin):
class Lyrics(models.Model):
work = models.ForeignKey(Work, related_name='lyrics', null=True, blank=True)
work = models.ForeignKey(
Work,
related_name='lyrics',
null=True,
blank=True,
on_delete=models.CASCADE)
url = models.URLField(unique=True)
content = models.TextField(null=True, blank=True)
@ -268,10 +274,21 @@ class Lyrics(models.Model):
class Track(APIModelMixin):
title = models.CharField(max_length=255)
artist = models.ForeignKey(Artist, related_name='tracks')
artist = models.ForeignKey(
Artist, related_name='tracks', on_delete=models.CASCADE)
position = models.PositiveIntegerField(null=True, blank=True)
album = models.ForeignKey(Album, related_name='tracks', null=True, blank=True)
work = models.ForeignKey(Work, related_name='tracks', null=True, blank=True)
album = models.ForeignKey(
Album,
related_name='tracks',
null=True,
blank=True,
on_delete=models.CASCADE)
work = models.ForeignKey(
Work,
related_name='tracks',
null=True,
blank=True,
on_delete=models.CASCADE)
musicbrainz_model = 'recording'
api = musicbrainz.api.recordings
@ -340,7 +357,8 @@ class Track(APIModelMixin):
class TrackFile(models.Model):
track = models.ForeignKey(Track, related_name='files')
track = models.ForeignKey(
Track, related_name='files', on_delete=models.CASCADE)
audio_file = models.FileField(upload_to='tracks/%Y/%m/%d', max_length=255)
source = models.URLField(null=True, blank=True)
duration = models.IntegerField(null=True, blank=True)
@ -376,7 +394,8 @@ class TrackFile(models.Model):
class ImportBatch(models.Model):
creation_date = models.DateTimeField(default=timezone.now)
submitted_by = models.ForeignKey('users.User', related_name='imports')
submitted_by = models.ForeignKey(
'users.User', related_name='imports', on_delete=models.CASCADE)
class Meta:
ordering = ['-creation_date']
@ -392,9 +411,14 @@ class ImportBatch(models.Model):
return 'finished'
class ImportJob(models.Model):
batch = models.ForeignKey(ImportBatch, related_name='jobs')
batch = models.ForeignKey(
ImportBatch, related_name='jobs', on_delete=models.CASCADE)
track_file = models.ForeignKey(
TrackFile, related_name='jobs', null=True, blank=True)
TrackFile,
related_name='jobs',
null=True,
blank=True,
on_delete=models.CASCADE)
source = models.URLField()
mbid = models.UUIDField(editable=False)
STATUS_CHOICES = (

View File

@ -59,3 +59,30 @@ class ImportJobFactory(factory.django.DjangoModelFactory):
class Meta:
model = 'music.ImportJob'
class WorkFactory(factory.django.DjangoModelFactory):
mbid = factory.Faker('uuid4')
language = 'eng'
nature = 'song'
title = factory.Faker('sentence', nb_words=3)
class Meta:
model = 'music.Work'
class LyricsFactory(factory.django.DjangoModelFactory):
work = factory.SubFactory(WorkFactory)
url = factory.Faker('url')
content = factory.Faker('paragraphs', nb=4)
class Meta:
model = 'music.Lyrics'
class TagFactory(factory.django.DjangoModelFactory):
name = factory.SelfAttribute('slug')
slug = factory.Faker('slug')
class Meta:
model = 'taggit.Tag'

View File

@ -1,7 +1,7 @@
import json
import unittest
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from django.urls import reverse
from funkwhale_api.music import models
from funkwhale_api.utils.tests import TMPDirTestCaseMixin

View File

@ -1,25 +1,26 @@
import json
import unittest
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from model_mommy import mommy
from django.urls import reverse
from funkwhale_api.music import models
from funkwhale_api.musicbrainz import api
from funkwhale_api.music import serializers
from funkwhale_api.users.models import User
from funkwhale_api.music import lyrics as lyrics_utils
from .mocking import lyricswiki
from . import factories
from . import data as api_data
from funkwhale_api.music import lyrics as lyrics_utils
class TestLyrics(TestCase):
@unittest.mock.patch('funkwhale_api.music.lyrics._get_html',
return_value=lyricswiki.content)
def test_works_import_lyrics_if_any(self, *mocks):
lyrics = mommy.make(
models.Lyrics,
lyrics = factories.LyricsFactory(
url='http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!')
lyrics.fetch_content()
@ -42,7 +43,7 @@ Is it me you're looking for?
content = """Hello
Is it me you're looking for?"""
l = mommy.make(models.Lyrics, content=content)
l = factories.LyricsFactory(content=content)
expected = "<p>Hello<br />Is it me you're looking for?</p>"
self.assertHTMLEqual(expected, l.content_rendered)
@ -54,8 +55,7 @@ Is it me you're looking for?"""
@unittest.mock.patch('funkwhale_api.music.lyrics._get_html',
return_value=lyricswiki.content)
def test_works_import_lyrics_if_any(self, *mocks):
track = mommy.make(
models.Track,
track = factories.TrackFactory(
work=None,
mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')

View File

@ -2,13 +2,11 @@ from test_plus.test import TestCase
import unittest.mock
from funkwhale_api.music import models
import datetime
from model_mommy import mommy
from . import factories
from . import data as api_data
from .cover import binary_data
def prettyprint(d):
import json
print(json.dumps(d, sort_keys=True, indent=4))
class TestMusic(TestCase):
@ -79,9 +77,9 @@ class TestMusic(TestCase):
self.assertEqual(track, track2)
def test_album_tags_deduced_from_tracks_tags(self):
tag = mommy.make('taggit.Tag')
album = mommy.make('music.Album')
tracks = mommy.make('music.Track', album=album, _quantity=5)
tag = factories.TagFactory()
album = factories.AlbumFactory()
tracks = factories.TrackFactory.create_batch(album=album, size=5)
for track in tracks:
track.tags.add(tag)
@ -92,10 +90,10 @@ class TestMusic(TestCase):
self.assertIn(tag, album.tags)
def test_artist_tags_deduced_from_album_tags(self):
tag = mommy.make('taggit.Tag')
artist = mommy.make('music.Artist')
album = mommy.make('music.Album', artist=artist)
tracks = mommy.make('music.Track', album=album, _quantity=5)
tag = factories.TagFactory()
artist = factories.ArtistFactory()
album = factories.AlbumFactory(artist=artist)
tracks = factories.TrackFactory.create_batch(album=album, size=5)
for track in tracks:
track.tags.add(tag)
@ -108,7 +106,7 @@ class TestMusic(TestCase):
@unittest.mock.patch('funkwhale_api.musicbrainz.api.images.get_front', return_value=binary_data)
def test_can_download_image_file_for_album(self, *mocks):
# client._api.get_image_front('55ea4f82-b42b-423e-a0e5-290ccdf443ed')
album = mommy.make('music.Album', mbid='55ea4f82-b42b-423e-a0e5-290ccdf443ed')
album = factories.AlbumFactory(mbid='55ea4f82-b42b-423e-a0e5-290ccdf443ed')
album.get_image()
album.save()

View File

@ -1,23 +1,24 @@
import json
import unittest
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from model_mommy import mommy
from django.urls import reverse
from funkwhale_api.music import models
from funkwhale_api.musicbrainz import api
from funkwhale_api.music import serializers
from funkwhale_api.music.tests import factories
from funkwhale_api.users.models import User
from . import data as api_data
class TestWorks(TestCase):
@unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
return_value=api_data.works['get']['chop_suey'])
def test_can_import_work(self, *mocks):
recording = mommy.make(
models.Track, mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
recording = factories.TrackFactory(
mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
work = models.Work.create_from_api(id=mbid)
@ -36,8 +37,7 @@ class TestWorks(TestCase):
@unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get',
return_value=api_data.tracks['get']['chop_suey'])
def test_can_get_work_from_recording(self, *mocks):
recording = mommy.make(
models.Track,
recording = factories.TrackFactory(
work=None,
mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'

View File

@ -2,7 +2,7 @@ import os
import json
import unicodedata
import urllib
from django.core.urlresolvers import reverse
from django.urls import reverse
from django.db import models, transaction
from django.db.models.functions import Length
from django.conf import settings
@ -102,7 +102,7 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
queryset = super().get_queryset()
filter_favorites = self.request.GET.get('favorites', None)
user = self.request.user
if user.is_authenticated() and filter_favorites == 'true':
if user.is_authenticated and filter_favorites == 'true':
queryset = queryset.filter(track_favorites__user=user)
return queryset

View File

@ -1,7 +1,7 @@
import json
import unittest
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from django.urls import reverse
from funkwhale_api.musicbrainz import api
from . import data as api_data

View File

@ -22,7 +22,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=50)),
('is_public', models.BooleanField(default=False)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='playlists')),
('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='playlists', on_delete=models.CASCADE)),
],
),
migrations.CreateModel(
@ -33,9 +33,9 @@ class Migration(migrations.Migration):
('rght', models.PositiveIntegerField(db_index=True, editable=False)),
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('position', models.PositiveIntegerField(db_index=True, editable=False)),
('playlist', models.ForeignKey(to='playlists.Playlist', related_name='playlist_tracks')),
('previous', mptt.fields.TreeOneToOneField(null=True, to='playlists.PlaylistTrack', related_name='next', blank=True)),
('track', models.ForeignKey(to='music.Track', related_name='playlist_tracks')),
('playlist', models.ForeignKey(to='playlists.Playlist', related_name='playlist_tracks', on_delete=models.CASCADE)),
('previous', mptt.fields.TreeOneToOneField(null=True, to='playlists.PlaylistTrack', related_name='next', blank=True, on_delete=models.CASCADE)),
('track', models.ForeignKey(to='music.Track', related_name='playlist_tracks', on_delete=models.CASCADE)),
],
options={
'ordering': ('-playlist', 'position'),

View File

@ -7,7 +7,8 @@ from mptt.models import MPTTModel, TreeOneToOneField
class Playlist(models.Model):
name = models.CharField(max_length=50)
is_public = models.BooleanField(default=False)
user = models.ForeignKey('users.User', related_name="playlists")
user = models.ForeignKey(
'users.User', related_name="playlists", on_delete=models.CASCADE)
creation_date = models.DateTimeField(default=timezone.now)
def __str__(self):
@ -21,9 +22,18 @@ class Playlist(models.Model):
class PlaylistTrack(MPTTModel):
track = models.ForeignKey('music.Track', related_name='playlist_tracks')
previous = TreeOneToOneField('self', blank=True, null=True, related_name='next')
playlist = models.ForeignKey(Playlist, related_name='playlist_tracks')
track = models.ForeignKey(
'music.Track',
related_name='playlist_tracks',
on_delete=models.CASCADE)
previous = TreeOneToOneField(
'self',
blank=True,
null=True,
related_name='next',
on_delete=models.CASCADE)
playlist = models.ForeignKey(
Playlist, related_name='playlist_tracks', on_delete=models.CASCADE)
class MPTTMeta:
level_attr = 'position'

View File

@ -1,11 +1,10 @@
import json
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from django.urls import reverse
from django.core.exceptions import ValidationError
from django.utils import timezone
from model_mommy import mommy
from funkwhale_api.music.tests import factories
from funkwhale_api.users.models import User
from funkwhale_api.playlists import models
from funkwhale_api.playlists.serializers import PlaylistSerializer
@ -18,7 +17,7 @@ class TestPlayLists(TestCase):
self.user = User.objects.create_user(username='test', email='test@test.com', password='test')
def test_can_create_playlist(self):
tracks = list(mommy.make('music.Track', _quantity=5))
tracks = factories.TrackFactory.create_batch(size=5)
playlist = models.Playlist.objects.create(user=self.user, name="test")
previous = None
@ -49,7 +48,7 @@ class TestPlayLists(TestCase):
self.assertEqual(playlist.name, 'test')
def test_can_add_playlist_track_via_api(self):
tracks = list(mommy.make('music.Track', _quantity=5))
tracks = factories.TrackFactory.create_batch(size=5)
playlist = models.Playlist.objects.create(user=self.user, name="test")
self.client.login(username=self.user.username, password='test')

View File

@ -1,8 +1,11 @@
from django.conf.urls import include, url
from funkwhale_api.music import views
urlpatterns = [
url(r'^youtube/', include('funkwhale_api.providers.youtube.urls', namespace='youtube')),
url(r'^musicbrainz/', include('funkwhale_api.musicbrainz.urls', namespace='musicbrainz')),
url(r'^youtube/', include(
('funkwhale_api.providers.youtube.urls', 'youtube'),
namespace='youtube')),
url(r'^musicbrainz/', include(
('funkwhale_api.musicbrainz.urls', 'musicbrainz'),
namespace='musicbrainz')),
]

View File

@ -2,7 +2,7 @@ import json
from collections import OrderedDict
import unittest
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from django.urls import reverse
from funkwhale_api.providers.youtube.client import client
from . import data as api_data

View File

@ -20,7 +20,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)),
('radio_type', models.CharField(max_length=50)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('user', models.ForeignKey(related_name='radio_sessions', blank=True, to=settings.AUTH_USER_MODEL, null=True)),
('user', models.ForeignKey(related_name='radio_sessions', blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)),
],
),
migrations.CreateModel(
@ -28,8 +28,8 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)),
('position', models.IntegerField(default=1)),
('session', models.ForeignKey(to='radios.RadioSession', related_name='session_tracks')),
('track', models.ForeignKey(to='music.Track', related_name='radio_session_tracks')),
('session', models.ForeignKey(to='radios.RadioSession', related_name='session_tracks', on_delete=models.CASCADE)),
('track', models.ForeignKey(to='music.Track', related_name='radio_session_tracks', on_delete=models.CASCADE)),
],
options={
'ordering': ('session', 'position'),

View File

@ -15,7 +15,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='radiosession',
name='related_object_content_type',
field=models.ForeignKey(null=True, to='contenttypes.ContentType', blank=True),
field=models.ForeignKey(null=True, to='contenttypes.ContentType', blank=True, on_delete=models.CASCADE),
),
migrations.AddField(
model_name='radiosession',

View File

@ -7,11 +7,20 @@ from django.contrib.contenttypes.models import ContentType
from funkwhale_api.music.models import Track
class RadioSession(models.Model):
user = models.ForeignKey('users.User', related_name='radio_sessions', null=True, blank=True)
user = models.ForeignKey(
'users.User',
related_name='radio_sessions',
null=True,
blank=True,
on_delete=models.CASCADE)
session_key = models.CharField(max_length=100, null=True, blank=True)
radio_type = models.CharField(max_length=50)
creation_date = models.DateTimeField(default=timezone.now)
related_object_content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, blank=True, null=True)
related_object_content_type = models.ForeignKey(
ContentType,
blank=True,
null=True,
on_delete=models.CASCADE)
related_object_id = models.PositiveIntegerField(blank=True, null=True)
related_object = GenericForeignKey('related_object_content_type', 'related_object_id')
@ -43,9 +52,11 @@ class RadioSession(models.Model):
return registry[self.radio_type](session=self)
class RadioSessionTrack(models.Model):
session = models.ForeignKey(RadioSession, related_name='session_tracks')
session = models.ForeignKey(
RadioSession, related_name='session_tracks', on_delete=models.CASCADE)
position = models.IntegerField(default=1)
track = models.ForeignKey(Track, related_name='radio_session_tracks')
track = models.ForeignKey(
Track, related_name='radio_session_tracks', on_delete=models.CASCADE)
class Meta:
ordering = ('session', 'position')

View File

@ -1,16 +1,18 @@
import random
import json
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from django.urls import reverse
from django.core.exceptions import ValidationError
from model_mommy import mommy
from funkwhale_api.radios import radios
from funkwhale_api.radios import models
from funkwhale_api.favorites.models import TrackFavorite
from funkwhale_api.users.models import User
from funkwhale_api.music.models import Artist
from funkwhale_api.music.tests import factories
from funkwhale_api.history.tests.factories import ListeningFactory
class TestRadios(TestCase):
@ -55,7 +57,7 @@ class TestRadios(TestCase):
self.assertTrue(picks[2] > picks[1])
def test_can_get_choices_for_favorites_radio(self):
tracks = mommy.make('music.Track', _quantity=100)
tracks = factories.TrackFactory.create_batch(size=100)
for i in range(20):
TrackFavorite.add(track=random.choice(tracks), user=self.user)
@ -73,7 +75,7 @@ class TestRadios(TestCase):
self.assertIn(pick, choices)
def test_can_use_radio_session_to_filter_choices(self):
tracks = mommy.make('music.Track', _quantity=30)
tracks = factories.TrackFactory.create_batch(size=30)
radio = radios.RandomRadio()
session = radio.start_session(self.user)
@ -85,7 +87,7 @@ class TestRadios(TestCase):
self.assertEqual(len(set(tracks_id)), 30)
def test_can_restore_radio_from_previous_session(self):
tracks = mommy.make('music.Track', _quantity=30)
tracks = factories.TrackFactory.create_batch(size=30)
radio = radios.RandomRadio()
session = radio.start_session(self.user)
@ -115,7 +117,7 @@ class TestRadios(TestCase):
self.assertIsNotNone(session.session_key)
def test_can_get_track_for_session_from_api(self):
tracks = mommy.make('music.Track', _quantity=1)
tracks = factories.TrackFactory.create_batch(size=1)
self.client.login(username=self.user.username, password='test')
url = reverse('api:v1:radios:sessions-list')
@ -129,7 +131,7 @@ class TestRadios(TestCase):
self.assertEqual(data['track']['id'], tracks[0].id)
self.assertEqual(data['position'], 1)
next_track = mommy.make('music.Track')
next_track = factories.TrackFactory()
response = self.client.post(url, {'session': session.pk})
data = json.loads(response.content.decode('utf-8'))
@ -148,9 +150,10 @@ class TestRadios(TestCase):
radio.start_session(self.user, related_object=self.user)
def test_can_start_artist_radio(self):
artist = mommy.make('music.Artist')
wrong_tracks = mommy.make('music.Track', _quantity=30)
good_tracks = mommy.make('music.Track', artist=artist, _quantity=5)
artist = factories.ArtistFactory()
wrong_tracks = factories.TrackFactory.create_batch(size=30)
good_tracks = factories.TrackFactory.create_batch(
artist=artist, size=5)
radio = radios.ArtistRadio()
session = radio.start_session(self.user, related_object=artist)
@ -159,9 +162,9 @@ class TestRadios(TestCase):
self.assertIn(radio.pick(), good_tracks)
def test_can_start_tag_radio(self):
tag = mommy.make('taggit.Tag')
wrong_tracks = mommy.make('music.Track', _quantity=30)
good_tracks = mommy.make('music.Track', _quantity=5)
tag = factories.TagFactory()
wrong_tracks = factories.TrackFactory.create_batch(size=30)
good_tracks = factories.TrackFactory.create_batch(size=5)
for track in good_tracks:
track.tags.add(tag)
@ -172,7 +175,7 @@ class TestRadios(TestCase):
self.assertIn(radio.pick(), good_tracks)
def test_can_start_artist_radio_from_api(self):
artist = mommy.make('music.Artist')
artist = factories.ArtistFactory()
url = reverse('api:v1:radios:sessions-list')
response = self.client.post(url, {'radio_type': 'artist', 'related_object_id': artist.id})
@ -181,10 +184,10 @@ class TestRadios(TestCase):
self.assertEqual(session.related_object, artist)
def test_can_start_less_listened_radio(self):
history = mommy.make('history.Listening', _quantity=5, user=self.user)
history = ListeningFactory.create_batch(size=5, user=self.user)
wrong_tracks = [h.track for h in history]
good_tracks = mommy.make('music.Track', _quantity=30)
good_tracks = factories.TrackFactory.create_batch(size=30)
radio = radios.LessListenedRadio()
session = radio.start_session(self.user)

View File

@ -6,4 +6,5 @@ router = routers.SimpleRouter()
router.register(r'sessions', views.RadioSessionViewSet, 'sessions')
router.register(r'tracks', views.RadioSessionTrackViewSet, 'tracks')
urlpatterns = router.urls

View File

@ -19,14 +19,14 @@ class RadioSessionViewSet(mixins.CreateModelMixin,
def get_queryset(self):
queryset = super().get_queryset()
if self.request.user.is_authenticated():
if self.request.user.is_authenticated:
return queryset.filter(user=self.request.user)
else:
return queryset.filter(session_key=self.request.session.session_key)
def get_serializer_context(self):
context = super().get_serializer_context()
if self.request.user.is_authenticated():
if self.request.user.is_authenticated:
context['user'] = self.request.user
else:
context['session_key'] = self.request.session.session_key
@ -44,7 +44,7 @@ class RadioSessionTrackViewSet(mixins.CreateModelMixin,
serializer.is_valid(raise_exception=True)
session = serializer.validated_data['session']
try:
if request.user.is_authenticated():
if request.user.is_authenticated:
assert request.user == session.user
else:
assert request.session.session_key == session.session_key

View File

@ -1,6 +1,11 @@
class AnonymousSessionMiddleware(object):
def process_request(self, request):
class AnonymousSessionMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not request.session.session_key:
request.session.save()
response = self.get_response(request)
return response

View File

@ -38,7 +38,7 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'users',
},
managers=[
(b'objects', django.contrib.auth.models.UserManager()),
('objects', django.contrib.auth.models.UserManager()),
],
),
]

View File

@ -2,7 +2,7 @@
from __future__ import unicode_literals, absolute_import
from django.contrib.auth.models import AbstractUser
from django.core.urlresolvers import reverse
from django.urls import reverse
from django.db import models
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _

View File

@ -1,59 +1,59 @@
# Bleeding edge Django
django==1.11
django>=2.0,<2.1
# Configuration
django-environ==0.4.0
django-secure==1.0.1
whitenoise==2.0.6
# Models
django-model-utils==2.3.1
django-environ>=0.4,<0.5
whitenoise>=3.3,<3.4
# Images
Pillow==3.0.0
Pillow>=4.3,<4.4
# For user registration, either via email or social
# Well-built with regular release cycles!
django-allauth==0.24.1
django-allauth>=0.34,<0.35
# Python-PostgreSQL Database Adapter
psycopg2==2.6.1
psycopg2>=2.7,<=2.8
# Time zones support
pytz==2015.7
pytz==2017.3
# Redis support
django-redis==4.3.0
redis>=2.10.0
django-redis>=4.5,<4.6
redis>=2.10,<2.11
celery==3.1.19
celery>=3.1,<3.2
# Your custom requirements go here
django-cors-headers==2.1.0
django-cors-headers>=2.1,<2.2
musicbrainzngs==0.6
youtube_dl>=2015.12.21
djangorestframework==3.6.3
djangorestframework-jwt==1.11.0
django-celery==3.2.1
django-mptt==0.8.7
google-api-python-client==1.6.2
arrow==0.10.0
django-taggit==0.22.1
persisting-theory==0.2.1
django-versatileimagefield==1.7.1
django-cachalot==1.5.0
django-filter==1.1
django-rest-auth==0.9.1
beautifulsoup4==4.6.0
Markdown==2.6.8
ipython==6.1.0
mutagen==1.38
youtube_dl>=2017.12.14
djangorestframework>=3.7,<3.8
djangorestframework-jwt>=1.11,<1.12
django-celery>=3.2,<3.3
django-mptt>=0.9,<0.10
google-api-python-client>=1.6,<1.7
arrow>=0.12,<0.13
persisting-theory>=0.2,<0.3
django-versatileimagefield>=1.8,<1.9
django-filter>=1.1,<1.2
django-rest-auth>=0.9,<0.10
beautifulsoup4>=4.6,<4.7
Markdown>=2.6,<2.7
ipython>=6,<7
mutagen>=1.39,<1.40
# Until this is merged
#django-taggit>=0.22,<0.23
git+https://github.com/jdufresne/django-taggit.git@e8f7f216f04c9781bebc84363ab24d575f948ede
# Until this is merged
git+https://github.com/EliotBerriot/PyMemoize.git@django
# Until this is merged
#django-cachalot==1.5.0
git+https://github.com/EliotBerriot/django-cachalot.git@django-2
django-dynamic-preferences>=1.3,<1.4
django-dynamic-preferences>=1.5,<1.6

View File

@ -1,15 +1,15 @@
# Local development dependencies go here
-r base.txt
coverage==4.0.3
django_coverage_plugin==1.1
Sphinx==1.6.2
django-extensions==1.5.9
Werkzeug==0.11.2
django-test-plus==1.0.11
coverage>=4.4,<4.5
django_coverage_plugin>=1.5,<1.6
Sphinx>=1.6,<1.7
django-extensions>=1.9,<1.10
Werkzeug>=0.13,<0.14
django-test-plus>=1.0.20
factory_boy>=2.8.1
# django-debug-toolbar that works with Django 1.5+
django-debug-toolbar>=1.5,<1.6
django-debug-toolbar>=1.9,<1.10
# improved REPL
ipdb==0.8.1

View File

@ -1,8 +1,5 @@
# Pro-tip: Try not to put anything here. There should be no dependency in
# production that isn't in development.
-r base.txt
# WSGI Handler
# ------------------------------------------------

View File

@ -1,9 +1,6 @@
# Test dependencies go here.
-r local.txt
flake8==2.5.0
model-mommy==1.3.2
flake8
pytest
pytest-django
pytest-mock

View File

@ -26,6 +26,7 @@ class Audio {
if (options.onEnded) {
this.onEnded = options.onEnded
}
this.onError = options.onError
this.state = {
preload: preload,
@ -60,8 +61,12 @@ class Audio {
init (src, options = {}) {
if (!src) throw Error('src must be required')
this.state.startLoad = true
if (this.state.tried === this.state.try) {
if (this.state.tried >= this.state.try) {
this.state.failed = true
logger.default.error('Cannot fetch audio', src)
if (this.onError) {
this.onError(src)
}
return
}
this.$Audio = new window.Audio(src)

View File

@ -140,7 +140,6 @@ class Queue {
} else {
index = index || this.tracks.length
}
console.log('INDEEEEEX', index)
tracks.forEach((t) => {
self.append(t, index, true)
index += 1
@ -243,7 +242,11 @@ class Queue {
rate: 1,
loop: false,
volume: this.state.volume,
onEnded: this.handleAudioEnded.bind(this)
onEnded: this.handleAudioEnded.bind(this),
onError: function (src) {
self.errored = true
self.next()
}
})
this.audio = audio
audio.updateHook('playState', function (e) {

View File

@ -0,0 +1,34 @@
<template>
<div class="main pusher">
<div class="ui vertical stripe segment">
<div class="ui text container">
<h1 class="ui huge header">
<i class="warning icon"></i>
<div class="content">
<strike>Whale</strike> Page not found!
</div>
</h1>
<p>We're sorry, the page you asked for does not exists.</p>
<p>Requested URL: <a :href="path">{{ path }}</a></p>
<router-link class="ui icon button" to="/">
Go to home page
<i class="right arrow icon"></i>
</router-link>
</div>
</div>
</div>
</template>
<script>
export default {
data: function () {
return {
path: window.location.href
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -43,6 +43,9 @@ import auth from '@/auth'
export default {
name: 'login',
props: {
next: {type: String}
},
data () {
return {
// We need to initialize the component with any
@ -69,7 +72,7 @@ export default {
}
// We need to pass the component's this context
// to properly make use of http in the auth service
auth.login(this, credentials, {path: '/library'}, function (response) {
auth.login(this, credentials, {path: this.next}, function (response) {
// error callback
if (response.status === 400) {
self.error = 'invalid_credentials'

View File

@ -12,6 +12,10 @@
<label>{{ t }}</label>
</div>
</div>
<div class="field">
<label>Query template</label>
<input v-model="customQueryTemplate" />
</div>
</div>
</form>
<template
@ -22,6 +26,7 @@
:backends="backends"
:defaultEnabled="false"
:default-backend-id="defaultBackendId"
:query-template="customQueryTemplate"
@import-data-changed="recordReleaseData"
@enabled="recordReleaseEnabled"
></release-import>

View File

@ -13,10 +13,12 @@ export default {
metadata: {type: Object, required: true},
defaultEnabled: {type: Boolean, default: true},
backends: {type: Array},
defaultBackendId: {type: String}
defaultBackendId: {type: String},
queryTemplate: {type: String, default: '$artist $title'}
},
data () {
return {
customQueryTemplate: this.queryTemplate,
currentBackendId: this.defaultBackendId,
isImporting: false,
enabled: this.defaultEnabled
@ -56,6 +58,9 @@ export default {
return this.backends.filter(b => {
return b.id === self.currentBackendId
})[0]
},
realQueryTemplate () {
}
},
watch: {
@ -70,6 +75,14 @@ export default {
},
enabled (newValue) {
this.$emit('enabled', this.importData, newValue)
},
queryTemplate (newValue, oldValue) {
// we inherit from the prop template unless the component changed
// the value
if (oldValue === this.customQueryTemplate) {
// no changed from prop, we keep the sync
this.customQueryTemplate = newValue
}
}
}
}

View File

@ -20,6 +20,7 @@
:release-metadata="metadata"
:backends="backends"
:default-backend-id="defaultBackendId"
:query-template="customQueryTemplate"
@import-data-changed="recordTrackData"
@enabled="recordTrackEnabled"
></track-import>

View File

@ -92,13 +92,7 @@ export default Vue.extend({
releaseMetadata: {type: Object, required: true}
},
data () {
let queryParts = [
this.releaseMetadata['artist-credit'][0]['artist']['name'],
this.releaseMetadata['title'],
this.metadata['recording']['title']
]
return {
query: queryParts.join(' '),
isLoading: false,
results: [],
currentResultIndex: 0,
@ -151,6 +145,18 @@ export default Vue.extend({
mbid: this.metadata.recording.id,
source: this.importedUrl
}
},
query () {
let queryMapping = [
['artist', this.releaseMetadata['artist-credit'][0]['artist']['name']],
['album', this.releaseMetadata['title']],
['title', this.metadata['recording']['title']]
]
let query = this.customQueryTemplate
queryMapping.forEach(e => {
query = query.split('$' + e[0]).join(e[1])
})
return query
}
},
watch: {

View File

@ -32,7 +32,7 @@ Vue.http.interceptors.push(function (request, next) {
// redirect to login form when we get unauthorized response from server
if (response.status === 401) {
logger.default.warn('Received 401 response from API, redirecting to login form')
router.push({name: 'login'})
router.push({name: 'login', query: {next: router.currentRoute.fullPath}})
}
})
})

View File

@ -1,5 +1,6 @@
import Vue from 'vue'
import Router from 'vue-router'
import PageNotFound from '@/components/PageNotFound'
import Home from '@/components/Home'
import Login from '@/components/auth/Login'
import Profile from '@/components/auth/Profile'
@ -30,7 +31,8 @@ export default new Router({
{
path: '/login',
name: 'login',
component: Login
component: Login,
props: (route) => ({ next: route.query.next || '/library' })
},
{
path: '/logout',
@ -71,7 +73,7 @@ export default new Router({
},
{ path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true }
]
}
},
{ path: '*', component: PageNotFound }
]
})