diff --git a/exact/exact/administration/permissions.py b/exact/exact/administration/permissions.py new file mode 100644 index 00000000..7a039411 --- /dev/null +++ b/exact/exact/administration/permissions.py @@ -0,0 +1,24 @@ +# administration/permissions.py +from functools import wraps +from django.http import HttpResponseForbidden +from django.shortcuts import redirect +from django.urls import reverse + +def site_admin_required(view_func): + """ + Allows access only to authenticated users with user.prefs.site_admin == True. + Returns 403 for AJAX/JSON requests; redirects to login for anonymous users. + """ + @wraps(view_func) + def _wrapped(request, *args, **kwargs): + user = request.user + if not user.is_authenticated: + # If you prefer 401 JSON instead, adjust here. + return redirect(f"{reverse('login')}?next={request.path}") + + prefs = getattr(user, "prefs", None) + if not prefs or not getattr(prefs, "is_site_admin", False): + return HttpResponseForbidden("Site admin permission required.") + return view_func(request, *args, **kwargs) + + return _wrapped diff --git a/exact/exact/administration/templates/administration/user_management.html b/exact/exact/administration/templates/administration/user_management.html new file mode 100644 index 00000000..6c6b218f --- /dev/null +++ b/exact/exact/administration/templates/administration/user_management.html @@ -0,0 +1,735 @@ +{% extends 'base/base.html' %} +{% load static %} +{% load i18n %} + +{% block taggerimports %} + + +{% 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..423ce246 100644 --- a/exact/exact/administration/urls.py +++ b/exact/exact/administration/urls.py @@ -18,6 +18,21 @@ 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'^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 4bc90be6..154b5f31 100644 --- a/exact/exact/administration/views.py +++ b/exact/exact/administration/views.py @@ -17,16 +17,32 @@ 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 exact.users.models import UserPreferences +from django.core.exceptions import ValidationError +import json +import secrets +import string + +User = get_user_model() + def logs(request): @@ -69,6 +85,330 @@ def product(request, product_id): }) +@staff_member_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()) + + 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) + + 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}) + + +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 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: + + 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, + "username": u.username, + "display_name": display_name, + "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}) + + +@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().last_used.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), + "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, + }, + "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) + + 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}) + + +@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/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/base/templates/base/base.html b/exact/exact/base/templates/base/base.html index 819a5a74..392d8511 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_staff or request.user.prefs.is_site_admin %} + User Management + {% endif %} {% if my_teams %} 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/__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/admin.py b/exact/exact/users/admin.py index b1c3c28c..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, UI_User +from .models import Team, TeamMembership, User, UserPreferences, PersonalAccessToken class CustomUserAdmin(UserAdmin): list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'last_login') @@ -9,4 +9,5 @@ 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) +admin.site.register(PersonalAccessToken) \ 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/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/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 2e3b84fc..1b822721 100644 --- a/exact/exact/users/models.py +++ b/exact/exact/users/models.py @@ -8,24 +8,56 @@ 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 - - frontend = models.IntegerField(choices=Frontends.choices, default=1) + DEFAULT = 1, "Default" + LIGHTROOM = 2, "Lightroom" + frontend = models.IntegerField(choices=Frontends.choices, default=Frontends.LIGHTROOM) + is_site_admin = models.BooleanField(default=False) + 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/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 diff --git a/exact/exact/users/templates/registration/login.html b/exact/exact/users/templates/registration/login.html index 971f523a..133d6fee 100644 --- a/exact/exact/users/templates/registration/login.html +++ b/exact/exact/users/templates/registration/login.html @@ -1,7 +1,10 @@ {% extends 'base/base.html' %} +{% load static %} {% load i18n %} {% load widget_tweaks %} + + {% block bodyblock %}
@@ -45,7 +48,10 @@

Login

{% endblock bodyblock %} -{% block extra_js %} +{% block additional_js %} + + + - {% endblock taggerimports %} +{% block additional_js %} + + +{% endblock additional_js %} + {% 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/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..f298ef08 100644 --- a/exact/exact/users/urls.py +++ b/exact/exact/users/urls.py @@ -1,7 +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 = [ @@ -18,4 +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)), ] + diff --git a/exact/exact/users/views.py b/exact/exact/users/views.py index 117048a7..364515bb 100644 --- a/exact/exact/users/views.py +++ b/exact/exact/users/views.py @@ -19,8 +19,20 @@ 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 +from exact.users.models import TeamMembership + @login_required def create_team(request): @@ -61,6 +73,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): @@ -259,33 +323,42 @@ 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) 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 (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 request.POST['first_name']: + + if 'password1' not in request.POST or 'password2' not in request.POST: + passwordmatching=True + + 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 '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() @@ -293,12 +366,11 @@ def user(request, user_id): info = 'Information updated.' - - 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, + 'memberships' : memberships, 'info': info, 'passwordmatching' : passwordmatching, 'teams': teams,