From 7062723433ac845b53a22d50d3fbd8adc6ea6fcf Mon Sep 17 00:00:00 2001 From: Ross Date: Tue, 28 Apr 2026 22:09:06 +0100 Subject: [PATCH] feat: Implement sharing functionality for CidUserExam attempts, including user management and sharing settings Co-authored-by: Copilot --- .../atlas/collection_take_overview.html | 15 +- .../atlas/partials/_sharing_panel.html | 120 +++++++++ .../_sharing_user_search_results.html | 27 ++ atlas/templates/atlas/shared_with_me.html | 71 ++++++ atlas/urls.py | 21 ++ atlas/views.py | 237 ++++++++++++++++++ .../0032_ciduserexam_shared_with_users.py | 23 ++ generic/models.py | 86 +++++++ 8 files changed, 597 insertions(+), 3 deletions(-) create mode 100644 atlas/templates/atlas/partials/_sharing_panel.html create mode 100644 atlas/templates/atlas/partials/_sharing_user_search_results.html create mode 100644 atlas/templates/atlas/shared_with_me.html create mode 100644 generic/migrations/0032_ciduserexam_shared_with_users.py diff --git a/atlas/templates/atlas/collection_take_overview.html b/atlas/templates/atlas/collection_take_overview.html index 5095bc48..8db246c8 100644 --- a/atlas/templates/atlas/collection_take_overview.html +++ b/atlas/templates/atlas/collection_take_overview.html @@ -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 %} {% endif %} + {# Sharing panel – only for registered-user attempts (not CID candidates) #} + {% if cid_user_exam.user_user_id %} +
+
Loading sharing settings…
+
+ {% endif %} + {% endblock %} diff --git a/atlas/templates/atlas/partials/_sharing_panel.html b/atlas/templates/atlas/partials/_sharing_panel.html new file mode 100644 index 00000000..080ef1af --- /dev/null +++ b/atlas/templates/atlas/partials/_sharing_panel.html @@ -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 +#} +
+
+ Result Sharing + Shared with me +
+
+ + {# Always-visible users #} +
+
Always shared with (authors & markers)
+ {% if sharing_info.always_visible_to %} +
    + {% for u in sharing_info.always_visible_to %} +
  • + Author/Marker + {{ u.get_full_name|default:u.username }} + {% if u.email %}<{{ u.email }}>{% endif %} +
  • + {% endfor %} +
+ {% else %} + No authors or markers assigned. + {% endif %} +
+ + {# Supervisor sharing #} + {% if not sharing_info.exam_supervisor_visible %} +
+
Share with supervisor
+ {% if is_owner %} + + {% else %} + + {% endif %} + {% if sharing_info.supervisor %} +
+ Supervisor: + {{ sharing_info.supervisor.get_full_name|default:sharing_info.supervisor.username }} + {% if sharing_info.supervisor.email %}<{{ sharing_info.supervisor.email }}>{% endif %} +
+ {% elif sharing_info.supervisor_enabled %} +
No supervisor account linked to your profile — results cannot be shared automatically.
+ {% endif %} +
+ {% else %} +
+
Supervisor
+ Sharing always enabled (exam setting) + {% if sharing_info.supervisor %} +
+ Supervisor: + {{ sharing_info.supervisor.get_full_name|default:sharing_info.supervisor.username }} + {% if sharing_info.supervisor.email %}<{{ sharing_info.supervisor.email }}>{% endif %} +
+ {% endif %} +
+ {% endif %} + + {# Manual shares — only for registered-user attempts #} + {% if cid_user_exam.user_user_id %} +
+
Also shared with
+ {% if sharing_info.manual_shares %} +
    + {% for u in sharing_info.manual_shares %} +
  • + {{ u.get_full_name|default:u.username }} + {% if u.email %}<{{ u.email }}>{% endif %} + + {% if is_owner %} + + {% endif %} +
  • + {% endfor %} +
+ {% else %} +
No additional users.
+ {% endif %} + + {% if is_owner %} + {# User search input #} +
+ +
+
+ {% endif %} +
+ {% endif %} + +
+
diff --git a/atlas/templates/atlas/partials/_sharing_user_search_results.html b/atlas/templates/atlas/partials/_sharing_user_search_results.html new file mode 100644 index 00000000..3c62f28d --- /dev/null +++ b/atlas/templates/atlas/partials/_sharing_user_search_results.html @@ -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 %} + +{% elif q|length >= 2 %} +
No matching users found.
+{% endif %} diff --git a/atlas/templates/atlas/shared_with_me.html b/atlas/templates/atlas/shared_with_me.html new file mode 100644 index 00000000..f9c7fec7 --- /dev/null +++ b/atlas/templates/atlas/shared_with_me.html @@ -0,0 +1,71 @@ +{% extends 'atlas/exams.html' %} +{% load static %} + +{% block content %} +
+

Shared with me

+

+ Collection attempts shared with you as an author, marker, supervisor, or by direct invitation. +

+
+ +{% if attempt_list %} +
+ + + + + + + + + + + + {% for attempt, collection in attempt_list %} + + + + + + + + {% endfor %} + +
CollectionLearnerStartedStatus
+ {% if collection %} + {{ collection.name }} + {% else %} + Unknown collection + {% endif %} + + {% if attempt.user_user %} + {{ attempt.user_user.get_full_name|default:attempt.user_user.username }} + {% if attempt.user_user.email %} +
{{ attempt.user_user.email }} + {% endif %} + {% elif attempt.cid_user %} + CID {{ attempt.cid_user.cid }} + {% if attempt.cid_user.name %}
{{ attempt.cid_user.name }}{% endif %} + {% else %} + Unknown + {% endif %} +
{{ attempt.start_time|default:"-" }} + {% if attempt.completed %} + Completed + {% else %} + In progress + {% endif %} + + + View attempt + +
+
+{% else %} +
+ No collection attempts have been shared with you yet. +
+{% endif %} +{% endblock %} diff --git a/atlas/urls.py b/atlas/urls.py index 2fdf7ad0..b0b39fc8 100755 --- a/atlas/urls.py +++ b/atlas/urls.py @@ -752,4 +752,25 @@ urlpatterns = [ name="linked_cases_overview", ), path("cases/by_size", views.cases_by_size, name="cases_by_size"), + # Sharing + path( + "exam//sharing", + views.collection_exam_sharing_panel, + name="collection_exam_sharing_panel", + ), + path( + "exam//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//attempt-overview", + views.collection_shared_attempt_overview, + name="collection_shared_attempt_overview", + ), ] diff --git a/atlas/views.py b/atlas/views.py index 484b545a..1c613a16 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -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) diff --git a/generic/migrations/0032_ciduserexam_shared_with_users.py b/generic/migrations/0032_ciduserexam_shared_with_users.py new file mode 100644 index 00000000..fbf0d4aa --- /dev/null +++ b/generic/migrations/0032_ciduserexam_shared_with_users.py @@ -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, + ), + ), + ] diff --git a/generic/models.py b/generic/models.py index 80e579f5..a51de4ac 100644 --- a/generic/models.py +++ b/generic/models.py @@ -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: