From 498aa1137bfc57c0d6ac723d4a337237fe34aa41 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 25 Feb 2018 13:05:01 +0100 Subject: [PATCH 01/25] Initial channels integration --- api/config/settings/common.py | 14 +++++++++++++- api/requirements/base.txt | 2 ++ docker/nginx/conf.dev | 8 ++++++++ front/config/index.js | 1 + front/package.json | 1 + 5 files changed, 25 insertions(+), 1 deletion(-) diff --git a/api/config/settings/common.py b/api/config/settings/common.py index f5ddec00b..e5389756f 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -30,6 +30,7 @@ ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS') # APP CONFIGURATION # ------------------------------------------------------------------------------ DJANGO_APPS = ( + 'channels', # Default Django apps: 'django.contrib.auth', 'django.contrib.contenttypes', @@ -253,9 +254,9 @@ MEDIA_URL = env("MEDIA_URL", default='/media/') # URL Configuration # ------------------------------------------------------------------------------ ROOT_URLCONF = 'config.urls' - # See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application WSGI_APPLICATION = 'config.wsgi.application' +ASGI_APPLICATION = "config.routing.application" # AUTHENTICATION CONFIGURATION # ------------------------------------------------------------------------------ @@ -284,6 +285,17 @@ CACHES = { } CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache" +from urllib.parse import urlparse +cache_url = urlparse(CACHES['default']['LOCATION']) +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [(cache_url.hostname, cache_url.port)], + }, + }, +} + CACHES["default"]["OPTIONS"] = { "CLIENT_CLASS": "django_redis.client.DefaultClient", "IGNORE_EXCEPTIONS": True, # mimics memcache behavior. diff --git a/api/requirements/base.txt b/api/requirements/base.txt index 133fcc0cb..d402d3591 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -59,3 +59,5 @@ pyacoustid>=1.1.5,<1.2 raven>=6.5,<7 python-magic==0.4.15 ffmpeg-python==0.1.10 +channels>=2,<2.1 +channels_redis>=2.1,<2.2 diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev index 29c04fc66..9847c2dcb 100644 --- a/docker/nginx/conf.dev +++ b/docker/nginx/conf.dev @@ -28,6 +28,11 @@ http { #gzip on; proxy_cache_path /tmp/funkwhale-transcode levels=1:2 keys_zone=transcode:10m max_size=1g inactive=24h use_temp_path=off; + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + server { listen 6001; charset utf-8; @@ -40,6 +45,9 @@ http { proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host localhost:8080; proxy_set_header X-Forwarded-Port 8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; proxy_redirect off; location /_protected/media { diff --git a/front/config/index.js b/front/config/index.js index 7ce6e26e1..14cbe3e43 100644 --- a/front/config/index.js +++ b/front/config/index.js @@ -32,6 +32,7 @@ module.exports = { '/api': { target: 'http://nginx:6001', changeOrigin: true, + ws: true }, '/media': { target: 'http://nginx:6001', diff --git a/front/package.json b/front/package.json index d6bdb8c56..201694e43 100644 --- a/front/package.json +++ b/front/package.json @@ -17,6 +17,7 @@ "dependencies": { "axios": "^0.17.1", "dateformat": "^2.0.0", + "django-channels": "^1.1.6", "js-logger": "^1.3.0", "jwt-decode": "^2.2.0", "lodash": "^4.17.4", From 5c2ddc56c4d4182a3d2059a0e24693704990761f Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 25 Feb 2018 13:05:29 +0100 Subject: [PATCH 02/25] Basic channels middleware for token auth --- api/config/routing.py | 17 ++++++++++ api/funkwhale_api/common/auth.py | 47 +++++++++++++++++++++++++++ api/funkwhale_api/common/consumers.py | 11 +++++++ api/tests/channels/test_auth.py | 37 +++++++++++++++++++++ api/tests/channels/test_consumers.py | 26 +++++++++++++++ 5 files changed, 138 insertions(+) create mode 100644 api/config/routing.py create mode 100644 api/funkwhale_api/common/auth.py create mode 100644 api/funkwhale_api/common/consumers.py create mode 100644 api/tests/channels/test_auth.py create mode 100644 api/tests/channels/test_consumers.py diff --git a/api/config/routing.py b/api/config/routing.py new file mode 100644 index 000000000..249bf51a8 --- /dev/null +++ b/api/config/routing.py @@ -0,0 +1,17 @@ +from django.conf.urls import url + +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter + +from funkwhale_api.common.auth import TokenAuthMiddleware +from funkwhale_api.music import consumers + + +application = ProtocolTypeRouter({ + # Empty for now (http->django views is added by default) + "websocket": TokenAuthMiddleware( + URLRouter([ + url("^api/v1/test/$", consumers.MyConsumer), + ]) + ), +}) diff --git a/api/funkwhale_api/common/auth.py b/api/funkwhale_api/common/auth.py new file mode 100644 index 000000000..6f99b3bba --- /dev/null +++ b/api/funkwhale_api/common/auth.py @@ -0,0 +1,47 @@ +from urllib.parse import parse_qs + +import jwt + +from django.contrib.auth.models import AnonymousUser +from django.utils.encoding import smart_text + +from rest_framework import exceptions +from rest_framework_jwt.settings import api_settings +from rest_framework_jwt.authentication import BaseJSONWebTokenAuthentication + + + +class TokenHeaderAuth(BaseJSONWebTokenAuthentication): + def get_jwt_value(self, request): + + try: + qs = request.get('query_string', b'').decode('utf-8') + parsed = parse_qs(qs) + token = parsed['token'][0] + except KeyError: + raise exceptions.AuthenticationFailed('No token') + + if not token: + raise exceptions.AuthenticationFailed('Empty token') + + return token + + +class TokenAuthMiddleware: + """ + Custom middleware (insecure) that takes user IDs from the query string. + """ + + def __init__(self, inner): + # Store the ASGI application we were passed + self.inner = inner + + def __call__(self, scope): + auth = TokenHeaderAuth() + try: + user, token = auth.authenticate(scope) + except exceptions.AuthenticationFailed: + user = AnonymousUser() + + scope['user'] = user + return self.inner(scope) diff --git a/api/funkwhale_api/common/consumers.py b/api/funkwhale_api/common/consumers.py new file mode 100644 index 000000000..26e57fc8a --- /dev/null +++ b/api/funkwhale_api/common/consumers.py @@ -0,0 +1,11 @@ +from channels.generic.websocket import JsonWebsocketConsumer + + +class JsonAuthConsumer(JsonWebsocketConsumer): + def connect(self): + try: + assert self.scope['user'].pk is not None + except (AssertionError, AttributeError, KeyError): + return self.close() + + return self.accept() diff --git a/api/tests/channels/test_auth.py b/api/tests/channels/test_auth.py new file mode 100644 index 000000000..a2b7eaf0c --- /dev/null +++ b/api/tests/channels/test_auth.py @@ -0,0 +1,37 @@ +import pytest + +from rest_framework_jwt.settings import api_settings + +from funkwhale_api.common.auth import TokenAuthMiddleware + +jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER +jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER + + +@pytest.mark.parametrize('query_string', [ + b'token=wrong', + b'', +]) +def test_header_anonymous(query_string, factories): + def callback(scope): + assert scope['user'].is_anonymous + + scope = { + 'query_string': query_string + } + consumer = TokenAuthMiddleware(callback) + consumer(scope) + + +def test_header_correct_token(factories): + user = factories['users.User']() + payload = jwt_payload_handler(user) + token = jwt_encode_handler(payload) + def callback(scope): + assert scope['user'] == user + + scope = { + 'query_string': 'token={}'.format(token).encode('utf-8') + } + consumer = TokenAuthMiddleware(callback) + consumer(scope) diff --git a/api/tests/channels/test_consumers.py b/api/tests/channels/test_consumers.py new file mode 100644 index 000000000..f1648efb3 --- /dev/null +++ b/api/tests/channels/test_consumers.py @@ -0,0 +1,26 @@ +from funkwhale_api.common import consumers + + +def test_auth_consumer_requires_valid_user(mocker): + m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.close') + scope = {'user': None} + consumer = consumers.JsonAuthConsumer(scope=scope) + consumer.connect() + m.assert_called_once_with() + + +def test_auth_consumer_requires_user_in_scope(mocker): + m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.close') + scope = {} + consumer = consumers.JsonAuthConsumer(scope=scope) + consumer.connect() + m.assert_called_once_with() + + +def test_auth_consumer_accepts_connection(mocker, factories): + user = factories['users.User']() + m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.accept') + scope = {'user': user} + consumer = consumers.JsonAuthConsumer(scope=scope) + consumer.connect() + m.assert_called_once_with() From 93e4a4f1236c0d3d47ec906d9c40ebd1e4c36b79 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 25 Feb 2018 14:43:23 +0100 Subject: [PATCH 03/25] Added FUNWHALE_URL setting to use in activity stream in representations --- .gitlab-ci.yml | 2 +- api/config/settings/common.py | 2 +- api/test.yml | 1 + deploy/env.prod.sample | 4 ++++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0fa450c46..e4accd722 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -22,7 +22,7 @@ test_api: variables: DJANGO_ALLOWED_HOSTS: "localhost" DATABASE_URL: "postgresql://postgres@postgres/postgres" - + FUNKWHALE_URL: "https://funkwhale.ci" before_script: - cd api - pip install -r requirements/base.txt diff --git a/api/config/settings/common.py b/api/config/settings/common.py index e5389756f..4c574b4c7 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -25,7 +25,7 @@ except FileNotFoundError: pass ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS') - +FUNKWHALE_URL = env('FUNKWHALE_URL') # APP CONFIGURATION # ------------------------------------------------------------------------------ diff --git a/api/test.yml b/api/test.yml index e892dfb17..5e785cb1a 100644 --- a/api/test.yml +++ b/api/test.yml @@ -12,5 +12,6 @@ services: environment: - "DJANGO_ALLOWED_HOSTS=localhost" - "DATABASE_URL=postgresql://postgres@postgres/postgres" + - "FUNKWHALE_URL=https://funkwhale.test" postgres: image: postgres diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample index 6a4b15b67..037dc4651 100644 --- a/deploy/env.prod.sample +++ b/deploy/env.prod.sample @@ -2,6 +2,7 @@ # following variables: # - DJANGO_SECRET_KEY # - DJANGO_ALLOWED_HOSTS +# - FUNKWHALE_URL # Additionaly, on non-docker setup, you'll also have to tweak/uncomment those # variables: @@ -28,6 +29,9 @@ FUNKWHALE_VERSION=latest FUNKWHALE_API_IP=127.0.0.1 FUNKWHALE_API_PORT=5000 +# Replace this by the definitive, public domain you will use for +# your instance +FUNKWHALE_URL=https.//yourdomain.funwhale # API/Django configuration From 691665e3cf209da431299ef04646ca65b6d89127 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 25 Feb 2018 14:44:00 +0100 Subject: [PATCH 04/25] Activity stream representations for user and favorites --- api/funkwhale_api/activity/__init__.py | 0 api/funkwhale_api/activity/serializers.py | 9 ++++++ api/funkwhale_api/favorites/models.py | 6 ++++ api/funkwhale_api/favorites/serializers.py | 33 +++++++++++++++++++++ api/funkwhale_api/music/consumers.py | 17 +++++++++++ api/funkwhale_api/music/models.py | 6 ++++ api/funkwhale_api/users/models.py | 4 +++ api/funkwhale_api/users/serializers.py | 18 +++++++++++ api/tests/favorites/test_activity.py | 28 +++++++++++++++++ api/tests/{ => favorites}/test_favorites.py | 0 api/tests/music/test_activity.py | 17 +++++++++++ api/tests/users/test_activity.py | 21 +++++++++++++ 12 files changed, 159 insertions(+) create mode 100644 api/funkwhale_api/activity/__init__.py create mode 100644 api/funkwhale_api/activity/serializers.py create mode 100644 api/funkwhale_api/music/consumers.py create mode 100644 api/tests/favorites/test_activity.py rename api/tests/{ => favorites}/test_favorites.py (100%) create mode 100644 api/tests/music/test_activity.py create mode 100644 api/tests/users/test_activity.py diff --git a/api/funkwhale_api/activity/__init__.py b/api/funkwhale_api/activity/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/funkwhale_api/activity/serializers.py b/api/funkwhale_api/activity/serializers.py new file mode 100644 index 000000000..4b40bb0d2 --- /dev/null +++ b/api/funkwhale_api/activity/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + + +class ModelSerializer(serializers.ModelSerializer): + id = serializers.CharField(source='get_activity_url') + # url = serializers.SerializerMethodField() + + def get_url(self, obj): + return self.get_id(obj) diff --git a/api/funkwhale_api/favorites/models.py b/api/funkwhale_api/favorites/models.py index 899ed9cff..0c6a6b11c 100644 --- a/api/funkwhale_api/favorites/models.py +++ b/api/funkwhale_api/favorites/models.py @@ -1,8 +1,10 @@ +from django.conf import settings from django.db import models from django.utils import timezone from funkwhale_api.music.models import Track + class TrackFavorite(models.Model): creation_date = models.DateTimeField(default=timezone.now) user = models.ForeignKey( @@ -18,3 +20,7 @@ class TrackFavorite(models.Model): def add(cls, track, user): favorite, created = cls.objects.get_or_create(user=user, track=track) return favorite + + def get_activity_url(self): + return '{}/favorites/tracks/{}'.format( + self.user.get_activity_url(), self.pk) diff --git a/api/funkwhale_api/favorites/serializers.py b/api/funkwhale_api/favorites/serializers.py index 57af4570e..01ad2e475 100644 --- a/api/funkwhale_api/favorites/serializers.py +++ b/api/funkwhale_api/favorites/serializers.py @@ -1,10 +1,43 @@ +from django.conf import settings + from rest_framework import serializers +from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.music.serializers import TrackSerializerNested +from funkwhale_api.users.serializers import UserActivitySerializer from . import models + + + +class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer): + type = serializers.SerializerMethodField() + object = serializers.CharField(source='track.get_activity_url') + actor = UserActivitySerializer(source='user') + published = serializers.DateTimeField(source='creation_date') + + class Meta: + model = models.TrackFavorite + fields = [ + 'id', + 'object', + 'type', + 'actor', + 'published' + ] + + def get_actor(self, obj): + return UserActivitySerializer(obj.user).data + + def get_type(self, obj): + return 'Like' + + def get_object(self, obj): + return obj.track.get_activity_url() + + class UserTrackFavoriteSerializer(serializers.ModelSerializer): # track = TrackSerializerNested(read_only=True) class Meta: diff --git a/api/funkwhale_api/music/consumers.py b/api/funkwhale_api/music/consumers.py new file mode 100644 index 000000000..452346d19 --- /dev/null +++ b/api/funkwhale_api/music/consumers.py @@ -0,0 +1,17 @@ +from funkwhale_api.common.consumers import JsonAuthConsumer + + +class MyConsumer(JsonAuthConsumer): + groups = ["broadcast"] + + def receive_json(self, payload): + print(payload, self.scope["user"]) + # Called with either text_data or bytes_data for each frame + # You can call: + self.send_json({'test': 'me'}) + # Or, to send a binary frame: + # self.send(bytes_data="{Hello} world!") + # Want to force-close the connection? Call: + # self.close() + # # Or add a custom WebSocket error code! + # self.close(code=4123) diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 97992fc8f..7138dcdd6 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -360,6 +360,12 @@ class Track(APIModelMixin): self.title, ) + def get_activity_url(self): + if self.mbid: + return 'https://musicbrainz.org/recording/{}'.format( + self.mbid) + return settings.FUNKWHALE_URL + '/tracks/{}'.format(self.pk) + class TrackFile(models.Model): track = models.ForeignKey( diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 3a0baf11a..2b0dfa888 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals, absolute_import import uuid +from django.conf import settings from django.contrib.auth.models import AbstractUser from django.urls import reverse from django.db import models @@ -43,3 +44,6 @@ class User(AbstractUser): def set_password(self, raw_password): super().set_password(raw_password) self.update_secret_key() + + def get_activity_url(self): + return settings.FUNKWHALE_URL + '/@{}'.format(self.username) diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index 8c218b1c2..2e873d94c 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -1,8 +1,26 @@ from rest_framework import serializers +from funkwhale_api.activity import serializers as activity_serializers + from . import models +class UserActivitySerializer(activity_serializers.ModelSerializer): + type = serializers.SerializerMethodField() + name = serializers.CharField(source='username') + + class Meta: + model = models.User + fields = [ + 'id', + 'name', + 'type' + ] + + def get_type(self, obj): + return 'Person' + + class UserBasicSerializer(serializers.ModelSerializer): class Meta: model = models.User diff --git a/api/tests/favorites/test_activity.py b/api/tests/favorites/test_activity.py new file mode 100644 index 000000000..b4d76658a --- /dev/null +++ b/api/tests/favorites/test_activity.py @@ -0,0 +1,28 @@ +from funkwhale_api.users.serializers import UserActivitySerializer +from funkwhale_api.favorites import serializers + + +def test_get_favorite_activity_url(settings, factories): + favorite = factories['favorites.TrackFavorite']() + user_url = favorite.user.get_activity_url() + expected = '{}/favorites/tracks/{}'.format( + user_url, favorite.pk) + assert favorite.get_activity_url() == expected + + +def test_activity_favorite_serializer(factories): + favorite = factories['favorites.TrackFavorite']() + + actor = UserActivitySerializer(favorite.user).data + field = serializers.serializers.DateTimeField() + expected = { + "type": "Like", + "id": favorite.get_activity_url(), + "actor": actor, + "object": favorite.track.get_activity_url(), + "published": field.to_representation(favorite.creation_date), + } + + data = serializers.TrackFavoriteActivitySerializer(favorite).data + + assert data == expected diff --git a/api/tests/test_favorites.py b/api/tests/favorites/test_favorites.py similarity index 100% rename from api/tests/test_favorites.py rename to api/tests/favorites/test_favorites.py diff --git a/api/tests/music/test_activity.py b/api/tests/music/test_activity.py new file mode 100644 index 000000000..f604874c1 --- /dev/null +++ b/api/tests/music/test_activity.py @@ -0,0 +1,17 @@ +from funkwhale_api.users.serializers import UserActivitySerializer +from funkwhale_api.favorites import serializers + + + +def test_get_track_activity_url_mbid(factories): + track = factories['music.Track']() + expected = 'https://musicbrainz.org/recording/{}'.format( + track.mbid) + assert track.get_activity_url() == expected + + +def test_get_track_activity_url_no_mbid(settings, factories): + track = factories['music.Track'](mbid=None) + expected = settings.FUNKWHALE_URL + '/tracks/{}'.format( + track.pk) + assert track.get_activity_url() == expected diff --git a/api/tests/users/test_activity.py b/api/tests/users/test_activity.py new file mode 100644 index 000000000..3cee4fb40 --- /dev/null +++ b/api/tests/users/test_activity.py @@ -0,0 +1,21 @@ +from funkwhale_api.users import serializers + + +def test_get_user_activity_url(settings, factories): + user = factories['users.User']() + assert user.get_activity_url() == '{}/@{}'.format( + settings.FUNKWHALE_URL, user.username) + + +def test_activity_user_serializer(factories): + user = factories['users.User']() + + expected = { + "type": "Person", + "id": user.get_activity_url(), + "name": user.username, + } + + data = serializers.UserActivitySerializer(user).data + + assert data == expected From fd7c1e5dd89fb15d2880e35e84053ddf118edcf8 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 1 Mar 2018 20:36:29 +0100 Subject: [PATCH 05/25] Added privacy level for user --- .../migrations/0004_user_privacy_level.py | 18 ++++++++++++++++++ api/funkwhale_api/users/models.py | 11 +++++++++++ 2 files changed, 29 insertions(+) create mode 100644 api/funkwhale_api/users/migrations/0004_user_privacy_level.py diff --git a/api/funkwhale_api/users/migrations/0004_user_privacy_level.py b/api/funkwhale_api/users/migrations/0004_user_privacy_level.py new file mode 100644 index 000000000..81891eb0f --- /dev/null +++ b/api/funkwhale_api/users/migrations/0004_user_privacy_level.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.2 on 2018-03-01 19:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_auto_20171226_1357'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='privacy_level', + field=models.CharField(choices=[('me', 'Only me'), ('followers', 'Me and my followers'), ('instance', 'Everyone on my instance, and my followers'), ('everyone', 'Everyone, including people on other instances')], default='instance', max_length=30), + ), + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 2b0dfa888..a5478656b 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -11,6 +11,14 @@ from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ + +PRIVACY_LEVEL_CHOICES = [ + ('me', 'Only me'), + ('followers', 'Me and my followers'), + ('instance', 'Everyone on my instance, and my followers'), + ('everyone', 'Everyone, including people on other instances'), +] + @python_2_unicode_compatible class User(AbstractUser): @@ -31,6 +39,9 @@ class User(AbstractUser): }, } + privacy_level = models.CharField( + max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance') + def __str__(self): return self.username From dd5881f2c68798a7cc5336b7b15d8d09e4943c65 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 1 Mar 2018 20:37:48 +0100 Subject: [PATCH 06/25] Founndations for real-time event serialization/subscription/broadcasting --- api/config/settings/common.py | 1 + api/funkwhale_api/activity/apps.py | 12 ++++++++ api/funkwhale_api/activity/record.py | 38 +++++++++++++++++++++++ api/funkwhale_api/common/channels.py | 5 ++++ api/tests/activity/test_record.py | 45 ++++++++++++++++++++++++++++ api/tests/conftest.py | 26 ++++++++++++++++ 6 files changed, 127 insertions(+) create mode 100644 api/funkwhale_api/activity/apps.py create mode 100644 api/funkwhale_api/activity/record.py create mode 100644 api/funkwhale_api/common/channels.py create mode 100644 api/tests/activity/test_record.py diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 4c574b4c7..bff43b233 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -83,6 +83,7 @@ if RAVEN_ENABLED: # Apps specific for this project go here. LOCAL_APPS = ( 'funkwhale_api.common', + 'funkwhale_api.activity.apps.ActivityConfig', 'funkwhale_api.users', # custom users app # Your stuff: custom apps go here 'funkwhale_api.instance', diff --git a/api/funkwhale_api/activity/apps.py b/api/funkwhale_api/activity/apps.py new file mode 100644 index 000000000..0c66cbf50 --- /dev/null +++ b/api/funkwhale_api/activity/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig, apps + +from . import record + +class ActivityConfig(AppConfig): + name = 'funkwhale_api.activity' + + def ready(self): + super(ActivityConfig, self).ready() + + app_names = [app.name for app in apps.app_configs.values()] + record.registry.autodiscover(app_names) diff --git a/api/funkwhale_api/activity/record.py b/api/funkwhale_api/activity/record.py new file mode 100644 index 000000000..fa55c0e85 --- /dev/null +++ b/api/funkwhale_api/activity/record.py @@ -0,0 +1,38 @@ +import persisting_theory + + +class ActivityRegistry(persisting_theory.Registry): + look_into = 'activities' + + def _register_for_model(self, model, attr, value): + key = model._meta.label + d = self.setdefault(key, {'consumers': []}) + d[attr] = value + + def register_serializer(self, serializer_class): + model = serializer_class.Meta.model + self._register_for_model(model, 'serializer', serializer_class) + return serializer_class + + def register_consumer(self, label): + def decorator(func): + consumers = self[label]['consumers'] + if func not in consumers: + consumers.append(func) + return func + return decorator + + +registry = ActivityRegistry() + + + + +def send(obj): + conf = registry[obj.__class__._meta.label] + consumers = conf['consumers'] + if not consumers: + return + serializer = conf['serializer'](obj) + for consumer in consumers: + consumer(data=serializer.data, obj=obj) diff --git a/api/funkwhale_api/common/channels.py b/api/funkwhale_api/common/channels.py new file mode 100644 index 000000000..a2f85ee4e --- /dev/null +++ b/api/funkwhale_api/common/channels.py @@ -0,0 +1,5 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + +channel_layer = get_channel_layer() +group_send = async_to_sync(channel_layer.group_send) diff --git a/api/tests/activity/test_record.py b/api/tests/activity/test_record.py new file mode 100644 index 000000000..41846ba6f --- /dev/null +++ b/api/tests/activity/test_record.py @@ -0,0 +1,45 @@ +import pytest + +from django.db import models +from rest_framework import serializers + +from funkwhale_api.activity import record + + +class FakeModel(models.Model): + class Meta: + app_label = 'tests' + + +class FakeSerializer(serializers.ModelSerializer): + class Meta: + model = FakeModel + fields = ['id'] + + + + +def test_can_bind_serializer_to_model(activity_registry): + activity_registry.register_serializer(FakeSerializer) + + assert activity_registry['tests.FakeModel']['serializer'] == FakeSerializer + + +def test_can_bind_consumer_to_model(activity_registry): + activity_registry.register_serializer(FakeSerializer) + @activity_registry.register_consumer('tests.FakeModel') + def propagate(data, obj): + return True + + assert activity_registry['tests.FakeModel']['consumers'] == [propagate] + + +def test_record_object_calls_consumer(activity_registry, mocker): + activity_registry.register_serializer(FakeSerializer) + stub = mocker.stub() + activity_registry.register_consumer('tests.FakeModel')(stub) + o = FakeModel(id=1) + data = FakeSerializer(o).data + record.send(o) + + stub.assert_called_once_with(data=data, obj=o) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 10d7c3235..2d655f23f 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -5,6 +5,7 @@ from django.core.cache import cache as django_cache from dynamic_preferences.registries import global_preferences_registry from rest_framework.test import APIClient +from funkwhale_api.activity import record from funkwhale_api.taskapp import celery @@ -81,3 +82,28 @@ def superuser_client(db, factories, client): setattr(client, 'user', user) yield client delattr(client, 'user') + + +@pytest.fixture +def activity_registry(): + r = record.registry + state = list(record.registry.items()) + yield record.registry + record.registry.clear() + for key, value in state: + record.registry[key] = value + + +@pytest.fixture +def activity_registry(): + r = record.registry + state = list(record.registry.items()) + yield record.registry + record.registry.clear() + for key, value in state: + record.registry[key] = value + + +@pytest.fixture +def activity_muted(activity_registry, mocker): + yield mocker.patch.object(record, 'send') From e4902845118aa3807f0c9f056c09a5fe9872a5d2 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 1 Mar 2018 20:38:48 +0100 Subject: [PATCH 07/25] Use our new event system for track favorites --- api/funkwhale_api/favorites/activities.py | 17 +++++++++ api/funkwhale_api/favorites/consumers.py | 0 api/funkwhale_api/favorites/views.py | 2 ++ api/tests/favorites/test_activity.py | 43 +++++++++++++++++++++++ api/tests/favorites/test_favorites.py | 24 ++++++++++++- 5 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 api/funkwhale_api/favorites/activities.py create mode 100644 api/funkwhale_api/favorites/consumers.py diff --git a/api/funkwhale_api/favorites/activities.py b/api/funkwhale_api/favorites/activities.py new file mode 100644 index 000000000..a9deef552 --- /dev/null +++ b/api/funkwhale_api/favorites/activities.py @@ -0,0 +1,17 @@ +from funkwhale_api.common import channels +from funkwhale_api.activity import record + +from . import serializers + +record.registry.register_serializer( + serializers.TrackFavoriteActivitySerializer) + + +@record.registry.register_consumer('favorites.TrackFavorite') +def broadcast_track_favorite_to_instance_timeline(data, obj): + if obj.user.privacy_level not in ['instance', 'everyone']: + return + channels.group_send('instance_timeline', { + 'type': 'event', + 'data': data + }) diff --git a/api/funkwhale_api/favorites/consumers.py b/api/funkwhale_api/favorites/consumers.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py index 08ae00b68..d874c9e1e 100644 --- a/api/funkwhale_api/favorites/views.py +++ b/api/funkwhale_api/favorites/views.py @@ -4,6 +4,7 @@ from rest_framework.response import Response from rest_framework import pagination from rest_framework.decorators import list_route +from funkwhale_api.activity import record from funkwhale_api.music.models import Track from funkwhale_api.common.permissions import ConditionalAuthentication @@ -33,6 +34,7 @@ class TrackFavoriteViewSet(mixins.CreateModelMixin, instance = self.perform_create(serializer) serializer = self.get_serializer(instance=instance) headers = self.get_success_headers(serializer.data) + record.send(instance) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) def get_queryset(self): diff --git a/api/tests/favorites/test_activity.py b/api/tests/favorites/test_activity.py index b4d76658a..84778fd0f 100644 --- a/api/tests/favorites/test_activity.py +++ b/api/tests/favorites/test_activity.py @@ -1,5 +1,6 @@ from funkwhale_api.users.serializers import UserActivitySerializer from funkwhale_api.favorites import serializers +from funkwhale_api.favorites import activities def test_get_favorite_activity_url(settings, factories): @@ -26,3 +27,45 @@ def test_activity_favorite_serializer(factories): data = serializers.TrackFavoriteActivitySerializer(favorite).data assert data == expected + + +def test_track_favorite_serializer_is_connected(activity_registry): + conf = activity_registry['favorites.TrackFavorite'] + assert conf['serializer'] == serializers.TrackFavoriteActivitySerializer + + +def test_track_favorite_serializer_instance_activity_consumer( + activity_registry): + conf = activity_registry['favorites.TrackFavorite'] + consumer = activities.broadcast_track_favorite_to_instance_timeline + assert consumer in conf['consumers'] + + +def test_broadcast_track_favorite_to_instance_timeline( + factories, mocker): + p = mocker.patch('funkwhale_api.common.channels.group_send') + favorite = factories['favorites.TrackFavorite']() + data = serializers.TrackFavoriteActivitySerializer(favorite).data + consumer = activities.broadcast_track_favorite_to_instance_timeline + message = { + "type": 'event', + "data": data + } + consumer(data=data, obj=favorite) + p.assert_called_once_with('instance_timeline', message) + + +def test_broadcast_track_favorite_to_instance_timeline_private( + factories, mocker): + p = mocker.patch('funkwhale_api.common.channels.group_send') + favorite = factories['favorites.TrackFavorite']( + user__privacy_level='me' + ) + data = serializers.TrackFavoriteActivitySerializer(favorite).data + consumer = activities.broadcast_track_favorite_to_instance_timeline + message = { + "type": 'event', + "data": data + } + consumer(data=data, obj=favorite) + p.assert_not_called() diff --git a/api/tests/favorites/test_favorites.py b/api/tests/favorites/test_favorites.py index 8165722ea..f4a045af8 100644 --- a/api/tests/favorites/test_favorites.py +++ b/api/tests/favorites/test_favorites.py @@ -33,7 +33,8 @@ def test_user_can_get_his_favorites(factories, logged_in_client, client): assert expected == parsed_json['results'] -def test_user_can_add_favorite_via_api(factories, logged_in_client, client): +def test_user_can_add_favorite_via_api( + factories, logged_in_client, activity_muted): track = factories['music.Track']() url = reverse('api:v1:favorites:tracks-list') response = logged_in_client.post(url, {'track': track.pk}) @@ -51,6 +52,27 @@ def test_user_can_add_favorite_via_api(factories, logged_in_client, client): assert favorite.user == logged_in_client.user +def test_adding_favorites_calls_activity_record( + factories, logged_in_client, activity_muted): + track = factories['music.Track']() + url = reverse('api:v1:favorites:tracks-list') + response = logged_in_client.post(url, {'track': track.pk}) + + favorite = TrackFavorite.objects.latest('id') + expected = { + 'track': track.pk, + 'id': favorite.id, + 'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'), + } + parsed_json = json.loads(response.content.decode('utf-8')) + + assert expected == parsed_json + assert favorite.track == track + assert favorite.user == logged_in_client.user + + activity_muted.assert_called_once_with(favorite) + + def test_user_can_remove_favorite_via_api(logged_in_client, factories, client): favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user) url = reverse('api:v1:favorites:tracks-detail', kwargs={'pk': favorite.pk}) From d509c090d347847378fa608ca142bc8b51b81ba9 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 1 Mar 2018 21:51:20 +0100 Subject: [PATCH 08/25] Basic working poc with favorites send over websocket --- api/config/routing.py | 5 +++-- api/funkwhale_api/common/channels.py | 1 + api/funkwhale_api/common/consumers.py | 6 ++++++ api/funkwhale_api/favorites/activities.py | 8 +++++--- api/funkwhale_api/instance/consumers.py | 8 ++++++++ api/funkwhale_api/music/consumers.py | 17 ----------------- api/tests/favorites/test_activity.py | 12 ++++++------ 7 files changed, 29 insertions(+), 28 deletions(-) create mode 100644 api/funkwhale_api/instance/consumers.py delete mode 100644 api/funkwhale_api/music/consumers.py diff --git a/api/config/routing.py b/api/config/routing.py index 249bf51a8..574d5a18e 100644 --- a/api/config/routing.py +++ b/api/config/routing.py @@ -4,14 +4,15 @@ from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter, URLRouter from funkwhale_api.common.auth import TokenAuthMiddleware -from funkwhale_api.music import consumers +from funkwhale_api.instance import consumers application = ProtocolTypeRouter({ # Empty for now (http->django views is added by default) "websocket": TokenAuthMiddleware( URLRouter([ - url("^api/v1/test/$", consumers.MyConsumer), + url("^api/v1/instance/activity$", + consumers.InstanceActivityConsumer), ]) ), }) diff --git a/api/funkwhale_api/common/channels.py b/api/funkwhale_api/common/channels.py index a2f85ee4e..a009ab5ab 100644 --- a/api/funkwhale_api/common/channels.py +++ b/api/funkwhale_api/common/channels.py @@ -3,3 +3,4 @@ from channels.layers import get_channel_layer channel_layer = get_channel_layer() group_send = async_to_sync(channel_layer.group_send) +group_add = async_to_sync(channel_layer.group_add) diff --git a/api/funkwhale_api/common/consumers.py b/api/funkwhale_api/common/consumers.py index 26e57fc8a..300ce5e26 100644 --- a/api/funkwhale_api/common/consumers.py +++ b/api/funkwhale_api/common/consumers.py @@ -1,4 +1,5 @@ from channels.generic.websocket import JsonWebsocketConsumer +from funkwhale_api.common import channels class JsonAuthConsumer(JsonWebsocketConsumer): @@ -9,3 +10,8 @@ class JsonAuthConsumer(JsonWebsocketConsumer): return self.close() return self.accept() + + def accept(self): + super().accept() + for group in self.groups: + channels.group_add(group, self.channel_name) diff --git a/api/funkwhale_api/favorites/activities.py b/api/funkwhale_api/favorites/activities.py index a9deef552..a2dbc4e2f 100644 --- a/api/funkwhale_api/favorites/activities.py +++ b/api/funkwhale_api/favorites/activities.py @@ -8,10 +8,12 @@ record.registry.register_serializer( @record.registry.register_consumer('favorites.TrackFavorite') -def broadcast_track_favorite_to_instance_timeline(data, obj): +def broadcast_track_favorite_to_instance_activity(data, obj): if obj.user.privacy_level not in ['instance', 'everyone']: return - channels.group_send('instance_timeline', { - 'type': 'event', + + channels.group_send('instance_activity', { + 'type': 'event.send', + 'text': '', 'data': data }) diff --git a/api/funkwhale_api/instance/consumers.py b/api/funkwhale_api/instance/consumers.py new file mode 100644 index 000000000..eee5f7f0e --- /dev/null +++ b/api/funkwhale_api/instance/consumers.py @@ -0,0 +1,8 @@ +from funkwhale_api.common.consumers import JsonAuthConsumer + + +class InstanceActivityConsumer(JsonAuthConsumer): + groups = ["instance_activity"] + + def event_send(self, message): + self.send_json(message['data']) diff --git a/api/funkwhale_api/music/consumers.py b/api/funkwhale_api/music/consumers.py deleted file mode 100644 index 452346d19..000000000 --- a/api/funkwhale_api/music/consumers.py +++ /dev/null @@ -1,17 +0,0 @@ -from funkwhale_api.common.consumers import JsonAuthConsumer - - -class MyConsumer(JsonAuthConsumer): - groups = ["broadcast"] - - def receive_json(self, payload): - print(payload, self.scope["user"]) - # Called with either text_data or bytes_data for each frame - # You can call: - self.send_json({'test': 'me'}) - # Or, to send a binary frame: - # self.send(bytes_data="{Hello} world!") - # Want to force-close the connection? Call: - # self.close() - # # Or add a custom WebSocket error code! - # self.close(code=4123) diff --git a/api/tests/favorites/test_activity.py b/api/tests/favorites/test_activity.py index 84778fd0f..74695ed8d 100644 --- a/api/tests/favorites/test_activity.py +++ b/api/tests/favorites/test_activity.py @@ -37,32 +37,32 @@ def test_track_favorite_serializer_is_connected(activity_registry): def test_track_favorite_serializer_instance_activity_consumer( activity_registry): conf = activity_registry['favorites.TrackFavorite'] - consumer = activities.broadcast_track_favorite_to_instance_timeline + consumer = activities.broadcast_track_favorite_to_instance_activity assert consumer in conf['consumers'] -def test_broadcast_track_favorite_to_instance_timeline( +def test_broadcast_track_favorite_to_instance_activity( factories, mocker): p = mocker.patch('funkwhale_api.common.channels.group_send') favorite = factories['favorites.TrackFavorite']() data = serializers.TrackFavoriteActivitySerializer(favorite).data - consumer = activities.broadcast_track_favorite_to_instance_timeline + consumer = activities.broadcast_track_favorite_to_instance_activity message = { "type": 'event', "data": data } consumer(data=data, obj=favorite) - p.assert_called_once_with('instance_timeline', message) + p.assert_called_once_with('instance_activity', message) -def test_broadcast_track_favorite_to_instance_timeline_private( +def test_broadcast_track_favorite_to_instance_activity_private( factories, mocker): p = mocker.patch('funkwhale_api.common.channels.group_send') favorite = factories['favorites.TrackFavorite']( user__privacy_level='me' ) data = serializers.TrackFavoriteActivitySerializer(favorite).data - consumer = activities.broadcast_track_favorite_to_instance_timeline + consumer = activities.broadcast_track_favorite_to_instance_activity message = { "type": 'event', "data": data From a6da10be41e17fb6c3ab26d2ea4513b886fc94cc Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 1 Mar 2018 23:41:51 +0100 Subject: [PATCH 09/25] API refinements for activity stream --- api/funkwhale_api/activity/serializers.py | 1 + api/funkwhale_api/favorites/serializers.py | 10 +-- api/funkwhale_api/history/activities.py | 19 ++++++ api/funkwhale_api/history/models.py | 5 ++ api/funkwhale_api/history/serializers.py | 28 ++++++++ api/funkwhale_api/history/views.py | 9 ++- api/funkwhale_api/music/serializers.py | 23 +++++++ api/funkwhale_api/users/serializers.py | 2 + api/tests/favorites/test_activity.py | 10 ++- api/tests/history/test_activity.py | 75 ++++++++++++++++++++++ api/tests/{ => history}/test_history.py | 17 ++++- api/tests/users/test_activity.py | 1 + 12 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 api/funkwhale_api/history/activities.py create mode 100644 api/tests/history/test_activity.py rename api/tests/{ => history}/test_history.py (70%) diff --git a/api/funkwhale_api/activity/serializers.py b/api/funkwhale_api/activity/serializers.py index 4b40bb0d2..325d1e820 100644 --- a/api/funkwhale_api/activity/serializers.py +++ b/api/funkwhale_api/activity/serializers.py @@ -3,6 +3,7 @@ from rest_framework import serializers class ModelSerializer(serializers.ModelSerializer): id = serializers.CharField(source='get_activity_url') + local_id = serializers.IntegerField(source='id') # url = serializers.SerializerMethodField() def get_url(self, obj): diff --git a/api/funkwhale_api/favorites/serializers.py b/api/funkwhale_api/favorites/serializers.py index 01ad2e475..276b0f6bd 100644 --- a/api/funkwhale_api/favorites/serializers.py +++ b/api/funkwhale_api/favorites/serializers.py @@ -4,17 +4,15 @@ from rest_framework import serializers from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.music.serializers import TrackSerializerNested +from funkwhale_api.music.serializers import TrackActivitySerializer from funkwhale_api.users.serializers import UserActivitySerializer from . import models - - - class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer): type = serializers.SerializerMethodField() - object = serializers.CharField(source='track.get_activity_url') + object = TrackActivitySerializer(source='track') actor = UserActivitySerializer(source='user') published = serializers.DateTimeField(source='creation_date') @@ -22,6 +20,7 @@ class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer): model = models.TrackFavorite fields = [ 'id', + 'local_id', 'object', 'type', 'actor', @@ -34,9 +33,6 @@ class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer): def get_type(self, obj): return 'Like' - def get_object(self, obj): - return obj.track.get_activity_url() - class UserTrackFavoriteSerializer(serializers.ModelSerializer): # track = TrackSerializerNested(read_only=True) diff --git a/api/funkwhale_api/history/activities.py b/api/funkwhale_api/history/activities.py new file mode 100644 index 000000000..e478f9b7f --- /dev/null +++ b/api/funkwhale_api/history/activities.py @@ -0,0 +1,19 @@ +from funkwhale_api.common import channels +from funkwhale_api.activity import record + +from . import serializers + +record.registry.register_serializer( + serializers.ListeningActivitySerializer) + + +@record.registry.register_consumer('history.Listening') +def broadcast_listening_to_instance_activity(data, obj): + if obj.user.privacy_level not in ['instance', 'everyone']: + return + + channels.group_send('instance_activity', { + 'type': 'event.send', + 'text': '', + 'data': data + }) diff --git a/api/funkwhale_api/history/models.py b/api/funkwhale_api/history/models.py index f7f62de62..56310ddc0 100644 --- a/api/funkwhale_api/history/models.py +++ b/api/funkwhale_api/history/models.py @@ -25,3 +25,8 @@ class Listening(models.Model): raise ValidationError('Cannot have both session_key and user empty for listening') super().save(**kwargs) + + + def get_activity_url(self): + return '{}/listenings/tracks/{}'.format( + self.user.get_activity_url(), self.pk) diff --git a/api/funkwhale_api/history/serializers.py b/api/funkwhale_api/history/serializers.py index 64bdf41c6..7a2280cea 100644 --- a/api/funkwhale_api/history/serializers.py +++ b/api/funkwhale_api/history/serializers.py @@ -1,9 +1,37 @@ from rest_framework import serializers +from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.music.serializers import TrackSerializerNested +from funkwhale_api.music.serializers import TrackActivitySerializer +from funkwhale_api.users.serializers import UserActivitySerializer + from . import models +class ListeningActivitySerializer(activity_serializers.ModelSerializer): + type = serializers.SerializerMethodField() + object = TrackActivitySerializer(source='track') + actor = UserActivitySerializer(source='user') + published = serializers.DateTimeField(source='end_date') + + class Meta: + model = models.Listening + fields = [ + 'id', + 'local_id', + 'object', + 'type', + 'actor', + 'published' + ] + + def get_actor(self, obj): + return UserActivitySerializer(obj.user).data + + def get_type(self, obj): + return 'Listen' + + class ListeningSerializer(serializers.ModelSerializer): class Meta: diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py index 59dcbd26b..d5cbe316b 100644 --- a/api/funkwhale_api/history/views.py +++ b/api/funkwhale_api/history/views.py @@ -3,8 +3,9 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.decorators import detail_route -from funkwhale_api.music.serializers import TrackSerializerNested +from funkwhale_api.activity import record from funkwhale_api.common.permissions import ConditionalAuthentication +from funkwhale_api.music.serializers import TrackSerializerNested from . import models from . import serializers @@ -17,6 +18,12 @@ class ListeningViewSet(mixins.CreateModelMixin, queryset = models.Listening.objects.all() permission_classes = [ConditionalAuthentication] + def perform_create(self, serializer): + r = super().perform_create(serializer) + if self.request.user.is_authenticated: + record.send(serializer.instance) + return r + def get_queryset(self): queryset = super().get_queryset() if self.request.user.is_authenticated: diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index db6298a9e..48419bbe4 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -1,6 +1,8 @@ from rest_framework import serializers from taggit.models import Tag +from funkwhale_api.activity import serializers as activity_serializers + from . import models @@ -127,3 +129,24 @@ class ImportBatchSerializer(serializers.ModelSerializer): model = models.ImportBatch fields = ('id', 'jobs', 'status', 'creation_date', 'import_request') read_only_fields = ('creation_date',) + + +class TrackActivitySerializer(activity_serializers.ModelSerializer): + type = serializers.SerializerMethodField() + name = serializers.CharField(source='title') + artist = serializers.CharField(source='artist.name') + album = serializers.CharField(source='album.title') + + class Meta: + model = models.Track + fields = [ + 'id', + 'local_id', + 'name', + 'type', + 'artist', + 'album', + ] + + def get_type(self, obj): + return 'Audio' diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index 2e873d94c..e8adf9eda 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -8,11 +8,13 @@ from . import models class UserActivitySerializer(activity_serializers.ModelSerializer): type = serializers.SerializerMethodField() name = serializers.CharField(source='username') + local_id = serializers.CharField(source='username') class Meta: model = models.User fields = [ 'id', + 'local_id', 'name', 'type' ] diff --git a/api/tests/favorites/test_activity.py b/api/tests/favorites/test_activity.py index 74695ed8d..63174f9e2 100644 --- a/api/tests/favorites/test_activity.py +++ b/api/tests/favorites/test_activity.py @@ -1,4 +1,5 @@ from funkwhale_api.users.serializers import UserActivitySerializer +from funkwhale_api.music.serializers import TrackActivitySerializer from funkwhale_api.favorites import serializers from funkwhale_api.favorites import activities @@ -18,9 +19,10 @@ def test_activity_favorite_serializer(factories): field = serializers.serializers.DateTimeField() expected = { "type": "Like", + "local_id": favorite.pk, "id": favorite.get_activity_url(), "actor": actor, - "object": favorite.track.get_activity_url(), + "object": TrackActivitySerializer(favorite.track).data, "published": field.to_representation(favorite.creation_date), } @@ -48,7 +50,8 @@ def test_broadcast_track_favorite_to_instance_activity( data = serializers.TrackFavoriteActivitySerializer(favorite).data consumer = activities.broadcast_track_favorite_to_instance_activity message = { - "type": 'event', + "type": 'event.send', + "text": '', "data": data } consumer(data=data, obj=favorite) @@ -64,7 +67,8 @@ def test_broadcast_track_favorite_to_instance_activity_private( data = serializers.TrackFavoriteActivitySerializer(favorite).data consumer = activities.broadcast_track_favorite_to_instance_activity message = { - "type": 'event', + "type": 'event.send', + "text": '', "data": data } consumer(data=data, obj=favorite) diff --git a/api/tests/history/test_activity.py b/api/tests/history/test_activity.py new file mode 100644 index 000000000..b5ab07b82 --- /dev/null +++ b/api/tests/history/test_activity.py @@ -0,0 +1,75 @@ +from funkwhale_api.users.serializers import UserActivitySerializer +from funkwhale_api.music.serializers import TrackActivitySerializer +from funkwhale_api.history import serializers +from funkwhale_api.history import activities + + +def test_get_listening_activity_url(settings, factories): + listening = factories['history.Listening']() + user_url = listening.user.get_activity_url() + expected = '{}/listenings/tracks/{}'.format( + user_url, listening.pk) + assert listening.get_activity_url() == expected + + +def test_activity_listening_serializer(factories): + listening = factories['history.Listening']() + + actor = UserActivitySerializer(listening.user).data + field = serializers.serializers.DateTimeField() + expected = { + "type": "Listen", + "local_id": listening.pk, + "id": listening.get_activity_url(), + "actor": actor, + "object": TrackActivitySerializer(listening.track).data, + "published": field.to_representation(listening.end_date), + } + + data = serializers.ListeningActivitySerializer(listening).data + + assert data == expected + + +def test_track_listening_serializer_is_connected(activity_registry): + conf = activity_registry['history.Listening'] + assert conf['serializer'] == serializers.ListeningActivitySerializer + + +def test_track_listening_serializer_instance_activity_consumer( + activity_registry): + conf = activity_registry['history.Listening'] + consumer = activities.broadcast_listening_to_instance_activity + assert consumer in conf['consumers'] + + +def test_broadcast_listening_to_instance_activity( + factories, mocker): + p = mocker.patch('funkwhale_api.common.channels.group_send') + listening = factories['history.Listening']() + data = serializers.ListeningActivitySerializer(listening).data + consumer = activities.broadcast_listening_to_instance_activity + message = { + "type": 'event.send', + "text": '', + "data": data + } + consumer(data=data, obj=listening) + p.assert_called_once_with('instance_activity', message) + + +def test_broadcast_listening_to_instance_activity_private( + factories, mocker): + p = mocker.patch('funkwhale_api.common.channels.group_send') + listening = factories['history.Listening']( + user__privacy_level='me' + ) + data = serializers.ListeningActivitySerializer(listening).data + consumer = activities.broadcast_listening_to_instance_activity + message = { + "type": 'event.send', + "text": '', + "data": data + } + consumer(data=data, obj=listening) + p.assert_not_called() diff --git a/api/tests/test_history.py b/api/tests/history/test_history.py similarity index 70% rename from api/tests/test_history.py rename to api/tests/history/test_history.py index 113e5ff64..ec8689e96 100644 --- a/api/tests/test_history.py +++ b/api/tests/history/test_history.py @@ -28,7 +28,8 @@ def test_anonymous_user_can_create_listening_via_api(client, factories, settings assert listening.session_key == client.session.session_key -def test_logged_in_user_can_create_listening_via_api(logged_in_client, factories): +def test_logged_in_user_can_create_listening_via_api( + logged_in_client, factories, activity_muted): track = factories['music.Track']() url = reverse('api:v1:history:listenings-list') @@ -40,3 +41,17 @@ def test_logged_in_user_can_create_listening_via_api(logged_in_client, factories assert listening.track == track assert listening.user == logged_in_client.user + + +def test_adding_listening_calls_activity_record( + factories, logged_in_client, activity_muted): + track = factories['music.Track']() + + url = reverse('api:v1:history:listenings-list') + response = logged_in_client.post(url, { + 'track': track.pk, + }) + + listening = models.Listening.objects.latest('id') + + activity_muted.assert_called_once_with(listening) diff --git a/api/tests/users/test_activity.py b/api/tests/users/test_activity.py index 3cee4fb40..26d0b11f8 100644 --- a/api/tests/users/test_activity.py +++ b/api/tests/users/test_activity.py @@ -13,6 +13,7 @@ def test_activity_user_serializer(factories): expected = { "type": "Person", "id": user.get_activity_url(), + "local_id": user.username, "name": user.username, } From 2d12c56b90755d4756656dd84fd0bbb24bc570b4 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 1 Mar 2018 23:45:46 +0100 Subject: [PATCH 10/25] Username component --- front/src/components/common/Username.vue | 8 ++++++++ front/src/components/globals.js | 4 ++++ 2 files changed, 12 insertions(+) create mode 100644 front/src/components/common/Username.vue diff --git a/front/src/components/common/Username.vue b/front/src/components/common/Username.vue new file mode 100644 index 000000000..17fb34925 --- /dev/null +++ b/front/src/components/common/Username.vue @@ -0,0 +1,8 @@ + + diff --git a/front/src/components/globals.js b/front/src/components/globals.js index 40315bc47..b1d7d6104 100644 --- a/front/src/components/globals.js +++ b/front/src/components/globals.js @@ -4,4 +4,8 @@ import HumanDate from '@/components/common/HumanDate' Vue.component('human-date', HumanDate) +import Username from '@/components/common/Username' + +Vue.component('username', Username) + export default {} From 40350f05558fca740463b083dca2611ec8f29e8e Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 1 Mar 2018 23:46:32 +0100 Subject: [PATCH 11/25] Redraw ago datetime every minute --- front/src/App.vue | 4 ++++ front/src/components/common/HumanDate.vue | 16 ++++++++++++++-- front/src/store/index.js | 2 ++ front/src/store/ui.js | 12 ++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 front/src/store/ui.js diff --git a/front/src/App.vue b/front/src/App.vue index 3e39d7262..d60f82feb 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -44,6 +44,10 @@ export default { }, created () { this.$store.dispatch('instance/fetchSettings') + setInterval(() => { + // used to redraw ago dates every minute + self.$store.commit('ui/computeLastDate') + }, 1000 * 60) } } diff --git a/front/src/components/common/HumanDate.vue b/front/src/components/common/HumanDate.vue index ff6ff5c71..9ff8e48bd 100644 --- a/front/src/components/common/HumanDate.vue +++ b/front/src/components/common/HumanDate.vue @@ -1,8 +1,20 @@ diff --git a/front/src/store/index.js b/front/src/store/index.js index 74f9d42b1..e111966a0 100644 --- a/front/src/store/index.js +++ b/front/src/store/index.js @@ -8,11 +8,13 @@ import instance from './instance' import queue from './queue' import radios from './radios' import player from './player' +import ui from './ui' Vue.use(Vuex) export default new Vuex.Store({ modules: { + ui, auth, favorites, instance, diff --git a/front/src/store/ui.js b/front/src/store/ui.js new file mode 100644 index 000000000..f0935e491 --- /dev/null +++ b/front/src/store/ui.js @@ -0,0 +1,12 @@ + +export default { + namespaced: true, + state: { + lastDate: new Date() + }, + mutations: { + computeLastDate: (state) => { + state.lastDate = new Date() + } + } +} From e319f5933e39bd76a57c953a7091c96a337d9d44 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Fri, 2 Mar 2018 00:13:45 +0100 Subject: [PATCH 12/25] Store for instance events --- front/src/store/index.js | 4 ++++ front/src/store/instance.js | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/front/src/store/index.js b/front/src/store/index.js index e111966a0..2453c0e71 100644 --- a/front/src/store/index.js +++ b/front/src/store/index.js @@ -30,6 +30,10 @@ export default new Vuex.Store({ return mutation.type.startsWith('auth/') } }), + createPersistedState({ + key: 'instance', + paths: ['instance.events'] + }), createPersistedState({ key: 'radios', paths: ['radios'], diff --git a/front/src/store/instance.js b/front/src/store/instance.js index a4dfcada6..2436eab07 100644 --- a/front/src/store/instance.js +++ b/front/src/store/instance.js @@ -5,6 +5,8 @@ import _ from 'lodash' export default { namespaced: true, state: { + maxEvents: 200, + events: [], settings: { instance: { name: { @@ -35,6 +37,12 @@ export default { mutations: { settings: (state, value) => { _.merge(state.settings, value) + }, + event: (state, value) => { + state.events.unshift(value) + if (state.events.length > state.maxEvents) { + state.events = state.events.slice(0, state.maxEvents) + } } }, actions: { From e231b71701726d436007d3dbd94a2ae895881612 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Fri, 2 Mar 2018 00:14:08 +0100 Subject: [PATCH 13/25] Timeline view --- front/src/components/Sidebar.vue | 3 ++ front/src/router/index.js | 6 ++++ front/src/views/instance/Timeline.vue | 52 +++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 front/src/views/instance/Timeline.vue diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 86ec57819..f5229b207 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -36,6 +36,9 @@ Login Browse library Favorites + Activity
diff --git a/front/src/router/index.js b/front/src/router/index.js index 827afc218..ba9aadd98 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -3,6 +3,7 @@ import Router from 'vue-router' import PageNotFound from '@/components/PageNotFound' import About from '@/components/About' import Home from '@/components/Home' +import InstanceTimeline from '@/views/instance/Timeline' import Login from '@/components/auth/Login' import Signup from '@/components/auth/Signup' import Profile from '@/components/auth/Profile' @@ -39,6 +40,11 @@ export default new Router({ name: 'about', component: About }, + { + path: '/activity', + name: 'activity', + component: InstanceTimeline + }, { path: '/login', name: 'login', diff --git a/front/src/views/instance/Timeline.vue b/front/src/views/instance/Timeline.vue new file mode 100644 index 000000000..b959c25d6 --- /dev/null +++ b/front/src/views/instance/Timeline.vue @@ -0,0 +1,52 @@ + + + + + + From 88e68081553d87449a32f421c429a147aed18387 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Fri, 2 Mar 2018 00:14:23 +0100 Subject: [PATCH 14/25] Activity components for timeline --- front/src/components/activity/Like.vue | 35 ++++++++++++++++++++++++ front/src/components/activity/Listen.vue | 35 ++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 front/src/components/activity/Like.vue create mode 100644 front/src/components/activity/Listen.vue diff --git a/front/src/components/activity/Like.vue b/front/src/components/activity/Like.vue new file mode 100644 index 000000000..ffb831278 --- /dev/null +++ b/front/src/components/activity/Like.vue @@ -0,0 +1,35 @@ + + + + + + diff --git a/front/src/components/activity/Listen.vue b/front/src/components/activity/Listen.vue new file mode 100644 index 000000000..7c8ee8a69 --- /dev/null +++ b/front/src/components/activity/Listen.vue @@ -0,0 +1,35 @@ + + + + + + From 6e6d874f3aa75a8d1fc25a79617a658a2c6773d7 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Fri, 2 Mar 2018 00:14:40 +0100 Subject: [PATCH 15/25] Websocket opening logic --- front/src/App.vue | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/front/src/App.vue b/front/src/App.vue index d60f82feb..b26959fe7 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -33,6 +33,9 @@ From ce3af5194f42452186ae2567bd03640b5513876d Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Fri, 2 Mar 2018 00:14:55 +0100 Subject: [PATCH 16/25] Minor fixes to docker setup --- dev.yml | 2 ++ front/Dockerfile | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/dev.yml b/dev.yml index e3cd50da7..2c102f3ae 100644 --- a/dev.yml +++ b/dev.yml @@ -36,6 +36,7 @@ services: - C_FORCE_ROOT=true - "DATABASE_URL=postgresql://postgres@postgres/postgres" - "CACHE_URL=redis://redis:6379/0" + - "FUNKWHALE_URL=http://funkwhale.test" volumes: - ./api:/app - ./data/music:/music @@ -54,6 +55,7 @@ services: - "DJANGO_SECRET_KEY=dev" - "DATABASE_URL=postgresql://postgres@postgres/postgres" - "CACHE_URL=redis://redis:6379/0" + - "FUNKWHALE_URL=http://funkwhale.test" links: - postgres - redis diff --git a/front/Dockerfile b/front/Dockerfile index cdf92446b..60b03c9b8 100644 --- a/front/Dockerfile +++ b/front/Dockerfile @@ -3,8 +3,7 @@ FROM node:9 EXPOSE 8080 WORKDIR /app/ ADD package.json . -RUN yarn install --only=production -RUN yarn install --only=dev +RUN yarn install VOLUME ["/app/node_modules"] COPY . . From 8dea9130af77e1a0836746fb1e9ca597b33dfae3 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sat, 3 Mar 2018 10:05:39 +0100 Subject: [PATCH 17/25] Smaller docker image --- api/Dockerfile | 6 +++++- api/docker/Dockerfile.test | 9 ++++++--- api/requirements.apt | 9 +++------ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/api/Dockerfile b/api/Dockerfile index 5d4e85857..a9aa33e3c 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -5,7 +5,11 @@ ENV PYTHONUNBUFFERED 1 # Requirements have to be pulled and installed here, otherwise caching won't work RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list COPY ./requirements.apt /requirements.apt -RUN apt-get update -qq && grep "^[^#;]" requirements.apt | xargs apt-get install -y +RUN apt-get update; \ + grep "^[^#;]" requirements.apt | \ + grep -Fv "python3-dev" | \ + xargs apt-get install -y --no-install-recommends; \ + rm -rf /usr/share/doc/* /usr/share/locale/* RUN curl -L https://github.com/acoustid/chromaprint/releases/download/v1.4.2/chromaprint-fpcalc-1.4.2-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin --strip 1 COPY ./requirements/base.txt /requirements/base.txt RUN pip install -r /requirements/base.txt diff --git a/api/docker/Dockerfile.test b/api/docker/Dockerfile.test index 069b89c2f..032b2842f 100644 --- a/api/docker/Dockerfile.test +++ b/api/docker/Dockerfile.test @@ -1,13 +1,16 @@ FROM python:3.5 ENV PYTHONUNBUFFERED 1 -ENV PYTHONDONTWRITEBYTECODE 1 # Requirements have to be pulled and installed here, otherwise caching won't work RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list COPY ./requirements.apt /requirements.apt -COPY ./install_os_dependencies.sh /install_os_dependencies.sh -RUN bash install_os_dependencies.sh install +RUN apt-get update; \ + grep "^[^#;]" requirements.apt | \ + grep -Fv "python3-dev" | \ + xargs apt-get install -y --no-install-recommends; \ + rm -rf /usr/share/doc/* /usr/share/locale/* + RUN curl -L https://github.com/acoustid/chromaprint/releases/download/v1.4.2/chromaprint-fpcalc-1.4.2-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin --strip 1 RUN mkdir /requirements diff --git a/api/requirements.apt b/api/requirements.apt index 462a5a705..224ff955a 100644 --- a/api/requirements.apt +++ b/api/requirements.apt @@ -1,11 +1,8 @@ build-essential -gettext -zlib1g-dev +curl +ffmpeg libjpeg-dev -zlib1g-dev +libmagic-dev libpq-dev postgresql-client -libmagic-dev -ffmpeg python3-dev -curl From 7f7a62cfa2f1f7c9cd9eeb057f1f0805354950e9 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sat, 3 Mar 2018 10:52:48 +0100 Subject: [PATCH 18/25] Replaced gunicorn with daphne --- api/compose/django/daphne.sh | 3 +++ api/compose/django/gunicorn.sh | 3 --- api/config/asgi.py | 8 ++++++++ api/config/settings/production.py | 2 -- api/docker/Dockerfile.test | 1 + api/requirements/production.txt | 5 +---- 6 files changed, 13 insertions(+), 9 deletions(-) create mode 100755 api/compose/django/daphne.sh delete mode 100755 api/compose/django/gunicorn.sh create mode 100644 api/config/asgi.py diff --git a/api/compose/django/daphne.sh b/api/compose/django/daphne.sh new file mode 100755 index 000000000..16b4d50b8 --- /dev/null +++ b/api/compose/django/daphne.sh @@ -0,0 +1,3 @@ +#!/bin/bash -eux +python /app/manage.py collectstatic --noinput +/usr/local/bin/daphne --root-path=/app -b 0.0.0.0 -p 5000 config.asgi:application diff --git a/api/compose/django/gunicorn.sh b/api/compose/django/gunicorn.sh deleted file mode 100755 index 014f173e3..000000000 --- a/api/compose/django/gunicorn.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -python /app/manage.py collectstatic --noinput -/usr/local/bin/gunicorn config.wsgi -w 4 -b 0.0.0.0:5000 --chdir=/app \ No newline at end of file diff --git a/api/config/asgi.py b/api/config/asgi.py new file mode 100644 index 000000000..b976a02eb --- /dev/null +++ b/api/config/asgi.py @@ -0,0 +1,8 @@ +import django +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") + +django.setup() + +from .routing import application diff --git a/api/config/settings/production.py b/api/config/settings/production.py index df15d325f..f238c2d20 100644 --- a/api/config/settings/production.py +++ b/api/config/settings/production.py @@ -58,8 +58,6 @@ CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS # END SITE CONFIGURATION -INSTALLED_APPS += ("gunicorn", ) - # STORAGE CONFIGURATION # ------------------------------------------------------------------------------ # Uploaded Media Files diff --git a/api/docker/Dockerfile.test b/api/docker/Dockerfile.test index 032b2842f..00638e9dd 100644 --- a/api/docker/Dockerfile.test +++ b/api/docker/Dockerfile.test @@ -21,4 +21,5 @@ RUN pip install -r /requirements/local.txt COPY ./requirements/test.txt /requirements/test.txt RUN pip install -r /requirements/test.txt +COPY . /app WORKDIR /app diff --git a/api/requirements/production.txt b/api/requirements/production.txt index 42b66eb15..4ad8edf94 100644 --- a/api/requirements/production.txt +++ b/api/requirements/production.txt @@ -4,7 +4,4 @@ # WSGI Handler # ------------------------------------------------ -# there's no python 3 support in stable, have to use the latest release candidate for gevent -gevent==1.1rc1 - -gunicorn==19.4.1 +daphne==2.0.4 From 1800f7ad176fb23984ae6f0ada52b5bb19f99046 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sat, 3 Mar 2018 11:20:21 +0100 Subject: [PATCH 19/25] API endpoint for updating privacy --- api/funkwhale_api/users/serializers.py | 12 ++++++++- api/funkwhale_api/users/views.py | 20 ++++++++++++--- api/tests/requests/test_views.py | 3 ++- api/tests/users/test_views.py | 35 ++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index e8adf9eda..b21aa6935 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -29,7 +29,16 @@ class UserBasicSerializer(serializers.ModelSerializer): fields = ['id', 'username', 'name', 'date_joined'] -class UserSerializer(serializers.ModelSerializer): +class UserWriteSerializer(serializers.ModelSerializer): + class Meta: + model = models.User + fields = [ + 'name', + 'privacy_level' + ] + + +class UserReadSerializer(serializers.ModelSerializer): permissions = serializers.SerializerMethodField() @@ -44,6 +53,7 @@ class UserSerializer(serializers.ModelSerializer): 'is_superuser', 'permissions', 'date_joined', + 'privacy_level' ] def get_permissions(self, o): diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py index b7c1df28f..7c58363a3 100644 --- a/api/funkwhale_api/users/views.py +++ b/api/funkwhale_api/users/views.py @@ -1,4 +1,5 @@ from rest_framework.response import Response +from rest_framework import mixins from rest_framework import viewsets from rest_framework.decorators import list_route @@ -23,12 +24,25 @@ class RegisterView(BaseRegisterView): return get_adapter().is_open_for_signup(request) -class UserViewSet(viewsets.GenericViewSet): +class UserViewSet( + mixins.UpdateModelMixin, + viewsets.GenericViewSet): queryset = models.User.objects.all() - serializer_class = serializers.UserSerializer + serializer_class = serializers.UserWriteSerializer + lookup_field = 'username' @list_route(methods=['get']) def me(self, request, *args, **kwargs): """Return information about the current user""" - serializer = self.serializer_class(request.user) + serializer = serializers.UserReadSerializer(request.user) return Response(serializer.data) + + def update(self, request, *args, **kwargs): + if not self.request.user.username == kwargs.get('username'): + return Response(status=403) + return super().update(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + if not self.request.user.username == kwargs.get('username'): + return Response(status=403) + return super().partial_update(request, *args, **kwargs) diff --git a/api/tests/requests/test_views.py b/api/tests/requests/test_views.py index 6c34f9ad1..3956fb405 100644 --- a/api/tests/requests/test_views.py +++ b/api/tests/requests/test_views.py @@ -7,7 +7,8 @@ def test_request_viewset_requires_auth(db, api_client): assert response.status_code == 401 -def test_user_can_create_request(logged_in_api_client): +@pytest.mark.parametrize('method', ['put', 'patch']) +def test_user_can_create_request(method, logged_in_api_client): url = reverse('api:v1:requests:import-requests-list') user = logged_in_api_client.user data = { diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py index 569acbd15..02b903bf4 100644 --- a/api/tests/users/test_views.py +++ b/api/tests/users/test_views.py @@ -1,4 +1,5 @@ import json +import pytest from django.test import RequestFactory from django.urls import reverse @@ -116,3 +117,37 @@ def test_changing_password_updates_secret_key(logged_in_client): assert user.secret_key != secret_key assert user.password != password + + +def test_user_can_patch_his_own_settings(logged_in_api_client): + user = logged_in_api_client.user + payload = { + 'privacy_level': 'me', + } + url = reverse( + 'api:v1:users:users-detail', + kwargs={'username': user.username}) + + response = logged_in_api_client.patch(url, payload) + + assert response.status_code == 200 + user.refresh_from_db() + + assert user.privacy_level == 'me' + + +@pytest.mark.parametrize('method', ['put', 'patch']) +def test_user_cannot_patch_another_user( + method, logged_in_api_client, factories): + user = factories['users.User']() + payload = { + 'privacy_level': 'me', + } + url = reverse( + 'api:v1:users:users-detail', + kwargs={'username': user.username}) + + handler = getattr(logged_in_api_client, method) + response = handler(url, payload) + + assert response.status_code == 403 From f9786d4b458fcb3eb66e55f0b055ada3b3411e5e Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sat, 3 Mar 2018 12:37:50 +0100 Subject: [PATCH 20/25] Remove batch refresh logic when not on batch detail --- front/src/components/library/import/BatchDetail.vue | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/front/src/components/library/import/BatchDetail.vue b/front/src/components/library/import/BatchDetail.vue index 417fd55c2..0864d2464 100644 --- a/front/src/components/library/import/BatchDetail.vue +++ b/front/src/components/library/import/BatchDetail.vue @@ -62,12 +62,18 @@ export default { data () { return { isLoading: true, - batch: null + batch: null, + timeout: null } }, created () { this.fetchData() }, + destroyed () { + if (this.timeout) { + clearTimeout(this.timeout) + } + }, methods: { fetchData () { var self = this @@ -78,7 +84,7 @@ export default { self.batch = response.data self.isLoading = false if (self.batch.status === 'pending') { - setTimeout( + self.timeout = setTimeout( self.fetchData, 5000 ) From 2c79418d8199825b03cc56cf577bd830221ecc25 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sat, 3 Mar 2018 12:38:11 +0100 Subject: [PATCH 21/25] Moved backend error handling in axios --- front/src/components/auth/Signup.vue | 17 +---------------- front/src/main.js | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/front/src/components/auth/Signup.vue b/front/src/components/auth/Signup.vue index 13b723d20..749d2eb02 100644 --- a/front/src/components/auth/Signup.vue +++ b/front/src/components/auth/Signup.vue @@ -100,24 +100,9 @@ export default { username: this.username }}) }, error => { - self.errors = this.getErrors(error.response) + self.errors = error.backendErrors self.isLoading = false }) - }, - getErrors (response) { - let errors = [] - if (response.status !== 400) { - errors.push('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running') - return errors - } - for (var field in response.data) { - if (response.data.hasOwnProperty(field)) { - response.data[field].forEach(e => { - errors.push(e) - }) - } - } - return errors } }, computed: { diff --git a/front/src/main.js b/front/src/main.js index caf924188..f20af42bf 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -47,11 +47,28 @@ axios.interceptors.request.use(function (config) { axios.interceptors.response.use(function (response) { return response }, function (error) { + error.backendErrors = [] if (error.response.status === 401) { store.commit('auth/authenticated', false) logger.default.warn('Received 401 response from API, redirecting to login form') router.push({name: 'login', query: {next: router.currentRoute.fullPath}}) } + if (error.response.status === 404) { + error.backendErrors.push('Resource not found') + } else if (error.response.status === 500) { + error.backendErrors.push('A server error occured') + } else if (error.response.data) { + for (var field in error.response.data) { + if (error.response.data.hasOwnProperty(field)) { + error.response.data[field].forEach(e => { + error.backendErrors.push(e) + }) + } + } + } + if (error.backendErrors.length === 0) { + error.backendErrors.push('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running') + } // Do something with response error return Promise.reject(error) }) From 26b2d3b0b2838267c4128e6495d30ce9d3462516 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sat, 3 Mar 2018 12:40:01 +0100 Subject: [PATCH 22/25] Added setting form to manage privacy level --- front/src/components/auth/Settings.vue | 106 ++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 10 deletions(-) diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index 4e8f33289..8cd03d755 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -2,12 +2,35 @@
-

Change my password

-
-
+

Account settings

+ +
+
Settings updated
+
+
+
We cannot save your settings
+
    +
  • {{ error }}
  • +
+
+
+ +

{{ f.help }}

+ +
+ + +
+ +
+

Change my password

+
+
Cannot change your password
    -
  • Please double-check your password is correct
  • +
  • Please double-check your password is correct
@@ -36,22 +59,68 @@