Merge branch '212-user-management-interface' into 'develop'
Resolve "Implement a user management interface" Closes #212 See merge request funkwhale/funkwhale!262
This commit is contained in:
commit
33b6db8dc1
|
@ -20,7 +20,7 @@ black:
|
||||||
before_script:
|
before_script:
|
||||||
- pip install black
|
- pip install black
|
||||||
script:
|
script:
|
||||||
- black --check --diff api/
|
- black --exclude "/(\.git|\.hg|\.mypy_cache|\.tox|\.venv|_build|buck-out|build|dist|migrations)/" --check --diff api/
|
||||||
|
|
||||||
flake8:
|
flake8:
|
||||||
image: python:3.6
|
image: python:3.6
|
||||||
|
|
|
@ -146,6 +146,7 @@ MIDDLEWARE = (
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
"funkwhale_api.users.middleware.RecordActivityMiddleware",
|
||||||
)
|
)
|
||||||
|
|
||||||
# MIGRATIONS CONFIGURATION
|
# MIGRATIONS CONFIGURATION
|
||||||
|
|
|
@ -3,6 +3,7 @@ from django_filters import rest_framework as filters
|
||||||
|
|
||||||
from funkwhale_api.common import fields
|
from funkwhale_api.common import fields
|
||||||
from funkwhale_api.music import models as music_models
|
from funkwhale_api.music import models as music_models
|
||||||
|
from funkwhale_api.users import models as users_models
|
||||||
|
|
||||||
|
|
||||||
class ManageTrackFileFilterSet(filters.FilterSet):
|
class ManageTrackFileFilterSet(filters.FilterSet):
|
||||||
|
@ -18,3 +19,21 @@ class ManageTrackFileFilterSet(filters.FilterSet):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = music_models.TrackFile
|
model = music_models.TrackFile
|
||||||
fields = ["q", "track__album", "track__artist", "track", "library_track"]
|
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",
|
||||||
|
]
|
||||||
|
|
|
@ -3,6 +3,7 @@ from rest_framework import serializers
|
||||||
|
|
||||||
from funkwhale_api.common import serializers as common_serializers
|
from funkwhale_api.common import serializers as common_serializers
|
||||||
from funkwhale_api.music import models as music_models
|
from funkwhale_api.music import models as music_models
|
||||||
|
from funkwhale_api.users import models as users_models
|
||||||
|
|
||||||
from . import filters
|
from . import filters
|
||||||
|
|
||||||
|
@ -67,3 +68,50 @@ class ManageTrackFileActionSerializer(common_serializers.ActionSerializer):
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def handle_delete(self, objects):
|
def handle_delete(self, objects):
|
||||||
return objects.delete()
|
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
|
||||||
|
|
|
@ -5,7 +5,10 @@ from . import views
|
||||||
|
|
||||||
library_router = routers.SimpleRouter()
|
library_router = routers.SimpleRouter()
|
||||||
library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files")
|
library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files")
|
||||||
|
users_router = routers.SimpleRouter()
|
||||||
|
users_router.register(r"users", views.ManageUserViewSet, "users")
|
||||||
|
|
||||||
urlpatterns = [
|
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")),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
from rest_framework import mixins, response, viewsets
|
from rest_framework import mixins, response, viewsets
|
||||||
from rest_framework.decorators import list_route
|
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.music import models as music_models
|
||||||
|
from funkwhale_api.users import models as users_models
|
||||||
from funkwhale_api.users.permissions import HasUserPermission
|
from funkwhale_api.users.permissions import HasUserPermission
|
||||||
|
|
||||||
from . import filters, serializers
|
from . import filters, serializers
|
||||||
|
@ -41,3 +43,22 @@ class ManageTrackFileViewSet(
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
result = serializer.save()
|
result = serializer.save()
|
||||||
return response.Response(result, status=200)
|
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
|
||||||
|
|
|
@ -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
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -2,6 +2,7 @@
|
||||||
from __future__ import absolute_import, unicode_literals
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
import binascii
|
import binascii
|
||||||
|
import datetime
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
@ -9,6 +10,7 @@ from django.conf import settings
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.encoding import python_2_unicode_compatible
|
from django.utils.encoding import python_2_unicode_compatible
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
@ -75,11 +77,13 @@ class User(AbstractUser):
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
last_activity = models.DateTimeField(default=None, null=True, blank=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self, defaults=None):
|
||||||
defaults = preferences.get("users__default_permissions")
|
defaults = defaults or preferences.get("users__default_permissions")
|
||||||
perms = {}
|
perms = {}
|
||||||
for p in PERMISSIONS:
|
for p in PERMISSIONS:
|
||||||
v = (
|
v = (
|
||||||
|
@ -90,6 +94,10 @@ class User(AbstractUser):
|
||||||
perms[p] = v
|
perms[p] = v
|
||||||
return perms
|
return perms
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_permissions(self):
|
||||||
|
return self.get_permissions()
|
||||||
|
|
||||||
def has_permissions(self, *perms, **kwargs):
|
def has_permissions(self, *perms, **kwargs):
|
||||||
operator = kwargs.pop("operator", "and")
|
operator = kwargs.pop("operator", "and")
|
||||||
if operator not in ["and", "or"]:
|
if operator not in ["and", "or"]:
|
||||||
|
@ -117,3 +125,16 @@ class User(AbstractUser):
|
||||||
|
|
||||||
def get_activity_url(self):
|
def get_activity_url(self):
|
||||||
return settings.FUNKWHALE_URL + "/@{}".format(self.username)
|
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"])
|
||||||
|
|
|
@ -7,6 +7,7 @@ import pytest
|
||||||
import requests_mock
|
import requests_mock
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.core.cache import cache as django_cache
|
from django.core.cache import cache as django_cache
|
||||||
|
from django.utils import timezone
|
||||||
from django.test import client
|
from django.test import client
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
from rest_framework import fields as rest_fields
|
from rest_framework import fields as rest_fields
|
||||||
|
@ -250,3 +251,10 @@ def to_api_date():
|
||||||
raise ValueError("Invalid value: {}".format(value))
|
raise ValueError("Invalid value: {}".format(value))
|
||||||
|
|
||||||
return inner
|
return inner
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def now(mocker):
|
||||||
|
now = timezone.now()
|
||||||
|
mocker.patch("django.utils.timezone.now", return_value=now)
|
||||||
|
return now
|
||||||
|
|
|
@ -8,3 +8,26 @@ def test_manage_track_file_action_delete(factories):
|
||||||
s.handle_delete(tfs.__class__.objects.all())
|
s.handle_delete(tfs.__class__.objects.all())
|
||||||
|
|
||||||
assert tfs.__class__.objects.count() == 0
|
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
|
||||||
|
|
|
@ -5,7 +5,11 @@ from funkwhale_api.manage import serializers, views
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@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):
|
def test_permissions(assert_user_permission, view, permissions, operator):
|
||||||
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["count"] == len(tfs)
|
||||||
assert response.data["results"] == expected
|
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
|
||||||
|
|
|
@ -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)
|
|
@ -78,3 +78,20 @@ def test_has_permissions_and(args, perms, expected, factories):
|
||||||
def test_has_permissions_or(args, perms, expected, factories):
|
def test_has_permissions_or(args, perms, expected, factories):
|
||||||
user = factories["users.User"](**args)
|
user = factories["users.User"](**args)
|
||||||
assert user.has_permissions(*perms, operator="or") is expected
|
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()
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Management interface for users (#212)
|
|
@ -96,6 +96,12 @@
|
||||||
:to="{path: '/manage/settings'}">
|
:to="{path: '/manage/settings'}">
|
||||||
<i class="settings icon"></i>{{ $t('Settings') }}
|
<i class="settings icon"></i>{{ $t('Settings') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
class="item"
|
||||||
|
v-if="$store.state.auth.availablePermissions['settings']"
|
||||||
|
:to="{path: '/manage/users'}">
|
||||||
|
<i class="users icon"></i>{{ $t('Users') }}
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -61,7 +61,7 @@
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th v-if="actions.length > 0">
|
||||||
<div class="ui checkbox">
|
<div class="ui checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
@ -75,7 +75,7 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody v-if="objectsData.count > 0">
|
<tbody v-if="objectsData.count > 0">
|
||||||
<tr v-for="(obj, index) in objectsData.results">
|
<tr v-for="(obj, index) in objectsData.results">
|
||||||
<td class="collapsing">
|
<td v-if="actions.length > 0" class="collapsing">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:disabled="checkable.indexOf(obj.id) === -1"
|
:disabled="checkable.indexOf(obj.id) === -1"
|
||||||
|
@ -184,6 +184,9 @@ export default {
|
||||||
})[0]
|
})[0]
|
||||||
},
|
},
|
||||||
checkable () {
|
checkable () {
|
||||||
|
if (!this.currentAction) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
let objs = this.objectsData.results
|
let objs = this.objectsData.results
|
||||||
let filter = this.currentAction.filterCheckable
|
let filter = this.currentAction.filterCheckable
|
||||||
if (filter) {
|
if (filter) {
|
||||||
|
|
|
@ -0,0 +1,216 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="ui inline form">
|
||||||
|
<div class="fields">
|
||||||
|
<div class="ui field">
|
||||||
|
<label>{{ $t('Search') }}</label>
|
||||||
|
<input type="text" v-model="search" placeholder="Search by username, email, name..." />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<i18next tag="label" path="Ordering"/>
|
||||||
|
<select class="ui dropdown" v-model="ordering">
|
||||||
|
<option v-for="option in orderingOptions" :value="option[0]">
|
||||||
|
{{ option[1] }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<i18next tag="label" path="Ordering direction"/>
|
||||||
|
<select class="ui dropdown" v-model="orderingDirection">
|
||||||
|
<option value="+">{{ $t('Ascending') }}</option>
|
||||||
|
<option value="-">{{ $t('Descending') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dimmable">
|
||||||
|
<div v-if="isLoading" class="ui active inverted dimmer">
|
||||||
|
<div class="ui loader"></div>
|
||||||
|
</div>
|
||||||
|
<action-table
|
||||||
|
v-if="result"
|
||||||
|
@action-launched="fetchData"
|
||||||
|
:objects-data="result"
|
||||||
|
:actions="actions"
|
||||||
|
:action-url="'manage/library/track-files/action/'"
|
||||||
|
:filters="actionFilters">
|
||||||
|
<template slot="header-cells">
|
||||||
|
<th>{{ $t('Username') }}</th>
|
||||||
|
<th>{{ $t('Email') }}</th>
|
||||||
|
<th>{{ $t('Account status') }}</th>
|
||||||
|
<th>{{ $t('Sign-up') }}</th>
|
||||||
|
<th>{{ $t('Last activity') }}</th>
|
||||||
|
<th>{{ $t('Permissions') }}</th>
|
||||||
|
<th>{{ $t('Status') }}</th>
|
||||||
|
</template>
|
||||||
|
<template slot="row-cells" slot-scope="scope">
|
||||||
|
<td>
|
||||||
|
<router-link :to="{name: 'manage.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.username }}</router-link>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span>{{ scope.obj.email }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="scope.obj.is_active" class="ui basic green label">{{ $t('Active') }}</span>
|
||||||
|
<span v-else class="ui basic grey label">{{ $t('Inactive') }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<human-date :date="scope.obj.date_joined"></human-date>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<human-date v-if="scope.obj.last_activity" :date="scope.obj.last_activity"></human-date>
|
||||||
|
<template v-else>{{ $t('N/A') }}</template>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<template v-for="p in permissions">
|
||||||
|
<span class="ui basic tiny label" v-if="scope.obj.permissions[p.code]">{{ p.label }}</span>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="scope.obj.is_superuser" class="ui pink label">{{ $t('Admin') }}</span>
|
||||||
|
<span v-else-if="scope.obj.is_staff" class="ui purple label">{{ $t('Staff member') }}</span>
|
||||||
|
<span v-else class="ui basic label">{{ $t('regular user') }}</span>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</action-table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<pagination
|
||||||
|
v-if="result && result.results.length > 0"
|
||||||
|
@page-changed="selectPage"
|
||||||
|
:compact="true"
|
||||||
|
:current="page"
|
||||||
|
:paginate-by="paginateBy"
|
||||||
|
:total="result.count"
|
||||||
|
></pagination>
|
||||||
|
|
||||||
|
<span v-if="result && result.results.length > 0">
|
||||||
|
{{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import axios from 'axios'
|
||||||
|
import _ from 'lodash'
|
||||||
|
import time from '@/utils/time'
|
||||||
|
import Pagination from '@/components/Pagination'
|
||||||
|
import ActionTable from '@/components/common/ActionTable'
|
||||||
|
import OrderingMixin from '@/components/mixins/Ordering'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [OrderingMixin],
|
||||||
|
props: {
|
||||||
|
filters: {type: Object, required: false}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Pagination,
|
||||||
|
ActionTable
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-date_joined')
|
||||||
|
return {
|
||||||
|
time,
|
||||||
|
isLoading: false,
|
||||||
|
result: null,
|
||||||
|
page: 1,
|
||||||
|
paginateBy: 50,
|
||||||
|
search: '',
|
||||||
|
orderingDirection: defaultOrdering.direction || '+',
|
||||||
|
ordering: defaultOrdering.field,
|
||||||
|
orderingOptions: [
|
||||||
|
['date_joined', 'Sign-up date'],
|
||||||
|
['last_activity', 'Last activity'],
|
||||||
|
['username', 'Username']
|
||||||
|
]
|
||||||
|
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
this.fetchData()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetchData () {
|
||||||
|
let params = _.merge({
|
||||||
|
'page': this.page,
|
||||||
|
'page_size': this.paginateBy,
|
||||||
|
'q': this.search,
|
||||||
|
'ordering': this.getOrderingAsString()
|
||||||
|
}, this.filters)
|
||||||
|
let self = this
|
||||||
|
self.isLoading = true
|
||||||
|
self.checked = []
|
||||||
|
axios.get('/manage/users/users/', {params: params}).then((response) => {
|
||||||
|
self.result = response.data
|
||||||
|
self.isLoading = false
|
||||||
|
}, error => {
|
||||||
|
self.isLoading = false
|
||||||
|
self.errors = error.backendErrors
|
||||||
|
})
|
||||||
|
},
|
||||||
|
selectPage: function (page) {
|
||||||
|
this.page = page
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
privacyLevels () {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
permissions () {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'code': 'upload',
|
||||||
|
'label': this.$t('Upload')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'library',
|
||||||
|
'label': this.$t('Library')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'federation',
|
||||||
|
'label': this.$t('Federation')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'settings',
|
||||||
|
'label': this.$t('Settings')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
actionFilters () {
|
||||||
|
var currentFilters = {
|
||||||
|
q: this.search
|
||||||
|
}
|
||||||
|
if (this.filters) {
|
||||||
|
return _.merge(currentFilters, this.filters)
|
||||||
|
} else {
|
||||||
|
return currentFilters
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions () {
|
||||||
|
return [
|
||||||
|
// {
|
||||||
|
// name: 'delete',
|
||||||
|
// label: this.$t('Delete'),
|
||||||
|
// isDangerous: true
|
||||||
|
// }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
search (newValue) {
|
||||||
|
this.page = 1
|
||||||
|
this.fetchData()
|
||||||
|
},
|
||||||
|
page () {
|
||||||
|
this.fetchData()
|
||||||
|
},
|
||||||
|
ordering () {
|
||||||
|
this.fetchData()
|
||||||
|
},
|
||||||
|
orderingDirection () {
|
||||||
|
this.fetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -31,6 +31,9 @@ import Favorites from '@/components/favorites/List'
|
||||||
import AdminSettings from '@/views/admin/Settings'
|
import AdminSettings from '@/views/admin/Settings'
|
||||||
import AdminLibraryBase from '@/views/admin/library/Base'
|
import AdminLibraryBase from '@/views/admin/library/Base'
|
||||||
import AdminLibraryFilesList from '@/views/admin/library/FilesList'
|
import AdminLibraryFilesList from '@/views/admin/library/FilesList'
|
||||||
|
import AdminUsersBase from '@/views/admin/users/Base'
|
||||||
|
import AdminUsersDetail from '@/views/admin/users/UsersDetail'
|
||||||
|
import AdminUsersList from '@/views/admin/users/UsersList'
|
||||||
import FederationBase from '@/views/federation/Base'
|
import FederationBase from '@/views/federation/Base'
|
||||||
import FederationScan from '@/views/federation/Scan'
|
import FederationScan from '@/views/federation/Scan'
|
||||||
import FederationLibraryDetail from '@/views/federation/LibraryDetail'
|
import FederationLibraryDetail from '@/views/federation/LibraryDetail'
|
||||||
|
@ -180,6 +183,23 @@ export default new Router({
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/manage/users',
|
||||||
|
component: AdminUsersBase,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'manage.users.list',
|
||||||
|
component: AdminUsersList
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id',
|
||||||
|
name: 'manage.users.detail',
|
||||||
|
component: AdminUsersDetail,
|
||||||
|
props: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/library',
|
path: '/library',
|
||||||
component: Library,
|
component: Library,
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
<template>
|
||||||
|
<div class="main pusher" v-title="$t('Manage users')">
|
||||||
|
<div class="ui secondary pointing menu">
|
||||||
|
<router-link
|
||||||
|
class="ui item"
|
||||||
|
:to="{name: 'manage.users.list'}">{{ $t('Users') }}</router-link>
|
||||||
|
</div>
|
||||||
|
<router-view :key="$route.fullPath"></router-view>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import '../../../style/vendor/media';
|
||||||
|
|
||||||
|
.main.pusher > .ui.secondary.menu {
|
||||||
|
@include media(">tablet") {
|
||||||
|
margin: 0 2.5rem;
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
padding-top: 1.5em;
|
||||||
|
padding-bottom: 1.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,177 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="isLoading" class="ui vertical segment">
|
||||||
|
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||||
|
</div>
|
||||||
|
<template v-if="object">
|
||||||
|
<div :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']" v-title="object.username">
|
||||||
|
<div class="segment-content">
|
||||||
|
<h2 class="ui center aligned icon header">
|
||||||
|
<i class="circular inverted user red icon"></i>
|
||||||
|
<div class="content">
|
||||||
|
@{{ object.username }}
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<div class="ui one column centered grid">
|
||||||
|
<table class="ui collapsing very basic table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ $t('Name') }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ object.name }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ $t('Email address') }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ object.email }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ $t('Sign-up') }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<human-date :date="object.date_joined"></human-date>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ $t('Last activity') }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<human-date v-if="object.last_activity" :date="object.last_activity"></human-date>
|
||||||
|
<template v-else>{{ $t('N/A') }}</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ $t('Account active') }}
|
||||||
|
<span :data-tooltip="$t('Determine if the user account is active or not. Inactive users cannot login or user the service.')"><i class="question circle icon"></i></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="ui toggle checkbox">
|
||||||
|
<input
|
||||||
|
@change="update('is_active')"
|
||||||
|
v-model="object.is_active" type="checkbox">
|
||||||
|
<label></label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ $t('Permissions') }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
@change="update('permissions')"
|
||||||
|
v-model="permissions"
|
||||||
|
multiple
|
||||||
|
class="ui search selection dropdown">
|
||||||
|
<option v-for="p in allPermissions" :value="p.code">{{ p.label }}</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<button @click="fetchData" class="ui basic button">{{ $t('Refresh') }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import $ from 'jquery'
|
||||||
|
import axios from 'axios'
|
||||||
|
import logger from '@/logging'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['id'],
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
isLoading: true,
|
||||||
|
object: null,
|
||||||
|
permissions: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
this.fetchData()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetchData () {
|
||||||
|
var self = this
|
||||||
|
this.isLoading = true
|
||||||
|
let url = 'manage/users/users/' + this.id + '/'
|
||||||
|
axios.get(url).then((response) => {
|
||||||
|
self.object = response.data
|
||||||
|
self.permissions = []
|
||||||
|
self.allPermissions.forEach(p => {
|
||||||
|
if (self.object.permissions[p.code]) {
|
||||||
|
self.permissions.push(p.code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
self.isLoading = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
update (attr) {
|
||||||
|
let newValue = this.object[attr]
|
||||||
|
let params = {}
|
||||||
|
if (attr === 'permissions') {
|
||||||
|
params['permissions'] = {}
|
||||||
|
this.allPermissions.forEach(p => {
|
||||||
|
params['permissions'][p.code] = this.permissions.indexOf(p.code) > -1
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
params[attr] = newValue
|
||||||
|
}
|
||||||
|
axios.patch('manage/users/users/' + this.id + '/', params).then((response) => {
|
||||||
|
logger.default.info(`${attr} was updated succcessfully to ${newValue}`)
|
||||||
|
}, (error) => {
|
||||||
|
logger.default.error(`Error while setting ${attr} to ${newValue}`, error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
allPermissions () {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'code': 'upload',
|
||||||
|
'label': this.$t('Upload')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'library',
|
||||||
|
'label': this.$t('Library')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'federation',
|
||||||
|
'label': this.$t('Federation')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'settings',
|
||||||
|
'label': this.$t('Settings')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
object () {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
$('select.dropdown').dropdown()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
|
@ -0,0 +1,23 @@
|
||||||
|
<template>
|
||||||
|
<div v-title="$t('Users')">
|
||||||
|
<div class="ui vertical stripe segment">
|
||||||
|
<h2 class="ui header">{{ $t('Users') }}</h2>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<users-table></users-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import UsersTable from '@/components/manage/users/UsersTable'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
UsersTable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
Loading…
Reference in New Issue