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
+26 -12
View File
@@ -2,16 +2,30 @@
{% block navigation %}
{{block.super}}
<br/>
Exams: {{exam}}->
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a> /
{% if exam.exam_mode %}
<a href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a> /
<a href="{% url 'anatomy:mark2_overview' pk=exam.pk %}">Mark2</a> /
<a href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a> /
{% endif %}
<a href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a>
{% 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 %}
<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>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 %}
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark2_overview' pk=exam.pk %}">Mark2</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a></li>
{% endif %}
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a></li>
</ul>
{% include 'generic/partials/exams/dropdown_menu.html'%}
</div>
</div>
</nav>
{% 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">
<a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
<a href="{% url 'anatomy:question_clone' question.id %}" title="Clone the Question">Clone</a>
<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>
<a href="#"
onclick="return window.create_popup_window('{% url 'feedback_create' question_type='anatomy' pk=question.pk %}')">
Add Note</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#questionNav" aria-controls="questionNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
{% if request.user.is_superuser %}
<a href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}"
title="Edit the Question using the admin interface">Admin Edit</a>
<a href="{% url 'anatomy:question_user_answers' question.id %}" title="View user answers associated with this question">User answers</a>
{% endif %}
{% if exam %}
<div>
{% if previous > -1 %}
<a href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous question</a>
<div class="collapse navbar-collapse" id="questionNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% if exam %}
<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 %}
Viewing question as part of exam: <a href="{% url 'anatomy:exam_overview' exam.id %}">{{exam}}</a> [{{pos}}/{{exam_length}}]
{% if next %}
<a href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next question</a>
{% endif %}
</div>
{% endif %}
</div>
</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 %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-warning" href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}">
<i class="bi bi-tools me-2"></i> Admin Edit
</a>
</li>
<li>
<a class="dropdown-item text-muted" href="{% url 'anatomy:question_user_answers' question.id %}">
<i class="bi bi-people me-2"></i> User answers
</a>
</li>
{% endif %}
</ul>
</li>
</ul>
</div>
</div>
</nav>
+5 -22
View File
@@ -831,7 +831,7 @@ class BaseReportAnswerForm(ModelForm):
# "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)
self.fields["answer"].required = False
@@ -847,12 +847,12 @@ class BaseQuestionAnswerForm(ModelForm):
# "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)
# self.fields["json_answer"].schema = case_detail.question_schema
if case_detail.question_schema is not None:
# self.fields["json_answer"].schema = casedetail.question_schema
if casedetail.question_schema is not None:
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 = ""
@@ -1153,23 +1153,6 @@ class PriorCaseForm(Form):
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):
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:
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):
return json.dumps(self.question_schema)
@@ -1545,7 +1552,7 @@ class CaseDetail(models.Model):
return mark_safe(html)
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")
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:
unique_together = ("case_detail", "prior_case")
unique_together = ("casedetail", "prior_case")
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):
question = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
@@ -464,7 +464,7 @@
{% 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 %}
@@ -3,17 +3,9 @@
{% block content %}
<div>
{% 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>
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_details" %}
<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>
@@ -3,66 +3,67 @@
{% 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="{{casedetail.case.get_series_images_nested}}"
style="box-sizing: border-box; background: #222; width: 100vw; height: 800px; max-width: 1600px;"
data-auto-cache-stack="true"
{% if casedetail.default_viewerstate %}
data-viewerstate={{ casedetail.default_viewerstate }}
{% endif %}
<div id="root" class="dicom-viewer-root" data-images="{{case_detail.case.get_series_images_nested}}"
style="box-sizing: border-box; background: #222; width: 100vw; height: 800px; max-width: 1600px;"
data-auto-cache-stack="true"
{% if case_detail.default_viewerstate %}
data-viewerstate={{ case_detail.default_viewerstate }}
{% endif %}
></div>
<button id="save-viewerstate" type="button" class="btn btn-primary mt-3">
Save Viewer State
</button>
<button id="reset-viewerstate" type="button" class="btn btn-secondary mt-3 ms-2">
Reset to Default
</button>
<div id="viewerstate-save-response" class="mt-2"></div>
<button id="save-viewerstate" type="button" class="btn btn-primary mt-3">
Save Viewer State
</button>
<button id="reset-viewerstate" type="button" class="btn btn-secondary mt-3 ms-2">
Reset to Default
</button>
<div id="viewerstate-save-response" class="mt-2"></div>
<script>
document.getElementById('save-viewerstate').addEventListener('click', function() {
let viewerState = window.exportViewerState_root();
if (!viewerState) {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
return;
}
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token }}"
},
body: JSON.stringify({viewerstate: viewerState})
})
.then(response => response.text())
.then(html => {
document.getElementById('viewerstate-save-response').innerHTML = html;
})
.catch(error => {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error saving viewer state.</span>";
});
});
document.getElementById('reset-viewerstate').addEventListener('click', function() {
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token }}"
},
body: JSON.stringify({reset_viewerstate: true})
})
.then(response => response.text())
.then(html => {
document.getElementById('viewerstate-save-response').innerHTML = html;
<script>
document.getElementById('save-viewerstate').addEventListener('click', function() {
let viewerState = window.exportViewerState_root();
if (!viewerState) {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
return;
}
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",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token }}"
},
body: JSON.stringify({viewerstate: viewerState})
})
.then(response => response.text())
.then(html => {
document.getElementById('viewerstate-save-response').innerHTML = html;
})
.catch(error => {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error saving viewer state.</span>";
});
});
document.getElementById('reset-viewerstate').addEventListener('click', function() {
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",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token }}"
},
body: JSON.stringify({reset_viewerstate: true})
})
.then(response => response.text())
.then(html => {
document.getElementById('viewerstate-save-response').innerHTML = html;
// Optionally reload the page or viewer here if needed
})
.catch(error => {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error resetting viewer state.</span>";
});
});
</script>
})
.catch(error => {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error resetting viewer state.</span>";
});
});
</script>
{% endblock %}
@@ -4,106 +4,28 @@
{% block content %}
<div>
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_priors" %}
{% if previous %}
<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 %}
<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>
<div class="card mb-4">
<div class="card-body">
<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>
<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>
</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>
<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>
<div class="row g-3">
{% for case, added, relation, visibility in available_priors %}
<li class='
{% if added %}
added
{% else %}
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>
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility collection=collection %}
{% empty %}
<div class="col-12">
<div class="alert alert-info">No available prior cases found for the case.</div>
</div>
{% endfor %}
</ul>
</div>
@@ -4,21 +4,11 @@
{% block content %}
<div>
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_questions" %}
{% if previous %}
<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>
<h2>Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}">{{casedetail.case.title}}</a></h2>
<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 %}
{% if casedetail.question_schema is not None and not casedetail.question_answers %}
<div class="alert alert-danger" role="alert">
@@ -42,7 +32,7 @@
Import schema from:
<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"
>Collection questions</button>
<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
</summary>
<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><summary>Modify Questions</summary>
<div id="questions-container" style="margin-top: 20px;">
@@ -123,7 +113,7 @@
</form> {% endcomment %}
{% if case_detail.question_schema %}
{% if casedetail.question_schema %}
<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.
@@ -57,7 +57,7 @@
{% endif %}
{% if show_history %}
<div>
History: {{case_detail.get_history_pre|linebreaks}}
History: {{casedetail.get_history_pre|linebreaks}}
</div>
{% endif %}
@@ -66,9 +66,9 @@
<div>
{% 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 %}
<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 %}
</div>
@@ -78,9 +78,9 @@
{% if collection.show_ohif_viewer_link %}
<div>
{% 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 %}
<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 %}
</div>
{% endif %}
@@ -92,7 +92,7 @@
<div id="main_viewer" class="dicom-viewer-root"
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
data-auto-cache-stack="false"
data-named-stacks='{{case_detail.get_case_named_stacks}}'
data-named-stacks='{{casedetail.get_case_named_stacks}}'
></div>
</details>
@@ -3,10 +3,10 @@
{% block content %}
<h2>Case: {{ case.title }}</h2>
{{ case_detail.question_schema }}
<h2>Case: {{ case.title }}</h2>
{{ casedetail.question_schema }}
{{ case.question_answers }}
{{ case.question_answers }}
{% endblock %}
@@ -2,9 +2,9 @@
{% block content %}
<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">
<h4>Case: {{case_detail.case.title}}</h4>
<h4>Case: {{casedetail.case.title}}</h4>
Answers
<ul>
{% for answer in answers %}
@@ -3,15 +3,15 @@
{% 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 %}
<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 %}>
<h2>Case: {{ case_detail.case.title }}</h2>
<li id="case-detail-{{casedetail.pk}}"{% if casedetail.question_schema %} class="cases"{% endif %}>
<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>
<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>
<div class="form"></div>
{% else %}
+14 -14
View File
@@ -69,7 +69,7 @@
</div>
{# Hidden per-case question snippet to inject when case is loaded #}
<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>
{% if case.display_sets.all %}
<details class="displayset-detail"><summary>Display Sets:</summary>
@@ -212,14 +212,14 @@
let c = this.closest(".case-item");
console.log('clicked local', c)
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) || {};
$('#loading-case').show()
$("#open-viewer-local").addClass("flash-button");
$('#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-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
$("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
bc.postMessage({"type": "open",
"case" : c.dataset.case, "images": c.dataset.images,
@@ -239,15 +239,15 @@
let c = this;
console.log('clicked local', c)
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 annotations = JSON.parse(c.dataset.annotations) || {};
$('#loading-case').show()
$("#open-viewer-local").addClass("flash-button");
$('#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-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
$("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
bc.postMessage({"type": "open",
"case" : c.dataset.case, "images": c.dataset.images,
@@ -302,15 +302,15 @@
let c = $(this).closest(".case-item");
case_details = c.data('casejson');
console.log(case_details);
casedetails = c.data('casejson');
console.log(casedetails);
$('#loading-case').show()
$("#open-viewer").addClass("flash-button");
$('#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-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
$("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
console.log($(this).data('type'))
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">
{% 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 %}
{{ casedetail.case.series.count }} series
{% endif %}
@@ -1,14 +1,14 @@
<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>
</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>
</a>
{% 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 %}
<i class="bi bi-question-square text-success"></i>
{% else %}
@@ -18,7 +18,7 @@
{% endif %}
{% 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>
</a>
{% endif %}
@@ -1,10 +1,10 @@
{# Partial: render questions and answers as HTML. #}
{% if case_detail.question_schema %}
{% if casedetail.question_schema %}
<div class="collection-question-block">
<h4>Questions</h4>
<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>
<dd class="col-sm-8">
{% if prop.description %}
@@ -18,8 +18,8 @@
{% endif %}
{# Safely fetch the stored/example answer using the project's `get_item` filter. #}
{% if case_detail.question_answers %}
{% with correct=case_detail.question_answers|get_item:name %}
{% if casedetail.question_answers %}
{% with correct=casedetail.question_answers|get_item:name %}
<div class="mt-2">
<strong>Example / Correct:</strong>
{% if correct %}
@@ -37,7 +37,7 @@
{% if user_answer %}
{% if 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">
<strong>Your answer:</strong>
<span class="ms-2 {% if ua == correct %}text-success{% else %}text-danger{% endif %}">{{ ua }}</span>
@@ -18,22 +18,8 @@
</div>
</div>
{% if collection %}
<div class="text-end mb-2">
<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 %}
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>
{% if collection %}
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_view" %}
{% endif %}
+58 -6
View File
@@ -168,30 +168,56 @@ urlpatterns = [
),
path("exam/<int:pk>/groups", views.ExamGroupsUpdate.as_view(), name="exam_groups_edit"),
path(
"collection/<int:exam_id>/case/<int:case_id>/details",
"collection/<int:exam_id>/case/<int:case_number>/details",
views.collection_case_details,
name="collection_case_details",
),
# Backwards-compatible redirect from legacy case_id-style URLs
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,
name="collection_case_questions",
),
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,
name="collection_case_priors",
),
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,
name="collection_case_dicom_json",
),
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,
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(
"collection/<int:exam_id>/question_schemas",
views.collection_question_schemas,
@@ -272,26 +298,52 @@ urlpatterns = [
views.collection_case_view,
name="collection_case_view",
),
# legacy path that used case PK; redirect to canonical case_number URL
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,
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(
"collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>",
views.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(
"collection/<int:pk>/<int:case_number>/take/",
views.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(
"collection/<int:pk>/<int:case_number>/take/answers",
views.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(
"collection/<int:pk>/json_edit",
views.GenericExamViews.exam_json_edit,
+354 -128
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
def collection_case_priors(request, exam_id, case_id):
case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id)
def collection_case_priors(request, exam_id, case_number):
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:
# 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:
p = CasePrior.objects.get(
case_detail=case_detail, prior_case=request.POST["remove"]
prior_pk = request.POST["remove"]
try:
p = CasePrior.objects.get(casedetail=casedetail, prior_case=prior_pk)
p.delete()
except CasePrior.DoesNotExist:
# Already removed; continue and render the not-added card
pass
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,
)
p.delete()
return HttpResponse(f"Case removed")
return HttpResponse(html)
elif "prior_case_id" in request.POST:
if not request.POST["relation"]:
return HttpResponse(
"You need to enter text to describe the relationship between the cases"
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"])
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.prior_visibility = request.POST["prior_visibility"]
p.relation_text = request.POST.get("relation", "")
p.prior_visibility = request.POST.get("prior_visibility", "AL")
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:
return HttpResponse("False")
raise Http404()
return HttpResponse("False", status=400)
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 = {}
for prior in added_priors:
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()
@@ -2808,22 +2946,22 @@ def collection_case_priors(request, exam_id, case_id):
form = PriorCaseForm()
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)
next = collection.get_next_case(case_detail.case)
previous = collection.get_previous_case(casedetail.case)
next = collection.get_next_case(casedetail.case)
return render(
request,
"atlas/collection_case_priors.html",
{
"case_detail": case_detail,
"casedetail": casedetail,
"form": form,
#"model_form": model_form,
# "example_form": example_form,
"collection": collection,
"case": case_detail.case,
"case": casedetail.case,
"previous": previous,
"next": next,
"collection_length": case_count,
@@ -2836,9 +2974,17 @@ def collection_case_priors(request, exam_id, case_id):
@user_is_collection_author_or_atlas_editor
def collection_case_questions(request, exam_id, case_id):
case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id)
collection = case_detail.collection
# Support either case_number (index) or case_id (case PK)
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 not collection.collection_type == "QUE":
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
if request.POST.get("submit") == "answer":
# answers = request.POST
case_detail.question_answers = json.loads(request.POST.get("json_answer"))
case_detail.save()
casedetail.question_answers = json.loads(request.POST.get("json_answer"))
casedetail.save()
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
elif request.POST.get("submit") == "save":
form = CaseQuestionForm(request.POST, instance=case_detail)
form = CaseQuestionForm(request.POST, instance=casedetail)
if form.is_valid():
form.save()
# 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()
if (
case_detail.question_schema is not None
and case_detail.question_answers is not None
casedetail.question_schema is not None
and casedetail.question_answers is not None
):
question_keys = set(case_detail.question_schema["properties"].keys())
answer_keys = set(case_detail.question_answers.keys())
question_keys = set(casedetail.question_schema["properties"].keys())
answer_keys = set(casedetail.question_answers.keys())
answers = {}
for key, value in case_detail.question_answers.items():
for key, value in casedetail.question_answers.items():
if key in question_keys:
answers[key] = value
@@ -2879,35 +3025,35 @@ def collection_case_questions(request, exam_id, case_id):
answers[key] = ""
post_data["json_answer"] = json.dumps(answers)
case_detail.question_answers = answers
case_detail.save()
casedetail.question_answers = answers
casedetail.save()
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
else:
assert False
form = CaseQuestionForm(request.POST, instance=case_detail)
form = CaseQuestionForm(request.POST, instance=casedetail)
else:
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(
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
# post_data = None
# if case_detail.question_schema is not None and case_detail.question_answers is not None:
# question_keys = set(case_detail.question_schema["properties"].keys())
# answer_keys = set(case_detail.question_answers.keys())
# if casedetail.question_schema is not None and casedetail.question_answers is not None:
# question_keys = set(casedetail.question_schema["properties"].keys())
# answer_keys = set(casedetail.question_answers.keys())
# answers = {}
# for key, value in case_detail.question_answers.items():
# for key, value in casedetail.question_answers.items():
# if key in question_keys:
# answers[key] = value
@@ -2918,28 +3064,28 @@ def collection_case_questions(request, exam_id, case_id):
# post_data = request.POST.copy()
# post_data["json_answer"]= json.dumps(answers)
# case_detail.question_answers = answers
# example_form = JsonAnswerForm(post_data, question_schema=case_detail.question_schema)
# casedetail.question_answers = answers
# 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_detail.case, case_count=True
casedetail.case, case_count=True
)
previous = collection.get_previous_case(case_detail.case)
next = collection.get_next_case(case_detail.case)
previous = collection.get_previous_case(casedetail.case)
next = collection.get_next_case(casedetail.case)
return render(
request,
"atlas/collection_case_questions.html",
{
"case_detail": case_detail,
"casedetail": casedetail,
"form": form,
"example_form": example_form,
"collection": collection,
"case": case_detail.case,
"case": casedetail.case,
"previous": previous,
"next": next,
"collection_length": case_count,
@@ -2949,12 +3095,22 @@ def collection_case_questions(request, exam_id, case_id):
@user_is_collection_author_or_atlas_editor
def collection_case_details(request, exam_id, case_id):
case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id)
collection = case_detail.collection
# Accept either case_number (index into collection) or case_id (case PK).
casedetail = None
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":
form = CaseDetailForm(request.POST, instance=case_detail)
form = CaseDetailForm(request.POST, instance=casedetail)
if form.is_valid():
form.save()
if request.htmx:
@@ -2964,23 +3120,23 @@ def collection_case_details(request, exam_id, case_id):
if request.htmx:
return HttpResponse("Invalid form", status=400)
else:
form = CaseDetailForm(instance=case_detail)
form = CaseDetailForm(instance=casedetail)
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)
next = collection.get_next_case(case_detail.case)
previous = collection.get_previous_case(casedetail.case)
next = collection.get_next_case(casedetail.case)
return render(
request,
"atlas/collection_case_details.html",
{
"case_detail": case_detail,
"casedetail": casedetail,
"form": form,
"collection": collection,
"case": case_detail.case,
"case": casedetail.case,
"previous": previous,
"next": next,
"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()
case_details = CaseDetail.objects.filter(
casedetails = CaseDetail.objects.filter(
case__in=cases, collection=collection
).prefetch_related("case")
answers = CidReportAnswer.objects.filter(question__in=case_details)
answers = CidReportAnswer.objects.filter(question__in=casedetails)
case_map = {}
for cd in case_details:
for cd in casedetails:
case_map[cd] = []
for answer in answers:
@@ -3017,7 +3173,7 @@ def collection_mark_overview(request, pk):
{
"collection": collection,
"answers": answers,
"case_details": case_details,
"casedetails": casedetails,
"case_map": case_map,
},
)
@@ -3035,9 +3191,9 @@ def collection_mark_question(request, pk, case_number):
previous = case_number > 0
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 = []
if request.method == "POST":
@@ -3051,7 +3207,7 @@ def collection_mark_question(request, pk, case_number):
if form.is_valid():
answer = form.save(commit=False)
# answer.cid = cid
# answer.question = case_detail
# answer.question = casedetail
# answer.published_date = timezone.now()
answer.save()
@@ -3089,7 +3245,7 @@ def collection_mark_question(request, pk, case_number):
"collection": collection,
"series_list": series_list,
"answers": answers,
"case_detail": case_detail,
"casedetail": casedetail,
"case": case,
"case_number": case_number,
"answer_forms": answer_forms,
@@ -3172,13 +3328,13 @@ def collection_take_overview(
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:
answers = CidReportAnswer.objects.filter(cid=cid, question__in=case_details)
answers = CidReportAnswer.objects.filter(cid=cid, question__in=casedetails)
else:
answers = UserReportAnswer.objects.filter(
user=request.user, question__in=case_details
user=request.user, question__in=casedetails
)
answer_question_map = {}
@@ -3187,7 +3343,7 @@ def collection_take_overview(
question_answer_tuples = []
answer_count = 0
for cd in case_details:
for cd in casedetails:
self_review = cid_user_exam.selfreview_set.filter(case=cd.case)
if cd in answer_question_map and (
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
)
case_detail = CaseDetail.objects.get(case=case, collection=collection)
casedetail = CaseDetail.objects.get(case=case, collection=collection)
return render(
request,
@@ -3242,17 +3398,19 @@ def collection_case_view_take_answers(
{
"collection": collection,
"case": case,
"case_detail": case_detail,
"casedetail": casedetail,
},
)
def collection_case_view_take_user(request, pk: int, case_number: int):
return collection_case_view_take(request, pk, case_number)
def collection_case_view_take_user(request, pk, case_number=None, case_id=None):
# 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(
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
@@ -3272,6 +3430,21 @@ def collection_case_view_take(
"""
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
answer: None | CidReportAnswer | UserReportAnswer = None
@@ -3291,23 +3464,23 @@ def collection_case_view_take(
cid_user_exam.start_time = timezone.now()
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 collection.collection_type == "REP":
if cid is not None:
answer = case_detail.cidreportanswer_set.filter(cid=cid).first()
answer = casedetail.cidreportanswer_set.filter(cid=cid).first()
ReportAnswerForm = CidReportAnswerForm
else:
answer = case_detail.userreportanswer_set.filter(
answer = casedetail.userreportanswer_set.filter(
user=request.user
).first()
ReportAnswerForm = UserReportAnswerForm
elif collection.collection_type == "QUE":
if cid is not None:
answer = case_detail.cidreportanswer_set.filter(cid=cid).first()
answer = casedetail.cidreportanswer_set.filter(cid=cid).first()
ReportAnswerForm = CidQuestionAnswerForm
else:
answer = case_detail.userreportanswer_set.filter(
answer = casedetail.userreportanswer_set.filter(
user=request.user
).first()
ReportAnswerForm = UserQuestionAnswerForm
@@ -3325,9 +3498,9 @@ def collection_case_view_take(
if not answer and collection.collection_type in ("REP", "QUE"):
try:
if cid is not None:
answer = CidReportAnswer(question=case_detail, cid=cid)
answer = CidReportAnswer(question=casedetail, cid=cid)
else:
answer = UserReportAnswer(question=case_detail, user=request.user)
answer = UserReportAnswer(question=casedetail, user=request.user)
answer.started_at = timezone.now()
answer.save()
except Exception:
@@ -3340,10 +3513,10 @@ def collection_case_view_take(
if not collection.publish_results:
if answer:
form = ReportAnswerForm(
request.POST, instance=answer, case_detail=case_detail
request.POST, instance=answer, casedetail=casedetail
)
else:
form = ReportAnswerForm(request.POST, case_detail=case_detail)
form = ReportAnswerForm(request.POST, casedetail=casedetail)
if not cid_user_exam.completed and not (
answer is not None and answer.completed
@@ -3351,7 +3524,7 @@ def collection_case_view_take(
if form.is_valid():
answer = form.save(commit=False)
answer.set_cid_or_user(cid=cid, user=request.user)
answer.question = case_detail
answer.question = casedetail
# Record submission timestamp
answer.submitted_at = timezone.now()
# answer.published_date = timezone.now()
@@ -3422,7 +3595,7 @@ def collection_case_view_take(
)
else:
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")
@@ -3461,7 +3634,7 @@ def collection_case_view_take(
resources = case.caseresource_set.filter(pre_review=True)
prior_cases = case_detail.caseprior_set.all()
prior_cases = casedetail.caseprior_set.all()
series_to_load = []
@@ -3487,7 +3660,7 @@ def collection_case_view_take(
"form": form,
"collection": collection,
"case": case,
"case_detail": case_detail,
"casedetail": casedetail,
"series_list": series_list,
"series_to_load": series_to_load,
"case_number": case_number,
@@ -3511,7 +3684,7 @@ def collection_case_view_take(
@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)
Args:
@@ -3527,6 +3700,19 @@ def collection_case_view(request, pk, case_number):
"""
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
answer = None
@@ -3538,8 +3724,8 @@ def collection_case_view(request, pk, case_number):
series_list = case.series.all().prefetch_related("images", "examination", "plane")
previous = case_number > 0
next = case_number < (case_count - 1)
previous = collection.get_previous_case(casedetail.case)
next = collection.get_next_case(casedetail.case)
return render(
request,
@@ -3579,15 +3765,25 @@ def use_dates_as_descriptions(request, pk):
def collection_case_dicom_json_review(request, exam_id, case_id):
return collection_case_dicom_json(request, exam_id, case_id, review=True)
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:
priors = case_detail.caseprior_set.exclude(prior_visibility="NO")
priors = casedetail.caseprior_set.exclude(prior_visibility="NO")
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):
@@ -3608,12 +3804,12 @@ def delete_collection_cid_answers(request, exam_id, cid):
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
).prefetch_related("case")
cid_user_answers = CidReportAnswer.objects.filter(
question__in=case_details, cid=cid
question__in=casedetails, cid=cid
)
temp = {}
@@ -3630,11 +3826,11 @@ def delete_collection_answers(request, exam_id):
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
).prefetch_related("case")
cid_user_answers = CidReportAnswer.objects.filter(question__in=case_details)
cid_user_answers = CidReportAnswer.objects.filter(question__in=casedetails)
temp = {}
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()
case_details = CaseDetail.objects.filter(
casedetails = CaseDetail.objects.filter(
case__in=cases, collection=collection
).prefetch_related("case")
cid_user_answers = CidReportAnswer.objects.filter(question__in=case_details)
cid_user_answers = CidReportAnswer.objects.filter(question__in=casedetails)
cids = set()
@@ -4070,7 +4266,7 @@ def collection_question_schemas(request, exam_id: int):
questions = collection.cases.all().prefetch_related()
case_details = (
casedetails = (
CaseDetail.objects.filter(case__in=questions, collection=collection)
.prefetch_related("case")
.order_by("sort_order")
@@ -4081,7 +4277,7 @@ def collection_question_schemas(request, exam_id: int):
"atlas/collection_question_schemas.html",
{
"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)
# 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
for case in case_details:
for case in casedetails:
user_answers = case.userreportanswer_set.filter(user_id=user_id)
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)
# 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
for case in case_details:
for case in casedetails:
cid_answers = case.cidreportanswer_set.filter(cid=cid)
cid_answers.delete()
@@ -4241,10 +4437,10 @@ def collection_reset_answers(request, exam_id: int):
collection = get_object_or_404(CaseCollection, pk=exam_id)
# 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
for case in case_details:
for case in casedetails:
cid_answers = case.cidreportanswer_set.all()
cid_answers.delete()
@@ -4461,27 +4657,57 @@ def series_bulk_delete(request):
@user_is_collection_author_or_atlas_editor
@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.
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":
data = json.loads(request.body.decode("utf-8"))
if data.get("reset_viewerstate"):
case_detail.default_viewerstate = None
case_detail.save()
casedetail.default_viewerstate = None
casedetail.save()
return HttpResponse("<span class='text-success'>Viewer state reset to default.</span>")
viewerstate = data.get("viewerstate")
if viewerstate is not None:
case_detail.default_viewerstate = viewerstate
case_detail.save()
casedetail.default_viewerstate = viewerstate
casedetail.save()
return HttpResponse("<span class='text-success'>Viewer state saved.</span>")
else:
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", {
"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>
<h1 class="h4 mb-1">Exam: {{ exam }}</h1>
</div>
<div class="mt-2 mt-md-0">
{% comment %} <div class="mt-2 mt-md-0">
{% include "generic/exam_link_headers.html" %}
</div>
</div> {% endcomment %}
</div>
{% 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.super}}
<br/>
Exams: {{exam}}->
<a href="{% url 'longs:exam_overview' pk=exam.pk %}">Overview</a> /
<a href="{% url 'longs:mark_overview' pk=exam.pk %}">Mark</a> /
<a href="{% url 'longs:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'longs:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'longs:exam_stats' exam_id=exam.pk %}">Stats</a> /
<a href="{% url 'longs:exam_review_start' pk=exam.pk %}">Review</a> /
<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>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 '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 %}
+30 -1
View File
@@ -47,4 +47,33 @@ class ExamMarkerForm(ExamMarkerFormMixin):
class ExamGroupsForm(ExamGroupsFormMixin):
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)
+12 -27
View File
@@ -58,35 +58,20 @@
- 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 {
--bg: #050607;
--text: #e6eef6;
--muted: #9aa4ad;
--card-bg: #0f1720;
--card-border: #1f2a33;
--primary: #6ea8ff;
--success: #36d07a;
--danger: #ff6b6b;
--feedback-bg: #071322;
--shadow: 0 2px 6px rgba(0,0,0,0.6);
}
body, .exam-take-container { background: var(--bg); color: var(--text); }
:root {
--bg: #050607;
--text: #e6eef6;
--muted: #9aa4ad;
--card-bg: #0f1720;
--card-border: #1f2a33;
--primary: #6ea8ff;
--success: #36d07a;
--danger: #ff6b6b;
--feedback-bg: #071322;
--shadow: 0 2px 6px rgba(0,0,0,0.6);
}
body, .exam-take-container { background: var(--bg); color: var(--text); }
/* 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); }
+23 -7
View File
@@ -1,11 +1,27 @@
{% extends 'physics/base.html' %}
{% block navigation %}
{{block.super}}
<br/>
Exams: {{exam}}-> <a href="{% url 'physics:exam_overview' pk=exam.pk %}">Overview</a> /
<a href="{% url 'physics:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'physics:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'physics:exam_stats' exam_id=exam.pk %}">Stats</a> /
<a href="{% url 'physics:exam_review_start' pk=exam.pk %}">Review</a> /
{{block.super}}
<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>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 '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 %}
@@ -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(
[
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("exam/<int:pk>/take_old", views.exam_take_old, name="exam_take_old"),
path(
+57 -1
View File
@@ -56,7 +56,14 @@ from generic.views import (
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 (
user_is_author_or_physics_checker,
user_is_exam_author_or_physics_checker,
@@ -534,6 +541,55 @@ class QuestionView(
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):
model = UserAnswer
+26 -11
View File
@@ -2,15 +2,30 @@
{% block navigation %}
{{block.super}}
<br/>
Exams: {{exam}}->
<a href="{% url 'rapids:exam_overview' pk=exam.pk %}">Overview</a> /
{% if exam.exam_mode %}
<a href="{% url 'rapids:mark_overview' pk=exam.pk %}">Mark</a> /
<a href="{% url 'rapids:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'rapids:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'rapids:exam_stats' exam_id=exam.pk %}">Stats</a> /
{% endif %}
<a href="{% url 'rapids:question_create_exam' pk=exam.pk %}">Add New Question</a>
<a href="{% url 'rapids:exam_review_start' pk=exam.pk %}">Review</a>
<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>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 %}
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:mark_overview' pk=exam.pk %}">Mark</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_scores_all' pk=exam.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_stats' exam_id=exam.pk %}">Stats</a></li>
{% endif %}
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:question_create_exam' pk=exam.pk %}">Add Question</a></li>
<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 %}
+22 -7
View File
@@ -2,11 +2,26 @@
{% block navigation %}
{{block.super}}
<br/>
Exams: {{exam}}->
<a href="{% url 'sbas:exam_overview' pk=exam.pk %}">Overview</a> /
<a href="{% url 'sbas:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'sbas:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'sbas:exam_stats' exam_id=exam.pk %}">Stats</a> /
<a href="{% url 'sbas:exam_review_start' pk=exam.pk %}">Review</a> /
<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>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 '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 %}
+26 -11
View File
@@ -2,15 +2,30 @@
{% block navigation %}
{{block.super}}
<br/>
Exams: {{exam}}->
<a href="{% url 'shorts:exam_overview' pk=exam.pk %}">Overview</a> /
{% if exam.exam_mode %}
<a href="{% url 'shorts:mark_overview' pk=exam.pk %}">Mark</a> /
<a href="{% url 'shorts:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'shorts:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'shorts:exam_stats' exam_id=exam.pk %}">Stats</a> /
{% endif %}
<a href="{% url 'shorts:question_create_exam' pk=exam.pk %}">Add New Question</a>
<a href="{% url 'shorts:exam_review_start' pk=exam.pk %}">Review</a>
<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>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 %}
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:mark_overview' pk=exam.pk %}">Mark</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:exam_scores_all' pk=exam.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:exam_stats' exam_id=exam.pk %}">Stats</a></li>
{% endif %}
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:question_create_exam' pk=exam.pk %}">Add Question</a></li>
<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 %}