feat: Implement sharing functionality for CidUserExam attempts, including user management and sharing settings

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Ross
2026-04-28 22:09:06 +01:00
parent cc100dba89
commit 7062723433
8 changed files with 597 additions and 3 deletions
@@ -38,7 +38,7 @@
{% if collection.case_query_messaging_enabled and collection.in_review_mode or collection.case_query_messaging_enabled and cid_user_exam.completed %}
<div class="mt-2">
<a class="btn btn-sm {% if total_outstanding_feedback %}btn-danger{% else %}btn-outline-secondary{% endif %}"
href="{% if cid is not None %}{% url 'atlas:collection_feedback_overview' pk=collection.id cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_feedback_overview_user' pk=collection.id %}{% endif %}">
href="{% if cid is not None %}{% url 'atlas:collection_feedback_overview' pk=collection.id cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_feedback_overview_user' pk=collection.id %}{% endif %}">
{% if total_outstanding_feedback %}
Review Feedback Hub
{% else %}
@@ -89,12 +89,12 @@
</a>
{% if collection.in_review_mode or cid_user_exam.completed %}
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:add_self_review' cid_user_exam.id question.case.id %}">
href="{% url 'atlas:add_self_review' cid_user_exam.id question.case.id %}">
{% if self_review %}Add New Self Review{% else %}Add Self Review{% endif %}
</a>
{% if collection.case_query_messaging_enabled and review_stats.has_messages %}
<a class="btn btn-sm {% if review_stats.has_outstanding_feedback %}btn-danger{% else %}btn-outline-dark{% endif %}"
href="{% if cid is not None %}{% url 'atlas:collection_feedback_overview' pk=collection.id cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_feedback_overview_user' pk=collection.id %}{% endif %}#case-feedback-{{ question.case.id }}">
href="{% if cid is not None %}{% url 'atlas:collection_feedback_overview' pk=collection.id cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_feedback_overview_user' pk=collection.id %}{% endif %}#case-feedback-{{ question.case.id }}">
View Conversation
</a>
{% endif %}
@@ -159,6 +159,15 @@
</div>
{% endif %}
{# Sharing panel only for registered-user attempts (not CID candidates) #}
{% if cid_user_exam.user_user_id %}
<div hx-get="{% url 'atlas:collection_exam_sharing_panel' cid_user_exam.pk %}"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-muted small mt-3">Loading sharing settings…</div>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,120 @@
{% load django_htmx %}
{# Sharing panel for a CidUserExam attempt.
Context:
cid_user_exam CidUserExam instance
sharing_info dict from cid_user_exam.get_sharing_info()
is_owner bool: current user is the attempt owner
#}
<div id="sharing-panel-{{ cid_user_exam.pk }}" class="card border-secondary mt-3">
<div class="card-header py-2 px-3 d-flex justify-content-between align-items-center">
<span class="fw-semibold small">Result Sharing</span>
<a href="{% url 'atlas:collection_shared_with_me' %}" class="btn btn-sm btn-outline-secondary">Shared with me</a>
</div>
<div class="card-body py-2 px-3">
{# Always-visible users #}
<div class="mb-3">
<div class="small text-muted mb-1">Always shared with (authors &amp; markers)</div>
{% if sharing_info.always_visible_to %}
<ul class="list-unstyled mb-0">
{% for u in sharing_info.always_visible_to %}
<li class="small">
<span class="badge bg-secondary me-1">Author/Marker</span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<span class="small text-muted fst-italic">No authors or markers assigned.</span>
{% endif %}
</div>
{# Supervisor sharing #}
{% if not sharing_info.exam_supervisor_visible %}
<div class="mb-3">
<div class="small text-muted mb-1">Share with supervisor</div>
{% if is_owner %}
<button class="btn btn-sm {% if sharing_info.supervisor_enabled %}btn-success{% else %}btn-outline-secondary{% endif %}"
hx-post="{% url 'atlas:collection_exam_sharing_panel' cid_user_exam.pk %}"
hx-vals='{"action": "toggle_supervisor"}'
hx-target="#sharing-panel-{{ cid_user_exam.pk }}"
hx-swap="outerHTML">
{% if sharing_info.supervisor_enabled %}Supervisor sharing ON{% else %}Supervisor sharing OFF{% endif %}
</button>
{% else %}
<span class="badge {% if sharing_info.supervisor_enabled %}bg-success{% else %}bg-secondary{% endif %}">
{% if sharing_info.supervisor_enabled %}ON{% else %}OFF{% endif %}
</span>
{% endif %}
{% if sharing_info.supervisor %}
<div class="small text-muted mt-1">
Supervisor:
<strong>{{ sharing_info.supervisor.get_full_name|default:sharing_info.supervisor.username }}</strong>
{% if sharing_info.supervisor.email %}&lt;{{ sharing_info.supervisor.email }}&gt;{% endif %}
</div>
{% elif sharing_info.supervisor_enabled %}
<div class="small text-warning mt-1">No supervisor account linked to your profile — results cannot be shared automatically.</div>
{% endif %}
</div>
{% else %}
<div class="mb-3">
<div class="small text-muted mb-1">Supervisor</div>
<span class="badge bg-success">Sharing always enabled (exam setting)</span>
{% if sharing_info.supervisor %}
<div class="small text-muted mt-1">
Supervisor:
<strong>{{ sharing_info.supervisor.get_full_name|default:sharing_info.supervisor.username }}</strong>
{% if sharing_info.supervisor.email %}&lt;{{ sharing_info.supervisor.email }}&gt;{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{# Manual shares — only for registered-user attempts #}
{% if cid_user_exam.user_user_id %}
<div>
<div class="small text-muted mb-1">Also shared with</div>
{% if sharing_info.manual_shares %}
<ul class="list-unstyled mb-2">
{% for u in sharing_info.manual_shares %}
<li class="small d-flex align-items-center gap-2 mb-1">
<span>{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</span>
{% if is_owner %}
<button class="btn btn-sm btn-outline-danger py-0 px-1"
hx-post="{% url 'atlas:collection_exam_sharing_panel' cid_user_exam.pk %}"
hx-vals='{"action": "remove_user", "user_id": "{{ u.pk }}"}'
hx-target="#sharing-panel-{{ cid_user_exam.pk }}"
hx-swap="outerHTML">
&times; Remove
</button>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<div class="small text-muted fst-italic mb-2">No additional users.</div>
{% endif %}
{% if is_owner %}
{# User search input #}
<div id="sharing-user-search-{{ cid_user_exam.pk }}">
<input type="text"
class="form-control form-control-sm"
placeholder="Search user by name or email…"
name="q"
autocomplete="off"
hx-get="{% url 'atlas:collection_exam_user_search' cid_user_exam.pk %}"
hx-trigger="input changed delay:300ms, focus"
hx-target="#sharing-search-results-{{ cid_user_exam.pk }}"
hx-swap="innerHTML">
<div id="sharing-search-results-{{ cid_user_exam.pk }}" class="mt-1"></div>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
@@ -0,0 +1,27 @@
{# HTMX partial: user search results for the sharing panel.
Context:
users list of User objects matching the query
cid_user_exam CidUserExam instance
q search query string
#}
{% if users %}
<ul class="list-group list-group-flush small border rounded">
{% for u in users %}
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-1 px-2">
<span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</span>
<button class="btn btn-sm btn-outline-primary py-0 px-2"
hx-post="{% url 'atlas:collection_exam_sharing_panel' cid_user_exam.pk %}"
hx-vals='{"action": "add_user", "user_id": "{{ u.pk }}"}'
hx-target="#sharing-panel-{{ cid_user_exam.pk }}"
hx-swap="outerHTML">
Add
</button>
</li>
{% endfor %}
</ul>
{% elif q|length >= 2 %}
<div class="small text-muted fst-italic">No matching users found.</div>
{% endif %}
+71
View File
@@ -0,0 +1,71 @@
{% extends 'atlas/exams.html' %}
{% 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>
{% 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 %}
<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 %}
+21
View File
@@ -752,4 +752,25 @@ urlpatterns = [
name="linked_cases_overview",
),
path("cases/by_size", views.cases_by_size, name="cases_by_size"),
# Sharing
path(
"exam/<int:pk>/sharing",
views.collection_exam_sharing_panel,
name="collection_exam_sharing_panel",
),
path(
"exam/<int:pk>/sharing/user-search",
views.collection_exam_user_search,
name="collection_exam_user_search",
),
path(
"collection/shared-with-me",
views.collection_shared_with_me,
name="collection_shared_with_me",
),
path(
"exam/<int:cid_user_exam_pk>/attempt-overview",
views.collection_shared_attempt_overview,
name="collection_shared_attempt_overview",
),
]
+237
View File
@@ -5703,6 +5703,243 @@ def collection_take_overview(
)
@login_required
def collection_exam_sharing_panel(request, pk):
"""HTMX view to display and manage sharing settings for a CidUserExam attempt.
Scope: attempt owner only (user_user). Read-only rendering for all others.
GET returns the sharing panel partial.
POST handles one of three actions (action= toggle_supervisor | add_user | remove_user).
"""
cid_user_exam = get_object_or_404(CidUserExam, pk=pk)
if not request.htmx:
raise PermissionDenied()
is_owner = bool(cid_user_exam.user_user_id and cid_user_exam.user_user_id == request.user.pk)
if request.method == "POST":
if not is_owner:
raise PermissionDenied()
action = request.POST.get("action")
if action == "toggle_supervisor":
cid_user_exam.share_with_supervisor = not cid_user_exam.share_with_supervisor
cid_user_exam.save(update_fields=["share_with_supervisor"])
elif action == "add_user":
try:
user_to_add = User.objects.get(pk=int(request.POST.get("user_id", "")))
if user_to_add.pk != request.user.pk:
cid_user_exam.shared_with_users.add(user_to_add)
except (User.DoesNotExist, ValueError, TypeError):
pass
elif action == "remove_user":
try:
user_to_remove = User.objects.get(pk=int(request.POST.get("user_id", "")))
cid_user_exam.shared_with_users.remove(user_to_remove)
except (User.DoesNotExist, ValueError, TypeError):
pass
sharing_info = cid_user_exam.get_sharing_info()
return render(
request,
"atlas/partials/_sharing_panel.html",
{
"cid_user_exam": cid_user_exam,
"sharing_info": sharing_info,
"is_owner": is_owner,
},
)
@login_required
def collection_exam_user_search(request, pk):
"""HTMX endpoint to search for users to add to a CidUserExam's shared_with_users.
Scope: attempt owner only.
GET param q minimum 2 characters. Returns a small result-list partial.
"""
cid_user_exam = get_object_or_404(CidUserExam, pk=pk)
if not request.htmx:
raise PermissionDenied()
if not (cid_user_exam.user_user_id and cid_user_exam.user_user_id == request.user.pk):
raise PermissionDenied()
q = (request.GET.get("q") or "").strip()
users = []
if len(q) >= 2:
already_shared = set(cid_user_exam.shared_with_users.values_list("pk", flat=True))
users = list(
User.objects.filter(
Q(first_name__icontains=q)
| Q(last_name__icontains=q)
| Q(email__icontains=q)
| Q(username__icontains=q)
)
.exclude(pk=request.user.pk)
.exclude(pk__in=already_shared)
.order_by("last_name", "first_name")[:10]
)
return render(
request,
"atlas/partials/_sharing_user_search_results.html",
{
"users": users,
"cid_user_exam": cid_user_exam,
"q": q,
},
)
@login_required
def collection_shared_with_me(request):
"""Full page listing all collection attempts that have been shared with the current user.
Includes attempts where the user is:
- An author or marker of the collection
- A supervisor of the attempt owner (share_with_supervisor=True or exam results_supervisor_visible)
- Manually listed in CidUserExam.shared_with_users
The user's own attempts are excluded.
"""
collection_ct = ContentType.objects.get_for_model(CaseCollection)
# Manual shares
manual_qs = CidUserExam.objects.filter(
shared_with_users=request.user,
content_type=collection_ct,
).exclude(user_user=request.user)
# Author or marker of the collection
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 (per-attempt flag)
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)
# Exam-level supervisor visibility
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").order_by("-start_time")
attempt_list = [(attempt, attempt.exam) for attempt in all_attempts]
return render(
request,
"atlas/shared_with_me.html",
{"attempt_list": attempt_list},
)
@login_required
def collection_shared_attempt_overview(request, cid_user_exam_pk):
"""Read-only overview of a learner's attempt, visible to authorised third parties.
Accessible to authors, markers, supervisors (when sharing is enabled), and
users manually added to CidUserExam.shared_with_users.
"""
cid_user_exam = get_object_or_404(
CidUserExam.objects.select_related("user_user", "cid_user"),
pk=cid_user_exam_pk,
)
if not cid_user_exam.can_user_view(request.user):
raise PermissionDenied()
collection = cid_user_exam.exam
if not isinstance(collection, CaseCollection):
raise Http404()
casedetails = list(
CaseDetail.objects.filter(collection=collection).select_related("case").order_by("sort_order")
)
reviews = cid_user_exam.selfreview_set.filter(
case__in=[cd.case for cd in casedetails]
).order_by("-review_update_date")
reviews_by_case = {}
for review in reviews:
reviews_by_case.setdefault(review.case_id, []).append(review)
case_review_stats, _, _ = _get_case_review_stats_for_exam(
cid_user_exam=cid_user_exam,
casedetails=casedetails,
messaging_enabled=_collection_case_query_messaging_enabled(collection),
)
history_rows = []
answer_count = 0
for casedetail in casedetails:
if cid_user_exam.user_user_id:
user_answer = casedetail.get_user_answers(cid_user_exam.user_user)
else:
user_answer = casedetail.get_cid_answers(cid_user_exam.cid_user.cid)
if user_answer and (user_answer.answer or user_answer.json_answer):
answer_count += 1
history_rows.append(
{
"casedetail": casedetail,
"user_answer": user_answer,
"self_reviews": reviews_by_case.get(casedetail.case_id, []),
"case_review_stats": case_review_stats.get(casedetail.case_id, {}),
}
)
return render(
request,
"atlas/collection_history_user.html",
{
"collection": collection,
"user": cid_user_exam.get_user_name(),
"cid_user_exam": cid_user_exam,
"history_rows": history_rows,
"answer_count": answer_count,
"collection_length": len(history_rows),
"cid": cid_user_exam.cid_user.cid if cid_user_exam.cid_user_id else None,
"read_only_shared_view": True,
},
)
def collection_case_view_take_user_answers(request, pk: int, case_number: int):
return collection_case_view_take_answers(request, pk, case_number)
@@ -0,0 +1,23 @@
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("generic", "0031_flag"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="ciduserexam",
name="shared_with_users",
field=models.ManyToManyField(
blank=True,
help_text="Users with whom this attempt has been manually shared.",
related_name="shared_cid_user_exams",
to=settings.AUTH_USER_MODEL,
),
),
]
+86
View File
@@ -1681,6 +1681,92 @@ class CidUserExam(models.Model):
share_with_supervisor = models.BooleanField(default=False, help_text="If true the exam status and results will be available to a users supervisor")
shared_with_users = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="shared_cid_user_exams",
help_text="Users with whom this attempt has been manually shared.",
)
def get_sharing_info(self):
"""Return a structured dict describing who can view this attempt and why.
Returns a dict with keys:
always_visible_to list of User objects (authors + markers of the exam)
supervisor User or None (the supervisor's Django account, if applicable)
supervisor_enabled bool (CidUserExam.share_with_supervisor value)
exam_supervisor_visible bool (exam-level results_supervisor_visible flag)
manual_shares QuerySet of User objects (shared_with_users)
"""
exam = self.exam
always_visible = []
seen_pks = set()
for attr in ("author", "markers"):
if hasattr(exam, attr):
for u in getattr(exam, attr).all():
if u.pk not in seen_pks:
seen_pks.add(u.pk)
always_visible.append(u)
exam_supervisor_visible = getattr(exam, "results_supervisor_visible", False)
supervisor_user = None
if self.share_with_supervisor or exam_supervisor_visible:
if self.user_user_id:
try:
sup = self.user_user.userprofile.supervisor
if sup and sup.user_id:
supervisor_user = sup.user
except Exception:
pass
elif self.cid_user_id:
sup = self.cid_user.supervisor
if sup and sup.user_id:
supervisor_user = sup.user
return {
"always_visible_to": always_visible,
"supervisor": supervisor_user,
"supervisor_enabled": self.share_with_supervisor,
"exam_supervisor_visible": exam_supervisor_visible,
"manual_shares": self.shared_with_users.all(),
}
def can_user_view(self, user):
"""Check whether a given authenticated User may view this attempt.
Permitted if the user is:
- The attempt owner (user_user)
- An author or marker of the related exam/collection
- The supervisor of the attempt owner (when share_with_supervisor=True or
the exam-level results_supervisor_visible flag is set)
- Manually listed in shared_with_users
"""
if not user or not user.is_authenticated:
return False
if self.user_user_id and self.user_user_id == user.pk:
return True
exam = self.exam
for attr in ("author", "markers"):
if hasattr(exam, attr) and getattr(exam, attr).filter(pk=user.pk).exists():
return True
exam_supervisor_visible = getattr(exam, "results_supervisor_visible", False)
if self.share_with_supervisor or exam_supervisor_visible:
if self.user_user_id:
try:
sup = self.user_user.userprofile.supervisor
if sup and sup.user_id == user.pk:
return True
except Exception:
pass
elif self.cid_user_id:
sup = self.cid_user.supervisor
if sup and sup.user_id == user.pk:
return True
if self.shared_with_users.filter(pk=user.pk).exists():
return True
return False
def __str__(self) -> str:
if self.cid_user is None:
try: