feat: Implement supervisor trainee management features, including search, add, and remove functionalities
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -2,11 +2,11 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
{% block css %}
|
{% block css %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
<style>
|
<style>
|
||||||
.collapse-chevron { transition: transform .2s ease; }
|
.collapse-chevron { transition: transform .2s ease; }
|
||||||
[aria-expanded="true"] .collapse-chevron { transform: rotate(-180deg); }
|
[aria-expanded="true"] .collapse-chevron { transform: rotate(-180deg); }
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -99,70 +99,70 @@
|
|||||||
<i class="bi bi-chevron-down ms-2 small collapse-chevron" aria-hidden="true"></i>
|
<i class="bi bi-chevron-down ms-2 small collapse-chevron" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse" id="group-{{ forloop.counter }}">
|
<div class="collapse" id="group-{{ forloop.counter }}">
|
||||||
<div class="table-responsive mt-2">
|
<div class="table-responsive mt-2">
|
||||||
<table class="table table-hover table-sm align-middle mb-0">
|
<table class="table table-hover table-sm align-middle mb-0">
|
||||||
<thead class="table-dark">
|
<thead class="table-dark">
|
||||||
<tr>
|
|
||||||
{% if group_by == 'user' %}
|
|
||||||
<th>Collection</th>
|
|
||||||
{% else %}
|
|
||||||
<th>Learner</th>
|
|
||||||
{% endif %}
|
|
||||||
<th>Started</th>
|
|
||||||
<th>Ended</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for attempt, collection in attempt_rows %}
|
|
||||||
<tr>
|
<tr>
|
||||||
{% if group_by == 'user' %}
|
{% if group_by == 'user' %}
|
||||||
<td>
|
<th>Collection</th>
|
||||||
{% if collection %}{{ collection.name }}{% else %}<span class="text-muted">Unknown</span>{% endif %}
|
|
||||||
</td>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<td>
|
<th>Learner</th>
|
||||||
{% if attempt.user_user %}
|
{% endif %}
|
||||||
{{ attempt.user_user.get_full_name|default:attempt.user_user.username }}
|
<th>Started</th>
|
||||||
{% if attempt.user_user.email %}
|
<th>Ended</th>
|
||||||
<br><span class="text-muted small">{{ attempt.user_user.email }}</span>
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for attempt, collection in attempt_rows %}
|
||||||
|
<tr>
|
||||||
|
{% if group_by == 'user' %}
|
||||||
|
<td>
|
||||||
|
{% if collection %}{{ collection.name }}{% else %}<span class="text-muted">Unknown</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% else %}
|
||||||
|
<td>
|
||||||
|
{% if attempt.user_user %}
|
||||||
|
{{ attempt.user_user.get_full_name|default:attempt.user_user.username }}
|
||||||
|
{% if attempt.user_user.email %}
|
||||||
|
<br><span class="text-muted small">{{ attempt.user_user.email }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% elif attempt.cid_user %}
|
||||||
|
CID {{ attempt.cid_user.cid }}
|
||||||
|
{% if attempt.cid_user.name %}<br><span class="text-muted small">{{ attempt.cid_user.name }}</span>{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Unknown</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif attempt.cid_user %}
|
</td>
|
||||||
CID {{ attempt.cid_user.cid }}
|
{% endif %}
|
||||||
{% if attempt.cid_user.name %}<br><span class="text-muted small">{{ attempt.cid_user.name }}</span>{% endif %}
|
<td class="small text-nowrap">{{ attempt.start_time|default:"-" }}</td>
|
||||||
|
<td class="small text-nowrap">{{ attempt.end_time|default:"-" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if attempt.completed %}
|
||||||
|
<span class="badge bg-success">Completed</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">Unknown</span>
|
<span class="badge bg-warning text-dark">In progress</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endif %}
|
<td>
|
||||||
<td class="small text-nowrap">{{ attempt.start_time|default:"-" }}</td>
|
{% if attempt.user_user_id and collection %}
|
||||||
<td class="small text-nowrap">{{ attempt.end_time|default:"-" }}</td>
|
<a class="btn btn-sm btn-outline-primary"
|
||||||
<td>
|
href="{% url 'atlas:collection_history_user' exam_id=collection.pk user_pk=attempt.user_user_id %}">
|
||||||
{% if attempt.completed %}
|
View attempt
|
||||||
<span class="badge bg-success">Completed</span>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-warning text-dark">In progress</span>
|
<a class="btn btn-sm btn-outline-primary"
|
||||||
{% endif %}
|
href="{% url 'atlas:collection_shared_attempt_overview' attempt.pk %}">
|
||||||
</td>
|
View attempt
|
||||||
<td>
|
</a>
|
||||||
{% if attempt.user_user_id and collection %}
|
{% endif %}
|
||||||
<a class="btn btn-sm btn-outline-primary"
|
</td>
|
||||||
href="{% url 'atlas:collection_history_user' exam_id=collection.pk user_pk=attempt.user_user_id %}">
|
</tr>
|
||||||
View attempt
|
{% endfor %}
|
||||||
</a>
|
</tbody>
|
||||||
{% else %}
|
</table>
|
||||||
<a class="btn btn-sm btn-outline-primary"
|
</div>
|
||||||
href="{% url 'atlas:collection_shared_attempt_overview' attempt.pk %}">
|
|
||||||
View attempt
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>{# /collapse #}
|
</div>{# /collapse #}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -835,6 +835,37 @@ class TraineeForm(Form):
|
|||||||
|
|
||||||
|
|
||||||
class SupervisorForm(ModelForm):
|
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:
|
class Meta:
|
||||||
model = Supervisor
|
model = Supervisor
|
||||||
exclude = ("",)
|
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' %}
|
{% extends 'generic/supervisor_base.html' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Supervisor: <span id="name">{{object.name}}</span></h2>
|
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
|
||||||
<span id="email">{{object.email}}</span>
|
<div>
|
||||||
|
<h2 class="mb-1">{{ object.name }}</h2>
|
||||||
<br/>User account: <span id="user">{{object.user}}</span>
|
<p class="text-muted mb-0">
|
||||||
<br/>Site: <span id="site">{{object.site}}</span>
|
Supervisor profile and trainee links.
|
||||||
<br/>Trainee(s): {% for trainee in object.trainee.all %}
|
</p>
|
||||||
<a href="{% url 'account_profile' trainee.user.username %}">{{trainee.user}}
|
</div>
|
||||||
|
<div class="text-md-end">
|
||||||
{% endfor %}
|
<span class="badge bg-info-subtle text-info-emphasis rounded-pill px-3 py-2">
|
||||||
|
{{ object.trainee.count }} trainee{{ object.trainee.count|pluralize }}
|
||||||
<div><a href="{% url 'generic:supervisor_overview' pk=object.pk %}">Supervisor overview</a></div>
|
</span>
|
||||||
|
</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>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block js %}
|
{% block css %}
|
||||||
|
{{ block.super }}
|
||||||
<style>
|
<style>
|
||||||
td, th { padding-left: 10px }
|
.trainee-row:not(:last-child) {
|
||||||
|
border-bottom: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -256,6 +256,21 @@ urlpatterns = [
|
|||||||
views.SupervisorDelete.as_view(),
|
views.SupervisorDelete.as_view(),
|
||||||
name="supervisor_delete",
|
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(
|
path(
|
||||||
"examcollection/",
|
"examcollection/",
|
||||||
views.ExamCollectionList.as_view(),
|
views.ExamCollectionList.as_view(),
|
||||||
|
|||||||
+124
-1
@@ -12,6 +12,7 @@ from django.db.models.functions import Coalesce, Lower
|
|||||||
from django.shortcuts import render, get_object_or_404, redirect
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
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 import reverse_lazy
|
||||||
from django.urls.base import reverse
|
from django.urls.base import reverse
|
||||||
from django.utils import timezone
|
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})
|
return render(request, form_template, {"form": form})
|
||||||
|
|
||||||
|
|
||||||
class SupervisorDetail(CidManagerRequiredMixin, DetailView):
|
class SupervisorDetail(DetailView):
|
||||||
model = Supervisor
|
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):
|
class SupervisorDelete(CidManagerRequiredMixin, DeleteView):
|
||||||
model = Supervisor
|
model = Supervisor
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ urlpatterns = [
|
|||||||
path("accounts/", include("django.contrib.auth.urls")),
|
path("accounts/", include("django.contrib.auth.urls")),
|
||||||
path("accounts/profile", views.profile, name="profile"),
|
path("accounts/profile", views.profile, name="profile"),
|
||||||
path("accounts/profile/<str:slug>/", views.account_profile, name="account_profile"),
|
path("accounts/profile/<str:slug>/", views.account_profile, name="account_profile"),
|
||||||
|
path(
|
||||||
|
"accounts/profile/<str:slug>/set-supervisor/",
|
||||||
|
views.account_set_supervisor,
|
||||||
|
name="account_set_supervisor",
|
||||||
|
),
|
||||||
#path("", TemplateView.as_view(template_name="index.html"), name="home"),
|
#path("", TemplateView.as_view(template_name="index.html"), name="home"),
|
||||||
path("", views.index, name="home"),
|
path("", views.index, name="home"),
|
||||||
path("cid/results/<int:cid>/", views.cid_results, name="cid_results"),
|
path("cid/results/<int:cid>/", views.cid_results, name="cid_results"),
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from django.urls import reverse_lazy, reverse
|
|||||||
from django.http import Http404, JsonResponse
|
from django.http import Http404, JsonResponse
|
||||||
|
|
||||||
from django.http import HttpResponseRedirect, HttpResponse
|
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 UserAnswer as PhysicsUserAnswer
|
||||||
from physics.models import Exam as PhysicsExam
|
from physics.models import Exam as PhysicsExam
|
||||||
@@ -120,6 +121,12 @@ def people(request):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def profile(request):
|
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
|
user = request.user
|
||||||
available_groups = UserUserGroup.objects.filter(archive=False).exclude(
|
available_groups = UserUserGroup.objects.filter(archive=False).exclude(
|
||||||
pk__in=user.user_groups.values_list("pk", flat=True)
|
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)
|
pk__in=user.groups.values_list("pk", flat=True)
|
||||||
)
|
)
|
||||||
is_cid_user_manager = request.user.groups.filter(name="cid_user_manager").exists()
|
is_cid_user_manager = request.user.groups.filter(name="cid_user_manager").exists()
|
||||||
|
supervisor_account = Supervisor.objects.filter(user=user).first()
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"profile.html",
|
"profile.html",
|
||||||
@@ -136,6 +144,8 @@ def profile(request):
|
|||||||
"available_groups": available_groups,
|
"available_groups": available_groups,
|
||||||
"available_auth_groups": available_auth_groups,
|
"available_auth_groups": available_auth_groups,
|
||||||
"is_cid_user_manager": is_cid_user_manager,
|
"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
|
@user_is_cid_user_manager
|
||||||
def account_profile(request, slug):
|
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)
|
user = get_object_or_404(User, username=slug)
|
||||||
available_groups = UserUserGroup.objects.filter(archive=False).exclude(
|
available_groups = UserUserGroup.objects.filter(archive=False).exclude(
|
||||||
pk__in=user.user_groups.values_list("pk", flat=True)
|
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)
|
pk__in=user.groups.values_list("pk", flat=True)
|
||||||
)
|
)
|
||||||
is_cid_user_manager = request.user.groups.filter(name="cid_user_manager").exists()
|
is_cid_user_manager = request.user.groups.filter(name="cid_user_manager").exists()
|
||||||
|
supervisor_account = Supervisor.objects.filter(user=user).first()
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"profile.html",
|
"profile.html",
|
||||||
@@ -176,10 +193,49 @@ def account_profile(request, slug):
|
|||||||
"available_groups": available_groups,
|
"available_groups": available_groups,
|
||||||
"available_auth_groups": available_auth_groups,
|
"available_auth_groups": available_auth_groups,
|
||||||
"is_cid_user_manager": is_cid_user_manager,
|
"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):
|
def cid_selector(request):
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -83,13 +83,45 @@
|
|||||||
<span class="text-muted">None</span>
|
<span class="text-muted">None</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
|
<li class="list-group-item">Supervisor account:
|
||||||
|
{% if supervisor_account %}
|
||||||
|
<a href="{% url 'generic:supervisor_detail' supervisor_account.pk %}">{{ supervisor_account.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Not linked</span>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
{% if supervisor_action == 'created' %}
|
||||||
|
<div class="alert alert-success py-2" role="alert">Supervisor account created and linked.</div>
|
||||||
|
{% elif supervisor_action == 'linked' %}
|
||||||
|
<div class="alert alert-success py-2" role="alert">Existing supervisor record linked to this user.</div>
|
||||||
|
{% elif supervisor_action == 'already_linked' %}
|
||||||
|
<div class="alert alert-info py-2" role="alert">This user is already linked to a supervisor account.</div>
|
||||||
|
{% elif supervisor_action == 'no_email' %}
|
||||||
|
<div class="alert alert-warning py-2" role="alert">Cannot create supervisor account: user has no email address.</div>
|
||||||
|
{% elif supervisor_action == 'email_in_use' %}
|
||||||
|
<div class="alert alert-danger py-2" role="alert">Cannot link supervisor account: email is already linked to another user.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<a class="btn btn-outline-primary d-block mb-2" href="{% url 'password_change' %}">Change password</a>
|
<a class="btn btn-outline-primary d-block mb-2" href="{% url 'password_change' %}">Change password</a>
|
||||||
{% if request.user|has_group:"cid_user_manager" %}
|
{% if request.user|has_group:"cid_user_manager" %}
|
||||||
<a class="btn btn-outline-secondary d-block mb-2" href="{% url 'account_update' user.username %}">Update user details</a>
|
<a class="btn btn-outline-secondary d-block mb-2" href="{% url 'account_update' user.username %}">Update user details</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="btn btn-primary d-block mb-2" href="{% url 'account_profile_update' user.username %}">Edit profile</a>
|
<a class="btn btn-primary d-block mb-2" href="{% url 'account_profile_update' user.username %}">Edit profile</a>
|
||||||
|
|
||||||
|
{% if request.user.is_superuser or is_cid_user_manager %}
|
||||||
|
{% if not supervisor_account %}
|
||||||
|
<form method="post" action="{% url 'account_set_supervisor' user.username %}" class="mb-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button type="submit" class="btn btn-outline-success d-block w-100">Set up as supervisor</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<a class="btn btn-outline-success d-block mb-2" href="{% url 'generic:supervisor_detail' supervisor_account.pk %}">Open supervisor record</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if request.user == user %}
|
{% if request.user == user %}
|
||||||
<a class="btn btn-outline-secondary d-block mb-2" href="{% url 'atlas:api_tokens' %}">API tokens</a>
|
<a class="btn btn-outline-secondary d-block mb-2" href="{% url 'atlas:api_tokens' %}">API tokens</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
Reference in New Issue
Block a user