feat: Implement user messages inbox with message acknowledgment tracking and UI updates

This commit is contained in:
Ross
2026-04-27 13:09:19 +01:00
parent dc479cafd0
commit 3db2c1a768
6 changed files with 322 additions and 5 deletions
@@ -94,6 +94,7 @@
</div>
<div class="mt-2 d-flex flex-wrap gap-2 justify-content-md-end">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_history' collection.pk %}">Open History</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:user_messages_inbox' %}">Global Inbox</a>
</div>
{% if user_message_rows %}
@@ -1,7 +1,10 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<h2>{{collection.name}}</h2>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<h2 class="mb-0">{{collection.name}}</h2>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:user_messages_inbox' %}">Global Messages Inbox</a>
</div>
{% if userexams %}
@@ -0,0 +1,116 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h2 class="h4 mb-1">Messages Inbox</h2>
<div class="text-muted small">All accessible conversation threads across collections.</div>
</div>
<div class="card border-secondary" style="min-width: 320px;">
<div class="card-body py-2 px-3">
<div class="small text-muted">Pending acknowledgements</div>
<div class="fw-semibold {% if total_outstanding %}text-danger{% endif %}">
{{ total_outstanding }} unacknowledged message{{ total_outstanding|pluralize }}
</div>
<div class="mt-2">
{% if show_all %}
<a class="btn btn-sm btn-outline-primary" href="?">Show outstanding first</a>
{% else %}
<a class="btn btn-sm btn-primary" href="?show=all">Load full history</a>
{% endif %}
</div>
</div>
</div>
</div>
{% if inbox_rows %}
<div class="d-flex flex-column gap-3">
{% for row in inbox_rows %}
<section class="card {% if row.outstanding_count %}border-danger-subtle{% else %}border-secondary-subtle{% endif %}">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<strong>{{ row.collection.name }}</strong>
<span class="text-muted small ms-2">{{ row.target_label }}</span>
{% if row.viewer_role == 'moderator' %}
<span class="badge bg-dark ms-1">Supervisor</span>
{% else %}
<span class="badge bg-secondary ms-1">Learner</span>
{% endif %}
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
{% if row.outstanding_count %}
<span class="badge bg-danger">{{ row.outstanding_count }} unacknowledged</span>
{% else %}
<span class="badge bg-secondary">No outstanding</span>
{% endif %}
<a class="btn btn-sm btn-outline-secondary" href="{{ row.detail_url }}">Open user message page</a>
</div>
</div>
<div class="card-body">
{% if row.outstanding_rows %}
<div class="mb-3">
<div class="small fw-semibold mb-2">Outstanding</div>
<div class="d-flex flex-column gap-2">
{% for case_row in row.outstanding_rows %}
<details open>
<summary class="small fw-semibold d-flex flex-wrap align-items-center gap-2">
<span>Case {{ case_row.casedetail.sort_order|add:1 }}</span>
{% if row.collection.show_title_post %}
<span class="text-muted">{{ case_row.casedetail.case.title|default:case_row.casedetail.case.pk|truncatechars:90 }}</span>
{% endif %}
<span class="badge bg-danger">{{ case_row.stats.outstanding_total_count }} unacknowledged</span>
</summary>
<div class="mt-2"
hx-get="{{ case_row.thread_url }}{% if case_row.cid %}?cid={{ case_row.cid }}{% endif %}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted">Loading conversation...</div>
</div>
</details>
{% endfor %}
</div>
</div>
{% endif %}
{% if show_all and row.history_rows %}
<div>
<div class="small fw-semibold mb-2">History</div>
<div class="accordion" id="history-{{ row.attempt.id }}">
{% for case_row in row.history_rows %}
<div class="accordion-item">
<h2 class="accordion-header" id="history-heading-{{ row.attempt.id }}-{{ case_row.casedetail.case.id }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#history-collapse-{{ row.attempt.id }}-{{ case_row.casedetail.case.id }}" aria-expanded="false" aria-controls="history-collapse-{{ row.attempt.id }}-{{ case_row.casedetail.case.id }}">
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 %}
<span class="badge bg-secondary ms-2">{{ case_row.stats.message_count }} messages</span>
</button>
</h2>
<div id="history-collapse-{{ row.attempt.id }}-{{ case_row.casedetail.case.id }}" class="accordion-collapse collapse" aria-labelledby="history-heading-{{ row.attempt.id }}-{{ case_row.casedetail.case.id }}" data-bs-parent="#history-{{ row.attempt.id }}">
<div class="accordion-body pt-0">
<div hx-get="{{ case_row.thread_url }}{% if case_row.cid %}?cid={{ case_row.cid }}{% endif %}"
hx-trigger="revealed once"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted">Open section to load conversation history...</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</section>
{% endfor %}
</div>
{% else %}
<div class="card border-secondary">
<div class="card-body text-muted small">No message threads available for your account.</div>
</div>
{% endif %}
{% endblock %}
+1
View File
@@ -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),
+197 -4
View File
@@ -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
+3
View File
@@ -203,6 +203,9 @@
<li id="atlas-link" class="nav-item">
<a class="nav-link active" href="{% url 'atlas:index' %}">Atlas</a>
</li>
<li id="messages-link" class="nav-item">
<a class="nav-link active" href="{% url 'atlas:user_messages_inbox' %}">Messages</a>
</li>
<li class="nav-item dropdown pt-0">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
FRCR