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 %}
+5 -1
View File
@@ -11,6 +11,9 @@ import datetime
class TestExamCollections:
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
response = client.get(reverse("generic:examcollection_list"))
assert response.status_code == 200
@@ -22,11 +25,12 @@ class TestExamCollections:
assert response.status_code == 404
collection = ExamCollection.objects.create(name="test collection", date=datetime.date.today())
collection.author.add(user)
response = client.get(reverse("generic:examcollection_list"))
assert response.status_code == 200
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, )))
+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,
name="supervisor_trainee",
),
path(
"supervisor/<int:pk>/trainee/<int:trainee_id>/collection/<int:collection_id>/",
views.supervisor_collection_feedback,
name="supervisor_collection_feedback",
),
path(
"supervisor/<int:pk>",
views.SupervisorDetail.as_view(),
+247 -22
View File
@@ -380,6 +380,126 @@ class CidManagerRequiredMixin(UserPassesTestMixin):
# 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):
EXAM_ANSWER_MAP = {
"physics": (PhysicsUserAnswer, PhysicsExam, "physics_exams"),
@@ -3547,11 +3667,34 @@ class ExamViews(View, LoginRequiredMixin):
return self.exam_scores_cid_user(request, pk, user=user)
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)
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
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(
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):
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."""
"""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)
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
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
return obj
raise PermissionDenied
@user_is_cid_user_manager
@@ -6529,12 +6671,38 @@ def supervisor_overview(request, pk):
except User.supervisor.RelatedObjectDoesNotExist:
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(
request,
"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:
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)
exams = get_user_exams(trainee, supervisor_view=True)
attempts = get_supervisor_attempts(supervisor, [trainee])
return render(
request,
"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,
},
)