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
+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,
},
)