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
+23 -2
View File
@@ -108,8 +108,29 @@ def user_is_collection_author_or_atlas_editor(function):
or request.user.is_superuser
):
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.__name__ = function.__name__
@@ -1,9 +1,18 @@
{% extends 'atlas/base.html' %}
{% load crispy_forms_tags %}
{% load django_htmx %}
{% block content %}
<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>
<h2 class="h4 mb-0">Case {{case_number|add:1}}
@@ -306,6 +315,7 @@
{% endif %}
{% endif %}
{% if not viewing_as_user %}
<div class="mb-2">
{% if previous %}
<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>
{% endif %}
<div class="mt-3">
<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>
</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>
{% if question_completed and collection.case_query_messaging_enabled %}
@@ -45,7 +45,7 @@
</div>
<div class="d-flex flex-wrap gap-2">
<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
</a>
{% 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,
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(
"collection/<int:pk>/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_query = False
if not can_feedback:
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
# Check if request.user is the supervisor of the attempt owner and sharing is enabled
is_supervisor = False
if request.user.is_authenticated and cid_user_exam.user_user_id:
try:
sup = cid_user_exam.user_user.userprofile.supervisor
if sup and sup.user_id == request.user.id:
if cid_user_exam.share_with_supervisor or collection.results_supervisor_visible:
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):
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)
@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(
request, pk: int, case_number: int, cid=None, passcode=None
):