Users: some interface to edit/view them

This commit is contained in:
Klaas van Schelven
2024-10-02 15:40:30 +02:00
parent 8ee526776e
commit 8c09c68ecd
8 changed files with 215 additions and 7 deletions

View File

@@ -34,6 +34,9 @@ urlpatterns = [
path("accounts/preferences/", preferences, name="preferences"),
# many user-related views are directly exposed above (/accounts/), the rest is here:
path("users/", include("users.urls")),
path('api/', include('ingest.urls')),
path('projects/', include('projects.urls')),

View File

@@ -31,6 +31,10 @@
<a href="/admin/"><div class="px-4 py-2 my-2 hover:bg-slate-300 rounded-xl">Admin</div></a>
{% endif %}
{% if user.is_superuser %}
<a href="/users/"><div class="px-4 py-2 my-2 hover:bg-slate-300 rounded-xl">Users</div></a>
{% endif %}
{% if logged_in_user.is_anonymous %}
<a href="/accounts/login/"><div class="px-4 py-2 my-2 hover:bg-slate-300 rounded-xl">Login</div></a> {# I don't think this is actually ever shown in practice, because you must always be logged in #}
{% else %}

View File

@@ -26,7 +26,6 @@ UserModel = get_user_model()
class UserCreationForm(BaseUserCreationForm):
# Our UserCreationForm is the place where the "use email for usernames" logic is implemented.
# We could instead push such logic in the model, and do it more thoroughly (i.e. remove either field, and point the
# USERNAME_FIELD to the other). But I'm not sure that this is the most future-proof way forward. In particular,
@@ -82,6 +81,34 @@ class UserCreationForm(BaseUserCreationForm):
return user
class UserEditForm(ModelForm):
# See notes in UserCreationForm about the "use email for usernames" logic; it's the same here.
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['username'].validators = [EmailValidator()]
self.fields['username'].label = "Email"
self.fields['username'].help_text = None # "Email" is descriptive enough
class Meta:
model = UserModel
fields = ("username",)
def clean_username(self):
if UserModel.objects.exclude(pk=self.instance.pk).filter(username=self.cleaned_data['username']).exists():
raise ValidationError(mark_safe("This email is already registered by another user."))
return self.cleaned_data['username']
def save(self, **kwargs):
commit = kwargs.pop("commit", True)
user = super().save(commit=False)
user.email = user.username
if commit:
user.save()
return user
class ResendConfirmationForm(forms.Form):
email = forms.EmailField()

View File

@@ -22,7 +22,7 @@
<input type="hidden" name="next" value="{{ next }}" />
<button class="bg-slate-800 font-medium p-2 md:p-4 text-white uppercase w-full">Sign up</button>
<button class="bg-slate-800 font-medium p-2 md:p-4 text-white uppercase w-full">Reset password</button>
</form>

View File

@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% load static %}
{% load tailwind_forms %}
{% block title %}Edit {{ form.instance.username }} · {{ site_title }}{% endblock %}
{% block content %}
<div class="flex items-center justify-center">
<div class="m-4 max-w-4xl flex-auto">
<form action="." method="post">
{% csrf_token %}
<div>
<h1 class="text-4xl my-4 font-bold">{{ form.instance.username }}</h1>
</div>
<div class="mt-4 mb-4">
Settings for "{{ form.instance.username }}".
</div>
{% tailwind_formfield form.username %}
<button name="action" value="invite" class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md">Save</button>
<a href="{% url "user_list" %}" class="text-cyan-500 font-bold ml-2">Cancel</a>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Users · {{ site_title }}{% endblock %}
{% block content %}
<div class="flex items-center justify-center">
<div class="m-4 max-w-4xl flex-auto">
{% if messages %}
<ul class="mb-4">
{% for message in messages %}
{# if we introduce different levels we can use{% message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} #}
<li class="bg-cyan-50 border-2 border-cyan-800 p-4 rounded-lg">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<div class="flex">
<h1 class="text-4xl mt-4 font-bold">Users</h1>
{% comment %}
Our current invite-system is tied to either a team or a project; no "global" invites (yet).
<div class="ml-auto mt-6">
<a class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md" href="{% url "team_members_invite" team_pk=team.pk %}">Invite Member</a>
</div>
{% endcomment %}
</div>
<div>
<form action="." method="post">
{% csrf_token %}
<table class="w-full mt-8">
<tbody>
<thead>
<tr class="bg-slate-200">
<th class="w-full p-4 text-left text-xl" colspan="2">Users</th>
</tr>
{% for user in users %}
<tr class="bg-white border-slate-200 border-b-2">
<td class="w-full p-4">
<div>
<a href="{% url "user_edit" user_pk=user.pk %}" class="text-xl text-cyan-500 font-bold">{{ user.username }}</a> {# "best name" perhaps later? #}
{# <span class="bg-slate-100 rounded-2xl px-4 py-2 ml-2 text-sm">Invitation pending</span> #} {# perhaps useful for "not active"? #}
{% if member.is_superuser %}
<span class="bg-cyan-100 rounded-2xl px-4 py-2 ml-2 text-sm">Superuser</span>
{% endif %}
</div>
</td>
<td class="p-4">
<div class="flex justify-end">
{% if not request.user == user %}
{% if user.is_active %}
<button name="action" value="deactivate:{{ user.id }}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-2 border-2 hover:bg-slate-200 active:ring rounded-md">Deactivate</button>
{% else %}
<button name="action" value="activate:{{ user.id }}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-2 border-2 hover:bg-slate-200 active:ring rounded-md">Activate</button>
{% endif %}
{% endif %}
</div>
</td>
</tr>
{% endfor %}
{#% empty %} not needed, a site without users cannot be visited by a user #}
</tbody>
</table>
</form>
</div>
{% comment %}
<div class="flex flex-direction-row">
<div class="ml-auto py-8 pr-4">
<a href="{% url "..." %}" class="text-cyan-500 font-bold">Back to Xxxx</a> {# perhaps once this is part of some other flow #}
</div>
</div>
{% endcomment %}
</div>
{% endblock %}

8
users/urls.py Normal file
View File

@@ -0,0 +1,8 @@
from django.urls import path
from .views import user_list, user_edit
urlpatterns = [
path('', user_list, name="user_list"),
path('<str:user_pk>/edit/', user_edit, name="user_edit"),
]

View File

@@ -6,12 +6,13 @@ from django.contrib.auth import get_user_model
from django.http import Http404
from django.utils import timezone
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.decorators import login_required, user_passes_test
from bugsink.app_settings import get_settings, CB_ANYBODY
from bugsink.decorators import atomic_for_request_method
from .forms import UserCreationForm, ResendConfirmationForm, RequestPasswordResetForm, SetPasswordForm, PreferencesForm
from .forms import (
UserCreationForm, ResendConfirmationForm, RequestPasswordResetForm, SetPasswordForm, PreferencesForm, UserEditForm)
from .models import EmailVerification
from .tasks import send_confirm_email, send_reset_email
@@ -19,6 +20,52 @@ from .tasks import send_confirm_email, send_reset_email
UserModel = get_user_model()
@atomic_for_request_method
@user_passes_test(lambda u: u.is_superuser)
def user_list(request):
users = UserModel.objects.all().order_by('username')
if request.method == 'POST':
full_action_str = request.POST.get('action')
action, user_pk = full_action_str.split(":", 1)
if action == "deactivate":
user = UserModel.objects.get(pk=user_pk)
user.is_active = False
user.save()
messages.success(request, 'User %s deactivated' % user.username)
return redirect('user_list')
if action == "activate":
user = UserModel.objects.get(pk=user_pk)
user.is_active = True
user.save()
messages.success(request, 'User %s activated' % user.username)
return redirect('user_list')
return render(request, 'users/user_list.html', {
'users': users,
})
@atomic_for_request_method
@user_passes_test(lambda u: u.is_superuser)
def user_edit(request, user_pk):
user = UserModel.objects.get(pk=user_pk)
if request.method == 'POST':
form = UserEditForm(request.POST, instance=user)
if form.is_valid():
form.save()
return redirect("user_list")
else:
form = UserEditForm(instance=user)
return render(request, "users/user_edit.html", {"form": form})
@atomic_for_request_method
def signup(request):
if get_settings().USER_REGISTRATION != CB_ANYBODY:
@@ -62,7 +109,7 @@ def confirm_email(request, token=None):
if request.method == 'POST':
# We insist on POST requests to do the actual confirmation (at the cost of an extra click). See:
# https://softwareengineering.stackexchange.com/a/422579/168778
# there's no form, the'res just a button to generate the post request
# there's no Django form (fields), there's just a button to generate the post request
verification.user.is_active = True
verification.user.save()
@@ -159,8 +206,8 @@ def reset_password(request, token=None):
@atomic_for_request_method
# in the general case this is done by Middleware but we're under /accounts/. not security-critical because we simply
# get a failure on request.user if this wasn't there, but still the "right thing"
# in the general case this is done by Middleware but we're under /accounts/, so we need it back.
# not security-critical because we simply get a failure on request.user if this wasn't there, but still the right thing.
@login_required
def preferences(request):
user = request.user