diff --git a/app/apps/common/decorators/user.py b/app/apps/common/decorators/user.py new file mode 100644 index 0000000..17db393 --- /dev/null +++ b/app/apps/common/decorators/user.py @@ -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 diff --git a/app/apps/common/views.py b/app/apps/common/views.py index 172b433..2013e54 100644 --- a/app/apps/common/views.py +++ b/app/apps/common/views.py @@ -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") diff --git a/app/apps/users/forms.py b/app/apps/users/forms.py index d855d47..53e528b 100644 --- a/app/apps/users/forms.py +++ b/app/apps/users/forms.py @@ -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": 'translations.herculino.com' } + + +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 diff --git a/app/apps/users/urls.py b/app/apps/users/urls.py index 8135485..faf0b4f 100644 --- a/app/apps/users/urls.py +++ b/app/apps/users/urls.py @@ -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//edit/", + views.user_edit, + name="user_edit", + ), ] diff --git a/app/apps/users/views.py b/app/apps/users/views.py index e5a0b6f..293428a 100644 --- a/app/apps/users/views.py +++ b/app/apps/users/views.py @@ -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}, + ) diff --git a/app/templates/includes/navbar.html b/app/templates/includes/navbar.html index 013f8ae..4f8c9db 100644 --- a/app/templates/includes/navbar.html +++ b/app/templates/includes/navbar.html @@ -138,9 +138,13 @@ {% endif %}
  • {% translate 'Automatic Exchange Rates' %}
  • + {% if user.is_superuser %}
  • +
  • +
  • {% translate 'Users' %}
  • + {% endif %} diff --git a/app/templates/includes/navbar/user_menu.html b/app/templates/includes/navbar/user_menu.html index 1232b3e..def811a 100644 --- a/app/templates/includes/navbar/user_menu.html +++ b/app/templates/includes/navbar/user_menu.html @@ -12,6 +12,11 @@ hx-target="#generic-offcanvas" role="button"> {% translate 'Settings' %} +
  • + {% translate 'Edit profile' %}
  • {% spaceless %}
  • diff --git a/app/templates/users/fragments/add.html b/app/templates/users/fragments/add.html new file mode 100644 index 0000000..076227b --- /dev/null +++ b/app/templates/users/fragments/add.html @@ -0,0 +1,11 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Add user' %}{% endblock %} + +{% block body %} +
    + {% crispy form %} +
    +{% endblock %} diff --git a/app/templates/users/fragments/edit.html b/app/templates/users/fragments/edit.html new file mode 100644 index 0000000..a5283d6 --- /dev/null +++ b/app/templates/users/fragments/edit.html @@ -0,0 +1,11 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Edit user' %}{% endblock %} + +{% block body %} +
    + {% crispy form %} +
    +{% endblock %} diff --git a/app/templates/users/fragments/list.html b/app/templates/users/fragments/list.html new file mode 100644 index 0000000..dc1f205 --- /dev/null +++ b/app/templates/users/fragments/list.html @@ -0,0 +1,85 @@ +{% load hijack %} +{% load i18n %} +
    +
    + {% spaceless %} +
    {% translate 'Users' %} + + +
    + {% endspaceless %} +
    + +
    +
    +
    + {% if users %} +
    + + + + + + + + + + + + + {% for user in users %} + + + + + + + + {% endfor %} + +
    {% translate 'Active' %}{% translate 'Name' %}{% translate 'Email' %}{% translate 'Superuser' %}
    +
    + + + {% if request.user|can_hijack:user and request.user != user %} + + + {% endif %} +
    +
    + {% if user.is_active %} + + {% endif %} + {{ user.first_name }} {{ user.last_name }}{{ user.email }} + {% if user.is_superuser %} + + {% endif %} +
    +
    + {% else %} + + {% endif %} +
    +
    +
    +
    diff --git a/app/templates/users/pages/index.html b/app/templates/users/pages/index.html new file mode 100644 index 0000000..9bb10f3 --- /dev/null +++ b/app/templates/users/pages/index.html @@ -0,0 +1,8 @@ +{% extends "layouts/base.html" %} +{% load i18n %} + +{% block title %}{% translate 'Users' %}{% endblock %} + +{% block content %} +
    +{% endblock %}