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 %}
|
||||
<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 & 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",
|
||||
),
|
||||
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):
|
||||
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")
|
||||
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user