Verified Commit 7b18e46f authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch 'release/0.13'

parents 107cca7b d299964c
...@@ -9,3 +9,5 @@ FUNKWHALE_PROTOCOL=http ...@@ -9,3 +9,5 @@ FUNKWHALE_PROTOCOL=http
PYTHONDONTWRITEBYTECODE=true PYTHONDONTWRITEBYTECODE=true
WEBPACK_DEVSERVER_PORT=8080 WEBPACK_DEVSERVER_PORT=8080
MUSIC_DIRECTORY_PATH=/music MUSIC_DIRECTORY_PATH=/music
BROWSABLE_API_ENABLED=True
CACHEOPS_ENABLED=False
...@@ -10,6 +10,127 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog. ...@@ -10,6 +10,127 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.
.. towncrier .. towncrier
0.13 (2018-05-19)
-----------------
Upgrade instructions are available at
https://docs.funkwhale.audio/upgrading.html
Features:
- Can now import and play flac files (#157)
- Simpler permission system (#152)
- Store file length, size and bitrate (#195)
- We now have a brand new instance settings interface in the front-end (#206)
Enhancements:
- Disabled browsable HTML API in production (#205)
- Instances can now indicate on the nodeinfo endpoint if they want to remain
private (#200)
Bugfixes:
- .well-known/nodeinfo endpoint can now answer to request with Accept:
application/json (#197)
- Fixed escaping issue of track name in playlist modal (#201)
- Fixed missing dot when downloading file (#204)
- In-place imported tracks with non-ascii characters don't break reverse-proxy
serving (#196)
- Removed Python 3.6 dependency (secrets module) (#198)
- Uplayable tracks are now properly disabled in the interface (#199)
Instance settings interface
^^^^^^^^^^^^^^^^^^^^^^^^^^^
Prior to this release, the only way to update instance settings (such as
instance description, signup policy, federation configuration, etc.) was using
the admin interface provided by Django (the back-end framework which power the API).
This interface worked, but was not really-user friendly and intuitive.
Starting from this release, we now offer a dedicated interface directly
in the front-end. You can view and edit all your instance settings from here,
assuming you have the required permissions.
This interface is available at ``/manage/settings` and via link in the sidebar.
Storage of bitrate, size and length in database
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Starting with this release, when importing files, Funkwhale will store
additional information about audio files:
- Bitrate
- Size (in bytes)
- Duration
This change is not retroactive, meaning already imported files will lack those
informations. The interface and API should work as before in such case, however,
we offer a command to deal with legacy files and populate the missing values.
On docker setups:
.. code-block:: shell
docker-compose run --rm api python manage.py fix_track_files
On non-docker setups:
.. code-block:: shell
# from your activated virtualenv
python manage.py fix_track_files
.. note::
The execution time for this command is proportional to the number of
audio files stored on your instance. This is because we need to read the
files from disk to fetch the data. You can run it in the background
while Funkwhale is up.
It's also safe to interrupt this command and rerun it at a later point, or run
it multiple times.
Use the --dry-run flag to check how many files would be impacted.
Simpler permission system
^^^^^^^^^^^^^^^^^^^^^^^^^
Starting from this release, the permission system is much simpler. Up until now,
we were using Django's built-in permission system, which was working, but also
quite complex to deal with.
The new implementation relies on simpler logic, which will make integration
on the front-end in upcoming releases faster and easier.
If you have manually given permissions to users on your instance,
you can migrate those to the new system.
On docker setups:
.. code-block:: shell
docker-compose run --rm api python manage.py script django_permissions_to_user_permissions --no-input
On non-docker setups:
.. code-block:: shell
# in your virtualenv
python api/manage.py script django_permissions_to_user_permissions --no-input
There is still no dedicated interface to manage user permissions, but you
can use the admin interface at ``/api/admin/users/user/`` for that purpose in
the meantime.
0.12 (2018-05-09) 0.12 (2018-05-09)
----------------- -----------------
...@@ -110,9 +231,7 @@ We offer two settings to manage nodeinfo in your Funkwhale instance: ...@@ -110,9 +231,7 @@ We offer two settings to manage nodeinfo in your Funkwhale instance:
and user activity. and user activity.
To make your instance fully compatible with the nodeinfo protocol, you need to To make your instance fully compatible with the nodeinfo protocol, you need to
to edit your nginx configuration file: to edit your nginx configuration file::
.. code-block::
# before # before
... ...
...@@ -130,9 +249,7 @@ to edit your nginx configuration file: ...@@ -130,9 +249,7 @@ to edit your nginx configuration file:
} }
... ...
You can do the same if you use apache: You can do the same if you use apache::
.. code-block::
# before # before
... ...
......
...@@ -406,8 +406,18 @@ REST_FRAMEWORK = { ...@@ -406,8 +406,18 @@ REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': ( 'DEFAULT_FILTER_BACKENDS': (
'rest_framework.filters.OrderingFilter', 'rest_framework.filters.OrderingFilter',
'django_filters.rest_framework.DjangoFilterBackend', 'django_filters.rest_framework.DjangoFilterBackend',
),
'DEFAULT_RENDERER_CLASSES': (
'rest_framework.renderers.JSONRenderer',
) )
} }
BROWSABLE_API_ENABLED = env.bool('BROWSABLE_API_ENABLED', default=False)
if BROWSABLE_API_ENABLED:
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] += (
'rest_framework.renderers.BrowsableAPIRenderer',
)
REST_AUTH_SERIALIZERS = { REST_AUTH_SERIALIZERS = {
'PASSWORD_RESET_SERIALIZER': 'funkwhale_api.users.serializers.PasswordResetSerializer' # noqa 'PASSWORD_RESET_SERIALIZER': 'funkwhale_api.users.serializers.PasswordResetSerializer' # noqa
} }
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
__version__ = '0.12' __version__ = '0.13'
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')]) __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
...@@ -16,5 +16,5 @@ class APIAutenticationRequired( ...@@ -16,5 +16,5 @@ class APIAutenticationRequired(
help_text = ( help_text = (
'If disabled, anonymous users will be able to query the API' 'If disabled, anonymous users will be able to query the API'
'and access music data (as well as other data exposed in the API ' 'and access music data (as well as other data exposed in the API '
'without specific permissions)' 'without specific permissions).'
) )
from django.core.management.base import BaseCommand, CommandError
from funkwhale_api.common import scripts
class Command(BaseCommand):
help = 'Run a specific script from funkwhale_api/common/scripts/'
def add_arguments(self, parser):
parser.add_argument('script_name', nargs='?', type=str)
parser.add_argument(
'--noinput', '--no-input', action='store_false', dest='interactive',
help="Do NOT prompt the user for input of any kind.",
)
def handle(self, *args, **options):
name = options['script_name']
if not name:
self.show_help()
available_scripts = self.get_scripts()
try:
script = available_scripts[name]
except KeyError:
raise CommandError(
'{} is not a valid script. Run python manage.py script for a '
'list of available scripts'.format(name))
self.stdout.write('')
if options['interactive']:
message = (
'Are you sure you want to execute the script {}?\n\n'
"Type 'yes' to continue, or 'no' to cancel: "
).format(name)
if input(''.join(message)) != 'yes':
raise CommandError("Script cancelled.")
script['entrypoint'](self, **options)
def show_help(self):
indentation = 4
self.stdout.write('')
self.stdout.write('Available scripts:')
self.stdout.write('Launch with: python manage.py <script_name>')
available_scripts = self.get_scripts()
for name, script in sorted(available_scripts.items()):
self.stdout.write('')
self.stdout.write(self.style.SUCCESS(name))
self.stdout.write('')
for line in script['help'].splitlines():
self.stdout.write('     {}'.format(line))
self.stdout.write('')
def get_scripts(self):
available_scripts = [
k for k in sorted(scripts.__dict__.keys())
if not k.startswith('__')
]
data = {}
for name in available_scripts:
module = getattr(scripts, name)
data[name] = {
'name': name,
'help': module.__doc__.strip(),
'entrypoint': module.main
}
return data
...@@ -3,7 +3,7 @@ import operator ...@@ -3,7 +3,7 @@ import operator
from django.conf import settings from django.conf import settings
from django.http import Http404 from django.http import Http404
from rest_framework.permissions import BasePermission, DjangoModelPermissions from rest_framework.permissions import BasePermission
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
...@@ -16,17 +16,6 @@ class ConditionalAuthentication(BasePermission): ...@@ -16,17 +16,6 @@ class ConditionalAuthentication(BasePermission):
return True return True
class HasModelPermission(DjangoModelPermissions):
"""
Same as DjangoModelPermissions, but we pin the model:
class MyModelPermission(HasModelPermission):
model = User
"""
def get_required_permissions(self, method, model_cls):
return super().get_required_permissions(method, self.model)
class OwnerPermission(BasePermission): class OwnerPermission(BasePermission):
""" """
Ensure the request user is the owner of the object. Ensure the request user is the owner of the object.
......
from . import django_permissions_to_user_permissions
from . import test
"""
Convert django permissions to user permissions in the database,
following the work done in #152.
"""
from django.db.models import Q
from funkwhale_api.users import models
from django.contrib.auth.models import Permission
mapping = {
'dynamic_preferences.change_globalpreferencemodel': 'settings',
'music.add_importbatch': 'library',
'federation.change_library': 'federation',
}
def main(command, **kwargs):
for codename, user_permission in sorted(mapping.items()):
app_label, c = codename.split('.')
p = Permission.objects.get(
content_type__app_label=app_label, codename=c)
users = models.User.objects.filter(
Q(groups__permissions=p) | Q(user_permissions=p)).distinct()
total = users.count()
command.stdout.write('Updating {} users with {} permission...'.format(
total, user_permission
))
users.update(**{'permission_{}'.format(user_permission): True})
"""
This is a test script that does nothing.
You can launch it just to check how it works.
"""
def main(command, **kwargs):
command.stdout.write('Test script run successfully')
...@@ -19,6 +19,9 @@ class MusicCacheDuration(types.IntPreference): ...@@ -19,6 +19,9 @@ class MusicCacheDuration(types.IntPreference):
'locally? Federated files that were not listened in this interval ' 'locally? Federated files that were not listened in this interval '
'will be erased and refetched from the remote on the next listening.' 'will be erased and refetched from the remote on the next listening.'
) )
field_kwargs = {
'required': False,
}
@global_preferences_registry.register @global_preferences_registry.register
...@@ -29,7 +32,7 @@ class Enabled(preferences.DefaultFromSettingMixin, types.BooleanPreference): ...@@ -29,7 +32,7 @@ class Enabled(preferences.DefaultFromSettingMixin, types.BooleanPreference):
verbose_name = 'Federation enabled' verbose_name = 'Federation enabled'
help_text = ( help_text = (
'Use this setting to enable or disable federation logic and API' 'Use this setting to enable or disable federation logic and API'
' globally' ' globally.'
) )
...@@ -41,8 +44,11 @@ class CollectionPageSize( ...@@ -41,8 +44,11 @@ class CollectionPageSize(
setting = 'FEDERATION_COLLECTION_PAGE_SIZE' setting = 'FEDERATION_COLLECTION_PAGE_SIZE'
verbose_name = 'Federation collection page size' verbose_name = 'Federation collection page size'
help_text = ( help_text = (
'How much items to display in ActivityPub collections' 'How much items to display in ActivityPub collections.'
) )
field_kwargs = {
'required': False,
}
@global_preferences_registry.register @global_preferences_registry.register
...@@ -54,8 +60,11 @@ class ActorFetchDelay( ...@@ -54,8 +60,11 @@ class ActorFetchDelay(
verbose_name = 'Federation actor fetch delay' verbose_name = 'Federation actor fetch delay'
help_text = ( help_text = (
'How much minutes to wait before refetching actors on ' 'How much minutes to wait before refetching actors on '
'request authentication' 'request authentication.'
) )
field_kwargs = {
'required': False,
}
@global_preferences_registry.register @global_preferences_registry.register
...@@ -66,6 +75,6 @@ class MusicNeedsApproval( ...@@ -66,6 +75,6 @@ class MusicNeedsApproval(
setting = 'FEDERATION_MUSIC_NEEDS_APPROVAL' setting = 'FEDERATION_MUSIC_NEEDS_APPROVAL'
verbose_name = 'Federation music needs approval' verbose_name = 'Federation music needs approval'
help_text = ( help_text = (
'When true, other federation actors will require your approval' 'When true, other federation actors will need your approval'
' before being able to browse your library.' ' before being able to browse your library.'
) )
...@@ -233,6 +233,9 @@ class AudioMetadataFactory(factory.Factory): ...@@ -233,6 +233,9 @@ class AudioMetadataFactory(factory.Factory):
release = factory.LazyAttribute( release = factory.LazyAttribute(
lambda o: 'https://musicbrainz.org/release/{}'.format(uuid.uuid4()) lambda o: 'https://musicbrainz.org/release/{}'.format(uuid.uuid4())
) )
bitrate = 42
length = 43
size = 44
class Meta: class Meta:
model = dict model = dict
......
...@@ -216,3 +216,6 @@ class LibraryTrack(models.Model): ...@@ -216,3 +216,6 @@ class LibraryTrack(models.Model):
for chunk in r.iter_content(chunk_size=512): for chunk in r.iter_content(chunk_size=512):
tmp_file.write(chunk) tmp_file.write(chunk)
self.audio_file.save(filename, tmp_file) self.audio_file.save(filename, tmp_file)
def get_metadata(self, key):
return self.metadata.get(key)
...@@ -688,6 +688,12 @@ class AudioMetadataSerializer(serializers.Serializer): ...@@ -688,6 +688,12 @@ class AudioMetadataSerializer(serializers.Serializer):
artist = ArtistMetadataSerializer() artist = ArtistMetadataSerializer()
release = ReleaseMetadataSerializer() release = ReleaseMetadataSerializer()
recording = RecordingMetadataSerializer() recording = RecordingMetadataSerializer()
bitrate = serializers.IntegerField(
required=False, allow_null=True, min_value=0)
size = serializers.IntegerField(
required=False, allow_null=True, min_value=0)
length = serializers.IntegerField(
required=False, allow_null=True, min_value=0)
class AudioSerializer(serializers.Serializer): class AudioSerializer(serializers.Serializer):
...@@ -760,6 +766,9 @@ class AudioSerializer(serializers.Serializer): ...@@ -760,6 +766,9 @@ class AudioSerializer(serializers.Serializer):
'musicbrainz_id': str(track.mbid) if track.mbid else None, 'musicbrainz_id': str(track.mbid) if track.mbid else None,
'title': track.title, 'title': track.title,
}, },
'bitrate': instance.bitrate,
'size': instance.size,
'length': instance.duration,
}, },
'url': { 'url': {
'href': utils.full_url(instance.path), 'href': utils.full_url(instance.path),
......
...@@ -15,8 +15,8 @@ from rest_framework.serializers import ValidationError ...@@ -15,8 +15,8 @@ from rest_framework.serializers import ValidationError
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.common import utils as funkwhale_utils from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.common.permissions import HasModelPermission
from funkwhale_api.music.models import TrackFile from funkwhale_api.music.models import TrackFile
from funkwhale_api.users.permissions import HasUserPermission
from . import activity from . import activity
from . import actors from . import actors
...@@ -88,7 +88,7 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet): ...@@ -88,7 +88,7 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
class WellKnownViewSet(viewsets.GenericViewSet): class WellKnownViewSet(viewsets.GenericViewSet):
authentication_classes = [] authentication_classes = []
permission_classes = [] permission_classes = []
renderer_classes = [renderers.WebfingerRenderer] renderer_classes = [renderers.JSONRenderer, renderers.WebfingerRenderer]
@list_route(methods=['get']) @list_route(methods=['get'])
def nodeinfo(self, request, *args, **kwargs): def nodeinfo(self, request, *args, **kwargs):
...@@ -187,16 +187,13 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet): ...@@ -187,16 +187,13 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
return response.Response(data) return response.Response(data)
class LibraryPermission(HasModelPermission):
model = models.Library
class LibraryViewSet( class LibraryViewSet(
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.UpdateModelMixin, mixins.UpdateModelMixin,
mixins.ListModelMixin, mixins.ListModelMixin,
viewsets.GenericViewSet): viewsets.GenericViewSet):
permission_classes = [LibraryPermission] permission_classes = (HasUserPermission,)
required_permissions = ['federation']
queryset = models.Library.objects.all().select_related( queryset = models.Library.objects.all().select_related(
'actor', 'actor',
'follow', 'follow',
...@@ -291,7 +288,8 @@ class LibraryViewSet( ...@@ -291,7 +288,8 @@ class LibraryViewSet(
class LibraryTrackViewSet( class LibraryTrackViewSet(
mixins.ListModelMixin, mixins.ListModelMixin,
viewsets.GenericViewSet): viewsets.GenericViewSet):
permission_classes = [LibraryPermission] permission_classes = (HasUserPermission,)
required_permissions = ['federation']
queryset = models.LibraryTrack.objects.all().select_related( queryset = models.LibraryTrack.objects.all().select_related(
'library__actor', 'library__actor',
'library__follow', 'library__follow',
......
...@@ -13,8 +13,11 @@ class InstanceName(types.StringPreference): ...@@ -13,8 +13,11 @@ class InstanceName(types.StringPreference):
section = instance section = instance
name = 'name' name = 'name'
default = '' default = ''
help_text = 'Instance public name' verbose_name = 'Public name'
verbose_name = 'The public name of your instance' help_text = 'The public name of your instance, displayed in the about page.'
field_kwargs = {
'required': False,
}
@global_preferences_registry.register @global_preferences_registry.register
...@@ -23,7 +26,11 @@ class InstanceShortDescription(types.StringPreference): ...@@ -23,7 +26,11 @@ class InstanceShortDescription(types.StringPreference):
section = instance section = instance
name = 'short_description' name = 'short_description'
default = '' default = ''
verbose_name = 'Instance succinct description' verbose_name = 'Short description'
help_text = 'Instance succinct description, displayed in the about page.'
field_kwargs = {
'required': False,
}
@global_preferences_registry.register @global_preferences_registry.register
...@@ -31,31 +38,31 @@ class InstanceLongDescription(types.StringPreference): ...@@ -31,31 +38,31 @@ class InstanceLongDescription(types.StringPreference):
show_in_api = True show_in_api = True
section = instance section = instance
name = 'lo