Skip to content
Snippets Groups Projects
Verified Commit 7b18e46f authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch 'release/0.13'

parents 107cca7b d299964c
No related branches found
No related tags found
No related merge requests found
Showing
with 346 additions and 40 deletions
...@@ -2,9 +2,11 @@ from rest_framework import views ...@@ -2,9 +2,11 @@ from rest_framework import views
from rest_framework.response import Response from rest_framework.response import Response
from dynamic_preferences.api import serializers from dynamic_preferences.api import serializers
from dynamic_preferences.api import viewsets as preferences_viewsets
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.users.permissions import HasUserPermission
from . import nodeinfo from . import nodeinfo
from . import stats from . import stats
...@@ -15,6 +17,11 @@ NODEINFO_2_CONTENT_TYPE = ( ...@@ -15,6 +17,11 @@ NODEINFO_2_CONTENT_TYPE = (
) )
class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet):
pagination_class = None
permission_classes = (HasUserPermission,)
required_permissions = ['settings']
class InstanceSettings(views.APIView): class InstanceSettings(views.APIView):
permission_classes = [] permission_classes = []
authentication_classes = [] authentication_classes = []
......
...@@ -38,6 +38,7 @@ class ImportBatchAdmin(admin.ModelAdmin): ...@@ -38,6 +38,7 @@ class ImportBatchAdmin(admin.ModelAdmin):
search_fields = [ search_fields = [
'import_request__name', 'source', 'batch__pk', 'mbid'] 'import_request__name', 'source', 'batch__pk', 'mbid']
@admin.register(models.ImportJob) @admin.register(models.ImportJob)
class ImportJobAdmin(admin.ModelAdmin): class ImportJobAdmin(admin.ModelAdmin):
list_display = ['source', 'batch', 'track_file', 'status', 'mbid'] list_display = ['source', 'batch', 'track_file', 'status', 'mbid']
...@@ -73,9 +74,16 @@ class TrackFileAdmin(admin.ModelAdmin): ...@@ -73,9 +74,16 @@ class TrackFileAdmin(admin.ModelAdmin):
'source', 'source',
'duration', 'duration',
'mimetype', 'mimetype',
'size',
'bitrate'
] ]
list_select_related = [ list_select_related = [
'track' 'track'
] ]
search_fields = ['source', 'acoustid_track_id'] search_fields = [
'source',
'acoustid_track_id',
'track__title',
'track__album__title',
'track__artist__name']
list_filter = ['mimetype'] list_filter = ['mimetype']
...@@ -54,6 +54,10 @@ class TrackFileFactory(factory.django.DjangoModelFactory): ...@@ -54,6 +54,10 @@ class TrackFileFactory(factory.django.DjangoModelFactory):
audio_file = factory.django.FileField( audio_file = factory.django.FileField(
from_path=os.path.join(SAMPLES_PATH, 'test.ogg')) from_path=os.path.join(SAMPLES_PATH, 'test.ogg'))
bitrate = None
size = None
duration = None
class Meta: class Meta:
model = 'music.TrackFile' model = 'music.TrackFile'
......
...@@ -2,6 +2,7 @@ import cacheops ...@@ -2,6 +2,7 @@ import cacheops
import os import os
from django.db import transaction from django.db import transaction
from django.db.models import Q
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
...@@ -24,6 +25,8 @@ class Command(BaseCommand): ...@@ -24,6 +25,8 @@ class Command(BaseCommand):
if options['dry_run']: if options['dry_run']:
self.stdout.write('Dry-run on, will not commit anything') self.stdout.write('Dry-run on, will not commit anything')
self.fix_mimetypes(**options) self.fix_mimetypes(**options)
self.fix_file_data(**options)
self.fix_file_size(**options)
cacheops.invalidate_model(models.TrackFile) cacheops.invalidate_model(models.TrackFile)
@transaction.atomic @transaction.atomic
...@@ -43,3 +46,60 @@ class Command(BaseCommand): ...@@ -43,3 +46,60 @@ class Command(BaseCommand):
if not dry_run: if not dry_run:
self.stdout.write('[mimetypes] commiting...') self.stdout.write('[mimetypes] commiting...')
qs.update(mimetype=mimetype) qs.update(mimetype=mimetype)
def fix_file_data(self, dry_run, **kwargs):
self.stdout.write('Fixing missing bitrate or length...')
matching = models.TrackFile.objects.filter(
Q(bitrate__isnull=True) | Q(duration__isnull=True))
total = matching.count()
self.stdout.write(
'[bitrate/length] {} entries found with missing values'.format(
total))
if dry_run:
return
for i, tf in enumerate(matching.only('audio_file')):
self.stdout.write(
'[bitrate/length] {}/{} fixing file #{}'.format(
i+1, total, tf.pk
))
try:
audio_file = tf.get_audio_file()
if audio_file:
with audio_file as f:
data = utils.get_audio_file_data(audio_file)
tf.bitrate = data['bitrate']
tf.duration = data['length']
tf.save(update_fields=['duration', 'bitrate'])
else:
self.stderr.write('[bitrate/length] no file found')
except Exception as e:
self.stderr.write(
'[bitrate/length] error with file #{}: {}'.format(
tf.pk, str(e)
)
)
def fix_file_size(self, dry_run, **kwargs):
self.stdout.write('Fixing missing size...')
matching = models.TrackFile.objects.filter(size__isnull=True)
total = matching.count()
self.stdout.write(
'[size] {} entries found with missing values'.format(total))
if dry_run:
return
for i, tf in enumerate(matching.only('size')):
self.stdout.write(
'[size] {}/{} fixing file #{}'.format(
i+1, total, tf.pk
))
try:
tf.size = tf.get_file_size()
tf.save(update_fields=['size'])
except Exception as e:
self.stderr.write(
'[size] error with file #{}: {}'.format(
tf.pk, str(e)
)
)
...@@ -28,6 +28,13 @@ def get_id3_tag(f, k): ...@@ -28,6 +28,13 @@ def get_id3_tag(f, k):
raise TagNotFound(k) raise TagNotFound(k)
def get_flac_tag(f, k):
try:
return f.get(k)[0]
except (KeyError, IndexError):
raise TagNotFound(k)
def get_mp3_recording_id(f, k): def get_mp3_recording_id(f, k):
try: try:
return [ return [
...@@ -121,7 +128,38 @@ CONF = { ...@@ -121,7 +128,38 @@ CONF = {
'getter': get_mp3_recording_id, 'getter': get_mp3_recording_id,
}, },
} }
},
'FLAC': {
'getter': get_flac_tag,
'fields': {
'track_number': {
'field': 'tracknumber',
'to_application': convert_track_number
},
'title': {
'field': 'title'
},
'artist': {
'field': 'artist'
},
'album': {
'field': 'album'
},
'date': {
'field': 'date',
'to_application': lambda v: arrow.get(str(v)).date()
},
'musicbrainz_albumid': {
'field': 'musicbrainz_albumid'
},
'musicbrainz_artistid': {
'field': 'musicbrainz_artistid'
},
'musicbrainz_recordingid': {
'field': 'musicbrainz_trackid'
},
} }
},
} }
......
# Generated by Django 2.0.3 on 2018-05-15 18:08
from django.db import migrations, models
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('music', '0026_trackfile_accessed_date'),
]
operations = [
migrations.AddField(
model_name='trackfile',
name='bitrate',
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='trackfile',
name='size',
field=models.IntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='track',
name='tags',
field=taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
),
]
...@@ -361,7 +361,7 @@ class Track(APIModelMixin): ...@@ -361,7 +361,7 @@ class Track(APIModelMixin):
import_tags import_tags
] ]
objects = TrackQuerySet.as_manager() objects = TrackQuerySet.as_manager()
tags = TaggableManager() tags = TaggableManager(blank=True)
class Meta: class Meta:
ordering = ['album', 'position'] ordering = ['album', 'position']
...@@ -429,6 +429,8 @@ class TrackFile(models.Model): ...@@ -429,6 +429,8 @@ class TrackFile(models.Model):
modification_date = models.DateTimeField(auto_now=True) modification_date = models.DateTimeField(auto_now=True)
accessed_date = models.DateTimeField(null=True, blank=True) accessed_date = models.DateTimeField(null=True, blank=True)
duration = models.IntegerField(null=True, blank=True) duration = models.IntegerField(null=True, blank=True)
size = models.IntegerField(null=True, blank=True)
bitrate = models.IntegerField(null=True, blank=True)
acoustid_track_id = models.UUIDField(null=True, blank=True) acoustid_track_id = models.UUIDField(null=True, blank=True)
mimetype = models.CharField(null=True, blank=True, max_length=200) mimetype = models.CharField(null=True, blank=True, max_length=200)
...@@ -467,7 +469,7 @@ class TrackFile(models.Model): ...@@ -467,7 +469,7 @@ class TrackFile(models.Model):
@property @property
def filename(self): def filename(self):
return '{}{}'.format( return '{}.{}'.format(
self.track.full_name, self.track.full_name,
self.extension) self.extension)
...@@ -477,6 +479,41 @@ class TrackFile(models.Model): ...@@ -477,6 +479,41 @@ class TrackFile(models.Model):
return return
return os.path.splitext(self.audio_file.name)[-1].replace('.', '', 1) return os.path.splitext(self.audio_file.name)[-1].replace('.', '', 1)
def get_file_size(self):
if self.audio_file:
return self.audio_file.size
if self.source.startswith('file://'):
return os.path.getsize(self.source.replace('file://', '', 1))
if self.library_track and self.library_track.audio_file:
return self.library_track.audio_file.size
def get_audio_file(self):
if self.audio_file:
return self.audio_file.open()
if self.source.startswith('file://'):
return open(self.source.replace('file://', '', 1), 'rb')
if self.library_track and self.library_track.audio_file:
return self.library_track.audio_file.open()
def set_audio_data(self):
audio_file = self.get_audio_file()
if audio_file:
with audio_file as f:
audio_data = utils.get_audio_file_data(f)
if not audio_data:
return
self.duration = int(audio_data['length'])
self.bitrate = audio_data['bitrate']
self.size = self.get_file_size()
else:
lt = self.library_track
if lt:
self.duration = lt.get_metadata('length')
self.size = lt.get_metadata('size')
self.bitrate = lt.get_metadata('bitrate')
def save(self, **kwargs): def save(self, **kwargs):
if not self.mimetype and self.audio_file: if not self.mimetype and self.audio_file:
self.mimetype = utils.guess_mimetype(self.audio_file) self.mimetype = utils.guess_mimetype(self.audio_file)
......
...@@ -27,6 +27,7 @@ class SimpleArtistSerializer(serializers.ModelSerializer): ...@@ -27,6 +27,7 @@ class SimpleArtistSerializer(serializers.ModelSerializer):
class ArtistSerializer(serializers.ModelSerializer): class ArtistSerializer(serializers.ModelSerializer):
tags = TagSerializer(many=True, read_only=True) tags = TagSerializer(many=True, read_only=True)
class Meta: class Meta:
model = models.Artist model = models.Artist
fields = ('id', 'mbid', 'name', 'tags', 'creation_date') fields = ('id', 'mbid', 'name', 'tags', 'creation_date')
...@@ -40,11 +41,21 @@ class TrackFileSerializer(serializers.ModelSerializer): ...@@ -40,11 +41,21 @@ class TrackFileSerializer(serializers.ModelSerializer):
fields = ( fields = (
'id', 'id',
'path', 'path',
'duration',
'source', 'source',
'filename', 'filename',
'mimetype', 'mimetype',
'track') 'track',
'duration',
'mimetype',
'bitrate',
'size',
)
read_only_fields = [
'duration',
'mimetype',
'bitrate',
'size',
]
def get_path(self, o): def get_path(self, o):
url = o.path url = o.path
......
...@@ -134,6 +134,7 @@ def _do_import(import_job, replace=False, use_acoustid=True): ...@@ -134,6 +134,7 @@ def _do_import(import_job, replace=False, use_acoustid=True):
# in place import, we set mimetype from extension # in place import, we set mimetype from extension
path, ext = os.path.splitext(import_job.source) path, ext = os.path.splitext(import_job.source)
track_file.mimetype = music_utils.get_type_from_ext(ext) track_file.mimetype = music_utils.get_type_from_ext(ext)
track_file.set_audio_data()
track_file.save() track_file.save()
import_job.status = 'finished' import_job.status = 'finished'
import_job.track_file = track_file import_job.track_file = track_file
......
import magic import magic
import mimetypes import mimetypes
import mutagen
import re import re
from django.db.models import Q from django.db.models import Q
...@@ -66,6 +67,7 @@ def compute_status(jobs): ...@@ -66,6 +67,7 @@ def compute_status(jobs):
AUDIO_EXTENSIONS_AND_MIMETYPE = [ AUDIO_EXTENSIONS_AND_MIMETYPE = [
('ogg', 'audio/ogg'), ('ogg', 'audio/ogg'),
('mp3', 'audio/mpeg'), ('mp3', 'audio/mpeg'),
('flac', 'audio/x-flac'),
] ]
EXTENSION_TO_MIMETYPE = {ext: mt for ext, mt in AUDIO_EXTENSIONS_AND_MIMETYPE} EXTENSION_TO_MIMETYPE = {ext: mt for ext, mt in AUDIO_EXTENSIONS_AND_MIMETYPE}
...@@ -81,3 +83,14 @@ def get_type_from_ext(extension): ...@@ -81,3 +83,14 @@ def get_type_from_ext(extension):
# we remove leading dot # we remove leading dot
extension = extension[1:] extension = extension[1:]
return EXTENSION_TO_MIMETYPE.get(extension) return EXTENSION_TO_MIMETYPE.get(extension)
def get_audio_file_data(f):
data = mutagen.File(f)
if not data:
return
d = {}
d['bitrate'] = data.info.bitrate
d['length'] = data.info.length
return d
...@@ -25,8 +25,8 @@ from rest_framework import permissions ...@@ -25,8 +25,8 @@ from rest_framework import permissions
from musicbrainzngs import ResponseError from musicbrainzngs import ResponseError
from funkwhale_api.common import utils as funkwhale_utils from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.common.permissions import ( from funkwhale_api.common.permissions import ConditionalAuthentication
ConditionalAuthentication, HasModelPermission) from funkwhale_api.users.permissions import HasUserPermission
from taggit.models import Tag from taggit.models import Tag
from funkwhale_api.federation import actors from funkwhale_api.federation import actors
from funkwhale_api.federation.authentication import SignatureAuthentication from funkwhale_api.federation.authentication import SignatureAuthentication
...@@ -107,25 +107,22 @@ class ImportBatchViewSet( ...@@ -107,25 +107,22 @@ class ImportBatchViewSet(
.annotate(job_count=Count('jobs')) .annotate(job_count=Count('jobs'))
) )
serializer_class = serializers.ImportBatchSerializer serializer_class = serializers.ImportBatchSerializer
permission_classes = (permissions.DjangoModelPermissions, ) permission_classes = (HasUserPermission,)
required_permissions = ['library']
filter_class = filters.ImportBatchFilter filter_class = filters.ImportBatchFilter
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(submitted_by=self.request.user) serializer.save(submitted_by=self.request.user)
class ImportJobPermission(HasModelPermission):
# not a typo, perms on import job is proxied to import batch
model = models.ImportBatch
class ImportJobViewSet( class ImportJobViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
mixins.ListModelMixin, mixins.ListModelMixin,
viewsets.GenericViewSet): viewsets.GenericViewSet):
queryset = (models.ImportJob.objects.all().select_related()) queryset = (models.ImportJob.objects.all().select_related())
serializer_class = serializers.ImportJobSerializer serializer_class = serializers.ImportJobSerializer
permission_classes = (ImportJobPermission, ) permission_classes = (HasUserPermission,)
required_permissions = ['library']
filter_class = filters.ImportJobFilter filter_class = filters.ImportJobFilter
@list_route(methods=['get']) @list_route(methods=['get'])
...@@ -230,7 +227,7 @@ def get_file_path(audio_file): ...@@ -230,7 +227,7 @@ def get_file_path(audio_file):
'MUSIC_DIRECTORY_PATH to serve in-place imported files' 'MUSIC_DIRECTORY_PATH to serve in-place imported files'
) )
path = '/music' + audio_file.replace(prefix, '', 1) path = '/music' + audio_file.replace(prefix, '', 1)
return settings.PROTECT_FILES_PATH + path return (settings.PROTECT_FILES_PATH + path).encode('utf-8')
if t == 'apache2': if t == 'apache2':
try: try:
path = audio_file.path path = audio_file.path
...@@ -241,7 +238,7 @@ def get_file_path(audio_file): ...@@ -241,7 +238,7 @@ def get_file_path(audio_file):
'You need to specify MUSIC_DIRECTORY_SERVE_PATH and ' 'You need to specify MUSIC_DIRECTORY_SERVE_PATH and '
'MUSIC_DIRECTORY_PATH to serve in-place imported files' 'MUSIC_DIRECTORY_PATH to serve in-place imported files'
) )
path = audio_file.replace(prefix, serve_path, 1) path = audio_file.replace(prefix, serve_path, 1).encode('utf-8')
return path return path
...@@ -268,6 +265,10 @@ def handle_serve(track_file): ...@@ -268,6 +265,10 @@ def handle_serve(track_file):
qs = LibraryTrack.objects.select_for_update() qs = LibraryTrack.objects.select_for_update()
library_track = qs.get(pk=library_track.pk) library_track = qs.get(pk=library_track.pk)
library_track.download_audio() library_track.download_audio()
track_file.library_track = library_track
track_file.set_audio_data()
track_file.save(update_fields=['bitrate', 'duration', 'size'])
audio_file = library_track.audio_file audio_file = library_track.audio_file
file_path = get_file_path(audio_file) file_path = get_file_path(audio_file)
mt = library_track.audio_mimetype mt = library_track.audio_mimetype
...@@ -275,6 +276,9 @@ def handle_serve(track_file): ...@@ -275,6 +276,9 @@ def handle_serve(track_file):
file_path = get_file_path(audio_file) file_path = get_file_path(audio_file)
elif f.source and f.source.startswith('file://'): elif f.source and f.source.startswith('file://'):
file_path = get_file_path(f.source.replace('file://', '', 1)) file_path = get_file_path(f.source.replace('file://', '', 1))
if mt:
response = Response(content_type=mt)
else:
response = Response() response = Response()
filename = f.filename filename = f.filename
mapping = { mapping = {
...@@ -293,7 +297,11 @@ def handle_serve(track_file): ...@@ -293,7 +297,11 @@ def handle_serve(track_file):
class TrackFileViewSet(viewsets.ReadOnlyModelViewSet): class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
queryset = (models.TrackFile.objects.all().order_by('-id')) queryset = (
models.TrackFile.objects.all()
.select_related('track__artist', 'track__album')
.order_by('-id')
)
serializer_class = serializers.TrackFileSerializer serializer_class = serializers.TrackFileSerializer
authentication_classes = rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES + [ authentication_classes = rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES + [
SignatureAuthentication SignatureAuthentication
...@@ -431,7 +439,8 @@ class Search(views.APIView): ...@@ -431,7 +439,8 @@ class Search(views.APIView):
class SubmitViewSet(viewsets.ViewSet): class SubmitViewSet(viewsets.ViewSet):
queryset = models.ImportBatch.objects.none() queryset = models.ImportBatch.objects.none()
permission_classes = (permissions.DjangoModelPermissions, ) permission_classes = (HasUserPermission,)
required_permissions = ['library']
@list_route(methods=['post']) @list_route(methods=['post'])
@transaction.non_atomic_requests @transaction.non_atomic_requests
......
...@@ -13,3 +13,6 @@ class MaxTracks(preferences.DefaultFromSettingMixin, types.IntegerPreference): ...@@ -13,3 +13,6 @@ class MaxTracks(preferences.DefaultFromSettingMixin, types.IntegerPreference):
name = 'max_tracks' name = 'max_tracks'
verbose_name = 'Max tracks per playlist' verbose_name = 'Max tracks per playlist'
setting = 'PLAYLISTS_MAX_TRACKS' setting = 'PLAYLISTS_MAX_TRACKS'
field_kwargs = {
'required': False,
}
from django import forms
from dynamic_preferences.types import StringPreference, Section from dynamic_preferences.types import StringPreference, Section
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
...@@ -11,3 +13,7 @@ class APIKey(StringPreference): ...@@ -11,3 +13,7 @@ class APIKey(StringPreference):
default = '' default = ''
verbose_name = 'Acoustid API key' verbose_name = 'Acoustid API key'
help_text = 'The API key used to query AcoustID. Get one at https://acoustid.org/new-application.' help_text = 'The API key used to query AcoustID. Get one at https://acoustid.org/new-application.'
widget = forms.PasswordInput
field_kwargs = {
'required': False,
}
from django import forms
from dynamic_preferences.types import StringPreference, Section from dynamic_preferences.types import StringPreference, Section
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
...@@ -11,3 +13,7 @@ class APIKey(StringPreference): ...@@ -11,3 +13,7 @@ class APIKey(StringPreference):
default = 'CHANGEME' default = 'CHANGEME'
verbose_name = 'YouTube API key' verbose_name = 'YouTube API key'
help_text = 'The API key used to query YouTube. Get one at https://console.developers.google.com/.' help_text = 'The API key used to query YouTube. Get one at https://console.developers.google.com/.'
widget = forms.PasswordInput
field_kwargs = {
'required': False,
}
...@@ -15,6 +15,9 @@ class SubsonicJSONRenderer(renderers.JSONRenderer): ...@@ -15,6 +15,9 @@ class SubsonicJSONRenderer(renderers.JSONRenderer):
} }
} }
final['subsonic-response'].update(data) final['subsonic-response'].update(data)
if 'error' in final:
# an error was returned
final['subsonic-response']['status'] = 'failed'
return super().render(final, accepted_media_type, renderer_context) return super().render(final, accepted_media_type, renderer_context)
...@@ -31,6 +34,9 @@ class SubsonicXMLRenderer(renderers.JSONRenderer): ...@@ -31,6 +34,9 @@ class SubsonicXMLRenderer(renderers.JSONRenderer):
'version': '1.16.0', 'version': '1.16.0',
} }
final.update(data) final.update(data)
if 'error' in final:
# an error was returned
final['status'] = 'failed'
tree = dict_to_xml_tree('subsonic-response', final) tree = dict_to_xml_tree('subsonic-response', final)
return b'<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(tree, encoding='utf-8') return b'<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(tree, encoding='utf-8')
......
...@@ -81,6 +81,10 @@ def get_track_data(album, track, tf): ...@@ -81,6 +81,10 @@ def get_track_data(album, track, tf):
'artistId': album.artist.pk, 'artistId': album.artist.pk,
'type': 'music', 'type': 'music',
} }
if tf.bitrate:
data['bitrate'] = int(tf.bitrate/1000)
if tf.size:
data['size'] = tf.size
if album.release_date: if album.release_date:
data['year'] = album.release_date.year data['year'] = album.release_date.year
return data return data
...@@ -211,5 +215,9 @@ def get_music_directory_data(artist): ...@@ -211,5 +215,9 @@ def get_music_directory_data(artist):
'parent': artist.id, 'parent': artist.id,
'type': 'music', 'type': 'music',
} }
if tf.bitrate:
td['bitrate'] = int(tf.bitrate/1000)
if tf.size:
td['size'] = tf.size
data['child'].append(td) data['child'].append(td)
return data return data
...@@ -31,15 +31,19 @@ def find_object(queryset, model_field='pk', field='id', cast=int): ...@@ -31,15 +31,19 @@ def find_object(queryset, model_field='pk', field='id', cast=int):
raw_value = data[field] raw_value = data[field]
except KeyError: except KeyError:
return response.Response({ return response.Response({
'error': {
'code': 10, 'code': 10,
'message': "required parameter '{}' not present".format(field) 'message': "required parameter '{}' not present".format(field)
}
}) })
try: try:
value = cast(raw_value) value = cast(raw_value)
except (TypeError, ValidationError): except (TypeError, ValidationError):
return response.Response({ return response.Response({
'error': {
'code': 0, 'code': 0,
'message': 'For input string "{}"'.format(raw_value) 'message': 'For input string "{}"'.format(raw_value)
}
}) })
qs = queryset qs = queryset
if hasattr(qs, '__call__'): if hasattr(qs, '__call__'):
...@@ -48,9 +52,11 @@ def find_object(queryset, model_field='pk', field='id', cast=int): ...@@ -48,9 +52,11 @@ def find_object(queryset, model_field='pk', field='id', cast=int):
obj = qs.get(**{model_field: value}) obj = qs.get(**{model_field: value})
except qs.model.DoesNotExist: except qs.model.DoesNotExist:
return response.Response({ return response.Response({
'error': {
'code': 70, 'code': 70,
'message': '{} not found'.format( 'message': '{} not found'.format(
qs.model.__class__.__name__) qs.model.__class__.__name__)
}
}) })
kwargs['obj'] = obj kwargs['obj'] = obj
return func(self, request, *args, **kwargs) return func(self, request, *args, **kwargs)
...@@ -83,11 +89,10 @@ class SubsonicViewSet(viewsets.GenericViewSet): ...@@ -83,11 +89,10 @@ class SubsonicViewSet(viewsets.GenericViewSet):
payload = { payload = {
'status': 'failed' 'status': 'failed'
} }
try: if exc.__class__ in mapping:
code, message = mapping[exc.__class__] code, message = mapping[exc.__class__]
except KeyError:
return super().handle_exception(exc)
else: else:
return super().handle_exception(exc)
payload['error'] = { payload['error'] = {
'code': code, 'code': code,
'message': message 'message': message
...@@ -450,8 +455,10 @@ class SubsonicViewSet(viewsets.GenericViewSet): ...@@ -450,8 +455,10 @@ class SubsonicViewSet(viewsets.GenericViewSet):
name = data.get('name', '') name = data.get('name', '')
if not name: if not name:
return response.Response({ return response.Response({
'error': {
'code': 10, 'code': 10,
'message': 'Playlist ID or name must be specified.' 'message': 'Playlist ID or name must be specified.'
}
}, data) }, data)
playlist = request.user.playlists.create( playlist = request.user.playlists.create(
......
...@@ -5,6 +5,7 @@ from django import forms ...@@ -5,6 +5,7 @@ from django import forms
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as AuthUserAdmin from django.contrib.auth.admin import UserAdmin as AuthUserAdmin
from django.contrib.auth.forms import UserChangeForm, UserCreationForm from django.contrib.auth.forms import UserChangeForm, UserCreationForm
from django.utils.translation import ugettext_lazy as _
from .models import User from .models import User
...@@ -41,8 +42,33 @@ class UserAdmin(AuthUserAdmin): ...@@ -41,8 +42,33 @@ class UserAdmin(AuthUserAdmin):
'email', 'email',
'date_joined', 'date_joined',
'last_login', 'last_login',
'privacy_level', 'is_staff',
'is_superuser',
] ]
list_filter = [ list_filter = [
'is_superuser',
'is_staff',
'privacy_level', 'privacy_level',
'permission_settings',
'permission_library',
'permission_federation',
] ]
fieldsets = (
(None, {'fields': ('username', 'password', 'privacy_level')}),
(_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}),
(_('Permissions'), {
'fields': (
'is_active',
'is_staff',
'is_superuser',
'permission_library',
'permission_settings',
'permission_federation')}),
(_('Important dates'), {'fields': ('last_login', 'date_joined')}),
(_('Useless fields'), {
'fields': (
'user_permissions',
'groups',
)})
)
...@@ -10,6 +10,7 @@ class RegistrationEnabled(types.BooleanPreference): ...@@ -10,6 +10,7 @@ class RegistrationEnabled(types.BooleanPreference):
section = users section = users
name = 'registration_enabled' name = 'registration_enabled'
default = False default = False
verbose_name = ( verbose_name = 'Open registrations to new users'
'Can visitors open a new account on this instance?' help_text = (
'When enabled, new users will be able to register on this instance.'
) )
import factory import factory
from funkwhale_api.factories import registry from funkwhale_api.factories import registry, ManyToManyFromList
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
@registry.register
class GroupFactory(factory.django.DjangoModelFactory):
name = factory.Sequence(lambda n: 'group-{0}'.format(n))
class Meta:
model = 'auth.Group'
@factory.post_generation
def perms(self, create, extracted, **kwargs):
if not create:
# Simple build, do nothing.
return
if extracted:
perms = [
Permission.objects.get(
content_type__app_label=p.split('.')[0],
codename=p.split('.')[1],
)
for p in extracted
]
# A list of permissions were passed in, use them
self.permissions.add(*perms)
@registry.register @registry.register
class UserFactory(factory.django.DjangoModelFactory): class UserFactory(factory.django.DjangoModelFactory):
username = factory.Sequence(lambda n: 'user-{0}'.format(n)) username = factory.Sequence(lambda n: 'user-{0}'.format(n))
email = factory.Sequence(lambda n: 'user-{0}@example.com'.format(n)) email = factory.Sequence(lambda n: 'user-{0}@example.com'.format(n))
password = factory.PostGenerationMethodCall('set_password', 'test') password = factory.PostGenerationMethodCall('set_password', 'test')
subsonic_api_token = None subsonic_api_token = None
groups = ManyToManyFromList('groups')
class Meta: class Meta:
model = 'users.User' model = 'users.User'
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment