Enhance Supervisor Dashboard and Trainee Feedback Features

- Redesigned the supervisor overview page to include a header card with supervisor details and trainee statistics.
- Implemented a tabbed interface for viewing trainees and their recent attempts.
- Improved the trainee detail page with a profile card and a table for displaying attempt history.
- Added a new view for supervisors to provide feedback on case collections, including outstanding feedback items and previous conversations.
- Updated URL routing to support new feedback functionality.
- Enhanced backend logic to ensure proper sharing permissions for exam attempts and case collections.
- Added comprehensive tests for supervisor access to trainee data and feedback functionalities.
This commit is contained in:
Ross
2026-06-15 12:28:00 +01:00
parent c52cf46d65
commit a573ed78a4
16 changed files with 1150 additions and 100 deletions
+4
View File
@@ -78,6 +78,10 @@ Instead
- Do not use `TestCase` unless necessary — prefer plain `pytest` functions with `@pytest.mark.django_db`. - Do not use `TestCase` unless necessary — prefer plain `pytest` functions with `@pytest.mark.django_db`.
- Run tests: `pytest` or `pytest {app}/tests/`. - Run tests: `pytest` or `pytest {app}/tests/`.
## Exceptions
- Avoid unnecessary try: except blocks.
- We should not generate empty excepty blocks, if there is an error or bug this should be surfaced (even if just to the logs).
## Celery / Tasks ## Celery / Tasks
- Tasks autodiscovered from `{app}/tasks.py`. - Tasks autodiscovered from `{app}/tasks.py`.
+23 -2
View File
@@ -108,8 +108,29 @@ def user_is_collection_author_or_atlas_editor(function):
or request.user.is_superuser or request.user.is_superuser
): ):
return function(request, *args, **kwargs) return function(request, *args, **kwargs)
else:
raise PermissionDenied # Allow access if request.user is the supervisor of the target user and sharing is enabled
user_pk = kwargs.get("user_pk") or kwargs.get("user_id")
if user_pk:
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from generic.models import CidUserExam
try:
target_user = get_user_model().objects.get(pk=user_pk)
sup = target_user.userprofile.supervisor
if sup and sup.user_id == request.user.id:
ct = ContentType.objects.get_for_model(CaseCollection)
cid_user_exam = CidUserExam.objects.filter(
content_type=ct,
object_id=atlas.pk,
user_user=target_user
).first()
if (cid_user_exam and cid_user_exam.share_with_supervisor) or atlas.results_supervisor_visible:
return function(request, *args, **kwargs)
except Exception:
pass
raise PermissionDenied
wrap.__doc__ = function.__doc__ wrap.__doc__ = function.__doc__
wrap.__name__ = function.__name__ wrap.__name__ = function.__name__
@@ -1,9 +1,18 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load django_htmx %}
{% block content %} {% block content %}
<div class="mb-2"><small class="text-muted">Collection: <strong>{{collection}}</strong></small></div> <div class="mb-2"><small class="text-muted">Collection: <strong>{{collection}}</strong></small></div>
{% if viewing_as_user %}
<div class="alert alert-info d-flex align-items-center gap-2 py-2 px-3 mb-3" role="alert">
<i class="bi bi-eye-fill me-1"></i>
<span><strong>Reviewer mode</strong> &mdash; Viewing <strong>{{ target_user.get_full_name|default:target_user.username }}</strong>&rsquo;s answers. This screen is read-only.</span>
<a href="{% url 'atlas:collection_history_user' exam_id=collection.id user_pk=target_user.pk %}" class="btn btn-outline-info btn-sm ms-auto">Back to overview</a>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-start mb-3"> <div class="d-flex justify-content-between align-items-start mb-3">
<div> <div>
<h2 class="h4 mb-0">Case {{case_number|add:1}} <h2 class="h4 mb-0">Case {{case_number|add:1}}
@@ -306,6 +315,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if not viewing_as_user %}
<div class="mb-2"> <div class="mb-2">
{% if previous %} {% if previous %}
<button type="submit" name="previous" class="btn btn-outline-secondary btn-sm me-2">Previous</button> <button type="submit" name="previous" class="btn btn-outline-secondary btn-sm me-2">Previous</button>
@@ -323,11 +333,24 @@
<button type="submit" name="complete_case" class="btn btn-success btn-sm">Finish Case</button> <button type="submit" name="complete_case" class="btn btn-success btn-sm">Finish Case</button>
{% endif %} {% endif %}
<div class="mt-3"> <div class="mt-3">
<button type="submit" name="finish" class="btn btn-outline-secondary btn-sm">Overview</button> <button type="submit" name="finish" class="btn btn-outline-secondary btn-sm">Overview</button>
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button> <button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
</div> </div>
{% else %}
{# Read-only navigation for reviewer mode — uses anchor links to avoid mutating POST #}
<div class="mb-2 d-flex gap-2 flex-wrap">
{% if previous %}
<a href="{% url 'atlas:collection_case_view_take_review_user' pk=collection.id case_number=case_number|add:'-1' user_pk=target_user.pk %}" class="btn btn-outline-secondary btn-sm">Previous</a>
{% endif %}
{% if next %}
<a href="{% url 'atlas:collection_case_view_take_review_user' pk=collection.id case_number=case_number|add:'1' user_pk=target_user.pk %}" class="btn btn-primary btn-sm">Next</a>
{% endif %}
</div>
<div class="mt-3">
<a href="{% url 'atlas:collection_history_user' exam_id=collection.id user_pk=target_user.pk %}" class="btn btn-outline-secondary btn-sm">Overview</a>
</div>
{% endif %}
</form> </form>
{% if question_completed and collection.case_query_messaging_enabled %} {% if question_completed and collection.case_query_messaging_enabled %}
@@ -45,7 +45,7 @@
</div> </div>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary" <a class="btn btn-sm btn-primary"
href="{% url 'atlas:collection_case_view' pk=collection.id case_number=forloop.counter0 %}"> href="{% url 'atlas:collection_case_view_take_review_user' pk=collection.id case_number=forloop.counter0 user_pk=user.pk %}">
Open Case Open Case
</a> </a>
{% if request.user.is_superuser and row.user_answer %} {% if request.user.is_superuser and row.user_answer %}
+5
View File
@@ -438,6 +438,11 @@ urlpatterns = [
views.redirect_collection_case_view_take_user_answers_by_id, views.redirect_collection_case_view_take_user_answers_by_id,
name="collection_case_view_take_user_answers_legacy", name="collection_case_view_take_user_answers_legacy",
), ),
path(
"collection/<int:pk>/<int:case_number>/take/review/<int:user_pk>/",
views.collection_case_view_take_review_user,
name="collection_case_view_take_review_user",
),
path( path(
"collection/<int:pk>/json_edit", "collection/<int:pk>/json_edit",
views.GenericExamViews.exam_json_edit, views.GenericExamViews.exam_json_edit,
+165 -8
View File
@@ -6790,14 +6790,28 @@ def collection_case_review_thread(request, exam_id: int, cid_user_exam_id: int,
can_feedback = _user_can_moderate_collection(collection, request.user) can_feedback = _user_can_moderate_collection(collection, request.user)
can_query = False can_query = False
if not can_feedback: if not can_feedback:
try: # Check if request.user is the supervisor of the attempt owner and sharing is enabled
collection.check_user_can_take(cid, passcode, request.user) is_supervisor = False
if cid_user_exam.user_user_id: if request.user.is_authenticated and cid_user_exam.user_user_id:
can_query = request.user.is_authenticated and cid_user_exam.user_user_id == request.user.id try:
elif cid_user_exam.cid_user_id and cid is not None: sup = cid_user_exam.user_user.userprofile.supervisor
can_query = str(cid_user_exam.cid_user.cid) == str(cid) if sup and sup.user_id == request.user.id:
except Exception: if cid_user_exam.share_with_supervisor or collection.results_supervisor_visible:
can_query = False is_supervisor = True
except Exception:
pass
if is_supervisor:
can_feedback = True
else:
try:
collection.check_user_can_take(cid, passcode, request.user)
if cid_user_exam.user_user_id:
can_query = request.user.is_authenticated and cid_user_exam.user_user_id == request.user.id
elif cid_user_exam.cid_user_id and cid is not None:
can_query = str(cid_user_exam.cid_user.cid) == str(cid)
except Exception:
can_query = False
if not (can_feedback or can_query): if not (can_feedback or can_query):
raise PermissionDenied raise PermissionDenied
@@ -8129,6 +8143,149 @@ def collection_case_view_take_user_answers(request, pk: int, case_number: int):
return collection_case_view_take_answers(request, pk, case_number) return collection_case_view_take_answers(request, pk, case_number)
@login_required
@user_is_collection_author_or_atlas_editor
def collection_case_view_take_review_user(request, pk: int, case_number: int, user_pk: int):
"""Read-only case review screen for supervisors and collection authors/editors.
Renders the same case-taking template as the candidate view but in a purely
read-only capacity loading the *target user's* saved answers and exam
state without mutating anything (no start_time, no answer creation, no POST
handling).
Access is gated by ``@user_is_collection_author_or_atlas_editor`` which also
permits an authorised supervisor when the trainee has enabled
``share_with_supervisor`` or the collection is marked
``results_supervisor_visible``.
Args:
pk: CaseCollection primary key.
case_number: Zero-based index of the case within the collection.
user_pk: Primary key of the trainee whose answers are to be viewed.
"""
from django.contrib.auth import get_user_model
User = get_user_model()
collection = get_object_or_404(CaseCollection, pk=pk)
target_user = get_object_or_404(User, pk=user_pk)
case, case_count = collection.get_case_by_index(case_number, case_count=True)
casedetail = CaseDetail.objects.get(case=case, collection=collection)
# Load the target user's exam record (read-only — no create side effects if
# using get_or_create, but we deliberately avoid creating one here).
from generic.models import CidUserExam
from django.contrib.contenttypes.models import ContentType
ct = ContentType.objects.get_for_model(CaseCollection)
cid_user_exam = CidUserExam.objects.filter(
content_type=ct,
object_id=collection.pk,
user_user=target_user,
).first()
# Load existing answer for this case — never create a placeholder.
answer = None
form = None
if collection.collection_type in ("REP", "QUE"):
answer = casedetail.userreportanswer_set.filter(user=target_user).first()
if answer:
if collection.collection_type == "REP":
from atlas.forms import UserReportAnswerForm
form = UserReportAnswerForm(instance=answer, casedetail=casedetail)
else:
from atlas.forms import UserQuestionAnswerForm
form = UserQuestionAnswerForm(instance=answer, casedetail=casedetail)
# Determine visibility / review state — treat the exam as completed for a
# reviewer so all post-completion sections (report, findings, discussion)
# are shown, matching what the trainee sees when reviewing their own answers.
question_completed = True
show_title = collection.show_title_post
show_history = collection.show_history_post
show_description = collection.show_description_post
show_discussion = collection.show_discussion_post
show_report = collection.show_report_post
series_list = casedetail.get_visible_case_series(review_mode=True)
prior_cases = casedetail.caseprior_set.all()
series_to_load = []
for series in series_list:
series_to_load.append((series, False, ""))
for prior in prior_cases:
match prior.prior_visibility:
case "AL":
for series in prior.prior_case.get_series():
series_to_load.append((series, True, prior.relation_text))
case "RE":
for series in prior.prior_case.get_series():
series_to_load.append((series, True, prior.relation_text))
case _:
continue
has_priors = any(prior_flag for (_, prior_flag, _) in series_to_load)
prior_count = prior_cases.count()
case_review_stats = {}
self_review = []
resources = case.caseresource_set.all()
if cid_user_exam:
case_review_stats_map, _, _ = _get_case_review_stats_for_exam(
cid_user_exam=cid_user_exam,
casedetails=[casedetail],
messaging_enabled=_collection_case_query_messaging_enabled(collection),
)
case_review_stats = case_review_stats_map.get(casedetail.case_id, {})
self_review = cid_user_exam.selfreview_set.filter(case=case)
previous = case_number > 0
next_case = case_number < (case_count - 1)
return render(
request,
"atlas/collection_case_view_take.html",
{
"form": form,
"collection": collection,
"case": case,
"casedetail": casedetail,
"series_list": series_list,
"named_stacks_json": casedetail.get_case_named_stacks(review_mode=True),
"series_to_load": series_to_load,
"has_priors": has_priors,
"prior_count": prior_count,
"case_number": case_number,
"previous": previous,
"next": next_case,
"cid": None,
"passcode": None,
"answer": answer,
"show_title": show_title,
"show_history": show_history,
"show_description": show_description,
"show_discussion": show_discussion,
"show_report": show_report,
"resources": resources,
"cid_user_exam": cid_user_exam,
"case_review_stats": case_review_stats,
"question_completed": question_completed,
"self_review": self_review,
"answer_started_at_iso": answer.started_at.isoformat() if answer and getattr(answer, "started_at", None) else None,
"effective_question_time_limit": None,
"effective_answer_entry_grace_period": None,
"case_view_locked": False,
"answer_entry_closed": False,
"start_screen_resources": collection.start_screen_resources.all(),
# Read-only mode flags
"viewing_as_user": True,
"target_user": target_user,
},
)
def collection_case_view_take_answers( def collection_case_view_take_answers(
request, pk: int, case_number: int, cid=None, passcode=None request, pk: int, case_number: int, cid=None, passcode=None
): ):
@@ -0,0 +1,99 @@
{% extends 'generic/base_supervisor.html' %}
{% block content %}
<div class="container py-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-2">
<li class="breadcrumb-item"><a href="{% url 'generic:supervisor_overview' supervisor.pk %}">Dashboard</a></li>
<li class="breadcrumb-item"><a href="{% url 'generic:supervisor_trainee' supervisor.pk trainee.pk %}">{{ trainee.first_name }} {{ trainee.last_name }}</a></li>
<li class="breadcrumb-item active text-muted" aria-current="page">Collection Feedback</li>
</ol>
</nav>
<h2 class="h4 mb-1">Collection: {{ collection.name }}</h2>
<div class="text-muted small">
Trainee: <strong>{{ trainee.first_name }} {{ trainee.last_name }} ({{ trainee.email }})</strong>
</div>
</div>
<div class="card border-secondary bg-dark bg-opacity-50 summary-card">
<div class="card-body py-2 px-3">
<div class="small text-muted">Feedback Summary</div>
<div class="fw-semibold text-light">{{ total_outstanding_feedback }} outstanding feedback item{{ total_outstanding_feedback|pluralize }}</div>
<div class="small text-muted mt-1">Across {{ outstanding_case_count }} case{{ outstanding_case_count|pluralize }}</div>
</div>
</div>
</div>
{% if outstanding_rows %}
<div class="mb-4">
<h3 class="h5 mb-3 text-warning"><i class="bi bi-exclamation-circle me-1"></i>Outstanding Feedback</h3>
<div class="d-flex flex-column gap-3">
{% for row in outstanding_rows %}
<section id="case-feedback-{{ row.casedetail.case.id }}" class="card border-warning bg-dark bg-opacity-25">
<div class="card-header d-flex justify-content-between align-items-center gap-2 bg-dark bg-opacity-50 border-bottom border-warning border-opacity-50">
<div>
<strong class="text-light">Case {{ row.casedetail.sort_order|add:1 }}</strong>
{% if collection.show_title_post %}
<span class="ms-1 text-muted">{{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}</span>
{% endif %}
</div>
<span class="badge bg-danger">{{ row.stats.outstanding_feedback_count }} awaiting acknowledgement</span>
</div>
<div class="card-body pt-3">
<div hx-get="{{ row.thread_url }}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Loading conversation...</div>
</div>
</div>
</section>
{% endfor %}
</div>
</div>
{% else %}
<div class="alert alert-success border border-success bg-success bg-opacity-10 text-success mb-4" role="alert">
<i class="bi bi-check-circle-fill me-2"></i>No outstanding feedback needs acknowledgement.
</div>
{% endif %}
{% if history_rows %}
<div>
<h3 class="h5 mb-3 text-info"><i class="bi bi-chat-left-text me-1"></i>Previous Conversations</h3>
<div class="accordion" id="feedbackHistoryAccordion">
{% for row in history_rows %}
<div class="accordion-item bg-dark border-secondary" id="case-feedback-{{ row.casedetail.case.id }}">
<h2 class="accordion-header bg-dark" id="heading-{{ row.casedetail.case.id }}">
<button class="accordion-button collapsed bg-dark text-light border-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-{{ row.casedetail.case.id }}" aria-expanded="false" aria-controls="collapse-{{ row.casedetail.case.id }}">
Case {{ row.casedetail.sort_order|add:1 }}
{% if collection.show_title_post %}
: {{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}
{% endif %}
<span class="badge bg-secondary ms-2">{{ row.stats.message_count }} message{{ row.stats.message_count|pluralize }}</span>
</button>
</h2>
<div id="collapse-{{ row.casedetail.case.id }}" class="accordion-collapse collapse" aria-labelledby="heading-{{ row.casedetail.case.id }}" data-bs-parent="#feedbackHistoryAccordion">
<div class="accordion-body bg-dark bg-opacity-50 pt-3">
<div hx-get="{{ row.thread_url }}"
hx-trigger="revealed once"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Open to load conversation history...</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% elif not outstanding_rows %}
<div class="card border-secondary bg-dark bg-opacity-25">
<div class="card-body text-muted text-center py-4">
<i class="bi bi-chat-dots-fill display-6 mb-2 d-block"></i>
No case conversations have been started yet.
</div>
</div>
{% endif %}
</div>
{% endblock %}
@@ -25,13 +25,7 @@
<dd class="col-sm-8" id="name">{{ object.name|default:"-" }}</dd> <dd class="col-sm-8" id="name">{{ object.name|default:"-" }}</dd>
<dt class="col-sm-4 text-muted">Email</dt> <dt class="col-sm-4 text-muted">Email</dt>
<dd class="col-sm-8" id="email"> <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>
{% 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> <dt class="col-sm-4 text-muted">User Account</dt>
<dd class="col-sm-8" id="user">{{ object.user|default:"-" }}</dd> <dd class="col-sm-8" id="user">{{ object.user|default:"-" }}</dd>
@@ -3,41 +3,201 @@
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% block content %} {% block content %}
<div class="container py-4">
<!-- Header Card -->
<div class="card bg-dark bg-opacity-50 border-secondary mb-4">
<div class="card-body d-flex flex-wrap align-items-center justify-content-between gap-3">
<div class="d-flex align-items-center gap-3">
<div class="bg-info bg-opacity-10 border border-info border-opacity-25 rounded-circle p-3 d-flex align-items-center justify-content-center text-info" style="width: 56px; height: 56px;">
<i class="bi bi-person-workspace fs-3"></i>
</div>
<div>
<h2 class="h4 mb-0 text-light">Supervisor: {{ supervisor.name }}</h2>
<p class="text-muted mb-0 small"><i class="bi bi-envelope me-1"></i>{{ supervisor.email }} &middot; <i class="bi bi-geo-alt me-1"></i>{{ supervisor.site|default:"No site linked" }}</p>
</div>
</div>
<div class="d-flex gap-3 text-center">
<div class="border-start border-secondary ps-3">
<div class="text-muted small">Trainees</div>
<div class="fs-4 fw-semibold text-info">{{ trainees.count }}</div>
</div>
<div class="border-start border-secondary ps-3">
<div class="text-muted small">Total Attempts</div>
<div class="fs-4 fw-semibold text-light">{{ attempts|length }}</div>
</div>
</div>
</div>
</div>
<h2>Supervisor: {{supervisor.name}}</h2> <!-- Help Info -->
<details class="help-text"> <div class="alert alert-secondary border-secondary bg-dark bg-opacity-25 mb-4">
<summary><i class="bi bi-info-circle"></i> Help</summary> <div class="d-flex gap-2">
<p>As a supervisor you have the ability to view results on the platform for your associated trainees.</p> <i class="bi bi-info-circle text-info fs-5"></i>
<p>Some exams / collection results will be automatically available for you to review, others may require the trainee to allow access</p> <div>
<p>If your trainees are incorrect please contact ross.kruger@nhs.net</p> <span class="fw-semibold text-light">Supervisor Dashboard Guide:</span>
<p class="mb-1 text-muted small">This hub allows you to monitor and review your trainees' progress on exams and case collections.</p>
<p class="mb-0 text-muted small">Trainees have option to choose which attempts to share. Shared attempts are fully viewable (score and answers link). Private attempts show the completion date only, keeping results confidential.</p>
</div>
</div>
</div>
</details> <!-- Dashboard Tabs -->
<ul class="nav nav-tabs border-secondary mb-4" id="supervisorTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active text-light border-secondary bg-transparent" id="trainees-tab" data-bs-toggle="tab" data-bs-target="#trainees" type="button" role="tab" aria-controls="trainees" aria-selected="true">
<i class="bi bi-people-fill me-1"></i>Trainees List
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link text-muted border-transparent bg-transparent" id="attempts-tab" data-bs-toggle="tab" data-bs-target="#attempts" type="button" role="tab" aria-controls="attempts" aria-selected="false">
<i class="bi bi-clock-history me-1"></i>Recent Trainee Attempts
</button>
</li>
</ul>
<div class="tab-content" id="supervisorTabContent">
<!-- Trainees Tab -->
<div class="tab-pane fade show active" id="trainees" role="tablist" aria-labelledby="trainees-tab">
<div class="card bg-dark bg-opacity-25 border-secondary">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-striped table-hover align-middle mb-0">
<thead>
<tr class="text-muted border-secondary">
<th class="ps-4">Trainee Name</th>
<th>Email</th>
<th>Grade</th>
<th>Site</th>
<th class="text-center">Shared / Private Attempts</th>
<th>Last Activity</th>
<th class="text-end pe-4">Action</th>
</tr>
</thead>
<tbody>
{% for stat in trainee_stats %}
<tr class="border-secondary">
<td class="ps-4 fw-semibold">
<i class="bi bi-person-fill text-muted me-2"></i>{{ stat.trainee.first_name }} {{ stat.trainee.last_name }}
</td>
<td>{{ stat.trainee.email }}</td>
<td><span class="badge bg-secondary">{{ stat.trainee.userprofile.grade|default:"N/A" }}</span></td>
<td>{{ stat.trainee.userprofile.site|default:"N/A" }}</td>
<td class="text-center">
<span class="badge bg-success" title="Shared attempts">{{ stat.shared_count }}</span>
<span class="badge bg-secondary" title="Private attempts">{{ stat.private_count }}</span>
</td>
<td class="text-muted small">
{% if stat.last_active %}
{{ stat.last_active|date:"d M Y H:i" }}
{% else %}
No activity
{% endif %}
</td>
<td class="text-end pe-4">
<a href='{% url "generic:supervisor_trainee" supervisor.pk stat.trainee.pk %}' class="btn btn-sm btn-outline-info">
<i class="bi bi-speedometer2 me-1"></i>View Progress
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center py-4 text-muted">
<i class="bi bi-people display-6 mb-2 d-block"></i>
You currently have no associated trainees.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<h4>Trainees</h4> <!-- Attempts Tab -->
You have the following trainee(s): <div class="tab-pane fade" id="attempts" role="tabpanel" aria-labelledby="attempts-tab">
<div class="card bg-dark bg-opacity-25 border-secondary">
<ul id="trainee-list"> <div class="card-body p-0">
{% for trainee in trainees %} <div class="table-responsive">
<li> <table class="table table-dark table-striped table-hover align-middle mb-0">
<i class="bi bi-file-person"></i> <thead>
<a href='{% url "generic:supervisor_trainee" supervisor.pk trainee.pk %}'>{{trainee.first_name}} {{trainee.last_name}}, {{trainee.email}}</a> <tr class="text-muted border-secondary">
</li> <th class="ps-4">Trainee</th>
<th>Attempt Item</th>
{% endfor %} <th>Type</th>
</ul> <th>Date Completed</th>
<th>Sharing</th>
<th>Score / Progress</th>
<th class="text-end pe-4">Action</th>
</tr>
</thead>
<tbody>
{% for item in attempts %}
<tr class="border-secondary">
<td class="ps-4 fw-semibold">
{{ item.trainee.first_name }} {{ item.trainee.last_name }}
</td>
<td>{{ item.exam }}</td>
<td>
<span class="badge {% if item.type == 'casecollection' %}bg-primary{% else %}bg-secondary{% endif %}">
{{ item.type_display }}
</span>
</td>
<td class="text-muted small">{{ item.date|date:"d M Y H:i" }}</td>
<td>
{% if item.is_shared %}
<span class="badge bg-success"><i class="bi bi-eye-fill me-1"></i>Shared</span>
{% else %}
<span class="badge bg-secondary"><i class="bi bi-lock-fill me-1"></i>Private</span>
{% endif %}
</td>
<td>
{% if item.is_shared %}
<span class="text-light fw-medium">{{ item.score_str }}</span>
{% else %}
<span class="text-muted small">Locked</span>
{% endif %}
</td>
<td class="text-end pe-4">
{% if item.is_shared and item.detail_url %}
<a href="{{ item.detail_url }}" class="btn btn-sm btn-outline-info">
<i class="bi bi-arrow-right-short me-1"></i>View Details
</a>
{% else %}
<button class="btn btn-sm btn-outline-secondary" disabled>
<i class="bi bi-lock-fill"></i> Private
</button>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center py-4 text-muted">
<i class="bi bi-clock-history display-6 mb-2 d-block"></i>
No trainee attempts recorded.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block css %} {% block css %}
<style> <style>
#trainee-list { .nav-tabs .nav-link.active {
list-style-type: none; border-color: var(--bs-secondary) var(--bs-secondary) var(--bs-body-bg) !important;
padding: 0; border-bottom-width: 2px !important;
}
.nav-tabs .nav-link:hover:not(.active) {
border-color: transparent !important;
color: var(--bs-light) !important;
} }
</style> </style>
{% endblock css %} {% endblock css %}
+104 -31
View File
@@ -1,38 +1,111 @@
{% extends 'generic/base_supervisor.html' %} {% extends 'generic/base_supervisor.html' %}
{% load crispy_forms_tags %}
{% block css %}
{% endblock %}
{% block content %} {% block content %}
<div class="container py-4">
<!-- Breadcrumbs -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'generic:supervisor_overview' supervisor.pk %}">Dashboard</a></li>
<li class="breadcrumb-item active text-muted" aria-current="page">{{ trainee.first_name }} {{ trainee.last_name }}</li>
</ol>
</nav>
<h2>Trainee: {{trainee.first_name}} {{trainee.last_name}}</h2> <!-- Trainee Profile Card -->
email: {{trainee.email}} <div class="card bg-dark bg-opacity-50 border-secondary mb-4">
<div class="card-body d-flex flex-wrap align-items-center justify-content-between gap-3">
<div class="d-flex align-items-center gap-3">
<div class="bg-primary bg-opacity-10 border border-primary border-opacity-25 rounded-circle p-3 d-flex align-items-center justify-content-center text-primary" style="width: 56px; height: 56px;">
<i class="bi bi-person-circle fs-3"></i>
</div>
<div>
<h2 class="h4 mb-0 text-light">{{ trainee.first_name }} {{ trainee.last_name }}</h2>
<p class="text-muted mb-0 small"><i class="bi bi-envelope me-1"></i>{{ trainee.email }}</p>
</div>
</div>
<div class="d-flex gap-3 text-center">
<div class="border-start border-secondary ps-3">
<div class="text-muted small">Grade</div>
<span class="badge bg-secondary mt-1">{{ trainee.userprofile.grade|default:"N/A" }}</span>
</div>
<div class="border-start border-secondary ps-3">
<div class="text-muted small">Primary Site</div>
<span class="text-light fs-6">{{ trainee.userprofile.site|default:"N/A" }}</span>
</div>
<div class="border-start border-secondary ps-3">
<div class="text-muted small">Attempts</div>
<span class="text-light fs-5 fw-semibold">{{ attempts|length }}</span>
</div>
</div>
</div>
</div>
<!-- Attempts Table -->
<h3>Results</h3> <div class="card bg-dark bg-opacity-25 border-secondary">
<div id="exam-results"> <div class="card-header bg-dark bg-opacity-50 border-secondary py-3">
{% if all_exams %} <h3 class="h5 mb-0 text-light"><i class="bi bi-list-task text-primary me-2"></i>Trainee Attempts History</h3>
The following exam results for the trainee are available. </div>
{% for exam_type, exams in all_exams %} <div class="card-body p-0">
{% if exams %} <div class="table-responsive">
<h4>{{exam_type|title}}</h4> <table class="table table-dark table-striped table-hover align-middle mb-0">
<ul class='{{exam_type|lower}}'> <thead>
{% for exam in exams %} <tr class="text-muted border-secondary">
<li class="exam" data-exam-id="{{exam.pk}}"> <th class="ps-4">Attempt Item</th>
<th>Type</th>
<a href= "{% url exam_type|add:':exam_scores_user_supervisor' pk=exam.pk user_id=trainee.pk %}" title="Click to view results"> <th>Date Attempted</th>
<th>Sharing Status</th>
{{exam}}</a> {% if exam.active %}[Active]{% endif %} {% if exam.publish_results %}[Results Published]{% endif %}</li> <th>Score / Progress</th>
{% endfor %} <th class="text-end pe-4">Action</th>
</ul> </tr>
{% endif %} </thead>
{% endfor %} <tbody>
{% else %} {% for item in attempts %}
No results are available for this trainee. <tr class="border-secondary">
{% endif %} <td class="ps-4 fw-semibold">{{ item.exam }}</td>
<td>
<span class="badge {% if item.type == 'casecollection' %}bg-primary{% else %}bg-secondary{% endif %}">
{{ item.type_display }}
</span>
</td>
<td class="text-muted small">{{ item.date|date:"d M Y H:i" }}</td>
<td>
{% if item.is_shared %}
<span class="badge bg-success"><i class="bi bi-eye-fill me-1"></i>Shared</span>
{% else %}
<span class="badge bg-secondary"><i class="bi bi-lock-fill me-1"></i>Private</span>
{% endif %}
</td>
<td>
{% if item.is_shared %}
<span class="text-light fw-medium">{{ item.score_str }}</span>
{% else %}
<span class="text-muted small">Locked (Private)</span>
{% endif %}
</td>
<td class="text-end pe-4">
{% if item.is_shared and item.detail_url %}
<a href="{{ item.detail_url }}" class="btn btn-sm btn-outline-info">
<i class="bi bi-arrow-right-short me-1"></i>View Details
</a>
{% else %}
<button class="btn btn-sm btn-outline-secondary" disabled>
<i class="bi bi-lock-fill"></i> Locked
</button>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<i class="bi bi-folder2-open display-6 mb-2 d-block"></i>
This trainee has not started any attempts yet.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
+5 -1
View File
@@ -11,6 +11,9 @@ import datetime
class TestExamCollections: class TestExamCollections:
def test_list_url(self, db, client): def test_list_url(self, db, client):
from django.contrib.auth.models import User
user = User.objects.create_user(username="test_list_user", password="password")
client.force_login(user)
# Test with no content # Test with no content
response = client.get(reverse("generic:examcollection_list")) response = client.get(reverse("generic:examcollection_list"))
assert response.status_code == 200 assert response.status_code == 200
@@ -22,11 +25,12 @@ class TestExamCollections:
assert response.status_code == 404 assert response.status_code == 404
collection = ExamCollection.objects.create(name="test collection", date=datetime.date.today()) collection = ExamCollection.objects.create(name="test collection", date=datetime.date.today())
collection.author.add(user)
response = client.get(reverse("generic:examcollection_list")) response = client.get(reverse("generic:examcollection_list"))
assert response.status_code == 200 assert response.status_code == 200
soup = BeautifulSoup(response.content, "html.parser") soup = BeautifulSoup(response.content, "html.parser")
assert collection.name in soup.find("li", class_="collection").text assert collection.name in soup.find("a", class_="stretched-link").text
response = client.get(reverse("generic:examcollection_detail", args=( collection.pk, ))) response = client.get(reverse("generic:examcollection_detail", args=( collection.pk, )))
+265
View File
@@ -0,0 +1,265 @@
import pytest
from django.urls import reverse
from django.contrib.auth.models import User
from django.test import Client
from django.contrib.contenttypes.models import ContentType
from generic.models import Supervisor, UserProfile, CidUserExam
from physics.models import Exam as PhysicsExam
from atlas.models import CaseCollection, Case, CaseDetail
@pytest.fixture
def supervisor_user(db):
return User.objects.create_user(username="supervisor_user", password="password", email="supervisor@test.com")
@pytest.fixture
def supervisor_model(db, supervisor_user):
return Supervisor.objects.create(name="Test Supervisor", email="supervisor@test.com", user=supervisor_user)
@pytest.fixture
def other_supervisor_user(db):
return User.objects.create_user(username="other_supervisor", password="password", email="other@test.com")
@pytest.fixture
def other_supervisor(db, other_supervisor_user):
return Supervisor.objects.create(name="Other Supervisor", email="other@test.com", user=other_supervisor_user)
@pytest.fixture
def trainee_user(db, supervisor_model):
user = User.objects.create_user(username="trainee", password="password", email="trainee@test.com")
profile = user.userprofile
profile.supervisor = supervisor_model
profile.save()
return user
@pytest.fixture
def physics_exam(db):
return PhysicsExam.objects.create(name="Test Physics Exam", exam_mode=True)
@pytest.fixture
def case_collection(db):
collection = CaseCollection.objects.create(name="Test Case Collection")
# Enable messaging
collection.feedback_once_collection_complete = True
collection.save()
return collection
@pytest.fixture
def case_model(db):
return Case.objects.create(title="Test Case")
@pytest.fixture
def case_detail(db, case_collection, case_model):
return CaseDetail.objects.create(collection=case_collection, case=case_model, sort_order=0)
@pytest.fixture
def exam_attempt(db, trainee_user, physics_exam):
ct = ContentType.objects.get_for_model(PhysicsExam)
return CidUserExam.objects.create(
content_type=ct,
object_id=physics_exam.pk,
user_user=trainee_user,
share_with_supervisor=False
)
@pytest.fixture
def collection_attempt(db, trainee_user, case_collection):
ct = ContentType.objects.get_for_model(CaseCollection)
return CidUserExam.objects.create(
content_type=ct,
object_id=case_collection.pk,
user_user=trainee_user,
share_with_supervisor=False
)
@pytest.mark.django_db
def test_supervisor_overview_access(
supervisor_user,
other_supervisor_user,
supervisor_model,
trainee_user,
physics_exam,
exam_attempt,
case_collection,
collection_attempt,
):
client = Client()
url = reverse("generic:supervisor_overview", kwargs={"pk": supervisor_model.pk})
# Anonymous blocked
response = client.get(url)
assert response.status_code == 302
# Other supervisor blocked
client.force_login(other_supervisor_user)
response = client.get(url)
assert response.status_code == 403
# Authorized supervisor allowed
client.force_login(supervisor_user)
# First test with private attempts
response = client.get(url)
assert response.status_code == 200
assert b"Test Physics Exam" in response.content
assert b"Test Case Collection" in response.content
assert b"Locked" in response.content
# Now test with shared attempts
exam_attempt.share_with_supervisor = True
exam_attempt.save()
collection_attempt.share_with_supervisor = True
collection_attempt.save()
response = client.get(url)
assert response.status_code == 200
assert b"Test Physics Exam" in response.content
assert b"Test Case Collection" in response.content
assert b"0 / 0 cases" in response.content # collection progress check
@pytest.mark.django_db
def test_supervisor_trainee_access(supervisor_user, other_supervisor_user, supervisor_model, trainee_user):
client = Client()
url = reverse("generic:supervisor_trainee", kwargs={"pk": supervisor_model.pk, "trainee_id": trainee_user.pk})
# Other supervisor blocked
client.force_login(other_supervisor_user)
response = client.get(url)
assert response.status_code == 403
# Authorized supervisor allowed
client.force_login(supervisor_user)
response = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_exam_scores_user_supervisor_sharing(supervisor_user, other_supervisor_user, supervisor_model, trainee_user, physics_exam, exam_attempt):
client = Client()
url = reverse("physics:exam_scores_user_supervisor", kwargs={"pk": physics_exam.pk, "user_id": trainee_user.pk})
# Other supervisor blocked regardless
client.force_login(other_supervisor_user)
response = client.get(url)
assert response.status_code == 403
# Authorized supervisor blocked when not shared
client.force_login(supervisor_user)
response = client.get(url)
assert response.status_code == 403
# Share and check allowed
exam_attempt.share_with_supervisor = True
exam_attempt.save()
response = client.get(url)
assert response.status_code == 200
# Unshare, but set results_supervisor_visible = True
exam_attempt.share_with_supervisor = False
exam_attempt.save()
physics_exam.results_supervisor_visible = True
physics_exam.save()
response = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_supervisor_collection_feedback_sharing(supervisor_user, supervisor_model, trainee_user, case_collection, collection_attempt):
client = Client()
url = reverse("atlas:collection_history_user", kwargs={
"exam_id": case_collection.pk,
"user_pk": trainee_user.pk
})
# Supervisor blocked when not shared
client.force_login(supervisor_user)
response = client.get(url)
assert response.status_code == 403
# Supervisor allowed when shared
collection_attempt.share_with_supervisor = True
collection_attempt.save()
response = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_collection_case_review_thread_supervisor_access(supervisor_user, supervisor_model, trainee_user, case_collection, collection_attempt, case_model, case_detail):
client = Client()
url = reverse("atlas:collection_case_review_thread", kwargs={
"exam_id": case_collection.pk,
"cid_user_exam_id": collection_attempt.pk,
"case_id": case_model.pk
})
# Blocked when attempt is not shared
client.force_login(supervisor_user)
response = client.get(url)
assert response.status_code == 403
# Allowed when shared
collection_attempt.share_with_supervisor = True
collection_attempt.save()
response = client.get(url)
assert response.status_code == 200
# Post a feedback reply as supervisor
response = client.post(url, {
"action": "add_message",
"message_type": "FB",
"message": "Nice job trainee!"
}, HTTP_X_REQUESTED_WITH="XMLHttpRequest")
assert response.status_code == 200
assert b"Nice job trainee!" in response.content
@pytest.mark.django_db
def test_collection_case_view_take_review_user(
supervisor_user,
other_supervisor_user,
supervisor_model,
trainee_user,
case_collection,
case_model,
case_detail,
collection_attempt,
):
"""Supervisors can view a trainee's case answers in read-only mode.
- Unshared attempts are blocked (403).
- Shared attempts return 200 with the reviewer banner present.
- Other supervisors with no relationship to the trainee are blocked.
- A collection author can always view the answers.
"""
client = Client()
url = reverse(
"atlas:collection_case_view_take_review_user",
kwargs={"pk": case_collection.pk, "case_number": 0, "user_pk": trainee_user.pk},
)
# Anonymous user is redirected to login
response = client.get(url)
assert response.status_code == 302
# Authorized supervisor blocked when attempt not shared
client.force_login(supervisor_user)
response = client.get(url)
assert response.status_code == 403
# Authorized supervisor allowed when attempt is shared
collection_attempt.share_with_supervisor = True
collection_attempt.save()
response = client.get(url)
assert response.status_code == 200
# Reviewer banner should be present
assert b"Reviewer mode" in response.content
assert b"read-only" in response.content
# Other supervisor (not linked to trainee) is still blocked even when shared
client.force_login(other_supervisor_user)
response = client.get(url)
assert response.status_code == 403
# Collection author can always view the answers
author_user = User.objects.create_user(username="author_user", password="password")
case_collection.author.add(author_user)
client.force_login(author_user)
response = client.get(url)
assert response.status_code == 200
assert b"Reviewer mode" in response.content
+5
View File
@@ -239,6 +239,11 @@ urlpatterns = [
views.supervisor_trainee, views.supervisor_trainee,
name="supervisor_trainee", name="supervisor_trainee",
), ),
path(
"supervisor/<int:pk>/trainee/<int:trainee_id>/collection/<int:collection_id>/",
views.supervisor_collection_feedback,
name="supervisor_collection_feedback",
),
path( path(
"supervisor/<int:pk>", "supervisor/<int:pk>",
views.SupervisorDetail.as_view(), views.SupervisorDetail.as_view(),
+247 -22
View File
@@ -380,6 +380,126 @@ class CidManagerRequiredMixin(UserPassesTestMixin):
# raise PermissionDenied() # or Http404 # raise PermissionDenied() # or Http404
def get_exam_attempt_score(exam_type, exam, user):
from django.contrib.contenttypes.models import ContentType
from physics.models import UserAnswer as PhysicsUserAnswer
from anatomy.models import UserAnswer as AnatomyUserAnswer
from rapids.models import UserAnswer as RapidsUserAnswer
from shorts.models import UserAnswer as ShortsUserAnswer
from longs.models import UserAnswer as LongsUserAnswer
from sbas.models import UserAnswer as SbasUserAnswer
MAP = {
"physics": PhysicsUserAnswer,
"anatomy": AnatomyUserAnswer,
"rapids": RapidsUserAnswer,
"shorts": ShortsUserAnswer,
"longs": LongsUserAnswer,
"sbas": SbasUserAnswer,
}
UserAnswer = MAP.get(exam_type)
if not UserAnswer:
return None, None
questions = list(exam.get_questions())
if not questions:
return 0, 0
user_answers = UserAnswer.objects.filter(user=user, exam=exam).select_related("question")
user_answers_map = {ua.question_id: ua for ua in user_answers}
total_score = 0
for q in questions:
ua = user_answers_map.get(q.pk)
if ua:
total_score += ua.get_answer_score()
else:
score, _ = q.get_unanswered_mark_and_text()
total_score += score
# Max score
match exam_type:
case "rapids" | "anatomy":
max_score = len(questions) * 2
case "longs":
max_score = len(questions) * 8
case "physics" | "shorts":
max_score = len(questions) * 5
case _:
max_score = len(questions)
return total_score, max_score
def get_supervisor_attempts(supervisor, trainees=None):
from django.contrib.contenttypes.models import ContentType
from generic.models import CidUserExam
from atlas.models import CaseCollection
from django.urls import reverse
from django.utils import timezone
if trainees is None:
trainees = User.objects.filter(userprofile__supervisor=supervisor)
attempts = CidUserExam.objects.filter(user_user__in=trainees).select_related("user_user", "content_type")
parsed_attempts = []
for att in attempts:
exam = att.exam
if not exam:
continue
friendly_type = att.content_type.app_label
if friendly_type == "atlas":
friendly_type = "casecollection"
exam_supervisor_visible = getattr(exam, "results_supervisor_visible", False)
is_shared = att.share_with_supervisor or exam_supervisor_visible
score_str = "N/A"
detail_url = None
if is_shared:
if friendly_type == "casecollection":
total_cases = exam.casedetail_set.count()
reviewed_cases = att.selfreview_set.count()
score_str = f"{reviewed_cases} / {total_cases} cases"
detail_url = reverse(
"atlas:collection_history_user",
kwargs={
"exam_id": exam.pk,
"user_pk": att.user_user.pk,
},
)
else:
total_score, max_score = get_exam_attempt_score(friendly_type, exam, att.user_user)
if total_score is not None:
pct = int((total_score / max_score) * 100) if max_score > 0 else 0
score_str = f"{total_score} / {max_score} ({pct}%)"
else:
score_str = "No answers"
detail_url = reverse(
f"{friendly_type}:exam_scores_user_supervisor",
kwargs={"pk": exam.pk, "user_id": att.user_user.pk},
)
else:
score_str = "Private"
parsed_attempts.append({
"attempt": att,
"exam": exam,
"trainee": att.user_user,
"type": friendly_type,
"type_display": friendly_type.title() if friendly_type != "sbas" else "SBAs",
"date": att.end_time or att.start_time,
"is_shared": is_shared,
"score_str": score_str,
"detail_url": detail_url,
"completed": att.completed,
})
parsed_attempts.sort(key=lambda x: x["date"] or timezone.now(), reverse=True)
return parsed_attempts
def get_user_exams(user, supervisor_view=False): def get_user_exams(user, supervisor_view=False):
EXAM_ANSWER_MAP = { EXAM_ANSWER_MAP = {
"physics": (PhysicsUserAnswer, PhysicsExam, "physics_exams"), "physics": (PhysicsUserAnswer, PhysicsExam, "physics_exams"),
@@ -3547,11 +3667,34 @@ class ExamViews(View, LoginRequiredMixin):
return self.exam_scores_cid_user(request, pk, user=user) return self.exam_scores_cid_user(request, pk, user=user)
def exam_scores_user_supervisor(self, request, pk, user_id): def exam_scores_user_supervisor(self, request, pk, user_id):
from django.contrib.contenttypes.models import ContentType
from generic.models import CidUserExam
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
if not request.user.is_superuser: if not request.user.is_superuser:
if not request.user.supervisor == user.userprofile.supervisor: try:
if not request.user.supervisor == user.userprofile.supervisor:
raise PermissionDenied
except Exception:
raise PermissionDenied raise PermissionDenied
exam = get_object_or_404(self.Exam, pk=pk)
ct = ContentType.objects.get_for_model(self.Exam)
cid_user_exam = CidUserExam.objects.filter(
content_type=ct,
object_id=exam.pk,
user_user=user
).first()
is_visible = False
if exam.results_supervisor_visible:
is_visible = True
elif cid_user_exam and cid_user_exam.share_with_supervisor:
is_visible = True
if not is_visible:
raise PermissionDenied("This exam attempt has not been shared with the supervisor.")
return self.exam_scores_cid_user( return self.exam_scores_cid_user(
request, pk, user=user, supervisor=user.userprofile.supervisor.user request, pk, user=user, supervisor=user.userprofile.supervisor.user
) )
@@ -5988,27 +6131,26 @@ def create_user(request, context=None, trainee: bool = False):
class SupervisorDetail(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): def get_object(self, queryset=None):
"""Override to check permissions before returning object.""" """Return the supervisor if the requesting user is authorised.
- Superusers and ``cid_user_manager`` group members may view any supervisor.
- A supervisor linked to the requesting user may view their own record.
- All other authenticated users receive PermissionDenied (403).
- Unauthenticated users are redirected to the login page by
``LoginRequiredMixin`` (enforced via the URLconf decorator or mixin).
"""
obj = super().get_object(queryset) obj = super().get_object(queryset)
if not ( if not self.request.user.is_authenticated:
from django.contrib.auth.views import redirect_to_login
return redirect_to_login(self.request.get_full_path())
if (
self.request.user.is_superuser self.request.user.is_superuser
or self.request.user.groups.filter(name="cid_user_manager").exists() or self.request.user.groups.filter(name="cid_user_manager").exists()
or (hasattr(self.request.user, "supervisor") and obj.user == self.request.user) or (hasattr(self.request.user, "supervisor") and obj.user == self.request.user)
): ):
raise PermissionDenied return obj
return obj raise PermissionDenied
@user_is_cid_user_manager @user_is_cid_user_manager
@@ -6529,12 +6671,38 @@ def supervisor_overview(request, pk):
except User.supervisor.RelatedObjectDoesNotExist: except User.supervisor.RelatedObjectDoesNotExist:
raise PermissionDenied() raise PermissionDenied()
trainees = User.objects.filter(userprofile__supervisor=supervisor) trainees = User.objects.filter(userprofile__supervisor=supervisor).select_related("userprofile__grade", "userprofile__site")
attempts = get_supervisor_attempts(supervisor, trainees)
# Calculate statistics for trainees
trainee_stats = []
for trainee in trainees:
trainee_attempts = [a for a in attempts if a["trainee"] == trainee]
total_count = len(trainee_attempts)
shared_count = sum(1 for a in trainee_attempts if a["is_shared"])
private_count = total_count - shared_count
last_active = None
if trainee_attempts:
last_active = trainee_attempts[0]["date"]
trainee_stats.append({
"trainee": trainee,
"total_count": total_count,
"shared_count": shared_count,
"private_count": private_count,
"last_active": last_active,
})
return render( return render(
request, request,
"generic/supervisor_overview.html", "generic/supervisor_overview.html",
{"supervisor": supervisor, "trainees": trainees}, {
"supervisor": supervisor,
"trainees": trainees,
"trainee_stats": trainee_stats,
"attempts": attempts,
},
) )
@@ -6549,15 +6717,72 @@ def supervisor_trainee(request, pk, trainee_id):
except User.supervisor.RelatedObjectDoesNotExist: except User.supervisor.RelatedObjectDoesNotExist:
raise PermissionDenied() raise PermissionDenied()
# We do this to check that the supervisor can (should) see the trainee results
trainee = User.objects.get(userprofile__supervisor=supervisor, pk=trainee_id) trainee = User.objects.get(userprofile__supervisor=supervisor, pk=trainee_id)
attempts = get_supervisor_attempts(supervisor, [trainee])
exams = get_user_exams(trainee, supervisor_view=True)
return render( return render(
request, request,
"generic/supervisor_trainee.html", "generic/supervisor_trainee.html",
{"supervisor": supervisor, "trainee": trainee, "all_exams": exams}, {
"supervisor": supervisor,
"trainee": trainee,
"attempts": attempts,
},
)
@login_required
def supervisor_collection_feedback(request, pk, trainee_id, collection_id):
from atlas.models import CaseCollection, CaseDetail
from atlas.views import _build_collection_feedback_rows
from django.contrib.contenttypes.models import ContentType
supervisor = get_object_or_404(Supervisor, pk=pk)
if not request.user.is_superuser:
try:
if not request.user.supervisor == supervisor:
raise PermissionDenied()
except User.supervisor.RelatedObjectDoesNotExist:
raise PermissionDenied()
trainee = get_object_or_404(User, userprofile__supervisor=supervisor, pk=trainee_id)
collection = get_object_or_404(CaseCollection, pk=collection_id)
ct = ContentType.objects.get_for_model(CaseCollection)
cid_user_exam = get_object_or_404(
CidUserExam, user_user=trainee, content_type=ct, object_id=collection.pk
)
if not (cid_user_exam.share_with_supervisor or collection.results_supervisor_visible):
raise PermissionDenied("This collection attempt has not been shared with the supervisor.")
casedetails = list(
CaseDetail.objects.filter(collection=collection).select_related("case").order_by("sort_order")
)
feedback_rows, outstanding_case_count, total_outstanding_feedback = _build_collection_feedback_rows(
collection=collection,
cid_user_exam=cid_user_exam,
casedetails=casedetails,
)
outstanding_rows = [row for row in feedback_rows if row["stats"].get("has_outstanding_feedback")]
history_rows = [row for row in feedback_rows if row["stats"].get("has_messages") and not row["stats"].get("has_outstanding_feedback")]
return render(
request,
"generic/supervisor_collection_feedback.html",
{
"supervisor": supervisor,
"trainee": trainee,
"collection": collection,
"cid_user_exam": cid_user_exam,
"feedback_rows": feedback_rows,
"outstanding_rows": outstanding_rows,
"history_rows": history_rows,
"outstanding_case_count": outstanding_case_count,
"total_outstanding_feedback": total_outstanding_feedback,
},
) )
+5
View File
@@ -314,6 +314,11 @@
<a class="nav-link active" href="{% url 'people' %}">People</a> <a class="nav-link active" href="{% url 'people' %}">People</a>
</li> </li>
{% endif %} {% endif %}
{% if request.user.supervisor %}
<li id="supervisor-link" class="nav-item">
<a class="nav-link active" href="{% url 'generic:supervisor_overview' request.user.supervisor.pk %}">Supervisor</a>
</li>
{% endif %}
<li id="profile-link" class=" nav-item"> <li id="profile-link" class=" nav-item">
<a class="nav-link active" href="{% url 'profile' %}">Profile</a> <a class="nav-link active" href="{% url 'profile' %}">Profile</a>
</li> </li>
+10
View File
@@ -43,6 +43,16 @@
</div> </div>
{% endif %} {% endif %}
{% if request.user.supervisor %}
<div class="card mb-3 border border-info bg-dark bg-opacity-25">
<div class="card-body">
<h5 class="card-title text-info"><i class="bi bi-person-workspace me-2"></i>Supervisor Dashboard</h5>
<p class="card-text text-muted mb-3">Welcome, {{ request.user.supervisor.name }}. You can track, review, and leave feedback on your trainees' attempts here.</p>
<a class="btn btn-outline-info" href="{% url 'generic:supervisor_overview' request.user.supervisor.pk %}">Go to Dashboard</a>
</div>
</div>
{% endif %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Exams / Courses</h5> <h5 class="card-title">Exams / Courses</h5>