feat: Add case review messaging system with feedback and query capabilities

- Introduced CaseReviewMessage model to handle feedback and queries for cases.
- Implemented views for displaying user and CID messages, including outstanding feedback.
- Created templates for collection feedback overview and user messages.
- Added functionality for acknowledging messages and sending feedback or queries.
- Enhanced collection detail view to show outstanding messages and user message statistics.
- Added tests for handling duplicate DICOM uploads without series.
This commit is contained in:
Ross
2026-04-27 12:55:27 +01:00
parent b02120d5e7
commit dc479cafd0
15 changed files with 1354 additions and 113 deletions
+32
View File
@@ -46,6 +46,7 @@ from atlas.models import (
UncategorisedDicom,
UserReportAnswer,
CaseDisplaySet,
CaseReviewMessage,
)
from .models import NormalCase
@@ -1123,6 +1124,37 @@ class SelfReviewForm(ModelForm):
)
class CaseReviewMessageForm(ModelForm):
class Meta:
model = CaseReviewMessage
fields = ["message", "requires_acknowledgement"]
def __init__(self, *args, allow_ack=False, message_placeholder=None, **kwargs):
super(CaseReviewMessageForm, self).__init__(*args, **kwargs)
self.fields["message"].widget.attrs.update(
{
"rows": 3,
"placeholder": message_placeholder or "Write a message...",
"class": "form-control",
}
)
self.fields["requires_acknowledgement"].widget.attrs.update(
{
"class": "form-check-input",
}
)
if not allow_ack:
self.fields["requires_acknowledgement"].widget = HiddenInput()
self.fields["requires_acknowledgement"].required = False
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
class UncategorisedDicomForm(ModelForm):
class Meta:
model = UncategorisedDicom
@@ -0,0 +1,33 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("generic", "0031_flag"),
("atlas", "0096_casecollection_show_differentials_post"),
]
operations = [
migrations.CreateModel(
name="CaseReviewMessage",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("message_type", models.CharField(choices=[("feedback", "Feedback"), ("query", "Query"), ("reply", "Reply")], default="query", max_length=20)),
("message", models.TextField()),
("author_label", models.CharField(blank=True, max_length=255)),
("requires_acknowledgement", models.BooleanField(default=False)),
("acknowledged_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("author", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages_authored", to=settings.AUTH_USER_MODEL)),
("case", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages", to="atlas.case")),
("cid_user_exam", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages", to="generic.ciduserexam")),
],
options={
"ordering": ("created_at",),
},
),
]
+65
View File
@@ -2024,6 +2024,71 @@ class SelfReview(models.Model):
)
class CaseReviewMessage(models.Model):
"""Conversation message for one learner attempt on one case.
Supervisors/collection authors use this for per-case feedback and replies.
Learners use it to raise questions/queries. Supervisor messages may require
acknowledgement so they continue to flag on the learner-facing pages until
explicitly acknowledged.
"""
class MessageType(models.TextChoices):
FEEDBACK = "feedback", _("Feedback")
QUERY = "query", _("Query")
REPLY = "reply", _("Reply")
cid_user_exam = models.ForeignKey(
CidUserExam,
on_delete=models.CASCADE,
related_name="case_review_messages",
)
case = models.ForeignKey(
Case,
on_delete=models.CASCADE,
related_name="case_review_messages",
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="case_review_messages_authored",
null=True,
blank=True,
)
author_label = models.CharField(max_length=255, blank=True)
message_type = models.CharField(
max_length=20,
choices=MessageType.choices,
default=MessageType.QUERY,
)
message = models.TextField()
requires_acknowledgement = models.BooleanField(default=False)
acknowledged_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ("created_at",)
def __str__(self) -> str:
return f"{self.get_message_type_display()} for {self.case}"
@property
def is_acknowledged(self) -> bool:
return self.acknowledged_at is not None
def get_author_display(self) -> str:
if self.author is not None:
full_name = self.author.get_full_name().strip()
return full_name or self.author.username
return self.author_label or "Learner"
def acknowledge(self):
if self.acknowledged_at is None:
self.acknowledged_at = timezone.now()
self.save(update_fields=["acknowledged_at"])
class UncategorisedDicom(models.Model):
# We use image to maintain consitency across apps
image = models.FileField(upload_to=uncategorised_dicom_directory_path)
@@ -289,6 +289,14 @@
</div>
</form>
<div class="mt-3"
hx-get="{% url 'atlas:collection_case_review_thread' exam_id=collection.id cid_user_exam_id=cid_user_exam.id case_id=case.id %}{% if cid %}?cid={{ cid }}&passcode={{ passcode }}{% endif %}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted">Loading case conversation...</div>
</div>
<aside id="selfReviewSidebar" class="self-review-sidebar" aria-live="polite" aria-label="Self review sidebar">
<div class="self-review-sidebar-header">
<h5 class="mb-0" id="selfReviewSidebarLabel">Self Review</h5>
@@ -85,6 +85,50 @@
>Sync prerequisite users</button>
</div>
{% endif %}
<div class="card border-secondary w-100 mt-3">
<div class="card-body py-2 px-3 text-start text-md-end">
<div class="small text-muted">Outstanding messages</div>
<div class="fw-semibold {% if total_outstanding_messages %}text-danger{% endif %}">
{{ total_outstanding_messages }} pending acknowledgement
</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>
{% if user_message_rows %}
<div class="mt-2 small text-muted">Users</div>
<div class="d-flex flex-column gap-1">
{% for row in user_message_rows|slice:":5" %}
<a class="small {% if row.outstanding_message_count %}text-danger{% endif %}" href="{% url 'atlas:collection_user_messages' collection.pk row.user_user.pk %}">
{{ row.get_user_name }}
{% if row.outstanding_message_count %}
({{ row.outstanding_message_count }} outstanding)
{% else %}
({{ row.total_review_message_count }} history)
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
{% if cid_message_rows %}
<div class="mt-2 small text-muted">CID users</div>
<div class="d-flex flex-column gap-1">
{% for row in cid_message_rows|slice:":5" %}
<a class="small {% if row.outstanding_message_count %}text-danger{% endif %}" href="{% url 'atlas:collection_cid_messages' collection.pk row.cid_user.cid %}">
{{ row.cid_user }}
{% if row.outstanding_message_count %}
({{ row.outstanding_message_count }} outstanding)
{% else %}
({{ row.total_review_message_count }} history)
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,95 @@
{% extends 'base.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">{{ collection.name }}</h2>
<div class="text-muted small">
{% if cid is not None %}
CID: <strong>{{ cid }}</strong>
{% else %}
Feedback hub
{% endif %}
</div>
</div>
<div class="card border-secondary" style="min-width: 320px;">
<div class="card-body py-2 px-3">
<div class="small text-muted">Feedback Summary</div>
<div class="fw-semibold">{{ total_outstanding_feedback }} outstanding feedback item{{ total_outstanding_feedback|pluralize }}</div>
<div class="small text-muted mt-1">Across {{ outstanding_case_count }} case{{ outstanding_case_count|pluralize }}</div>
<div class="mt-2">
<a class="btn btn-sm btn-outline-secondary"
href="{% if cid is not None %}{% url 'atlas:collection_take_overview' pk=collection.id cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_take_overview_user' pk=collection.id %}{% endif %}">
Back to Overview
</a>
</div>
</div>
</div>
</div>
{% if outstanding_rows %}
<div class="mb-4">
<h3 class="h5 mb-3">Outstanding Feedback</h3>
<div class="d-flex flex-column gap-3">
{% for row in outstanding_rows %}
<section id="case-feedback-{{ row.casedetail.case.id }}" class="card border-danger-subtle">
<div class="card-header d-flex justify-content-between align-items-center gap-2">
<div>
<strong>Case {{ row.casedetail.sort_order|add:1 }}</strong>
{% if collection.show_title_post %}
<span class="ms-1">{{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}</span>
{% endif %}
</div>
<span class="badge bg-danger">{{ row.stats.outstanding_feedback_count }} awaiting acknowledgement</span>
</div>
<div class="card-body pt-0">
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}&passcode={{ passcode }}{% endif %}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Loading conversation...</div>
</div>
</div>
</section>
{% endfor %}
</div>
</div>
{% else %}
<div class="alert alert-success">No outstanding feedback needs acknowledgement.</div>
{% endif %}
{% if history_rows %}
<div>
<h3 class="h5 mb-3">Previous Conversations</h3>
<div class="accordion" id="feedbackHistoryAccordion">
{% for row in history_rows %}
<div class="accordion-item" id="case-feedback-{{ row.casedetail.case.id }}">
<h2 class="accordion-header" id="heading-{{ row.casedetail.case.id }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-{{ row.casedetail.case.id }}" aria-expanded="false" aria-controls="collapse-{{ row.casedetail.case.id }}">
Case {{ row.casedetail.sort_order|add:1 }}
{% if collection.show_title_post %}
: {{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}
{% endif %}
<span class="badge bg-secondary ms-2">{{ row.stats.message_count }} message{{ row.stats.message_count|pluralize }}</span>
</button>
</h2>
<div id="collapse-{{ row.casedetail.case.id }}" class="accordion-collapse collapse" aria-labelledby="heading-{{ row.casedetail.case.id }}" data-bs-parent="#feedbackHistoryAccordion">
<div class="accordion-body pt-0">
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}&passcode={{ passcode }}{% endif %}"
hx-trigger="revealed once"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Open to load conversation history...</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% elif not outstanding_rows %}
<div class="card border-secondary">
<div class="card-body text-muted small">No case conversations have been started yet.</div>
</div>
{% endif %}
{% endblock %}
@@ -13,6 +13,11 @@
<b><a href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">{{userexam.get_user_name}}</a><b><br/>
Completed: {{userexam.completed}}<br/>
Started: {{userexam.start_time}}, Ended: {{userexam.end_time}}<br/>
{% if userexam.outstanding_feedback_count %}
<span class="badge bg-danger">{{ userexam.outstanding_feedback_count }} outstanding feedback</span><br/>
{% elif userexam.total_review_message_count %}
<span class="badge bg-secondary">{{ userexam.total_review_message_count }} conversation message{{ userexam.total_review_message_count|pluralize }}</span><br/>
{% endif %}
<button
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk userexam.user_user.pk %}"
@@ -37,6 +42,11 @@
<b><a href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">{{cidexam.cid_user}}</a><b><br/>
Completed: {{cidexam.completed}}<br/>
Started: {{cidexam.start_time}}, Ended: {{cidexam.end_time}}<br/>
{% if cidexam.outstanding_feedback_count %}
<span class="badge bg-danger">{{ cidexam.outstanding_feedback_count }} outstanding feedback</span><br/>
{% elif cidexam.total_review_message_count %}
<span class="badge bg-secondary">{{ cidexam.total_review_message_count }} conversation message{{ cidexam.total_review_message_count|pluralize }}</span><br/>
{% endif %}
<button
hx-post="{% url 'atlas:collection_reset_answers_ciduser' collection.pk cidexam.cid_user.cid %}"
@@ -1,101 +1,157 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<h2>Collection: {{collection.name}}</h2>
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h4 mb-1">{{ collection.name }}</h2>
<div class="text-muted small">Learner: <strong>{{ user }}</strong></div>
</div>
<div class="card border-secondary" style="min-width: 320px;">
<div class="card-body py-2 px-3">
<div class="small text-muted">Attempt Progress</div>
<div class="fw-semibold">{{ answer_count }} / {{ collection_length }} answered</div>
<div class="progress mt-2" style="height: 8px;">
<div class="progress-bar" role="progressbar" style="width: {% widthratio answer_count collection_length 100 %}%"></div>
</div>
<div class="mt-2 small">Started: {{ cid_user_exam.start_time }}</div>
</div>
</div>
</div>
<h3>User: {{user}}</h3>
<ul>
{% for casedetail, user_answer in user_answers %}
<li class="case">
<h4>{{forloop.counter}} / Case: {{casedetail.case.title}}</h4>
Question started: {{user_answer.started_at}} - Answer submitted: {{user_answer.submitted_at}}
{% if request.user.is_superuser and user_answer %}
&nbsp;(<a href="{% url 'admin:atlas_userreportanswer_change' user_answer.pk %}" target="_blank">Edit in admin</a>)
{% endif %}
<br/>
<br/>
{% if not user_answer %}
<span class="case-not-answered">Case not answered.</span>
{% else %}
<div class="answer-block">
User answer:
<div class="user-answer">
{% if user_answer.answer %}
{{user_answer.answer}}
{% else %}
{{user_answer.json_answer}}
{% for value, user_answer, correct_answer, answer_is_correct, automark in user_answer.get_correct_json_answers %}
{% if not user_answer %}
Not answered
{% else %}
<div class="{% if answer_is_correct %}
correct
{% else %}
incorrect
{% endif %}
{% if automark %}
automark
{% endif %}
">
<h5>{{value.title}}</h5>
{{value.description}}
<div
>
Answer : {{user_answer}}
{% if not answer_is_correct %}
<br/>Correct answer: {{correct_answer}}
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
{% endif %}
<div class="list-group mb-4">
{% for row in history_rows %}
<div class="list-group-item {% if not row.user_answer %}bg-danger-subtle border-danger-subtle{% endif %}">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-2">
<div>
<div class="fw-semibold">
Case {{ forloop.counter }}: {{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}
{% if row.user_answer and row.user_answer.completed %}
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
{% elif row.user_answer %}
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">Saved</span>
{% else %}
<span class="badge bg-danger-subtle text-danger-emphasis ms-2">Not answered</span>
{% endif %}
{% if row.self_reviews %}
<span class="badge bg-info-subtle text-info-emphasis ms-1">{{ row.self_reviews|length }} self review{{ row.self_reviews|length|pluralize }}</span>
{% endif %}
</div>
<div class="small text-muted">
Started: {{ row.user_answer.started_at|default:'-' }}
{% if row.user_answer %}
· Submitted: {{ row.user_answer.submitted_at|default:'-' }}
{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary"
href="{% url 'atlas:collection_case_view' pk=collection.id case_number=forloop.counter0 %}">
Open Case
</a>
{% if row.self_reviews %}
{% for review in row.self_reviews %}
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:self_review_detail' cid_user_exam.id row.casedetail.case.id review.id %}">
View Self Review {{ forloop.counter }}
</a>
{% endfor %}
{% endif %}
</div>
</div>
{% if not row.user_answer %}
<div class="small text-muted">This case has not been answered yet.</div>
{% else %}
<details class="mb-3" open>
<summary class="small fw-semibold">Answer / Report</summary>
<div class="small mt-2 p-3 rounded bg-body-tertiary border">
{% if row.user_answer.answer %}
{{ row.user_answer.answer|linebreaksbr }}
{% else %}
<div class="mb-2">{{ row.user_answer.json_answer }}</div>
{% for value, single_answer, correct_answer, answer_is_correct, automark in row.user_answer.get_correct_json_answers %}
<div class="mb-2 p-2 border rounded {% if answer_is_correct %}border-success-subtle{% else %}border-danger-subtle{% endif %}">
<div class="fw-semibold">
{{ value.title }}
{% if answer_is_correct %}
<span class="text-success"></span>
{% elif automark %}
<span class="text-danger"></span>
{% else %}
<span class="text-warning"></span>
{% endif %}
</div>
<div class="small text-muted">{{ value.description }}</div>
<div class="small mt-1">Answer: {{ single_answer|default:'Not answered' }}</div>
{% if not answer_is_correct and correct_answer %}
<div class="small text-muted">Correct answer: {{ correct_answer }}</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
{% if request.user.is_superuser and row.user_answer %}
<div class="mt-2 small">
{% if row.user_answer.user_id %}
<a href="{% url 'admin:atlas_userreportanswer_change' row.user_answer.pk %}" target="_blank">Edit in admin</a>
{% else %}
<a href="{% url 'admin:atlas_cidreportanswer_change' row.user_answer.pk %}" target="_blank">Edit in admin</a>
{% endif %}
</div>
{% endif %}
</div>
</details>
{% endif %}
</li>
{% if row.self_reviews %}
<details class="mb-3">
<summary class="small fw-semibold">Self Reviews</summary>
<div class="d-flex flex-column gap-2 mt-2">
{% for review in row.self_reviews %}
<div class="border rounded p-3 bg-body-tertiary">
<div class="d-flex justify-content-between align-items-start gap-2 mb-2">
<div class="small text-muted">Updated: {{ review.review_update_date|date:"Y-m-d H:i" }}</div>
<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:self_review_detail' cid_user_exam.id row.casedetail.case.id review.id %}">
View
</a>
<a class="btn btn-sm btn-outline-warning"
href="{% url 'atlas:self_review_update' cid_user_exam.id row.casedetail.case.id review.id %}">
Edit
</a>
</div>
</div>
<div class="small mb-1"><strong>Findings:</strong> {{ review.findings|default:'-' }}/5</div>
<div class="small mb-2"><strong>Interpretation:</strong> {{ review.interpretation|default:'-' }}/5</div>
<div class="small text-body-secondary">{{ review.comments|default:'No comments added.'|linebreaksbr }}</div>
</div>
{% endfor %}
</div>
</details>
{% endif %}
<details class="mt-3" {% if row.case_review_stats.has_outstanding_query %}open{% endif %}>
<summary class="small fw-semibold d-flex flex-wrap align-items-center gap-2">
<span>Case conversation</span>
{% if row.case_review_stats.has_outstanding_query %}
<span class="badge bg-danger">{{ row.case_review_stats.outstanding_query_count }} new learner message{{ row.case_review_stats.outstanding_query_count|pluralize }}</span>
{% elif row.case_review_stats.has_messages %}
<span class="badge bg-secondary">{{ row.case_review_stats.message_count }} message{{ row.case_review_stats.message_count|pluralize }}</span>
{% else %}
<span class="text-muted">No messages yet</span>
{% endif %}
</summary>
<div class="mt-2"
hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}{% endif %}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted">Loading case conversation...</div>
</div>
</details>
</div>
{% endfor %}
</ul>
</div>
{% endblock %}
{% block css %}
{{ block.super }}
<style>
.correct {
}
.correct h5::after {
content: '✓';
color: green;
}
.incorrect h5::after {
content: '✗';
color: orange;
}
.incorrect.automark h5::after {
content: '✗';
color: red;
}
.answer-block>div {
padding-bottom: 10px;
}
.case:has(.case-not-answered) {
opacity: 0.5;
}
</style>
{% endblock %}
@@ -8,6 +8,9 @@
{% 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 %}
<span class="badge bg-danger ms-2">{{ total_outstanding_feedback }} outstanding feedback</span>
{% endif %}
</h2>
<div class="text-muted small">
{% if cid is not None %}
@@ -32,13 +35,23 @@
{% if cid_user_exam.completed %}
<div class="mt-1 small">End time: {{ cid_user_exam.end_time|default:"-" }}</div>
{% endif %}
<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 %}">
{% if total_outstanding_feedback %}
Review Feedback Hub
{% else %}
Open Feedback Hub
{% endif %}
</a>
</div>
</div>
</div>
</div>
<div class="list-group mb-3">
{% for question, answer, self_review in question_answer_tuples %}
<div class="list-group-item {% if not answer %}bg-danger-subtle border-danger-subtle{% endif %}">
{% for question, answer, self_review, review_stats in question_answer_tuples %}
<div class="list-group-item {% if review_stats.has_outstanding_feedback %}border-warning border-2{% elif not answer %}bg-danger-subtle border-danger-subtle{% endif %}">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
<div>
<div class="fw-semibold">
@@ -53,6 +66,11 @@
{% if self_review %}
<span class="badge bg-info-subtle text-info-emphasis ms-1">Self review added</span>
{% endif %}
{% 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 %}
</div>
{% if collection.show_title_post %}
<div class="small text-muted">
@@ -80,6 +98,12 @@
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 %}
<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
</a>
{% endif %}
</div>
</div>
@@ -0,0 +1,99 @@
{% 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">{{ collection.name }}</h2>
<div class="text-muted small">Message hub for <strong>{{ target_label }}</strong></div>
</div>
<div class="card border-secondary" style="min-width: 320px;">
<div class="card-body py-2 px-3">
<div class="small text-muted">Outstanding messages</div>
<div class="fw-semibold {% if total_outstanding_messages %}text-danger{% endif %}">
{{ total_outstanding_messages }} awaiting acknowledgement
</div>
<div class="mt-2 d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_history' collection.pk %}">Back to Collection History</a>
<a class="btn btn-sm btn-outline-secondary" href="{% if target_user %}{% url 'atlas:collection_history_user' collection.pk target_user.pk %}{% else %}{% url 'atlas:collection_history_ciduser' collection.pk target_cid %}{% endif %}">
Open Full Attempt Page
</a>
</div>
<div class="mt-2">
{% if show_all %}
<a class="btn btn-sm btn-outline-primary" href="?">Show outstanding only</a>
{% else %}
<a class="btn btn-sm btn-primary" href="?show=all">Load all conversation history</a>
{% endif %}
</div>
</div>
</div>
</div>
{% if outstanding_rows %}
<div class="mb-4">
<h3 class="h5 mb-3">Outstanding Messages</h3>
<div class="d-flex flex-column gap-3">
{% for row in outstanding_rows %}
<section id="case-feedback-{{ row.casedetail.case.id }}" class="card border-danger-subtle">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<strong>Case {{ row.casedetail.sort_order|add:1 }}</strong>
{% if collection.show_title_post %}
<span class="ms-1">{{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:90 }}</span>
{% endif %}
</div>
<span class="badge bg-danger">{{ row.stats.outstanding_total_count }} outstanding</span>
</div>
<div class="card-body pt-0">
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}{% endif %}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Loading conversation...</div>
</div>
</div>
</section>
{% endfor %}
</div>
</div>
{% else %}
<div class="alert alert-success">No outstanding messages for this user.</div>
{% endif %}
{% if show_all %}
{% if history_rows %}
<div>
<h3 class="h5 mb-3">Conversation History</h3>
<div class="accordion" id="userMessageHistoryAccordion">
{% for row in history_rows %}
<div class="accordion-item" id="history-case-{{ row.casedetail.case.id }}">
<h2 class="accordion-header" id="history-heading-{{ row.casedetail.case.id }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#history-collapse-{{ row.casedetail.case.id }}" aria-expanded="false" aria-controls="history-collapse-{{ row.casedetail.case.id }}">
Case {{ row.casedetail.sort_order|add:1 }}
{% if collection.show_title_post %}
: {{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:90 }}
{% endif %}
<span class="badge bg-secondary ms-2">{{ row.stats.message_count }} messages</span>
</button>
</h2>
<div id="history-collapse-{{ row.casedetail.case.id }}" class="accordion-collapse collapse" aria-labelledby="history-heading-{{ row.casedetail.case.id }}" data-bs-parent="#userMessageHistoryAccordion">
<div class="accordion-body pt-0">
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}{% endif %}"
hx-trigger="revealed once"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Open section to load history...</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% elif not outstanding_rows %}
<div class="card border-secondary">
<div class="card-body text-muted small">No conversation history for this user yet.</div>
</div>
{% endif %}
{% endif %}
{% endblock %}
@@ -0,0 +1,114 @@
<div id="{{ thread_dom_id }}" class="case-review-thread-root card border-secondary-subtle mt-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong>Case Conversation</strong>
{% if unacknowledged_count %}
<span class="badge bg-danger-subtle text-danger-emphasis ms-2">{{ unacknowledged_count }} awaiting acknowledgement</span>
{% endif %}
</div>
<span class="small text-muted">Case {{ case.pk }}</span>
</div>
<div class="card-body">
{% if messages %}
<div class="d-flex flex-column gap-2 mb-3">
{% for message in messages %}
<div class="border rounded p-2 {% if message.message_type == 'feedback' %}border-warning-subtle bg-warning-subtle{% elif message.message_type == 'query' %}border-info-subtle bg-info-subtle{% else %}border-secondary-subtle bg-body-tertiary{% endif %}">
<div class="d-flex justify-content-between align-items-start gap-2 mb-1">
<div>
<span class="fw-semibold">{{ message.get_author_display }}</span>
<span class="badge text-bg-dark ms-1">{{ message.get_message_type_display }}</span>
{% if message.requires_acknowledgement %}
{% if message.is_acknowledged %}
<span class="badge bg-success-subtle text-success-emphasis ms-1">Acknowledged</span>
{% else %}
<span class="badge bg-danger-subtle text-danger-emphasis ms-1">Awaiting acknowledgement</span>
{% endif %}
{% endif %}
</div>
<div class="small text-muted">{{ message.created_at|date:"Y-m-d H:i" }}</div>
</div>
<div class="small">{{ message.message|linebreaksbr }}</div>
{% if message.requires_acknowledgement and not message.is_acknowledged %}
{% if can_acknowledge_query and message.message_type == 'query' %}
<form class="mt-2"
hx-post="{{ thread_url }}"
hx-target="#{{ thread_dom_id }}"
hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="action" value="acknowledge">
<input type="hidden" name="message_id" value="{{ message.id }}">
{% if cid %}<input type="hidden" name="cid" value="{{ cid }}">{% endif %}
{% if passcode %}<input type="hidden" name="passcode" value="{{ passcode }}">{% endif %}
<button type="submit" class="btn btn-sm btn-outline-success">Acknowledge learner query</button>
</form>
{% elif can_acknowledge_feedback and message.message_type != 'query' %}
<form class="mt-2"
hx-post="{{ thread_url }}"
hx-target="#{{ thread_dom_id }}"
hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="action" value="acknowledge">
<input type="hidden" name="message_id" value="{{ message.id }}">
{% if cid %}<input type="hidden" name="cid" value="{{ cid }}">{% endif %}
{% if passcode %}<input type="hidden" name="passcode" value="{{ passcode }}">{% endif %}
<button type="submit" class="btn btn-sm btn-outline-success">Acknowledge feedback</button>
</form>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted small mb-3">No messages yet for this case.</div>
{% endif %}
{% if can_feedback %}
<form hx-post="{{ thread_url }}"
hx-target="#{{ thread_dom_id }}"
hx-swap="outerHTML"
class="border rounded p-3 mb-2 bg-body-tertiary">
{% csrf_token %}
<input type="hidden" name="action" value="add_message">
<div class="row g-2 align-items-end">
<div class="col-md-8">
<label class="form-label small fw-semibold">Supervisor response</label>
{{ supervisor_form.message }}
</div>
<div class="col-md-2">
<label class="form-label small fw-semibold">Type</label>
<select name="message_type" class="form-select form-select-sm">
<option value="feedback">Feedback</option>
<option value="reply">Reply</option>
</select>
</div>
<div class="col-md-2">
<div class="form-check mt-4 pt-2">
{{ supervisor_form.requires_acknowledgement }}
<label for="{{ supervisor_form.requires_acknowledgement.id_for_label }}" class="form-check-label small">Needs acknowledgement</label>
</div>
</div>
</div>
<div class="mt-2">
<button type="submit" class="btn btn-sm btn-primary">Send feedback</button>
</div>
</form>
{% endif %}
{% if can_query %}
<form hx-post="{{ thread_url }}"
hx-target="#{{ thread_dom_id }}"
hx-swap="outerHTML"
class="border rounded p-3 bg-body-tertiary">
{% csrf_token %}
<input type="hidden" name="action" value="add_message">
{% if cid %}<input type="hidden" name="cid" value="{{ cid }}">{% endif %}
{% if passcode %}<input type="hidden" name="passcode" value="{{ passcode }}">{% endif %}
<label class="form-label small fw-semibold">Ask a question / raise a query</label>
{{ query_form.message }}
<div class="mt-2">
<button type="submit" class="btn btn-sm btn-outline-info">Send query</button>
</div>
</form>
{% endif %}
</div>
</div>
+34
View File
@@ -0,0 +1,34 @@
from types import SimpleNamespace
from django.core.files.uploadedfile import SimpleUploadedFile
import atlas.api as atlas_api
from atlas.models import DuplicateDicom, SeriesImage
def test_upload_dicom_duplicate_series_image_without_series_does_not_500(
monkeypatch,
):
request = SimpleNamespace(user=SimpleNamespace(is_authenticated=True))
class DummyUncategorisedDicom:
def __init__(self, image, user):
self.image = image
self.user = user
self.image_blake3_hash = "hash123"
def save(self, *args, **kwargs):
raise DuplicateDicom(SeriesImage(series=None))
monkeypatch.setattr(atlas_api, "UncategorisedDicom", DummyUncategorisedDicom)
upload = SimpleUploadedFile(
"duplicate-no-series.dcm",
b"dicom-bytes",
content_type="application/dicom",
)
payload = atlas_api.upload_dicom(request, files=[upload])
assert payload["uploaded"] == []
assert payload["failed"] == []
assert payload["duplicate_series"] == []
assert payload["duplicates"] == [("duplicate-no-series.dcm", "hash123")]
+25
View File
@@ -164,11 +164,26 @@ urlpatterns = [
views.collection_history_user,
name="collection_history_user",
),
path(
"collection/<int:exam_id>/messages/<int:user_pk>/user",
views.collection_user_messages,
name="collection_user_messages",
),
path(
"collection/<int:exam_id>/history/<int:cid>/ciduser",
views.collection_history_ciduser,
name="collection_history_ciduser",
),
path(
"collection/<int:exam_id>/messages/<int:cid>/ciduser",
views.collection_cid_messages,
name="collection_cid_messages",
),
path(
"collection/<int:exam_id>/attempt/<int:cid_user_exam_id>/case/<int:case_id>/review-thread",
views.collection_case_review_thread,
name="collection_case_review_thread",
),
path(
"collection/<int:pk>/user_status",
@@ -335,11 +350,21 @@ urlpatterns = [
views.collection_take_overview,
name="collection_take_overview",
),
path(
"collection/<int:pk>/feedback/<int:cid>/<str:passcode>",
views.collection_feedback_overview,
name="collection_feedback_overview",
),
path(
"collection/<int:pk>/take_overview",
views.collection_take_overview_user,
name="collection_take_overview_user",
),
path(
"collection/<int:pk>/feedback",
views.collection_feedback_overview_user,
name="collection_feedback_overview_user",
),
path(
"collection/<int:pk>/<int:case_number>",
views.collection_case_view,
+620 -19
View File
@@ -82,6 +82,7 @@ from .forms import (
PriorCaseForm,
QuestionSchemaForm,
ResourceForm,
CaseReviewMessageForm,
SelfReviewForm,
SeriesAuthorForm,
SeriesForm,
@@ -104,6 +105,7 @@ from .models import (
CaseDisplaySet,
CasePrior,
CaseResource,
CaseReviewMessage,
CidReportAnswer,
Condition,
Differential,
@@ -4018,6 +4020,7 @@ def collection_viva_series(request, pk, series_id):
@user_is_collection_author_or_atlas_editor
def collection_detail(request, pk):
collection = get_object_or_404(CaseCollection, pk=pk)
collection_content_type = ContentType.objects.get_for_model(collection)
# cases = collection.cases.all().order_by("casedetail__sort_order")
casedetails = (
@@ -4028,10 +4031,62 @@ 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),
)
.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")
)
return render(
request,
"atlas/collection_detail.html",
{"collection": collection, "casesdetails": casedetails, "can_edit": True, "exam": collection},
{
"collection": collection,
"casesdetails": casedetails,
"can_edit": True,
"exam": collection,
"total_outstanding_messages": total_outstanding_messages,
"user_message_rows": user_message_rows,
"cid_message_rows": cid_message_rows,
},
)
@@ -4044,8 +4099,28 @@ def collection_history(request, exam_id: int):
"sort_order"
)
userexams = collection.get_user_exams()
cidexams = collection.get_cid_exams()
userexams = collection.get_user_exams().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,
),
total_review_message_count=Count("case_review_messages", distinct=True),
)
cidexams = collection.get_cid_exams().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,
),
total_review_message_count=Count("case_review_messages", distinct=True),
)
print("useruserexams:", userexams)
print("cidexams:", cidexams)
@@ -4063,22 +4138,147 @@ def collection_history(request, exam_id: int):
)
def _get_case_review_stats_for_exam(
*, cid_user_exam: CidUserExam, casedetails: list[CaseDetail] | QuerySet[CaseDetail]
) -> tuple[dict[int, dict[str, int | bool]], int, int]:
case_ids = [casedetail.case_id for casedetail in casedetails]
stats_by_case = {
case_id: {
"message_count": 0,
"outstanding_feedback_count": 0,
"outstanding_query_count": 0,
"outstanding_total_count": 0,
"has_messages": False,
"has_outstanding_feedback": False,
"has_outstanding_query": False,
"has_outstanding_any": False,
}
for case_id in case_ids
}
message_rows = (
CaseReviewMessage.objects.filter(cid_user_exam=cid_user_exam, case_id__in=case_ids)
.values("case_id")
.annotate(
message_count=Count("id"),
outstanding_feedback_count=Count(
"id",
filter=Q(
requires_acknowledgement=True,
acknowledged_at__isnull=True,
message_type=CaseReviewMessage.MessageType.FEEDBACK,
),
),
outstanding_query_count=Count(
"id",
filter=Q(
requires_acknowledgement=True,
acknowledged_at__isnull=True,
message_type=CaseReviewMessage.MessageType.QUERY,
),
),
)
)
outstanding_case_count = 0
total_outstanding_feedback = 0
for row in message_rows:
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,
"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,
}
if outstanding_feedback_count:
outstanding_case_count += 1
total_outstanding_feedback += outstanding_feedback_count
return stats_by_case, outstanding_case_count, total_outstanding_feedback
def _build_collection_feedback_rows(
*,
collection: CaseCollection,
cid_user_exam: CidUserExam,
casedetails: list[CaseDetail] | QuerySet[CaseDetail],
cid: int | None = None,
) -> tuple[list[dict[str, Any]], int, int]:
stats_by_case, outstanding_case_count, total_outstanding_feedback = _get_case_review_stats_for_exam(
cid_user_exam=cid_user_exam,
casedetails=casedetails,
)
rows = []
for casedetail in casedetails:
case_stats = stats_by_case.get(casedetail.case_id, {})
rows.append(
{
"casedetail": casedetail,
"stats": case_stats,
"thread_url": reverse(
"atlas:collection_case_review_thread",
kwargs={
"exam_id": collection.pk,
"cid_user_exam_id": cid_user_exam.pk,
"case_id": casedetail.case_id,
},
),
"cid": cid,
}
)
return rows, outstanding_case_count, total_outstanding_feedback
@user_is_collection_author_or_atlas_editor
def collection_history_user(request, exam_id: int, user_pk: int):
collection = get_object_or_404(CaseCollection, pk=exam_id)
user = get_object_or_404(User, pk=user_pk)
cid_user_exam = collection.get_or_create_cid_user_exam(user_user=user)
casedetails = CaseDetail.objects.filter(
collection=collection,
).order_by("sort_order")
print(user)
print(casedetails)
reviews = cid_user_exam.selfreview_set.filter(case__in=[cd.case for cd in casedetails]).order_by("-review_update_date")
reviews_by_case = {}
for review in reviews:
reviews_by_case.setdefault(review.case_id, []).append(review)
user_answers = [
(casedetail, casedetail.get_user_answers(user)) for casedetail in casedetails
]
history_rows = []
answer_count = 0
case_review_stats, _, _ = _get_case_review_stats_for_exam(
cid_user_exam=cid_user_exam,
casedetails=casedetails,
)
for casedetail in casedetails:
user_answer = casedetail.get_user_answers(user)
if user_answer and (user_answer.answer or user_answer.json_answer):
answer_count += 1
history_rows.append(
{
"casedetail": casedetail,
"user_answer": user_answer,
"self_reviews": reviews_by_case.get(casedetail.case_id, []),
"case_review_stats": case_review_stats.get(casedetail.case_id, {}),
"thread_url": reverse(
"atlas:collection_case_review_thread",
kwargs={
"exam_id": collection.pk,
"cid_user_exam_id": cid_user_exam.pk,
"case_id": casedetail.case_id,
},
),
}
)
return render(
request,
@@ -4086,8 +4286,10 @@ def collection_history_user(request, exam_id: int, user_pk: int):
{
"collection": collection,
"user": user,
"casedetails": casedetails,
"user_answers": user_answers,
"cid_user_exam": cid_user_exam,
"history_rows": history_rows,
"answer_count": answer_count,
"collection_length": len(history_rows),
},
)
@@ -4095,16 +4297,45 @@ def collection_history_user(request, exam_id: int, user_pk: int):
@user_is_collection_author_or_atlas_editor
def collection_history_ciduser(request, exam_id: int, cid: int):
collection = get_object_or_404(CaseCollection, pk=exam_id)
cid_user_exam = collection.get_or_create_cid_user_exam(cid=cid)
casedetails = CaseDetail.objects.filter(
collection=collection,
).order_by("sort_order")
print(casedetails)
reviews = cid_user_exam.selfreview_set.filter(case__in=[cd.case for cd in casedetails]).order_by("-review_update_date")
reviews_by_case = {}
for review in reviews:
reviews_by_case.setdefault(review.case_id, []).append(review)
user_answers = [
(casedetail, casedetail.get_cid_answers(cid)) for casedetail in casedetails
]
history_rows = []
answer_count = 0
case_review_stats, _, _ = _get_case_review_stats_for_exam(
cid_user_exam=cid_user_exam,
casedetails=casedetails,
)
for casedetail in casedetails:
user_answer = casedetail.get_cid_answers(cid)
if user_answer and (user_answer.answer or user_answer.json_answer):
answer_count += 1
history_rows.append(
{
"casedetail": casedetail,
"user_answer": user_answer,
"self_reviews": reviews_by_case.get(casedetail.case_id, []),
"case_review_stats": case_review_stats.get(casedetail.case_id, {}),
"thread_url": reverse(
"atlas:collection_case_review_thread",
kwargs={
"exam_id": collection.pk,
"cid_user_exam_id": cid_user_exam.pk,
"case_id": casedetail.case_id,
},
),
"cid": cid,
}
)
return render(
request,
@@ -4112,10 +4343,320 @@ def collection_history_ciduser(request, exam_id: int, cid: int):
{
"collection": collection,
"user": cid,
"casedetails": casedetails,
"user_answers": user_answers,
"cid_user_exam": cid_user_exam,
"history_rows": history_rows,
"answer_count": answer_count,
"collection_length": len(history_rows),
"cid": cid,
},
)
@user_is_collection_author_or_atlas_editor
def collection_user_messages(request, exam_id: int, user_pk: int):
"""Supervisor page showing case conversations for one user attempt.
Outstanding items are prioritized. Full historical threads can be loaded on
demand from the same page.
"""
collection = get_object_or_404(CaseCollection, pk=exam_id)
user = get_object_or_404(User, pk=user_pk)
cid_user_exam = collection.get_or_create_cid_user_exam(user_user=user)
casedetails = list(
CaseDetail.objects.filter(collection=collection).select_related("case").order_by("sort_order")
)
feedback_rows, _, _ = _build_collection_feedback_rows(
collection=collection,
cid_user_exam=cid_user_exam,
casedetails=casedetails,
)
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")]
total_outstanding_messages = sum(row["stats"].get("outstanding_total_count", 0) for row in feedback_rows)
show_all = request.GET.get("show") == "all"
return render(
request,
"atlas/collection_user_messages.html",
{
"collection": collection,
"target_label": user.get_full_name() or user.username,
"target_user": user,
"cid_user_exam": cid_user_exam,
"outstanding_rows": outstanding_rows,
"history_rows": history_rows,
"total_outstanding_messages": total_outstanding_messages,
"show_all": show_all,
"cid": None,
"passcode": None,
},
)
@user_is_collection_author_or_atlas_editor
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)
cid_user_exam = collection.get_or_create_cid_user_exam(cid=cid)
casedetails = list(
CaseDetail.objects.filter(collection=collection).select_related("case").order_by("sort_order")
)
feedback_rows, _, _ = _build_collection_feedback_rows(
collection=collection,
cid_user_exam=cid_user_exam,
casedetails=casedetails,
cid=cid,
)
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")]
total_outstanding_messages = sum(row["stats"].get("outstanding_total_count", 0) for row in feedback_rows)
show_all = request.GET.get("show") == "all"
return render(
request,
"atlas/collection_user_messages.html",
{
"collection": collection,
"target_label": f"CID {cid}",
"target_cid": cid,
"cid_user_exam": cid_user_exam,
"outstanding_rows": outstanding_rows,
"history_rows": history_rows,
"total_outstanding_messages": total_outstanding_messages,
"show_all": show_all,
"cid": cid,
"passcode": None,
},
)
def _user_can_moderate_collection(collection: CaseCollection, user: User) -> bool:
if not getattr(user, "is_authenticated", False):
return False
return (
user.is_superuser
or user.groups.filter(name="atlas_editor").exists()
or collection.author.filter(pk=user.pk).exists()
)
def _get_case_review_thread_context(
*,
collection: CaseCollection,
cid_user_exam: CidUserExam,
case: Case,
request,
cid: int | None = None,
passcode: str | None = None,
):
can_feedback = _user_can_moderate_collection(collection, request.user)
can_query = False
can_acknowledge_feedback = False
can_acknowledge_query = False
if not can_feedback:
try:
collection.check_user_can_take(cid, passcode, request.user)
if cid_user_exam.user_user_id:
can_query = request.user.is_authenticated and cid_user_exam.user_user_id == request.user.id
elif cid_user_exam.cid_user_id and cid is not None:
can_query = str(cid_user_exam.cid_user.cid) == str(cid)
can_acknowledge_feedback = can_query
except Exception:
can_query = False
can_acknowledge_feedback = False
if can_feedback:
can_acknowledge_query = True
messages = list(
CaseReviewMessage.objects.filter(cid_user_exam=cid_user_exam, case=case)
.select_related("author")
.order_by("created_at")
)
supervisor_form = None
if can_feedback:
supervisor_form = CaseReviewMessageForm(
allow_ack=True,
message_placeholder="Give case-specific feedback or reply to a learner query...",
initial={"requires_acknowledgement": True},
)
query_form = None
if can_query:
query_form = CaseReviewMessageForm(
allow_ack=False,
message_placeholder="Ask a question or raise a query about this case...",
)
unacknowledged_count = sum(
1 for message in messages if message.requires_acknowledgement and not message.is_acknowledged
)
if can_feedback:
pending_for_viewer_count = sum(
1
for message in messages
if message.requires_acknowledgement
and not message.is_acknowledged
and message.message_type == CaseReviewMessage.MessageType.QUERY
)
else:
pending_for_viewer_count = sum(
1
for message in messages
if message.requires_acknowledgement
and not message.is_acknowledged
and message.message_type != CaseReviewMessage.MessageType.QUERY
)
return {
"collection": collection,
"cid_user_exam": cid_user_exam,
"case": case,
"messages": messages,
"can_feedback": can_feedback,
"can_query": can_query,
"can_acknowledge_feedback": can_acknowledge_feedback,
"can_acknowledge_query": can_acknowledge_query,
"supervisor_form": supervisor_form,
"query_form": query_form,
"thread_url": reverse(
"atlas:collection_case_review_thread",
kwargs={
"exam_id": collection.pk,
"cid_user_exam_id": cid_user_exam.pk,
"case_id": case.pk,
},
),
"thread_dom_id": f"case-review-thread-{cid_user_exam.pk}-{case.pk}",
"cid": cid,
"passcode": passcode,
"unacknowledged_count": unacknowledged_count,
"pending_for_viewer_count": pending_for_viewer_count,
}
@require_http_methods(["GET", "POST"])
def collection_case_review_thread(request, exam_id: int, cid_user_exam_id: int, case_id: int):
"""Render and mutate the per-case conversation thread for one learner attempt.
The thread supports supervisor feedback/replies, learner queries, and learner
acknowledgement of flagged feedback. All POSTs return the refreshed HTMX
fragment for the same case thread.
"""
collection = get_object_or_404(CaseCollection, pk=exam_id)
collection_content_type = ContentType.objects.get_for_model(collection)
cid_user_exam = get_object_or_404(
CidUserExam,
pk=cid_user_exam_id,
content_type=collection_content_type,
object_id=collection.pk,
)
case = get_object_or_404(Case, pk=case_id)
if not CaseDetail.objects.filter(collection=collection, case=case).exists():
raise Http404("Case not found in collection")
cid = request.POST.get("cid") or request.GET.get("cid")
passcode = request.POST.get("passcode") or request.GET.get("passcode")
can_feedback = _user_can_moderate_collection(collection, request.user)
can_query = False
if not can_feedback:
try:
collection.check_user_can_take(cid, passcode, request.user)
if cid_user_exam.user_user_id:
can_query = request.user.is_authenticated and cid_user_exam.user_user_id == request.user.id
elif cid_user_exam.cid_user_id and cid is not None:
can_query = str(cid_user_exam.cid_user.cid) == str(cid)
except Exception:
can_query = False
if not (can_feedback or can_query):
raise PermissionDenied
if request.method == "POST":
action = request.POST.get("action")
if action == "add_message":
if can_feedback:
form = CaseReviewMessageForm(
request.POST,
allow_ack=True,
message_placeholder="Give case-specific feedback or reply to a learner query...",
)
message_type = request.POST.get("message_type", CaseReviewMessage.MessageType.FEEDBACK)
if message_type not in {
CaseReviewMessage.MessageType.FEEDBACK,
CaseReviewMessage.MessageType.REPLY,
}:
message_type = CaseReviewMessage.MessageType.FEEDBACK
else:
form = CaseReviewMessageForm(
request.POST,
allow_ack=False,
message_placeholder="Ask a question or raise a query about this case...",
)
message_type = CaseReviewMessage.MessageType.QUERY
if form.is_valid():
message = form.save(commit=False)
message.cid_user_exam = cid_user_exam
message.case = case
if request.user.is_authenticated:
message.author = request.user
message.author_label = ""
else:
message.author = None
message.author_label = f"CID {cid}" if cid is not None else "Learner"
message.message_type = message_type
if message_type == CaseReviewMessage.MessageType.QUERY:
# Learner queries must be acknowledged by a supervisor/author.
message.requires_acknowledgement = True
elif message_type not in {
CaseReviewMessage.MessageType.FEEDBACK,
CaseReviewMessage.MessageType.REPLY,
}:
message.requires_acknowledgement = False
message.save()
elif action == "acknowledge":
message = get_object_or_404(
CaseReviewMessage,
pk=request.POST.get("message_id"),
cid_user_exam=cid_user_exam,
case=case,
)
if can_feedback and message.message_type != CaseReviewMessage.MessageType.QUERY:
raise PermissionDenied
if can_query and message.message_type == CaseReviewMessage.MessageType.QUERY:
raise PermissionDenied
if not (can_feedback or can_query):
raise PermissionDenied
if message.requires_acknowledgement:
message.acknowledge()
context = _get_case_review_thread_context(
collection=collection,
cid_user_exam=cid_user_exam,
case=case,
request=request,
cid=int(cid) if cid and str(cid).isdigit() else None,
passcode=passcode,
)
return render(request, "atlas/partials/_case_review_thread.html", context)
@@ -4699,6 +5240,56 @@ def collection_take_overview_user(request, pk):
return collection_take_overview(request, pk)
@login_required
def collection_feedback_overview_user(request, pk: int):
return collection_feedback_overview(request, pk)
def collection_feedback_overview(
request, pk: int, cid: int | None = None, passcode: str | None = None
):
"""Show all case conversations for one learner attempt with outstanding items first."""
collection = get_object_or_404(CaseCollection, pk=pk)
try:
collection.check_user_can_take(cid, passcode, request.user)
except PrerequisiteRequired as e:
prereq = getattr(e, 'prereq', None)
return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode})
cid_user_exam = collection.get_or_create_cid_user_exam(
cid=cid, user_user=request.user
)
casedetails = list(
CaseDetail.objects.filter(collection=collection).select_related("case").order_by("sort_order")
)
feedback_rows, outstanding_case_count, total_outstanding_feedback = _build_collection_feedback_rows(
collection=collection,
cid_user_exam=cid_user_exam,
casedetails=casedetails,
cid=cid,
)
outstanding_rows = [row for row in feedback_rows if row["stats"].get("has_outstanding_feedback")]
history_rows = [row for row in feedback_rows if row["stats"].get("has_messages") and not row["stats"].get("has_outstanding_feedback")]
return render(
request,
"atlas/collection_feedback_overview.html",
{
"collection": collection,
"cid": cid,
"passcode": passcode,
"cid_user_exam": cid_user_exam,
"feedback_rows": feedback_rows,
"outstanding_rows": outstanding_rows,
"history_rows": history_rows,
"outstanding_case_count": outstanding_case_count,
"total_outstanding_feedback": total_outstanding_feedback,
},
)
def collection_take_overview(
request, pk: int, cid: int | None = None, passcode: str | None = None
):
@@ -4744,7 +5335,9 @@ def collection_take_overview(
cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()
casedetails = CaseDetail.objects.filter(case__in=cases, collection=collection)
casedetails = list(
CaseDetail.objects.filter(case__in=cases, collection=collection).select_related("case")
)
if cid is not None:
answers = CidReportAnswer.objects.filter(cid=cid, question__in=casedetails)
@@ -4757,17 +5350,23 @@ def collection_take_overview(
for ans in answers:
answer_question_map[ans.question] = ans
case_review_stats, outstanding_case_count, total_outstanding_feedback = _get_case_review_stats_for_exam(
cid_user_exam=cid_user_exam,
casedetails=casedetails,
)
question_answer_tuples = []
answer_count = 0
for cd in casedetails:
self_review = cid_user_exam.selfreview_set.filter(case=cd.case)
review_stats = case_review_stats.get(cd.case_id, {})
if cd in answer_question_map and (
answer_question_map[cd].answer or answer_question_map[cd].json_answer
):
question_answer_tuples.append((cd, answer_question_map[cd], self_review))
question_answer_tuples.append((cd, answer_question_map[cd], self_review, review_stats))
answer_count += 1
else:
question_answer_tuples.append((cd, None, self_review))
question_answer_tuples.append((cd, None, self_review, review_stats))
return render(
request,
@@ -4780,6 +5379,8 @@ def collection_take_overview(
"collection_length": len(cases),
"passcode": passcode,
"cid_user_exam": cid_user_exam,
"outstanding_case_count": outstanding_case_count,
"total_outstanding_feedback": total_outstanding_feedback,
},
)
+2 -1
View File
@@ -19,7 +19,8 @@
"/^python -m py_compile /home/ross/penracourses/rad/generic/tables\\.py$/": {
"approve": true,
"matchCommandLine": true
}
},
"/home/ross/penracourses/rad/.venv/bin/python": true
}
}
}