From 43caae1ff748aa6e5e1d1c61bfc2b43edb759a95 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 23 Dec 2025 11:18:41 +0100 Subject: [PATCH 01/16] Added navigation back to user settings. --- exact/exact/users/templates/users/view_user.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/exact/exact/users/templates/users/view_user.html b/exact/exact/users/templates/users/view_user.html index 55852ec6..3f4c4ad8 100644 --- a/exact/exact/users/templates/users/view_user.html +++ b/exact/exact/users/templates/users/view_user.html @@ -9,6 +9,11 @@ {% endblock taggerimports %} +{% block additional_js %} + + +{% endblock additional_js %} + {% block bodyblock %}
From c6cde1c5736bc9b6c0a896d46c1aef8aaf9ffe3f Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 23 Dec 2025 11:24:25 +0100 Subject: [PATCH 02/16] Added personalaccesstokens and renamed UI_User to UserPreferences (extension of User) --- exact/exact/annotations/views.py | 2 +- exact/exact/base/context_processors.py | 2 +- exact/exact/images/views.py | 4 +- exact/exact/settings_base.py | 3 +- exact/exact/users/admin.py | 4 +- exact/exact/users/authentication.py | 59 ++++++++++++++ exact/exact/users/forms.py | 4 +- .../migrations/0008_personalaccesstoken.py | 32 ++++++++ .../0009_userpreferences_delete_ui_user.py | 19 +++++ exact/exact/users/models.py | 45 +++++++++-- exact/exact/users/serializers.py | 13 ++++ exact/exact/users/tokens.py | 17 +++++ exact/exact/users/urls.py | 5 ++ exact/exact/users/views.py | 76 +++++++++++++++++-- 14 files changed, 263 insertions(+), 22 deletions(-) create mode 100644 exact/exact/users/authentication.py create mode 100644 exact/exact/users/migrations/0008_personalaccesstoken.py create mode 100644 exact/exact/users/migrations/0009_userpreferences_delete_ui_user.py create mode 100644 exact/exact/users/tokens.py diff --git a/exact/exact/annotations/views.py b/exact/exact/annotations/views.py index bc5d3744..c8f07409 100644 --- a/exact/exact/annotations/views.py +++ b/exact/exact/annotations/views.py @@ -83,7 +83,7 @@ def annotate(request, image_id): if hasattr(cache, "delete_pattern"): cache.set(f"{selected_image.image_set.id}_contains_asthma", asthma, 5*60) - template = 'annotations/annotate_v2.html' if hasattr(request.user,'ui') and hasattr(request.user.ui,'frontend') and request.user.ui.frontend==2 else 'annotations/annotate.html' + template = 'annotations/annotate_v2.html' if hasattr(request.user,'prefs') and hasattr(request.user.prefs,'frontend') and request.user.prefs.frontend==2 else 'annotations/annotate.html' response = render(request, template, { 'team': selected_image.image_set.team, diff --git a/exact/exact/base/context_processors.py b/exact/exact/base/context_processors.py index df1adfb9..016b9495 100644 --- a/exact/exact/base/context_processors.py +++ b/exact/exact/base/context_processors.py @@ -41,7 +41,7 @@ def base_data(request): 'TOOLS_ENABLED': settings.TOOLS_ENABLED, 'SHOW_AVAILABLE_SPACE' : settings.SHOW_AVAILABLE_SPACE, 'my_teams': my_teams, - 'frontend' : request.user.ui.frontend if hasattr(request.user,'ui') and hasattr(request.user.ui,'frontend') and request.user.ui.frontend else 1, + 'frontend' : request.user.prefs.frontend if hasattr(request.user,'prefs') and hasattr(request.user.prefs,'frontend') and request.user.prefs.frontend else 1, 'free_tb' : storage_memory_data['free_tb'], 'used_tb' : storage_memory_data['used_tb'], 'total_tb' : storage_memory_data['total_tb'], diff --git a/exact/exact/images/views.py b/exact/exact/images/views.py index 9e8e81ca..06237512 100644 --- a/exact/exact/images/views.py +++ b/exact/exact/images/views.py @@ -190,7 +190,7 @@ def index(request): last_image_action = LogImageAction.objects.filter(user=request.user).order_by('-time').first() - template = 'images/index_v2.html' if hasattr(request.user,'ui') and hasattr(request.user.ui,'frontend') and request.user.ui.frontend==2 else 'images/index.html' + template = 'images/index_v2.html' if hasattr(request.user,'prefs') and hasattr(request.user.prefs,'frontend') and request.user.prefs.frontend==2 else 'images/index.html' return TemplateResponse(request, template, { 'last_image_action': last_image_action, @@ -845,7 +845,7 @@ def view_imageset(request, image_set_id): target_imageset=imageset all_products = Product.objects.filter(team=imageset.team).order_by('name') - template = 'images/imageset_v2.html' if hasattr(request.user,'ui') and hasattr(request.user.ui,'frontend') and request.user.ui.frontend==2 else 'images/imageset.html' + template = 'images/imageset_v2.html' if hasattr(request.user,'prefs') and hasattr(request.user.prefs,'frontend') and request.user.prefs.frontend==2 else 'images/imageset.html' return render(request, template, { 'image_count': imageset.images.count(), 'imageset': imageset, diff --git a/exact/exact/settings_base.py b/exact/exact/settings_base.py index 54ab0104..ded7298f 100644 --- a/exact/exact/settings_base.py +++ b/exact/exact/settings_base.py @@ -99,7 +99,8 @@ ), 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework.authentication.TokenAuthentication', + "exact.users.authentication.PersonalAccessTokenAuthentication", +# 'rest_framework.authentication.TokenAuthentication', 'rest_framework.authentication.BasicAuthentication', 'rest_framework.authentication.SessionAuthentication', ), diff --git a/exact/exact/users/admin.py b/exact/exact/users/admin.py index b1c3c28c..3e1b7855 100644 --- a/exact/exact/users/admin.py +++ b/exact/exact/users/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin -from .models import Team, TeamMembership, User, UI_User +from .models import Team, TeamMembership, User, UserPreferences class CustomUserAdmin(UserAdmin): list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'last_login') @@ -9,4 +9,4 @@ class CustomUserAdmin(UserAdmin): admin.site.register(Team) admin.site.register(TeamMembership) admin.site.register(User, CustomUserAdmin) -admin.site.register(UI_User) +admin.site.register(UserPreferences) diff --git a/exact/exact/users/authentication.py b/exact/exact/users/authentication.py new file mode 100644 index 00000000..f791abef --- /dev/null +++ b/exact/exact/users/authentication.py @@ -0,0 +1,59 @@ +# app/authentication.py +from django.utils import timezone +from rest_framework.authentication import BaseAuthentication, get_authorization_header +from rest_framework import exceptions +import secrets + +from .models import PersonalAccessToken +from .tokens import hash_token + +class PersonalAccessTokenAuthentication(BaseAuthentication): + keyword = b"bearer" + + def authenticate(self, request): + auth = get_authorization_header(request).split() + if not auth: + return None + + if auth[0].lower() != self.keyword: + return None + + if len(auth) != 2: + raise exceptions.AuthenticationFailed("Invalid Authorization header format.") + + token = auth[1].decode("utf-8") + + # Expecting format: pat_. + if not token.startswith("pat_") or "." not in token: + raise exceptions.AuthenticationFailed("Invalid token format.") + + try: + prefix_part = token.split(".", 1)[0] # pat_ + prefix = prefix_part.replace("pat_", "", 1) + except Exception: + raise exceptions.AuthenticationFailed("Invalid token format.") + + # Lookup by prefix, then verify hash + candidate_qs = PersonalAccessToken.objects.select_related("user").filter(prefix=prefix) + if not candidate_qs.exists(): + raise exceptions.AuthenticationFailed("Invalid token.") + + token_h = hash_token(token) + + # Usually one row per prefix; if you allow collisions, iterate + pat = None + for row in candidate_qs: + if secrets.compare_digest(row.token_hash, token_h): + pat = row + break + + if pat is None: + raise exceptions.AuthenticationFailed("Invalid token.") + + if not pat.is_active: + raise exceptions.AuthenticationFailed("Token expired or revoked.") + + # Optional: update last_used_at + PersonalAccessToken.objects.filter(pk=pat.pk).update(last_used_at=timezone.now()) + + return (pat.user, pat) \ No newline at end of file diff --git a/exact/exact/users/forms.py b/exact/exact/users/forms.py index e5333a52..707ffb13 100644 --- a/exact/exact/users/forms.py +++ b/exact/exact/users/forms.py @@ -1,7 +1,7 @@ from django import forms from django_registration.forms import RegistrationForm -from .models import Team, UI_User, User +from .models import Team, UserPreferences, User class UserRegistrationForm(RegistrationForm): @@ -26,7 +26,7 @@ class Meta: class UIUserEditForm(forms.ModelForm): class Meta: - model = UI_User + model = UserPreferences fields = [ 'frontend', ] diff --git a/exact/exact/users/migrations/0008_personalaccesstoken.py b/exact/exact/users/migrations/0008_personalaccesstoken.py new file mode 100644 index 00000000..4fdf8166 --- /dev/null +++ b/exact/exact/users/migrations/0008_personalaccesstoken.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.18 on 2025-12-22 09:24 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0007_alter_ui_user_user'), + ] + + operations = [ + migrations.CreateModel( + name='PersonalAccessToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('prefix', models.CharField(db_index=True, max_length=16)), + ('token_hash', models.CharField(max_length=64, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_used_at', models.DateTimeField(blank=True, null=True)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('revoked_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='personal_tokens', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'indexes': [models.Index(fields=['user', 'revoked_at', 'expires_at'], name='users_perso_user_id_3d589e_idx')], + }, + ), + ] diff --git a/exact/exact/users/migrations/0009_userpreferences_delete_ui_user.py b/exact/exact/users/migrations/0009_userpreferences_delete_ui_user.py new file mode 100644 index 00000000..e57f37a8 --- /dev/null +++ b/exact/exact/users/migrations/0009_userpreferences_delete_ui_user.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.18 on 2025-12-23 10:58 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0008_personalaccesstoken'), + ] + + operations = [ + migrations.RenameModel( + old_name="UI_User", + new_name="UserPreferences", + ), + ] diff --git a/exact/exact/users/models.py b/exact/exact/users/models.py index 2e3b84fc..e6d0aac1 100644 --- a/exact/exact/users/models.py +++ b/exact/exact/users/models.py @@ -8,24 +8,55 @@ from django.utils.functional import cached_property from django.dispatch import receiver from django.db.models.signals import post_save +from django.utils import timezone + +# New: Use personal access tokens for API access + +class PersonalAccessToken(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="personal_tokens") + + name = models.CharField(max_length=100) # label shown in UI + prefix = models.CharField(max_length=16, db_index=True) # helps lookup + token_hash = models.CharField(max_length=64, unique=True) # sha256 hex length 64 + + created_at = models.DateTimeField(auto_now_add=True) + last_used_at = models.DateTimeField(null=True, blank=True) + + expires_at = models.DateTimeField(null=True, blank=True) + revoked_at = models.DateTimeField(null=True, blank=True) + + class Meta: + indexes = [models.Index(fields=["user", "revoked_at", "expires_at"])] + + @property + def is_active(self): + if self.revoked_at is not None: + return False + if self.expires_at is not None and self.expires_at <= timezone.now(): + return False + return True class User(AbstractUser): # points are updated by database triggers points = models.IntegerField(default=0) - -class UI_User(models.Model): - user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="ui") +class UserPreferences(models.Model): + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="prefs", + ) class Frontends(models.IntegerChoices): - Default = 1 - Lightroom = 2 + DEFAULT = 1, "Default" + LIGHTROOM = 2, "Lightroom" - frontend = models.IntegerField(choices=Frontends.choices, default=1) + frontend = models.IntegerField(choices=Frontends.choices, default=Frontends.LIGHTROOM) def __str__(self): - return 'UI properties of user '+str(self.user) + return f"Preferences of user {self.user_id}" + # Add new users to teams if the ADD_USER_TO_TEAM is set @receiver([post_save], sender=User) diff --git a/exact/exact/users/serializers.py b/exact/exact/users/serializers.py index b98cba26..32e2016d 100644 --- a/exact/exact/users/serializers.py +++ b/exact/exact/users/serializers.py @@ -4,6 +4,19 @@ from exact.users.models import Team, User, TeamMembership +from rest_framework import serializers +from .models import PersonalAccessToken + +class PersonalAccessTokenListSerializer(serializers.ModelSerializer): + class Meta: + model = PersonalAccessToken + fields = ["id", "name", "prefix", "created_at", "last_used_at", "expires_at", "revoked_at"] + +class PersonalAccessTokenCreateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=100) + expires_at = serializers.DateTimeField(required=False, allow_null=True) + + class TeamSerializer(FlexFieldsModelSerializer): class Meta: model = Team diff --git a/exact/exact/users/tokens.py b/exact/exact/users/tokens.py new file mode 100644 index 00000000..304a2913 --- /dev/null +++ b/exact/exact/users/tokens.py @@ -0,0 +1,17 @@ +# app/tokens.py +import secrets +import hashlib + +def generate_pat() -> tuple[str, str]: + """ + Returns (token, prefix). + token: full bearer token string user will copy + prefix: short identifier for lookup + """ + prefix = secrets.token_urlsafe(9)[:12] # 12-ish chars + secret = secrets.token_urlsafe(32) # strong entropy + token = f"pat_{prefix}.{secret}" + return token, prefix + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() \ No newline at end of file diff --git a/exact/exact/users/urls.py b/exact/exact/users/urls.py index c37759f9..14d16efe 100644 --- a/exact/exact/users/urls.py +++ b/exact/exact/users/urls.py @@ -1,7 +1,9 @@ from django.urls import include, re_path from . import views +from rest_framework.routers import DefaultRouter +from .views import PersonalAccessTokenViewSet app_name = 'users' urlpatterns = [ @@ -19,3 +21,6 @@ re_path(r'^user/(\d+)/$', views.user, name='user'), re_path(r'^user/explore/$', views.explore_user, name='explore_user'), ] + +router = DefaultRouter() +router.register(r"api/user/tokens", PersonalAccessTokenViewSet, basename="api-tokens") diff --git a/exact/exact/users/views.py b/exact/exact/users/views.py index 117048a7..19b7e18c 100644 --- a/exact/exact/users/views.py +++ b/exact/exact/users/views.py @@ -19,8 +19,19 @@ from exact.administration.models import Product from exact.images.models import ImageSet from exact.users.forms import TeamCreationForm, UserEditForm -from .models import Team, UI_User, User +from .models import Team, UserPreferences, User from .serializers import TeamSerializer +from rest_framework import viewsets, mixins, status +from .tokens import generate_pat, hash_token +from rest_framework.permissions import IsAuthenticated +from .models import PersonalAccessToken +from .serializers import ( + PersonalAccessTokenListSerializer, + PersonalAccessTokenCreateSerializer, +) +from django.utils import timezone +from rest_framework.decorators import action + @login_required def create_team(request): @@ -61,6 +72,58 @@ def revoke_team_admin(request, team_id, user_id): return redirect(reverse('users:team', args=(team.id,))) + +class PersonalAccessTokenViewSet( + mixins.ListModelMixin, + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return PersonalAccessToken.objects.filter(user=self.request.user).order_by("-created_at") + + def get_serializer_class(self): + if self.action == "create": + return PersonalAccessTokenCreateSerializer + return PersonalAccessTokenListSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + token, prefix = generate_pat() + pat = PersonalAccessToken.objects.create( + user=request.user, + name=serializer.validated_data["name"], + prefix=prefix, + token_hash=hash_token(token), + expires_at=serializer.validated_data.get("expires_at"), + ) + + # Return the token ONCE + return Response( + { + "id": pat.id, + "name": pat.name, + "prefix": pat.prefix, + "created_at": pat.created_at, + "expires_at": pat.expires_at, + "token": token, + }, + status=status.HTTP_201_CREATED, + ) + + @action(detail=True, methods=["post"]) + def revoke(self, request, pk=None): + pat = self.get_queryset().filter(pk=pk).first() + if not pat: + return Response(status=404) + if pat.revoked_at is None: + pat.revoked_at = timezone.now() + pat.save(update_fields=["revoked_at"]) + return Response({"status": "revoked"}) + @login_required @require_POST def grant_team_admin(request, team_id, user_id): @@ -272,10 +335,11 @@ def user(request, user_id): user.save() if (user.is_superuser) and request.POST['frontend']: - if not hasattr(user,'ui'): - user.ui = UI_User(user=user) - user.ui.frontend = int(request.POST['frontend']) - user.ui.save() + if not hasattr(user,'prefs'): + user.prefs = UserPreferences.objects.get_or_create(user=user) + + user.prefs.frontend = int(request.POST['frontend']) + user.prefs.save() if request.POST['first_name']: user.first_name = str(request.POST['first_name']) @@ -297,7 +361,7 @@ def user(request, user_id): return render(request, 'users/view_user.html', { 'user': user, - 'frontend' : request.user.ui.frontend if hasattr(request.user,'ui') and hasattr(request.user.ui,'frontend') and request.user.ui.frontend else 1, + 'frontend' : request.user.prefs.frontend if hasattr(request.user,'prefs') and hasattr(request.user.prefs,'frontend') and request.user.prefs.frontend else 1, 'form': form, 'info': info, 'passwordmatching' : passwordmatching, From 36aaccf429eb5d0652098ec0c1ea74340b24e467 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 23 Dec 2025 13:15:01 +0100 Subject: [PATCH 03/16] Updated user preferences. --- exact/exact/users/admin.py | 3 +- .../users/templates/users/view_user.html | 422 +++++++++++++++--- exact/exact/users/urls.py | 10 +- exact/exact/users/views.py | 11 +- 4 files changed, 369 insertions(+), 77 deletions(-) diff --git a/exact/exact/users/admin.py b/exact/exact/users/admin.py index 3e1b7855..031597b8 100644 --- a/exact/exact/users/admin.py +++ b/exact/exact/users/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin -from .models import Team, TeamMembership, User, UserPreferences +from .models import Team, TeamMembership, User, UserPreferences, PersonalAccessToken class CustomUserAdmin(UserAdmin): list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'last_login') @@ -10,3 +10,4 @@ class CustomUserAdmin(UserAdmin): admin.site.register(TeamMembership) admin.site.register(User, CustomUserAdmin) admin.site.register(UserPreferences) +admin.site.register(PersonalAccessToken) \ No newline at end of file diff --git a/exact/exact/users/templates/users/view_user.html b/exact/exact/users/templates/users/view_user.html index 3f4c4ad8..9bebc43d 100644 --- a/exact/exact/users/templates/users/view_user.html +++ b/exact/exact/users/templates/users/view_user.html @@ -2,11 +2,10 @@ {% load static %} {% load i18n %} {% load widget_tweaks %} -{% block taggerimports %} +{% block taggerimports %} - {% endblock taggerimports %} {% block additional_js %} @@ -16,84 +15,367 @@ {% block bodyblock %} -
-
-

User profile ({{ user.username }})

-
- +
+
+

User profile ({{ user.username }})

+
+ +
+ {% if not passwordmatching %} -
Passwords mismatch
- +
Passwords mismatch
{% endif %} {% if info %} -
{{info}}
+
{{ info }}
{% endif %} -
+ + - - +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ +
+ + + +
+

Teams

+ + {% if teams %} + + {% else %} +

You are not a member of any team.

+ {% endif %} +
+ + - -
- -
- -
-

Teams:

- + + + + + {% endblock %} diff --git a/exact/exact/users/urls.py b/exact/exact/users/urls.py index 14d16efe..f298ef08 100644 --- a/exact/exact/users/urls.py +++ b/exact/exact/users/urls.py @@ -1,9 +1,13 @@ -from django.urls import include, re_path +from django.urls import include, re_path, path from . import views + from rest_framework.routers import DefaultRouter from .views import PersonalAccessTokenViewSet +router = DefaultRouter() +router.register(r"tokens", PersonalAccessTokenViewSet, basename="api-tokens") + app_name = 'users' urlpatterns = [ @@ -20,7 +24,7 @@ re_path(r'^team/(\d+)/revoke_admin/(\d+)/$', views.revoke_team_admin, name='revoke_team_admin'), re_path(r'^user/(\d+)/$', views.user, name='user'), re_path(r'^user/explore/$', views.explore_user, name='explore_user'), + + path("api/", include(router.urls)), ] -router = DefaultRouter() -router.register(r"api/user/tokens", PersonalAccessTokenViewSet, basename="api-tokens") diff --git a/exact/exact/users/views.py b/exact/exact/users/views.py index 19b7e18c..a812f2fd 100644 --- a/exact/exact/users/views.py +++ b/exact/exact/users/views.py @@ -31,6 +31,7 @@ ) from django.utils import timezone from rest_framework.decorators import action +from exact.users.models import TeamMembership @login_required @@ -322,7 +323,12 @@ def view_team(request, team_id): def user(request, user_id): user = get_object_or_404(User, id=user_id) teams = Team.objects.filter(members=user) - + memberships = ( + TeamMembership.objects + .select_related("team") + .filter(user=user) + .order_by("team__name") + ) info = None passwordmatching=True form = UserEditForm(instance=user) @@ -357,12 +363,11 @@ def user(request, user_id): info = 'Information updated.' - - return render(request, 'users/view_user.html', { 'user': user, 'frontend' : request.user.prefs.frontend if hasattr(request.user,'prefs') and hasattr(request.user.prefs,'frontend') and request.user.prefs.frontend else 1, 'form': form, + 'memberships' : memberships, 'info': info, 'passwordmatching' : passwordmatching, 'teams': teams, From 456d62949e02b438817aa0d7c52a6feb3a7ea142 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 23 Dec 2025 13:16:54 +0100 Subject: [PATCH 04/16] removed institution field again. --- exact/exact/users/templates/users/view_user.html | 7 ------- 1 file changed, 7 deletions(-) diff --git a/exact/exact/users/templates/users/view_user.html b/exact/exact/users/templates/users/view_user.html index 9bebc43d..b3ef2423 100644 --- a/exact/exact/users/templates/users/view_user.html +++ b/exact/exact/users/templates/users/view_user.html @@ -77,13 +77,6 @@

Personal information

- {# Institution (example). Replace with your real field, e.g. user.preferences.institution #} -
- - -
-
From 3f7580c36d92dbc56d37051460e4b126cc3ebf8c Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 23 Dec 2025 13:40:51 +0100 Subject: [PATCH 05/16] Bugfix for new user management (profile view) --- exact/exact/users/views.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/exact/exact/users/views.py b/exact/exact/users/views.py index a812f2fd..1a6e5c1f 100644 --- a/exact/exact/users/views.py +++ b/exact/exact/users/views.py @@ -334,28 +334,31 @@ def user(request, user_id): form = UserEditForm(instance=user) if request.method == 'POST': - passwordmatching = request.POST['password1']==request.POST['password2'] + passwordmatching = 'password1' in request.POST and 'password2' in request.POST and request.POST['password1']==request.POST['password2'] if passwordmatching and len(request.POST['password1'])>0: user.set_password(request.POST['password1']) user.save() + + if 'password1' not in request.POST or 'password2' not in request.POST: + passwordmatching=True - if (user.is_superuser) and request.POST['frontend']: + if 'frontend' in request.POST and request.POST['frontend']: if not hasattr(user,'prefs'): user.prefs = UserPreferences.objects.get_or_create(user=user) user.prefs.frontend = int(request.POST['frontend']) user.prefs.save() - if request.POST['first_name']: + if 'first_name' in request.POST: user.first_name = str(request.POST['first_name']) user.save() - if request.POST['last_name']: + if 'last_name' in request.POST: user.last_name = str(request.POST['last_name']) user.save() - if request.POST['email']: + if 'email' in request.POST: user.email = str(request.POST['email']) user.save() From f41993435404982051e3f017eb5b1f666ca95f2e Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 23 Dec 2025 14:08:42 +0100 Subject: [PATCH 06/16] Fixed block naming (from extra_js to additional_js) to allow for conditional UI checking. --- exact/exact/users/templates/registration/login.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exact/exact/users/templates/registration/login.html b/exact/exact/users/templates/registration/login.html index 971f523a..9887432f 100644 --- a/exact/exact/users/templates/registration/login.html +++ b/exact/exact/users/templates/registration/login.html @@ -45,7 +45,7 @@

Login

{% endblock bodyblock %} -{% block extra_js %} +{% block additional_js %} + +{% endblock taggerimports %} + +{% block additional_js %} + +{% endblock additional_js %} + +{% block bodyblock %} + +
+
+

User management

+
+ +
+
+ + +
+
+ +
+ +
+
+ +
+
+ +
+
+ +
+
Loading users…
+
+
+ + +
+
+ Select a user to view details. +
+ + + +
+ +
+
+
+ + + + + + + +{% endblock %} diff --git a/exact/exact/administration/urls.py b/exact/exact/administration/urls.py index 7c5ed7f3..696d8900 100644 --- a/exact/exact/administration/urls.py +++ b/exact/exact/administration/urls.py @@ -18,6 +18,20 @@ re_path(r'^products/(\d+)/$', views.product, name='product'), re_path(r'^products/create/$', views.create_product, name='create_product'), + re_path(r'users/$', views.user_management, name='user_management'), + # AJAX API for the management UI + re_path(r'^users/api/list/$', views.user_list_api, name='user_list_api'), + re_path(r'^users/api/(?P\d+)/detail/$', views.user_detail_api, name='user_detail_api'), + re_path(r'^users/api/(?P\d+)/toggle-active/$', views.user_toggle_active_api, name='user_toggle_active_api'), + re_path(r'^users/api/(?P\d+)/toggle-staff/$', views.user_toggle_staff_api, name='user_toggle_staff_api'), + re_path(r'^users/api/(?P\d+)/send-password-reset/$', views.user_send_password_reset_api, name='user_send_password_reset_api'), + re_path(r'^users/api/(?P\d+)/update/$', views.user_update_api, name='user_update_api'), + re_path(r'^users/api/(?P\d+)/delete/$', views.user_delete_api, name='user_delete_api'), + re_path(r'^users/api/teams/list/$', views.team_list_api, name='team_list_api'), + re_path(r'^users/api/(?P\d+)/teams/add/$', views.user_team_add_api, name='user_team_add_api'), + re_path(r'^users/api/(?P\d+)/teams/(?P\d+)/remove/$', views.user_team_remove_api, name='user_team_remove_api'), + re_path(r'^users/api/(?P\d+)/teams/(?P\d+)/toggle-admin/$', views.user_team_toggle_admin_api, name='user_team_toggle_admin_api'), + re_path(r'^products/edit/(\d+)/$', views.edit_product, name='edit_product'), re_path(r'^plugins/list/$', views.plugins, name='plugins'), re_path(r'^storage/$', views.storage, name='storage'), diff --git a/exact/exact/administration/views.py b/exact/exact/administration/views.py index 4bc90be6..e6bc2f05 100644 --- a/exact/exact/administration/views.py +++ b/exact/exact/administration/views.py @@ -17,16 +17,29 @@ from exact.users.models import Team from exact.images.models import ImageSet from exact.administration.serializers import serialize_annotationType, ProductSerializer +from passkeys.models import UserPasskey +from django.views.decorators.http import require_GET, require_POST from rest_framework.decorators import api_view from rest_framework.exceptions import ParseError from rest_framework.response import Response from rest_framework.status import HTTP_201_CREATED, HTTP_400_BAD_REQUEST, HTTP_200_OK, \ HTTP_403_FORBIDDEN -from django.conf import settings import csv import os import shutil +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.forms import PasswordResetForm +from django.http import JsonResponse, HttpResponseBadRequest +from django.shortcuts import render, get_object_or_404 +from exact.users.models import Team, TeamMembership +from .permissions import site_admin_required +from django.core.exceptions import ValidationError +import json + +User = get_user_model() + def logs(request): @@ -69,6 +82,289 @@ def product(request, product_id): }) +@site_admin_required +@require_POST +def user_update_api(request, user_id: int): + u = get_object_or_404(User, pk=user_id) + + try: + payload = json.loads(request.body.decode("utf-8") or "{}") + except Exception: + return JsonResponse({"error": "Invalid JSON body."}, status=400) + + if getattr(u, "is_superuser", False) and not getattr(request.user, "is_superuser", False): + return JsonResponse({"error": "Only superusers may modify superusers."}, status=403) + + # Apply updates (restrict to allowed fields) + allowed = ["first_name", "last_name", "email", "username"] + for field in allowed: + if field in payload: + setattr(u, field, (payload[field] or "").strip()) + + # Basic validation – extend as needed + if "email" in payload and u.email and "@" not in u.email: + return JsonResponse({"error": "Invalid email address."}, status=400) + + try: + u.full_clean(exclude=None) # may be too strict depending on your User model; adjust if needed + except ValidationError as e: + return JsonResponse({"error": e.message_dict}, status=400) + + u.save() + return JsonResponse({"ok": True}) + +@site_admin_required +@require_POST +def user_delete_api(request, user_id: int): + u = get_object_or_404(User, pk=user_id) + + if u.id == request.user.id: + return JsonResponse({"error": "You cannot delete your own account."}, status=400) + + if getattr(u, "is_superuser", False) and not getattr(request.user, "is_superuser", False): + return JsonResponse({"error": "Only superusers may delete superusers."}, status=403) + + u.delete() + return JsonResponse({"ok": True}) + +@site_admin_required +@require_GET +def team_list_api(request): + from exact.users.models import Team + teams = Team.objects.all().order_by("name")[:500] + return JsonResponse({ + "results": [{"id": t.id, "name": t.name} for t in teams] + }) + + +@site_admin_required +@require_POST +def user_team_add_api(request, user_id: int): + from exact.users.models import TeamMembership, Team # adjust path + u = get_object_or_404(User, pk=user_id) + + try: + payload = json.loads(request.body.decode("utf-8") or "{}") + except Exception: + return JsonResponse({"error": "Invalid JSON body."}, status=400) + + team_id = payload.get("team_id") + if not team_id: + return JsonResponse({"error": "team_id is required."}, status=400) + + team = get_object_or_404(Team, pk=team_id) + + obj, created = TeamMembership.objects.get_or_create(user=u, team=team, defaults={"is_admin": False}) + return JsonResponse({"ok": True, "created": created}) + + +@site_admin_required +@require_POST +def user_team_remove_api(request, user_id: int, team_id: int): + from exact.users.models import TeamMembership # adjust path + u = get_object_or_404(User, pk=user_id) + + TeamMembership.objects.filter(user=u, team_id=team_id).delete() + return JsonResponse({"ok": True}) + + +@site_admin_required +@require_POST +def user_team_toggle_admin_api(request, user_id: int, team_id: int): + from exact.users.models import TeamMembership # adjust path + u = get_object_or_404(User, pk=user_id) + + m = TeamMembership.objects.filter(user=u, team_id=team_id).first() + if not m: + return JsonResponse({"error": "User is not a member of this team."}, status=400) + + m.is_admin = not bool(m.is_admin) + m.save(update_fields=["is_admin"]) + return JsonResponse({"ok": True, "is_admin": m.is_admin}) + + +@staff_member_required +def user_management(request): + # Page shell; all data loaded via AJAX + return render(request, "administration/user_management.html", {'user':request.user}) + +@staff_member_required +@require_GET +def user_list_api(request): + """ + Returns a list of users with minimal fields for the left pane. + Query params: + q: search in username/email/first/last + active: '1' or '0' or '' + staff: '1' or '0' or '' + limit: int (default 50) + """ + q = (request.GET.get("q") or "").strip() + active = (request.GET.get("active") or "").strip() + staff = (request.GET.get("staff") or "").strip() + limit = request.GET.get("limit") or "50" + + try: + limit = max(1, min(int(limit), 200)) + except ValueError: + limit = 50 + + qs = User.objects.all().order_by("username") + + if q: + from django.db.models import Q + qs = qs.filter( + Q(username__icontains=q) | + Q(email__icontains=q) | + Q(first_name__icontains=q) | + Q(last_name__icontains=q) + ) + + if active in ("0", "1"): + qs = qs.filter(is_active=(active == "1")) + + if staff in ("0", "1"): + qs = qs.filter(is_staff=(staff == "1")) + + qs = qs[:limit] + + data = [] + for u in qs: + display_name = (f"{u.first_name} {u.last_name}".strip() or u.username) + data.append({ + "id": u.id, + "username": u.username, + "display_name": display_name, + "email": u.email, + "is_active": u.is_active, + "is_staff": u.is_staff, + }) + + return JsonResponse({"results": data}) + + +@staff_member_required +@require_GET +def user_detail_api(request, user_id: int): + u = get_object_or_404(User, pk=user_id) + + # Optional: user preferences (safe if it exists) + prefs = None + if hasattr(u, "preferences"): + prefs = u.preferences + + # Optional: teams via TeamMembership if present in your project + memberships_data = [] + try: + memberships = TeamMembership.objects.select_related("team").filter(user=u).order_by("team__name") + for m in memberships: + memberships_data.append({ + "team_id": m.team_id, + "team_name": m.team.name, + "is_admin": bool(getattr(m, "is_admin", False)), + }) + except Exception: + # If you don’t have that model yet, just omit memberships + memberships_data = [] + + # Optional: passkeys summary (depends on your passkeys app; keep it minimal) + passkeys_supported = True # you can replace with real logic + passkeys_used = UserPasskey.objects.filter(user=u).count()>0 + + passkeys_last_used = UserPasskey.objects.filter(user=u).order_by('-last_used').first().isoformat() if passkeys_used else '' + + tokens_data = [] + try: + from exact.users.models import PersonalAccessToken # adjust import to your token model location + tokens = PersonalAccessToken.objects.filter(user=u).order_by("-created_at")[:200] + for t in tokens: + tokens_data.append({ + "id": t.id, + "name": t.name, + "prefix": t.prefix, + "created_at": t.created_at.isoformat() if t.created_at else None, + "last_used_at": t.last_used_at.isoformat() if t.last_used_at else None, + "expires_at": t.expires_at.isoformat() if t.expires_at else None, + "revoked_at": t.revoked_at.isoformat() if t.revoked_at else None, + }) + except Exception as e: + print(e) + tokens_data = [] + + return JsonResponse({ + "user": { + "id": u.id, + "username": u.username, + "first_name": u.first_name, + "last_name": u.last_name, + "email": u.email, + "is_active": u.is_active, + "is_staff": u.is_staff, + "is_superuser": getattr(u, "is_superuser", False), + "date_joined": u.date_joined.isoformat() if getattr(u, "date_joined", None) else None, + "last_login": u.last_login.isoformat() if getattr(u, "last_login", None) else None, + }, + "preferences": { + "frontend": getattr(prefs, "frontend", None), + } if prefs else None, + "memberships": memberships_data, + "tokens": tokens_data, + "passkeys": {"supported": passkeys_supported, + "used": passkeys_used, + "last_used": passkeys_last_used}, + + }) + + +@staff_member_required +@require_POST +def user_toggle_active_api(request, user_id: int): + u = get_object_or_404(User, pk=user_id) + if u.id == request.user.id: + return JsonResponse({"error": "You cannot deactivate your own account."}, status=400) + + u.is_active = not u.is_active + u.save(update_fields=["is_active"]) + return JsonResponse({"ok": True, "is_active": u.is_active}) + + +@staff_member_required +@require_POST +def user_toggle_staff_api(request, user_id: int): + u = get_object_or_404(User, pk=user_id) + if u.id == request.user.id: + return JsonResponse({"error": "You cannot change your own staff status here."}, status=400) + + # Optional: restrict to superusers only + if not getattr(request.user, "is_superuser", False): + return JsonResponse({"error": "Only superusers may change staff status."}, status=403) + + u.is_staff = not u.is_staff + u.save(update_fields=["is_staff"]) + return JsonResponse({"ok": True, "is_staff": u.is_staff}) + + +@staff_member_required +@require_POST +def user_send_password_reset_api(request, user_id: int): + u = get_object_or_404(User, pk=user_id) + if not u.email: + return JsonResponse({"error": "User has no email address on file."}, status=400) + + form = PasswordResetForm(data={"email": u.email}) + if not form.is_valid(): + return JsonResponse({"error": "Invalid email."}, status=400) + + # Uses Django’s built-in password reset email flow. + form.save( + request=request, + use_https=request.is_secure(), + from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None), + email_template_name="registration/password_reset_email.html", + subject_template_name="registration/password_reset_subject.txt", + ) + return JsonResponse({"ok": True}) + def create_product(request): diff --git a/exact/exact/base/templates/base/base.html b/exact/exact/base/templates/base/base.html index 819a5a74..6715fc62 100644 --- a/exact/exact/base/templates/base/base.html +++ b/exact/exact/base/templates/base/base.html @@ -98,6 +98,9 @@ Plugins {% endif %} Storage + {% if request.user.is_superuser and request.user.is_staff %} + User Management + {% endif %} {% if my_teams %} diff --git a/exact/exact/users/migrations/0010_userpreferences_is_site_admin_and_more.py b/exact/exact/users/migrations/0010_userpreferences_is_site_admin_and_more.py new file mode 100644 index 00000000..244caee4 --- /dev/null +++ b/exact/exact/users/migrations/0010_userpreferences_is_site_admin_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.18 on 2025-12-23 14:23 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0009_userpreferences_delete_ui_user'), + ] + + operations = [ + migrations.AddField( + model_name='userpreferences', + name='is_site_admin', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='userpreferences', + name='frontend', + field=models.IntegerField(choices=[(1, 'Default'), (2, 'Lightroom')], default=2), + ), + migrations.AlterField( + model_name='userpreferences', + name='user', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='prefs', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/exact/exact/users/models.py b/exact/exact/users/models.py index e6d0aac1..1b822721 100644 --- a/exact/exact/users/models.py +++ b/exact/exact/users/models.py @@ -53,7 +53,8 @@ class Frontends(models.IntegerChoices): LIGHTROOM = 2, "Lightroom" frontend = models.IntegerField(choices=Frontends.choices, default=Frontends.LIGHTROOM) - + is_site_admin = models.BooleanField(default=False) + def __str__(self): return f"Preferences of user {self.user_id}" From 11b55b130dff6479d3c06800ac7ec5031191dd86 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 23 Dec 2025 15:23:33 +0100 Subject: [PATCH 08/16] Made user management a bit more beautiful. --- .../administration/user_management.html | 138 ++++++++++++------ 1 file changed, 92 insertions(+), 46 deletions(-) diff --git a/exact/exact/administration/templates/administration/user_management.html b/exact/exact/administration/templates/administration/user_management.html index 65302d88..cc4e626d 100644 --- a/exact/exact/administration/templates/administration/user_management.html +++ b/exact/exact/administration/templates/administration/user_management.html @@ -75,57 +75,92 @@

-
-
-
Edit user
- -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
+
+
+
+
Edit user
+ Update user identity fields and account status. +
+
-
- -

-
+
+ +
+
+
+ + +
+
+ + +
+
-
- -

-
+
+
+ + +
+
+ + +
+
+
-
- -

-
+ +
+
+
+
Date joined
+
+
+ +
+
Account status
+
+ Active: ? + Staff: ? +
+
+ +
+
Last login
+
+
+
+
+
+ +
+ +
+ +
+ + +
+ + +
+ + + + {% if user.prefs.site_admin %} + + {% endif %} +
+
+ + {% if user.prefs.site_admin %} + + Deleting a user is irreversible and may remove memberships and tokens. + + {% endif %} +
-
- - {% if user.prefs.is_site_admin %} - - {% endif %} - - -
-
-
@@ -397,6 +432,17 @@ $('#f-date-joined').text(fmt(u.date_joined)); $('#f-last-login').text(fmt(u.last_login)); + + $('#badge-active') + .text('Active: ' + (u.is_active ? 'Yes' : 'No')) + .removeClass('badge-success badge-secondary') + .addClass(u.is_active ? 'badge-success' : 'badge-secondary'); + + $('#badge-staff') + .text('Staff: ' + (u.is_staff ? 'Yes' : 'No')) + .removeClass('badge-info badge-secondary') + .addClass(u.is_staff ? 'badge-info' : 'badge-secondary'); + $('#f-passkeys-used').text(resp.passkeys.used ? ('In use (last '+fmt(resp.passkeys.last_used)+')') : 'Not in use'); $('#f-active').text(u.is_active ? 'Yes' : 'No'); $('#f-staff').text(u.is_staff ? 'Yes' : 'No'); From a26b1cc7ad5c041781f316fa080a22e3ebb0099a Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 23 Dec 2025 15:29:11 +0100 Subject: [PATCH 09/16] Added setting of random passwords. --- .../administration/user_management.html | 34 +++++++++++++++++++ exact/exact/administration/urls.py | 3 +- exact/exact/administration/views.py | 27 +++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/exact/exact/administration/templates/administration/user_management.html b/exact/exact/administration/templates/administration/user_management.html index cc4e626d..6f6712f4 100644 --- a/exact/exact/administration/templates/administration/user_management.html +++ b/exact/exact/administration/templates/administration/user_management.html @@ -169,8 +169,11 @@
Edit user
Password reset

Sends the standard Django password reset email to the user.

+
+ +
Passkeys

@@ -526,6 +529,37 @@ $('#deleteUserModal').modal('show'); }); +$('#btn-random-password').on('click', function() { + if (!currentUserId) return; + + if (!confirm("Set a new random password for this user? This will immediately replace their current password.")) { + return; + } + + $.ajax({ + url: "{% url 'administration:user_set_random_password_api' user_id=1 %}".replace('/1/set-random-password/', '/' + currentUserId + '/set-random-password/'), + method: "POST", + headers: { "X-CSRFToken": csrftoken }, + success: function(resp) { + if (resp && resp.password) { + var msg = "New password (copy now; it will not be shown again): " + resp.password; + $('#random-password-box').text(msg).show(); + $.notify("Random password set.", "success"); + } else { + $.notify("Random password set.", "success"); + } + }, + error: function(xhr) { + var msg = "Action failed."; + try { + var data = xhr.responseJSON; + if (data && data.error) msg = data.error; + } catch(e) {} + $.notify(msg, "error"); + } + }); +}); + $('#delete-confirm-input').on('input', function() { var expected = $('#btn-confirm-delete').data("expected-username") || ""; var typed = $(this).val().trim(); diff --git a/exact/exact/administration/urls.py b/exact/exact/administration/urls.py index 696d8900..423ce246 100644 --- a/exact/exact/administration/urls.py +++ b/exact/exact/administration/urls.py @@ -31,7 +31,8 @@ re_path(r'^users/api/(?P\d+)/teams/add/$', views.user_team_add_api, name='user_team_add_api'), re_path(r'^users/api/(?P\d+)/teams/(?P\d+)/remove/$', views.user_team_remove_api, name='user_team_remove_api'), re_path(r'^users/api/(?P\d+)/teams/(?P\d+)/toggle-admin/$', views.user_team_toggle_admin_api, name='user_team_toggle_admin_api'), - + re_path(r'^users/api/(?P\d+)/set-random-password/$', views.user_set_random_password_api, name='user_set_random_password_api'), + re_path(r'^products/edit/(\d+)/$', views.edit_product, name='edit_product'), re_path(r'^plugins/list/$', views.plugins, name='plugins'), re_path(r'^storage/$', views.storage, name='storage'), diff --git a/exact/exact/administration/views.py b/exact/exact/administration/views.py index e6bc2f05..687799dd 100644 --- a/exact/exact/administration/views.py +++ b/exact/exact/administration/views.py @@ -37,6 +37,8 @@ from .permissions import site_admin_required from django.core.exceptions import ValidationError import json +import secrets +import string User = get_user_model() @@ -158,6 +160,31 @@ def user_team_add_api(request, user_id: int): return JsonResponse({"ok": True, "created": created}) +def _generate_password(length: int = 20) -> str: + # Strong, URL-safe-ish password with a wide charset. + alphabet = string.ascii_letters + string.digits + "!@#$%^&*()-_=+" + return "".join(secrets.choice(alphabet) for _ in range(length)) + +@site_admin_required +@require_POST +def user_set_random_password_api(request, user_id: int): + u = get_object_or_404(User, pk=user_id) + + if u.id == request.user.id: + return JsonResponse({"error": "You cannot set a random password for your own account."}, status=400) + + if getattr(u, "is_superuser", False) and not getattr(request.user, "is_superuser", False): + return JsonResponse({"error": "Only superusers may change passwords for superusers."}, status=403) + + new_pw = _generate_password(20) + u.set_password(new_pw) + u.save(update_fields=["password"]) + + # Optional: invalidate sessions by rotating a custom token, if you manage sessions explicitly. + # Django does not automatically log out all sessions on password change unless you implement it. + + return JsonResponse({"ok": True, "password": new_pw}) + @site_admin_required @require_POST def user_team_remove_api(request, user_id: int, team_id: int): From cf192c612d49e53848766794536d86d0f60f4190 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 23 Dec 2025 15:33:18 +0100 Subject: [PATCH 10/16] Small fix to hide new password in other user. --- .../templates/administration/user_management.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/exact/exact/administration/templates/administration/user_management.html b/exact/exact/administration/templates/administration/user_management.html index 6f6712f4..f7b7807f 100644 --- a/exact/exact/administration/templates/administration/user_management.html +++ b/exact/exact/administration/templates/administration/user_management.html @@ -436,6 +436,8 @@ $('#f-date-joined').text(fmt(u.date_joined)); $('#f-last-login').text(fmt(u.last_login)); + $('#random-password-box').hide(); + $('#badge-active') .text('Active: ' + (u.is_active ? 'Yes' : 'No')) .removeClass('badge-success badge-secondary') From b97d9bfee0a7c61d2f317526fe1bc271538acdaf Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 23 Dec 2025 15:42:25 +0100 Subject: [PATCH 11/16] Fixed last_used field. --- exact/exact/administration/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exact/exact/administration/views.py b/exact/exact/administration/views.py index 687799dd..02b828d3 100644 --- a/exact/exact/administration/views.py +++ b/exact/exact/administration/views.py @@ -298,7 +298,7 @@ def user_detail_api(request, user_id: int): passkeys_supported = True # you can replace with real logic passkeys_used = UserPasskey.objects.filter(user=u).count()>0 - passkeys_last_used = UserPasskey.objects.filter(user=u).order_by('-last_used').first().isoformat() if passkeys_used else '' + passkeys_last_used = UserPasskey.objects.filter(user=u).order_by('-last_used').first().last_used.isoformat() if passkeys_used else '' tokens_data = [] try: From c77b1683f137d77f74a54c252c6a6511fe53f6bd Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 23 Dec 2025 16:16:17 +0100 Subject: [PATCH 12/16] Small bugfix. --- exact/exact/users/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exact/exact/users/views.py b/exact/exact/users/views.py index 1a6e5c1f..364515bb 100644 --- a/exact/exact/users/views.py +++ b/exact/exact/users/views.py @@ -345,7 +345,7 @@ def user(request, user_id): if 'frontend' in request.POST and request.POST['frontend']: if not hasattr(user,'prefs'): - user.prefs = UserPreferences.objects.get_or_create(user=user) + user.prefs, _ = UserPreferences.objects.get_or_create(user=user) user.prefs.frontend = int(request.POST['frontend']) user.prefs.save() From fc4d12b2c8fe02c494c5b05c443968ebf7110c0e Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 23 Dec 2025 20:08:14 +0100 Subject: [PATCH 13/16] Changed admin view to only site managers. --- exact/exact/base/templates/base/base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exact/exact/base/templates/base/base.html b/exact/exact/base/templates/base/base.html index 6715fc62..08864b06 100644 --- a/exact/exact/base/templates/base/base.html +++ b/exact/exact/base/templates/base/base.html @@ -98,7 +98,7 @@ Plugins {% endif %} Storage - {% if request.user.is_superuser and request.user.is_staff %} + {% if request.user.is_superuser and request.user.prefs.is_site_admin %} User Management {% endif %}
From 7704c839d18958bb004b5a6dd1e9fac8cac2c740 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 23 Dec 2025 21:14:35 +0100 Subject: [PATCH 14/16] Split up priviledges between site_admin and staff. Only site_admin can delete users in frontend, or change team memberships. --- .../administration/user_management.html | 53 +++++++++++++++++-- exact/exact/administration/views.py | 19 ++++++- exact/exact/base/templates/base/base.html | 2 +- exact/exact/users/__init__.py | 1 + exact/exact/users/apps.py | 4 ++ exact/exact/users/signals.py | 13 +++++ 6 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 exact/exact/users/signals.py diff --git a/exact/exact/administration/templates/administration/user_management.html b/exact/exact/administration/templates/administration/user_management.html index f7b7807f..6c6b218f 100644 --- a/exact/exact/administration/templates/administration/user_management.html +++ b/exact/exact/administration/templates/administration/user_management.html @@ -69,7 +69,10 @@

@@ -148,13 +151,13 @@
Edit user
- {% if user.prefs.site_admin %} + {% if user.prefs.is_site_admin %} {% endif %}
- {% if user.prefs.site_admin %} + {% if user.prefs.is_site_admin %} Deleting a user is irreversible and may remove memberships and tokens. @@ -199,6 +202,7 @@
Access tokens
+ {% if user.prefs.is_site_admin %}
@@ -221,7 +225,16 @@
Manage team memberships
+ {% endif %} +
+
+
+
GUI Mode: +
+
+
+
@@ -294,6 +307,7 @@ .replace('/1/teams/1/remove/', '/' + userId + '/teams/' + teamId + '/remove/'); if (name === "teamToggleAdmin") return "{% url 'administration:user_team_toggle_admin_api' user_id=1 team_id=1 %}" .replace('/1/teams/1/toggle-admin/', '/' + userId + '/teams/' + teamId + '/toggle-admin/'); + return null; } @@ -343,10 +357,11 @@ results.forEach(function(u) { var badge = u.is_active ? '' : ' inactive'; var staffBadge = u.is_staff ? ' staff' : ''; + var siteadminBadge = u.is_site_admin ? ' site admin' : ''; var item = ''; @@ -435,6 +450,7 @@ $('#f-date-joined').text(fmt(u.date_joined)); $('#f-last-login').text(fmt(u.last_login)); + $('#f-ui-frontend').val(u.prefs.frontend); $('#random-password-box').hide(); @@ -633,6 +649,35 @@ }); }); +$('#f-ui-frontend').on('change', function() { + if (!currentUserId) return; + + var payload = { + frontend: $('#f-ui-frontend').val().trim(), + }; + + $.ajax({ + url: urlFor("update", currentUserId), + method: "POST", + contentType: "application/json", + data: JSON.stringify(payload), + headers: { "X-CSRFToken": csrftoken }, + success: function() { + $.notify("User updated.", "success"); + loadUserDetail(currentUserId); + loadUserList(); + }, + error: function(xhr) { + var msg = "Update failed."; + try { + var data = xhr.responseJSON; + if (data && data.error) msg = (typeof data.error === "string") ? data.error : JSON.stringify(data.error); + } catch(e) {} + $.notify(msg, "error"); + } + }); +}); + $(document).on('click', '.team-toggle-admin', function() { if (!currentUserId) return; var teamId = $(this).data('team-id'); @@ -679,7 +724,9 @@ // Initial load $(document).ready(function() { loadUserList(); + {% if user.prefs.is_site_admin %} loadTeamsCatalog(); + {% endif %} }); diff --git a/exact/exact/administration/views.py b/exact/exact/administration/views.py index 02b828d3..154b5f31 100644 --- a/exact/exact/administration/views.py +++ b/exact/exact/administration/views.py @@ -35,6 +35,7 @@ from django.shortcuts import render, get_object_or_404 from exact.users.models import Team, TeamMembership from .permissions import site_admin_required +from exact.users.models import UserPreferences from django.core.exceptions import ValidationError import json import secrets @@ -84,7 +85,7 @@ def product(request, product_id): }) -@site_admin_required +@staff_member_required @require_POST def user_update_api(request, user_id: int): u = get_object_or_404(User, pk=user_id) @@ -103,6 +104,12 @@ def user_update_api(request, user_id: int): if field in payload: setattr(u, field, (payload[field] or "").strip()) + allowed_prefs = ["frontend"] + for field in allowed_prefs: + if field in payload: + setattr(u.prefs, field, (payload[field] or 1)) + u.prefs.save() + # Basic validation – extend as needed if "email" in payload and u.email and "@" not in u.email: return JsonResponse({"error": "Invalid email address."}, status=400) @@ -257,6 +264,11 @@ def user_list_api(request): data = [] for u in qs: + + if getattr(u, "prefs", None) is None: + # Create preferences object for user, if nonexistant + u.prefs, _ = UserPreferences.objects.get_or_create(user=u) + display_name = (f"{u.first_name} {u.last_name}".strip() or u.username) data.append({ "id": u.id, @@ -265,6 +277,7 @@ def user_list_api(request): "email": u.email, "is_active": u.is_active, "is_staff": u.is_staff, + 'is_site_admin' : u.prefs.is_site_admin, }) return JsonResponse({"results": data}) @@ -328,6 +341,7 @@ def user_detail_api(request, user_id: int): "is_active": u.is_active, "is_staff": u.is_staff, "is_superuser": getattr(u, "is_superuser", False), + "prefs": {'frontend': u.prefs.frontend if getattr(u, "prefs", 0) else 0,}, "date_joined": u.date_joined.isoformat() if getattr(u, "date_joined", None) else None, "last_login": u.last_login.isoformat() if getattr(u, "last_login", None) else None, }, @@ -350,6 +364,9 @@ def user_toggle_active_api(request, user_id: int): if u.id == request.user.id: return JsonResponse({"error": "You cannot deactivate your own account."}, status=400) + if u.prefs.is_site_admin: + return JsonResponse({"error": "You cannot deactivate site admin accounts."}, status=400) + u.is_active = not u.is_active u.save(update_fields=["is_active"]) return JsonResponse({"ok": True, "is_active": u.is_active}) diff --git a/exact/exact/base/templates/base/base.html b/exact/exact/base/templates/base/base.html index 08864b06..392d8511 100644 --- a/exact/exact/base/templates/base/base.html +++ b/exact/exact/base/templates/base/base.html @@ -98,7 +98,7 @@ Plugins {% endif %} Storage - {% if request.user.is_superuser and request.user.prefs.is_site_admin %} + {% if request.user.is_staff or request.user.prefs.is_site_admin %} User Management {% endif %} diff --git a/exact/exact/users/__init__.py b/exact/exact/users/__init__.py index e69de29b..7828ddc0 100644 --- a/exact/exact/users/__init__.py +++ b/exact/exact/users/__init__.py @@ -0,0 +1 @@ +default_app_config = "exact.users.apps.UsersConfig" \ No newline at end of file diff --git a/exact/exact/users/apps.py b/exact/exact/users/apps.py index 8e05bd50..0548612b 100644 --- a/exact/exact/users/apps.py +++ b/exact/exact/users/apps.py @@ -4,3 +4,7 @@ class UsersConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'exact.users' + + def ready(self): + from . import signals + \ No newline at end of file diff --git a/exact/exact/users/signals.py b/exact/exact/users/signals.py new file mode 100644 index 00000000..30a4d929 --- /dev/null +++ b/exact/exact/users/signals.py @@ -0,0 +1,13 @@ +from django.contrib.auth import get_user_model +from django.db.models.signals import post_save +from django.dispatch import receiver + +from .models import UserPreferences # your prefs model + +User = get_user_model() + +@receiver(post_save, sender=User) +def ensure_user_preferences(sender, instance, created, **kwargs): + if created: + UserPreferences.objects.get_or_create(user=instance) + \ No newline at end of file From 2486b056a63a1d0f5a3d448c6fcb36c6374c23c6 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 23 Dec 2025 21:17:32 +0100 Subject: [PATCH 15/16] added jquery/notify/etc to login --- exact/exact/users/templates/registration/login.html | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/exact/exact/users/templates/registration/login.html b/exact/exact/users/templates/registration/login.html index 9887432f..96978200 100644 --- a/exact/exact/users/templates/registration/login.html +++ b/exact/exact/users/templates/registration/login.html @@ -2,6 +2,13 @@ {% load i18n %} {% load widget_tweaks %} +{% block additional_js %} + + + +{% endblock additional_js %} + + {% block bodyblock %}
From 6bf861c008a56aa170923a05fc2c2d63d5ec6c6d Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 23 Dec 2025 21:18:58 +0100 Subject: [PATCH 16/16] Though shall never commit without test - ok, fixed that bug. --- exact/exact/users/templates/registration/login.html | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/exact/exact/users/templates/registration/login.html b/exact/exact/users/templates/registration/login.html index 96978200..133d6fee 100644 --- a/exact/exact/users/templates/registration/login.html +++ b/exact/exact/users/templates/registration/login.html @@ -1,12 +1,8 @@ {% extends 'base/base.html' %} +{% load static %} {% load i18n %} {% load widget_tweaks %} -{% block additional_js %} - - - -{% endblock additional_js %} {% block bodyblock %} @@ -53,6 +49,9 @@

Login

{% endblock bodyblock %} {% block additional_js %} + + +