feat: Implement per-case query and messaging functionality with UI updates and tests

This commit is contained in:
Ross
2026-04-27 22:28:45 +01:00
parent 70ea3788ca
commit 02ad8c61e5
11 changed files with 390 additions and 138 deletions
+1 -1
View File
@@ -120,7 +120,7 @@ class CaseSelect(Select):
class MoveSelectedSeriesForm(Form):
destination_case = ModelChoiceField(
queryset=Case.objects.none(),
widget=CaseSelect(attrs={"class": "form-select form-select-sm"}),
widget=HiddenInput(),
label="Move selected series to case",
required=True,
)
@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-04-27 21:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0097_casereviewmessage'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='case_query_messaging_enabled',
field=models.BooleanField(default=True, help_text='Enable per-case query and messaging for this collection.'),
),
]
+4
View File
@@ -1356,6 +1356,10 @@ class CaseCollection(ExamOrCollectionGenericBase):
cid_user_exam = GenericRelation("generic.CidUserExam", related_query_name="atlas_exams")
feedback_once_collection_complete = models.BooleanField(default=True, help_text="If true feedback is only given once the collection is complete. If false feedback is given after each case.")
case_query_messaging_enabled = models.BooleanField(
default=True,
help_text="Enable per-case query and messaging for this collection.",
)
question_time_limit = models.PositiveIntegerField(
blank=True,
+45 -5
View File
@@ -1,4 +1,6 @@
{% load case_widgets %}
{% partialdef case-series %}
{% for series in case.get_ordered_series %}
<span class="series-block" id="series-block-{{ series.pk }}">
@@ -471,13 +473,18 @@
</button>
</div>
<div class="series-move-panel border rounded p-2">
<div class="small fw-semibold mb-2">Move selected series to another case</div>
<details class="series-move-panel border rounded p-2">
<summary class="small fw-semibold">Move selected series to another case</summary>
{% if move_series_form %}
<form id="move-selected-series-form" class="d-flex flex-column gap-2">
<form id="move-selected-series-form" class="d-flex flex-column gap-2 mt-2">
{{ move_series_form.destination_case }}
<div class="small text-muted">Click any case row below to select the destination case.</div>
<div id="selected-move-case" class="small text-muted">No destination case selected.</div>
{% case_search_widget collection=None input_id='move-series-case-search-input' target_id='move-series-case-search-results' %}
<button type="button"
id="move-selected-series-submit"
class="btn btn-sm btn-outline-primary text-start"
disabled
hx-post="{% url 'atlas:move_selected_series_to_case' case.pk %}"
hx-include="[name='series-ids']:checked, #move-selected-series-form [name='destination_case']"
hx-target="#series-action-results"
@@ -487,9 +494,9 @@
</button>
</form>
{% else %}
<div class="text-muted small">Case move options are unavailable in this view.</div>
<div class="text-muted small mt-2">Case move options are unavailable in this view.</div>
{% endif %}
</div>
</details>
</div>
<div class="alert alert-info mt-2" id="series-action-alert" style="display:none;">
<span id="series-action-results" ></span>
@@ -1024,6 +1031,39 @@
detailsElement.addEventListener("toggle", toggleCheckboxes);
}
const moveSeriesForm = document.getElementById("move-selected-series-form");
if (moveSeriesForm) {
const hiddenInput = moveSeriesForm.querySelector("[name='destination_case']");
const moveSubmitButton = document.getElementById("move-selected-series-submit");
function syncMoveSeriesSubmitState() {
if (!moveSubmitButton || !hiddenInput) {
return;
}
moveSubmitButton.disabled = !hiddenInput.value;
}
moveSeriesForm.addEventListener("case:selected", function(event) {
const selectionLabel = document.getElementById("selected-move-case");
if (!hiddenInput || !event.detail) {
return;
}
const casePk = event.detail.casePk;
const caseTitle = event.detail.caseTitle || ("Case #" + casePk);
hiddenInput.value = casePk;
if (selectionLabel) {
selectionLabel.innerHTML = "Selected destination: <strong>" + caseTitle + " (" + casePk + ")</strong>";
selectionLabel.classList.remove("text-muted");
}
syncMoveSeriesSubmitState();
});
syncMoveSeriesSubmitState();
}
const modal = new bootstrap.Modal(document.getElementById("findingModal"));
const modalBody = document.getElementById("findingModalBody");
@@ -289,7 +289,7 @@
</div>
</form>
{% if question_completed %}
{% if question_completed and collection.case_query_messaging_enabled %}
<details class="mt-3" {% if case_review_stats.has_outstanding_any %}open{% endif %}>
<summary class="small fw-semibold d-flex flex-wrap align-items-center gap-2">
<span>Case conversation</span>
@@ -88,6 +88,7 @@
<div class="card border-secondary w-100 mt-3">
<div class="card-body py-2 px-3 text-start text-md-end">
{% if collection.case_query_messaging_enabled %}
<div class="small text-muted">Outstanding messages</div>
<div class="fw-semibold {% if total_outstanding_messages %}text-danger{% endif %}">
{{ total_outstanding_messages }} pending acknowledgement
@@ -128,6 +129,13 @@
{% endfor %}
</div>
{% endif %}
{% else %}
<div class="small text-muted">Case query/messaging</div>
<div class="fw-semibold text-muted">Disabled for this collection</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>
</div>
{% endif %}
</div>
</div>
</div>
@@ -8,7 +8,9 @@
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_detail' collection.pk %}">Collection Detail</a>
{% if collection.case_query_messaging_enabled %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:user_messages_inbox' %}">Global Messages Inbox</a>
{% endif %}
</div>
</div>
@@ -40,8 +42,13 @@
<div class="col-md-3 col-sm-6">
<div class="card border-secondary h-100 {% if total_outstanding_messages %}border-danger-subtle{% endif %}">
<div class="card-body py-3">
{% if collection.case_query_messaging_enabled %}
<div class="small text-muted">Outstanding Messages</div>
<div class="h4 mb-0 {% if total_outstanding_messages %}text-danger{% endif %}">{{ total_outstanding_messages }}</div>
{% else %}
<div class="small text-muted">Case Messaging</div>
<div class="h6 mb-0 text-muted">Disabled</div>
{% endif %}
</div>
</div>
</div>
@@ -71,6 +78,7 @@
<div class="small text-muted mt-1">Started: {{ userexam.start_time|default:"-" }}</div>
<div class="small text-muted">Ended: {{ userexam.end_time|default:"-" }}</div>
<div class="mt-2 d-flex flex-wrap gap-2">
{% if collection.case_query_messaging_enabled %}
{% if userexam.outstanding_feedback_count %}
<span class="badge bg-danger">{{ userexam.outstanding_feedback_count }} outstanding message{{ userexam.outstanding_feedback_count|pluralize }}</span>
{% endif %}
@@ -79,11 +87,14 @@
{% else %}
<span class="badge bg-dark-subtle text-body-secondary">No conversation yet</span>
{% endif %}
{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary" href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">Open Attempt</a>
{% if collection.case_query_messaging_enabled %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_user_messages' collection.pk userexam.user_user.pk %}">Messages</a>
{% endif %}
<button
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk userexam.user_user.pk %}"
hx-target="#user-history-{{ userexam.user_user.pk }}"
@@ -124,6 +135,7 @@
<div class="small text-muted mt-1">Started: {{ cidexam.start_time|default:"-" }}</div>
<div class="small text-muted">Ended: {{ cidexam.end_time|default:"-" }}</div>
<div class="mt-2 d-flex flex-wrap gap-2">
{% if collection.case_query_messaging_enabled %}
{% if cidexam.outstanding_feedback_count %}
<span class="badge bg-danger">{{ cidexam.outstanding_feedback_count }} outstanding message{{ cidexam.outstanding_feedback_count|pluralize }}</span>
{% endif %}
@@ -132,11 +144,14 @@
{% else %}
<span class="badge bg-dark-subtle text-body-secondary">No conversation yet</span>
{% endif %}
{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary" href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">Open Attempt</a>
{% if collection.case_query_messaging_enabled %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_cid_messages' collection.pk cidexam.cid_user.cid %}">Messages</a>
{% endif %}
<button
hx-post="{% url 'atlas:collection_reset_answers_ciduser' collection.pk cidexam.cid_user.cid %}"
hx-target="#cid-history-{{ cidexam.cid_user.cid }}"
@@ -131,6 +131,7 @@
</details>
{% endif %}
{% if collection.case_query_messaging_enabled %}
<details class="mt-3" {% if row.case_review_stats.has_outstanding_any %}open{% endif %}>
<summary class="small fw-semibold d-flex flex-wrap align-items-center gap-2">
<span>Case conversation</span>
@@ -151,6 +152,7 @@
<div class="small text-muted">Loading case conversation...</div>
</div>
</details>
{% endif %}
</div>
{% endfor %}
</div>
@@ -8,7 +8,7 @@
{% if collection.in_review_mode or cid_user_exam.completed %}
<span class="badge bg-light text-dark ms-2">Review</span>
{% endif %}
{% if total_outstanding_feedback %}
{% if collection.case_query_messaging_enabled and total_outstanding_feedback %}
<span class="badge bg-danger ms-2">{{ total_outstanding_feedback }} outstanding feedback</span>
{% endif %}
</h2>
@@ -35,7 +35,7 @@
{% if cid_user_exam.completed %}
<div class="mt-1 small">End time: {{ cid_user_exam.end_time|default:"-" }}</div>
{% endif %}
{% if collection.in_review_mode or cid_user_exam.completed %}
{% if collection.case_query_messaging_enabled and (collection.in_review_mode or 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 %}">
@@ -68,11 +68,13 @@
{% if self_review %}
<span class="badge bg-info-subtle text-info-emphasis ms-1">Self review added</span>
{% endif %}
{% if collection.case_query_messaging_enabled %}
{% if review_stats.has_outstanding_feedback %}
<span class="badge bg-danger ms-1">{{ review_stats.outstanding_feedback_count }} feedback item{{ review_stats.outstanding_feedback_count|pluralize }} to review</span>
{% elif review_stats.has_messages %}
<span class="badge bg-secondary ms-1">{{ review_stats.message_count }} conversation message{{ review_stats.message_count|pluralize }}</span>
{% endif %}
{% endif %}
</div>
{% if collection.show_title_post %}
<div class="small text-muted">
@@ -90,7 +92,7 @@
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 review_stats.has_messages %}
{% 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 }}">
View Conversation
@@ -0,0 +1,118 @@
import pytest
from django.contrib.auth import get_user_model
from django.urls import reverse
from atlas.models import CaseCollection, CaseDetail, CaseReviewMessage
@pytest.mark.django_db
def test_collection_case_query_messaging_enabled_default_true():
collection = CaseCollection.objects.create(name="Default messaging collection")
assert collection.case_query_messaging_enabled is True
@pytest.mark.django_db
def test_collection_case_review_thread_returns_404_when_messaging_disabled(create_case, client):
user_model = get_user_model()
author = user_model.objects.create_user(username="author-thread", password="testpass")
collection = CaseCollection.objects.create(
name="Thread disabled",
case_query_messaging_enabled=False,
)
collection.author.add(author)
case = create_case
CaseDetail.objects.create(collection=collection, case=case, sort_order=0)
attempt = collection.get_or_create_cid_user_exam(user_user=author)
client.force_login(author)
url = reverse(
"atlas:collection_case_review_thread",
kwargs={
"exam_id": collection.pk,
"cid_user_exam_id": attempt.pk,
"case_id": case.pk,
},
)
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_collection_user_messages_returns_404_when_messaging_disabled(create_case, client):
user_model = get_user_model()
author = user_model.objects.create_user(username="author-user-messages", password="testpass")
learner = user_model.objects.create_user(username="learner-user-messages", password="testpass")
collection = CaseCollection.objects.create(
name="User messages disabled",
case_query_messaging_enabled=False,
)
collection.author.add(author)
case = create_case
CaseDetail.objects.create(collection=collection, case=case, sort_order=0)
collection.get_or_create_cid_user_exam(user_user=learner)
client.force_login(author)
url = reverse(
"atlas:collection_user_messages",
kwargs={"exam_id": collection.pk, "user_pk": learner.pk},
)
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_user_messages_inbox_ignores_disabled_collection_messages(create_case, client):
user_model = get_user_model()
moderator = user_model.objects.create_user(username="moderator", password="testpass")
learner = user_model.objects.create_user(username="learner", password="testpass")
enabled_collection = CaseCollection.objects.create(
name="Enabled messaging collection",
case_query_messaging_enabled=True,
)
disabled_collection = CaseCollection.objects.create(
name="Disabled messaging collection",
case_query_messaging_enabled=False,
)
enabled_collection.author.add(moderator)
disabled_collection.author.add(moderator)
case = create_case
CaseDetail.objects.create(collection=enabled_collection, case=case, sort_order=0)
CaseDetail.objects.create(collection=disabled_collection, case=case, sort_order=0)
enabled_attempt = enabled_collection.get_or_create_cid_user_exam(user_user=learner)
disabled_attempt = disabled_collection.get_or_create_cid_user_exam(user_user=learner)
CaseReviewMessage.objects.create(
cid_user_exam=enabled_attempt,
case=case,
author=learner,
message_type=CaseReviewMessage.MessageType.QUERY,
message="Enabled message",
requires_acknowledgement=True,
)
CaseReviewMessage.objects.create(
cid_user_exam=disabled_attempt,
case=case,
author=learner,
message_type=CaseReviewMessage.MessageType.QUERY,
message="Disabled message",
requires_acknowledgement=True,
)
client.force_login(moderator)
response = client.get(reverse("atlas:user_messages_inbox"))
assert response.status_code == 200
assert "Enabled messaging collection" in response.content.decode()
assert "Disabled messaging collection" not in response.content.decode()
+47 -2
View File
@@ -4024,6 +4024,7 @@ def collection_viva_series(request, pk, series_id):
def collection_detail(request, pk):
collection = get_object_or_404(CaseCollection, pk=pk)
collection_content_type = ContentType.objects.get_for_model(collection)
messaging_enabled = _collection_case_query_messaging_enabled(collection)
# cases = collection.cases.all().order_by("casedetail__sort_order")
casedetails = (
@@ -4034,6 +4035,7 @@ def collection_detail(request, pk):
.order_by("sort_order")
)
if messaging_enabled:
outstanding_messages = CaseReviewMessage.objects.filter(
cid_user_exam__content_type=collection_content_type,
cid_user_exam__object_id=collection.pk,
@@ -4077,6 +4079,10 @@ def collection_detail(request, pk):
.filter(Q(outstanding_message_count__gt=0) | Q(total_review_message_count__gt=0))
.order_by("-outstanding_message_count", "-total_review_message_count", "-start_time")
)
else:
total_outstanding_messages = 0
user_message_rows = []
cid_message_rows = []
return render(
request,
@@ -4100,6 +4106,7 @@ def collection_history(request, exam_id: int):
casedetails = list(CaseDetail.objects.filter(collection=collection).order_by("sort_order"))
if _collection_case_query_messaging_enabled(collection):
userexams = list(
collection.get_user_exams()
.select_related("user_user")
@@ -4132,6 +4139,15 @@ def collection_history(request, exam_id: int):
)
.order_by("-outstanding_feedback_count", "completed", "-start_time")
)
else:
userexams = list(collection.get_user_exams().select_related("user_user").order_by("completed", "-start_time"))
cidexams = list(collection.get_cid_exams().select_related("cid_user").order_by("completed", "-start_time"))
for userexam in userexams:
userexam.outstanding_feedback_count = 0
userexam.total_review_message_count = 0
for cidexam in cidexams:
cidexam.outstanding_feedback_count = 0
cidexam.total_review_message_count = 0
total_outstanding_messages = sum(item.outstanding_feedback_count for item in userexams) + sum(
item.outstanding_feedback_count for item in cidexams
@@ -4292,6 +4308,7 @@ def collection_history_user(request, exam_id: int, user_pk: int):
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),
)
for casedetail in casedetails:
user_answer = casedetail.get_user_answers(user)
@@ -4348,6 +4365,7 @@ def collection_history_ciduser(request, exam_id: int, cid: int):
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),
)
for casedetail in casedetails:
user_answer = casedetail.get_cid_answers(cid)
@@ -4396,6 +4414,7 @@ def collection_user_messages(request, exam_id: int, user_pk: int):
"""
collection = get_object_or_404(CaseCollection, pk=exam_id)
_enforce_collection_case_query_messaging_enabled(collection)
user = get_object_or_404(User, pk=user_pk)
cid_user_exam = collection.get_or_create_cid_user_exam(user_user=user)
@@ -4436,6 +4455,7 @@ def collection_cid_messages(request, exam_id: int, cid: int):
"""Supervisor page showing case conversations for one CID attempt."""
collection = get_object_or_404(CaseCollection, pk=exam_id)
_enforce_collection_case_query_messaging_enabled(collection)
cid_user_exam = collection.get_or_create_cid_user_exam(cid=cid)
casedetails = list(
@@ -4517,10 +4537,31 @@ def user_messages_inbox(request):
)
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)
enabled_collection_ids = {
collection_id
for collection_id, collection in collections_by_id.items()
if collection is not None and _collection_case_query_messaging_enabled(collection)
}
attempts = [attempt for attempt in attempts if attempt.object_id in enabled_collection_ids]
if not attempts:
return render(
request,
"atlas/user_messages_inbox.html",
{
"inbox_rows": [],
"total_outstanding": 0,
"total_needs_your_ack": 0,
"total_waiting_on_other": 0,
"show_all": request.GET.get("show") == "all",
},
)
attempt_ids = [attempt.pk for attempt in attempts]
collection_ids = sorted(enabled_collection_ids)
casedetails_by_collection = defaultdict(list)
all_case_ids = set()
@@ -4575,7 +4616,7 @@ def user_messages_inbox(request):
attempt = item["attempt"]
viewer_role = item["viewer_role"]
collection = collections_by_id.get(attempt.object_id)
if collection is None:
if collection is None or not _collection_case_query_messaging_enabled(collection):
continue
casedetails = casedetails_by_collection.get(collection.pk, [])
@@ -4822,6 +4863,7 @@ def collection_case_review_thread(request, exam_id: int, cid_user_exam_id: int,
"""
collection = get_object_or_404(CaseCollection, pk=exam_id)
_enforce_collection_case_query_messaging_enabled(collection)
collection_content_type = ContentType.objects.get_for_model(collection)
cid_user_exam = get_object_or_404(
CidUserExam,
@@ -5517,6 +5559,7 @@ def collection_feedback_overview(
):
"""Show all case conversations for one learner attempt with outstanding items first."""
collection = get_object_or_404(CaseCollection, pk=pk)
_enforce_collection_case_query_messaging_enabled(collection)
try:
collection.check_user_can_take(cid, passcode, request.user)
@@ -5620,6 +5663,7 @@ def collection_take_overview(
case_review_stats, outstanding_case_count, total_outstanding_feedback = _get_case_review_stats_for_exam(
cid_user_exam=cid_user_exam,
casedetails=casedetails,
messaging_enabled=_collection_case_query_messaging_enabled(collection),
)
question_answer_tuples = []
@@ -5940,6 +5984,7 @@ def collection_case_view_take(
case_review_stats, _, _ = _get_case_review_stats_for_exam(
cid_user_exam=cid_user_exam,
casedetails=[casedetail],
messaging_enabled=_collection_case_query_messaging_enabled(collection),
)
return render(
request,