mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-07-08 22:05:11 +02:00
Merge pull request #240
feat: user management screen; allow users to edit their profile
This commit is contained in:
@@ -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
|
||||||
@@ -15,10 +15,11 @@ from cachalot.api import invalidate
|
|||||||
|
|
||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
from apps.transactions.models import Transaction
|
from apps.transactions.models import Transaction
|
||||||
|
from apps.common.decorators.user import htmx_login_required
|
||||||
|
|
||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@htmx_login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def toasts(request):
|
def toasts(request):
|
||||||
return render(request, "common/fragments/toasts.html")
|
return render(request, "common/fragments/toasts.html")
|
||||||
|
|||||||
+264
-1
@@ -2,16 +2,20 @@ from crispy_forms.bootstrap import (
|
|||||||
FormActions,
|
FormActions,
|
||||||
)
|
)
|
||||||
from crispy_forms.helper import FormHelper
|
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 import forms
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.forms import (
|
from django.contrib.auth.forms import (
|
||||||
UsernameField,
|
UsernameField,
|
||||||
AuthenticationForm,
|
AuthenticationForm,
|
||||||
|
UserCreationForm,
|
||||||
)
|
)
|
||||||
|
from django.db import transaction
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||||
from apps.users.models import UserSettings
|
from apps.users.models import UserSettings
|
||||||
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
|
|
||||||
|
|
||||||
class LoginForm(AuthenticationForm):
|
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>'
|
"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,
|
views.update_settings,
|
||||||
name="user_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",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
+94
-7
@@ -1,19 +1,24 @@
|
|||||||
from django.contrib import messages
|
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.decorators import login_required
|
||||||
from django.contrib.auth.views import (
|
from django.contrib.auth.views import (
|
||||||
LoginView,
|
LoginView,
|
||||||
)
|
)
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.http import HttpResponse
|
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.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 (
|
from apps.users.forms import (
|
||||||
LoginForm,
|
LoginForm,
|
||||||
UserSettingsForm,
|
UserSettingsForm,
|
||||||
|
UserUpdateForm,
|
||||||
|
UserAddForm,
|
||||||
)
|
)
|
||||||
from apps.common.decorators.htmx import only_htmx
|
|
||||||
from apps.users.models import UserSettings
|
from apps.users.models import UserSettings
|
||||||
|
|
||||||
|
|
||||||
@@ -22,7 +27,7 @@ def logout_view(request):
|
|||||||
return redirect(reverse("login"))
|
return redirect(reverse("login"))
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@htmx_login_required
|
||||||
def index(request):
|
def index(request):
|
||||||
if request.user.settings.start_page == UserSettings.StartPage.MONTHLY:
|
if request.user.settings.start_page == UserSettings.StartPage.MONTHLY:
|
||||||
return redirect(reverse("monthly_index"))
|
return redirect(reverse("monthly_index"))
|
||||||
@@ -49,7 +54,7 @@ class UserLoginView(LoginView):
|
|||||||
|
|
||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@htmx_login_required
|
||||||
def toggle_amount_visibility(request):
|
def toggle_amount_visibility(request):
|
||||||
user_settings, created = UserSettings.objects.get_or_create(user=request.user)
|
user_settings, created = UserSettings.objects.get_or_create(user=request.user)
|
||||||
current_hide_amounts = user_settings.hide_amounts
|
current_hide_amounts = user_settings.hide_amounts
|
||||||
@@ -70,7 +75,7 @@ def toggle_amount_visibility(request):
|
|||||||
|
|
||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@htmx_login_required
|
||||||
def toggle_sound_playing(request):
|
def toggle_sound_playing(request):
|
||||||
user_settings, created = UserSettings.objects.get_or_create(user=request.user)
|
user_settings, created = UserSettings.objects.get_or_create(user=request.user)
|
||||||
current_mute_sounds = user_settings.mute_sounds
|
current_mute_sounds = user_settings.mute_sounds
|
||||||
@@ -91,7 +96,7 @@ def toggle_sound_playing(request):
|
|||||||
|
|
||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@htmx_login_required
|
||||||
def update_settings(request):
|
def update_settings(request):
|
||||||
user_settings = request.user.settings
|
user_settings = request.user.settings
|
||||||
|
|
||||||
@@ -108,3 +113,85 @@ def update_settings(request):
|
|||||||
form = UserSettingsForm(instance=user_settings)
|
form = UserSettingsForm(instance=user_settings)
|
||||||
|
|
||||||
return render(request, "users/fragments/user_settings.html", {"form": form})
|
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},
|
||||||
|
)
|
||||||
|
|||||||
@@ -138,9 +138,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<li><a class="dropdown-item {% active_link views='automatic_exchange_rates_index' %}"
|
<li><a class="dropdown-item {% active_link views='automatic_exchange_rates_index' %}"
|
||||||
href="{% url 'automatic_exchange_rates_index' %}">{% translate 'Automatic Exchange Rates' %}</a></li>
|
href="{% url 'automatic_exchange_rates_index' %}">{% translate 'Automatic Exchange Rates' %}</a></li>
|
||||||
|
{% if user.is_superuser %}
|
||||||
<li>
|
<li>
|
||||||
<hr class="dropdown-divider">
|
<hr class="dropdown-divider">
|
||||||
</li>
|
</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>
|
<li>
|
||||||
<a class="dropdown-item"
|
<a class="dropdown-item"
|
||||||
href="{% url 'admin:index' %}"
|
href="{% url 'admin:index' %}"
|
||||||
@@ -151,6 +155,7 @@
|
|||||||
{% translate 'Django Admin' %}
|
{% translate 'Django Admin' %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -12,6 +12,11 @@
|
|||||||
hx-target="#generic-offcanvas"
|
hx-target="#generic-offcanvas"
|
||||||
role="button">
|
role="button">
|
||||||
<i class="fa-solid fa-gear me-2 fa-fw"></i>{% translate 'Settings' %}</a></li>
|
<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>
|
<li><hr class="dropdown-divider"></li>
|
||||||
{% spaceless %}
|
{% spaceless %}
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user