diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 5dfbf0642..206bb50cc 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -20,7 +20,7 @@ black:
before_script:
- pip install black
script:
- - black --check --diff api/
+ - black --exclude "/(\.git|\.hg|\.mypy_cache|\.tox|\.venv|_build|buck-out|build|dist|migrations)/" --check --diff api/
flake8:
image: python:3.6
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/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..6e57db81f 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,50 @@ class ManageTrackFileActionSerializer(common_serializers.ActionSerializer):
@transaction.atomic
def handle_delete(self, objects):
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 = PermissionsSerializer(source="*")
+
+ 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 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/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/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..15d16db23 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,11 +77,13 @@ class User(AbstractUser):
default=False,
)
+ last_activity = models.DateTimeField(default=None, null=True, blank=True)
+
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 = (
@@ -90,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"]:
@@ -117,3 +125,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/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/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
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()
diff --git a/changes/changelog.d/212.feature b/changes/changelog.d/212.feature
new file mode 100644
index 000000000..0d029856b
--- /dev/null
+++ b/changes/changelog.d/212.feature
@@ -0,0 +1 @@
+Management interface for users (#212)
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('Name') }} + | ++ {{ object.name }} + | +
+ {{ $t('Email address') }} + | ++ {{ object.email }} + | +
+ {{ $t('Sign-up') }} + | +
+ |
+
+ {{ $t('Last activity') }} + | +
+ |
+
+ {{ $t('Account active') }} + + | +
+
+
+
+
+ |
+
+ {{ $t('Permissions') }} + | ++ + | +