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:
@@ -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",),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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 %}
|
||||
(<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>
|
||||
@@ -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")]
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user