feat: Implement supervisor trainee management features, including search, add, and remove functionalities

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Ross
2026-04-29 22:27:09 +01:00
parent f63f3b3687
commit 89c8cd565f
10 changed files with 500 additions and 82 deletions
+31
View File
@@ -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 = ("",)
@@ -0,0 +1,27 @@
{% if results %}
<div class="card border-secondary bg-light">
<div class="card-body p-2">
<small class="text-muted d-block mb-2">{{ results|length }} result{{ results|pluralize }}</small>
<ul class="list-group list-group-sm">
{% for user in results %}
<li class="list-group-item d-flex justify-content-between align-items-center py-2">
<div>
<div class="small fw-semibold">{{ user.get_full_name|default:user.username }}</div>
<div class="text-muted" style="font-size: 0.85rem;">{{ user.email|default:"no email" }}</div>
</div>
<form method="post" action="{% url 'generic:supervisor_trainee_add' supervisor.pk user.pk %}" style="display:inline;">
{% csrf_token %}
<button type="submit"
class="btn btn-sm btn-outline-success"
hx-post="{% url 'generic:supervisor_trainee_add' supervisor.pk user.pk %}"
hx-target="#trainees-list-content"
hx-swap="outerHTML">
Add
</button>
</form>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
@@ -0,0 +1,55 @@
{% load django_htmx %}
{% if supervisor.trainee.all %}
<ul class="list-group list-group-flush">
{% for trainee in supervisor.trainee.all %}
{% with trainee_user=trainee.user %}
<li class="list-group-item px-0 py-3 trainee-row">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<div class="fw-semibold">
{% if trainee_user %}
{{ trainee_user.get_full_name|default:trainee_user.username }}
{% else %}
{{ trainee }}
{% endif %}
</div>
<div class="text-muted small">
{% if trainee_user and trainee_user.email %}
{{ trainee_user.email }}
{% else %}
No email on file
{% endif %}
</div>
</div>
<div class="d-flex gap-1">
{% if trainee_user %}
<a href="{% url 'account_profile' trainee_user.username %}"
class="btn btn-sm btn-outline-secondary">
Profile
</a>
{% endif %}
{% if request.user == supervisor.user or request.user.is_superuser or request.user.groups.filter|length %}
<form method="post" action="{% url 'generic:supervisor_trainee_remove' supervisor.pk trainee_user.pk %}" style="display:inline;">
{% csrf_token %}
<button type="submit"
class="btn btn-sm btn-outline-danger"
hx-post="{% url 'generic:supervisor_trainee_remove' supervisor.pk trainee_user.pk %}"
hx-target="#trainees-list-content"
hx-swap="outerHTML"
hx-confirm="Remove {{ trainee_user.get_full_name|default:trainee_user.username }}?">
Remove
</button>
</form>
{% endif %}
</div>
</div>
</li>
{% endwith %}
{% endfor %}
</ul>
{% else %}
<div class="alert alert-secondary mb-0">
No trainees are currently linked to this supervisor.
</div>
{% endif %}
@@ -1,29 +1,103 @@
{% extends 'generic/supervisor_base.html' %}
{% block content %}
<h2>Supervisor: <span id="name">{{object.name}}</span></h2>
<span id="email">{{object.email}}</span>
<br/>User account: <span id="user">{{object.user}}</span>
<br/>Site: <span id="site">{{object.site}}</span>
<br/>Trainee(s): {% for trainee in object.trainee.all %}
<a href="{% url 'account_profile' trainee.user.username %}">{{trainee.user}}
{% endfor %}
<div><a href="{% url 'generic:supervisor_overview' pk=object.pk %}">Supervisor overview</a></div>
<div>
<a href="{% url 'generic:supervisor_edit' pk=object.pk %}"> Edit</a>
<a href="{% url 'generic:supervisor_delete' pk=object.pk %}"> Delete</a>
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h2 class="mb-1">{{ object.name }}</h2>
<p class="text-muted mb-0">
Supervisor profile and trainee links.
</p>
</div>
<div class="text-md-end">
<span class="badge bg-info-subtle text-info-emphasis rounded-pill px-3 py-2">
{{ object.trainee.count }} trainee{{ object.trainee.count|pluralize }}
</span>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-lg-7">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<h5 class="card-title mb-3">Supervisor Details</h5>
<dl class="row mb-0">
<dt class="col-sm-4 text-muted">Name</dt>
<dd class="col-sm-8" id="name">{{ object.name|default:"-" }}</dd>
<dt class="col-sm-4 text-muted">Email</dt>
<dd class="col-sm-8" id="email">
{% if object.email %}
<a href="mailto:{{ object.email }}" class="link-body-emphasis">{{ object.email }}</a>
{% else %}
-
{% endif %}
</dd>
<dt class="col-sm-4 text-muted">User Account</dt>
<dd class="col-sm-8" id="user">{{ object.user|default:"-" }}</dd>
<dt class="col-sm-4 text-muted">Site</dt>
<dd class="col-sm-8" id="site">{{ object.site|default:"-" }}</dd>
</dl>
</div>
</div>
</div>
<div class="col-12 col-lg-5">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<h5 class="card-title mb-3">Actions</h5>
<div class="d-grid gap-2">
<a class="btn btn-outline-primary" href="{% url 'generic:supervisor_overview' pk=object.pk %}">
Supervisor Overview
</a>
{% if request.user.is_superuser or request.user.groups.filter|length %}
<a class="btn btn-outline-secondary" href="{% url 'generic:supervisor_edit' pk=object.pk %}">
Edit Supervisor
</a>
<a class="btn btn-outline-danger" href="{% url 'generic:supervisor_delete' pk=object.pk %}">
Delete Supervisor
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="card-title mb-0">Trainees</h5>
<span class="text-muted small">{{ object.trainee.count }} linked</span>
</div>
{% if request.user == object.user or request.user.is_superuser or request.user.groups.filter|length %}
<div class="mb-3">
<label class="form-label small text-muted">Search to add trainee</label>
<input type="search"
class="form-control form-control-sm"
placeholder="Name, email, username…"
hx-get="{% url 'generic:supervisor_trainee_search' object.pk %}"
hx-target="#trainee-search-results"
hx-trigger="input delay:300ms"
name="q">
</div>
<div id="trainee-search-results" class="mb-3"></div>
{% endif %}
<div id="trainees-list-content">
{% include 'generic/partials/supervisor_trainees_list.html' with supervisor=object %}
</div>
</div>
</div>
{% endblock %}
{% block js %}
{% block css %}
{{ block.super }}
<style>
td, th { padding-left: 10px }
.trainee-row:not(:last-child) {
border-bottom: 1px solid var(--bs-border-color);
}
</style>
{% endblock %}
+15
View File
@@ -256,6 +256,21 @@ urlpatterns = [
views.SupervisorDelete.as_view(),
name="supervisor_delete",
),
path(
"supervisor/<int:pk>/trainee/search",
views.supervisor_trainee_search,
name="supervisor_trainee_search",
),
path(
"supervisor/<int:pk>/trainee/<int:user_id>/add",
views.supervisor_trainee_add,
name="supervisor_trainee_add",
),
path(
"supervisor/<int:pk>/trainee/<int:user_id>/remove",
views.supervisor_trainee_remove,
name="supervisor_trainee_remove",
),
path(
"examcollection/",
views.ExamCollectionList.as_view(),
+124 -1
View File
@@ -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