From 2e4f862387926f90de14a4510fdd8c82b8f4c679 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 17 Jun 2018 17:53:40 +0200 Subject: [PATCH 1/6] See #212: record user last activity date --- api/config/settings/common.py | 1 + api/funkwhale_api/users/middleware.py | 9 ++++++++ .../migrations/0008_auto_20180617_1531.py | 23 +++++++++++++++++++ api/funkwhale_api/users/models.py | 17 ++++++++++++++ api/tests/conftest.py | 8 +++++++ api/tests/users/test_middleware.py | 18 +++++++++++++++ api/tests/users/test_models.py | 17 ++++++++++++++ 7 files changed, 93 insertions(+) create mode 100644 api/funkwhale_api/users/middleware.py create mode 100644 api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py create mode 100644 api/tests/users/test_middleware.py diff --git a/api/config/settings/common.py b/api/config/settings/common.py index cb5573ed5..a836dfdfd 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -146,6 +146,7 @@ MIDDLEWARE = ( "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "funkwhale_api.users.middleware.RecordActivityMiddleware", ) # MIGRATIONS CONFIGURATION diff --git a/api/funkwhale_api/users/middleware.py b/api/funkwhale_api/users/middleware.py new file mode 100644 index 000000000..d5e83f080 --- /dev/null +++ b/api/funkwhale_api/users/middleware.py @@ -0,0 +1,9 @@ +class RecordActivityMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + if hasattr(request, "user") and request.user.is_authenticated: + request.user.record_activity() + return response diff --git a/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py b/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py new file mode 100644 index 000000000..b731e3279 --- /dev/null +++ b/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.6 on 2018-06-17 15:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0007_auto_20180524_2009'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='last_activity', + field=models.DateTimeField(blank=True, default=None, null=True), + ), + migrations.AlterField( + model_name='user', + name='permission_library', + field=models.BooleanField(default=False, help_text='Manage library, delete files, tracks, artists, albums...', verbose_name='Manage library'), + ), + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index caf1e452b..d37656c11 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals import binascii +import datetime import os import uuid @@ -9,6 +10,7 @@ from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models from django.urls import reverse +from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ @@ -75,6 +77,8 @@ class User(AbstractUser): default=False, ) + last_activity = models.DateTimeField(default=None, null=True, blank=True) + def __str__(self): return self.username @@ -117,3 +121,16 @@ class User(AbstractUser): def get_activity_url(self): return settings.FUNKWHALE_URL + "/@{}".format(self.username) + + def record_activity(self): + """ + Simply update the last_activity field if current value is too old + than a threshold. This is useful to keep a track of inactive accounts. + """ + current = self.last_activity + delay = 60 * 15 # fifteen minutes + now = timezone.now() + + if current is None or current < now - datetime.timedelta(seconds=delay): + self.last_activity = now + self.save(update_fields=["last_activity"]) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 40203ee3d..aa36e1f76 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -7,6 +7,7 @@ import pytest import requests_mock from django.contrib.auth.models import AnonymousUser from django.core.cache import cache as django_cache +from django.utils import timezone from django.test import client from dynamic_preferences.registries import global_preferences_registry from rest_framework import fields as rest_fields @@ -250,3 +251,10 @@ def to_api_date(): raise ValueError("Invalid value: {}".format(value)) return inner + + +@pytest.fixture() +def now(mocker): + now = timezone.now() + mocker.patch("django.utils.timezone.now", return_value=now) + return now diff --git a/api/tests/users/test_middleware.py b/api/tests/users/test_middleware.py new file mode 100644 index 000000000..fd13df4b3 --- /dev/null +++ b/api/tests/users/test_middleware.py @@ -0,0 +1,18 @@ +from funkwhale_api.users import middleware + + +def test_record_activity_middleware(factories, api_request, mocker): + m = middleware.RecordActivityMiddleware(lambda request: None) + user = factories["users.User"]() + record_activity = mocker.patch("funkwhale_api.users.models.User.record_activity") + request = api_request.get("/") + request.user = user + m(request) + + record_activity.assert_called_once_with() + + +def test_record_activity_middleware_no_user(api_request): + m = middleware.RecordActivityMiddleware(lambda request: None) + request = api_request.get("/") + m(request) diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py index c73a4a1b1..74bb091e5 100644 --- a/api/tests/users/test_models.py +++ b/api/tests/users/test_models.py @@ -78,3 +78,20 @@ def test_has_permissions_and(args, perms, expected, factories): def test_has_permissions_or(args, perms, expected, factories): user = factories["users.User"](**args) assert user.has_permissions(*perms, operator="or") is expected + + +def test_record_activity(factories, now): + user = factories["users.User"]() + assert user.last_activity is None + + user.record_activity() + + assert user.last_activity == now + + +def test_record_activity_does_nothing_if_already(factories, now, mocker): + user = factories["users.User"](last_activity=now) + save = mocker.patch("funkwhale_api.users.models.User.save") + user.record_activity() + + save.assert_not_called() From 3ef5f29dd39a9e9c60dc022238f9c84f9e1f781c Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Tue, 19 Jun 2018 18:48:43 +0200 Subject: [PATCH 2/6] See #212: API viewset --- api/funkwhale_api/manage/filters.py | 19 +++++++++++++++ api/funkwhale_api/manage/serializers.py | 32 +++++++++++++++++++++++++ api/funkwhale_api/manage/urls.py | 5 +++- api/funkwhale_api/manage/views.py | 21 ++++++++++++++++ api/funkwhale_api/users/models.py | 4 ++-- api/tests/manage/test_views.py | 21 +++++++++++++++- 6 files changed, 98 insertions(+), 4 deletions(-) diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index 2f2bde838..e4cda18c5 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -3,6 +3,7 @@ from django_filters import rest_framework as filters from funkwhale_api.common import fields from funkwhale_api.music import models as music_models +from funkwhale_api.users import models as users_models class ManageTrackFileFilterSet(filters.FilterSet): @@ -18,3 +19,21 @@ class ManageTrackFileFilterSet(filters.FilterSet): class Meta: model = music_models.TrackFile fields = ["q", "track__album", "track__artist", "track", "library_track"] + + +class ManageUserFilterSet(filters.FilterSet): + q = fields.SearchFilter(search_fields=["username", "email", "name"]) + + class Meta: + model = users_models.User + fields = [ + "q", + "is_active", + "privacy_level", + "is_staff", + "is_superuser", + "permission_upload", + "permission_library", + "permission_settings", + "permission_federation", + ] diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 1c94cf553..13f886a7e 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -3,6 +3,7 @@ from rest_framework import serializers from funkwhale_api.common import serializers as common_serializers from funkwhale_api.music import models as music_models +from funkwhale_api.users import models as users_models from . import filters @@ -67,3 +68,34 @@ class ManageTrackFileActionSerializer(common_serializers.ActionSerializer): @transaction.atomic def handle_delete(self, objects): return objects.delete() + + +class ManageUserSerializer(serializers.ModelSerializer): + permissions = serializers.SerializerMethodField() + + class Meta: + model = users_models.User + fields = ( + "id", + "username", + "email", + "name", + "is_active", + "is_staff", + "is_superuser", + "date_joined", + "last_activity", + "permissions", + "privacy_level", + ) + read_only_fields = [ + "id", + "email", + "privacy_level", + "username", + "date_joined", + "last_activity", + ] + + def get_permissions(self, o): + return o.get_permissions(defaults=self.context.get("default_permissions")) diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index 60853034f..f208fb857 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -5,7 +5,10 @@ from . import views library_router = routers.SimpleRouter() library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files") +users_router = routers.SimpleRouter() +users_router.register(r"users", views.ManageUserViewSet, "users") urlpatterns = [ - url(r"^library/", include((library_router.urls, "instance"), namespace="library")) + url(r"^library/", include((library_router.urls, "instance"), namespace="library")), + url(r"^users/", include((users_router.urls, "instance"), namespace="users")), ] diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index 8511732c9..f9b78ef87 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -1,7 +1,9 @@ from rest_framework import mixins, response, viewsets from rest_framework.decorators import list_route +from funkwhale_api.common import preferences from funkwhale_api.music import models as music_models +from funkwhale_api.users import models as users_models from funkwhale_api.users.permissions import HasUserPermission from . import filters, serializers @@ -41,3 +43,22 @@ class ManageTrackFileViewSet( serializer.is_valid(raise_exception=True) result = serializer.save() return response.Response(result, status=200) + + +class ManageUserViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + queryset = users_models.User.objects.all().order_by("-id") + serializer_class = serializers.ManageUserSerializer + filter_class = filters.ManageUserFilterSet + permission_classes = (HasUserPermission,) + required_permissions = ["settings"] + ordering_fields = ["date_joined", "last_activity", "username"] + + def get_serializer_context(self): + context = super().get_serializer_context() + context["default_permissions"] = preferences.get("users__default_permissions") + return context diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index d37656c11..055a971b3 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -82,8 +82,8 @@ class User(AbstractUser): def __str__(self): return self.username - def get_permissions(self): - defaults = preferences.get("users__default_permissions") + def get_permissions(self, defaults=None): + defaults = defaults or preferences.get("users__default_permissions") perms = {} for p in PERMISSIONS: v = ( diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index e2bfbf3a8..a72bcf5af 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -5,7 +5,11 @@ from funkwhale_api.manage import serializers, views @pytest.mark.parametrize( - "view,permissions,operator", [(views.ManageTrackFileViewSet, ["library"], "and")] + "view,permissions,operator", + [ + (views.ManageTrackFileViewSet, ["library"], "and"), + (views.ManageUserViewSet, ["settings"], "and"), + ], ) def test_permissions(assert_user_permission, view, permissions, operator): assert_user_permission(view, permissions, operator) @@ -23,3 +27,18 @@ def test_track_file_view(factories, superuser_api_client): assert response.data["count"] == len(tfs) assert response.data["results"] == expected + + +def test_user_view(factories, superuser_api_client, mocker): + mocker.patch("funkwhale_api.users.models.User.record_activity") + users = factories["users.User"].create_batch(size=5) + [superuser_api_client.user] + qs = users[0].__class__.objects.order_by("-id") + url = reverse("api:v1:manage:users:users-list") + + response = superuser_api_client.get(url, {"sort": "-id"}) + expected = serializers.ManageUserSerializer( + qs, many=True, context={"request": response.wsgi_request} + ).data + + assert response.data["count"] == len(users) + assert response.data["results"] == expected From 8e6b6f454ae047775f98b50d2d263990839c4c83 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Tue, 19 Jun 2018 18:50:22 +0200 Subject: [PATCH 3/6] See #212: front-end list --- front/src/components/Sidebar.vue | 6 + front/src/components/common/ActionTable.vue | 7 +- .../components/manage/users/UsersTable.vue | 216 ++++++++++++++++++ front/src/router/index.js | 13 ++ front/src/views/admin/users/Base.vue | 28 +++ front/src/views/admin/users/UsersList.vue | 23 ++ 6 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 front/src/components/manage/users/UsersTable.vue create mode 100644 front/src/views/admin/users/Base.vue create mode 100644 front/src/views/admin/users/UsersList.vue diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index d46fb846c..03ea4ee07 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -96,6 +96,12 @@ :to="{path: '/manage/settings'}"> {{ $t('Settings') }} + + {{ $t('Users') }} + diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue index 5221c3282..f23479066 100644 --- a/front/src/components/common/ActionTable.vue +++ b/front/src/components/common/ActionTable.vue @@ -61,7 +61,7 @@ - +
- + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+ + + + +
+
+ + + + {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}} + +
+
+ + + diff --git a/front/src/router/index.js b/front/src/router/index.js index a52070e35..459077d34 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -31,6 +31,8 @@ import Favorites from '@/components/favorites/List' import AdminSettings from '@/views/admin/Settings' import AdminLibraryBase from '@/views/admin/library/Base' import AdminLibraryFilesList from '@/views/admin/library/FilesList' +import AdminUsersBase from '@/views/admin/users/Base' +import AdminUsersList from '@/views/admin/users/UsersList' import FederationBase from '@/views/federation/Base' import FederationScan from '@/views/federation/Scan' import FederationLibraryDetail from '@/views/federation/LibraryDetail' @@ -180,6 +182,17 @@ export default new Router({ } ] }, + { + path: '/manage/users', + component: AdminUsersBase, + children: [ + { + path: '', + name: 'manage.users.list', + component: AdminUsersList + } + ] + }, { path: '/library', component: Library, diff --git a/front/src/views/admin/users/Base.vue b/front/src/views/admin/users/Base.vue new file mode 100644 index 000000000..e545b7f70 --- /dev/null +++ b/front/src/views/admin/users/Base.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/front/src/views/admin/users/UsersList.vue b/front/src/views/admin/users/UsersList.vue new file mode 100644 index 000000000..b22d4aaf8 --- /dev/null +++ b/front/src/views/admin/users/UsersList.vue @@ -0,0 +1,23 @@ + + + + + + From 8636b456a8660eef30fe688a02a903a498f27f10 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Tue, 19 Jun 2018 20:11:40 +0200 Subject: [PATCH 4/6] See #212: user detail profile --- api/funkwhale_api/manage/serializers.py | 22 ++- api/funkwhale_api/users/models.py | 4 + api/tests/manage/test_serializers.py | 23 +++ .../components/manage/users/UsersTable.vue | 2 +- front/src/router/index.js | 7 + front/src/views/admin/users/UsersDetail.vue | 177 ++++++++++++++++++ 6 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 front/src/views/admin/users/UsersDetail.vue diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 13f886a7e..6e57db81f 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -70,8 +70,16 @@ class ManageTrackFileActionSerializer(common_serializers.ActionSerializer): return objects.delete() +class PermissionsSerializer(serializers.Serializer): + def to_representation(self, o): + return o.get_permissions(defaults=self.context.get("default_permissions")) + + def to_internal_value(self, o): + return {"permissions": o} + + class ManageUserSerializer(serializers.ModelSerializer): - permissions = serializers.SerializerMethodField() + permissions = PermissionsSerializer(source="*") class Meta: model = users_models.User @@ -97,5 +105,13 @@ class ManageUserSerializer(serializers.ModelSerializer): "last_activity", ] - def get_permissions(self, o): - return o.get_permissions(defaults=self.context.get("default_permissions")) + def update(self, instance, validated_data): + instance = super().update(instance, validated_data) + permissions = validated_data.pop("permissions", {}) + if permissions: + for p, value in permissions.items(): + setattr(instance, "permission_{}".format(p), value) + instance.save( + update_fields=["permission_{}".format(p) for p in permissions.keys()] + ) + return instance diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 055a971b3..15d16db23 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -94,6 +94,10 @@ class User(AbstractUser): perms[p] = v return perms + @property + def all_permissions(self): + return self.get_permissions() + def has_permissions(self, *perms, **kwargs): operator = kwargs.pop("operator", "and") if operator not in ["and", "or"]: diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index 893cfd86e..2f0c6bc25 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -8,3 +8,26 @@ def test_manage_track_file_action_delete(factories): s.handle_delete(tfs.__class__.objects.all()) assert tfs.__class__.objects.count() == 0 + + +def test_user_update_permission(factories): + user = factories["users.User"]( + permission_library=False, + permission_upload=False, + permission_federation=True, + permission_settings=True, + is_active=True, + ) + s = serializers.ManageUserSerializer( + user, + data={"is_active": False, "permissions": {"federation": False, "upload": True}}, + ) + s.is_valid(raise_exception=True) + s.save() + user.refresh_from_db() + + assert user.is_active is False + assert user.permission_federation is False + assert user.permission_upload is True + assert user.permission_library is False + assert user.permission_settings is True diff --git a/front/src/components/manage/users/UsersTable.vue b/front/src/components/manage/users/UsersTable.vue index 746b158a7..5658583c3 100644 --- a/front/src/components/manage/users/UsersTable.vue +++ b/front/src/components/manage/users/UsersTable.vue @@ -45,7 +45,7 @@