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:
@@ -78,6 +78,10 @@ Instead
|
|||||||
- Do not use `TestCase` unless necessary — prefer plain `pytest` functions with `@pytest.mark.django_db`.
|
- Do not use `TestCase` unless necessary — prefer plain `pytest` functions with `@pytest.mark.django_db`.
|
||||||
- Run tests: `pytest` or `pytest {app}/tests/`.
|
- Run tests: `pytest` or `pytest {app}/tests/`.
|
||||||
|
|
||||||
|
## Exceptions
|
||||||
|
- Avoid unnecessary try: except blocks.
|
||||||
|
- We should not generate empty excepty blocks, if there is an error or bug this should be surfaced (even if just to the logs).
|
||||||
|
|
||||||
## Celery / Tasks
|
## Celery / Tasks
|
||||||
|
|
||||||
- Tasks autodiscovered from `{app}/tasks.py`.
|
- Tasks autodiscovered from `{app}/tasks.py`.
|
||||||
|
|||||||
+23
-2
@@ -108,8 +108,29 @@ def user_is_collection_author_or_atlas_editor(function):
|
|||||||
or request.user.is_superuser
|
or request.user.is_superuser
|
||||||
):
|
):
|
||||||
return function(request, *args, **kwargs)
|
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.__doc__ = function.__doc__
|
||||||
wrap.__name__ = function.__name__
|
wrap.__name__ = function.__name__
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
{% extends 'atlas/base.html' %}
|
{% extends 'atlas/base.html' %}
|
||||||
{% load crispy_forms_tags %}
|
{% load crispy_forms_tags %}
|
||||||
|
{% load django_htmx %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mb-2"><small class="text-muted">Collection: <strong>{{collection}}</strong></small></div>
|
<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 class="d-flex justify-content-between align-items-start mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="h4 mb-0">Case {{case_number|add:1}}
|
<h2 class="h4 mb-0">Case {{case_number|add:1}}
|
||||||
@@ -306,6 +315,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if not viewing_as_user %}
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
{% if previous %}
|
{% if previous %}
|
||||||
<button type="submit" name="previous" class="btn btn-outline-secondary btn-sm me-2">Previous</button>
|
<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>
|
<button type="submit" name="complete_case" class="btn btn-success btn-sm">Finish Case</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<button type="submit" name="finish" class="btn btn-outline-secondary btn-sm">Overview</button>
|
<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>
|
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
|
|
||||||
{% if question_completed and collection.case_query_messaging_enabled %}
|
{% if question_completed and collection.case_query_messaging_enabled %}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
<a class="btn btn-sm btn-primary"
|
<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
|
Open Case
|
||||||
</a>
|
</a>
|
||||||
{% if request.user.is_superuser and row.user_answer %}
|
{% if request.user.is_superuser and row.user_answer %}
|
||||||
|
|||||||
@@ -438,6 +438,11 @@ urlpatterns = [
|
|||||||
views.redirect_collection_case_view_take_user_answers_by_id,
|
views.redirect_collection_case_view_take_user_answers_by_id,
|
||||||
name="collection_case_view_take_user_answers_legacy",
|
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(
|
path(
|
||||||
"collection/<int:pk>/json_edit",
|
"collection/<int:pk>/json_edit",
|
||||||
views.GenericExamViews.exam_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_feedback = _user_can_moderate_collection(collection, request.user)
|
||||||
can_query = False
|
can_query = False
|
||||||
if not can_feedback:
|
if not can_feedback:
|
||||||
try:
|
# Check if request.user is the supervisor of the attempt owner and sharing is enabled
|
||||||
collection.check_user_can_take(cid, passcode, request.user)
|
is_supervisor = False
|
||||||
if cid_user_exam.user_user_id:
|
if request.user.is_authenticated and cid_user_exam.user_user_id:
|
||||||
can_query = request.user.is_authenticated and cid_user_exam.user_user_id == request.user.id
|
try:
|
||||||
elif cid_user_exam.cid_user_id and cid is not None:
|
sup = cid_user_exam.user_user.userprofile.supervisor
|
||||||
can_query = str(cid_user_exam.cid_user.cid) == str(cid)
|
if sup and sup.user_id == request.user.id:
|
||||||
except Exception:
|
if cid_user_exam.share_with_supervisor or collection.results_supervisor_visible:
|
||||||
can_query = False
|
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):
|
if not (can_feedback or can_query):
|
||||||
raise PermissionDenied
|
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)
|
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(
|
def collection_case_view_take_answers(
|
||||||
request, pk: int, case_number: int, cid=None, passcode=None
|
request, pk: int, case_number: int, cid=None, passcode=None
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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>
|
<dd class="col-sm-8" id="name">{{ object.name|default:"-" }}</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4 text-muted">Email</dt>
|
<dt class="col-sm-4 text-muted">Email</dt>
|
||||||
<dd class="col-sm-8" id="email">
|
<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>
|
||||||
{% 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>
|
<dt class="col-sm-4 text-muted">User Account</dt>
|
||||||
<dd class="col-sm-8" id="user">{{ object.user|default:"-" }}</dd>
|
<dd class="col-sm-8" id="user">{{ object.user|default:"-" }}</dd>
|
||||||
|
|||||||
@@ -3,41 +3,201 @@
|
|||||||
{% load crispy_forms_tags %}
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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 }} · <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>
|
<!-- Help Info -->
|
||||||
<details class="help-text">
|
<div class="alert alert-secondary border-secondary bg-dark bg-opacity-25 mb-4">
|
||||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
<div class="d-flex gap-2">
|
||||||
<p>As a supervisor you have the ability to view results on the platform for your associated trainees.</p>
|
<i class="bi bi-info-circle text-info fs-5"></i>
|
||||||
<p>Some exams / collection results will be automatically available for you to review, others may require the trainee to allow access</p>
|
<div>
|
||||||
<p>If your trainees are incorrect please contact ross.kruger@nhs.net</p>
|
<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>
|
<!-- Attempts Tab -->
|
||||||
You have the following trainee(s):
|
<div class="tab-pane fade" id="attempts" role="tabpanel" aria-labelledby="attempts-tab">
|
||||||
|
<div class="card bg-dark bg-opacity-25 border-secondary">
|
||||||
<ul id="trainee-list">
|
<div class="card-body p-0">
|
||||||
{% for trainee in trainees %}
|
<div class="table-responsive">
|
||||||
<li>
|
<table class="table table-dark table-striped table-hover align-middle mb-0">
|
||||||
<i class="bi bi-file-person"></i>
|
<thead>
|
||||||
<a href='{% url "generic:supervisor_trainee" supervisor.pk trainee.pk %}'>{{trainee.first_name}} {{trainee.last_name}}, {{trainee.email}}</a>
|
<tr class="text-muted border-secondary">
|
||||||
</li>
|
<th class="ps-4">Trainee</th>
|
||||||
|
<th>Attempt Item</th>
|
||||||
{% endfor %}
|
<th>Type</th>
|
||||||
</ul>
|
<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 %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block css %}
|
{% block css %}
|
||||||
<style>
|
<style>
|
||||||
#trainee-list {
|
.nav-tabs .nav-link.active {
|
||||||
list-style-type: none;
|
border-color: var(--bs-secondary) var(--bs-secondary) var(--bs-body-bg) !important;
|
||||||
padding: 0;
|
border-bottom-width: 2px !important;
|
||||||
|
}
|
||||||
|
.nav-tabs .nav-link:hover:not(.active) {
|
||||||
|
border-color: transparent !important;
|
||||||
|
color: var(--bs-light) !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
{% endblock css %}
|
{% endblock css %}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,111 @@
|
|||||||
{% extends 'generic/base_supervisor.html' %}
|
{% extends 'generic/base_supervisor.html' %}
|
||||||
|
|
||||||
{% load crispy_forms_tags %}
|
|
||||||
{% block css %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% 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>
|
<!-- Trainee Profile Card -->
|
||||||
email: {{trainee.email}}
|
<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>
|
||||||
|
|
||||||
|
<!-- Attempts Table -->
|
||||||
<h3>Results</h3>
|
<div class="card bg-dark bg-opacity-25 border-secondary">
|
||||||
<div id="exam-results">
|
<div class="card-header bg-dark bg-opacity-50 border-secondary py-3">
|
||||||
{% if all_exams %}
|
<h3 class="h5 mb-0 text-light"><i class="bi bi-list-task text-primary me-2"></i>Trainee Attempts History</h3>
|
||||||
The following exam results for the trainee are available.
|
</div>
|
||||||
{% for exam_type, exams in all_exams %}
|
<div class="card-body p-0">
|
||||||
{% if exams %}
|
<div class="table-responsive">
|
||||||
<h4>{{exam_type|title}}</h4>
|
<table class="table table-dark table-striped table-hover align-middle mb-0">
|
||||||
<ul class='{{exam_type|lower}}'>
|
<thead>
|
||||||
{% for exam in exams %}
|
<tr class="text-muted border-secondary">
|
||||||
<li class="exam" data-exam-id="{{exam.pk}}">
|
<th class="ps-4">Attempt Item</th>
|
||||||
|
<th>Type</th>
|
||||||
<a href= "{% url exam_type|add:':exam_scores_user_supervisor' pk=exam.pk user_id=trainee.pk %}" title="Click to view results">
|
<th>Date Attempted</th>
|
||||||
|
<th>Sharing Status</th>
|
||||||
{{exam}}</a> {% if exam.active %}[Active]{% endif %} {% if exam.publish_results %}[Results Published]{% endif %}</li>
|
<th>Score / Progress</th>
|
||||||
{% endfor %}
|
<th class="text-end pe-4">Action</th>
|
||||||
</ul>
|
</tr>
|
||||||
{% endif %}
|
</thead>
|
||||||
{% endfor %}
|
<tbody>
|
||||||
{% else %}
|
{% for item in attempts %}
|
||||||
No results are available for this trainee.
|
<tr class="border-secondary">
|
||||||
{% endif %}
|
<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>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import datetime
|
|||||||
|
|
||||||
class TestExamCollections:
|
class TestExamCollections:
|
||||||
def test_list_url(self, db, client):
|
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
|
# Test with no content
|
||||||
response = client.get(reverse("generic:examcollection_list"))
|
response = client.get(reverse("generic:examcollection_list"))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -22,11 +25,12 @@ class TestExamCollections:
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
collection = ExamCollection.objects.create(name="test collection", date=datetime.date.today())
|
collection = ExamCollection.objects.create(name="test collection", date=datetime.date.today())
|
||||||
|
collection.author.add(user)
|
||||||
|
|
||||||
response = client.get(reverse("generic:examcollection_list"))
|
response = client.get(reverse("generic:examcollection_list"))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
soup = BeautifulSoup(response.content, "html.parser")
|
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, )))
|
response = client.get(reverse("generic:examcollection_detail", args=( collection.pk, )))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -239,6 +239,11 @@ urlpatterns = [
|
|||||||
views.supervisor_trainee,
|
views.supervisor_trainee,
|
||||||
name="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(
|
path(
|
||||||
"supervisor/<int:pk>",
|
"supervisor/<int:pk>",
|
||||||
views.SupervisorDetail.as_view(),
|
views.SupervisorDetail.as_view(),
|
||||||
|
|||||||
+247
-22
@@ -380,6 +380,126 @@ class CidManagerRequiredMixin(UserPassesTestMixin):
|
|||||||
# raise PermissionDenied() # or Http404
|
# 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):
|
def get_user_exams(user, supervisor_view=False):
|
||||||
EXAM_ANSWER_MAP = {
|
EXAM_ANSWER_MAP = {
|
||||||
"physics": (PhysicsUserAnswer, PhysicsExam, "physics_exams"),
|
"physics": (PhysicsUserAnswer, PhysicsExam, "physics_exams"),
|
||||||
@@ -3547,11 +3667,34 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
return self.exam_scores_cid_user(request, pk, user=user)
|
return self.exam_scores_cid_user(request, pk, user=user)
|
||||||
|
|
||||||
def exam_scores_user_supervisor(self, request, pk, user_id):
|
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)
|
user = User.objects.get(id=user_id)
|
||||||
if not request.user.is_superuser:
|
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
|
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(
|
return self.exam_scores_cid_user(
|
||||||
request, pk, user=user, supervisor=user.userprofile.supervisor.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):
|
class SupervisorDetail(DetailView):
|
||||||
model = Supervisor
|
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):
|
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)
|
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
|
self.request.user.is_superuser
|
||||||
or self.request.user.groups.filter(name="cid_user_manager").exists()
|
or self.request.user.groups.filter(name="cid_user_manager").exists()
|
||||||
or (hasattr(self.request.user, "supervisor") and obj.user == self.request.user)
|
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
|
@user_is_cid_user_manager
|
||||||
@@ -6529,12 +6671,38 @@ def supervisor_overview(request, pk):
|
|||||||
except User.supervisor.RelatedObjectDoesNotExist:
|
except User.supervisor.RelatedObjectDoesNotExist:
|
||||||
raise PermissionDenied()
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"generic/supervisor_overview.html",
|
"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:
|
except User.supervisor.RelatedObjectDoesNotExist:
|
||||||
raise PermissionDenied()
|
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)
|
trainee = User.objects.get(userprofile__supervisor=supervisor, pk=trainee_id)
|
||||||
|
attempts = get_supervisor_attempts(supervisor, [trainee])
|
||||||
exams = get_user_exams(trainee, supervisor_view=True)
|
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"generic/supervisor_trainee.html",
|
"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,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -314,6 +314,11 @@
|
|||||||
<a class="nav-link active" href="{% url 'people' %}">People</a>
|
<a class="nav-link active" href="{% url 'people' %}">People</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if request.user.supervisor %}
|
||||||
|
<li id="supervisor-link" class="nav-item">
|
||||||
|
<a class="nav-link active" href="{% url 'generic:supervisor_overview' request.user.supervisor.pk %}">Supervisor</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
<li id="profile-link" class=" nav-item">
|
<li id="profile-link" class=" nav-item">
|
||||||
<a class="nav-link active" href="{% url 'profile' %}">Profile</a>
|
<a class="nav-link active" href="{% url 'profile' %}">Profile</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -43,6 +43,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if request.user.supervisor %}
|
||||||
|
<div class="card mb-3 border border-info bg-dark bg-opacity-25">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title text-info"><i class="bi bi-person-workspace me-2"></i>Supervisor Dashboard</h5>
|
||||||
|
<p class="card-text text-muted mb-3">Welcome, {{ request.user.supervisor.name }}. You can track, review, and leave feedback on your trainees' attempts here.</p>
|
||||||
|
<a class="btn btn-outline-info" href="{% url 'generic:supervisor_overview' request.user.supervisor.pk %}">Go to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">Exams / Courses</h5>
|
<h5 class="card-title">Exams / Courses</h5>
|
||||||
|
|||||||
Reference in New Issue
Block a user