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:
+23
-2
@@ -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> — Viewing <strong>{{ target_user.get_full_name|default:target_user.username }}</strong>’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 %}
|
||||
|
||||
@@ -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
@@ -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
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user