Compare commits

..
18 Commits
Author SHA1 Message Date
Ross feb5a85af2 Refactor dropdown menus: enhance action items with icons and improve visual hierarchy for better user experience 2025-11-17 12:20:46 +00:00
Ross 292f81ad74 Refactor question link header: update text for clarity in exam context 2025-11-17 12:16:26 +00:00
Ross 349c46c6db Refactor viewing case template: adjust HTML structure for improved readability and maintainability 2025-11-17 12:14:05 +00:00
Ross 681a7560ba Refactor question form layout: improve indentation and formatting for better readability 2025-11-17 12:12:31 +00:00
Ross aa90ae5e09 Add QuestionForm and related views: implement form for creating questions with user context and exam association 2025-11-17 12:12:06 +00:00
Ross f55bb1b18e Refactor exam navigation: enhance layout and accessibility by replacing links with a responsive navbar structure 2025-11-17 12:02:21 +00:00
Ross 5e78cc4603 Refactor exam overview headers: comment out unused div for improved code clarity 2025-11-17 11:54:35 +00:00
Ross a9cbb1303a Refactor exam navigation: restructure HTML for improved layout and accessibility, adding dropdown menu for exam actions 2025-11-17 11:52:53 +00:00
Ross 24558f92ce Refactor case prior handling: update function to support case_number instead of case_id for improved clarity and maintainability 2025-11-17 11:15:08 +00:00
Ross 0f8bae8a14 Refactor URL patterns: update legacy case_id references to include 'id' for improved clarity and consistency 2025-11-17 11:11:09 +00:00
Ross 6e658aca93 Refactor case handling: update URLs and views to support case_number and case_id interchangeably, enhancing backward compatibility and maintainability 2025-11-17 11:05:13 +00:00
Ross 81cd109449 Refactor dark mode styles: update CSS variables for dark mode theme and set default background and text colors 2025-11-17 10:49:31 +00:00
Ross d86f990be2 Refactor case detail URL references: update template links from 'casedetail' to 'case_detail' for consistency 2025-11-17 10:36:19 +00:00
Ross b6478c44dc Refactor case detail references: standardize variable naming from case_detail to casedetail across templates for consistency 2025-11-17 10:34:55 +00:00
Ross 12b7fa6629 Refactor CaseDetail references to Casedetail across templates and views
- Updated all instances of 'case_detail' to 'casedetail' in templates to maintain consistency.
- Adjusted view functions to use 'casedetail' instead of 'case_detail' for better clarity.
- Renamed the field 'case_detail' to 'casedetail' in the CasePrior model to align with the new naming convention.
- Ensured that all related logic and references in forms and data handling reflect this change.
2025-11-17 10:34:27 +00:00
Ross 7f4570417e Refactor case navigation: consolidate previous and next links into a reusable partial for improved maintainability and consistency across templates 2025-11-17 10:09:00 +00:00
Ross 0098fa5dfc Refactor case prior display: extract prior card markup into a separate partial for improved maintainability and readability 2025-11-17 09:27:02 +00:00
Ross 740d013198 Refactor case prior display: enhance layout with Bootstrap cards for improved readability and user interaction 2025-11-17 09:22:17 +00:00
35 changed files with 1118 additions and 506 deletions
+24 -10
View File
@@ -2,16 +2,30 @@
{% block navigation %} {% block navigation %}
{{block.super}} {{block.super}}
<br/> <nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
Exams: {{exam}}-> <div class="container-fluid px-3">
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a> / <span class="navbar-text me-3 text-white">
<strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="examNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a></li>
{% if exam.exam_mode %} {% if exam.exam_mode %}
<a href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a> / <li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a></li>
<a href="{% url 'anatomy:mark2_overview' pk=exam.pk %}">Mark2</a> / <li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark2_overview' pk=exam.pk %}">Mark2</a></li>
<a href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a> / <li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a></li>
<a href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a> / <li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
<a href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a> / <li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a></li>
{% endif %} {% endif %}
<a href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a> <li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a></li>
{% comment %} <a href="{% url 'anatomy:question_create_exam' pk=exam.pk %}" title="Add a new question directly into the exam.">Add New Question</a> {% endcomment %} </ul>
{% include 'generic/partials/exams/dropdown_menu.html'%}
</div>
</div>
</nav>
{% endblock %} {% endblock %}
@@ -1,28 +1,95 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
<div class="container-fluid px-3">
<span class="navbar-text me-3 text-white">
<strong>Question:</strong>
{% if question.stem %}
{{ question.stem|striptags|truncatechars:80 }}
{% else %}
#{{ question.pk }}
{% endif %}
</span>
<div class="floating-header"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#questionNav" aria-controls="questionNav" aria-expanded="false" aria-label="Toggle navigation">
<a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a> <span class="navbar-toggler-icon"></span>
<a href="{% url 'anatomy:question_clone' question.id %}" title="Clone the Question">Clone</a> </button>
<a href="{% url 'anatomy:question_delete' pk=question.pk %}" title="Delete the Question">Delete</a>
<a href="{% url 'anatomy:question_answer_update' pk=question.pk %}" title="Update the question answers">Edit Answers</a> <div class="collapse navbar-collapse" id="questionNav">
<a href="#" <ul class="navbar-nav me-auto mb-2 mb-lg-0">
onclick="return window.create_popup_window('{% url 'feedback_create' question_type='anatomy' pk=question.pk %}')"> {% if exam %}
Add Note</a> <li class="nav-item">
{% if previous > -1 %}
<a class="nav-link" href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous</a>
{% endif %}
</li>
<li class="nav-item align-self-center">
Viewing as part of exam: <a class="nav-link d-inline p-0" href="{% url 'anatomy:exam_overview' exam.id %}">{{ exam }}</a>
<span class="navbar-text text-muted ms-2">[{{ pos }}/{{ exam_length }}]</span>
</li>
<li class="nav-item">
{% if next %}
<a class="nav-link" href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next</a>
{% endif %}
</li>
{% endif %}
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="questionActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="questionActions">
<li>
<a class="dropdown-item text-primary" href="{% url 'anatomy:question_detail' question.id %}">
<i class="bi bi-eye me-2"></i> View
</a>
</li>
<li>
<a class="dropdown-item text-primary" href="{% url 'anatomy:anatomy_question_update' question.id %}">
<i class="bi bi-pencil-square me-2"></i> Edit
</a>
</li>
<li>
<a class="dropdown-item text-white" href="{% url 'anatomy:question_answer_update' pk=question.pk %}">
<i class="bi bi-list-check me-2"></i> Edit Answers
</a>
</li>
<li>
<a class="dropdown-item text-white" href="{% url 'anatomy:question_clone' question.id %}">
<i class="bi bi-files me-2"></i> Clone
</a>
</li>
<li>
<a class="dropdown-item text-info" href="#" onclick="return window.create_popup_window('{% url 'feedback_create' question_type='anatomy' pk=question.pk %}')">
<i class="bi bi-chat-left-text me-2"></i> Add Note
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-danger" href="{% url 'anatomy:question_delete' pk=question.pk %}">
<i class="bi bi-trash me-2"></i> Delete
</a>
</li>
{% if request.user.is_superuser %} {% if request.user.is_superuser %}
<a href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}" <li><hr class="dropdown-divider"></li>
title="Edit the Question using the admin interface">Admin Edit</a> <li>
<a href="{% url 'anatomy:question_user_answers' question.id %}" title="View user answers associated with this question">User answers</a> <a class="dropdown-item text-warning" href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}">
{% endif %} <i class="bi bi-tools me-2"></i> Admin Edit
{% if exam %} </a>
<div> </li>
<li>
{% if previous > -1 %} <a class="dropdown-item text-muted" href="{% url 'anatomy:question_user_answers' question.id %}">
<a href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous question</a> <i class="bi bi-people me-2"></i> User answers
{% endif %} </a>
Viewing question as part of exam: <a href="{% url 'anatomy:exam_overview' exam.id %}">{{exam}}</a> [{{pos}}/{{exam_length}}] </li>
{% if next %}
<a href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next question</a>
{% endif %} {% endif %}
</ul>
</li>
</ul>
</div> </div>
{% endif %}
</div> </div>
</nav>
+5 -22
View File
@@ -831,7 +831,7 @@ class BaseReportAnswerForm(ModelForm):
# "answer": "Write your answer in here." # "answer": "Write your answer in here."
# } # }
def __init__(self, *args, case_detail, **kwargs): def __init__(self, *args, casedetail, **kwargs):
super(BaseReportAnswerForm, self).__init__(*args, **kwargs) super(BaseReportAnswerForm, self).__init__(*args, **kwargs)
self.fields["answer"].required = False self.fields["answer"].required = False
@@ -847,12 +847,12 @@ class BaseQuestionAnswerForm(ModelForm):
# "answer": "Write your answer in here." # "answer": "Write your answer in here."
# } # }
def __init__(self, *args, case_detail, **kwargs): def __init__(self, *args, casedetail, **kwargs):
super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs) super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs)
# self.fields["json_answer"].schema = case_detail.question_schema # self.fields["json_answer"].schema = casedetail.question_schema
if case_detail.question_schema is not None: if casedetail.question_schema is not None:
self.fields["json_answer"] = JSONSchemaField( self.fields["json_answer"] = JSONSchemaField(
schema=case_detail.question_schema, options="schema/options.json" schema=casedetail.question_schema, options="schema/options.json"
) )
self.fields["json_answer"].label = "" self.fields["json_answer"].label = ""
@@ -1153,23 +1153,6 @@ class PriorCaseForm(Form):
prior_visibility = CharField(max_length=2, required=True) prior_visibility = CharField(max_length=2, required=True)
# def __init__(self, *args, **kwargs):
# self.case_detail = kwargs.pop(
# "case_detail"
# ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
# super(PriorCaseForm, self).__init__(*args, **kwargs)
# prior_cases = self.case_detail.case.get_all_prior_cases()
# if not prior_cases:
# prior_cases = Case.objects.none()
# self.fields["case"] = ChoiceField(
# required=False,
# # widget=Select(verbose_name="Series", is_stacked=False),
# )
class CaseSeriesForm(forms.ModelForm): class CaseSeriesForm(forms.ModelForm):
def __init__(self, *args, queryset, user=None, is_atlas_editor=False, **kwargs): def __init__(self, *args, queryset, user=None, is_atlas_editor=False, **kwargs):
@@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2025-11-17 10:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('atlas', '0084_resource_open_access'),
]
operations = [
migrations.RenameField(
model_name='caseprior',
old_name='case_detail',
new_name='casedetail',
),
migrations.AlterUniqueTogether(
name='caseprior',
unique_together={('casedetail', 'prior_case')},
),
]
+10 -3
View File
@@ -1443,6 +1443,13 @@ class CaseDetail(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.case} -> {self.collection}" return f"{self.case} -> {self.collection}"
def get_case_index(self):
"""Returns the question index (number) in the collection (0 indexed)"""
case_details = list(self.collection.casedetail_set.all().order_by("sort_order"))
return case_details.index(self)
def get_question_schema(self): def get_question_schema(self):
return json.dumps(self.question_schema) return json.dumps(self.question_schema)
@@ -1545,7 +1552,7 @@ class CaseDetail(models.Model):
return mark_safe(html) return mark_safe(html)
class CasePrior(models.Model): class CasePrior(models.Model):
case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE) casedetail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case") prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case")
relation_text = models.CharField(max_length=255, blank=True, help_text="Text to describe the relationship between the cases") relation_text = models.CharField(max_length=255, blank=True, help_text="Text to describe the relationship between the cases")
@@ -1564,10 +1571,10 @@ class CasePrior(models.Model):
) )
class Meta: class Meta:
unique_together = ("case_detail", "prior_case") unique_together = ("casedetail", "prior_case")
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.case_detail.case} -> {self.prior_case}" return f"{self.casedetail.case} -> {self.prior_case}"
class BaseReportAnswer(models.Model): class BaseReportAnswer(models.Model):
question = models.ForeignKey(CaseDetail, on_delete=models.CASCADE) question = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
@@ -464,7 +464,7 @@
{% if casedetail %} {% if casedetail %}
{% include 'atlas/partials/collection_question_block.html' with case_detail=casedetail can_edit=can_edit %} {% include 'atlas/partials/collection_question_block.html' with can_edit=can_edit %}
{% endif %} {% endif %}
@@ -3,17 +3,9 @@
{% block content %} {% block content %}
<div> {% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_details" %}
{% if previous %}
<a href="{% url 'atlas:collection_case_details' collection.id previous.id %}">Previous question</a>
{% endif %}
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
{% if next %}
<a href="{% url 'atlas:collection_case_details' collection.id next.id %}">Next question</a>
{% endif %}
</div>
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2> <h2>Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}">{{casedetail.case.title}}</a></h2>
<p>This page allows you to configure how the case is displayed as part of the collection.</p> <p>This page allows you to configure how the case is displayed as part of the collection.</p>
@@ -3,33 +3,34 @@
{% block content %} {% block content %}
<h2>Setup default display for case</h2> {% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_displaysetup" %}
<h2>Setup default display for case</h2>
<div id="root" class="dicom-viewer-root" data-images="{{case_detail.case.get_series_images_nested}}" <div id="root" class="dicom-viewer-root" data-images="{{casedetail.case.get_series_images_nested}}"
style="box-sizing: border-box; background: #222; width: 100vw; height: 800px; max-width: 1600px;" style="box-sizing: border-box; background: #222; width: 100vw; height: 800px; max-width: 1600px;"
data-auto-cache-stack="true" data-auto-cache-stack="true"
{% if case_detail.default_viewerstate %} {% if casedetail.default_viewerstate %}
data-viewerstate={{ case_detail.default_viewerstate }} data-viewerstate={{ casedetail.default_viewerstate }}
{% endif %} {% endif %}
></div> ></div>
<button id="save-viewerstate" type="button" class="btn btn-primary mt-3"> <button id="save-viewerstate" type="button" class="btn btn-primary mt-3">
Save Viewer State Save Viewer State
</button> </button>
<button id="reset-viewerstate" type="button" class="btn btn-secondary mt-3 ms-2"> <button id="reset-viewerstate" type="button" class="btn btn-secondary mt-3 ms-2">
Reset to Default Reset to Default
</button> </button>
<div id="viewerstate-save-response" class="mt-2"></div> <div id="viewerstate-save-response" class="mt-2"></div>
<script> <script>
document.getElementById('save-viewerstate').addEventListener('click', function() { document.getElementById('save-viewerstate').addEventListener('click', function() {
let viewerState = window.exportViewerState_root(); let viewerState = window.exportViewerState_root();
if (!viewerState) { if (!viewerState) {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>"; document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
return; return;
} }
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", { fetch({% if case_number %}"{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk case_number=case_number %}"{% else %}"{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}"{% endif %}, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -44,9 +45,9 @@ document.getElementById('save-viewerstate').addEventListener('click', function()
.catch(error => { .catch(error => {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error saving viewer state.</span>"; document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error saving viewer state.</span>";
}); });
}); });
document.getElementById('reset-viewerstate').addEventListener('click', function() { document.getElementById('reset-viewerstate').addEventListener('click', function() {
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", { fetch({% if case_number %}"{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk case_number=case_number %}"{% else %}"{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}"{% endif %}, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -62,7 +63,7 @@ document.getElementById('reset-viewerstate').addEventListener('click', function(
.catch(error => { .catch(error => {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error resetting viewer state.</span>"; document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error resetting viewer state.</span>";
}); });
}); });
</script> </script>
{% endblock %} {% endblock %}
@@ -4,106 +4,28 @@
{% block content %} {% block content %}
<div> {% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_priors" %}
{% if previous %} <h2 class="mb-3">Managing Priors for Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}" class="text-decoration-none">{{casedetail.case.title}}</a></h2>
<a href="{% url 'atlas:collection_case_questions' collection.id previous.id %}">Previous question</a>
{% endif %} <div class="card mb-4">
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}] <div class="card-body">
{% if next %} <p class="mb-1">This page allows you to add series from prior cases to a case in a collection. In order to be available here the cases must be linked on the system.</p>
<a href="{% url 'atlas:collection_case_questions' collection.id next.id %}">Next question</a> <p class="mb-0">It is necessary to give a description of how the cases are related that will be displayed to the user when viewing the case.</p>
{% endif %} </div>
</div> </div>
<h3 class="mb-3">Available priors</h3>
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2> <div class="row g-3">
<p>This page allows you to add series from prior cases to a case in a collection. In order to be available here the cases must be linked on the system.</p>
<p>It is necessary to give a description of how the cases are related that will be displayed to the user when viewing the case.</p>
<h3>Available priors</h3>
<ul>
{% for case, added, relation, visibility in available_priors %} {% for case, added, relation, visibility in available_priors %}
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility collection=collection %}
<li class=' {% empty %}
{% if added %} <div class="col-12">
added <div class="alert alert-info">No available prior cases found for the case.</div>
{% else %} </div>
not-added
{% endif %}
' data-case={{case.id}}>
{{case.title}} / {{added}} / {{relation}} / <a href="{% url 'atlas:case_detail' case.id %}">View case</a>
<br/>{{case.get_series_blocks|safe}}
<br/>
{% if not added %}
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
hx-target="#htmx-response-{{case.id}}"
_="on htmx:afterRequest log 'test' then remove me">
{% csrf_token %}
<input type="hidden" name="prior_case_id" value="{{case.id}}" />
<input type="text" name="relation" value="{{relation}}" placeholder="relation to case" />
<br />
<label for="prior_visibility_{{case.id}}">Visibility:</label>
<select name="prior_visibility" id="prior_visibility_{{case.id}}">
<option value="AL"
{% if visibility == 'AL' %}
selected
{% endif %}
>Always</option>
<option value="RE"
{% if visibility == 'RE' %}
selected
{% endif %}
>Review</option>
<option value="NO"
{% if visibility == 'NO' %}
selected
{% endif %}
>None</option>
</select>
<br />
<button class="btn primary">Add to case</button>
</form>
{% else %}
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
hx-target="#htmx-response-{{case.id}}"
_="on htmx:afterRequest log 'test' then remove me">
Case available: {{relation}}
<input type="hidden" name="remove" value="{{case.id}}" />
<br />
<label for="prior_visibility_{{case.id}}">Visibility:</label>
<select name="prior_visibility" id="prior_visibility_{{case.id}}" disabled>
<option value="AL"
{% if visibility == 'AL' %}
selected
{% endif %}
>Always</option>
<option value="RE"
{% if visibility == 'RE' %}
selected
{% endif %}
>Review</option>
<option value="NO"
{% if visibility == 'NO' %}
selected
{% endif %}
>None</option>
</select>
<br />
<button class="btn primary">Remove from case</button>
</form>
{% endif %}
<span id="htmx-response-{{case.id}}"></span>
</li>
{% endfor %} {% endfor %}
</ul> </div>
@@ -4,21 +4,11 @@
{% block content %} {% block content %}
<div> {% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_questions" %}
{% if previous %} <h2>Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}">{{casedetail.case.title}}</a></h2>
<a href="{% url 'atlas:collection_case_questions' collection.id previous.id %}">Previous question</a>
{% endif %}
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
{% if next %}
<a href="{% url 'atlas:collection_case_questions' collection.id next.id %}">Next question</a>
{% endif %}
</div>
{% if casedetail.question_schema is not None and not casedetail.question_answers %}
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
{% if case_detail.question_schema is not None and not case_detail.question_answers %}
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
@@ -42,7 +32,7 @@
Import schema from: Import schema from:
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight" <button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
hx-get="{% url 'atlas:collection_question_schemas' case_detail.collection.id %}" hx-get="{% url 'atlas:collection_question_schemas' casedetail.collection.id %}"
hx-target=".offcanvas-body" hx-target=".offcanvas-body"
>Collection questions</button> >Collection questions</button>
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight" <button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
@@ -54,7 +44,7 @@
Question Block Title Question Block Title
</summary> </summary>
<p>Title of the question block.</p> <p>Title of the question block.</p>
<input id="question_block_title_input" type="text" name="title" value="{{case_detail.question_schema.title}}" class="form-control" placeholder="Question title" aria-label="Question title"> <input id="question_block_title_input" type="text" name="title" value="{{casedetail.question_schema.title}}" class="form-control" placeholder="Question title" aria-label="Question title">
</details> </details>
<details><summary>Modify Questions</summary> <details><summary>Modify Questions</summary>
<div id="questions-container" style="margin-top: 20px;"> <div id="questions-container" style="margin-top: 20px;">
@@ -123,7 +113,7 @@
</form> {% endcomment %} </form> {% endcomment %}
{% if case_detail.question_schema %} {% if casedetail.question_schema %}
<h2>Question display</h2> <h2>Question display</h2>
This shows the form as it will be displayed (with the correct answers). If you wish to edit a correct answer you can change it below. This shows the form as it will be displayed (with the correct answers). If you wish to edit a correct answer you can change it below.
@@ -57,7 +57,7 @@
{% endif %} {% endif %}
{% if show_history %} {% if show_history %}
<div> <div>
History: {{case_detail.get_history_pre|linebreaks}} History: {{casedetail.get_history_pre|linebreaks}}
</div> </div>
{% endif %} {% endif %}
@@ -66,9 +66,9 @@
<div> <div>
{% if question_completed %} {% if question_completed %}
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}"></iframe> <iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number=case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% else %} {% else %}
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}"></iframe> <iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number=case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% endif %} {% endif %}
</div> </div>
@@ -78,9 +78,9 @@
{% if collection.show_ohif_viewer_link %} {% if collection.show_ohif_viewer_link %}
<div> <div>
{% if question_completed %} {% if question_completed %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a> <a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number=case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% else %} {% else %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a> <a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number=case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
@@ -92,7 +92,7 @@
<div id="main_viewer" class="dicom-viewer-root" <div id="main_viewer" class="dicom-viewer-root"
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;" style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
data-auto-cache-stack="false" data-auto-cache-stack="false"
data-named-stacks='{{case_detail.get_case_named_stacks}}' data-named-stacks='{{casedetail.get_case_named_stacks}}'
></div> ></div>
</details> </details>
@@ -3,10 +3,10 @@
{% block content %} {% block content %}
<h2>Case: {{ case.title }}</h2> <h2>Case: {{ case.title }}</h2>
{{ case_detail.question_schema }} {{ casedetail.question_schema }}
{{ case.question_answers }} {{ case.question_answers }}
{% endblock %} {% endblock %}
@@ -2,9 +2,9 @@
{% block content %} {% block content %}
<div class="vstack gap-3"> <div class="vstack gap-3">
{% for case_detail, answers in case_map.items %} {% for casedetail, answers in case_map.items %}
<div class="border border-dark"> <div class="border border-dark">
<h4>Case: {{case_detail.case.title}}</h4> <h4>Case: {{casedetail.case.title}}</h4>
Answers Answers
<ul> <ul>
{% for answer in answers %} {% for answer in answers %}
@@ -3,15 +3,15 @@
{% comment %} <script src="{% static 'js/jquery-3.7.1.min.js' %}"></script> {% comment %} <script src="{% static 'js/jquery-3.7.1.min.js' %}"></script>
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %} <script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %}
<ol> <ol>
{% for case_detail in case_details %} {% for casedetail in casedetails %}
<li id="case-detail-{{case_detail.pk}}"{% if case_detail.question_schema %} class="cases"{% endif %}> <li id="case-detail-{{casedetail.pk}}"{% if casedetail.question_schema %} class="cases"{% endif %}>
<h2>Case: {{ case_detail.case.title }}</h2> <h2>Case: {{ casedetail.case.title }}</h2>
{% if case_detail.question_schema %} {% if casedetail.question_schema %}
<button class="btn btn-sm btn-secondary use-layout">Use schema</button> <button class="btn btn-sm btn-secondary use-layout">Use schema</button>
<details><summary>Schema:</summary> <details><summary>Schema:</summary>
<div class="schema" data-schema='{{ case_detail.get_question_schema }}'>{{ case_detail.question_schema }}</div> <div class="schema" data-schema='{{ casedetail.get_question_schema }}'>{{ casedetail.question_schema }}</div>
</details> </details>
<div class="form"></div> <div class="form"></div>
{% else %} {% else %}
+14 -14
View File
@@ -69,7 +69,7 @@
</div> </div>
{# Hidden per-case question snippet to inject when case is loaded #} {# Hidden per-case question snippet to inject when case is loaded #}
<div class="question-block-snippet" style="display:none;"> <div class="question-block-snippet" style="display:none;">
{% include 'atlas/partials/collection_question_block.html' with case_detail=casedetail %} {% include 'atlas/partials/collection_question_block.html' with casedetail=casedetail %}
</div> </div>
{% if case.display_sets.all %} {% if case.display_sets.all %}
<details class="displayset-detail"><summary>Display Sets:</summary> <details class="displayset-detail"><summary>Display Sets:</summary>
@@ -212,14 +212,14 @@
let c = this.closest(".case-item"); let c = this.closest(".case-item");
console.log('clicked local', c) console.log('clicked local', c)
console.log('open local', c.dataset.case) console.log('open local', c.dataset.case)
case_details = JSON.parse(c.dataset.casejson); casedetails = JSON.parse(c.dataset.casejson);
let viewerstate = JSON.parse(c.dataset.viewerstate) || {}; let viewerstate = JSON.parse(c.dataset.viewerstate) || {};
$('#loading-case').show() $('#loading-case').show()
$("#open-viewer-local").addClass("flash-button"); $("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title); $('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']); $("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']); $("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']); $("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
bc.postMessage({"type": "open", bc.postMessage({"type": "open",
"case" : c.dataset.case, "images": c.dataset.images, "case" : c.dataset.case, "images": c.dataset.images,
@@ -239,15 +239,15 @@
let c = this; let c = this;
console.log('clicked local', c) console.log('clicked local', c)
console.log('open local', c.dataset.case) console.log('open local', c.dataset.case)
case_details = JSON.parse(c.dataset.casejson); casedetails = JSON.parse(c.dataset.casejson);
let viewerstate = JSON.parse(c.dataset.viewerstate) || {}; let viewerstate = JSON.parse(c.dataset.viewerstate) || {};
let annotations = JSON.parse(c.dataset.annotations) || {}; let annotations = JSON.parse(c.dataset.annotations) || {};
$('#loading-case').show() $('#loading-case').show()
$("#open-viewer-local").addClass("flash-button"); $("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title); $('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']); $("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']); $("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']); $("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
bc.postMessage({"type": "open", bc.postMessage({"type": "open",
"case" : c.dataset.case, "images": c.dataset.images, "case" : c.dataset.case, "images": c.dataset.images,
@@ -302,15 +302,15 @@
let c = $(this).closest(".case-item"); let c = $(this).closest(".case-item");
case_details = c.data('casejson'); casedetails = c.data('casejson');
console.log(case_details); console.log(casedetails);
$('#loading-case').show() $('#loading-case').show()
$("#open-viewer").addClass("flash-button"); $("#open-viewer").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.data('title')); $('#current-case-title').html("<span class='title'>Case:</span> "+c.data('title'));
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']); $("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']); $("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']); $("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
console.log($(this).data('type')) console.log($(this).data('type'))
if ($(this).data('type') == 'case') { if ($(this).data('type') == 'case') {
@@ -0,0 +1,80 @@
<div id="prior-card-{{case.id}}" class="col-12 col-md-6 col-lg-4" data-case="{{case.id}}">
<div class="card h-100 shadow-sm {% if added %}border-success{% else %}border-secondary{% endif %}">
<div class="card-body d-flex flex-column">
{% if error %}
<div class="alert alert-danger py-1 mb-2 small">{{ error }}</div>
{% endif %}
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">{{case.title}}</h5>
<div>
{% if added %}
<span class="badge bg-success">Added</span>
{% else %}
<span class="badge bg-secondary">Available</span>
{% endif %}
</div>
</div>
<p class="card-text small text-muted mb-2">Relation: <span class="fw-medium">{{ relation }}</span></p>
<div class="mb-3 small">{{case.get_series_blocks|safe}}</div>
<div class="mt-auto">
<div class="d-flex gap-2 mb-2">
<a href="{% url 'atlas:case_detail' case.id %}" class="btn btn-sm btn-outline-primary">View case</a>
</div>
{% if not added %}
<form class="row g-2"
{% if case_number %}
hx-post="{% url 'atlas:collection_case_priors' collection.id case_number=case_number %}"
{% else %}
hx-post="{% url 'atlas:collection_case_priors_legacy' collection.id casedetail.case.pk %}"
{% endif %}
hx-target="#prior-card-{{case.id}}" hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="prior_case_id" value="{{case.id}}" />
<div class="col-12">
<input class="form-control form-control-sm" type="text" name="relation" value="{{relation}}" placeholder="Relation to case" />
</div>
<div class="col-8">
<select class="form-select form-select-sm" name="prior_visibility" id="prior_visibility_{{case.id}}">
<option value="AL" {% if visibility == 'AL' %}selected{% endif %}>Always</option>
<option value="RE" {% if visibility == 'RE' %}selected{% endif %}>Review</option>
<option value="NO" {% if visibility == 'NO' %}selected{% endif %}>None</option>
</select>
</div>
<div class="col-4 d-grid">
<button class="btn btn-sm btn-primary" type="submit">Add</button>
</div>
</form>
{% else %}
<form
{% if case_number %}
hx-post="{% url 'atlas:collection_case_priors' collection.id case_number=case_number %}"
{% else %}
hx-post="{% url 'atlas:collection_case_priors_legacy' collection.id casedetail.case.pk %}"
{% endif %}
hx-target="#prior-card-{{case.id}}" hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="remove" value="{{case.id}}" />
<div class="mb-2 small text-muted">Current relation: <strong>{{relation}}</strong></div>
<div class="d-flex gap-2">
<select class="form-select form-select-sm w-50" name="prior_visibility" id="prior_visibility_{{case.id}}" disabled>
<option value="AL" {% if visibility == 'AL' %}selected{% endif %}>Always</option>
<option value="RE" {% if visibility == 'RE' %}selected{% endif %}>Review</option>
<option value="NO" {% if visibility == 'NO' %}selected{% endif %}>None</option>
</select>
<button class="btn btn-sm btn-danger" type="submit">Remove</button>
</div>
</form>
{% endif %}
</div>
</div>
<div class="card-footer bg-transparent">
<small class="text-muted">Case ID: {{ case.id }}</small>
</div>
</div>
</div>
@@ -0,0 +1,24 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
{% if previous %}
<a class="btn btn-outline-secondary btn-sm" href="{% url nav_link_view collection.id case_number|add:-1 %}">&larr; Previous</a>
{% endif %}
</div>
<div class="text-center">
<div class="h6 mb-0">Viewing as part of a collection</div>
<div>
<a href="{% url 'atlas:collection_detail' collection.id %}" class="fw-bold">{{collection.name}}</a>
<small class="text-muted">[{{case_number|add:1}}/{{collection_length}}]</small>
</div>
</div>
<div>
{% if next %}
<a class="btn btn-outline-secondary btn-sm" href="{% url nav_link_view collection.id case_number|add:1 %}">Next &rarr;</a>
{% endif %}
</div>
<div class="mb-1">
{% include "atlas/collection_detail.html#casedetails-management-links" %}
</div>
</div>
@@ -14,7 +14,7 @@
<div class="mt-2 small text-muted"> <div class="mt-2 small text-muted">
{% if casedetail.case.series.count == 0 %} {% if casedetail.case.series.count == 0 %}
<span class="text-warning">No series attached</span> <span class="text-warning">This case has no series / stacks attached</span>
{% else %} {% else %}
{{ casedetail.case.series.count }} series {{ casedetail.case.series.count }} series
{% endif %} {% endif %}
@@ -1,14 +1,14 @@
<div class="btn-group btn-group-sm me-2" role="group" aria-label="management-actions"> <div class="btn-group btn-group-sm me-2" role="group" aria-label="management-actions">
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk casedetail.case.pk %}" title="Setup default display"> <a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Setup default display">
<i class="bi bi-display"></i> <i class="bi bi-display"></i>
</a> </a>
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_details' casedetail.collection.pk casedetail.case.pk %}" title="Case details"> <a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_details_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Case details">
<i class="bi bi-info-square"></i> <i class="bi bi-info-square"></i>
</a> </a>
{% if collection.collection_type == "QUE" %} {% if collection.collection_type == "QUE" %}
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_questions" casedetail.collection.pk casedetail.case.pk %}' title="Manage questions"> <a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_questions_legacy" casedetail.collection.pk casedetail.case.pk %}' title="Manage questions">
{% if casedetail.question_schema %} {% if casedetail.question_schema %}
<i class="bi bi-question-square text-success"></i> <i class="bi bi-question-square text-success"></i>
{% else %} {% else %}
@@ -18,7 +18,7 @@
{% endif %} {% endif %}
{% if casedetail.case.previous_case %} {% if casedetail.case.previous_case %}
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}' title="Manage priors"> <a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_priors_legacy" casedetail.collection.pk casedetail.case.pk %}' title="Manage priors">
<i class="bi bi-link-45deg"></i> <i class="bi bi-link-45deg"></i>
</a> </a>
{% endif %} {% endif %}
@@ -1,10 +1,10 @@
{# Partial: render questions and answers as HTML. #} {# Partial: render questions and answers as HTML. #}
{% if case_detail.question_schema %} {% if casedetail.question_schema %}
<div class="collection-question-block"> <div class="collection-question-block">
<h4>Questions</h4> <h4>Questions</h4>
<dl class="row"> <dl class="row">
{% for name, prop in case_detail.question_schema.properties.items %} {% for name, prop in casedetail.question_schema.properties.items %}
<dt class="col-sm-4">{{ prop.title|default:name }}</dt> <dt class="col-sm-4">{{ prop.title|default:name }}</dt>
<dd class="col-sm-8"> <dd class="col-sm-8">
{% if prop.description %} {% if prop.description %}
@@ -18,8 +18,8 @@
{% endif %} {% endif %}
{# Safely fetch the stored/example answer using the project's `get_item` filter. #} {# Safely fetch the stored/example answer using the project's `get_item` filter. #}
{% if case_detail.question_answers %} {% if casedetail.question_answers %}
{% with correct=case_detail.question_answers|get_item:name %} {% with correct=casedetail.question_answers|get_item:name %}
<div class="mt-2"> <div class="mt-2">
<strong>Example / Correct:</strong> <strong>Example / Correct:</strong>
{% if correct %} {% if correct %}
@@ -37,7 +37,7 @@
{% if user_answer %} {% if user_answer %}
{% if user_answer|get_item:name %} {% if user_answer|get_item:name %}
{% with ua=user_answer|get_item:name %} {% with ua=user_answer|get_item:name %}
{% with correct=case_detail.question_answers|get_item:name %} {% with correct=casedetail.question_answers|get_item:name %}
<div class="mt-1"> <div class="mt-1">
<strong>Your answer:</strong> <strong>Your answer:</strong>
<span class="ms-2 {% if ua == correct %}text-success{% else %}text-danger{% endif %}">{{ ua }}</span> <span class="ms-2 {% if ua == correct %}text-success{% else %}text-danger{% endif %}">{{ ua }}</span>
@@ -18,22 +18,8 @@
</div> </div>
</div> </div>
</div>
{% if collection %} {% if collection %}
<div class="text-end mb-2"> {% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_view" %}
<div class="small text-muted">Collection</div>
<div class="mb-1">
{% include "atlas/collection_detail.html#casedetails-management-links" %}
</div>
<div class="small">
{% if previous %}
<a class="me-2" href="{% url 'atlas:collection_case_view' collection.id case_number|add:-1 %}">Previous</a>
{% endif %} {% endif %}
Viewing question as part of a collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{ collection.name }}</a>
[{{ case_number|add:1 }} / {{ collection_length }}]
{% if next %}
<a class="ms-2" href="{% url 'atlas:collection_case_view' collection.id case_number|add:1 %}">Next</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
+58 -6
View File
@@ -168,30 +168,56 @@ urlpatterns = [
), ),
path("exam/<int:pk>/groups", views.ExamGroupsUpdate.as_view(), name="exam_groups_edit"), path("exam/<int:pk>/groups", views.ExamGroupsUpdate.as_view(), name="exam_groups_edit"),
path( path(
"collection/<int:exam_id>/case/<int:case_id>/details", "collection/<int:exam_id>/case/<int:case_number>/details",
views.collection_case_details, views.collection_case_details,
name="collection_case_details", name="collection_case_details",
), ),
# Backwards-compatible redirect from legacy case_id-style URLs
path( path(
"collection/<int:exam_id>/case/<int:case_id>/questions", "collection/<int:exam_id>/case/id/<int:case_id>/details",
views.redirect_collection_case_details_by_id,
name="collection_case_details_legacy",
),
path(
"collection/<int:exam_id>/case/<int:case_number>/questions",
views.collection_case_questions, views.collection_case_questions,
name="collection_case_questions", name="collection_case_questions",
), ),
path( path(
"collection/<int:exam_id>/case/<int:case_id>/priors", "collection/<int:exam_id>/case/id/<int:case_id>/questions",
views.redirect_collection_case_questions_by_id,
name="collection_case_questions_legacy",
),
path(
"collection/<int:exam_id>/case/<int:case_number>/priors",
views.collection_case_priors, views.collection_case_priors,
name="collection_case_priors", name="collection_case_priors",
), ),
path( path(
"collection/<int:exam_id>/case/<int:case_id>/dicom_json", "collection/<int:exam_id>/case/id/<int:case_id>/priors",
views.redirect_collection_case_priors_by_id,
name="collection_case_priors_legacy",
),
path(
"collection/<int:exam_id>/case/<int:case_number>/dicom_json",
views.collection_case_dicom_json, views.collection_case_dicom_json,
name="collection_case_dicom_json", name="collection_case_dicom_json",
), ),
path( path(
"collection/<int:exam_id>/case/<int:case_id>/dicom_json/review", "collection/<int:exam_id>/case/id/<int:case_id>/dicom_json",
views.redirect_collection_case_dicom_json_by_id,
name="collection_case_dicom_json_legacy",
),
path(
"collection/<int:exam_id>/case/<int:case_number>/dicom_json/review",
views.collection_case_dicom_json_review, views.collection_case_dicom_json_review,
name="collection_case_dicom_json_review", name="collection_case_dicom_json_review",
), ),
path(
"collection/<int:exam_id>/case/id/<int:case_id>/dicom_json/review",
views.redirect_collection_case_dicom_json_review_by_id,
name="collection_case_dicom_json_review_legacy",
),
path( path(
"collection/<int:exam_id>/question_schemas", "collection/<int:exam_id>/question_schemas",
views.collection_question_schemas, views.collection_question_schemas,
@@ -272,26 +298,52 @@ urlpatterns = [
views.collection_case_view, views.collection_case_view,
name="collection_case_view", name="collection_case_view",
), ),
# legacy path that used case PK; redirect to canonical case_number URL
path( path(
"collection/<int:collection_id>/<int:case_id>/display_setup", "collection/<int:pk>/id/<int:case_id>",
views.redirect_collection_case_view_by_id,
name="collection_case_view_legacy",
),
path(
"collection/<int:collection_id>/<int:case_number>/display_setup",
views.collection_case_displaysetup, views.collection_case_displaysetup,
name="collection_case_displaysetup", name="collection_case_displaysetup",
), ),
path(
"collection/<int:collection_id>/id/<int:case_id>/display_setup",
views.redirect_collection_case_displaysetup_by_id,
name="collection_case_displaysetup_legacy",
),
path( path(
"collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>", "collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>",
views.collection_case_view_take, views.collection_case_view_take,
name="collection_case_view_take", name="collection_case_view_take",
), ),
path(
"collection/<int:pk>/id/<int:case_id>/take/<int:cid>/<str:passcode>",
views.redirect_collection_case_view_take_by_id,
name="collection_case_view_take_legacy",
),
path( path(
"collection/<int:pk>/<int:case_number>/take/", "collection/<int:pk>/<int:case_number>/take/",
views.collection_case_view_take_user, views.collection_case_view_take_user,
name="collection_case_view_take_user", name="collection_case_view_take_user",
), ),
path(
"collection/<int:pk>/id/<int:case_id>/take/",
views.redirect_collection_case_view_take_user_by_id,
name="collection_case_view_take_user_legacy",
),
path( path(
"collection/<int:pk>/<int:case_number>/take/answers", "collection/<int:pk>/<int:case_number>/take/answers",
views.collection_case_view_take_user_answers, views.collection_case_view_take_user_answers,
name="collection_case_view_take_user_answers", name="collection_case_view_take_user_answers",
), ),
path(
"collection/<int:pk>/id/<int:case_id>/take/answers",
views.redirect_collection_case_view_take_user_answers_by_id,
name="collection_case_view_take_user_answers_legacy",
),
path( path(
"collection/<int:pk>/json_edit", "collection/<int:pk>/json_edit",
views.GenericExamViews.exam_json_edit, views.GenericExamViews.exam_json_edit,
+355 -129
View File
@@ -2755,46 +2755,184 @@ def collection_take_start(request, pk, cid=None, passcode=None):
) )
def _case_number_for_collection_and_caseid(collection_pk, case_id):
"""Helper: given a collection PK and a case PK, return the case index (case_number).
Raises Http404 if the case is not found in the collection.
"""
collection = get_object_or_404(CaseCollection, pk=collection_pk)
try:
# attempt to resolve the case object (may raise Http404)
case_obj = get_case_for_case_detail(case_id)
except Http404:
raise
try:
return collection.get_index_of_case(case_obj)
except ValueError:
raise Http404(f"Case {case_id} not in collection {collection_pk}")
@login_required
def redirect_collection_case_details_by_id(request, exam_id, case_id):
case_number = _case_number_for_collection_and_caseid(exam_id, case_id)
return redirect("atlas:collection_case_details", exam_id=exam_id, case_number=case_number)
@login_required
def redirect_collection_case_questions_by_id(request, exam_id, case_id):
case_number = _case_number_for_collection_and_caseid(exam_id, case_id)
return redirect("atlas:collection_case_questions", exam_id=exam_id, case_number=case_number)
@login_required
def redirect_collection_case_priors_by_id(request, exam_id, case_id):
case_number = _case_number_for_collection_and_caseid(exam_id, case_id)
return redirect("atlas:collection_case_priors", exam_id=exam_id, case_number=case_number)
@login_required
def redirect_collection_case_dicom_json_by_id(request, exam_id, case_id):
case_number = _case_number_for_collection_and_caseid(exam_id, case_id)
return redirect("atlas:collection_case_dicom_json", exam_id=exam_id, case_number=case_number)
@login_required
def redirect_collection_case_dicom_json_review_by_id(request, exam_id, case_id):
case_number = _case_number_for_collection_and_caseid(exam_id, case_id)
return redirect("atlas:collection_case_dicom_json_review", exam_id=exam_id, case_number=case_number)
@login_required
def redirect_collection_case_view_by_id(request, pk, case_id):
case_number = _case_number_for_collection_and_caseid(pk, case_id)
return redirect("atlas:collection_case_view", pk=pk, case_number=case_number)
@login_required
def redirect_collection_case_displaysetup_by_id(request, collection_id, case_id):
case_number = _case_number_for_collection_and_caseid(collection_id, case_id)
return redirect("atlas:collection_case_displaysetup", collection_id=collection_id, case_number=case_number)
@login_required
def redirect_collection_case_view_take_by_id(request, pk, case_id, cid, passcode):
case_number = _case_number_for_collection_and_caseid(pk, case_id)
return redirect("atlas:collection_case_view_take", pk=pk, case_number=case_number, cid=cid, passcode=passcode)
@login_required
def redirect_collection_case_view_take_user_by_id(request, pk, case_id):
case_number = _case_number_for_collection_and_caseid(pk, case_id)
return redirect("atlas:collection_case_view_take_user", pk=pk, case_number=case_number)
@login_required
def redirect_collection_case_view_take_user_answers_by_id(request, pk, case_id):
case_number = _case_number_for_collection_and_caseid(pk, case_id)
return redirect("atlas:collection_case_view_take_user_answers", pk=pk, case_number=case_number)
@user_is_collection_author_or_atlas_editor @user_is_collection_author_or_atlas_editor
def collection_case_priors(request, exam_id, case_id): def collection_case_priors(request, exam_id, case_number):
case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id) collection = get_object_or_404(CaseCollection, pk=exam_id)
try:
case_obj = collection.get_case_by_index(case_number)
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
except Exception:
raise Http404("Case not found in collection")
if request.htmx: if request.htmx:
# Ensure we can render the updated single-card partial and return it so HTMX
# can swap the card on the client side.
if "remove" in request.POST: if "remove" in request.POST:
p = CasePrior.objects.get( prior_pk = request.POST["remove"]
case_detail=case_detail, prior_case=request.POST["remove"] try:
) p = CasePrior.objects.get(casedetail=casedetail, prior_case=prior_pk)
p.delete() p.delete()
return HttpResponse(f"Case removed") except CasePrior.DoesNotExist:
elif "prior_case_id" in request.POST: # Already removed; continue and render the not-added card
if not request.POST["relation"]: pass
return HttpResponse(
"You need to enter text to describe the relationship between the cases" prior_case = Case.objects.get(pk=prior_pk)
added = False
relation = ""
visibility = "AL"
html = render_to_string(
"atlas/partials/_prior_card.html",
{
"case": prior_case,
"added": added,
"relation": relation,
"visibility": visibility,
"casedetail": casedetail,
"collection": collection,
},
request=request,
) )
return HttpResponse(html)
elif "prior_case_id" in request.POST:
if not request.POST.get("relation"):
# Return the card partial with an inline error so HTMX will replace
# the card and display the validation message rather than navigating
# to a new page or showing a plain text response.
prior_case = Case.objects.get(pk=request.POST.get("prior_case_id"))
added = False
relation = request.POST.get("relation", "")
visibility = request.POST.get("prior_visibility", "AL")
html = render_to_string(
"atlas/partials/_prior_card.html",
{
"case": prior_case,
"added": added,
"relation": relation,
"visibility": visibility,
"casedetail": casedetail,
"collection": collection,
"error": "You need to enter text to describe the relationship between the cases",
},
request=request,
)
return HttpResponse(html)
prior_case = Case.objects.get(pk=request.POST["prior_case_id"]) prior_case = Case.objects.get(pk=request.POST["prior_case_id"])
p, created = CasePrior.objects.get_or_create( p, created = CasePrior.objects.get_or_create(
case_detail=case_detail, prior_case=prior_case casedetail=casedetail, prior_case=prior_case
) )
p.relation_text = request.POST["relation"] p.relation_text = request.POST.get("relation", "")
p.prior_visibility = request.POST.get("prior_visibility", "AL")
p.prior_visibility = request.POST["prior_visibility"]
p.save() p.save()
return HttpResponse(f"Case added") added = True
relation = p.relation_text
visibility = p.prior_visibility
html = render_to_string(
"atlas/partials/_prior_card.html",
{
"case": prior_case,
"added": added,
"relation": relation,
"visibility": visibility,
"casedetail": casedetail,
"collection": collection,
},
request=request,
)
return HttpResponse(html)
else: else:
return HttpResponse("False") return HttpResponse("False", status=400)
raise Http404()
collection = case_detail.collection collection = casedetail.collection
added_priors = CasePrior.objects.filter(case_detail=case_detail) added_priors = CasePrior.objects.filter(casedetail=casedetail)
added_relation_map = {} added_relation_map = {}
for prior in added_priors: for prior in added_priors:
added_relation_map[prior.prior_case] = (prior.relation_text, prior.prior_visibility) added_relation_map[prior.prior_case] = (prior.relation_text, prior.prior_visibility)
case = case_detail.case case = casedetail.case
available_cases = case.get_all_prior_cases() available_cases = case.get_all_prior_cases()
@@ -2808,22 +2946,22 @@ def collection_case_priors(request, exam_id, case_id):
form = PriorCaseForm() form = PriorCaseForm()
case_number, case_count = collection.get_index_of_case( case_number, case_count = collection.get_index_of_case(
case_detail.case, case_count=True casedetail.case, case_count=True
) )
previous = collection.get_previous_case(case_detail.case) previous = collection.get_previous_case(casedetail.case)
next = collection.get_next_case(case_detail.case) next = collection.get_next_case(casedetail.case)
return render( return render(
request, request,
"atlas/collection_case_priors.html", "atlas/collection_case_priors.html",
{ {
"case_detail": case_detail, "casedetail": casedetail,
"form": form, "form": form,
#"model_form": model_form, #"model_form": model_form,
# "example_form": example_form, # "example_form": example_form,
"collection": collection, "collection": collection,
"case": case_detail.case, "case": casedetail.case,
"previous": previous, "previous": previous,
"next": next, "next": next,
"collection_length": case_count, "collection_length": case_count,
@@ -2836,9 +2974,17 @@ def collection_case_priors(request, exam_id, case_id):
@user_is_collection_author_or_atlas_editor @user_is_collection_author_or_atlas_editor
def collection_case_questions(request, exam_id, case_id): def collection_case_questions(request, exam_id, case_id):
case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id) # Support either case_number (index) or case_id (case PK)
try:
collection = case_detail.collection casedetail = CaseDetail.objects.get(case=case_id, collection=exam_id)
collection = casedetail.collection
except Exception:
collection = get_object_or_404(CaseCollection, pk=exam_id)
try:
case_obj = collection.get_case_by_index(int(case_id))
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
except Exception:
raise Http404("Case not found in collection")
if not collection.collection_type == "QUE": if not collection.collection_type == "QUE":
raise Http404("Collection not in question mode") raise Http404("Collection not in question mode")
@@ -2847,29 +2993,29 @@ def collection_case_questions(request, exam_id, case_id):
# Called if the user saves the correct answer form # Called if the user saves the correct answer form
if request.POST.get("submit") == "answer": if request.POST.get("submit") == "answer":
# answers = request.POST # answers = request.POST
case_detail.question_answers = json.loads(request.POST.get("json_answer")) casedetail.question_answers = json.loads(request.POST.get("json_answer"))
case_detail.save() casedetail.save()
example_form = JsonAnswerForm( example_form = JsonAnswerForm(
request.POST, question_schema=case_detail.question_schema request.POST, question_schema=casedetail.question_schema
) )
form = CaseQuestionForm(instance=case_detail) form = CaseQuestionForm(instance=casedetail)
# Called if the user saves the main form # Called if the user saves the main form
elif request.POST.get("submit") == "save": elif request.POST.get("submit") == "save":
form = CaseQuestionForm(request.POST, instance=case_detail) form = CaseQuestionForm(request.POST, instance=casedetail)
if form.is_valid(): if form.is_valid():
form.save() form.save()
# Add any additional logic or redirection here # Add any additional logic or redirection here
# example_form = JsonAnswerForm(request.POST, question_schema=case_detail.question_schema) # example_form = JsonAnswerForm(request.POST, question_schema=casedetail.question_schema)
post_data = request.POST.copy() post_data = request.POST.copy()
if ( if (
case_detail.question_schema is not None casedetail.question_schema is not None
and case_detail.question_answers is not None and casedetail.question_answers is not None
): ):
question_keys = set(case_detail.question_schema["properties"].keys()) question_keys = set(casedetail.question_schema["properties"].keys())
answer_keys = set(case_detail.question_answers.keys()) answer_keys = set(casedetail.question_answers.keys())
answers = {} answers = {}
for key, value in case_detail.question_answers.items(): for key, value in casedetail.question_answers.items():
if key in question_keys: if key in question_keys:
answers[key] = value answers[key] = value
@@ -2879,35 +3025,35 @@ def collection_case_questions(request, exam_id, case_id):
answers[key] = "" answers[key] = ""
post_data["json_answer"] = json.dumps(answers) post_data["json_answer"] = json.dumps(answers)
case_detail.question_answers = answers casedetail.question_answers = answers
case_detail.save() casedetail.save()
example_form = JsonAnswerForm( example_form = JsonAnswerForm(
post_data, question_schema=case_detail.question_schema post_data, question_schema=casedetail.question_schema
) )
form = CaseQuestionForm(instance=case_detail) form = CaseQuestionForm(instance=casedetail)
# This shouldn't happen # This shouldn't happen
else: else:
assert False assert False
form = CaseQuestionForm(request.POST, instance=case_detail) form = CaseQuestionForm(request.POST, instance=casedetail)
else: else:
post_data = request.POST.copy() post_data = request.POST.copy()
post_data["json_answer"] = json.dumps(case_detail.question_answers) post_data["json_answer"] = json.dumps(casedetail.question_answers)
example_form = JsonAnswerForm( example_form = JsonAnswerForm(
post_data, question_schema=case_detail.question_schema post_data, question_schema=casedetail.question_schema
) )
form = CaseQuestionForm(instance=case_detail) form = CaseQuestionForm(instance=casedetail)
pass pass
# post_data = None # post_data = None
# if case_detail.question_schema is not None and case_detail.question_answers is not None: # if casedetail.question_schema is not None and casedetail.question_answers is not None:
# question_keys = set(case_detail.question_schema["properties"].keys()) # question_keys = set(casedetail.question_schema["properties"].keys())
# answer_keys = set(case_detail.question_answers.keys()) # answer_keys = set(casedetail.question_answers.keys())
# answers = {} # answers = {}
# for key, value in case_detail.question_answers.items(): # for key, value in casedetail.question_answers.items():
# if key in question_keys: # if key in question_keys:
# answers[key] = value # answers[key] = value
@@ -2918,28 +3064,28 @@ def collection_case_questions(request, exam_id, case_id):
# post_data = request.POST.copy() # post_data = request.POST.copy()
# post_data["json_answer"]= json.dumps(answers) # post_data["json_answer"]= json.dumps(answers)
# case_detail.question_answers = answers # casedetail.question_answers = answers
# example_form = JsonAnswerForm(post_data, question_schema=case_detail.question_schema) # example_form = JsonAnswerForm(post_data, question_schema=casedetail.question_schema)
# form = CaseQuestionForm(instance=case_detail) # form = CaseQuestionForm(instance=casedetail)
# blank_form = JsonAnswerForm(question_schema=case_detail.question_schema) # blank_form = JsonAnswerForm(question_schema=casedetail.question_schema)
case_number, case_count = collection.get_index_of_case( case_number, case_count = collection.get_index_of_case(
case_detail.case, case_count=True casedetail.case, case_count=True
) )
previous = collection.get_previous_case(case_detail.case) previous = collection.get_previous_case(casedetail.case)
next = collection.get_next_case(case_detail.case) next = collection.get_next_case(casedetail.case)
return render( return render(
request, request,
"atlas/collection_case_questions.html", "atlas/collection_case_questions.html",
{ {
"case_detail": case_detail, "casedetail": casedetail,
"form": form, "form": form,
"example_form": example_form, "example_form": example_form,
"collection": collection, "collection": collection,
"case": case_detail.case, "case": casedetail.case,
"previous": previous, "previous": previous,
"next": next, "next": next,
"collection_length": case_count, "collection_length": case_count,
@@ -2949,12 +3095,22 @@ def collection_case_questions(request, exam_id, case_id):
@user_is_collection_author_or_atlas_editor @user_is_collection_author_or_atlas_editor
def collection_case_details(request, exam_id, case_id): def collection_case_details(request, exam_id, case_id):
case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id) # Accept either case_number (index into collection) or case_id (case PK).
casedetail = None
collection = case_detail.collection collection = None
try:
casedetail = CaseDetail.objects.get(case=case_id, collection=exam_id)
collection = casedetail.collection
except Exception:
collection = get_object_or_404(CaseCollection, pk=exam_id)
try:
case_obj = collection.get_case_by_index(int(case_id))
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
except Exception:
raise Http404("Case not found in collection")
if request.method == "POST": if request.method == "POST":
form = CaseDetailForm(request.POST, instance=case_detail) form = CaseDetailForm(request.POST, instance=casedetail)
if form.is_valid(): if form.is_valid():
form.save() form.save()
if request.htmx: if request.htmx:
@@ -2964,23 +3120,23 @@ def collection_case_details(request, exam_id, case_id):
if request.htmx: if request.htmx:
return HttpResponse("Invalid form", status=400) return HttpResponse("Invalid form", status=400)
else: else:
form = CaseDetailForm(instance=case_detail) form = CaseDetailForm(instance=casedetail)
case_number, case_count = collection.get_index_of_case( case_number, case_count = collection.get_index_of_case(
case_detail.case, case_count=True casedetail.case, case_count=True
) )
previous = collection.get_previous_case(case_detail.case) previous = collection.get_previous_case(casedetail.case)
next = collection.get_next_case(case_detail.case) next = collection.get_next_case(casedetail.case)
return render( return render(
request, request,
"atlas/collection_case_details.html", "atlas/collection_case_details.html",
{ {
"case_detail": case_detail, "casedetail": casedetail,
"form": form, "form": form,
"collection": collection, "collection": collection,
"case": case_detail.case, "case": casedetail.case,
"previous": previous, "previous": previous,
"next": next, "next": next,
"collection_length": case_count, "collection_length": case_count,
@@ -2998,14 +3154,14 @@ def collection_mark_overview(request, pk):
cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related() cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()
case_details = CaseDetail.objects.filter( casedetails = CaseDetail.objects.filter(
case__in=cases, collection=collection case__in=cases, collection=collection
).prefetch_related("case") ).prefetch_related("case")
answers = CidReportAnswer.objects.filter(question__in=case_details) answers = CidReportAnswer.objects.filter(question__in=casedetails)
case_map = {} case_map = {}
for cd in case_details: for cd in casedetails:
case_map[cd] = [] case_map[cd] = []
for answer in answers: for answer in answers:
@@ -3017,7 +3173,7 @@ def collection_mark_overview(request, pk):
{ {
"collection": collection, "collection": collection,
"answers": answers, "answers": answers,
"case_details": case_details, "casedetails": casedetails,
"case_map": case_map, "case_map": case_map,
}, },
) )
@@ -3035,9 +3191,9 @@ def collection_mark_question(request, pk, case_number):
previous = case_number > 0 previous = case_number > 0
next = case_number < (case_count - 1) next = case_number < (case_count - 1)
case_detail = CaseDetail.objects.filter(case=case, collection=collection) casedetail = CaseDetail.objects.filter(case=case, collection=collection)
answers = CidReportAnswer.objects.filter(question__in=case_detail) answers = CidReportAnswer.objects.filter(question__in=casedetail)
answer_forms = [] answer_forms = []
if request.method == "POST": if request.method == "POST":
@@ -3051,7 +3207,7 @@ def collection_mark_question(request, pk, case_number):
if form.is_valid(): if form.is_valid():
answer = form.save(commit=False) answer = form.save(commit=False)
# answer.cid = cid # answer.cid = cid
# answer.question = case_detail # answer.question = casedetail
# answer.published_date = timezone.now() # answer.published_date = timezone.now()
answer.save() answer.save()
@@ -3089,7 +3245,7 @@ def collection_mark_question(request, pk, case_number):
"collection": collection, "collection": collection,
"series_list": series_list, "series_list": series_list,
"answers": answers, "answers": answers,
"case_detail": case_detail, "casedetail": casedetail,
"case": case, "case": case,
"case_number": case_number, "case_number": case_number,
"answer_forms": answer_forms, "answer_forms": answer_forms,
@@ -3172,13 +3328,13 @@ def collection_take_overview(
cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related() cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()
case_details = CaseDetail.objects.filter(case__in=cases, collection=collection) casedetails = CaseDetail.objects.filter(case__in=cases, collection=collection)
if cid is not None: if cid is not None:
answers = CidReportAnswer.objects.filter(cid=cid, question__in=case_details) answers = CidReportAnswer.objects.filter(cid=cid, question__in=casedetails)
else: else:
answers = UserReportAnswer.objects.filter( answers = UserReportAnswer.objects.filter(
user=request.user, question__in=case_details user=request.user, question__in=casedetails
) )
answer_question_map = {} answer_question_map = {}
@@ -3187,7 +3343,7 @@ def collection_take_overview(
question_answer_tuples = [] question_answer_tuples = []
answer_count = 0 answer_count = 0
for cd in case_details: for cd in casedetails:
self_review = cid_user_exam.selfreview_set.filter(case=cd.case) self_review = cid_user_exam.selfreview_set.filter(case=cd.case)
if cd in answer_question_map and ( if cd in answer_question_map and (
answer_question_map[cd].answer or answer_question_map[cd].json_answer answer_question_map[cd].answer or answer_question_map[cd].json_answer
@@ -3234,7 +3390,7 @@ def collection_case_view_take_answers(
cid=cid, user_user=request.user cid=cid, user_user=request.user
) )
case_detail = CaseDetail.objects.get(case=case, collection=collection) casedetail = CaseDetail.objects.get(case=case, collection=collection)
return render( return render(
request, request,
@@ -3242,17 +3398,19 @@ def collection_case_view_take_answers(
{ {
"collection": collection, "collection": collection,
"case": case, "case": case,
"case_detail": case_detail, "casedetail": casedetail,
}, },
) )
def collection_case_view_take_user(request, pk: int, case_number: int): def collection_case_view_take_user(request, pk, case_number=None, case_id=None):
return collection_case_view_take(request, pk, case_number) # Accept either case_number (index) or case_id (case PK) and forward both to
# the handler. This allows URLs that use either name to keep working.
return collection_case_view_take(request, pk, case_number=case_number, cid=None, passcode=None, case_id=case_id)
def collection_case_view_take( def collection_case_view_take(
request, pk: int, case_number: int, cid=None, passcode=None request, pk, case_number=None, cid=None, passcode=None, case_id=None
): ):
""" """
Displays the page from which the user takes a case in the collection Displays the page from which the user takes a case in the collection
@@ -3272,6 +3430,21 @@ def collection_case_view_take(
""" """
collection = get_object_or_404(CaseCollection, pk=pk) collection = get_object_or_404(CaseCollection, pk=pk)
# If caller provided a case_id (case PK) instead of a case_number (index),
# translate it into the index used by collection.get_case_by_index().
if case_id is not None and case_number is None:
try:
case_obj = get_case_for_case_detail(case_id)
except Http404:
raise
try:
case_number = collection.get_index_of_case(case_obj)
except ValueError:
raise Http404(f"Case {case_id} not in collection {pk}")
# Ensure we have a case_number to work with
if case_number is None:
raise Http404("No case specified")
form = None form = None
answer: None | CidReportAnswer | UserReportAnswer = None answer: None | CidReportAnswer | UserReportAnswer = None
@@ -3291,23 +3464,23 @@ def collection_case_view_take(
cid_user_exam.start_time = timezone.now() cid_user_exam.start_time = timezone.now()
cid_user_exam.save() cid_user_exam.save()
case_detail = CaseDetail.objects.get(case=case, collection=collection) casedetail = CaseDetail.objects.get(case=case, collection=collection)
if not collection.review_only(): if not collection.review_only():
if collection.collection_type == "REP": if collection.collection_type == "REP":
if cid is not None: if cid is not None:
answer = case_detail.cidreportanswer_set.filter(cid=cid).first() answer = casedetail.cidreportanswer_set.filter(cid=cid).first()
ReportAnswerForm = CidReportAnswerForm ReportAnswerForm = CidReportAnswerForm
else: else:
answer = case_detail.userreportanswer_set.filter( answer = casedetail.userreportanswer_set.filter(
user=request.user user=request.user
).first() ).first()
ReportAnswerForm = UserReportAnswerForm ReportAnswerForm = UserReportAnswerForm
elif collection.collection_type == "QUE": elif collection.collection_type == "QUE":
if cid is not None: if cid is not None:
answer = case_detail.cidreportanswer_set.filter(cid=cid).first() answer = casedetail.cidreportanswer_set.filter(cid=cid).first()
ReportAnswerForm = CidQuestionAnswerForm ReportAnswerForm = CidQuestionAnswerForm
else: else:
answer = case_detail.userreportanswer_set.filter( answer = casedetail.userreportanswer_set.filter(
user=request.user user=request.user
).first() ).first()
ReportAnswerForm = UserQuestionAnswerForm ReportAnswerForm = UserQuestionAnswerForm
@@ -3325,9 +3498,9 @@ def collection_case_view_take(
if not answer and collection.collection_type in ("REP", "QUE"): if not answer and collection.collection_type in ("REP", "QUE"):
try: try:
if cid is not None: if cid is not None:
answer = CidReportAnswer(question=case_detail, cid=cid) answer = CidReportAnswer(question=casedetail, cid=cid)
else: else:
answer = UserReportAnswer(question=case_detail, user=request.user) answer = UserReportAnswer(question=casedetail, user=request.user)
answer.started_at = timezone.now() answer.started_at = timezone.now()
answer.save() answer.save()
except Exception: except Exception:
@@ -3340,10 +3513,10 @@ def collection_case_view_take(
if not collection.publish_results: if not collection.publish_results:
if answer: if answer:
form = ReportAnswerForm( form = ReportAnswerForm(
request.POST, instance=answer, case_detail=case_detail request.POST, instance=answer, casedetail=casedetail
) )
else: else:
form = ReportAnswerForm(request.POST, case_detail=case_detail) form = ReportAnswerForm(request.POST, casedetail=casedetail)
if not cid_user_exam.completed and not ( if not cid_user_exam.completed and not (
answer is not None and answer.completed answer is not None and answer.completed
@@ -3351,7 +3524,7 @@ def collection_case_view_take(
if form.is_valid(): if form.is_valid():
answer = form.save(commit=False) answer = form.save(commit=False)
answer.set_cid_or_user(cid=cid, user=request.user) answer.set_cid_or_user(cid=cid, user=request.user)
answer.question = case_detail answer.question = casedetail
# Record submission timestamp # Record submission timestamp
answer.submitted_at = timezone.now() answer.submitted_at = timezone.now()
# answer.published_date = timezone.now() # answer.published_date = timezone.now()
@@ -3422,7 +3595,7 @@ def collection_case_view_take(
) )
else: else:
if collection.collection_type in ("REP", "QUE"): if collection.collection_type in ("REP", "QUE"):
form = ReportAnswerForm(instance=answer, case_detail=case_detail) form = ReportAnswerForm(instance=answer, casedetail=casedetail)
series_list = case.series.all().prefetch_related("images", "examination", "plane") series_list = case.series.all().prefetch_related("images", "examination", "plane")
@@ -3461,7 +3634,7 @@ def collection_case_view_take(
resources = case.caseresource_set.filter(pre_review=True) resources = case.caseresource_set.filter(pre_review=True)
prior_cases = case_detail.caseprior_set.all() prior_cases = casedetail.caseprior_set.all()
series_to_load = [] series_to_load = []
@@ -3487,7 +3660,7 @@ def collection_case_view_take(
"form": form, "form": form,
"collection": collection, "collection": collection,
"case": case, "case": case,
"case_detail": case_detail, "casedetail": casedetail,
"series_list": series_list, "series_list": series_list,
"series_to_load": series_to_load, "series_to_load": series_to_load,
"case_number": case_number, "case_number": case_number,
@@ -3511,7 +3684,7 @@ def collection_case_view_take(
@user_is_collection_author_or_atlas_editor @user_is_collection_author_or_atlas_editor
def collection_case_view(request, pk, case_number): def collection_case_view(request, pk, case_number=None, case_id=None):
"""Edit/management overview of the case (as part of the collection) """Edit/management overview of the case (as part of the collection)
Args: Args:
@@ -3527,6 +3700,19 @@ def collection_case_view(request, pk, case_number):
""" """
collection = get_object_or_404(CaseCollection, pk=pk) collection = get_object_or_404(CaseCollection, pk=pk)
# Support either case_number (index) or case_id (case PK) in the URL/kwargs.
if case_id is not None and case_number is None:
try:
case_obj = get_case_for_case_detail(case_id)
except Http404:
raise
try:
case_number = collection.get_index_of_case(case_obj)
except ValueError:
raise Http404(f"Case {case_id} not in collection {pk}")
if case_number is None:
raise Http404("No case specified")
form = None form = None
answer = None answer = None
@@ -3538,8 +3724,8 @@ def collection_case_view(request, pk, case_number):
series_list = case.series.all().prefetch_related("images", "examination", "plane") series_list = case.series.all().prefetch_related("images", "examination", "plane")
previous = case_number > 0 previous = collection.get_previous_case(casedetail.case)
next = case_number < (case_count - 1) next = collection.get_next_case(casedetail.case)
return render( return render(
request, request,
@@ -3579,15 +3765,25 @@ def use_dates_as_descriptions(request, pk):
def collection_case_dicom_json_review(request, exam_id, case_id): def collection_case_dicom_json_review(request, exam_id, case_id):
return collection_case_dicom_json(request, exam_id, case_id, review=True) return collection_case_dicom_json(request, exam_id, case_id, review=True)
def collection_case_dicom_json(request, exam_id, case_id, review=False): def collection_case_dicom_json(request, exam_id, case_id, review=False):
case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id) # Support either case_number (index) or case_id (case PK)
try:
casedetail = CaseDetail.objects.get(case=case_id, collection=exam_id)
except Exception:
collection = get_object_or_404(CaseCollection, pk=exam_id)
try:
case_obj = collection.get_case_by_index(int(case_id))
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
except Exception:
raise Http404("Case not found in collection")
if review: if review:
priors = case_detail.caseprior_set.exclude(prior_visibility="NO") priors = casedetail.caseprior_set.exclude(prior_visibility="NO")
else: else:
priors = case_detail.caseprior_set.exclude(prior_visibility__in=["NO", "RE"]) priors = casedetail.caseprior_set.exclude(prior_visibility__in=["NO", "RE"])
return JsonResponse(case_detail.case.get_case_dicom_json(priors=priors)) return JsonResponse(casedetail.case.get_case_dicom_json(priors=priors))
def case_dicom_json(request, pk): def case_dicom_json(request, pk):
@@ -3608,12 +3804,12 @@ def delete_collection_cid_answers(request, exam_id, cid):
cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related() cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()
case_details = CaseDetail.objects.filter( casedetails = CaseDetail.objects.filter(
case__in=cases, collection=collection case__in=cases, collection=collection
).prefetch_related("case") ).prefetch_related("case")
cid_user_answers = CidReportAnswer.objects.filter( cid_user_answers = CidReportAnswer.objects.filter(
question__in=case_details, cid=cid question__in=casedetails, cid=cid
) )
temp = {} temp = {}
@@ -3630,11 +3826,11 @@ def delete_collection_answers(request, exam_id):
cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related() cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()
case_details = CaseDetail.objects.filter( casedetails = CaseDetail.objects.filter(
case__in=cases, collection=collection case__in=cases, collection=collection
).prefetch_related("case") ).prefetch_related("case")
cid_user_answers = CidReportAnswer.objects.filter(question__in=case_details) cid_user_answers = CidReportAnswer.objects.filter(question__in=casedetails)
temp = {} temp = {}
for ans in cid_user_answers: for ans in cid_user_answers:
@@ -3663,11 +3859,11 @@ def collection_scores_cid(request, pk):
cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related() cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()
case_details = CaseDetail.objects.filter( casedetails = CaseDetail.objects.filter(
case__in=cases, collection=collection case__in=cases, collection=collection
).prefetch_related("case") ).prefetch_related("case")
cid_user_answers = CidReportAnswer.objects.filter(question__in=case_details) cid_user_answers = CidReportAnswer.objects.filter(question__in=casedetails)
cids = set() cids = set()
@@ -4070,7 +4266,7 @@ def collection_question_schemas(request, exam_id: int):
questions = collection.cases.all().prefetch_related() questions = collection.cases.all().prefetch_related()
case_details = ( casedetails = (
CaseDetail.objects.filter(case__in=questions, collection=collection) CaseDetail.objects.filter(case__in=questions, collection=collection)
.prefetch_related("case") .prefetch_related("case")
.order_by("sort_order") .order_by("sort_order")
@@ -4081,7 +4277,7 @@ def collection_question_schemas(request, exam_id: int):
"atlas/collection_question_schemas.html", "atlas/collection_question_schemas.html",
{ {
"collection": collection, "collection": collection,
"case_details": case_details, "casedetails": casedetails,
}, },
) )
@@ -4199,10 +4395,10 @@ def collection_reset_answers_user(request, exam_id: int, user_id: int):
collection = get_object_or_404(CaseCollection, pk=exam_id) collection = get_object_or_404(CaseCollection, pk=exam_id)
# Select all case details (answers are linked to these) # Select all case details (answers are linked to these)
case_details = collection.casedetail_set.all().prefetch_related() casedetails = collection.casedetail_set.all().prefetch_related()
# Delete all answers # Delete all answers
for case in case_details: for case in casedetails:
user_answers = case.userreportanswer_set.filter(user_id=user_id) user_answers = case.userreportanswer_set.filter(user_id=user_id)
user_answers.delete() user_answers.delete()
@@ -4220,10 +4416,10 @@ def collection_reset_answers_ciduser(request, exam_id: int, cid: int):
collection = get_object_or_404(CaseCollection, pk=exam_id) collection = get_object_or_404(CaseCollection, pk=exam_id)
# Select all case details (answers are linked to these) # Select all case details (answers are linked to these)
case_details = collection.casedetail_set.all().prefetch_related() casedetails = collection.casedetail_set.all().prefetch_related()
# Delete all answers # Delete all answers
for case in case_details: for case in casedetails:
cid_answers = case.cidreportanswer_set.filter(cid=cid) cid_answers = case.cidreportanswer_set.filter(cid=cid)
cid_answers.delete() cid_answers.delete()
@@ -4241,10 +4437,10 @@ def collection_reset_answers(request, exam_id: int):
collection = get_object_or_404(CaseCollection, pk=exam_id) collection = get_object_or_404(CaseCollection, pk=exam_id)
# Select all case details (answers are linked to these) # Select all case details (answers are linked to these)
case_details = collection.casedetail_set.all().prefetch_related() casedetails = collection.casedetail_set.all().prefetch_related()
# Delete all answers # Delete all answers
for case in case_details: for case in casedetails:
cid_answers = case.cidreportanswer_set.all() cid_answers = case.cidreportanswer_set.all()
cid_answers.delete() cid_answers.delete()
@@ -4461,27 +4657,57 @@ def series_bulk_delete(request):
@user_is_collection_author_or_atlas_editor @user_is_collection_author_or_atlas_editor
@csrf_exempt # Only if you have CSRF issues; otherwise, keep CSRF protection @csrf_exempt # Only if you have CSRF issues; otherwise, keep CSRF protection
def collection_case_displaysetup(request, collection_id, case_id): def collection_case_displaysetup(request, collection_id, case_number=None, case_id=None):
""" """
View to set up the display options for a specific case in a collection. View to set up the display options for a specific case in a collection.
This can include setting which series to display, annotations, etc. This can include setting which series to display, annotations, etc.
""" """
case_detail = get_object_or_404(CaseDetail, case__pk=case_id, collection__pk=collection_id) # Accept either case_number (index into collection) or case_id (case PK).
casedetail = None
try:
# If a case_id was passed explicitly, try that first
if case_id is not None:
casedetail = get_object_or_404(CaseDetail, case__pk=case_id, collection__pk=collection_id)
else:
# Otherwise the route most likely supplied case_number; try resolving by index
collection = get_object_or_404(CaseCollection, pk=collection_id)
case_obj = collection.get_case_by_index(int(case_number))
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
except Http404:
raise
except Exception:
raise Http404("Case not found in collection")
if request.method == "POST": if request.method == "POST":
data = json.loads(request.body.decode("utf-8")) data = json.loads(request.body.decode("utf-8"))
if data.get("reset_viewerstate"): if data.get("reset_viewerstate"):
case_detail.default_viewerstate = None casedetail.default_viewerstate = None
case_detail.save() casedetail.save()
return HttpResponse("<span class='text-success'>Viewer state reset to default.</span>") return HttpResponse("<span class='text-success'>Viewer state reset to default.</span>")
viewerstate = data.get("viewerstate") viewerstate = data.get("viewerstate")
if viewerstate is not None: if viewerstate is not None:
case_detail.default_viewerstate = viewerstate casedetail.default_viewerstate = viewerstate
case_detail.save() casedetail.save()
return HttpResponse("<span class='text-success'>Viewer state saved.</span>") return HttpResponse("<span class='text-success'>Viewer state saved.</span>")
else: else:
return HttpResponse("<span class='text-danger'>No viewer state provided.</span>", status=400) return HttpResponse("<span class='text-danger'>No viewer state provided.</span>", status=400)
collection = casedetail.collection
case_number, case_count = collection.get_index_of_case(
casedetail.case, case_count=True
)
previous = collection.get_previous_case(casedetail.case)
next = collection.get_next_case(casedetail.case)
return render(request, "atlas/collection_case_displaysetup.html", { return render(request, "atlas/collection_case_displaysetup.html", {
"case_detail": case_detail "casedetail": casedetail,
"collection": collection,
"case_number": case_number,
"case_count": case_count,
"previous": previous,
"next": next,
"collection_length": case_count,
}) })
@@ -7,9 +7,9 @@
<div> <div>
<h1 class="h4 mb-1">Exam: {{ exam }}</h1> <h1 class="h4 mb-1">Exam: {{ exam }}</h1>
</div> </div>
<div class="mt-2 mt-md-0"> {% comment %} <div class="mt-2 mt-md-0">
{% include "generic/exam_link_headers.html" %} {% include "generic/exam_link_headers.html" %}
</div> </div> {% endcomment %}
</div> </div>
{% include 'exam_notes.html' %} {% include 'exam_notes.html' %}
@@ -0,0 +1,58 @@
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="examActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="examActions">
{# Primary action: add question #}
<li>
<a class="dropdown-item text-primary" href="{% url exam.get_app_name|add:':question_create' %}">
<i class="bi bi-plus-lg me-2"></i> Add Question
</a>
</li>
{# Edit / Clone group #}
<li>
<a class="dropdown-item text-white" href="{% url exam.get_app_name|add:':exam_update' exam.pk %}">
<i class="bi bi-pencil-square me-2"></i> Edit Exam
</a>
</li>
<li>
<a class="dropdown-item text-white" href="{% url exam.get_app_name|add:':exam_clone' exam.pk %}">
<i class="bi bi-files me-2"></i> Clone
</a>
</li>
<li><hr class="dropdown-divider"></li>
{# Authors / Markers (informational) #}
<li>
<a class="dropdown-item text-info" href="{% url exam.get_app_name|add:':exam_authors' exam.id %}">
<i class="bi bi-people me-2"></i> Authors
</a>
</li>
<li>
<a class="dropdown-item text-info" href="{% url exam.get_app_name|add:':exam_markers' exam.id %}">
<i class="bi bi-person-badge me-2"></i> Markers
</a>
</li>
<li><hr class="dropdown-divider"></li>
{# Delete is destructive - show in red #}
<li>
<a class="dropdown-item text-danger" href="{% url exam.get_app_name|add:':exam_delete' exam.id %}">
<i class="bi bi-trash me-2"></i> Delete
</a>
</li>
{% if request.user.is_superuser %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-warning" href="{% url 'admin:anatomy_exam_change' exam.pk %}">
<i class="bi bi-tools me-2"></i> Admin Edit
</a>
</li>
{% endif %}
</ul>
</li>
</ul>
+23 -8
View File
@@ -2,12 +2,27 @@
{% block navigation %} {% block navigation %}
{{block.super}} {{block.super}}
<br/> <nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
Exams: {{exam}}-> <div class="container-fluid px-3">
<a href="{% url 'longs:exam_overview' pk=exam.pk %}">Overview</a> / <span class="navbar-text me-3 text-white">
<a href="{% url 'longs:mark_overview' pk=exam.pk %}">Mark</a> / <strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
<a href="{% url 'longs:exam_scores_all' pk=exam.pk %}">Scores</a> / </span>
<a href="{% url 'longs:exam_cids' exam_id=exam.pk %}">Candidates</a> / <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
<a href="{% url 'longs:exam_stats' exam_id=exam.pk %}">Stats</a> / <span class="navbar-toggler-icon"></span>
<a href="{% url 'longs:exam_review_start' pk=exam.pk %}">Review</a> / </button>
<div class="collapse navbar-collapse" id="examNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'longs:exam_overview' pk=exam.pk %}">Overview</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'longs:mark_overview' pk=exam.pk %}">Mark</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'longs:exam_scores_all' pk=exam.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'longs:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'longs:exam_stats' exam_id=exam.pk %}">Stats</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'longs:exam_review_start' pk=exam.pk %}">Review</a></li>
</ul>
{% include 'generic/partials/exams/dropdown_menu.html'%}
</div>
</div>
</nav>
{% endblock %} {% endblock %}
+29
View File
@@ -48,3 +48,32 @@ class ExamMarkerForm(ExamMarkerFormMixin):
class ExamGroupsForm(ExamGroupsFormMixin): class ExamGroupsForm(ExamGroupsFormMixin):
class Meta(ExamGroupsFormMixin.Meta): class Meta(ExamGroupsFormMixin.Meta):
model = Exam model = Exam
class QuestionForm(ModelForm):
class Meta:
model = Question
fields = [
"stem",
"a",
"a_answer",
"a_feedback",
"b",
"b_answer",
"b_feedback",
"c",
"c_answer",
"c_feedback",
"d",
"d_answer",
"d_feedback",
"e",
"e_answer",
"e_feedback",
"category",
]
def __init__(self, *args, **kwargs):
# accept an optional user kwarg for parity with other apps
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)
-15
View File
@@ -58,21 +58,7 @@
- Also supports a manual `.dark-mode` class on body or a parent - Also supports a manual `.dark-mode` class on body or a parent
*/ */
:root {
--bg: #ffffff;
--text: #212529;
--muted: #6c757d;
--secondary: #6c757d;
--card-bg: #ffffff;
--card-border: #e9ecef;
--primary: #0d6efd;
--success: #198754;
--danger: #dc3545;
--feedback-bg: #f8f9fa;
--shadow: 0 1px 2px rgba(0,0,0,0.03);
}
@media (prefers-color-scheme: dark) {
:root { :root {
--bg: #050607; --bg: #050607;
--text: #e6eef6; --text: #e6eef6;
@@ -86,7 +72,6 @@
--shadow: 0 2px 6px rgba(0,0,0,0.6); --shadow: 0 2px 6px rgba(0,0,0,0.6);
} }
body, .exam-take-container { background: var(--bg); color: var(--text); } body, .exam-take-container { background: var(--bg); color: var(--text); }
}
/* Manual override: add `dark-mode` to body or a parent to force dark theme */ /* Manual override: add `dark-mode` to body or a parent to force dark theme */
body.dark-mode, .dark-mode { color: var(--text); background: var(--bg); } body.dark-mode, .dark-mode { color: var(--text); background: var(--bg); }
+23 -7
View File
@@ -1,11 +1,27 @@
{% extends 'physics/base.html' %} {% extends 'physics/base.html' %}
{% block navigation %} {% block navigation %}
{{block.super}} {{block.super}}
<br/> <nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
Exams: {{exam}}-> <a href="{% url 'physics:exam_overview' pk=exam.pk %}">Overview</a> / <div class="container-fluid px-3">
<a href="{% url 'physics:exam_scores_all' pk=exam.pk %}">Scores</a> / <span class="navbar-text me-3 text-white">
<a href="{% url 'physics:exam_cids' exam_id=exam.pk %}">Candidates</a> / <strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
<a href="{% url 'physics:exam_stats' exam_id=exam.pk %}">Stats</a> / </span>
<a href="{% url 'physics:exam_review_start' pk=exam.pk %}">Review</a> / <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="examNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'physics:exam_overview' pk=exam.pk %}">Overview</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'physics:exam_scores_all' pk=exam.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'physics:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'physics:exam_stats' exam_id=exam.pk %}">Stats</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'physics:exam_review_start' pk=exam.pk %}">Review</a></li>
</ul>
{% include 'generic/partials/exams/dropdown_menu.html'%}
</div>
</div>
</nav>
{% endblock %} {% endblock %}
@@ -0,0 +1,40 @@
{% extends "physics/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container py-3">
<div class="row">
<div class="col-12">
<h1>Add question</h1>
<form method="post" novalidate>
{% csrf_token %}
{{ form.media }}
{% if form.non_field_errors %}
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
{% endif %}
{% if form.helper %}
{% crispy form form.helper %}
{% else %}
{% for field in form %}
<div class="mb-3">
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}
<div class="form-text">{{ field.help_text }}</div>
{% endif %}
{% for error in field.errors %}
<div class="text-danger small">{{ error }}</div>
{% endfor %}
</div>
{% endfor %}
{% endif %}
<button type="submit" class="btn btn-primary">Save</button>
<a href="javascript:history.back()" class="btn btn-secondary">Cancel</a>
</form>
</div>
</div>
</div>
{% endblock %}
+2
View File
@@ -10,6 +10,8 @@ urlpatterns = []
urlpatterns.extend( urlpatterns.extend(
[ [
path("question/", views.QuestionView.as_view(), name="question_view"), path("question/", views.QuestionView.as_view(), name="question_view"),
path("question/create/", views.QuestionCreate.as_view(), name="question_create"),
path("question/create/exam/<int:pk>", views.QuestionCreate.as_view(), name="question_create_exam"),
path("question/<int:pk>/", views.question_detail, name="question_detail"), path("question/<int:pk>/", views.question_detail, name="question_detail"),
path("exam/<int:pk>/take_old", views.exam_take_old, name="exam_take_old"), path("exam/<int:pk>/take_old", views.exam_take_old, name="exam_take_old"),
path( path(
+57 -1
View File
@@ -56,7 +56,14 @@ from generic.views import (
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from .forms import ExamGroupsForm, ExamMarkerForm, UserAnswerForm, ExamAuthorForm, ExamForm from .forms import (
ExamGroupsForm,
ExamMarkerForm,
UserAnswerForm,
ExamAuthorForm,
ExamForm,
QuestionForm,
)
from .decorators import ( from .decorators import (
user_is_author_or_physics_checker, user_is_author_or_physics_checker,
user_is_exam_author_or_physics_checker, user_is_exam_author_or_physics_checker,
@@ -534,6 +541,55 @@ class QuestionView(
return context return context
class QuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
model = Question
form_class = QuestionForm
def get_form_kwargs(self):
kwargs = super(QuestionCreateBase, self).get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
# add the current user as an author
form.instance.author.add(self.request.user.id)
response = super().form_valid(form)
return response
class QuestionCreate(QuestionCreateBase):
def get_initial(self):
if "pk" in self.kwargs:
initial = super().get_initial()
exam = get_object_or_404(Exam, pk=self.kwargs["pk"])
# When creating a question from an exam context we don't have an
# 'exams' field on the ModelForm (exams is a reverse relation).
# Store nothing in the initial data here; attachment is handled
# in form_valid below.
return initial
def form_valid(self, form):
# Let the base class save the object and set the author
response = super().form_valid(form)
# If the create URL included an exam pk, add the new question to that exam
if "pk" in self.kwargs:
try:
exam = get_object_or_404(Exam, pk=self.kwargs["pk"])
# exam.exam_questions is a ManyToMany through relation; add is fine
exam.exam_questions.add(self.object)
except Exception:
# Don't fail the whole request if attaching to the exam fails;
# the question has already been created and author set.
pass
return response
class UserAnswerView(AuthorOrCheckerRequiredMixin, LoginRequiredMixin, DetailView): class UserAnswerView(AuthorOrCheckerRequiredMixin, LoginRequiredMixin, DetailView):
model = UserAnswer model = UserAnswer
+24 -9
View File
@@ -2,15 +2,30 @@
{% block navigation %} {% block navigation %}
{{block.super}} {{block.super}}
<br/> <nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
Exams: {{exam}}-> <div class="container-fluid px-3">
<a href="{% url 'rapids:exam_overview' pk=exam.pk %}">Overview</a> / <span class="navbar-text me-3 text-white">
<strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="examNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_overview' pk=exam.pk %}">Overview</a></li>
{% if exam.exam_mode %} {% if exam.exam_mode %}
<a href="{% url 'rapids:mark_overview' pk=exam.pk %}">Mark</a> / <li class="nav-item"><a class="nav-link" href="{% url 'rapids:mark_overview' pk=exam.pk %}">Mark</a></li>
<a href="{% url 'rapids:exam_scores_all' pk=exam.pk %}">Scores</a> / <li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_scores_all' pk=exam.pk %}">Scores</a></li>
<a href="{% url 'rapids:exam_cids' exam_id=exam.pk %}">Candidates</a> / <li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
<a href="{% url 'rapids:exam_stats' exam_id=exam.pk %}">Stats</a> / <li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_stats' exam_id=exam.pk %}">Stats</a></li>
{% endif %} {% endif %}
<a href="{% url 'rapids:question_create_exam' pk=exam.pk %}">Add New Question</a> <li class="nav-item"><a class="nav-link" href="{% url 'rapids:question_create_exam' pk=exam.pk %}">Add Question</a></li>
<a href="{% url 'rapids:exam_review_start' pk=exam.pk %}">Review</a> <li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_review_start' pk=exam.pk %}">Review</a></li>
</ul>
{% include 'generic/partials/exams/dropdown_menu.html'%}
</div>
</div>
</nav>
{% endblock %} {% endblock %}
+22 -7
View File
@@ -2,11 +2,26 @@
{% block navigation %} {% block navigation %}
{{block.super}} {{block.super}}
<br/> <nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
Exams: {{exam}}-> <div class="container-fluid px-3">
<a href="{% url 'sbas:exam_overview' pk=exam.pk %}">Overview</a> / <span class="navbar-text me-3 text-white">
<a href="{% url 'sbas:exam_scores_all' pk=exam.pk %}">Scores</a> / <strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
<a href="{% url 'sbas:exam_cids' exam_id=exam.pk %}">Candidates</a> / </span>
<a href="{% url 'sbas:exam_stats' exam_id=exam.pk %}">Stats</a> / <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
<a href="{% url 'sbas:exam_review_start' pk=exam.pk %}">Review</a> / <span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="examNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'sbas:exam_overview' pk=exam.pk %}">Overview</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'sbas:exam_scores_all' pk=exam.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'sbas:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'sbas:exam_stats' exam_id=exam.pk %}">Stats</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'sbas:exam_review_start' pk=exam.pk %}">Review</a></li>
</ul>
{% include 'generic/partials/exams/dropdown_menu.html'%}
</div>
</div>
</nav>
{% endblock %} {% endblock %}
+24 -9
View File
@@ -2,15 +2,30 @@
{% block navigation %} {% block navigation %}
{{block.super}} {{block.super}}
<br/> <nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
Exams: {{exam}}-> <div class="container-fluid px-3">
<a href="{% url 'shorts:exam_overview' pk=exam.pk %}">Overview</a> / <span class="navbar-text me-3 text-white">
<strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="examNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:exam_overview' pk=exam.pk %}">Overview</a></li>
{% if exam.exam_mode %} {% if exam.exam_mode %}
<a href="{% url 'shorts:mark_overview' pk=exam.pk %}">Mark</a> / <li class="nav-item"><a class="nav-link" href="{% url 'shorts:mark_overview' pk=exam.pk %}">Mark</a></li>
<a href="{% url 'shorts:exam_scores_all' pk=exam.pk %}">Scores</a> / <li class="nav-item"><a class="nav-link" href="{% url 'shorts:exam_scores_all' pk=exam.pk %}">Scores</a></li>
<a href="{% url 'shorts:exam_cids' exam_id=exam.pk %}">Candidates</a> / <li class="nav-item"><a class="nav-link" href="{% url 'shorts:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
<a href="{% url 'shorts:exam_stats' exam_id=exam.pk %}">Stats</a> / <li class="nav-item"><a class="nav-link" href="{% url 'shorts:exam_stats' exam_id=exam.pk %}">Stats</a></li>
{% endif %} {% endif %}
<a href="{% url 'shorts:question_create_exam' pk=exam.pk %}">Add New Question</a> <li class="nav-item"><a class="nav-link" href="{% url 'shorts:question_create_exam' pk=exam.pk %}">Add Question</a></li>
<a href="{% url 'shorts:exam_review_start' pk=exam.pk %}">Review</a> <li class="nav-item"><a class="nav-link" href="{% url 'shorts:exam_review_start' pk=exam.pk %}">Review</a></li>
</ul>
{% include 'generic/partials/exams/dropdown_menu.html'%}
</div>
</div>
</nav>
{% endblock %} {% endblock %}