Merge pull request #240

feat: user management screen; allow users to edit their profile
This commit is contained in:
Herculino Trotta
2025-04-13 19:00:43 -03:00
committed by GitHub
11 changed files with 583 additions and 9 deletions

View File

@@ -0,0 +1,78 @@
from functools import wraps
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import redirect
from django.urls import reverse, NoReverseMatch
def is_superuser(view):
@wraps(view)
def _view(request, *args, **kwargs):
if not request.user.is_superuser:
raise PermissionDenied
return view(request, *args, **kwargs)
return _view
def htmx_login_required(function=None, login_url=None):
"""
Decorator that checks if the user is logged in.
Allows overriding the default login URL.
If the user is not logged in:
- If "hx-request" is present in the request header, it returns a 200 response
with a "HX-Redirect" header containing the determined login URL (including the "next" parameter).
- If "hx-request" is not present, it redirects to the determined login page normally.
Args:
function: The view function to decorate.
login_url: Optional. The URL or URL name to redirect to for login.
Defaults to settings.LOGIN_URL.
"""
def decorator(view_func):
# Simplified @wraps usage - it handles necessary attribute assignments by default
@wraps(view_func)
def wrapped_view(request, *args, **kwargs):
if request.user.is_authenticated:
return view_func(request, *args, **kwargs)
else:
# Determine the login URL
resolved_login_url = login_url
if not resolved_login_url:
resolved_login_url = settings.LOGIN_URL
# Try to reverse the URL name if it's not a path
try:
# Check if it looks like a URL path already
if "/" not in resolved_login_url and "." not in resolved_login_url:
login_url_path = reverse(resolved_login_url)
else:
login_url_path = resolved_login_url
except NoReverseMatch:
# If reverse fails, assume it's already a URL path
login_url_path = resolved_login_url
# Construct the full redirect path with 'next' parameter
# Ensure request.path is URL-encoded if needed, though Django usually handles this
redirect_path = f"{login_url_path}?next={request.get_full_path()}" # Use get_full_path() to include query params
if request.headers.get("hx-request"):
# For HTMX requests, return a 200 with the HX-Redirect header.
response = HttpResponse()
response["HX-Redirect"] = login_url_path
return response
else:
# For regular requests, redirect to the login page.
return redirect(redirect_path)
return wrapped_view
if function:
return decorator(function)
return decorator

View File

@@ -15,10 +15,11 @@ from cachalot.api import invalidate
from apps.common.decorators.htmx import only_htmx
from apps.transactions.models import Transaction
from apps.common.decorators.user import htmx_login_required
@only_htmx
@login_required
@htmx_login_required
@require_http_methods(["GET"])
def toasts(request):
return render(request, "common/fragments/toasts.html")

View File

@@ -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

View File

@@ -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",
),
]

View File

@@ -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},
)

View File

@@ -138,9 +138,13 @@
{% endif %}
<li><a class="dropdown-item {% active_link views='automatic_exchange_rates_index' %}"
href="{% url 'automatic_exchange_rates_index' %}">{% translate 'Automatic Exchange Rates' %}</a></li>
{% if user.is_superuser %}
<li>
<hr class="dropdown-divider">
</li>
<li><h6 class="dropdown-header">{% trans 'Admin' %}</h6></li>
<li><a class="dropdown-item {% active_link views='users_index' %}"
href="{% url 'users_index' %}">{% translate 'Users' %}</a></li>
<li>
<a class="dropdown-item"
href="{% url 'admin:index' %}"
@@ -151,6 +155,7 @@
{% translate 'Django Admin' %}
</a>
</li>
{% endif %}
</ul>
</li>
</ul>

View File

@@ -12,6 +12,11 @@
hx-target="#generic-offcanvas"
role="button">
<i class="fa-solid fa-gear me-2 fa-fw"></i>{% translate 'Settings' %}</a></li>
<li><a class="dropdown-item"
hx-get="{% url 'user_edit' pk=request.user.id %}"
hx-target="#generic-offcanvas"
role="button">
<i class="fa-solid fa-user me-2 fa-fw"></i>{% translate 'Edit profile' %}</a></li>
<li><hr class="dropdown-divider"></li>
{% spaceless %}
<li>

View File

@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Add user' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'user_add' %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Edit user' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'user_edit' pk=user.id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,85 @@
{% load hijack %}
{% load i18n %}
<div class="container px-md-3 py-3 column-gap-5">
<div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
{% spaceless %}
<div>{% translate 'Users' %}<span>
<a class="text-decoration-none tw-text-2xl p-1 category-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Add" %}"
hx-get="{% url 'user_add' %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-circle-plus fa-fw"></i></a>
</span></div>
{% endspaceless %}
</div>
<div class="card">
<div class="card-body">
<div id="tags-table">
{% if users %}
<div class="table-responsive">
<c-config.search></c-config.search>
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Active' %}</th>
<th scope="col" class="col">{% translate 'Name' %}</th>
<th scope="col" class="col">{% translate 'Email' %}</th>
<th scope="col" class="col">{% translate 'Superuser' %}</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr class="tag">
<td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'user_edit' pk=user.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
{% if request.user|can_hijack:user and request.user != user %}
<a class="btn btn-info btn-sm"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Impersonate" %}"
hx-post="{% url 'hijack:acquire' %}"
hx-vals='{"user_pk":"{{user.id}}"}'
hx-swap="none"
_="on htmx:afterRequest(event) from me
if event.detail.successful
go to url '/'">
<i class="fa-solid fa-mask fa-fw"></i></a>
{% endif %}
</div>
</td>
<td class="col">
{% if user.is_active %}
<i class="fa-solid fa-solid fa-check text-success"></i>
{% endif %}
</td>
<td class="col">{{ user.first_name }} {{ user.last_name }}</td>
<td class="col">{{ user.email }}</td>
<td class="col">
{% if user.is_superuser %}
<i class="fa-solid fa-solid fa-check text-success"></i>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No users" %}" remove-padding></c-msg.empty>
{% endif %}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Users' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'users_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
{% endblock %}