diff --git a/atlas/templates/atlas/supervision.html b/atlas/templates/atlas/supervision.html index 4264fa6c..9a50ac7f 100644 --- a/atlas/templates/atlas/supervision.html +++ b/atlas/templates/atlas/supervision.html @@ -2,11 +2,11 @@ {% load static %} {% block css %} -{{ block.super }} - + {{ block.super }} + {% endblock %} {% block content %} @@ -99,70 +99,70 @@
-
- - - - {% if group_by == 'user' %} - - {% else %} - - {% endif %} - - - - - - - - {% for attempt, collection in attempt_rows %} +
+
CollectionLearnerStartedEndedStatus
+ {% if group_by == 'user' %} - + {% else %} - + {% endif %} + + + + + + + + {% for attempt, collection in attempt_rows %} + + {% if group_by == 'user' %} + + {% else %} + + {% endif %} + + + - {% endif %} - - - - - - {% endfor %} - -
- {% if collection %}{{ collection.name }}{% else %}Unknown{% endif %} - Collection - {% if attempt.user_user %} - {{ attempt.user_user.get_full_name|default:attempt.user_user.username }} - {% if attempt.user_user.email %} -
{{ attempt.user_user.email }} +
LearnerStartedEndedStatus
+ {% if collection %}{{ collection.name }}{% else %}Unknown{% endif %} + + {% if attempt.user_user %} + {{ attempt.user_user.get_full_name|default:attempt.user_user.username }} + {% if attempt.user_user.email %} +
{{ attempt.user_user.email }} + {% endif %} + {% elif attempt.cid_user %} + CID {{ attempt.cid_user.cid }} + {% if attempt.cid_user.name %}
{{ attempt.cid_user.name }}{% endif %} + {% else %} + Unknown {% endif %} - {% elif attempt.cid_user %} - CID {{ attempt.cid_user.cid }} - {% if attempt.cid_user.name %}
{{ attempt.cid_user.name }}{% endif %} +
{{ attempt.start_time|default:"-" }}{{ attempt.end_time|default:"-" }} + {% if attempt.completed %} + Completed {% else %} - Unknown + In progress {% endif %} {{ attempt.start_time|default:"-" }}{{ attempt.end_time|default:"-" }} - {% if attempt.completed %} - Completed - {% else %} - In progress - {% endif %} - - {% if attempt.user_user_id and collection %} - - View attempt - - {% else %} - - View attempt - - {% endif %} -
-
+ + {% if attempt.user_user_id and collection %} + + View attempt + + {% else %} + + View attempt + + {% endif %} + + + {% endfor %} + + +
{# /collapse #} {% endfor %} diff --git a/generic/forms.py b/generic/forms.py index d42e97e5..31a4621a 100755 --- a/generic/forms.py +++ b/generic/forms.py @@ -835,6 +835,37 @@ class TraineeForm(Form): class SupervisorForm(ModelForm): + # UserSearchWidget returns list-like values; this field normalises to a + # single selected user for the Supervisor.user OneToOne relation. + user = ModelChoiceField( + queryset=User.objects.all(), + required=False, + widget=UserSearchWidget(), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + user_field = self.fields.get("user") + if user_field: + user_field.widget = UserSearchWidget(user_field) + + def clean_user(self): + value = self.cleaned_data.get("user") + + if isinstance(value, (list, tuple)): + value = value[0] if value else None + + if value is None: + return None + + if isinstance(value, User): + return value + + try: + return User.objects.get(pk=value) + except (TypeError, ValueError, User.DoesNotExist): + raise ValidationError("Select a valid user.") + class Meta: model = Supervisor exclude = ("",) diff --git a/generic/templates/generic/partials/supervisor_trainee_search_results.html b/generic/templates/generic/partials/supervisor_trainee_search_results.html new file mode 100644 index 00000000..4005ada2 --- /dev/null +++ b/generic/templates/generic/partials/supervisor_trainee_search_results.html @@ -0,0 +1,27 @@ +{% if results %} +
+
+ {{ results|length }} result{{ results|pluralize }} + +
+
+{% endif %} diff --git a/generic/templates/generic/partials/supervisor_trainees_list.html b/generic/templates/generic/partials/supervisor_trainees_list.html new file mode 100644 index 00000000..33634075 --- /dev/null +++ b/generic/templates/generic/partials/supervisor_trainees_list.html @@ -0,0 +1,55 @@ +{% load django_htmx %} +{% if supervisor.trainee.all %} + +{% else %} +
+ No trainees are currently linked to this supervisor. +
+{% endif %} diff --git a/generic/templates/generic/supervisor_detail.html b/generic/templates/generic/supervisor_detail.html index 608b4d5c..02aa81f9 100755 --- a/generic/templates/generic/supervisor_detail.html +++ b/generic/templates/generic/supervisor_detail.html @@ -1,29 +1,103 @@ {% extends 'generic/supervisor_base.html' %} {% block content %} -

Supervisor: {{object.name}}

- {{object.email}} - -
User account: {{object.user}} -
Site: {{object.site}} -
Trainee(s): {% for trainee in object.trainee.all %} - {{trainee.user}} - - {% endfor %} - -
Supervisor overview
- - -
- Edit - Delete +
+
+

{{ object.name }}

+

+ Supervisor profile and trainee links. +

+
+
+ + {{ object.trainee.count }} trainee{{ object.trainee.count|pluralize }} + +
+
+
+
+
+
Supervisor Details
+
+
Name
+
{{ object.name|default:"-" }}
+ +
Email
+
+ {% if object.email %} + {{ object.email }} + {% else %} + - + {% endif %} +
+ +
User Account
+
{{ object.user|default:"-" }}
+ +
Site
+
{{ object.site|default:"-" }}
+
+
+
+
+ +
+
+
+
Actions
+
+ + Supervisor Overview + + {% if request.user.is_superuser or request.user.groups.filter|length %} + + Edit Supervisor + + + Delete Supervisor + + {% endif %} +
+
+
+
+
+ +
+
+
+
Trainees
+ {{ object.trainee.count }} linked +
+ + {% if request.user == object.user or request.user.is_superuser or request.user.groups.filter|length %} +
+ + +
+
+ {% endif %} + +
+ {% include 'generic/partials/supervisor_trainees_list.html' with supervisor=object %} +
+
+
{% endblock %} -{% block js %} - +{% block css %} + {{ block.super }} {% endblock %} \ No newline at end of file diff --git a/generic/urls.py b/generic/urls.py index 4da768a0..2f5adfd7 100755 --- a/generic/urls.py +++ b/generic/urls.py @@ -256,6 +256,21 @@ urlpatterns = [ views.SupervisorDelete.as_view(), name="supervisor_delete", ), + path( + "supervisor//trainee/search", + views.supervisor_trainee_search, + name="supervisor_trainee_search", + ), + path( + "supervisor//trainee//add", + views.supervisor_trainee_add, + name="supervisor_trainee_add", + ), + path( + "supervisor//trainee//remove", + views.supervisor_trainee_remove, + name="supervisor_trainee_remove", + ), path( "examcollection/", views.ExamCollectionList.as_view(), diff --git a/generic/views.py b/generic/views.py index 04779661..6a2139a1 100644 --- a/generic/views.py +++ b/generic/views.py @@ -12,6 +12,7 @@ from django.db.models.functions import Coalesce, Lower from django.shortcuts import render, get_object_or_404, redirect from django.contrib.auth.decorators import login_required, user_passes_test +from django.views.decorators.http import require_POST from django.urls import reverse_lazy from django.urls.base import reverse from django.utils import timezone @@ -5910,9 +5911,131 @@ def create_user(request, context=None, trainee: bool = False): return render(request, form_template, {"form": form}) -class SupervisorDetail(CidManagerRequiredMixin, DetailView): +class SupervisorDetail(DetailView): model = Supervisor + def get_queryset(self): + """Allow supervisors to view their own detail page; managers see all.""" + qs = super().get_queryset() + if not self.request.user.is_authenticated: + return qs.none() + if self.request.user.is_superuser or self.request.user.groups.filter(name="cid_user_manager").exists(): + return qs + if hasattr(self.request.user, "supervisor"): + return qs.filter(user=self.request.user) + return qs.none() + + def get_object(self, queryset=None): + """Override to check permissions before returning object.""" + obj = super().get_object(queryset) + if not ( + self.request.user.is_superuser + or self.request.user.groups.filter(name="cid_user_manager").exists() + or (hasattr(self.request.user, "supervisor") and obj.user == self.request.user) + ): + raise PermissionDenied + return obj + + +@user_is_cid_user_manager +def supervisor_trainee_search(request, pk): + """HTMX endpoint: search for users to add as trainees to a supervisor. + Scope: managers and the supervisor themselves. + """ + supervisor = get_object_or_404(Supervisor, pk=pk) + + if not ( + request.user.is_superuser + or request.user.groups.filter(name="cid_user_manager").exists() + or supervisor.user == request.user + ): + raise PermissionDenied + + q = request.GET.get("q", "").strip() + if not q: + return render( + request, + "generic/partials/supervisor_trainee_search_results.html", + {"supervisor": supervisor, "results": []}, + ) + + already_trainees = supervisor.trainee.values_list("user_id", flat=True) + results = ( + User.objects.filter( + Q(first_name__icontains=q) + | Q(last_name__icontains=q) + | Q(username__icontains=q) + | Q(email__icontains=q) + ) + .exclude(pk__in=already_trainees) + .order_by("first_name", "last_name", "username")[:20] + ) + + return render( + request, + "generic/partials/supervisor_trainee_search_results.html", + {"supervisor": supervisor, "results": results}, + ) + + +@require_POST +@user_is_cid_user_manager +def supervisor_trainee_add(request, pk, user_id): + """Add a trainee to a supervisor. + Scope: managers and the supervisor themselves. + """ + supervisor = get_object_or_404(Supervisor, pk=pk) + trainee_user = get_object_or_404(User, pk=user_id) + + if not ( + request.user.is_superuser + or request.user.groups.filter(name="cid_user_manager").exists() + or supervisor.user == request.user + ): + raise PermissionDenied + + profile = trainee_user.userprofile + profile.supervisor = supervisor + profile.save() + + if request.htmx: + return render( + request, + "generic/partials/supervisor_trainees_list.html", + {"supervisor": supervisor}, + ) + return redirect(supervisor.get_absolute_url()) + + +@require_POST +@user_is_cid_user_manager +def supervisor_trainee_remove(request, pk, user_id): + """Remove a trainee from a supervisor. + Scope: managers and the supervisor themselves. + """ + supervisor = get_object_or_404(Supervisor, pk=pk) + trainee_user = get_object_or_404(User, pk=user_id) + + if not ( + request.user.is_superuser + or request.user.groups.filter(name="cid_user_manager").exists() + or supervisor.user == request.user + ): + raise PermissionDenied + + profile = trainee_user.userprofile + if profile.supervisor == supervisor: + profile.supervisor = None + profile.save() + + if request.htmx: + return render( + request, + "generic/partials/supervisor_trainees_list.html", + {"supervisor": supervisor}, + ) + return redirect(supervisor.get_absolute_url()) + class SupervisorDelete(CidManagerRequiredMixin, DeleteView): model = Supervisor diff --git a/rad/urls.py b/rad/urls.py index c2c633d8..c25afb92 100644 --- a/rad/urls.py +++ b/rad/urls.py @@ -99,6 +99,11 @@ urlpatterns = [ path("accounts/", include("django.contrib.auth.urls")), path("accounts/profile", views.profile, name="profile"), path("accounts/profile//", views.account_profile, name="account_profile"), + path( + "accounts/profile//set-supervisor/", + views.account_set_supervisor, + name="account_set_supervisor", + ), #path("", TemplateView.as_view(template_name="index.html"), name="home"), path("", views.index, name="home"), path("cid/results//", views.cid_results, name="cid_results"), diff --git a/rad/views.py b/rad/views.py index c5146ad1..0e166177 100644 --- a/rad/views.py +++ b/rad/views.py @@ -45,6 +45,7 @@ from django.urls import reverse_lazy, reverse from django.http import Http404, JsonResponse from django.http import HttpResponseRedirect, HttpResponse +from django.views.decorators.http import require_POST from physics.models import UserAnswer as PhysicsUserAnswer from physics.models import Exam as PhysicsExam @@ -120,6 +121,12 @@ def people(request): @login_required def profile(request): + """Render the current user's profile page with group management context. + + Scope: authenticated user's own profile view. + Functionality: provides user details, available groups, and supervisor + account state for template actions. + """ user = request.user available_groups = UserUserGroup.objects.filter(archive=False).exclude( pk__in=user.user_groups.values_list("pk", flat=True) @@ -128,6 +135,7 @@ def profile(request): pk__in=user.groups.values_list("pk", flat=True) ) is_cid_user_manager = request.user.groups.filter(name="cid_user_manager").exists() + supervisor_account = Supervisor.objects.filter(user=user).first() return render( request, "profile.html", @@ -136,6 +144,8 @@ def profile(request): "available_groups": available_groups, "available_auth_groups": available_auth_groups, "is_cid_user_manager": is_cid_user_manager, + "supervisor_account": supervisor_account, + "supervisor_action": request.GET.get("supervisor_action", ""), }, ) @@ -160,6 +170,12 @@ def index(request): @user_is_cid_user_manager def account_profile(request, slug): + """Render an account profile page for administrators. + + Scope: cid_user_manager and superusers viewing another user's profile. + Functionality: exposes profile details, group controls, and supervisor + account creation/link status for admin actions. + """ user = get_object_or_404(User, username=slug) available_groups = UserUserGroup.objects.filter(archive=False).exclude( pk__in=user.user_groups.values_list("pk", flat=True) @@ -168,6 +184,7 @@ def account_profile(request, slug): pk__in=user.groups.values_list("pk", flat=True) ) is_cid_user_manager = request.user.groups.filter(name="cid_user_manager").exists() + supervisor_account = Supervisor.objects.filter(user=user).first() return render( request, "profile.html", @@ -176,10 +193,49 @@ def account_profile(request, slug): "available_groups": available_groups, "available_auth_groups": available_auth_groups, "is_cid_user_manager": is_cid_user_manager, + "supervisor_account": supervisor_account, + "supervisor_action": request.GET.get("supervisor_action", ""), }, ) +@require_POST +@user_is_cid_user_manager +def account_set_supervisor(request, slug): + """Create or link a Supervisor record for an existing user. + + Scope: cid_user_manager and superusers only. + Functionality: one-click action from profile pages to assign a user as a + supervisor account, reusing an existing supervisor by email when safe. + """ + user = get_object_or_404(User, username=slug) + + next_url = request.POST.get("next") or reverse("account_profile", kwargs={"slug": user.username}) + + email = (user.email or "").strip().lower() + if not email: + return redirect(f"{next_url}?supervisor_action=no_email") + + existing_for_user = Supervisor.objects.filter(user=user).first() + if existing_for_user: + return redirect(f"{next_url}?supervisor_action=already_linked") + + supervisor = Supervisor.objects.filter(email__iexact=email).first() + if supervisor: + if supervisor.user and supervisor.user != user: + return redirect(f"{next_url}?supervisor_action=email_in_use") + + supervisor.user = user + if not supervisor.name: + supervisor.name = user.get_full_name().strip() or user.username + supervisor.save() + return redirect(f"{next_url}?supervisor_action=linked") + + name = user.get_full_name().strip() or user.username + Supervisor.objects.create(email=email, name=name, user=user) + return redirect(f"{next_url}?supervisor_action=created") + + def cid_selector(request): return render( request, diff --git a/templates/profile.html b/templates/profile.html index 75e5a3c0..a12adc6a 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -83,13 +83,45 @@ None {% endif %} +
  • Supervisor account: + {% if supervisor_account %} + {{ supervisor_account.name }} + {% else %} + Not linked + {% endif %} +
  • + {% if supervisor_action == 'created' %} + + {% elif supervisor_action == 'linked' %} + + {% elif supervisor_action == 'already_linked' %} + + {% elif supervisor_action == 'no_email' %} + + {% elif supervisor_action == 'email_in_use' %} + + {% endif %} + Change password {% if request.user|has_group:"cid_user_manager" %} Update user details {% endif %} Edit profile + + {% if request.user.is_superuser or is_cid_user_manager %} + {% if not supervisor_account %} +
    + {% csrf_token %} + + +
    + {% else %} + Open supervisor record + {% endif %} + {% endif %} + {% if request.user == user %} API tokens {% endif %}