From fce5ee0e7fbac196e5a1e40c29fb446085ca0988 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 25 Mar 2018 17:17:33 +0200 Subject: [PATCH 1/9] Renamed listening end_date to creation_date --- api/funkwhale_api/history/admin.py | 2 +- .../migrations/0002_auto_20180325_1433.py | 22 +++++++++++++++++++ api/funkwhale_api/history/models.py | 5 +++-- api/funkwhale_api/history/serializers.py | 4 ++-- api/tests/history/test_activity.py | 2 +- 5 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py diff --git a/api/funkwhale_api/history/admin.py b/api/funkwhale_api/history/admin.py index 6d0480e73..5ddfb8998 100644 --- a/api/funkwhale_api/history/admin.py +++ b/api/funkwhale_api/history/admin.py @@ -4,7 +4,7 @@ from . import models @admin.register(models.Listening) class ListeningAdmin(admin.ModelAdmin): - list_display = ['track', 'end_date', 'user', 'session_key'] + list_display = ['track', 'creation_date', 'user', 'session_key'] search_fields = ['track__name', 'user__username'] list_select_related = [ 'user', diff --git a/api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py b/api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py new file mode 100644 index 000000000..d83dbb0a4 --- /dev/null +++ b/api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py @@ -0,0 +1,22 @@ +# Generated by Django 2.0.3 on 2018-03-25 14:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('history', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='listening', + options={'ordering': ('-creation_date',)}, + ), + migrations.RenameField( + model_name='listening', + old_name='end_date', + new_name='creation_date', + ), + ] diff --git a/api/funkwhale_api/history/models.py b/api/funkwhale_api/history/models.py index 56310ddc0..762d5bf7b 100644 --- a/api/funkwhale_api/history/models.py +++ b/api/funkwhale_api/history/models.py @@ -6,7 +6,8 @@ from funkwhale_api.music.models import Track class Listening(models.Model): - end_date = models.DateTimeField(default=timezone.now, null=True, blank=True) + creation_date = models.DateTimeField( + default=timezone.now, null=True, blank=True) track = models.ForeignKey( Track, related_name="listenings", on_delete=models.CASCADE) user = models.ForeignKey( @@ -18,7 +19,7 @@ class Listening(models.Model): session_key = models.CharField(max_length=100, null=True, blank=True) class Meta: - ordering = ('-end_date',) + ordering = ('-creation_date',) def save(self, **kwargs): if not self.user and not self.session_key: diff --git a/api/funkwhale_api/history/serializers.py b/api/funkwhale_api/history/serializers.py index 7a2280cea..8fe6fa6e0 100644 --- a/api/funkwhale_api/history/serializers.py +++ b/api/funkwhale_api/history/serializers.py @@ -12,7 +12,7 @@ class ListeningActivitySerializer(activity_serializers.ModelSerializer): type = serializers.SerializerMethodField() object = TrackActivitySerializer(source='track') actor = UserActivitySerializer(source='user') - published = serializers.DateTimeField(source='end_date') + published = serializers.DateTimeField(source='creation_date') class Meta: model = models.Listening @@ -36,7 +36,7 @@ class ListeningSerializer(serializers.ModelSerializer): class Meta: model = models.Listening - fields = ('id', 'user', 'session_key', 'track', 'end_date') + fields = ('id', 'user', 'session_key', 'track', 'creation_date') def create(self, validated_data): diff --git a/api/tests/history/test_activity.py b/api/tests/history/test_activity.py index b5ab07b82..04000604b 100644 --- a/api/tests/history/test_activity.py +++ b/api/tests/history/test_activity.py @@ -23,7 +23,7 @@ def test_activity_listening_serializer(factories): "id": listening.get_activity_url(), "actor": actor, "object": TrackActivitySerializer(listening.track).data, - "published": field.to_representation(listening.end_date), + "published": field.to_representation(listening.creation_date), } data = serializers.ListeningActivitySerializer(listening).data From 2d4003c8c4496396d6364fad42bc20e9ef746759 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 25 Mar 2018 17:17:51 +0200 Subject: [PATCH 2/9] anonymoususer test fixture --- api/tests/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 62bc5ada6..06d225a0f 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -3,6 +3,7 @@ import tempfile import shutil import pytest +from django.contrib.auth.models import AnonymousUser from django.core.cache import cache as django_cache from dynamic_preferences.registries import global_preferences_registry @@ -66,6 +67,11 @@ def logged_in_client(db, factories, client): delattr(client, 'user') +@pytest.fixture +def anonymous_user(): + return AnonymousUser() + + @pytest.fixture def api_client(client): return APIClient() From 122c39075a8258b89557c8f1087c578c6540cde8 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 25 Mar 2018 17:18:22 +0200 Subject: [PATCH 3/9] Fixed privacy issue in get_privacy_query utils function --- api/funkwhale_api/common/fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py index ef9f840dc..1a18b5f27 100644 --- a/api/funkwhale_api/common/fields.py +++ b/api/funkwhale_api/common/fields.py @@ -22,6 +22,6 @@ def privacy_level_query(user, lookup_field='privacy_level'): return models.Q(**{ '{}__in'.format(lookup_field): [ - 'me', 'followers', 'instance', 'everyone' + 'followers', 'instance', 'everyone' ] }) From 1f2e14b20e725c6db881c3619eeccc20eb49cfa1 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 25 Mar 2018 17:18:44 +0200 Subject: [PATCH 4/9] Use anonymous_user fixture in other tests --- api/tests/common/test_permissions.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/api/tests/common/test_permissions.py b/api/tests/common/test_permissions.py index b5c5160f8..f04f12e0b 100644 --- a/api/tests/common/test_permissions.py +++ b/api/tests/common/test_permissions.py @@ -2,7 +2,6 @@ import pytest from rest_framework.views import APIView -from django.contrib.auth.models import AnonymousUser from django.http import Http404 from funkwhale_api.common import permissions @@ -19,24 +18,26 @@ def test_owner_permission_owner_field_ok(nodb_factories, api_request): assert check is True -def test_owner_permission_owner_field_not_ok(nodb_factories, api_request): +def test_owner_permission_owner_field_not_ok( + anonymous_user, nodb_factories, api_request): playlist = nodb_factories['playlists.Playlist']() view = APIView.as_view() permission = permissions.OwnerPermission() request = api_request.get('/') - setattr(request, 'user', AnonymousUser()) + setattr(request, 'user', anonymous_user) with pytest.raises(Http404): permission.has_object_permission(request, view, playlist) -def test_owner_permission_read_only(nodb_factories, api_request): +def test_owner_permission_read_only( + anonymous_user, nodb_factories, api_request): playlist = nodb_factories['playlists.Playlist']() view = APIView.as_view() setattr(view, 'owner_checks', ['write']) permission = permissions.OwnerPermission() request = api_request.get('/') - setattr(request, 'user', AnonymousUser()) + setattr(request, 'user', anonymous_user) check = permission.has_object_permission(request, view, playlist) assert check is True From 18d8baae344649d079086cc128aced119d0f55cd Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 25 Mar 2018 17:24:08 +0200 Subject: [PATCH 5/9] API Views/serializers/tests for activity (#141) --- api/config/api_urls.py | 2 + api/funkwhale_api/activity/serializers.py | 14 +++++ api/funkwhale_api/activity/utils.py | 64 +++++++++++++++++++++++ api/funkwhale_api/activity/views.py | 20 +++++++ api/tests/activity/test_serializers.py | 17 ++++++ api/tests/activity/test_utils.py | 21 ++++++++ api/tests/activity/test_views.py | 18 +++++++ 7 files changed, 156 insertions(+) create mode 100644 api/funkwhale_api/activity/utils.py create mode 100644 api/funkwhale_api/activity/views.py create mode 100644 api/tests/activity/test_serializers.py create mode 100644 api/tests/activity/test_utils.py create mode 100644 api/tests/activity/test_views.py diff --git a/api/config/api_urls.py b/api/config/api_urls.py index ff6db0d06..cab6805b6 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -1,5 +1,6 @@ from rest_framework import routers from django.conf.urls import include, url +from funkwhale_api.activity import views as activity_views from funkwhale_api.instance import views as instance_views from funkwhale_api.music import views from funkwhale_api.playlists import views as playlists_views @@ -10,6 +11,7 @@ from dynamic_preferences.users.viewsets import UserPreferencesViewSet router = routers.SimpleRouter() router.register(r'settings', GlobalPreferencesViewSet, base_name='settings') +router.register(r'activity', activity_views.ActivityViewSet, 'activity') router.register(r'tags', views.TagViewSet, 'tags') router.register(r'tracks', views.TrackViewSet, 'tracks') router.register(r'trackfiles', views.TrackFileViewSet, 'trackfiles') diff --git a/api/funkwhale_api/activity/serializers.py b/api/funkwhale_api/activity/serializers.py index 325d1e820..fd9b185cf 100644 --- a/api/funkwhale_api/activity/serializers.py +++ b/api/funkwhale_api/activity/serializers.py @@ -1,5 +1,7 @@ from rest_framework import serializers +from funkwhale_api.activity import record + class ModelSerializer(serializers.ModelSerializer): id = serializers.CharField(source='get_activity_url') @@ -8,3 +10,15 @@ class ModelSerializer(serializers.ModelSerializer): def get_url(self, obj): return self.get_id(obj) + + +class AutoSerializer(serializers.Serializer): + """ + A serializer that will automatically use registered activity serializers + to serialize an henerogeneous list of objects (favorites, listenings, etc.) + """ + def to_representation(self, instance): + serializer = record.registry[instance._meta.label]['serializer']( + instance + ) + return serializer.data diff --git a/api/funkwhale_api/activity/utils.py b/api/funkwhale_api/activity/utils.py new file mode 100644 index 000000000..46336930e --- /dev/null +++ b/api/funkwhale_api/activity/utils.py @@ -0,0 +1,64 @@ +from django.db import models + +from funkwhale_api.common import fields +from funkwhale_api.favorites.models import TrackFavorite +from funkwhale_api.history.models import Listening + + +def combined_recent(limit, **kwargs): + datetime_field = kwargs.pop('datetime_field', 'creation_date') + source_querysets = { + qs.model._meta.label: qs for qs in kwargs.pop('querysets') + } + querysets = { + k: qs.annotate( + __type=models.Value( + qs.model._meta.label, output_field=models.CharField() + ) + ).values('pk', datetime_field, '__type') + for k, qs in source_querysets.items() + } + _qs_list = list(querysets.values()) + union_qs = _qs_list[0].union(*_qs_list[1:]) + records = [] + for row in union_qs.order_by('-{}'.format(datetime_field))[:limit]: + records.append({ + 'type': row['__type'], + 'when': row[datetime_field], + 'pk': row['pk'] + }) + # Now we bulk-load each object type in turn + to_load = {} + for record in records: + to_load.setdefault(record['type'], []).append(record['pk']) + fetched = {} + + for key, pks in to_load.items(): + for item in source_querysets[key].filter(pk__in=pks): + fetched[(key, item.pk)] = item + + # Annotate 'records' with loaded objects + for record in records: + record['object'] = fetched[(record['type'], record['pk'])] + return records + + +def get_activity(user, limit=20): + query = fields.privacy_level_query( + user, lookup_field='user__privacy_level') + querysets = [ + Listening.objects.filter(query).select_related( + 'track', + 'user', + 'track__artist', + 'track__album__artist', + ), + TrackFavorite.objects.filter(query).select_related( + 'track', + 'user', + 'track__artist', + 'track__album__artist', + ), + ] + records = combined_recent(limit=limit, querysets=querysets) + return [r['object'] for r in records] diff --git a/api/funkwhale_api/activity/views.py b/api/funkwhale_api/activity/views.py new file mode 100644 index 000000000..e66de1ccf --- /dev/null +++ b/api/funkwhale_api/activity/views.py @@ -0,0 +1,20 @@ +from rest_framework import viewsets +from rest_framework.response import Response + +from funkwhale_api.common.permissions import ConditionalAuthentication +from funkwhale_api.favorites.models import TrackFavorite + +from . import serializers +from . import utils + + +class ActivityViewSet(viewsets.GenericViewSet): + + serializer_class = serializers.AutoSerializer + permission_classes = [ConditionalAuthentication] + queryset = TrackFavorite.objects.none() + + def list(self, request, *args, **kwargs): + activity = utils.get_activity(user=request.user) + serializer = self.serializer_class(activity, many=True) + return Response({'results': serializer.data}, status=200) diff --git a/api/tests/activity/test_serializers.py b/api/tests/activity/test_serializers.py new file mode 100644 index 000000000..792fa74b9 --- /dev/null +++ b/api/tests/activity/test_serializers.py @@ -0,0 +1,17 @@ +from funkwhale_api.activity import serializers +from funkwhale_api.favorites.serializers import TrackFavoriteActivitySerializer +from funkwhale_api.history.serializers import \ + ListeningActivitySerializer + + +def test_autoserializer(factories): + favorite = factories['favorites.TrackFavorite']() + listening = factories['history.Listening']() + objects = [favorite, listening] + serializer = serializers.AutoSerializer(objects, many=True) + expected = [ + TrackFavoriteActivitySerializer(favorite).data, + ListeningActivitySerializer(listening).data, + ] + + assert serializer.data == expected diff --git a/api/tests/activity/test_utils.py b/api/tests/activity/test_utils.py new file mode 100644 index 000000000..43bb45df8 --- /dev/null +++ b/api/tests/activity/test_utils.py @@ -0,0 +1,21 @@ +from funkwhale_api.activity import utils + + +def test_get_activity(factories): + user = factories['users.User']() + listening = factories['history.Listening']() + favorite = factories['favorites.TrackFavorite']() + + objects = list(utils.get_activity(user)) + assert objects == [favorite, listening] + + +def test_get_activity_honors_privacy_level(factories, anonymous_user): + listening = factories['history.Listening'](user__privacy_level='me') + favorite1 = factories['favorites.TrackFavorite']( + user__privacy_level='everyone') + favorite2 = factories['favorites.TrackFavorite']( + user__privacy_level='instance') + + objects = list(utils.get_activity(anonymous_user)) + assert objects == [favorite1] diff --git a/api/tests/activity/test_views.py b/api/tests/activity/test_views.py new file mode 100644 index 000000000..bdc3c6339 --- /dev/null +++ b/api/tests/activity/test_views.py @@ -0,0 +1,18 @@ +from django.urls import reverse + +from funkwhale_api.activity import serializers +from funkwhale_api.activity import utils + + +def test_activity_view(factories, api_client, settings, anonymous_user): + settings.API_AUTHENTICATION_REQUIRED = False + favorite = factories['favorites.TrackFavorite']( + user__privacy_level='everyone') + listening = factories['history.Listening']() + url = reverse('api:v1:activity-list') + objects = utils.get_activity(anonymous_user) + serializer = serializers.AutoSerializer(objects, many=True) + response = api_client.get(url) + + assert response.status_code == 200 + assert response.data['results'] == serializer.data From 2384f761b1efe9e9fa40f5b767691a09db0aee67 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 25 Mar 2018 17:36:02 +0200 Subject: [PATCH 6/9] Now fetch activity from API on first timeline display (#141) --- front/src/App.vue | 26 ---------------- front/src/store/instance.js | 3 ++ front/src/views/instance/Timeline.vue | 43 ++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/front/src/App.vue b/front/src/App.vue index bff52e97e..e8ab18694 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -36,9 +36,6 @@ diff --git a/front/src/store/instance.js b/front/src/store/instance.js index 2436eab07..245acaf03 100644 --- a/front/src/store/instance.js +++ b/front/src/store/instance.js @@ -43,6 +43,9 @@ export default { if (state.events.length > state.maxEvents) { state.events = state.events.slice(0, state.maxEvents) } + }, + events: (state, value) => { + state.events = value } }, actions: { diff --git a/front/src/views/instance/Timeline.vue b/front/src/views/instance/Timeline.vue index b959c25d6..8ffcd9758 100644 --- a/front/src/views/instance/Timeline.vue +++ b/front/src/views/instance/Timeline.vue @@ -1,7 +1,10 @@