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:
+247
-22
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user