Compare commits

..
18 Commits
Author SHA1 Message Date
Ross 87dde70fd3 Enhance AnatomyQuestion string representation; improve formatting and handle missing attributes for better readability 2025-11-11 14:04:46 +00:00
Ross c69eecc1ec Refactor mark2 overview page layout; enhance question display and button functionality for improved user interaction 2025-11-11 14:02:41 +00:00
Ross 6c149bb2ae Add mark2 overview page and update navigation for improved marking workflow 2025-11-11 13:56:11 +00:00
Ross 1232c3af32 Comment out suggestion section for incorrect answers in the marking template 2025-11-11 13:49:10 +00:00
Ross 05a2b2357c Enhance word suggestion functionality for incorrect answers; improve button layout and toggle behavior for better user experience. 2025-11-11 13:47:12 +00:00
Ross d88a626a76 Implement quick add functionality for incorrect answers; return collapsed partial for quick suggestions in the answers table. 2025-11-11 13:42:34 +00:00
Ross e4bab4a509 Add suggestion management for incorrect answers; implement collapsed view and HTMX support for dynamic updates 2025-11-11 13:36:55 +00:00
Ross 6e7135823a Implement suggestion feature for incorrect answers; add HTMX support for dynamic candidate rendering and inline editing. 2025-11-11 13:21:32 +00:00
Ross a158a6f928 Refactor question detail layout for improved readability; enhance card structure and styling for better user experience. 2025-11-11 13:05:31 +00:00
Ross b79d95cd70 Refactor layout of marked answers section for improved responsiveness; update column classes for better display on various screen sizes. 2025-11-11 13:03:44 +00:00
Ross 796ac3fb84 Implement edit answer functionality with HTMX support; add new endpoint and template for editing stored answers in the marking UI. 2025-11-11 13:01:51 +00:00
Ross 14a54dfa8b Refactor answer click handling to prevent duplicate event listeners on the new mark2 page; streamline state management for answer marking. 2025-11-11 12:49:13 +00:00
Ross ac6a8193a3 Refactor answer list item actions to use right-floating icons for improved accessibility and user interaction; streamline code for marked and unmarked answers. 2025-11-11 12:02:22 +00:00
Ross 6752f03bf2 Enhance marking UI with new navigation buttons and clipboard functionality; improve answer display with tooltips for clarity 2025-11-11 12:00:04 +00:00
Ross 34ef5106f4 Add new endpoints and templates for displaying marked answers in the improved marking UI 2025-11-11 11:53:35 +00:00
Ross 1997d013d8 Implement improved marking UI with new endpoints and templates for enhanced user experience 2025-11-11 11:34:05 +00:00
Ross 9a9ce11ccb . 2025-11-11 11:12:36 +00:00
Ross 9ebcd24938 Refactor image link paths in forms to ensure consistent static file referencing across anatomy, atlas, longs, rapids, and shorts templates. 2025-11-11 11:12:03 +00:00
24 changed files with 2220 additions and 771 deletions
+10 -8
View File
@@ -132,14 +132,16 @@ class AnatomyQuestion(QuestionBase):
return "anatomy"
def __str__(self):
# Get first answer
return "{}/{}: {} [{}, {}]".format(
self.pk,
self.question_type,
self.get_primary_answer(),
self.modality,
self.structure,
)
# Nicely formatted representation
pk = self.pk or ""
qtype = str(self.question_type) if self.question_type else "N/A"
primary = self.get_primary_answer() or "None"
modality = str(self.modality) if self.modality else "Unknown"
structure = str(self.structure) if self.structure else "Unknown"
title = self.get_title() if hasattr(self, "get_title") else (self.description or "")
if title and len(title) > 60:
title = title[:57] + "..."
return f"{title} (Type: {qtype}) — Answer: {primary} — Modality: {modality}; Structure: {structure}"
def get_link(self):
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self)
+5 -2
View File
@@ -44,6 +44,10 @@ $(document).ready(function () {
// $(element).append($("<span class='google-link' title='search for answer with google'><a href='https://www.google.com/search?q=" + $(element).text() + "' target='_blank'>G</a></span>"));
//});
// Legacy per-element click handler: skip on the new mark2 page which
// manages clicks via a delegated handler (it uses #answer-list). This
// prevents duplicate/colliding handlers when mark2 is rendered.
if (!document.getElementById('answer-list')) {
$(".answer-list span.answer").each(function (index, element) {
console.log(element);
@@ -58,10 +62,9 @@ $(document).ready(function () {
prepAnswerData();
});
});
}
prepAnswerData();
if ($(".post-form").length > 0) {
@@ -237,9 +237,9 @@
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %}
<a href="/anatomy/examination/create" id="add_examination" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
{{ form|crispy }}
<div id="drop-container" class="drop-target">Drop image here
+1
View File
@@ -7,6 +7,7 @@
<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> /
+2 -2
View File
@@ -20,7 +20,7 @@
</div>
</details>
{% endif %}
<details>
{% comment %} <details>
<summary>Suggest incorrect answers:</summary>
{% for word in words_suggest_incorrect %}
@@ -43,7 +43,7 @@
<div class="border border-secondary">
{% include "anatomy/question_detail.html#suggest-incorrect-form" %}
</div>
</details>
</details> {% endcomment %}
+344
View File
@@ -0,0 +1,344 @@
{% extends 'anatomy/exams.html' %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="card-title mb-1">Marking question {{ question_details.current }} of {{ question_details.total }}</h5>
<div class="mb-2">
<a class="btn btn-sm btn-outline-secondary me-1" href="{% url 'anatomy:question_detail' question.id %}" title="View the Question">View</a>
<a class="btn btn-sm btn-outline-secondary me-1" href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
{% if request.user.is_superuser %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}" title="Admin Edit">Admin Edit</a>
{% endif %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'anatomy:mark' exam.id question_number %}">Classic mark</a>
</div>
<h6 class="mb-1">{{ question.question_type }}</h6>
{% if question.structure %}
<div class="small text-muted">Structure: {{ question.structure }}</div>
{% endif %}
<div class="mt-2">Primary answer: <span id="primary-answer" title="The primary answer of the question">{{ question.get_primary_answer }}</span></div>
</div>
<div class="text-end">
{% if question.answer_help %}
<button class="btn btn-sm btn-outline-info" data-bs-toggle="collapse" data-bs-target="#marking-help">Answer Help</button>
{% endif %}
</div>
</div>
{% if question.answer_help %}
<div class="collapse mt-2" id="marking-help">
<div class="card card-body">
{{ question.answer_help|safe }}
</div>
</div>
{% endif %}
<div class="mt-3">
<form method="POST" class="post-form">{% csrf_token %}
<p class="small text-muted">Click each answer to toggle through marks awarded (as per colour). This UI writes marks immediately.</p>
<div class="d-flex justify-content-between align-items-center mb-2">
<div></div>
<div>
{% if question_details.current > 1 %}
<button type="submit" name="previous" class="btn btn-outline-secondary btn-sm me-1">Previous</button>
{% endif %}
<button type="submit" name="save" class="btn btn-primary btn-sm me-1">Save</button>
{% if question_details.current < question_details.total %}
<button type="submit" name="next" class="btn btn-outline-primary btn-sm me-1">Next</button>
{% endif %}
</div>
</div>
<div class="mb-2">
<button class="btn btn-sm btn-outline-info me-1" hx-get="{% url 'anatomy:mark2_exam_marked' exam.id question_number %}" hx-target="#exam-marked-container" hx-swap="innerHTML">Show exam-submitted answers</button>
<button class="btn btn-sm btn-outline-info" hx-get="{% url 'anatomy:mark2_question_marked' question.pk %}" hx-target="#question-marked-container" hx-swap="innerHTML">Show all stored answers for question</button>
</div>
<div class="row">
<div class="col-12">
<h6>Answers</h6>
<ul id="answer-list" class="list-group answer-list mb-3">
{# Render currently-stored marked answers first, then unmarked submissions #}
{% include 'anatomy/partials/mark2_marked_list_fragment.html' %}
{% include 'anatomy/partials/mark2_unmarked_list_fragment.html' %}
</ul>
<div id="exam-marked-container" class="mt-2"></div>
<div id="question-marked-container" class="mt-2"></div>
</div>
</div>
<div class="mt-3">
<div class="small text-muted key">Key: <span class="key-item correct">2 Marks</span>, <span class="key-item half-correct">1 Mark</span>, <span class="key-item incorrect">0 Marks</span></div>
</div>
<span class="visually-hidden">{{ form.as_p }}</span>
</form>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div id="single-dicom-viewer" class="marking-dicom" data-images="{{ question.get_image_url_array }}" data-annotations='{{ question.get_image_annotations }}'></div>
</div>
</div>
{% endblock %}
{% block css %}
<style>
/* Ensure answer states are visible and clickable in the new layout */
.answer { cursor: pointer; }
/* Use border to indicate mark state instead of background */
.answer.correct { color: #155724; background-color: transparent; }
.answer.half-correct { color: #856404; background-color: transparent; }
.answer.incorrect { color: #721c24; background-color: transparent; }
.answer.not-marked { color: inherit; background-color: transparent; }
.answer-list .list-group-item pre { margin: 0; font-family: monospace; white-space: pre-wrap; }
.mark-controls { display:flex; gap:0.25rem; }
.mark-btn { padding: 0.15rem 0.4rem; font-size: 0.8rem; }
/* Per-state border indicators on the list item */
.marked-item { }
.marked-correct { border-left: 4px solid #2a9d3f; }
.marked-half-correct { border-left: 4px solid #ffb020; }
.marked-incorrect { border-left: 4px solid #d3413a; }
/* Key items use a small left-border swatch to match the list markers */
.key .key-item { display:inline-block; padding-left:8px; margin-left:6px; }
.key .key-item.correct { border-left: 8px solid #2a9d3f; }
.key .key-item.half-correct { border-left: 8px solid #ffb020; }
.key .key-item.incorrect { border-left: 8px solid #d3413a; }
/* Right-floating action icons for answer external links */
.answer-actions { min-width: 6rem; }
.answer-actions .action-icon { margin-left: 0.5rem; color: #6c757d; text-decoration: none; border: none; background: none; }
.answer-actions .action-icon:hover { color: #000; text-decoration: none; }
.answer-actions .copy-to-clipboard { cursor: pointer; }
/* Active state for numeric mark buttons */
.mark-btn.active { background-color: #0d6efd; color: #fff; border-color: #0d6efd; }
</style>
{% endblock %}
{% block js %}
<script>
(function(){
// CSRF helper for fetch
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
// Delegated click handler for answers — only updates local state.
document.addEventListener('click', function(e){
// Ignore clicks on action icons or mark controls
if (e.target.closest('.answer-actions') || e.target.closest('.mark-controls') || e.target.closest('.copy-to-clipboard')) return;
// Prefer a clicked span.answer if present
let spanEl = e.target.closest('span.answer');
// Otherwise fall back to the list-item's span
if (!spanEl) {
const li = e.target.closest('#answer-list li');
if (!li) return;
spanEl = li.querySelector('span.answer');
if (!spanEl) return;
}
// Ensure span has an explicit state class
if (!spanEl.classList.contains('correct') && !spanEl.classList.contains('half-correct') && !spanEl.classList.contains('incorrect') && !spanEl.classList.contains('not-marked')) {
spanEl.classList.add('not-marked');
}
// cycle states: not-marked -> correct -> half-correct -> incorrect -> not-marked
const states = ['not-marked', 'correct', 'half-correct', 'incorrect'];
let currentState = 'not-marked';
for (let p of spanEl.classList) {
if (states.includes(p)) { currentState = p; break; }
}
let idx = (states.indexOf(currentState) + 1) % states.length;
const newState = states[idx];
// map to numeric for setMarkForSpan
let numeric = null;
if (newState === 'correct') numeric = 2;
else if (newState === 'half-correct') numeric = 1;
else if (newState === 'incorrect') numeric = 0;
// delegate to setMarkForSpan to keep behaviour consistent (updates classes, active buttons, hidden field)
setMarkForSpan(spanEl, numeric);
}, false);
function updateMarkedAnswersField() {
const marked = { 'correct': [], 'half-correct': [], 'incorrect': [] };
document.querySelectorAll('#answer-list span.answer').forEach(function(el) {
if (el.classList.contains('correct')) marked.correct.push((el.title || el.textContent).trim());
else if (el.classList.contains('half-correct')) marked['half-correct'].push((el.title || el.textContent).trim());
else if (el.classList.contains('incorrect')) marked.incorrect.push((el.title || el.textContent).trim());
});
// Ensure the hidden input exists
let hidden = document.getElementById('id_marked_answers');
if (!hidden) {
hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'marked_answers';
hidden.id = 'id_marked_answers';
const form = document.querySelector('form.post-form');
if (form) form.appendChild(hidden);
}
hidden.value = JSON.stringify(marked);
}
// Enhance list items with numeric mark controls (2,1,0,Clear)
function attachNumericControls() {
function makeButton(label, cls) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'btn btn-sm btn-outline-secondary me-1 mark-btn ' + cls;
b.textContent = label;
return b;
}
document.querySelectorAll('.answer-list li').forEach(function(li) {
if (li.querySelector('.mark-controls')) return; // already attached
const span = li.querySelector('span.answer');
if (!span) return;
// Ensure span has an explicit default state class so logic is consistent
if (!span.classList.contains('correct') && !span.classList.contains('half-correct') && !span.classList.contains('incorrect') && !span.classList.contains('not-marked')) {
span.classList.add('not-marked');
}
const controls = document.createElement('div');
controls.className = 'mark-controls mt-2';
const b2 = makeButton('2', 'mark-2');
const b1 = makeButton('1', 'mark-1');
const b0 = makeButton('0', 'mark-0');
const bClear = makeButton('', 'mark-clear');
controls.appendChild(b2);
controls.appendChild(b1);
controls.appendChild(b0);
controls.appendChild(bClear);
// append after the pre
li.appendChild(controls);
// wire clicks
b2.addEventListener('click', function(){ setMarkForSpan(span, 2); });
b1.addEventListener('click', function(){ setMarkForSpan(span, 1); });
b0.addEventListener('click', function(){ setMarkForSpan(span, 0); });
bClear.addEventListener('click', function(){ setMarkForSpan(span, null); });
// set initial active button based on existing state
(function(){
let state = null;
if (span.classList.contains('correct')) state = 2;
else if (span.classList.contains('half-correct')) state = 1;
else if (span.classList.contains('incorrect')) state = 0;
// clear any pre-existing active flags then set the correct one
[b2, b1, b0, bClear].forEach(b => b.classList.remove('active'));
if (state === 2) b2.classList.add('active');
else if (state === 1) b1.classList.add('active');
else if (state === 0) b0.classList.add('active');
else bClear.classList.add('active');
})();
});
}
function setMarkForSpan(el, numeric) {
// numeric: 2 -> correct, 1 -> half-correct, 0 -> incorrect, null -> not-marked
const li = el.closest('li');
let state = 'not-marked';
if (numeric === 2) state = 'correct';
else if (numeric === 1) state = 'half-correct';
else if (numeric === 0) state = 'incorrect';
el.className = 'answer ' + state;
if (li) {
// clear previous per-state classes
li.classList.remove('marked-correct','marked-half-correct','marked-incorrect','marked-item');
if (state === 'not-marked') {
// nothing
} else if (state === 'correct') {
li.classList.add('marked-item','marked-correct');
} else if (state === 'half-correct') {
li.classList.add('marked-item','marked-half-correct');
} else if (state === 'incorrect') {
li.classList.add('marked-item','marked-incorrect');
}
}
// update active state of numeric controls for this list item
try {
const li = el.closest('li');
if (li) {
const btn2 = li.querySelector('.mark-2');
const btn1 = li.querySelector('.mark-1');
const btn0 = li.querySelector('.mark-0');
const btnClear = li.querySelector('.mark-clear');
[btn2, btn1, btn0, btnClear].forEach(b => { if (b) { b.classList.remove('active'); } });
if (numeric === 2 && btn2) btn2.classList.add('active');
else if (numeric === 1 && btn1) btn1.classList.add('active');
else if (numeric === 0 && btn0) btn0.classList.add('active');
else if (numeric === null && btnClear) btnClear.classList.add('active');
}
} catch (err) {
// ignore
}
updateMarkedAnswersField();
}
// Run once on load to add controls. Also re-run after HTMX swaps so controls
// appear when partials are replaced.
function initMark2() {
attachNumericControls();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initMark2);
} else {
initMark2();
}
// If HTMX is used to update parts of the page, re-run attachNumericControls
// after swaps so newly inserted list items get controls.
document.addEventListener('htmx:afterSwap', function(evt) {
initMark2();
});
// Ensure the hidden marked_answers field is updated before any form submit
const postForm = document.querySelector('form.post-form');
if (postForm) {
postForm.addEventListener('submit', function(e){
try { updateMarkedAnswersField(); } catch (err) { /* still submit */ }
});
}
// Copy-to-clipboard handler for dropdown items
document.addEventListener('click', function(e){
const btn = e.target.closest('.copy-to-clipboard');
if (!btn) return;
const text = btn.getAttribute('data-text');
if (!text) return;
try {
navigator.clipboard.writeText(text);
// simple visual feedback: change the button text briefly
const prev = btn.textContent;
btn.textContent = 'Copied';
setTimeout(function(){ btn.textContent = prev; }, 1200);
} catch (err) {
console.warn('copy failed', err);
}
});
})();
</script>
{% endblock %}
@@ -0,0 +1,80 @@
{% extends 'anatomy/exams.html' %}
{% block content %}
<div class="container my-4 anatomy">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 class="h4 mb-1">Mark2 — Marking: {{ exam }}</h2>
<p class="small text-muted mb-0">Use the improved HTMX marking UI by opening a question below.</p>
</div>
<div class="text-end">
<div class="btn-group me-2" role="group" aria-label="listing actions">
<button class="btn btn-sm btn-outline-secondary show-all-button">Show all</button>
<button class="btn btn-sm btn-outline-secondary show-unmarked-button">Show unmarked</button>
</div>
<a href="{% url 'anatomy:mark2' exam_pk=exam.pk sk=0 %}" class="btn btn-sm btn-primary">Start mark2</a>
</div>
</div>
<div class="card">
<div class="card-body p-2">
<ul class="list-group list-group-flush" id="question-mark-list">
{% for question, unmarked_count, unmarked_count2 in question_unmarked_map %}
<li class="list-group-item d-flex justify-content-between align-items-start" data-unmarked="{{ unmarked_count }}">
<div class="me-3">
<a href="{% url 'anatomy:mark2' exam_pk=exam.pk sk=forloop.counter0 %}" class="fw-semibold">Question {{ forloop.counter }}:</a>
<div class="small text-truncate" style="max-width:60vw;">{{ question }}</div>
</div>
<div class="text-end">
<div class="small text-muted">Unmarked</div>
{% if unmarked_count > 0 %}
<span class="badge bg-danger">{{ unmarked_count }}</span>
{% else %}
<span class="badge bg-secondary">{{ unmarked_count }}</span>
{% endif %}
</div>
</li>
{% empty %}
<li class="list-group-item small text-muted">No questions found.</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<script>
(function(){
function $(sel, ctx){ return (ctx||document).querySelector(sel); }
function $all(sel, ctx){ return Array.from((ctx||document).querySelectorAll(sel)); }
var showAllBtn = $('.show-all-button');
var showUnmarkedBtn = $('.show-unmarked-button');
var list = $('#question-mark-list');
if (!list) return;
function showAll(){
$all('#question-mark-list .list-group-item').forEach(function(li){ li.classList.remove('d-none'); });
showAllBtn && showAllBtn.classList.add('active');
showUnmarkedBtn && showUnmarkedBtn.classList.remove('active');
}
function showUnmarked(){
$all('#question-mark-list .list-group-item').forEach(function(li){
var u = parseInt(li.getAttribute('data-unmarked') || '0', 10);
if (u > 0) li.classList.remove('d-none'); else li.classList.add('d-none');
});
showUnmarkedBtn && showUnmarkedBtn.classList.add('active');
showAllBtn && showAllBtn.classList.remove('active');
}
if (showAllBtn) showAllBtn.addEventListener('click', function(e){ e.preventDefault(); showAll(); });
if (showUnmarkedBtn) showUnmarkedBtn.addEventListener('click', function(e){ e.preventDefault(); showUnmarked(); });
// Initialize to show all
showAll();
})();
</script>
<style>
.show-all-button.active, .show-unmarked-button.active { box-shadow: inset 0 -2px 0 rgba(0,0,0,0.08); }
</style>
{% endblock %}
@@ -0,0 +1,35 @@
<div class="card mt-2">
<div class="card-body">
<h6 class="card-title">Edit stored answer</h6>
<form hx-post="{% url 'anatomy:mark2_edit_answer' %}" hx-target="closest .card" hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="question_pk" value="{{ question.pk }}">
<input type="hidden" name="original" value="{{ original }}">
{% if exam_pk %}
<input type="hidden" name="exam_pk" value="{{ exam_pk }}">
{% endif %}
{% if sk is not None %}
<input type="hidden" name="sk" value="{{ sk }}">
{% endif %}
<div class="mb-2">
<label class="form-label">Original</label>
<pre class="small bg-light p-2">{{ original }}</pre>
</div>
<div class="mb-2">
<label class="form-label">New answer text</label>
<textarea class="form-control" name="new_answer" rows="3">{% if existing %}{{ existing.answer }}{% else %}{{ original }}{% endif %}</textarea>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
{% if exam_pk and sk is not None %}
<button type="button" class="btn btn-outline-secondary btn-sm" hx-get="{% url 'anatomy:mark2_exam_marked' exam_pk sk %}" hx-swap="outerHTML" hx-target="closest .card">Cancel</button>
{% else %}
<button type="button" class="btn btn-outline-secondary btn-sm" _="on click remove .card">Cancel</button>
{% endif %}
</div>
</form>
</div>
</div>
@@ -0,0 +1,82 @@
{% comment %}List of distinct answers submitted within this exam for the question, with counts and mark badges{% endcomment %}
<div class="card mt-2">
<div class="card-body">
<style>
/* Hide edit buttons and mark-action forms by default; show only when card has editing-enabled */
.card .edit-btn { display: none !important; }
.card.editing-enabled .edit-btn { display: inline-block !important; }
.card .mark-actions { display: none !important; }
.card.editing-enabled .mark-actions { display: inline-block !important; }
</style>
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="card-title mb-0">Answers submitted in this exam</h6>
<div class="d-flex gap-2 align-items-center">
<div class="btn-group btn-group-sm" role="group" aria-label="Order">
<button class="btn btn-sm btn-outline-primary{% if request.GET.order == 'count' or not request.GET.order %} active{% endif %}" hx-get="{{ request.path }}?order=count" hx-swap="outerHTML" hx-target="closest .card">By count</button>
<button class="btn btn-sm btn-outline-primary{% if request.GET.order == 'mark' %} active{% endif %}" hx-get="{{ request.path }}?order=mark" hx-swap="outerHTML" hx-target="closest .card">By score</button>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="this.closest('.card').classList.toggle('editing-enabled'); this.textContent = this.closest('.card').classList.contains('editing-enabled') ? 'Disable editing' : 'Enable editing'">Enable editing</button>
</div>
</div>
{% if not items %}
<div class="small text-muted">No answers submitted in this exam.</div>
{% else %}
<ul class="list-group">
{% for it in items %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div class="flex-grow-1 me-3"><pre class="mb-0">{{ it.answer }}</pre></div>
<div class="text-end d-flex align-items-center gap-2">
{# Stored mark badge #}
{% if it.mark == 'correct' %}
<span class="badge bg-success me-1" title="Stored answer marked as correct (2)">2</span>
{% elif it.mark == 'half-correct' %}
<span class="badge bg-warning text-dark me-1" title="Stored answer marked as half-correct (1)">1</span>
{% elif it.mark == 'incorrect' %}
<span class="badge bg-danger me-1" title="Stored answer marked as incorrect (0)">0</span>
{% else %}
<span class="badge bg-secondary me-1" title="No stored mark for this answer">-</span>
{% endif %}
{# Submission count badge #}
<span class="badge bg-light text-dark me-2" title="Submitted {{ it.count }} times">{{ it.count }}</span>
<form hx-post="{% url 'anatomy:mark2_toggle_answer' %}" hx-target="closest .card" hx-swap="outerHTML" class="d-inline mark-actions">
{% csrf_token %}
<input type="hidden" name="question_pk" value="{{ question.pk }}">
<input type="hidden" name="answer" value="{{ it.answer }}">
<input type="hidden" name="exam_pk" value="{{ exam.pk }}">
<input type="hidden" name="sk" value="{{ sk }}">
<button class="btn btn-sm btn-outline-success" name="newmark" value="correct" title="Mark as correct (2)">2</button>
<button class="btn btn-sm btn-outline-warning text-dark" name="newmark" value="half-correct" title="Mark as half-correct (1)">1</button>
<button class="btn btn-sm btn-outline-danger" name="newmark" value="incorrect" title="Mark as incorrect (0)">0</button>
<button class="btn btn-sm btn-outline-secondary" name="newmark" value="clear" title="Clear stored mark"></button>
</form>
<button class="btn btn-sm btn-outline-secondary edit-btn" hx-get="{% url 'anatomy:mark2_edit_answer' %}?question_pk={{ question.pk }}&original={{ it.answer|urlencode }}&exam_pk={{ exam.pk }}&sk={{ sk }}" hx-target="closest .card" hx-swap="outerHTML" title="Edit stored answer">Edit</button>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
<script>
// Keep Edit buttons hidden by default; reveal them when the card receives
// the 'editing-enabled' class (toggled by the Enable editing button).
(function(){
document.querySelectorAll('.card').forEach(function(card){
function update(){
const enable = card.classList.contains('editing-enabled');
card.querySelectorAll('.edit-btn').forEach(function(b){
b.classList.toggle('d-none', !enable);
});
}
// run once in case HTMX inserted the fragment
update();
// observe class changes on the card to re-run update
const obs = new MutationObserver(update);
obs.observe(card, { attributes: true, attributeFilter: ['class'] });
});
})();
</script>
@@ -0,0 +1,43 @@
{% comment %}Renders only the list items for already-marked answers (used as a partial){% endcomment %}
{% for answer in correct_answers %}
{% with answer_url=answer|urlencode:'' %}
<li class="list-group-item d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer correct" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% endwith %}
{% endfor %}
{% for answer in half_mark_answers %}
{% with answer_url=answer|urlencode:'' %}
<li class="list-group-item d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer half-correct" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% endwith %}
{% endfor %}
{% for answer in incorrect_answers %}
{% with answer_url=answer|urlencode:'' %}
<li class="list-group-item d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer incorrect" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% endwith %}
{% endfor %}
@@ -0,0 +1,43 @@
{% comment %}All stored Answer choices for this question grouped by status{% endcomment %}
<div class="card mt-2">
<div class="card-body">
<h6 class="card-title">All stored answers for this question</h6>
<style>
/* Allow stored answers to wrap long lines and break words so they are visible
in narrow columns. Also ensure the lists stack on small screens. */
.card .list-group-item pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
</style>
<div class="row">
<div class="col-12 col-lg-4">
<h6>2 marks</h6>
<ul class="list-group mb-2">
{% for answer in correct_answers %}
<li class="list-group-item"><pre class="mb-0">{{ answer.answer }}</pre></li>
{% empty %}
<li class="list-group-item small text-muted">None</li>
{% endfor %}
</ul>
</div>
<div class="col-12 col-lg-4">
<h6>1 mark</h6>
<ul class="list-group mb-2">
{% for answer in half_mark_answers %}
<li class="list-group-item"><pre class="mb-0">{{ answer.answer }}</pre></li>
{% empty %}
<li class="list-group-item small text-muted">None</li>
{% endfor %}
</ul>
</div>
<div class="col-12 col-lg-4">
<h6>0 marks</h6>
<ul class="list-group mb-2">
{% for answer in incorrect_answers %}
<li class="list-group-item"><pre class="mb-0">{{ answer.answer }}</pre></li>
{% empty %}
<li class="list-group-item small text-muted">None</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
@@ -0,0 +1,28 @@
{% comment %}Renders only the list items for unmarked answers (used as a partial){% endcomment %}
{% for answer in user_answers %}
{% with answer_url=answer|urlencode:'' %}
{% if answer in answer_suggest_incorrect %}
<li class="list-group-item d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer incorrect suggest" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% else %}
<li class="list-group-item d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer not-marked" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% endif %}
{% endwith %}
{% endfor %}
@@ -0,0 +1,16 @@
<div id="incorrect-answers" class="small text-muted">
<strong>Answer suggest incorrect:</strong>
<div class="mt-2">
{% if question.answer_suggest_incorrect %}
{% for s in question.answer_suggest_incorrect %}
<span class="badge bg-secondary me-1 mb-1">{{ s }}</span>
{% endfor %}
{% else %}
<span class="small text-muted">No suggestions</span>
{% endif %}
</div>
<div class="mt-1">
<button id="answer-suggest-incorrect-button" class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="innerHTML">Edit suggestions</button>
</div>
</div>
@@ -0,0 +1,48 @@
<div class="card mt-2">
<div class="card-body">
<h6 class="card-title">Suggest incorrect answers</h6>
<form hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="outerHTML">
{% csrf_token %}
<div class="mb-2">
<label class="form-label">Current suggestions</label>
<div class="mb-2">
{% for s in question.answer_suggest_incorrect %}
<button type="button" class="btn btn-sm btn-outline-secondary me-1 mb-1" hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-vals='{"remove": "{{ s|escapejs }}"}' hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-target="closest .card" hx-swap="outerHTML">{{ s }} <span class="text-danger ms-1">&times;</span></button>
{% empty %}
<div class="small text-muted">No suggestions</div>
{% endfor %}
</div>
<div class="d-flex gap-2 align-items-center">
<input id="new-suggest" name="suggestion" class="form-control form-control-sm" placeholder="Add suggestion" />
<button type="button" class="btn btn-sm btn-primary" hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-include="#new-suggest" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-target="closest .card" hx-swap="outerHTML">Add</button>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<button type="submit" name="close" value="1" class="btn btn-primary btn-sm">Close</button>
</div>
</form>
{% if candidates %}
<hr/>
<h6 class="mt-3">Suggested candidates</h6>
<div class="small text-muted mb-2">These are frequent submitted answers that might be suitable to treat as incorrect suggestions.</div>
<ul class="list-group">
{% for c in candidates %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div class="flex-grow-1"><pre class="mb-0">{{ c.answer }}</pre></div>
<div class="text-end">
<span class="badge bg-light text-dark me-2">{{ c.count }}</span>
<button class="btn btn-sm btn-outline-primary" hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-vals='{"suggestion": "{{ c.answer|escapejs }}", "context": "fragment"}' hx-target="closest .card" hx-swap="outerHTML">Add</button>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
<template class="incorrect-answers-original" hidden>
<strong>Answer suggest incorrect:</strong> {{ question.answer_suggest_incorrect }}
<div class="mt-1"><button id="answer-suggest-incorrect-button" class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="innerHTML">Edit suggestions</button></div>
</template>
+210 -97
View File
@@ -3,117 +3,137 @@
{% block content %}
{% load static %}
{% include 'anatomy/question_link_header.html' %}
<div id="anatomy-dicom-image" class="dicom-image-legacy" data-url="{{ question.get_image_url }}"
data-annotations='{{question.image_annotations}}' data-edit_annotation=true>
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. To save the changes click the "Save Annotations" button.</p>
<p>There is no limit to the number of arrows that can be added to the image.</p>
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-body">
<div id="anatomy-dicom-image" class="dicom-image-legacy w-100" data-url="{{ question.get_image_url }}" data-annotations='{{question.image_annotations}}' data-edit_annotation=true style="min-height:300px">
</div>
<div class="mt-3 d-flex justify-content-between align-items-center">
<details class="mb-0">
<summary class="small text-muted"><i class="bi bi-info-circle"></i> Image help</summary>
<div class="small text-muted mt-2">Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. Click <strong>Save Annotations</strong> to persist.</div>
</details>
<button id="save-annotations">Save Annotations</button>
<button id="save-annotations" class="btn btn-sm btn-primary">Save Annotations</button>
</div>
<div class="question">
<div class="date">
Created: {{ question.created_date|date:"d/m/Y" }}
</div>
<h2>Question type: {{question.question_type}}</h2>
<h3>Primary answer: {{ question.get_primary_answer }}</h3>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<details>
<summary title="Click to view the question answers">
Answers:
</summary>
<table>
<tr><th>Answer</th><th>Score</th></tr>
<h5 class="card-title mb-1">Question</h5>
<div class="text-muted">Created: {{ question.created_date|date:"d/m/Y" }}</div>
</div>
<div class="text-end">
<h6 class="mb-0">Type: <span class="badge bg-secondary">{{ question.question_type }}</span></h6>
</div>
</div>
<h6 class="mt-2">Primary answer</h6>
<div class="mb-2"><strong>{{ question.get_primary_answer }}</strong></div>
<h6>Answers</h6>
<details class="mb-3">
<summary class="small">Show answers</summary>
<table class="table table-sm mt-2">
<thead>
<tr><th>Answer</th><th>Score</th><th></th></tr>
</thead>
<tbody>
{% for answer in question.answers.all|dictsortreversed:"status" %}
<tr>
<td {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="anatomy"
{% endif %}>
<span >
{{ answer }}
</span>
<td>
<td>{{answer.status}}</td>
{% if answer.proposed %}
<td>
<button class="btn btn-sm accept-button" data-aid={{answer.id}}
hx-get="{% url 'anatomy:confirm_answer' answer.id %}"
title="Click to accept the proposed answer">Accept</button>
<button class="btn btn-sm delete-button" data-aid={{answer.id}}
hx-get="{% url 'anatomy:delete_answer' answer.id %}"
title="Click to delete the proposed answer"
>Delete</button>
</td>
<td {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="anatomy"{% endif %}>
{% if answer.status == "0" %}
<div class="d-flex align-items-start">
<button type="button" class="btn btn-sm btn-link text-muted p-0 toggle-words-btn me-2" title="Suggest words: show candidate words extracted from this stored answer (click to quick-add)" aria-expanded="false" aria-label="Suggest words"><i class="bi bi-lightbulb-fill"></i></button>
<div class="flex-grow-1">
<pre class="mb-0">{{ answer.answer }}</pre>
<div class="mt-1">
<div class="word-buttons d-none mt-2" aria-hidden="true"></div>
</div>
</div>
</div>
{% else %}
<pre class="mb-0">{{ answer.answer }}</pre>
{% endif %}
</td>
<td>{{ answer.get_status_display|default:answer.status }}</td>
<td>
{% if answer.proposed %}
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-success accept-button" data-aid={{answer.id}} hx-get="{% url 'anatomy:confirm_answer' answer.id %}" title="Accept">Accept</button>
<button class="btn btn-outline-danger delete-button" data-aid={{answer.id}} hx-get="{% url 'anatomy:delete_answer' answer.id %}" title="Delete">Delete</button>
</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="3" class="small text-muted">No stored answers</td></tr>
{% endfor %}
</tbody>
</table>
</details>
</div>
<div>
Answer help: {{ question.answer_help|safe }}
</div>
<div>
{% partialdef suggest-incorrect-form inline %}
<span id="incorrect-answers">
Answer suggest incorrect: {{ question.answer_suggest_incorrect }}
<button id="answer-suggest-incorrect-button" class="btn btn-sm" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}"
hx-target="#incorrect-answers"
hx-swap="innerHTML">Edit suggestions</button>
</span>
{% endpartialdef %}
</div>
<div>
Description: {{ question.description }}
</div>
<div>
Exams: {% for exam in question.exams.all %}
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">{{ exam }}</a>
{% endfor %}
<button class="btn btn-sm" hx-get="{% url 'anatomy:question_add_exam' question_id=question.pk %}"
hx-target="#exam-list"
hx-swap="innerHTML">Edit exam(s)</button>
<span id="exam-list"></span>
</div>
<div>
Modality: {{ question.modality }}
</div>
<div>
Region: {{ question.region }}
</div>
<div>
Structure: {{ question.structure }}
</div>
<div>
Body Part: {{ question.body_part }}
</div>
<div>
Author: {% for author in question.author.all %}
{{ author }},
{% endfor %}
</div>
<div>
Open access: {{ question.open_access }}
</div>
{% include 'question_notes.html' %}
<div>
<details>
<summary>
<span id="annotation-json-toggle">Annotation JSON (+/-):</span> <span id="annotation-json-content">
</summary>
{% if question.image_annotations %}
<pre>{{ question.image_annotations }}</pre>
{% else %}
No saved annotations.
{% if question.answer_help %}
<div class="mb-3">
<h6>Answer help</h6>
<div class="small text-muted">{{ question.answer_help|safe }}</div>
</div>
{% endif %}
</span>
<div class="mb-2">
{% include 'anatomy/partials/suggest_incorrect_collapsed.html' %}
</div>
<div class="mb-2">
<h6>Description</h6>
<div class="small text-muted">{{ question.description }}</div>
</div>
<div class="row">
<div class="col-12 col-md-6">
<div class="mb-1"><strong>Modality:</strong> {{ question.modality }}</div>
<div class="mb-1"><strong>Region:</strong> {{ question.region }}</div>
<div class="mb-1"><strong>Structure:</strong> {{ question.structure }}</div>
</div>
<div class="col-12 col-md-6">
<div class="mb-1"><strong>Body part:</strong> {{ question.body_part }}</div>
<div class="mb-1"><strong>Open access:</strong> {{ question.open_access }}</div>
<div class="mb-1"><strong>Author:</strong> {% for author in question.author.all %}{{ author }}{% if not forloop.last %}, {% endif %}{% endfor %}</div>
</div>
</div>
<div class="mt-3">
<h6>Exams</h6>
<div class="small">
{% for exam in question.exams.all %}
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}" class="me-2">{{ exam }}</a>
{% empty %}
No exams
{% endfor %}
</div>
<div class="mt-2"><button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-target="#exam-list" hx-swap="innerHTML">Edit exam(s)</button>
<span id="exam-list"></span></div>
</div>
{% include 'question_notes.html' %}
<div class="mt-3">
<details>
<summary class="small">Annotation JSON</summary>
<div class="mt-2 small">{% if question.image_annotations %}<pre class="mb-0">{{ question.image_annotations }}</pre>{% else %}No saved annotations.{% endif %}</div>
</details>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
$(document).ready(function () {
@@ -174,6 +194,99 @@
}
</script>
<script>
// HTMX quick-add helpers for word buttons inside the answers table
var SUGGEST_URL = "{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}";
var CSRF_TOKEN = "{{ csrf_token }}";
function toggleWords(btn) {
var container = btn.nextElementSibling; // the flex-grow-1 wrapper
if (!container) return;
var wordContainer = container.querySelector('.word-buttons');
if (!wordContainer) return;
// populate if empty
if (!wordContainer.dataset.populated) {
var pre = container.querySelector('pre');
var text = pre ? pre.textContent || pre.innerText : '';
buildWordButtons(wordContainer, text);
wordContainer.dataset.populated = '1';
}
var expanded = !wordContainer.classList.contains('d-none');
if (expanded) {
wordContainer.classList.add('d-none');
btn.setAttribute('aria-expanded', 'false');
wordContainer.setAttribute('aria-hidden', 'true');
} else {
wordContainer.classList.remove('d-none');
btn.setAttribute('aria-expanded', 'true');
wordContainer.setAttribute('aria-hidden', 'false');
}
}
function buildWordButtons(container, text) {
if (!text) return;
var toks = (text.toLowerCase().match(/\w+/g) || []);
var seen = {};
toks.forEach(function(t){
if (t.length < 3) return;
if (seen[t]) return;
seen[t]=true;
var b = document.createElement('button');
b.type = 'button';
b.className = 'btn btn-sm btn-outline-primary me-1 mb-1';
b.textContent = t;
b.dataset.word = t;
// do not attach inline handlers here; use delegated listener
container.appendChild(b);
});
if (!Object.keys(seen).length) {
var span = document.createElement('div');
span.className = 'small text-muted';
span.textContent = 'No suitable words';
container.appendChild(span);
}
}
// Delegated click handler to keep markup unobtrusive and work for HTMX swaps
document.addEventListener('click', function(e){
var toggle = e.target.closest('.toggle-words-btn');
if (toggle) {
e.preventDefault();
toggleWords(toggle);
return;
}
var wordBtn = e.target.closest('.word-buttons button');
if (wordBtn) {
e.preventDefault();
var word = wordBtn.dataset.word || wordBtn.textContent.trim();
if (word) addQuickSuggestion(word);
return;
}
});
function addQuickSuggestion(word) {
if (!word) return;
if (typeof htmx === 'undefined') {
// Fallback: simple POST via fetch
fetch(SUGGEST_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': CSRF_TOKEN },
body: new URLSearchParams({ suggestion: word, quick: '1' })
}).then(function(r){ return r.text(); }).then(function(html){
var wrapper = document.getElementById('incorrect-answers');
if (wrapper) wrapper.innerHTML = html;
});
return;
}
htmx.ajax('POST', SUGGEST_URL, {
values: { suggestion: word, quick: '1' },
headers: { 'X-CSRFToken': CSRF_TOKEN },
target: '#incorrect-answers',
swap: 'innerHTML'
});
}
</script>
{% endblock %}
{% block css %}
@@ -182,9 +295,9 @@
content: "\F505";
font-family: bootstrap-icons;
}
/* Ensure pre-wrapped content inside cards and tables so long answers don't overflow */
.card pre, .table pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
.question div {
margin-bottom: 10px;
}
.card .mb-2, .card .mb-3 { margin-bottom: .75rem; }
</style>
{% endblock css %}
+12
View File
@@ -38,6 +38,11 @@ urlpatterns = [
views.question_suggest_incorrect_answers,
name="question_suggest_incorrect_answers",
),
path(
"question/<int:pk>/suggest_incorrect_collapsed",
views.question_suggest_incorrect_collapsed,
name="question_suggest_incorrect_collapsed",
),
path("question/<int:pk>/answer/", views.answer_question, name="answer_question"),
path(
@@ -102,6 +107,13 @@ urlpatterns = [
views.exam_review_question_summary,
name="exam_review_question_summary",
),
# New improved marking UI (mark2) and small toggle endpoint used by JS/HTMX
path("exam/<int:exam_pk>/<int:sk>/mark2", views.mark2, name="mark2"),
path("exam/<int:pk>/mark2", views.mark2_overview, name="mark2_overview"),
path("exam/mark2/toggle", views.mark2_toggle_answer, name="mark2_toggle_answer"),
path("exam/<int:exam_pk>/<int:sk>/mark2/exam_marked", views.mark2_exam_marked, name="mark2_exam_marked"),
path("question/<int:pk>/mark2/question_marked", views.mark2_question_marked, name="mark2_question_marked"),
path("exam/mark2/edit_answer", views.mark2_edit_answer, name="mark2_edit_answer"),
]
urlpatterns.extend(generic_view_urls(views.GenericViews))
+594 -1
View File
@@ -15,6 +15,7 @@ from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.views.generic import ListView
from django.db.models.functions import Lower
from django.db.models import Prefetch
from django.core.cache import cache
@@ -624,6 +625,426 @@ def exam_review_question_summary(request, pk: int, q_index: int):
return render(request, "anatomy/partials/exam_review_question_summary_fragment.html", context)
@login_required
def mark2(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
"""Improved marking UI (mark2).
Same core behaviour as `mark` but renders a modern, HTMX-friendly template
and exposes a small toggle endpoint for quick per-answer marking.
"""
exam = get_object_or_404(Exam, pk=exam_pk)
if not GenericExamViews.check_user_marker_access(request.user, exam_pk):
raise PermissionDenied
if not exam.exam_mode:
raise Http404("Packet not in exam mode")
mark_url = "anatomy:mark2"
if review:
mark_url = "anatomy:mark2"
questions = exam.get_questions().prefetch_related("answers")
n = sk
question_details = {
"total": len(questions),
"current": n + 1,
}
try:
question: AnatomyQuestion = questions[sk]
except IndexError:
raise Http404("Exam question does not exist")
# For consistency with the existing mark view we accept a standard POST form
# but the preferred workflow is immediate HTMX/ajax toggles via mark2_toggle_answer.
if request.method == "POST":
form = MarkAnatomyQuestionForm(request.POST)
if form.is_valid():
if "skip" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n + 1)
cd = form.cleaned_data
marked_answers = json.loads(cd.get("marked_answers"))
to_run = (
("correct", Answer.MarkOptions.CORRECT),
("half-correct", Answer.MarkOptions.HALF_MARK),
("incorrect", Answer.MarkOptions.INCORRECT),
)
for status_string, status in to_run:
for ans in marked_answers[status_string]:
ans = ans.strip()
if ans == "":
continue
a = Answer.objects.filter(answer__iexact=ans, question_id=question.pk).first()
if a is None:
a = Answer()
a.question_id = question.pk
a.answer = ans
a.status = status
a.save()
if "next" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n + 1)
elif "previous" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n - 1)
else:
form = MarkAnatomyQuestionForm()
# Reuse existing collections for display
correct_answers = []
half_mark_answers = []
incorrect_answers = []
review_user_answers = []
unmarked_answers_bool = False
unmarked_user_answers = []
if review:
unmarked_user_answers = question.get_user_answers(exam_pk=exam_pk, include_normal=False)
elif unmarked_exam_answers_only:
unmarked_user_answers = question.get_unmarked_user_answers(exam_pk=exam_pk)
else:
unmarked_user_answers = question.get_unmarked_user_answers()
if not unmarked_exam_answers_only:
correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT)
half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK)
incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT)
answer_suggest_incorrect: list = []
if review:
for ans in unmarked_user_answers:
marked_ans = question.answers.filter(answer_compare__iexact=ans).first()
mark = "unmarked"
if marked_ans is not None:
if marked_ans.status == Answer.MarkOptions.CORRECT:
mark = "correct"
elif marked_ans.status == Answer.MarkOptions.HALF_MARK:
mark = "half-correct"
elif marked_ans.status == Answer.MarkOptions.INCORRECT:
mark = "incorrect"
if mark == "unmarked":
unmarked_answers_bool = True
review_user_answers.append((ans, mark))
else:
for ans in unmarked_user_answers:
for suggest_incorrect in question.answer_suggest_incorrect:
if suggest_incorrect in ans:
answer_suggest_incorrect.append(ans)
break
words_present: set = set()
words_present_in_correct: set = set()
for ans in correct_answers:
answer_words = set(ans.get_constituent_words())
words_present.update(answer_words)
words_present_in_correct.update(answer_words)
for ans in half_mark_answers:
answer_words = set(ans.get_constituent_words())
words_present.update(answer_words)
words_present_in_correct.update(answer_words)
for ans in incorrect_answers:
answer_words = set(ans.get_constituent_words())
words_present.update(answer_words)
words_suggest_incorrect = words_present - words_present_in_correct - set(question.answer_suggest_incorrect)
context = {
"exam": exam,
"form": form,
"review": review,
"question": question,
"question_number": n,
"question_details": question_details,
"unmarked_answers_bool": unmarked_answers_bool,
"user_answers": unmarked_user_answers,
"review_user_answers": review_user_answers,
"correct_answers": correct_answers,
"half_mark_answers": half_mark_answers,
"incorrect_answers": incorrect_answers,
"unmarked_exam_answers_only": unmarked_exam_answers_only,
"answer_suggest_incorrect": answer_suggest_incorrect,
"words_suggest_incorrect": words_suggest_incorrect,
}
return render(request, "anatomy/mark2.html", context)
@login_required
def mark2_overview(request, pk):
"""Overview page for mark2 workflow: lists questions and unmarked counts and links into mark2."""
exam = get_object_or_404(Exam, pk=pk)
if not exam.exam_mode:
raise Http404("Packet not in exam mode")
# Only allow authors or designated markers
if request.user not in exam.author.all():
if not GenericExamViews.check_user_marker_access(request.user, pk):
raise PermissionDenied
# For anatomy prefer prefetch similar to generic.mark_overview
questions = (
exam.exam_questions.select_related(
"modality",
"structure",
"body_part",
"region",
"examination",
"question_type",
)
.all()
.prefetch_related(
Prefetch(
"answers",
queryset=Answer.objects.filter(proposed=False, status=Answer.MarkOptions.CORRECT),
to_attr="prefetched_primary_answer",
),
)
.order_by("examquestiondetail__sort_order")
)
# Handle double marking which needs per-marker counts
try:
if exam.double_mark:
question_unmarked_map = []
for q in questions:
question_unmarked_map.append(
(
q,
int(q.get_unmarked_user_answer_count(exam_pk=exam.pk)),
int(q.get_unmarked_user_answer_count(exam_pk=exam.pk, marker=request.user)),
)
)
return render(request, "anatomy/mark2_overview.html", {"exam": exam, "question_unmarked_map": question_unmarked_map})
except AttributeError:
pass
question_unmarked_map = []
for q in questions:
count = int(q.get_unmarked_user_answer_count(exam_pk=exam.pk))
question_unmarked_map.append((q, count, count))
return render(request, "anatomy/mark2_overview.html", {"exam": exam, "question_unmarked_map": question_unmarked_map})
@login_required
def mark2_toggle_answer(request):
"""Endpoint to toggle/set a single answer mark quickly.
Expects POST with form fields: question_pk, answer (text), newmark ("correct"|"half-correct"|"incorrect").
Returns JSON status.
"""
if request.method != "POST":
return JsonResponse({"status": "error", "message": "POST required"}, status=400)
question_pk = request.POST.get("question_pk") or request.POST.get("question")
answer_text = request.POST.get("answer")
newmark = request.POST.get("newmark")
if not question_pk or not answer_text or not newmark:
return JsonResponse({"status": "error", "message": "missing parameters"}, status=400)
try:
question = AnatomyQuestion.objects.get(pk=question_pk)
except AnatomyQuestion.DoesNotExist:
return JsonResponse({"status": "error", "message": "question not found"}, status=404)
# permission checks: ensure user can mark the exam(s) this question belongs to
# We can't easily infer exam_pk here, but ask GenericExamViews.check_user_marker_access
# using any exam that contains this question is adequate if present.
exams = list(question.exams.all())
if exams:
exam_pk = exams[0].pk
if not GenericExamViews.check_user_marker_access(request.user, exam_pk):
return JsonResponse({"status": "error", "message": "forbidden"}, status=403)
# Map mark strings to model constants
mark_map = {
"correct": Answer.MarkOptions.CORRECT,
"half-correct": Answer.MarkOptions.HALF_MARK,
"incorrect": Answer.MarkOptions.INCORRECT,
}
# Support clearing a stored answer (delete) via 'clear'
exam_pk = request.POST.get('exam_pk')
sk = request.POST.get('sk')
if newmark == 'clear':
a = Answer.objects.filter(answer_compare__iexact=answer_text, question_id=question.pk).first()
if a is not None:
# permission: ensure user can edit this answer
if not a.can_edit(request.user):
return JsonResponse({"status": "error", "message": "forbidden"}, status=403)
a.delete()
if request.htmx and exam_pk and sk is not None:
try:
return mark2_exam_marked(request, int(exam_pk), int(sk))
except Exception:
return JsonResponse({"status": "ok"})
return JsonResponse({"status": "ok"})
if newmark not in mark_map:
return JsonResponse({"status": "error", "message": "invalid mark"}, status=400)
status_val = mark_map[newmark]
# Find existing Answer by answer_compare or create
a = Answer.objects.filter(answer_compare__iexact=answer_text, question_id=question.pk).first()
if a is None:
a = Answer()
a.question_id = question.pk
a.answer = answer_text
a.status = status_val
a.save()
if request.htmx and exam_pk and sk is not None:
try:
return mark2_exam_marked(request, int(exam_pk), int(sk))
except Exception:
return JsonResponse({"status": "ok"})
return JsonResponse({"status": "ok"})
@login_required
def mark2_exam_marked(request, exam_pk, sk):
"""Return a partial listing of answers submitted for this question within the given exam.
Shows distinct submitted answers with counts and any existing mark/status.
"""
exam = get_object_or_404(Exam, pk=exam_pk)
questions = exam.get_questions()
try:
question = questions[sk]
except Exception:
raise Http404("Question not found in exam")
ua_qs = question.cid_user_answers.filter(exam=exam)
# Allow client to request ordering by 'count' (default) or by 'mark' (badge)
order = request.GET.get('order', 'count')
# Group distinct submitted answers and count occurrences
grouped = ua_qs.values('answer_compare').annotate(count=Count('id'))
items = []
for row in grouped:
ans = row['answer_compare']
cnt = row['count']
a = question.answers.filter(answer_compare__iexact=ans).first()
mark = None
if a is not None:
if a.status == Answer.MarkOptions.CORRECT:
mark = 'correct'
elif a.status == Answer.MarkOptions.HALF_MARK:
mark = 'half-correct'
elif a.status == Answer.MarkOptions.INCORRECT:
mark = 'incorrect'
items.append({'answer': ans, 'count': cnt, 'mark': mark})
# Sort items according to requested ordering
if order == 'mark':
weight = {'correct': 3, 'half-correct': 2, 'incorrect': 1, None: 0}
items.sort(key=lambda x: (weight.get(x.get('mark')), x.get('count', 0)), reverse=True)
else:
items.sort(key=lambda x: x.get('count', 0), reverse=True)
context = {'exam': exam, 'question': question, 'items': items, 'sk': sk}
return render(request, 'anatomy/partials/mark2_exam_marked_fragment.html', context)
@login_required
def mark2_edit_answer(request):
"""HTMX-friendly endpoint to edit/create a stored Answer for a question.
GET: returns a small form fragment.
POST: updates/creates the Answer and, if exam_pk/sk provided, returns the
refreshed exam-marked fragment so the UI updates in-place.
"""
if request.method == 'GET' and request.htmx:
question_pk = request.GET.get('question_pk')
original = request.GET.get('original', '')
exam_pk = request.GET.get('exam_pk')
sk = request.GET.get('sk')
question = get_object_or_404(AnatomyQuestion, pk=question_pk)
# Try to find an existing Answer by compare
existing = question.answers.filter(answer_compare__iexact=original).first()
context = {
'question': question,
'original': original,
'existing': existing,
'exam_pk': exam_pk,
'sk': sk,
}
return render(request, 'anatomy/partials/mark2_edit_answer_fragment.html', context)
if request.method == 'POST' and request.htmx:
question_pk = request.POST.get('question_pk')
original = request.POST.get('original', '')
new_text = request.POST.get('new_answer', '').strip()
exam_pk = request.POST.get('exam_pk')
sk = request.POST.get('sk')
if not question_pk or not new_text:
return HttpResponse('Missing parameters', status=400)
question = get_object_or_404(AnatomyQuestion, pk=question_pk)
a = question.answers.filter(answer_compare__iexact=original).first()
if a is None:
a = Answer()
a.question = question
a.answer = new_text
else:
a.answer = new_text
a.save()
# If we have exam context, refresh the exam-marked fragment for that question
if exam_pk and sk is not None:
# delegate to mark2_exam_marked to re-render the card
try:
return mark2_exam_marked(request, int(exam_pk), int(sk))
except Exception:
return HttpResponse('OK')
return HttpResponse('OK')
# Fallback: forbidden for non-HTMX or other methods
raise PermissionDenied()
@login_required
def mark2_question_marked(request, pk):
"""Return a partial listing of all stored Answer choices for this question (grouped by status)."""
question = get_object_or_404(AnatomyQuestion, pk=pk)
correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT)
half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK)
incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT)
context = {
'question': question,
'correct_answers': correct_answers,
'half_mark_answers': half_mark_answers,
'incorrect_answers': incorrect_answers,
}
return render(request, 'anatomy/partials/mark2_question_marked_fragment.html', context)
#@login_required
#def exam_scores_refresh(request, pk):
@@ -971,23 +1392,195 @@ class AnatomyQuestionView(LoginRequiredMixin, SingleTableMixin, FilterView):
@user_is_author_or_anatomy_checker
def question_suggest_incorrect_answers(request, pk):
question = get_object_or_404(AnatomyQuestion, pk=pk)
# helper: build candidate suggestions (n-grams + prefixes)
def build_candidates(question_obj):
from django.db.models import Count
import re
def tokenize(s: str):
return re.findall(r"\w+", (s or "").lower())
candidates_qs = (
question_obj.cid_user_answers.values("answer_compare")
.annotate(count=Count("id"))
.order_by("-count")
)
existing = set([s for s in question_obj.answer_suggest_incorrect if s])
marked_texts = [m.lower() for m in question_obj.get_marked_answers()]
ngram_map = {}
ngram_weight = {}
prefix_map = {}
prefix_weight = {}
for row in candidates_qs:
ans = (row.get("answer_compare") or "").strip()
cnt = int(row.get("count") or 0)
if not ans:
continue
tokens = tokenize(ans)
max_n = min(4, len(tokens))
for n in range(1, max_n + 1):
for i in range(len(tokens) - n + 1):
ngram = " ".join(tokens[i : i + n])
ngram_map.setdefault(ngram, set()).add(ans)
ngram_weight[ngram] = ngram_weight.get(ngram, 0) + cnt
for tok in tokens:
tlen = len(tok)
for plen in range(3, min(6, tlen) + 1):
pref = tok[:plen]
prefix_map.setdefault(pref, set()).add(ans)
prefix_weight[pref] = prefix_weight.get(pref, 0) + cnt
raw_candidates = []
for ngram, ans_set in ngram_map.items():
if ngram in existing:
continue
excluded = False
for mt in marked_texts:
if ngram in mt:
excluded = True
break
if excluded:
continue
coverage = len(ans_set)
if coverage < 1:
continue
raw_candidates.append(
{
"text": ngram,
"coverage": coverage,
"count": ngram_weight.get(ngram, 0),
"words": len(ngram.split()),
"type": "ngram",
}
)
for pref, ans_set in prefix_map.items():
if pref in existing:
continue
excluded = False
for mt in marked_texts:
if pref in mt:
excluded = True
break
if excluded:
continue
coverage = len(ans_set)
if coverage < 1:
continue
raw_candidates.append(
{
"text": pref,
"coverage": coverage,
"count": prefix_weight.get(pref, 0),
"words": 1,
"type": "prefix",
}
)
raw_candidates.sort(key=lambda x: (x["words"], -x["coverage"], -x["count"]))
selected = []
selected_texts = []
for c in raw_candidates:
t = c["text"]
skip = False
for s in selected_texts:
if s in t:
skip = True
break
if not skip:
selected.append({"answer": t, "count": c["count"], "type": c.get("type", "ngram")})
selected_texts.append(t)
return selected
# Support HTMX partial rendering with suggestion candidates and inline add
if request.method == "POST":
# Support remove action from the edit fragment
if request.htmx and request.POST.get("remove"):
to_remove = request.POST.get("remove").strip()
if to_remove:
existing = [s for s in question.answer_suggest_incorrect if s]
if to_remove in existing:
existing = [s for s in existing if s != to_remove]
question.answer_suggest_incorrect = existing
question.save()
# Recompute candidates and render the edit fragment
form = SuggestIncorrectAnswerForm(instance=question)
candidates = build_candidates(question)
context = {"form": form, "question": question, "candidates": candidates}
return render(request, "anatomy/partials/suggest_incorrect_fragment.html", context)
# Support single-candidate add via HTMX (button posts 'suggestion').
# Edits should only be performed via the edit fragment; when a
# suggestion is posted we re-render the edit fragment so the
# user remains in edit mode and sees the updated candidate list.
if request.htmx and request.POST.get("suggestion"):
suggestion = request.POST.get("suggestion").strip()
if suggestion:
existing = [s for s in question.answer_suggest_incorrect if s]
if suggestion not in existing:
existing.append(suggestion)
question.answer_suggest_incorrect = existing
question.save()
# If this is a quick add (from the answers table) return the collapsed partial
if request.POST.get("quick"):
return render(request, "anatomy/partials/suggest_incorrect_collapsed.html", {"question": question})
# Recompute candidates and render the edit fragment
form = SuggestIncorrectAnswerForm(instance=question)
candidates = build_candidates(question)
context = {"form": form, "question": question, "candidates": candidates}
return render(request, "anatomy/partials/suggest_incorrect_fragment.html", context)
# Otherwise treat as the full form submission
form = SuggestIncorrectAnswerForm(request.POST, instance=question)
if form.is_valid():
obj = form.save()
obj.save()
# If HTMX requested, return the fragment or collapsed partial
if request.htmx:
# If the close button was used, return the collapsed small partial
if request.POST.get("close"):
return render(request, "anatomy/partials/suggest_incorrect_collapsed.html", {"question": question})
# Otherwise re-render the edit fragment so the user stays in edit mode
form = SuggestIncorrectAnswerForm(instance=question)
candidates = build_candidates(question)
context = {"form": form, "question": question, "candidates": candidates}
return render(request, "anatomy/partials/suggest_incorrect_fragment.html", context)
return render(request, "anatomy/question_detail.html#suggest-incorrect-form", {"question": question})
return HttpResponse("Invalid")
else:
form = SuggestIncorrectAnswerForm(instance=question)
candidates = build_candidates(question)
context = {"form": form, "question": question, "candidates": candidates}
return render(request, "anatomy/suggest_incorrect.html", {"form": form, "question": question})
if request.htmx:
return render(request, "anatomy/partials/suggest_incorrect_fragment.html", context)
return render(request, "anatomy/suggest_incorrect.html", context)
return HttpResponse("Error", status=400)
@user_is_author_or_anatomy_checker
def question_suggest_incorrect_collapsed(request, pk):
"""Return the small collapsed 'Edit suggestions' box as a partial."""
question = get_object_or_404(AnatomyQuestion, pk=pk)
context = {"question": question}
return render(request, "anatomy/partials/suggest_incorrect_collapsed.html", context)
@user_is_author_or_anatomy_checker
def question_save_annotation(request, pk):
if request.method == "POST":
+1 -1
View File
@@ -267,7 +267,7 @@
<form action="" method="post" enctype="multipart/form-data" id="atlas-form">
{% csrf_token %}
<a href="/generic/examination/create" id="add_examination" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
<table>
{{ form.as_table }}
</table>
@@ -21,7 +21,10 @@
<small class="text-muted">Authors: {{ exam.get_authors }}</small>
{% if exam.exam_mode %}
{% if app_name in "rapids longs anatomy" %}
{% if request.user.is_staff %}<a href="{% url app_name|add:':mark_overview' pk=exam.pk %}">Mark answers</a>{% endif %}
{% if request.user.is_staff %}
<a href="{% url app_name|add:':mark_overview' pk=exam.pk %}">Mark answers</a>
{% if app_name == 'anatomy' %} / <a href="{% url 'anatomy:mark2_overview' pk=exam.pk %}">Mark (mark2)</a>{% endif %}
{% endif %}
{% endif %}
{% if request.user.is_staff %}<a href="{% url app_name|add:':exam_scores_all' pk=exam.pk %}">Scores</a>{% endif %}
{% endif %}
+12 -12
View File
@@ -5,7 +5,7 @@
{% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
<script type="text/javascript">
<script type="text/javascript">
function showEditPopup(url) {
var win = window.open(url, "Edit",
'height=500,width=800,resizable=yes,scrollbars=yes');
@@ -312,17 +312,17 @@
}
}
</script>
</script>
{{ form.media }}
{{ form.media }}
{% endblock %}
{% block content %}
<h2>Series Form</h2>
This form will create a image set that can be associated with a long question. If the question has already been created it can be linked below.
<form action="" method="post" enctype="multipart/form-data" id="long-form">
<h2>Series Form</h2>
This form will create a image set that can be associated with a long question. If the question has already been created it can be linked below.
<form action="" method="post" enctype="multipart/form-data" id="long-form">
{% csrf_token %}
<a href="/generic/examination/create" id="add_examination" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
<table>
{{ form.as_table }}
</table>
@@ -344,13 +344,13 @@ This form will create a image set that can be associated with a long question. I
</div>
{{ image_formset.management_form }}
<input type="submit" class="submit-button" value="Submit" name="submit">
</form>
<div id="empty_form" style="display:none">
</form>
<div id="empty_form" style="display:none">
<ul class='no_error image-formset'>
{{ image_formset.empty_form.as_ul }}
</ul>
</div>
<div id="images">
</div>
<div id="images">
</div>
</div>
{% endblock %}
+26 -27
View File
@@ -5,7 +5,7 @@
{% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
<script type="text/javascript">
<script type="text/javascript">
let csrf_token = "{{csrf_token}}";
let hash_url = "{% url 'rapids:rapid_question_by_hash' %}";
//let monitor_directory_handler = false;
@@ -266,8 +266,8 @@
}
}
}
</script>
<script type="text/hyperscript">
</script>
<script type="text/hyperscript">
init
set global $normal to false
if (#id_normal_0.checked )
@@ -307,38 +307,38 @@
</script>
</script>
{{ form.media }}
{{ form.media }}
{% endblock %}
{% block content %}
{% if object %}
{% if object %}
{% include "rapids/question_link_header.html" %}
{% endif %}
{% endif %}
<h2>Submit Rapid</h2>
<p>
<h3>Instructions</h3>
Abnormality, Region and Laterality are used to categorise within the system (they are not used for marking). String
answers that match those added below will be used for automatic marking. Multiple images can be added to a question.
If
the feedback image box is checked they will not be displayed when taking the question.
</p>
<h2>Submit Rapid</h2>
<p>
<h3>Instructions</h3>
Abnormality, Region and Laterality are used to categorise within the system (they are not used for marking). String
answers that match those added below will be used for automatic marking. Multiple images can be added to a question.
If
the feedback image box is checked they will not be displayed when taking the question.
</p>
<!-- <div>
To monitor a directory select it here.<br />
<button id="directory-picker-button">Pick a directory</button>
<span id="directory-picker-display">No directory monitored</span>
</div> -->
<form action="" method="post" enctype="multipart/form-data" id="rapid-form">
<form action="" method="post" enctype="multipart/form-data" id="rapid-form">
{% comment %} {% csrf_token %} {% endcomment %}
<a href="/rapids/abnormality/create" id="add_abnormality" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
<a href="/rapids/examination/create" id="add_examination" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
<a href="/rapids/region/create" id="add_region" class="add-popup" onclick="return showAddPopup(this);"><img
src="{% static '/img/icon-addlink.svg' %}"></a>
src="{% static 'img/icon-addlink.svg' %}"></a>
{% crispy form form.helper %}
@@ -373,19 +373,19 @@ the feedback image box is checked they will not be displayed when taking the que
</div>
{{ image_formset.management_form }}
<input type="submit" class="submit-button" value="Submit" name="submit">
</form>
<div id="empty_form" style="display:none">
</form>
<div id="empty_form" style="display:none">
<ul class='no_error image-formset'>
{{ image_formset.empty_form.as_ul }}
</ul>
</div>
<div id="answer_empty_form" style="display:none">
</div>
<div id="answer_empty_form" style="display:none">
<ul class='no_error answer-formset'>
{{ answer_formset.empty_form.as_ul }}
</ul>
</div>
</div>
<div id="single-dicom-viewer" class="rapid-dicom-viewer" data-images="" data-annotations='' style="display:none"></div>
<div id="single-dicom-viewer" class="rapid-dicom-viewer" data-images="" data-annotations='' style="display:none"></div>
{% endblock %}
@@ -395,6 +395,5 @@ the feedback image box is checked they will not be displayed when taking the que
.add-popup {
float: left;
}
</style>
</style>
{% endblock css %}
+22 -23
View File
@@ -5,7 +5,7 @@
{% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
<script type="text/javascript">
<script type="text/javascript">
let csrf_token = "{{csrf_token}}";
let hash_url = "{% url 'shorts:question_by_hash' %}";
//let monitor_directory_handler = false;
@@ -264,34 +264,34 @@
}
}
}
</script>
</script>
{{ form.media }}
{{ form.media }}
{% endblock %}
{% block content %}
{% if object %}
{% if object %}
{% include "shorts/question_link_header.html" %}
{% endif %}
{% endif %}
<h2>Submit Shorts Question</h2>
<h3>Instructions</h3>
<p>
Use this form to create a new short style question/case. Multiple images can be added to a question.
If the feedback image box is checked they will not be displayed when taking the question.
</p>
<p>Once you have created the question with this form you can add relevant findings.
</p>
<h2>Submit Shorts Question</h2>
<h3>Instructions</h3>
<p>
Use this form to create a new short style question/case. Multiple images can be added to a question.
If the feedback image box is checked they will not be displayed when taking the question.
</p>
<p>Once you have created the question with this form you can add relevant findings.
</p>
<!-- <div>
To monitor a directory select it here.<br />
<button id="directory-picker-button">Pick a directory</button>
<span id="directory-picker-display">No directory monitored</span>
</div> -->
<form action="" method="post" enctype="multipart/form-data" id="rapid-form">
<form action="" method="post" enctype="multipart/form-data" id="rapid-form">
{% comment %} {% csrf_token %} {% endcomment %}
<a href="/rapids/examination/create" id="add_examination" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
{% crispy form form.helper %}
@@ -312,19 +312,19 @@ If the feedback image box is checked they will not be displayed when taking the
</div>
{{ image_formset.management_form }}
<input type="submit" class="submit-button" value="Submit" name="submit">
</form>
<div id="empty_form" style="display:none">
</form>
<div id="empty_form" style="display:none">
<ul class='no_error image-formset'>
{{ image_formset.empty_form.as_ul }}
</ul>
</div>
<div id="answer_empty_form" style="display:none">
</div>
<div id="answer_empty_form" style="display:none">
<ul class='no_error answer-formset'>
{{ answer_formset.empty_form.as_ul }}
</ul>
</div>
</div>
<div id="single-dicom-viewer" class="rapid-dicom-viewer" data-images="" data-annotations='' style="display:none"></div>
<div id="single-dicom-viewer" class="rapid-dicom-viewer" data-images="" data-annotations='' style="display:none"></div>
{% endblock %}
@@ -334,6 +334,5 @@ If the feedback image box is checked they will not be displayed when taking the
.add-popup {
float: left;
}
</style>
</style>
{% endblock css %}
+5 -3
View File
@@ -20,7 +20,10 @@ $(document).ready(function () {
//$(".answer-list li").each(function (index, element) {
// $(element).append($("<span class='google-link' title='search for answer with google'><a href='https://www.google.com/search?q=" + $(element).text() + "' target='_blank'>G</a></span>"));
//});
// Legacy per-element click handler: skip on the new mark2 page which
// manages clicks via a delegated handler (it uses #answer-list). This
// prevents duplicate/colliding handlers when mark2 is rendered.
if (!document.getElementById('answer-list')) {
$(".answer-list span.answer").each(function (index, element) {
console.log(element);
@@ -35,10 +38,9 @@ $(document).ready(function () {
prepAnswerData();
});
});
}
prepAnswerData();
if ($(".post-form").length > 0) {
+4 -1
View File
@@ -12,7 +12,10 @@
<a href='{% url "generic:examcollection_detail" exam.examcollection_id %}' title="This exam is part of the collection {{exam.examcollection.name}}"><i class="bi bi-collection"></i></a>
{% endif %}
</span>
{% if marking %}<a href="{% url app_name|add:':mark_overview' pk=exam.pk %}" class="flex-col-half">Mark</a>{% endif %}
{% if marking %}
<a href="{% url app_name|add:':mark_overview' pk=exam.pk %}" class="flex-col-half">Mark</a>
{% if app_name == 'anatomy' %}<a href="{% url 'anatomy:mark2_overview' pk=exam.pk %}" class="flex-col-half">Mark2</a>{% endif %}
{% endif %}
<span class="flex-col">
<a href="{% url app_name|add:':exam_cids' exam_id=exam.pk %}">Candidates</a> <span class="candidate-counts">[<span title="Number of active CID users">
{{exam.valid_cid_users.count}}