diff --git a/atlas/forms.py b/atlas/forms.py index 32b03426..421b9d5d 100755 --- a/atlas/forms.py +++ b/atlas/forms.py @@ -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, ) diff --git a/atlas/migrations/0098_casecollection_case_query_messaging_enabled.py b/atlas/migrations/0098_casecollection_case_query_messaging_enabled.py new file mode 100644 index 00000000..53b8147e --- /dev/null +++ b/atlas/migrations/0098_casecollection_case_query_messaging_enabled.py @@ -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.'), + ), + ] diff --git a/atlas/models.py b/atlas/models.py index c77f4113..2d4dd0d6 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -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, diff --git a/atlas/templates/atlas/case_display_block.html b/atlas/templates/atlas/case_display_block.html index 4d6de113..782e7d7f 100755 --- a/atlas/templates/atlas/case_display_block.html +++ b/atlas/templates/atlas/case_display_block.html @@ -1,4 +1,6 @@ +{% load case_widgets %} + {% partialdef case-series %} {% for series in case.get_ordered_series %} @@ -471,13 +473,18 @@ -
-
Move selected series to another case
+
+ Move selected series to another case {% if move_series_form %} -
+ {{ move_series_form.destination_case }} +
Click any case row below to select the destination case.
+
No destination case selected.
+ {% case_search_widget collection=None input_id='move-series-case-search-input' target_id='move-series-case-search-results' %}
+ - {% if question_completed %} + {% if question_completed and collection.case_query_messaging_enabled %}
Case conversation diff --git a/atlas/templates/atlas/collection_detail.html b/atlas/templates/atlas/collection_detail.html index 56a63aca..d158313a 100644 --- a/atlas/templates/atlas/collection_detail.html +++ b/atlas/templates/atlas/collection_detail.html @@ -88,44 +88,52 @@
-
Outstanding messages
-
- {{ total_outstanding_messages }} pending acknowledgement -
- - - {% if user_message_rows %} -
Users
-
- {% for row in user_message_rows|slice:":5" %} - - {{ row.get_user_name }} - {% if row.outstanding_message_count %} - ({{ row.outstanding_message_count }} outstanding) - {% else %} - ({{ row.total_review_message_count }} history) - {% endif %} - - {% endfor %} + {% if collection.case_query_messaging_enabled %} +
Outstanding messages
+
+ {{ total_outstanding_messages }} pending acknowledgement +
+ - {% endif %} - {% if cid_message_rows %} -
CID users
- diff --git a/atlas/templates/atlas/collection_history.html b/atlas/templates/atlas/collection_history.html index f3fa9afa..750ce565 100644 --- a/atlas/templates/atlas/collection_history.html +++ b/atlas/templates/atlas/collection_history.html @@ -8,7 +8,9 @@
Collection Detail - Global Messages Inbox + {% if collection.case_query_messaging_enabled %} + Global Messages Inbox + {% endif %}
@@ -40,8 +42,13 @@
-
Outstanding Messages
-
{{ total_outstanding_messages }}
+ {% if collection.case_query_messaging_enabled %} +
Outstanding Messages
+
{{ total_outstanding_messages }}
+ {% else %} +
Case Messaging
+
Disabled
+ {% endif %}
@@ -71,19 +78,23 @@
Started: {{ userexam.start_time|default:"-" }}
Ended: {{ userexam.end_time|default:"-" }}
- {% if userexam.outstanding_feedback_count %} - {{ userexam.outstanding_feedback_count }} outstanding message{{ userexam.outstanding_feedback_count|pluralize }} - {% endif %} - {% if userexam.total_review_message_count %} - {{ userexam.total_review_message_count }} conversation message{{ userexam.total_review_message_count|pluralize }} - {% else %} - No conversation yet + {% if collection.case_query_messaging_enabled %} + {% if userexam.outstanding_feedback_count %} + {{ userexam.outstanding_feedback_count }} outstanding message{{ userexam.outstanding_feedback_count|pluralize }} + {% endif %} + {% if userexam.total_review_message_count %} + {{ userexam.total_review_message_count }} conversation message{{ userexam.total_review_message_count|pluralize }} + {% else %} + No conversation yet + {% endif %} {% endif %}
Open Attempt - Messages + {% if collection.case_query_messaging_enabled %} + Messages + {% endif %}
Ended: {{ cidexam.end_time|default:"-" }}
- {% if cidexam.outstanding_feedback_count %} - {{ cidexam.outstanding_feedback_count }} outstanding message{{ cidexam.outstanding_feedback_count|pluralize }} - {% endif %} - {% if cidexam.total_review_message_count %} - {{ cidexam.total_review_message_count }} conversation message{{ cidexam.total_review_message_count|pluralize }} - {% else %} - No conversation yet + {% if collection.case_query_messaging_enabled %} + {% if cidexam.outstanding_feedback_count %} + {{ cidexam.outstanding_feedback_count }} outstanding message{{ cidexam.outstanding_feedback_count|pluralize }} + {% endif %} + {% if cidexam.total_review_message_count %} + {{ cidexam.total_review_message_count }} conversation message{{ cidexam.total_review_message_count|pluralize }} + {% else %} + No conversation yet + {% endif %} {% endif %}
Open Attempt - Messages + {% if collection.case_query_messaging_enabled %} + Messages + {% endif %}
+ {% endif %} {% endfor %} diff --git a/atlas/templates/atlas/collection_take_overview.html b/atlas/templates/atlas/collection_take_overview.html index 40496762..dbb22177 100644 --- a/atlas/templates/atlas/collection_take_overview.html +++ b/atlas/templates/atlas/collection_take_overview.html @@ -8,7 +8,7 @@ {% if collection.in_review_mode or cid_user_exam.completed %} Review {% endif %} - {% if total_outstanding_feedback %} + {% if collection.case_query_messaging_enabled and total_outstanding_feedback %} {{ total_outstanding_feedback }} outstanding feedback {% endif %} @@ -35,7 +35,7 @@ {% if cid_user_exam.completed %}
End time: {{ cid_user_exam.end_time|default:"-" }}
{% 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) %}
@@ -68,10 +68,12 @@ {% if self_review %} Self review added {% endif %} - {% if review_stats.has_outstanding_feedback %} - {{ review_stats.outstanding_feedback_count }} feedback item{{ review_stats.outstanding_feedback_count|pluralize }} to review - {% elif review_stats.has_messages %} - {{ review_stats.message_count }} conversation message{{ review_stats.message_count|pluralize }} + {% if collection.case_query_messaging_enabled %} + {% if review_stats.has_outstanding_feedback %} + {{ review_stats.outstanding_feedback_count }} feedback item{{ review_stats.outstanding_feedback_count|pluralize }} to review + {% elif review_stats.has_messages %} + {{ review_stats.message_count }} conversation message{{ review_stats.message_count|pluralize }} + {% endif %} {% endif %}
{% if collection.show_title_post %} @@ -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 %} - {% if review_stats.has_messages %} + {% if collection.case_query_messaging_enabled and review_stats.has_messages %} View Conversation diff --git a/atlas/tests/test_collection_case_query_messaging.py b/atlas/tests/test_collection_case_query_messaging.py new file mode 100644 index 00000000..50173932 --- /dev/null +++ b/atlas/tests/test_collection_case_query_messaging.py @@ -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() diff --git a/atlas/views.py b/atlas/views.py index 2a0619b9..373ef86a 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -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,49 +4035,54 @@ def collection_detail(request, pk): .order_by("sort_order") ) - outstanding_messages = CaseReviewMessage.objects.filter( - cid_user_exam__content_type=collection_content_type, - cid_user_exam__object_id=collection.pk, - requires_acknowledgement=True, - acknowledged_at__isnull=True, - ) - total_outstanding_messages = outstanding_messages.count() - - user_message_rows = list( - collection.get_user_exams() - .select_related("user_user") - .annotate( - outstanding_message_count=Count( - "case_review_messages", - filter=Q( - case_review_messages__requires_acknowledgement=True, - case_review_messages__acknowledged_at__isnull=True, - ), - distinct=True, - ), - total_review_message_count=Count("case_review_messages", distinct=True), + if messaging_enabled: + outstanding_messages = CaseReviewMessage.objects.filter( + cid_user_exam__content_type=collection_content_type, + cid_user_exam__object_id=collection.pk, + requires_acknowledgement=True, + acknowledged_at__isnull=True, ) - .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") - ) + total_outstanding_messages = outstanding_messages.count() - cid_message_rows = list( - collection.get_cid_exams() - .select_related("cid_user") - .annotate( - outstanding_message_count=Count( - "case_review_messages", - filter=Q( - case_review_messages__requires_acknowledgement=True, - case_review_messages__acknowledged_at__isnull=True, + user_message_rows = list( + collection.get_user_exams() + .select_related("user_user") + .annotate( + outstanding_message_count=Count( + "case_review_messages", + filter=Q( + case_review_messages__requires_acknowledgement=True, + case_review_messages__acknowledged_at__isnull=True, + ), + distinct=True, ), - distinct=True, - ), - total_review_message_count=Count("case_review_messages", distinct=True), + total_review_message_count=Count("case_review_messages", distinct=True), + ) + .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") ) - .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") - ) + + cid_message_rows = list( + collection.get_cid_exams() + .select_related("cid_user") + .annotate( + outstanding_message_count=Count( + "case_review_messages", + filter=Q( + case_review_messages__requires_acknowledgement=True, + case_review_messages__acknowledged_at__isnull=True, + ), + distinct=True, + ), + total_review_message_count=Count("case_review_messages", distinct=True), + ) + .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,38 +4106,48 @@ def collection_history(request, exam_id: int): casedetails = list(CaseDetail.objects.filter(collection=collection).order_by("sort_order")) - userexams = list( - collection.get_user_exams() - .select_related("user_user") - .annotate( - outstanding_feedback_count=Count( - "case_review_messages", - filter=Q( - case_review_messages__requires_acknowledgement=True, - case_review_messages__acknowledged_at__isnull=True, + if _collection_case_query_messaging_enabled(collection): + userexams = list( + collection.get_user_exams() + .select_related("user_user") + .annotate( + outstanding_feedback_count=Count( + "case_review_messages", + filter=Q( + case_review_messages__requires_acknowledgement=True, + case_review_messages__acknowledged_at__isnull=True, + ), + distinct=True, ), - distinct=True, - ), - total_review_message_count=Count("case_review_messages", distinct=True), + total_review_message_count=Count("case_review_messages", distinct=True), + ) + .order_by("-outstanding_feedback_count", "completed", "-start_time") ) - .order_by("-outstanding_feedback_count", "completed", "-start_time") - ) - cidexams = list( - collection.get_cid_exams() - .select_related("cid_user") - .annotate( - outstanding_feedback_count=Count( - "case_review_messages", - filter=Q( - case_review_messages__requires_acknowledgement=True, - case_review_messages__acknowledged_at__isnull=True, + cidexams = list( + collection.get_cid_exams() + .select_related("cid_user") + .annotate( + outstanding_feedback_count=Count( + "case_review_messages", + filter=Q( + case_review_messages__requires_acknowledgement=True, + case_review_messages__acknowledged_at__isnull=True, + ), + distinct=True, ), - distinct=True, - ), - total_review_message_count=Count("case_review_messages", distinct=True), + total_review_message_count=Count("case_review_messages", distinct=True), + ) + .order_by("-outstanding_feedback_count", "completed", "-start_time") ) - .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,