diff --git a/atlas/templates/atlas/collection_detail.html b/atlas/templates/atlas/collection_detail.html index 98e2fcdf..56a63aca 100644 --- a/atlas/templates/atlas/collection_detail.html +++ b/atlas/templates/atlas/collection_detail.html @@ -94,6 +94,7 @@
Open History + Global Inbox
{% if user_message_rows %} diff --git a/atlas/templates/atlas/collection_history.html b/atlas/templates/atlas/collection_history.html index fa8959f5..a47ff6e2 100644 --- a/atlas/templates/atlas/collection_history.html +++ b/atlas/templates/atlas/collection_history.html @@ -1,7 +1,10 @@ {% extends 'atlas/exams.html' %} {% block content %} -

{{collection.name}}

+
+

{{collection.name}}

+ Global Messages Inbox +
{% if userexams %} diff --git a/atlas/templates/atlas/user_messages_inbox.html b/atlas/templates/atlas/user_messages_inbox.html new file mode 100644 index 00000000..1abae7f6 --- /dev/null +++ b/atlas/templates/atlas/user_messages_inbox.html @@ -0,0 +1,116 @@ +{% extends 'atlas/exams.html' %} + +{% block content %} +
+
+

Messages Inbox

+
All accessible conversation threads across collections.
+
+
+
+
Pending acknowledgements
+
+ {{ total_outstanding }} unacknowledged message{{ total_outstanding|pluralize }} +
+
+ {% if show_all %} + Show outstanding first + {% else %} + Load full history + {% endif %} +
+
+
+
+ + {% if inbox_rows %} +
+ {% for row in inbox_rows %} +
+
+
+ {{ row.collection.name }} + {{ row.target_label }} + {% if row.viewer_role == 'moderator' %} + Supervisor + {% else %} + Learner + {% endif %} +
+
+ {% if row.outstanding_count %} + {{ row.outstanding_count }} unacknowledged + {% else %} + No outstanding + {% endif %} + Open user message page +
+
+ +
+ {% if row.outstanding_rows %} +
+
Outstanding
+
+ {% for case_row in row.outstanding_rows %} +
+ + Case {{ case_row.casedetail.sort_order|add:1 }} + {% if row.collection.show_title_post %} + {{ case_row.casedetail.case.title|default:case_row.casedetail.case.pk|truncatechars:90 }} + {% endif %} + {{ case_row.stats.outstanding_total_count }} unacknowledged + +
+
Loading conversation...
+
+
+ {% endfor %} +
+
+ {% endif %} + + {% if show_all and row.history_rows %} +
+
History
+
+ {% for case_row in row.history_rows %} +
+

+ +

+
+
+
+
Open section to load conversation history...
+
+
+
+
+ {% endfor %} +
+
+ {% endif %} +
+
+ {% endfor %} +
+ {% else %} +
+
No message threads available for your account.
+
+ {% endif %} +{% endblock %} diff --git a/atlas/urls.py b/atlas/urls.py index da2da928..2ddf12e2 100755 --- a/atlas/urls.py +++ b/atlas/urls.py @@ -10,6 +10,7 @@ app_name = "atlas" urlpatterns = [ path("", views.index, name="index"), path("help/", TemplateView.as_view(template_name="atlas/help.html"), name="help"), + path("messages", views.user_messages_inbox, name="user_messages_inbox"), path( "create/", RedirectView.as_view(pattern_name="atlas:case_create", permanent=False), diff --git a/atlas/views.py b/atlas/views.py index 5f06c54e..70e7b625 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -4161,6 +4161,13 @@ def _get_case_review_stats_for_exam( .values("case_id") .annotate( message_count=Count("id"), + outstanding_total_count=Count( + "id", + filter=Q( + requires_acknowledgement=True, + acknowledged_at__isnull=True, + ), + ), outstanding_feedback_count=Count( "id", filter=Q( @@ -4183,21 +4190,22 @@ def _get_case_review_stats_for_exam( outstanding_case_count = 0 total_outstanding_feedback = 0 for row in message_rows: + outstanding_total_count = row["outstanding_total_count"] outstanding_feedback_count = row["outstanding_feedback_count"] outstanding_query_count = row["outstanding_query_count"] stats_by_case[row["case_id"]] = { "message_count": row["message_count"], "outstanding_feedback_count": outstanding_feedback_count, "outstanding_query_count": outstanding_query_count, - "outstanding_total_count": outstanding_feedback_count + outstanding_query_count, + "outstanding_total_count": outstanding_total_count, "has_messages": row["message_count"] > 0, "has_outstanding_feedback": outstanding_feedback_count > 0, "has_outstanding_query": outstanding_query_count > 0, - "has_outstanding_any": (outstanding_feedback_count + outstanding_query_count) > 0, + "has_outstanding_any": outstanding_total_count > 0, } - if outstanding_feedback_count: + if outstanding_total_count: outstanding_case_count += 1 - total_outstanding_feedback += outstanding_feedback_count + total_outstanding_feedback += outstanding_total_count return stats_by_case, outstanding_case_count, total_outstanding_feedback @@ -4436,6 +4444,191 @@ def collection_cid_messages(request, exam_id: int, cid: int): ) +@login_required +def user_messages_inbox(request): + """Generic message inbox for authenticated users. + + Shows outstanding acknowledgement items and allows loading full message + history across all accessible collection attempts. + """ + + user = request.user + collection_content_type = ContentType.objects.get_for_model(CaseCollection) + + is_global_moderator = user.is_superuser or user.groups.filter(name="atlas_editor").exists() + + base_attempts = CidUserExam.objects.filter(content_type=collection_content_type).select_related( + "user_user", "cid_user" + ) + learner_attempts = list(base_attempts.filter(user_user=user)) + + moderator_attempts = [] + if is_global_moderator: + moderator_attempts = list(base_attempts) + else: + authored_ids = list(CaseCollection.objects.filter(author=user).values_list("pk", flat=True)) + if authored_ids: + moderator_attempts = list(base_attempts.filter(object_id__in=authored_ids)) + + attempts_map = {} + for attempt in learner_attempts: + attempts_map[attempt.pk] = {"attempt": attempt, "viewer_role": "learner"} + for attempt in moderator_attempts: + attempts_map[attempt.pk] = {"attempt": attempt, "viewer_role": "moderator"} + + if not attempts_map: + return render( + request, + "atlas/user_messages_inbox.html", + { + "inbox_rows": [], + "total_outstanding": 0, + "show_all": request.GET.get("show") == "all", + }, + ) + + attempts = [item["attempt"] for item in attempts_map.values()] + attempt_ids = [attempt.pk for attempt in attempts] + collection_ids = sorted({attempt.object_id for attempt in attempts}) + + collections_by_id = CaseCollection.objects.in_bulk(collection_ids) + + casedetails_by_collection = defaultdict(list) + all_case_ids = set() + for casedetail in ( + CaseDetail.objects.filter(collection_id__in=collection_ids) + .select_related("case") + .order_by("collection_id", "sort_order") + ): + casedetails_by_collection[casedetail.collection_id].append(casedetail) + all_case_ids.add(casedetail.case_id) + + message_stats_by_attempt_case = defaultdict(dict) + if all_case_ids: + for row in ( + CaseReviewMessage.objects.filter( + cid_user_exam_id__in=attempt_ids, + case_id__in=all_case_ids, + ) + .values("cid_user_exam_id", "case_id") + .annotate( + message_count=Count("id"), + outstanding_total_count=Count( + "id", + filter=Q( + requires_acknowledgement=True, + acknowledged_at__isnull=True, + ), + ), + ) + ): + outstanding_total_count = row["outstanding_total_count"] + message_stats_by_attempt_case[row["cid_user_exam_id"]][row["case_id"]] = { + "message_count": row["message_count"], + "outstanding_total_count": outstanding_total_count, + "has_messages": row["message_count"] > 0, + "has_outstanding_any": outstanding_total_count > 0, + } + + inbox_rows = [] + for item in attempts_map.values(): + attempt = item["attempt"] + viewer_role = item["viewer_role"] + collection = collections_by_id.get(attempt.object_id) + if collection is None: + continue + + casedetails = casedetails_by_collection.get(collection.pk, []) + if not casedetails: + continue + + attempt_stats = message_stats_by_attempt_case.get(attempt.pk, {}) + feedback_rows = [] + for casedetail in casedetails: + stats = attempt_stats.get( + casedetail.case_id, + { + "message_count": 0, + "outstanding_total_count": 0, + "has_messages": False, + "has_outstanding_any": False, + }, + ) + feedback_rows.append( + { + "casedetail": casedetail, + "stats": stats, + "thread_url": reverse( + "atlas:collection_case_review_thread", + kwargs={ + "exam_id": collection.pk, + "cid_user_exam_id": attempt.pk, + "case_id": casedetail.case_id, + }, + ), + "cid": attempt.cid_user.cid if attempt.cid_user_id else None, + } + ) + + outstanding_rows = [row for row in feedback_rows if row["stats"].get("has_outstanding_any")] + history_rows = [ + row + for row in feedback_rows + if row["stats"].get("has_messages") and not row["stats"].get("has_outstanding_any") + ] + + if not outstanding_rows and not history_rows: + continue + + target_label = attempt.get_user_name() + if attempt.user_user_id: + detail_url = reverse( + "atlas:collection_user_messages", + kwargs={"exam_id": collection.pk, "user_pk": attempt.user_user_id}, + ) + else: + detail_url = reverse( + "atlas:collection_cid_messages", + kwargs={"exam_id": collection.pk, "cid": attempt.cid_user.cid}, + ) + + outstanding_count = sum(row["stats"].get("outstanding_total_count", 0) for row in feedback_rows) + inbox_rows.append( + { + "collection": collection, + "attempt": attempt, + "target_label": target_label, + "viewer_role": viewer_role, + "detail_url": detail_url, + "outstanding_rows": outstanding_rows, + "history_rows": history_rows, + "outstanding_count": outstanding_count, + } + ) + + inbox_rows.sort( + key=lambda row: ( + row["outstanding_count"] > 0, + row["outstanding_count"], + len(row["history_rows"]), + ), + reverse=True, + ) + + total_outstanding = sum(row["outstanding_count"] for row in inbox_rows) + show_all = request.GET.get("show") == "all" + + return render( + request, + "atlas/user_messages_inbox.html", + { + "inbox_rows": inbox_rows, + "total_outstanding": total_outstanding, + "show_all": show_all, + }, + ) + + def _user_can_moderate_collection(collection: CaseCollection, user: User) -> bool: if not getattr(user, "is_authenticated", False): return False diff --git a/templates/base.html b/templates/base.html index 2b0f0459..e6204479 100644 --- a/templates/base.html +++ b/templates/base.html @@ -203,6 +203,9 @@ +