From 24896aaecda69a7de439c903afd5ceee21f3a880 Mon Sep 17 00:00:00 2001 From: catborise Date: Sun, 25 Oct 2026 22:26:23 +0300 Subject: [PATCH] feat(admin): a password an administrator sets can be one the user must change The administrator's password form has "The user must change it at their next login". With it, the user is sent to the change form until they choose their own, as the generated admin password already does. --- accounts/middleware.py | 3 ++- admin/forms.py | 10 ++++++++++ admin/tests/test_user_pages.py | 18 ++++++++++++++++-- admin/views.py | 9 +++++---- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/accounts/middleware.py b/accounts/middleware.py index 115d91b6..829ba53c 100644 --- a/accounts/middleware.py +++ b/accounts/middleware.py @@ -7,7 +7,8 @@ ALLOWED_VIEWS = {"accounts:change_password", "accounts:logout", "accounts:login" class ForcePasswordChangeMiddleware: - """Send users who still have the generated admin password to the change form.""" + """Send users who must change their password (the generated admin one, or one + an administrator set) to the change form.""" def __init__(self, get_response): self.get_response = get_response diff --git a/admin/forms.py b/admin/forms.py index 63f523ac..8784fdc0 100644 --- a/admin/forms.py +++ b/admin/forms.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.auth import password_validation +from django.contrib.auth.forms import AdminPasswordChangeForm from django.contrib.auth.models import Group, User from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ @@ -123,3 +124,12 @@ class UserAttributesForm(forms.ModelForm): class Meta: model = UserAttributes exclude = ["user", "can_clone_instances", "must_change_password"] + + +class AdminPasswordForm(AdminPasswordChangeForm): + """A password an administrator sets; the user may have to change it at their next login.""" + must_change_password = forms.BooleanField(label=_("The user must change it at their next login"), required=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["must_change_password"] = self.fields.pop("must_change_password") # last, after Django's own diff --git a/admin/tests/test_user_pages.py b/admin/tests/test_user_pages.py index d9fa6512..3220c2f5 100644 --- a/admin/tests/test_user_pages.py +++ b/admin/tests/test_user_pages.py @@ -1,5 +1,6 @@ -"""The Users pages: an administrator cannot lock their own account out, and -the user form says what its fields mean.""" +"""The Users pages: an administrator cannot lock their own account out, the +user form says what its fields mean, and a password set by an administrator +can be one the user must change.""" from accounts.models import UserAttributes from django.contrib.auth import get_user_model @@ -65,3 +66,16 @@ class UserPagesTests(TestCase): page = html.fromstring(self.client.get(reverse("admin:user_list")).content) (row,) = [r for r in page.xpath("//tbody/tr") if r.xpath("td")[0].text_content().strip() == "up-alice"] self.assertEqual(row.xpath("td")[2].text_content().strip(), "never") + + def test_a_password_set_by_an_administrator_can_be_one_to_change(self): + url = reverse("admin:user_update_password", args=[self.alice.pk]) + self.client.post(url, {"password1": "New-pw-4567x", "password2": "New-pw-4567x", "must_change_password": "on"}) + self.assertTrue(UserAttributes.objects.get(user=self.alice).must_change_password) + # the user is sent to the change form until they choose their own + self.alice.refresh_from_db() # the new password's hash, or the session is not hers + self.client.force_login(self.alice) + self.assertRedirects(self.client.get(reverse("instances:index")), reverse("accounts:change_password"), + fetch_redirect_response=False) + self.client.force_login(self.admin) + self.client.post(url, {"password1": "New-pw-4567y", "password2": "New-pw-4567y"}) + self.assertFalse(UserAttributes.objects.get(user=self.alice).must_change_password) diff --git a/admin/views.py b/admin/views.py index 74d3b063..10ec4156 100644 --- a/admin/views.py +++ b/admin/views.py @@ -5,7 +5,6 @@ from appsettings.settings import app_settings from django.conf import settings from django.contrib import messages from django.contrib.auth import update_session_auth_hash -from django.contrib.auth.forms import AdminPasswordChangeForm from django.contrib.auth.models import Group, User from django.core.paginator import Paginator from django.db import transaction @@ -185,10 +184,12 @@ def user_update(request, pk): def user_update_password(request, pk): user = get_object_or_404(User, pk=pk) if request.method == "POST": - form = AdminPasswordChangeForm(user, request.POST) + form = forms.AdminPasswordForm(user, request.POST) if form.is_valid(): - with audit(request, None, f"User {user.username}", "set password"): + with audit(request, None, f"User {user.username}", "set password"), transaction.atomic(): user = form.save() + UserAttributes.objects.update_or_create( + user=user, defaults={"must_change_password": form.cleaned_data["must_change_password"]}) update_session_auth_hash(request, user) # Important! messages.success( request, _("Password changed for %(user)s") % {"user": user.username} @@ -197,7 +198,7 @@ def user_update_password(request, pk): else: messages.error(request, _("Wrong Data Provided")) else: - form = AdminPasswordChangeForm(user) + form = forms.AdminPasswordForm(user) return render( request,