feat: Implement user messages inbox with message acknowledgment tracking and UI updates
This commit is contained in:
@@ -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 %}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user