Merge pull request #966 from bookwyrm-social/user-admin

User admin
This commit is contained in:
Mouse Reeve 2021-04-20 13:43:51 -07:00 committed by GitHub
commit 2e12d54687
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 221 additions and 99 deletions

View file

@ -150,6 +150,12 @@ class LimitedEditUserForm(CustomForm):
help_texts = {f: None for f in fields} help_texts = {f: None for f in fields}
class UserGroupForm(CustomForm):
class Meta:
model = models.User
fields = ["groups"]
class TagForm(CustomForm): class TagForm(CustomForm):
class Meta: class Meta:
model = models.Tag model = models.Tag

View file

@ -15,76 +15,9 @@
{% include 'moderation/report_preview.html' with report=report %} {% include 'moderation/report_preview.html' with report=report %}
</div> </div>
<div class="block columns"> {% include 'user_admin/user_info.html' with user=report.user %}
<div class="column is-flex is-flex-direction-column">
<h4 class="title is-4">{% trans "User details" %}</h4>
<div class="box is-flex-grow-1">
{% include 'user/user_preview.html' with user=report.user %}
{% if report.user.summary %}
<div class="box content has-background-white-ter is-shadowless">
{{ report.user.summary | to_markdown | safe }}
</div>
{% endif %}
<p class="mt-2"><a href="{{ report.user.local_path }}">{% trans "View user profile" %}</a></p> {% include 'user_admin/user_moderation_actions.html' with user=report.user %}
</div>
</div>
{% if not report.user.local %}
{% with server=report.user.federated_server %}
<div class="column is-half is-flex is-flex-direction-column">
<h4 class="title is-4">{% trans "Instance details" %}</h4>
<div class="box content is-flex-grow-1">
{% if server %}
<h5>{{ server.server_name }}</h5>
<dl>
<div class="is-flex">
<dt>{% trans "Software:" %}</dt>
<dd>{{ server.application_type }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Version:" %}</dt>
<dd>{{ server.application_version }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Status:" %}</dt>
<dd>{{ server.status }}</dd>
</div>
</dl>
{% if server.notes %}
<h5>{% trans "Notes" %}</h5>
<div class="box content has-background-white-ter is-shadowless">
{{ server.notes }}
</div>
{% endif %}
<p>
<a href="{% url 'settings-federated-server' server.id %}">{% trans "View instance" %}</a>
</p>
{% else %}
<em>{% trans "Not set" %}</em>
{% endif %}
</div>
</div>
{% endwith %}
{% endif %}
</div>
<div class="block content">
<h3>{% trans "Actions" %}</h3>
<div class="is-flex">
<p class="mr-1">
<a class="button" href="{% url 'direct-messages-user' report.user.username %}">{% trans "Send direct message" %}</a>
</p>
<form name="deactivate" method="post" action="{% url 'settings-report-deactivate' report.id %}">
{% csrf_token %}
{% if report.user.is_active %}
<button type="submit" class="button is-danger is-light">{% trans "Deactivate user" %}</button>
{% else %}
<button class="button">{% trans "Reactivate user" %}</button>
{% endif %}
</form>
</div>
</div>
<div class="block"> <div class="block">
<h3 class="title is-4">{% trans "Moderator Comments" %}</h3> <h3 class="title is-4">{% trans "Moderator Comments" %}</h3>

View file

@ -30,7 +30,7 @@
</ul> </ul>
</div> </div>
{% include 'settings/user_admin_filters.html' %} {% include 'user_admin/user_admin_filters.html' %}
<div class="block"> <div class="block">
{% if not reports %} {% if not reports %}

View file

@ -0,0 +1,19 @@
{% extends 'settings/admin_layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load humanize %}
{% block title %}{{ user.username }}{% endblock %}
{% block header %}{{ user.username }}{% endblock %}
{% block panel %}
<div class="block">
<a href="{% url 'settings-users' %}">{% trans "Back to users" %}</a>
</div>
{% include 'user_admin/user_info.html' with user=user %}
{% include 'user_admin/user_moderation_actions.html' with user=user %}
{% endblock %}

View file

@ -13,7 +13,7 @@
{% block panel %} {% block panel %}
{% include 'settings/user_admin_filters.html' %} {% include 'user_admin/user_admin_filters.html' %}
<table class="table is-striped"> <table class="table is-striped">
<tr> <tr>
@ -41,7 +41,7 @@
</tr> </tr>
{% for user in users %} {% for user in users %}
<tr> <tr>
<td>{{ user.username }}</td> <td><a href="{% url 'settings-user' user.id %}">{{ user.username }}</a></td>
<td>{{ user.created_date }}</td> <td>{{ user.created_date }}</td>
<td>{{ user.last_active_date }}</td> <td>{{ user.last_active_date }}</td>
<td>{% if user.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}</td> <td>{% if user.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}</td>

View file

@ -1,6 +1,6 @@
{% extends 'snippets/filters_panel/filters_panel.html' %} {% extends 'snippets/filters_panel/filters_panel.html' %}
{% block filter_fields %} {% block filter_fields %}
{% include 'settings/server_filter.html' %} {% include 'user_admin/server_filter.html' %}
{% include 'settings/username_filter.html' %} {% include 'user_admin/username_filter.html' %}
{% endblock %} {% endblock %}

View file

@ -0,0 +1,56 @@
{% load i18n %}
{% load bookwyrm_tags %}
<div class="block columns">
<div class="column is-flex is-flex-direction-column">
<h4 class="title is-4">{% trans "User details" %}</h4>
<div class="box is-flex-grow-1">
{% include 'user/user_preview.html' with user=user %}
{% if user.summary %}
<div class="box content has-background-white-ter is-shadowless">
{{ user.summary | to_markdown | safe }}
</div>
{% endif %}
<p class="mt-2"><a href="{{ user.local_path }}">{% trans "View user profile" %}</a></p>
</div>
</div>
{% if not user.local %}
{% with server=user.federated_server %}
<div class="column is-half is-flex is-flex-direction-column">
<h4 class="title is-4">{% trans "Instance details" %}</h4>
<div class="box content is-flex-grow-1">
{% if server %}
<h5>{{ server.server_name }}</h5>
<dl>
<div class="is-flex">
<dt>{% trans "Software:" %}</dt>
<dd>{{ server.application_type }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Version:" %}</dt>
<dd>{{ server.application_version }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Status:" %}</dt>
<dd>{{ server.status }}</dd>
</div>
</dl>
{% if server.notes %}
<h5>{% trans "Notes" %}</h5>
<div class="box content has-background-white-ter is-shadowless">
{{ server.notes }}
</div>
{% endif %}
<p>
<a href="{% url 'settings-federated-server' server.id %}">{% trans "View instance" %}</a>
</p>
{% else %}
<em>{% trans "Not set" %}</em>
{% endif %}
</div>
</div>
{% endwith %}
{% endif %}
</div>

View file

@ -0,0 +1,42 @@
{% load i18n %}
<div class="block content">
<h3>{% trans "Actions" %}</h3>
<div class="is-flex">
<p class="mr-1">
<a class="button" href="{% url 'direct-messages-user' user.username %}">{% trans "Send direct message" %}</a>
</p>
<form name="suspend" method="post" action="{% url 'settings-report-suspend' user.id %}">
{% csrf_token %}
{% if user.is_active %}
<button type="submit" class="button is-danger is-light">{% trans "Suspend user" %}</button>
{% else %}
<button class="button">{% trans "Un-suspend user" %}</button>
{% endif %}
</form>
</div>
{% if user.local %}
<div>
<form name="permission" method="post" action="{% url 'settings-user' user.id %}">
{% csrf_token %}
<label class="label" for="id_user_group">{% trans "Access level:" %}</label>
{% if group_form.non_field_errors %}
{{ group_form.non_field_errors }}
{% endif %}
{% with group=user.groups.first %}
<div class="select">
<select name="groups" id="id_user_group">
{% for value, name in group_form.fields.groups.choices %}
<option value="{{ value }}" {% if name == group.name %}selected{% endif %}>{{ name|title }}</option>
{% endfor %}
<option value="" {% if not group %}selected{% endif %}>User</option>
</select>
</div>
{% for error in group_form.groups.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% endwith %}
<button class="button">{% trans "Save" %}</button>
</form>
</div>
{% endif %}
</div>

View file

@ -1,5 +1,4 @@
""" test for app action functionality """ """ test for app action functionality """
from unittest.mock import patch
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
@ -115,22 +114,19 @@ class ReportViews(TestCase):
report.refresh_from_db() report.refresh_from_db()
self.assertFalse(report.resolved) self.assertFalse(report.resolved)
def test_deactivate_user(self): def test_suspend_user(self):
""" toggle whether a user is able to log in """ """ toggle whether a user is able to log in """
self.assertTrue(self.rat.is_active) self.assertTrue(self.rat.is_active)
report = models.Report.objects.create(reporter=self.local_user, user=self.rat)
request = self.factory.post("") request = self.factory.post("")
request.user = self.local_user request.user = self.local_user
request.user.is_superuser = True request.user.is_superuser = True
# de-activate # de-activate
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): views.suspend_user(request, self.rat.id)
views.deactivate_user(request, report.id)
self.rat.refresh_from_db() self.rat.refresh_from_db()
self.assertFalse(self.rat.is_active) self.assertFalse(self.rat.is_active)
# re-activate # re-activate
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): views.suspend_user(request, self.rat.id)
views.deactivate_user(request, report.id)
self.rat.refresh_from_db() self.rat.refresh_from_db()
self.assertTrue(self.rat.is_active) self.assertTrue(self.rat.is_active)

View file

@ -1,4 +1,6 @@
""" test for app action functionality """ """ test for app action functionality """
from unittest.mock import patch
from django.contrib.auth.models import Group
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
@ -21,9 +23,9 @@ class UserAdminViews(TestCase):
) )
models.SiteSettings.objects.create() models.SiteSettings.objects.create()
def test_user_admin_page(self): def test_user_admin_list_page(self):
""" there are so many views, this just makes sure it LOADS """ """ there are so many views, this just makes sure it LOADS """
view = views.UserAdmin.as_view() view = views.UserAdminList.as_view()
request = self.factory.get("") request = self.factory.get("")
request.user = self.local_user request.user = self.local_user
request.user.is_superuser = True request.user.is_superuser = True
@ -31,3 +33,38 @@ class UserAdminViews(TestCase):
self.assertIsInstance(result, TemplateResponse) self.assertIsInstance(result, TemplateResponse)
result.render() result.render()
self.assertEqual(result.status_code, 200) self.assertEqual(result.status_code, 200)
def test_user_admin_page(self):
""" there are so many views, this just makes sure it LOADS """
view = views.UserAdmin.as_view()
request = self.factory.get("")
request.user = self.local_user
request.user.is_superuser = True
result = view(request, self.local_user.id)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
def test_user_admin_page_post(self):
""" set the user's group """
group = Group.objects.create(name="editor")
self.assertEqual(
list(self.local_user.groups.values_list("name", flat=True)), []
)
view = views.UserAdmin.as_view()
request = self.factory.post("", {"groups": [group.id]})
request.user = self.local_user
request.user.is_superuser = True
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
result = view(request, self.local_user.id)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(
list(self.local_user.groups.values_list("name", flat=True)), ["editor"]
)

View file

@ -51,13 +51,20 @@ urlpatterns = [
r"^password-reset/(?P<code>[A-Za-z0-9]+)/?$", views.PasswordReset.as_view() r"^password-reset/(?P<code>[A-Za-z0-9]+)/?$", views.PasswordReset.as_view()
), ),
# admin # admin
re_path(r"^settings/site-settings", views.Site.as_view(), name="settings-site"), re_path(r"^settings/site-settings/?$", views.Site.as_view(), name="settings-site"),
re_path( re_path(
r"^settings/email-preview", r"^settings/email-preview/?$",
views.site.email_preview, views.site.email_preview,
name="settings-email-preview", name="settings-email-preview",
), ),
re_path(r"^settings/users", views.UserAdmin.as_view(), name="settings-users"), re_path(
r"^settings/users/?$", views.UserAdminList.as_view(), name="settings-users"
),
re_path(
r"^settings/users/(?P<user>\d+)/?$",
views.UserAdmin.as_view(),
name="settings-user",
),
re_path( re_path(
r"^settings/federation/?$", r"^settings/federation/?$",
views.Federation.as_view(), views.Federation.as_view(),
@ -113,9 +120,9 @@ urlpatterns = [
name="settings-report", name="settings-report",
), ),
re_path( re_path(
r"^settings/reports/(?P<report_id>\d+)/deactivate/?$", r"^settings/reports/(?P<user_id>\d+)/suspend/?$",
views.deactivate_user, views.suspend_user,
name="settings-report-deactivate", name="settings-report-suspend",
), ),
re_path( re_path(
r"^settings/reports/(?P<report_id>\d+)/resolve/?$", r"^settings/reports/(?P<report_id>\d+)/resolve/?$",

View file

@ -25,7 +25,7 @@ from .notifications import Notifications
from .outbox import Outbox from .outbox import Outbox
from .reading import edit_readthrough, create_readthrough, delete_readthrough from .reading import edit_readthrough, create_readthrough, delete_readthrough
from .reading import start_reading, finish_reading, delete_progressupdate from .reading import start_reading, finish_reading, delete_progressupdate
from .reports import Report, Reports, make_report, resolve_report, deactivate_user from .reports import Report, Reports, make_report, resolve_report, suspend_user
from .rss_feed import RssFeed from .rss_feed import RssFeed
from .password import PasswordResetRequest, PasswordReset, ChangePassword from .password import PasswordResetRequest, PasswordReset, ChangePassword
from .search import Search from .search import Search
@ -37,5 +37,5 @@ from .status import CreateStatus, DeleteStatus, DeleteAndRedraft
from .tag import Tag, AddTag, RemoveTag from .tag import Tag, AddTag, RemoveTag
from .updates import get_notification_count, get_unread_status_count from .updates import get_notification_count, get_unread_status_count
from .user import User, EditUser, Followers, Following from .user import User, EditUser, Followers, Following
from .user_admin import UserAdmin from .user_admin import UserAdmin, UserAdminList
from .wellknown import * from .wellknown import *

View file

@ -74,12 +74,13 @@ class Report(View):
@login_required @login_required
@permission_required("bookwyrm_moderate_user") @permission_required("bookwyrm_moderate_user")
def deactivate_user(_, report_id): def suspend_user(_, user_id):
""" mark an account as inactive """ """ mark an account as inactive """
report = get_object_or_404(models.Report, id=report_id) user = get_object_or_404(models.User, id=user_id)
report.user.is_active = not report.user.is_active user.is_active = not user.is_active
report.user.save() # this isn't a full deletion, so we don't want to tell the world
return redirect("settings-report", report.id) user.save(broadcast=False)
return redirect("settings-user", user.id)
@login_required @login_required

View file

@ -1,11 +1,12 @@
""" manage user """ """ manage user """
from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.decorators import login_required, permission_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.shortcuts import get_object_or_404
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views import View from django.views import View
from bookwyrm import models from bookwyrm import forms, models
from bookwyrm.settings import PAGE_LENGTH from bookwyrm.settings import PAGE_LENGTH
@ -15,7 +16,7 @@ from bookwyrm.settings import PAGE_LENGTH
permission_required("bookwyrm.moderate_users", raise_exception=True), permission_required("bookwyrm.moderate_users", raise_exception=True),
name="dispatch", name="dispatch",
) )
class UserAdmin(View): class UserAdminList(View):
""" admin view of users on this server """ """ admin view of users on this server """
def get(self, request): def get(self, request):
@ -49,4 +50,28 @@ class UserAdmin(View):
"sort": sort, "sort": sort,
"server": server, "server": server,
} }
return TemplateResponse(request, "settings/user_admin.html", data) return TemplateResponse(request, "user_admin/user_admin.html", data)
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.moderate_users", raise_exception=True),
name="dispatch",
)
class UserAdmin(View):
""" moderate an individual user """
def get(self, request, user):
""" user view """
user = get_object_or_404(models.User, id=user)
data = {"user": user, "group_form": forms.UserGroupForm()}
return TemplateResponse(request, "user_admin/user.html", data)
def post(self, request, user):
""" update user group """
user = get_object_or_404(models.User, id=user)
form = forms.UserGroupForm(request.POST, instance=user)
if form.is_valid():
form.save()
data = {"user": user, "group_form": form}
return TemplateResponse(request, "user_admin/user.html", data)