Skip to content
Snippets Groups Projects
Commit 33b6db8d authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch '212-user-management-interface' into 'develop'

Resolve "Implement a user management interface"

Closes #212

See merge request funkwhale/funkwhale!262
parents b0430c38 49fc56b1
No related branches found
No related tags found
No related merge requests found
Showing
with 511 additions and 7 deletions
...@@ -20,7 +20,7 @@ black: ...@@ -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 = ( ...@@ -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 ...@@ -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): ...@@ -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 ...@@ -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): ...@@ -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 ...@@ -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")),
] ]
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( ...@@ -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
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
# 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 @@ ...@@ -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 ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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 ...@@ -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(): ...@@ -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): ...@@ -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 ...@@ -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): ...@@ -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
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): ...@@ -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()
Management interface for users (#212)
...@@ -96,6 +96,12 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 { ...@@ -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) {
......
<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' ...@@ -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({ ...@@ -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,
......
<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% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment