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
@@ -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>
<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>
<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>
@@ -3,41 +3,201 @@
{% load crispy_forms_tags %}
{% 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>
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>As a supervisor you have the ability to view results on the platform for your associated trainees.</p>
<p>Some exams / collection results will be automatically available for you to review, others may require the trainee to allow access</p>
<p>If your trainees are incorrect please contact ross.kruger@nhs.net</p>
<!-- Help Info -->
<div class="alert alert-secondary border-secondary bg-dark bg-opacity-25 mb-4">
<div class="d-flex gap-2">
<i class="bi bi-info-circle text-info fs-5"></i>
<div>
<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>
You have the following trainee(s):
<ul id="trainee-list">
{% for trainee in trainees %}
<li>
<i class="bi bi-file-person"></i>
<a href='{% url "generic:supervisor_trainee" supervisor.pk trainee.pk %}'>{{trainee.first_name}} {{trainee.last_name}}, {{trainee.email}}</a>
</li>
{% endfor %}
</ul>
<!-- Attempts Tab -->
<div class="tab-pane fade" id="attempts" role="tabpanel" aria-labelledby="attempts-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</th>
<th>Attempt Item</th>
<th>Type</th>
<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 %}
{% block css %}
<style>
#trainee-list {
list-style-type: none;
padding: 0;
.nav-tabs .nav-link.active {
border-color: var(--bs-secondary) var(--bs-secondary) var(--bs-body-bg) !important;
border-bottom-width: 2px !important;
}
.nav-tabs .nav-link:hover:not(.active) {
border-color: transparent !important;
color: var(--bs-light) !important;
}
</style>
{% endblock css %}
+104 -31
View File
@@ -1,38 +1,111 @@
{% extends 'generic/base_supervisor.html' %}
{% load crispy_forms_tags %}
{% block css %}
{% endblock %}
{% 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>
email: {{trainee.email}}
<!-- Trainee Profile 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-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>
<h3>Results</h3>
<div id="exam-results">
{% if all_exams %}
The following exam results for the trainee are available.
{% for exam_type, exams in all_exams %}
{% if exams %}
<h4>{{exam_type|title}}</h4>
<ul class='{{exam_type|lower}}'>
{% for exam in exams %}
<li class="exam" data-exam-id="{{exam.pk}}">
<a href= "{% url exam_type|add:':exam_scores_user_supervisor' pk=exam.pk user_id=trainee.pk %}" title="Click to view results">
{{exam}}</a> {% if exam.active %}[Active]{% endif %} {% if exam.publish_results %}[Results Published]{% endif %}</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
{% else %}
No results are available for this trainee.
{% endif %}
<!-- Attempts Table -->
<div class="card bg-dark bg-opacity-25 border-secondary">
<div class="card-header bg-dark bg-opacity-50 border-secondary py-3">
<h3 class="h5 mb-0 text-light"><i class="bi bi-list-task text-primary me-2"></i>Trainee Attempts History</h3>
</div>
<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">Attempt Item</th>
<th>Type</th>
<th>Date Attempted</th>
<th>Sharing Status</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.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>
{% endblock %}