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:
@@ -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 %}
|
{% 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">
|
<div class="mt-2">
|
||||||
<a class="btn btn-sm {% if total_outstanding_feedback %}btn-danger{% else %}btn-outline-secondary{% endif %}"
|
<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 %}
|
{% if total_outstanding_feedback %}
|
||||||
Review Feedback Hub
|
Review Feedback Hub
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -89,12 +89,12 @@
|
|||||||
</a>
|
</a>
|
||||||
{% if collection.in_review_mode or cid_user_exam.completed %}
|
{% if collection.in_review_mode or cid_user_exam.completed %}
|
||||||
<a class="btn btn-sm btn-outline-info"
|
<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 %}
|
{% if self_review %}Add New Self Review{% else %}Add Self Review{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% if collection.case_query_messaging_enabled and review_stats.has_messages %}
|
{% 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 %}"
|
<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
|
View Conversation
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -159,6 +159,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% 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 & 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"><{{ u.email }}></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 %}<{{ sharing_info.supervisor.email }}>{% 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 %}<{{ sharing_info.supervisor.email }}>{% 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"><{{ u.email }}></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">
|
||||||
|
× 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"><{{ u.email }}></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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -752,4 +752,25 @@ urlpatterns = [
|
|||||||
name="linked_cases_overview",
|
name="linked_cases_overview",
|
||||||
),
|
),
|
||||||
path("cases/by_size", views.cases_by_size, name="cases_by_size"),
|
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
@@ -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):
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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")
|
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:
|
def __str__(self) -> str:
|
||||||
if self.cid_user is None:
|
if self.cid_user is None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user