feat: Add supervision page to display collection attempts shared with the user
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -73,6 +73,50 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ---- Who can see your results ---- #}
|
||||
{% if sharing_summary %}
|
||||
<section class="take-start-card mt-3">
|
||||
<div class="fw-semibold mb-2 small text-body-secondary text-uppercase" style="letter-spacing:.05em;">Who can see your results</div>
|
||||
<ul class="list-unstyled mb-0 small">
|
||||
<li class="mb-1">
|
||||
<span class="badge bg-secondary me-1">You</span>
|
||||
Always visible to you.
|
||||
</li>
|
||||
{% for u in sharing_summary.always_visible_to %}
|
||||
<li class="mb-1">
|
||||
<span class="badge bg-secondary me-1">Author/Marker</span>
|
||||
{{ u.get_full_name|default:u.username }}
|
||||
{% if u.email %}<span class="text-muted"><{{ u.email }}></span>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if sharing_summary.supervisor_always %}
|
||||
<li class="mb-1">
|
||||
<span class="badge bg-info text-dark me-1">Supervisor</span>
|
||||
{% if sharing_summary.supervisor %}
|
||||
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
|
||||
(always shared – collection setting)
|
||||
{% else %}
|
||||
Your supervisor, if one is linked to your profile (collection setting always on).
|
||||
{% endif %}
|
||||
</li>
|
||||
{% elif sharing_summary.supervisor_opt_in %}
|
||||
<li class="mb-1">
|
||||
<span class="badge bg-outline-secondary me-1" style="border:1px solid var(--bs-secondary);color:var(--bs-secondary);">Supervisor</span>
|
||||
{% if sharing_summary.supervisor %}
|
||||
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
|
||||
— you can choose to share with them after starting.
|
||||
{% else %}
|
||||
You have a supervisor linked but they don't have a login account yet.
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if not sharing_summary.always_visible_to and not sharing_summary.supervisor_always and not sharing_summary.supervisor_opt_in %}
|
||||
<li class="text-muted fst-italic">Only you can see your results for this collection.</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<section class="take-start-card">
|
||||
<p class="mb-3">
|
||||
|
||||
@@ -2,70 +2,70 @@
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-3">
|
||||
<h2 class="h4 mb-1">Shared with me</h2>
|
||||
<p class="text-muted small mb-0">
|
||||
Collection attempts shared with you as an author, marker, supervisor, or by direct invitation.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<h2 class="h4 mb-1">Shared with me</h2>
|
||||
<p class="text-muted small mb-0">
|
||||
Collection attempts shared with you as an author, marker, supervisor, or by direct invitation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if attempt_list %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Collection</th>
|
||||
<th>Learner</th>
|
||||
<th>Started</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for attempt, collection in attempt_list %}
|
||||
{% if attempt_list %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>
|
||||
{% if collection %}
|
||||
<strong>{{ collection.name }}</strong>
|
||||
{% else %}
|
||||
<span class="text-muted">Unknown collection</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if attempt.user_user %}
|
||||
{{ attempt.user_user.get_full_name|default:attempt.user_user.username }}
|
||||
{% if attempt.user_user.email %}
|
||||
<br><span class="small text-muted">{{ attempt.user_user.email }}</span>
|
||||
{% endif %}
|
||||
{% elif attempt.cid_user %}
|
||||
CID {{ attempt.cid_user.cid }}
|
||||
{% if attempt.cid_user.name %}<br><span class="small text-muted">{{ attempt.cid_user.name }}</span>{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">Unknown</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small">{{ attempt.start_time|default:"-" }}</td>
|
||||
<td>
|
||||
{% if attempt.completed %}
|
||||
<span class="badge bg-success">Completed</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">In progress</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-outline-primary"
|
||||
href="{% url 'atlas:collection_shared_attempt_overview' attempt.pk %}">
|
||||
View attempt
|
||||
</a>
|
||||
</td>
|
||||
<th>Collection</th>
|
||||
<th>Learner</th>
|
||||
<th>Started</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
No collection attempts have been shared with you yet.
|
||||
</div>
|
||||
{% endif %}
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for attempt, collection in attempt_list %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if collection %}
|
||||
<strong>{{ collection.name }}</strong>
|
||||
{% else %}
|
||||
<span class="text-muted">Unknown collection</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if attempt.user_user %}
|
||||
{{ attempt.user_user.get_full_name|default:attempt.user_user.username }}
|
||||
{% if attempt.user_user.email %}
|
||||
<br><span class="small text-muted">{{ attempt.user_user.email }}</span>
|
||||
{% endif %}
|
||||
{% elif attempt.cid_user %}
|
||||
CID {{ attempt.cid_user.cid }}
|
||||
{% if attempt.cid_user.name %}<br><span class="small text-muted">{{ attempt.cid_user.name }}</span>{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">Unknown</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small">{{ attempt.start_time|default:"-" }}</td>
|
||||
<td>
|
||||
{% if attempt.completed %}
|
||||
<span class="badge bg-success">Completed</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">In progress</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-outline-primary"
|
||||
href="{% url 'atlas:collection_shared_attempt_overview' attempt.pk %}">
|
||||
View attempt
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
No collection attempts have been shared with you yet.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
{% extends 'atlas/exams.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
|
||||
<div>
|
||||
<h2 class="h4 mb-1">Supervision</h2>
|
||||
<p class="text-muted small mb-0">
|
||||
Collection attempts shared with you as author, marker, supervisor or by direct invitation.
|
||||
<strong>{{ total_attempts }}</strong> attempt{{ total_attempts|pluralize }} found.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Filter / group toolbar ---- #}
|
||||
<form method="get" class="row g-2 align-items-end mb-4">
|
||||
<div class="col-12 col-md-auto">
|
||||
<label class="form-label small mb-1">Group by</label>
|
||||
<div class="btn-group d-flex" role="group">
|
||||
<a href="?group_by=user&date_from={{ date_from }}&date_to={{ date_to }}"
|
||||
class="btn btn-sm {% if group_by == 'user' %}btn-primary{% else %}btn-outline-secondary{% endif %}">
|
||||
By learner
|
||||
</a>
|
||||
<a href="?group_by=collection&date_from={{ date_from }}&date_to={{ date_to }}"
|
||||
class="btn btn-sm {% if group_by == 'collection' %}btn-primary{% else %}btn-outline-secondary{% endif %}">
|
||||
By collection
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-auto">
|
||||
<label class="form-label small mb-1" for="date_from">From</label>
|
||||
<input type="date" id="date_from" name="date_from" class="form-control form-control-sm"
|
||||
value="{{ date_from }}">
|
||||
</div>
|
||||
<div class="col-12 col-md-auto">
|
||||
<label class="form-label small mb-1" for="date_to">To</label>
|
||||
<input type="date" id="date_to" name="date_to" class="form-control form-control-sm"
|
||||
value="{{ date_to }}">
|
||||
</div>
|
||||
<input type="hidden" name="group_by" value="{{ group_by }}">
|
||||
<div class="col-12 col-md-auto">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Apply filter</button>
|
||||
<a href="{% url 'atlas:collection_supervision' %}" class="btn btn-sm btn-outline-secondary ms-1">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# ---- Grouped results ---- #}
|
||||
{% if groups %}
|
||||
{% for group_label, attempt_rows in groups %}
|
||||
<div class="mb-4">
|
||||
<h5 class="h6 fw-semibold border-bottom pb-1 mb-2">
|
||||
{% if group_by == 'user' %}
|
||||
<i class="bi bi-person me-1" aria-hidden="true"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-collection me-1" aria-hidden="true"></i>
|
||||
{% endif %}
|
||||
{{ group_label }}
|
||||
<span class="badge bg-secondary fw-normal ms-1">{{ attempt_rows|length }} attempt{{ attempt_rows|length|pluralize }}</span>
|
||||
</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm align-middle mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
{% if group_by == 'user' %}
|
||||
<th>Collection</th>
|
||||
{% else %}
|
||||
<th>Learner</th>
|
||||
{% endif %}
|
||||
<th>Started</th>
|
||||
<th>Ended</th>
|
||||
<th>Status</th>
|
||||
<th>Progress</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for attempt, collection in attempt_rows %}
|
||||
<tr>
|
||||
{% if group_by == 'user' %}
|
||||
<td>
|
||||
{% if collection %}
|
||||
{{ collection.name }}
|
||||
{% else %}
|
||||
<span class="text-muted">Unknown</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td>
|
||||
{% if attempt.user_user %}
|
||||
{{ attempt.user_user.get_full_name|default:attempt.user_user.username }}
|
||||
{% if attempt.user_user.email %}
|
||||
<br><span class="text-muted small">{{ attempt.user_user.email }}</span>
|
||||
{% endif %}
|
||||
{% elif attempt.cid_user %}
|
||||
CID {{ attempt.cid_user.cid }}
|
||||
{% if attempt.cid_user.name %}<br><span class="text-muted small">{{ attempt.cid_user.name }}</span>{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">Unknown</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="small text-nowrap">{{ attempt.start_time|default:"-" }}</td>
|
||||
<td class="small text-nowrap">{{ attempt.end_time|default:"-" }}</td>
|
||||
<td>
|
||||
{% if attempt.completed %}
|
||||
<span class="badge bg-success">Completed</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">In progress</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if attempt.user_user_id %}
|
||||
<a class="btn btn-sm btn-outline-primary"
|
||||
href="{% url 'atlas:collection_history_user' exam_id=collection.pk user_pk=attempt.user_user_id %}">
|
||||
View attempt
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="btn btn-sm btn-outline-primary"
|
||||
href="{% url 'atlas:collection_shared_attempt_overview' attempt.pk %}">
|
||||
View attempt
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
No collection attempts have been shared with you yet. Try adjusting the date filter.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -773,4 +773,9 @@ urlpatterns = [
|
||||
views.collection_shared_attempt_overview,
|
||||
name="collection_shared_attempt_overview",
|
||||
),
|
||||
path(
|
||||
"supervision/",
|
||||
views.collection_supervision,
|
||||
name="collection_supervision",
|
||||
),
|
||||
]
|
||||
|
||||
+171
@@ -5013,10 +5013,14 @@ def collection_take_start(request, pk, cid=None, passcode=None):
|
||||
if collection.collection_type == "REV" or valid_user:
|
||||
cid_exam = collection.get_cid_and_user_exams(user_user=request.user).first()
|
||||
|
||||
# Build a lightweight summary of who results will be shared with.
|
||||
sharing_summary = _collection_sharing_summary(collection, request.user)
|
||||
|
||||
template_variables = {
|
||||
"collection": collection,
|
||||
"valid_user": valid_user,
|
||||
"cid_exam": cid_exam,
|
||||
"sharing_summary": sharing_summary,
|
||||
}
|
||||
|
||||
match collection.collection_type:
|
||||
@@ -5703,6 +5707,46 @@ def collection_take_overview(
|
||||
)
|
||||
|
||||
|
||||
def _collection_sharing_summary(collection, user):
|
||||
"""Return a dict describing who a registered user's results will be shared with
|
||||
for the given collection, before an attempt is created.
|
||||
|
||||
Keys:
|
||||
always_visible_to – list of User objects (authors + markers)
|
||||
supervisor – User | None (linked supervisor account, if any)
|
||||
supervisor_always – bool (results_supervisor_visible on the collection)
|
||||
supervisor_opt_in – bool (user can choose to share with supervisor)
|
||||
"""
|
||||
always_visible = []
|
||||
seen_pks = set()
|
||||
for attr in ("author", "markers"):
|
||||
if hasattr(collection, attr):
|
||||
for u in getattr(collection, attr).select_related():
|
||||
if u.pk not in seen_pks:
|
||||
seen_pks.add(u.pk)
|
||||
always_visible.append(u)
|
||||
|
||||
supervisor_always = getattr(collection, "results_supervisor_visible", False)
|
||||
supervisor_user = None
|
||||
supervisor_opt_in = False
|
||||
if user and user.is_authenticated:
|
||||
try:
|
||||
sup = user.userprofile.supervisor
|
||||
if sup:
|
||||
supervisor_opt_in = True
|
||||
if sup.user_id:
|
||||
supervisor_user = sup.user
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"always_visible_to": always_visible,
|
||||
"supervisor": supervisor_user,
|
||||
"supervisor_always": supervisor_always,
|
||||
"supervisor_opt_in": supervisor_opt_in,
|
||||
}
|
||||
|
||||
|
||||
@login_required
|
||||
def collection_exam_sharing_panel(request, pk):
|
||||
"""HTMX view to display and manage sharing settings for a CidUserExam attempt.
|
||||
@@ -5940,6 +5984,133 @@ def collection_shared_attempt_overview(request, cid_user_exam_pk):
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def collection_supervision(request):
|
||||
"""Supervision page: lists all collection attempts visible to the current user as
|
||||
an author, marker, supervisor, or manual-share recipient.
|
||||
|
||||
Supports:
|
||||
- GET param ``group_by``: ``user`` (default) or ``collection``
|
||||
- GET params ``date_from`` / ``date_to``: ISO date strings to filter by
|
||||
attempt start_time (inclusive).
|
||||
"""
|
||||
from django.db.models import Q
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
collection_ct = ContentType.objects.get_for_model(CaseCollection)
|
||||
|
||||
# ---- build base queryset of accessible attempts ----
|
||||
manual_qs = CidUserExam.objects.filter(
|
||||
shared_with_users=request.user,
|
||||
content_type=collection_ct,
|
||||
).exclude(user_user=request.user)
|
||||
|
||||
managed_collection_ids = list(
|
||||
CaseCollection.objects.filter(
|
||||
Q(author=request.user) | Q(markers=request.user)
|
||||
).values_list("pk", flat=True)
|
||||
)
|
||||
authored_qs = CidUserExam.objects.filter(
|
||||
content_type=collection_ct,
|
||||
object_id__in=managed_collection_ids,
|
||||
).exclude(user_user=request.user)
|
||||
|
||||
supervisor_qs = CidUserExam.objects.none()
|
||||
supervisor_exam_qs = CidUserExam.objects.none()
|
||||
try:
|
||||
supervisor = request.user.supervisor
|
||||
trainee_users = User.objects.filter(userprofile__supervisor=supervisor)
|
||||
trainee_cid_users = CidUser.objects.filter(supervisor=supervisor)
|
||||
supervisor_qs = CidUserExam.objects.filter(
|
||||
content_type=collection_ct,
|
||||
share_with_supervisor=True,
|
||||
).filter(
|
||||
Q(user_user__in=trainee_users) | Q(cid_user__in=trainee_cid_users)
|
||||
).exclude(user_user=request.user)
|
||||
|
||||
visible_collection_ids = list(
|
||||
CaseCollection.objects.filter(results_supervisor_visible=True).values_list("pk", flat=True)
|
||||
)
|
||||
supervisor_exam_qs = CidUserExam.objects.filter(
|
||||
content_type=collection_ct,
|
||||
object_id__in=visible_collection_ids,
|
||||
).filter(
|
||||
Q(user_user__in=trainee_users) | Q(cid_user__in=trainee_cid_users)
|
||||
).exclude(user_user=request.user)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
all_attempts = (
|
||||
manual_qs | authored_qs | supervisor_qs | supervisor_exam_qs
|
||||
).distinct().select_related("user_user", "cid_user")
|
||||
|
||||
# ---- date filter ----
|
||||
date_from_raw = request.GET.get("date_from", "").strip()
|
||||
date_to_raw = request.GET.get("date_to", "").strip()
|
||||
date_from = None
|
||||
date_to = None
|
||||
try:
|
||||
if date_from_raw:
|
||||
from django.utils.dateparse import parse_date
|
||||
date_from = parse_date(date_from_raw)
|
||||
if date_from:
|
||||
all_attempts = all_attempts.filter(start_time__date__gte=date_from)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if date_to_raw:
|
||||
from django.utils.dateparse import parse_date
|
||||
date_to = parse_date(date_to_raw)
|
||||
if date_to:
|
||||
all_attempts = all_attempts.filter(start_time__date__lte=date_to)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
all_attempts = all_attempts.order_by("-start_time")
|
||||
|
||||
# ---- grouping ----
|
||||
group_by = request.GET.get("group_by", "user")
|
||||
if group_by not in ("user", "collection"):
|
||||
group_by = "user"
|
||||
|
||||
grouped = {}
|
||||
for attempt in all_attempts:
|
||||
collection_obj = attempt.exam
|
||||
if group_by == "collection":
|
||||
key_label = str(collection_obj) if collection_obj else "Unknown collection"
|
||||
key_id = getattr(collection_obj, "pk", 0)
|
||||
key = (key_id, key_label)
|
||||
else:
|
||||
if attempt.user_user_id:
|
||||
key_label = attempt.user_user.get_full_name() or attempt.user_user.username
|
||||
key_id = attempt.user_user_id
|
||||
elif attempt.cid_user_id:
|
||||
name = attempt.cid_user.name or ""
|
||||
key_label = f"CID {attempt.cid_user.cid}" + (f" – {name}" if name else "")
|
||||
key_id = f"cid_{attempt.cid_user_id}"
|
||||
else:
|
||||
key_label = "Unknown learner"
|
||||
key_id = 0
|
||||
key = (key_id, key_label)
|
||||
grouped.setdefault(key, []).append((attempt, collection_obj))
|
||||
|
||||
# Convert to sorted list of (key_label, [(attempt, collection), ...])
|
||||
groups = sorted(grouped.items(), key=lambda x: str(x[0][1]).lower())
|
||||
groups = [(label, rows) for (_, label), rows in groups]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"atlas/supervision.html",
|
||||
{
|
||||
"groups": groups,
|
||||
"group_by": group_by,
|
||||
"date_from": date_from_raw,
|
||||
"date_to": date_to_raw,
|
||||
"total_attempts": all_attempts.count(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def collection_case_view_take_user_answers(request, pk: int, case_number: int):
|
||||
return collection_case_view_take_answers(request, pk, case_number)
|
||||
|
||||
|
||||
+15
-2
@@ -203,8 +203,21 @@
|
||||
<li id="atlas-link" class="nav-item">
|
||||
<a class="nav-link active" href="{% url 'atlas:index' %}">Atlas</a>
|
||||
</li>
|
||||
<li id="messages-link" class="nav-item">
|
||||
<a class="nav-link active" href="{% url 'atlas:user_messages_inbox' %}">Messages</a>
|
||||
<li id="messages-link" class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle active" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Activity
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:user_messages_inbox' %}">Messages</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:collection_supervision' %}">Supervision</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:collection_shared_with_me' %}">Shared with me</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown pt-0">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
|
||||
Reference in New Issue
Block a user