From 06d76a0bd4a65459e443a13471e2b7cace21d5f8 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 15 Apr 2024 15:42:37 +0200 Subject: [PATCH] Add new user list for administrators --- doc/changelog.rst | 1 + .../templates/orga/{ => admin}/admin.html | 0 .../templates/orga/admin/user_delete.html | 19 +++ .../templates/orga/admin/user_detail.html | 119 ++++++++++++++++++ .../orga/templates/orga/admin/user_list.html | 89 +++++++++++++ src/pretalx/orga/templates/orga/base.html | 25 +++- src/pretalx/orga/urls.py | 11 ++ src/pretalx/orga/views/admin.py | 109 +++++++++++++++- 8 files changed, 366 insertions(+), 7 deletions(-) rename src/pretalx/orga/templates/orga/{ => admin}/admin.html (100%) create mode 100644 src/pretalx/orga/templates/orga/admin/user_delete.html create mode 100644 src/pretalx/orga/templates/orga/admin/user_detail.html create mode 100644 src/pretalx/orga/templates/orga/admin/user_list.html diff --git a/doc/changelog.rst b/doc/changelog.rst index 6c27208bf0..d734c3fbbe 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -3,6 +3,7 @@ Release Notes ============= +- :feature:`orga` Administrators (i.e. instance owners) can now search a list of all users, which includes their teams and permissions, and links to trigger account deletion and password resets. - :bug:`orga:review` Assigning reviewers could lead to incorrect assignments when browsers cached the form, but new reviewers were added to the team, shifting the overall order of input fields. - :feature:`cfp` Choice and multiple choice questions now use a drop-down with typeahead (search for options) when they have a lot of options. - :feature:`orga,1079` All images in forms in the organiser area now include a preview of the saved image, and open a lightbox instead of the image file when clicked. diff --git a/src/pretalx/orga/templates/orga/admin.html b/src/pretalx/orga/templates/orga/admin/admin.html similarity index 100% rename from src/pretalx/orga/templates/orga/admin.html rename to src/pretalx/orga/templates/orga/admin/admin.html diff --git a/src/pretalx/orga/templates/orga/admin/user_delete.html b/src/pretalx/orga/templates/orga/admin/user_delete.html new file mode 100644 index 0000000000..53e3bb039e --- /dev/null +++ b/src/pretalx/orga/templates/orga/admin/user_delete.html @@ -0,0 +1,19 @@ +{% extends "orga/base.html" %} +{% load i18n %} +{% block content %} +

+ {% translate "Do you really want to delete this user?" %} +

+ {{ quotation_close }}{{ object.name }}{{ quotation_open }} – {{ object.text }}

+

+ {% csrf_token %} +
+ + {% translate "Back" %} + + +
+
+{% endblock %} diff --git a/src/pretalx/orga/templates/orga/admin/user_detail.html b/src/pretalx/orga/templates/orga/admin/user_detail.html new file mode 100644 index 0000000000..2685ab8316 --- /dev/null +++ b/src/pretalx/orga/templates/orga/admin/user_detail.html @@ -0,0 +1,119 @@ +{% extends "orga/base.html" %} +{% load bootstrap4 %} +{% load copyable %} +{% load i18n %} +{% load url_replace %} + +{% block title %}{{ user.name }}{% endblock %} + +{% block content %} +

{{ user.name }} +
+ {% csrf_token %} + + +
+

+ +
+ + + + + + + + + + + + + + + + + + + + + +
{% translate "Email" %}{{ user.email|copyable }}
{% translate "Last login" %}{{ user.last_login|default:'-' }}
{% translate "Password reset time" %}{{ user.pw_reset_time|default:'-' }}
{% translate "Language" %}{{ user.locale }}
{% translate "Timezone" %}{{ user.timezone }}
+
+ +
+
+ +

{% translate "Teams" %}

+
+ + + + + + + + + + + {% for team in user.teams.all %} + + + + + + {% empty %} + + + + {% endfor %} + +
{% translate "Team" %}{% translate "Organiser" %}{% translate "Events" %}{% translate "Permissions" %}
{{ team.name }}{{ team.organiser.name }} + {% if team.limit_events.all %} + {% for event in team.limit_events.all %} + {{ event.name }}{% if not forloop.last %},{% endif %} + {% endfor %} + {% else %} + {% translate "All events" %}: + {% for event in team.organiser.events.all %} + {{ event.name }}{% if not forloop.last %},{% endif %} + {% endfor %} + {% endif %} + + {{ team.permission_set }}
{% translate "User isn't in any teams" %}
+
+ +
+ +

{% translate "Proposals" %}

+ +
+ + + + + + + + + + + {% for submission in submissions %} + + + + + + + {% empty %} + + + + {% endfor %} + +
{% translate "Title" %}{% translate "Event" %}{% translate "State" %}{% translate "Submitted" %}
{{ submission.title }}{{ submission.event.name }}{% include "cfp/event/fragment_state.html" with state=submission.state %}{{ submission.created | date:"SHORT_DATE_FORMAT" }}
{% translate "User hasn't submitted any proposals" %}
+
+ +{% endblock %} diff --git a/src/pretalx/orga/templates/orga/admin/user_list.html b/src/pretalx/orga/templates/orga/admin/user_list.html new file mode 100644 index 0000000000..5978c6b54c --- /dev/null +++ b/src/pretalx/orga/templates/orga/admin/user_list.html @@ -0,0 +1,89 @@ +{% extends "orga/base.html" %} +{% load bootstrap4 %} +{% load copyable %} +{% load i18n %} +{% load url_replace %} + +{% block title %}{% translate "Users" %}{% endblock %} + +{% block content %} +

{% translate "Users" %}

+ +
+ +
+ +
{% csrf_token %} +
+ + + + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + + + {% endfor %} + +
+ {% translate "Name" %} + + {% translate "Email" %} + + {% translate "Language" %} + + {% translate "Teams" %} + + {% translate "Events" %} + + {% translate "Submissions" %} + + {% translate "Last login" %} + + {% translate "Password reset time" %} +
+ {{ user.name }} + {{ user.email|copyable }}{{ user.locale }} + + + + {{ user.submission_count }}{{ user.last_login|timesince }} {% if user.last_login %}ago{% endif %}{{ user.pw_reset_time|timesince }}{% if user.pw_reset_time %} ago{% endif %} +
+ + +
+ +
+ +
+ {% include "orga/includes/pagination.html" %} +{% endblock %} + + diff --git a/src/pretalx/orga/templates/orga/base.html b/src/pretalx/orga/templates/orga/base.html index b7a3fe058b..285220c0a8 100644 --- a/src/pretalx/orga/templates/orga/base.html +++ b/src/pretalx/orga/templates/orga/base.html @@ -405,10 +405,27 @@ {% endif %} {% if request.user.is_administrator %} - - - {% translate "Admin information" %} - + {% endif %} {% for nav_element in nav_global %} {% include "orga/includes/sidebar_nav.html" %} diff --git a/src/pretalx/orga/urls.py b/src/pretalx/orga/urls.py index e225a74f8b..b0440bfcd1 100644 --- a/src/pretalx/orga/urls.py +++ b/src/pretalx/orga/urls.py @@ -29,6 +29,17 @@ path("", RedirectView.as_view(url="event", permanent=False)), path("admin/", admin.AdminDashboard.as_view(), name="admin.dashboard"), path("admin/update/", admin.UpdateCheckView.as_view(), name="admin.update"), + path( + "admin/users//", + admin.AdminUserDetail.as_view(), + name="admin.user.view", + ), + path( + "admin/users//delete/", + admin.AdminUserDelete.as_view(), + name="admin.user.delete", + ), + path("admin/users/", admin.AdminUserList.as_view(), name="admin.user.list"), path("me", event.UserSettings.as_view(), name="user.view"), path("me/subuser", person.SubuserView.as_view(), name="user.subuser"), path( diff --git a/src/pretalx/orga/views/admin.py b/src/pretalx/orga/views/admin.py index 7c5c14ddf9..40fd6083bf 100644 --- a/src/pretalx/orga/views/admin.py +++ b/src/pretalx/orga/views/admin.py @@ -1,22 +1,32 @@ import sys +from csp.decorators import csp_update from django.conf import settings from django.contrib import messages +from django.db.models import Count, Q from django.shortcuts import redirect from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from django.views.generic import FormView, TemplateView +from django.views.generic import ( + DeleteView, + DetailView, + FormView, + ListView, + TemplateView, +) from django_context_decorator import context +from django_scopes import scopes_disabled from pretalx.celery_app import app from pretalx.common.mixins.views import PermissionRequired from pretalx.common.models.settings import GlobalSettings from pretalx.common.update_check import check_result_table, update_check from pretalx.orga.forms.admin import UpdateSettingsForm +from pretalx.person.models import User class AdminDashboard(PermissionRequired, TemplateView): - template_name = "orga/admin.html" + template_name = "orga/admin/admin.html" permission_required = "person.is_administrator" @context @@ -39,7 +49,7 @@ def pretalx_version(self): class UpdateCheckView(PermissionRequired, FormView): - template_name = "orga/update.html" + template_name = "orga/admin/update.html" permission_required = "person.is_administrator" form_class = UpdateSettingsForm @@ -72,3 +82,96 @@ def result_table(self): def get_success_url(self): return reverse("orga:admin.update") + + +class AdminUserList(PermissionRequired, ListView): + template_name = "orga/admin/user_list.html" + permission_required = "person.is_administrator" + model = User + context_object_name = "users" + paginate_by = "250" + + def dispatch(self, *args, **kwargs): + with scopes_disabled(): + return super().dispatch(*args, **kwargs) + + def get_queryset(self): + search = self.request.GET.get("q", "").strip() + if not search or len(search) < 3: + return User.objects.none() + return ( + User.objects.filter(Q(name__icontains=search) | Q(email__icontains=search)) + .prefetch_related( + "teams", + "teams__organiser", + "teams__organiser__events", + "teams__limit_events", + ) + .annotate( + submission_count=Count("submissions", distinct=True), + ) + ) + + def post(self, request, *args, **kwargs): + action = request.POST.get("action") or "-" + action, user_id = action.split("-") + user = User.objects.get(pk=user_id) + if action == "reset": + user.reset_password(event=None) + messages.success(request, _("The password was reset.")) + elif action == "delete": + return redirect(reverse("orga:admin.user.delete", kwargs={"pk": user.pk})) + return super().get(request, *args, **kwargs) + + +class AdminUserDetail(PermissionRequired, DetailView): + template_name = "orga/admin/user_detail.html" + permission_required = "person.is_administrator" + model = User + context_object_name = "user" + slug_url_kwarg = "code" + slug_field = "code" + + @csp_update(IMG_SRC="https://www.gravatar.com") + def dispatch(self, *args, **kwargs): + with scopes_disabled(): + return super().dispatch(*args, **kwargs) + + def post(self, request, *args, **kwargs): + action = request.POST.get("action") or "-" + if action == "pw-reset": + self.get_object().reset_password(event=None) + messages.success(request, _("The password was reset.")) + return redirect(self.get_success_url()) + + def get_success_url(self): + return reverse("orga:admin.user.list") + + def get_context_data(self, **kwargs): + result = super().get_context_data(**kwargs) + result["teams"] = self.object.teams.all().prefetch_related( + "organiser", "limit_events", "organiser__events" + ) + result["submissions"] = self.object.submissions.all() + return result + + +class AdminUserDelete(PermissionRequired, DeleteView): + template_name = "orga/admin/user_delete.html" + permission_required = "person.is_administrator" + model = User + context_object_name = "user" + slug_url_kwarg = "code" + slug_field = "code" + + def dispatch(self, *args, **kwargs): + with scopes_disabled(): + return super().dispatch(*args, **kwargs) + + def post(self, request, *args, **kwargs): + self.get_object().shred() + messages.success(request, _("The user has been deleted.")) + return redirect(self.get_success_url()) + + def get_success_url(self): + return reverse("orga:admin.user.list")