mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-04-21 16:21:32 +02:00
feat: user management screen; allow users to edit their profile
This commit is contained in:
@@ -2,16 +2,20 @@ from crispy_forms.bootstrap import (
|
||||
FormActions,
|
||||
)
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Submit
|
||||
from crispy_forms.layout import Layout, Submit, Row, Column, Field, Div
|
||||
from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.forms import (
|
||||
UsernameField,
|
||||
AuthenticationForm,
|
||||
UserCreationForm,
|
||||
)
|
||||
from django.db import transaction
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.users.models import UserSettings
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
|
||||
|
||||
class LoginForm(AuthenticationForm):
|
||||
@@ -139,3 +143,262 @@ class UserSettingsForm(forms.ModelForm):
|
||||
) % {
|
||||
"translation_link": '<a href="https://translations.herculino.com" target="_blank">translations.herculino.com</a>'
|
||||
}
|
||||
|
||||
|
||||
class UserUpdateForm(forms.ModelForm):
|
||||
new_password1 = forms.CharField(
|
||||
label=_("New Password"),
|
||||
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
|
||||
required=False,
|
||||
help_text=_("Leave blank to keep the current password."),
|
||||
)
|
||||
new_password2 = forms.CharField(
|
||||
label=_("Confirm New Password"),
|
||||
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = get_user_model()
|
||||
# Add the administrative fields
|
||||
fields = ["first_name", "last_name", "email", "is_active", "is_superuser"]
|
||||
# Help texts can be defined here or directly in the layout/field definition
|
||||
help_texts = {
|
||||
"is_active": _(
|
||||
"Designates whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
),
|
||||
"is_superuser": _(
|
||||
"Designates that this user has all permissions without explicitly assigning them."
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.instance = kwargs.get("instance") # Store instance for validation/checks
|
||||
self.requesting_user = get_current_user()
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
|
||||
# Define the layout using Crispy Forms, including the new fields
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column("first_name", css_class="form-group col-md-6"),
|
||||
Column("last_name", css_class="form-group col-md-6"),
|
||||
css_class="row",
|
||||
),
|
||||
Field("email"),
|
||||
# Group password fields (optional visual grouping)
|
||||
Div(
|
||||
Field("new_password1"),
|
||||
Field("new_password2"),
|
||||
css_class="border p-3 rounded mb-3",
|
||||
),
|
||||
# Group administrative status fields
|
||||
Div(
|
||||
Field("is_active"),
|
||||
Field("is_superuser"),
|
||||
css_class="border p-3 rounded mb-3 text-bg-secondary", # Example visual separation
|
||||
),
|
||||
)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
self.requesting_user == self.instance
|
||||
or not self.requesting_user.is_superuser
|
||||
):
|
||||
self.fields["is_superuser"].disabled = True
|
||||
self.fields["is_active"].disabled = True
|
||||
|
||||
# Keep existing clean methods
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data.get("email")
|
||||
# Use case-insensitive comparison for email uniqueness check
|
||||
if (
|
||||
self.instance
|
||||
and get_user_model()
|
||||
.objects.filter(email__iexact=email)
|
||||
.exclude(pk=self.instance.pk)
|
||||
.exists()
|
||||
):
|
||||
raise forms.ValidationError(
|
||||
_("This email address is already in use by another account.")
|
||||
)
|
||||
return email
|
||||
|
||||
def clean_new_password2(self):
|
||||
new_password1 = self.cleaned_data.get("new_password1")
|
||||
new_password2 = self.cleaned_data.get("new_password2")
|
||||
if new_password1 and new_password1 != new_password2:
|
||||
raise forms.ValidationError(_("The two password fields didn't match."))
|
||||
if new_password1 and not new_password2:
|
||||
raise forms.ValidationError(_("Please confirm your new password."))
|
||||
if new_password2 and not new_password1:
|
||||
raise forms.ValidationError(_("Please enter the new password first."))
|
||||
return new_password2
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
is_active_val = cleaned_data.get("is_active")
|
||||
is_superuser_val = cleaned_data.get("is_superuser")
|
||||
|
||||
# --- Crucial Security Check Example ---
|
||||
# Prevent the requesting user from deactivating or removing superuser status
|
||||
# from their *own* account via this form.
|
||||
if (
|
||||
self.requesting_user
|
||||
and self.instance
|
||||
and self.requesting_user.pk == self.instance.pk
|
||||
):
|
||||
# Check if 'is_active' field exists and user is trying to set it to False
|
||||
if "is_active" in self.fields and is_active_val is False:
|
||||
self.add_error(
|
||||
"is_active",
|
||||
_("You cannot deactivate your own account using this form."),
|
||||
)
|
||||
|
||||
# Check if 'is_superuser' field exists, the user *is* currently a superuser,
|
||||
# and they are trying to set it to False
|
||||
if (
|
||||
"is_superuser" in self.fields
|
||||
and self.instance.is_superuser
|
||||
and is_superuser_val is False
|
||||
):
|
||||
if get_user_model().objects.filter(is_superuser=True).count() <= 1:
|
||||
self.add_error(
|
||||
"is_superuser",
|
||||
_("Cannot remove status from the last superuser."),
|
||||
)
|
||||
else:
|
||||
self.add_error(
|
||||
"is_superuser",
|
||||
_(
|
||||
"You cannot remove your own superuser status using this form."
|
||||
),
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
# Save method remains the same, ModelForm handles boolean fields correctly
|
||||
def save(self, commit=True):
|
||||
user = super().save(commit=False)
|
||||
new_password = self.cleaned_data.get("new_password1")
|
||||
if new_password:
|
||||
user.set_password(new_password)
|
||||
|
||||
if commit:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
class UserAddForm(UserCreationForm):
|
||||
"""
|
||||
A form for administrators to create new users.
|
||||
Includes fields for first name, last name, email, active status,
|
||||
and superuser status. Uses email as the username field.
|
||||
Inherits password handling from UserCreationForm.
|
||||
"""
|
||||
|
||||
class Meta(UserCreationForm.Meta):
|
||||
model = get_user_model()
|
||||
# Specify the fields to include. UserCreationForm automatically handles
|
||||
# 'password1' and 'password2'. We replace 'username' with 'email'.
|
||||
fields = ("email", "first_name", "last_name", "is_active", "is_superuser")
|
||||
field_classes = {
|
||||
"email": forms.EmailField
|
||||
} # Ensure email field uses EmailField validation
|
||||
help_texts = {
|
||||
"is_active": _(
|
||||
"Designates whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
),
|
||||
"is_superuser": _(
|
||||
"Designates that this user has all permissions without explicitly assigning them."
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Set is_active to True by default for new users, can be overridden by admin
|
||||
self.fields["is_active"].initial = True
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
|
||||
# Define the layout, including password fields from UserCreationForm
|
||||
self.helper.layout = Layout(
|
||||
Field("email"),
|
||||
Row(
|
||||
Column("first_name", css_class="form-group col-md-6"),
|
||||
Column("last_name", css_class="form-group col-md-6"),
|
||||
css_class="row",
|
||||
),
|
||||
# UserCreationForm provides 'password1' and 'password2' fields
|
||||
Div(
|
||||
Field("password1", autocomplete="new-password"),
|
||||
Field("password2", autocomplete="new-password"),
|
||||
css_class="border p-3 rounded mb-3",
|
||||
),
|
||||
# Administrative status fields
|
||||
Div(
|
||||
Field("is_active"),
|
||||
Field("is_superuser"),
|
||||
css_class="border p-3 rounded mb-3 text-bg-secondary",
|
||||
),
|
||||
)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def clean_email(self):
|
||||
"""Ensure email uniqueness (case-insensitive)."""
|
||||
email = self.cleaned_data.get("email")
|
||||
if email and get_user_model().objects.filter(email__iexact=email).exists():
|
||||
raise forms.ValidationError(
|
||||
_("A user with this email address already exists.")
|
||||
)
|
||||
return email
|
||||
|
||||
@transaction.atomic # Ensure user creation is atomic
|
||||
def save(self, commit=True):
|
||||
"""
|
||||
Save the user instance. UserCreationForm's save handles password hashing.
|
||||
Our Meta class ensures other fields are included.
|
||||
"""
|
||||
user = super().save(commit=False)
|
||||
|
||||
if commit:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@@ -22,4 +22,24 @@ urlpatterns = [
|
||||
views.update_settings,
|
||||
name="user_settings",
|
||||
),
|
||||
path(
|
||||
"users/",
|
||||
views.users_index,
|
||||
name="users_index",
|
||||
),
|
||||
path(
|
||||
"users/list/",
|
||||
views.users_list,
|
||||
name="users_list",
|
||||
),
|
||||
path(
|
||||
"user/add/",
|
||||
views.user_add,
|
||||
name="user_add",
|
||||
),
|
||||
path(
|
||||
"user/<int:pk>/edit/",
|
||||
views.user_edit,
|
||||
name="user_edit",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth import logout, get_user_model
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.views import (
|
||||
LoginView,
|
||||
)
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.shortcuts import redirect, render, get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.common.decorators.user import is_superuser, htmx_login_required
|
||||
from apps.users.forms import (
|
||||
LoginForm,
|
||||
UserSettingsForm,
|
||||
UserUpdateForm,
|
||||
UserAddForm,
|
||||
)
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.users.models import UserSettings
|
||||
|
||||
|
||||
@@ -22,7 +27,7 @@ def logout_view(request):
|
||||
return redirect(reverse("login"))
|
||||
|
||||
|
||||
@login_required
|
||||
@htmx_login_required
|
||||
def index(request):
|
||||
if request.user.settings.start_page == UserSettings.StartPage.MONTHLY:
|
||||
return redirect(reverse("monthly_index"))
|
||||
@@ -49,7 +54,7 @@ class UserLoginView(LoginView):
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@htmx_login_required
|
||||
def toggle_amount_visibility(request):
|
||||
user_settings, created = UserSettings.objects.get_or_create(user=request.user)
|
||||
current_hide_amounts = user_settings.hide_amounts
|
||||
@@ -70,7 +75,7 @@ def toggle_amount_visibility(request):
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@htmx_login_required
|
||||
def toggle_sound_playing(request):
|
||||
user_settings, created = UserSettings.objects.get_or_create(user=request.user)
|
||||
current_mute_sounds = user_settings.mute_sounds
|
||||
@@ -91,7 +96,7 @@ def toggle_sound_playing(request):
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@htmx_login_required
|
||||
def update_settings(request):
|
||||
user_settings = request.user.settings
|
||||
|
||||
@@ -108,3 +113,85 @@ def update_settings(request):
|
||||
form = UserSettingsForm(instance=user_settings)
|
||||
|
||||
return render(request, "users/fragments/user_settings.html", {"form": form})
|
||||
|
||||
|
||||
@htmx_login_required
|
||||
@is_superuser
|
||||
@require_http_methods(["GET"])
|
||||
def users_index(request):
|
||||
return render(
|
||||
request,
|
||||
"users/pages/index.html",
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@htmx_login_required
|
||||
@is_superuser
|
||||
@require_http_methods(["GET"])
|
||||
def users_list(request):
|
||||
users = get_user_model().objects.all().order_by("id")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"users/fragments/list.html",
|
||||
{"users": users},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@htmx_login_required
|
||||
@is_superuser
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def user_add(request):
|
||||
if request.method == "POST":
|
||||
form = UserAddForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Item added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = UserAddForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"users/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@htmx_login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def user_edit(request, pk):
|
||||
user = get_object_or_404(get_user_model(), id=pk)
|
||||
|
||||
if not request.user.is_superuser and user != request.user:
|
||||
raise PermissionDenied
|
||||
|
||||
if request.method == "POST":
|
||||
form = UserUpdateForm(request.POST, instance=user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Item updated successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = UserUpdateForm(instance=user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"users/fragments/edit.html",
|
||||
{"form": form, "user": user},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user