Compare commits

...
52 Commits
Author SHA1 Message Date
Ross eebbe8b759 Enhance question feedback in exam review by adding answers and feedback details for each part 2025-11-10 13:17:19 +00:00
Ross d176288607 Refactor response badges in exam review to use border colors based on correctness percentage for improved visual feedback 2025-11-10 13:12:20 +00:00
Ross bd71c669f0 Refactor exam review response handling to include structured items with optional per-part breakdown for multi-part answers 2025-11-10 13:08:24 +00:00
Ross f3a0a9fd8a Add per-part true/false breakdown to exam review summary for enhanced clarity 2025-11-10 13:05:04 +00:00
Ross 66899b54bd Enhance exam review question fragment to display parts and responses with HTMX support for improved interactivity and user experience 2025-11-10 13:00:47 +00:00
Ross 24c35148b0 Refactor physics exam review scoring to count true/false sub-questions individually and improve accuracy of correct answer calculations 2025-11-10 12:54:29 +00:00
Ross bece7f6ce8 Enhance answer aggregation logic in exam review to handle diverse answer formats and improve robustness 2025-11-10 12:51:55 +00:00
Ross bd81fdcf41 Add exam review question responses and summary views with HTMX support 2025-11-10 12:49:10 +00:00
Ross eaf8a38fe2 Enhance exam review display to show correct answers and update result color descriptions for clarity 2025-11-10 12:41:38 +00:00
Ross 744f2c7600 Add primary answer display to exam review question fragment and ensure safe rendering of question text 2025-11-10 12:37:13 +00:00
Ross e7c15a75a0 Enhance exam review overview page to display per-question summaries with response metrics and color coding for quick triage 2025-11-10 12:26:12 +00:00
Ross cf09449d16 . 2025-11-10 12:23:51 +00:00
Ross 0acc57302f Enhance initAnatomyFragment to prevent double initialization and improve HTMX swap handling 2025-11-10 12:17:53 +00:00
Ross ed88f794f8 Implement initAnatomyFragment function for HTMX fragment swaps to initialize DICOM viewers 2025-11-10 12:15:15 +00:00
Ross 0acc2da374 Add HTMX event handler for DICOM image setup on fragment load 2025-11-10 12:12:49 +00:00
Ross 21a7383c4f Enhance loading indicators for response summary and responses in exam review fragment 2025-11-10 12:11:19 +00:00
Ross 8e038d958b Fix data-url reference in anatomy DICOM image elements for consistent image loading 2025-11-10 12:06:19 +00:00
Ross ad7f8a68e1 Fix element ID reference in getJsonToolStateNoId function for DICOM image handling 2025-11-10 12:02:43 +00:00
Ross 0c5d8bebd6 Refactor setUpDicomLegacy and loadJsonToolStateOnCurrentImage functions for improved annotation handling 2025-11-10 12:00:15 +00:00
Ross 477276c4c5 Fix annotation handling in setUpDicomLegacy function to support multiple annotations 2025-11-10 11:57:27 +00:00
Ross 697191dae9 Add annotation loading in setUpDicomLegacy function 2025-11-10 11:56:04 +00:00
Ross c2ccf02d3e Add annotation data extraction in setUpDicomLegacy function 2025-11-10 11:54:06 +00:00
Ross 1b6757bff2 Update DICOM image element ID for consistency and add CSS styles for anatomy DICOM image 2025-11-10 11:48:01 +00:00
Ross 02284be1a2 Update DICOM image element ID and CSS class for legacy support 2025-11-10 11:46:20 +00:00
Ross 6d03e81e77 Add legacy DICOM viewer setup and update HTML element ID for legacy images 2025-11-10 11:43:31 +00:00
Ross 65a68b6605 Add console log for single DICOM viewer load and update HTML element ID 2025-11-10 11:39:17 +00:00
Ross 2a07843a6b Refactor DICOM 3D setup to clear element before 2D loader fallback and comment out fixed height in CSS 2025-11-10 11:25:08 +00:00
Ross 72c20c13e0 Add fallback to 2D loader in DICOM 3D setup on viewport enable failure 2025-11-10 11:18:20 +00:00
Ross 4d97e9e881 Enhance DICOM 3D loader to fallback to 2D loader for standard web images and handle 'https' URLs 2025-11-10 11:15:52 +00:00
Ross 0be605b41d Enhance DICOM image URL handling to support standard web images without forcing wadouri scheme 2025-11-10 11:11:00 +00:00
Ross b467407e19 Optimize CID lookups to reduce N+1 query pattern in exam review question responses 2025-11-10 11:07:56 +00:00
Ross fdc60bf2e9 Refactor score count variable names for clarity in exam review question summary logic 2025-11-10 11:06:55 +00:00
Ross 486a897e64 Refactor DICOM image div indentation for improved readability in exam review template 2025-11-10 11:00:54 +00:00
Ross fee123741d Add get_image_url method to AnatomyQuestion and update DICOM image URL in exam review template 2025-11-10 10:56:07 +00:00
Ross 367cebb787 Fix DICOM image display by correcting indentation in exam review question fragment 2025-11-10 10:54:38 +00:00
Ross b3b6ce7e6a Refactor exam review question display to show question type instead of question text for improved clarity 2025-11-10 10:53:46 +00:00
Ross 1010f17e3d Enhance exam review responses to include sensible display names for respondents, improving clarity and context in the review process. 2025-11-10 10:52:43 +00:00
Ross 72b7940a8c Refactor show respondents buttons for improved readability and maintainability 2025-11-10 10:50:02 +00:00
Ross cc7a334cb7 Refactor show respondents buttons to improve HTMX integration and add fallback for non-HTMX environments 2025-11-10 10:48:31 +00:00
Ross 9c4eaceda0 Refactor exam review templates to enhance the display of marked answers and top submitted answers with collapsible sections and improved styling 2025-11-10 10:44:39 +00:00
Ross 2aeb1947af Enhance top submitted answers display by adding gap between elements and updating submission count styling 2025-11-10 10:40:18 +00:00
Ross ecf67e3c2b Enhance exam review summary to include dominant score for top submitted answers 2025-11-10 10:38:13 +00:00
Ross d0609e4f8e Remove duplicate question card and associated navigation buttons from exam review template 2025-11-10 10:34:19 +00:00
Ross f4878a58ac Add option to reveal respondent identities in exam review responses 2025-11-10 10:26:38 +00:00
Ross 0d4d4f1dc0 Add option to reveal respondent identities in exam review responses 2025-11-10 10:16:11 +00:00
Ross 3a40a5087d Add management command to normalize Question HTML fields by unwrapping single <p> tags 2025-11-10 10:11:05 +00:00
Ross 921cc53667 Refactor exam review question summary layout for improved alignment and text wrapping 2025-11-10 10:01:58 +00:00
Ross 53ff56edc0 Add clean method to QuestionForm to unwrap single <p> tags from TinyMCE HTML fields 2025-11-10 10:00:42 +00:00
Ross e078b5a98f Refactor exam review templates and views to improve response summary display and handle unanswered responses 2025-11-10 09:58:02 +00:00
Ross 3d6ca8be54 Disable TinyMCE branding badge in editor configuration 2025-11-10 09:50:04 +00:00
Ross e37a7e0b74 Allow superusers to bypass open access check in question detail view 2025-11-10 09:16:10 +00:00
Ross 390f6f098d Enhance exam review question fragment by ensuring safe rendering of question and answers, improving layout for answer feedback, and refining button alignment for better UI consistency. 2025-11-10 09:14:17 +00:00
28 changed files with 1915 additions and 379 deletions
+3
View File
@@ -180,6 +180,9 @@ class AnatomyQuestion(QuestionBase):
exams = ", ".join(e) exams = ", ".join(e)
return exams return exams
def get_image_url(self):
return "{}/{}".format(settings.REMOTE_URL, self.image.url)
def get_unmarked_user_answer_string(self, exam_pk=None): def get_unmarked_user_answer_string(self, exam_pk=None):
unmarked_answers = self.get_unmarked_user_answers(exam_pk) unmarked_answers = self.get_unmarked_user_answers(exam_pk)
+316 -158
View File
@@ -16,6 +16,8 @@ $(document).ready(function () {
clearableTextAreaSetup(); clearableTextAreaSetup();
document.body.addEventListener("htmx:responseError", function(e) { document.body.addEventListener("htmx:responseError", function(e) {
let el = document.getElementById("htmx-error"); let el = document.getElementById("htmx-error");
var error; var error;
@@ -80,6 +82,14 @@ $(document).ready(function () {
} }
} }
let dicom_images_legacy = document.getElementsByClassName("dicom-image-legacy");
if (dicom_images_legacy.length) {
for (let element of dicom_images_legacy) {
setUpDicomLegacy(element)
}
}
loadDicomViewer(); loadDicomViewer();
@@ -235,6 +245,7 @@ function loadDicomViewer(images_to_load, annotations_to_load) {
console.log("loadDicomViewer", images_to_load); console.log("loadDicomViewer", images_to_load);
let single_dicom = document.getElementById("single-dicom-viewer"); let single_dicom = document.getElementById("single-dicom-viewer");
if (single_dicom) { if (single_dicom) {
console.log("Load single dicom viewer");
$(single_dicom).empty() $(single_dicom).empty()
let images = single_dicom.dataset.images; let images = single_dicom.dataset.images;
@@ -307,8 +318,8 @@ function prepAnswerData() {
$("#id_marked_answers").val(JSON.stringify(window.marked_answers)); $("#id_marked_answers").val(JSON.stringify(window.marked_answers));
} }
function loadJsonToolStateOnCurrentImage(json) { function loadJsonToolStateOnCurrentImage(element, json) {
let el = document.getElementById("dicom-image"); let el = element;
const toolStateManager = cornerstoneTools.globalImageIdSpecificToolStateManager; const toolStateManager = cornerstoneTools.globalImageIdSpecificToolStateManager;
@@ -330,160 +341,195 @@ function loadJsonToolStateOnCurrentImage(json) {
} }
//async function setUpDicom(element) { // Called by HTMX after a fragment swap; initializes any dicom-image-legacy viewers
// console.log("setUpDicom (original)", element); window.initAnatomyFragment = function(fragmentRoot) {
// console.log("initAnatomyFragment", fragmentRoot);
// $(element).bind('contextmenu', function (e) { try {
// return false; const root = fragmentRoot && fragmentRoot.nodeType ? fragmentRoot : (fragmentRoot && fragmentRoot.detail && fragmentRoot.detail.target) ? fragmentRoot.detail.target : document;
// }); const imgs = (root && root.querySelectorAll) ? root.querySelectorAll('.dicom-image-legacy') : [];
// imgs.forEach((el) => {
// $(element).dblclick((evt) => { try {
// element.requestFullscreen(); // Avoid double initialization
// }) if (el.dataset && (el.dataset.dicomInitialised === '1' || el.dataset.dicomInitialised === 'true')) return;
// if (typeof window.setUpDicomLegacy === 'function') {
// cornerstoneBase64ImageLoader.external.cornerstone = cornerstone; window.setUpDicomLegacy(el);
// cornerstoneWebImageLoader.external.cornerstone = cornerstone; if (el.dataset) el.dataset.dicomInitialised = '1';
// cornerstoneWADOImageLoader.external.cornerstone = cornerstone; }
// } catch (err) {
// cornerstoneTools.init(); console.warn('initAnatomyFragment: setUpDicomLegacy failed for element', el, err);
// const PanTool = cornerstoneTools.PanTool; }
// const ZoomTool = cornerstoneTools.ZoomTool; });
// const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool; } catch (err) {
// const WwwcTool = cornerstoneTools.WwwcTool; console.warn('initAnatomyFragment error', err);
// const WwwcRegionTool = cornerstoneTools.WwwcRegionTool; }
// const RotateTool = cornerstoneTools.RotateTool; }
// const StackScrollTool = cornerstoneTools.StackScrollTool;
// const MagnifyTool = cornerstoneTools.MagnifyTool; // Global fallback: listen for HTMX swaps and initialise any viewers inside
// const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool; document.addEventListener('htmx:afterSwap', function (e) {
// try {
// const root = e && e.target ? e.target : (e && e.detail && e.detail.target ? e.detail.target : document);
// const images = element.dataset.url.split(","); if (window.initAnatomyFragment) window.initAnatomyFragment(root);
// } catch (err) {
// //console.log("Dicom - load imageId: ", imageIds); console.warn('htmx afterSwap listener error', err);
// //for (let index = 0; index < imageIds.length; index++) { }
// // if (imageIds[index].endsWith("dcm")) { });
// // imageIds[index] = "wadouri:" + imageIds[index];
// // } async function setUpDicomLegacy(element) {
// console.log("setUpDicom (original)", element);
// //}
// let imageIds = []; $(element).bind('contextmenu', function (e) {
// for (let i = 0; i < images.length; i++) { return false;
// let data_url = images[i]; });
// // check stack type
// if (data_url.startsWith("data:image")) { $(element).dblclick((evt) => {
// let imageId = "base64://" + data_url.split(",")[1]; element.requestFullscreen();
// })
// imageIds.push(imageId);
// } else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) { cornerstoneBase64ImageLoader.external.cornerstone = cornerstone;
// cornerstoneWebImageLoader.external.cornerstone = cornerstone;
// imageIds.push(data_url); cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
//
// // Treat application/octet-stream as if they are dicoms cornerstoneTools.init();
// } else if (data_url.startsWith("data:application/dicom") || data_url.startsWith("data:application/octet-stream")) { const PanTool = cornerstoneTools.PanTool;
// //stack = stack.split(";")[1]; const ZoomTool = cornerstoneTools.ZoomTool;
// const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool;
// let dfile = await urltoFile(data_url, "dicom", "application/dicom"); const WwwcTool = cornerstoneTools.WwwcTool;
// const WwwcRegionTool = cornerstoneTools.WwwcRegionTool;
// const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add( const RotateTool = cornerstoneTools.RotateTool;
// dfile const StackScrollTool = cornerstoneTools.StackScrollTool;
// ); const MagnifyTool = cornerstoneTools.MagnifyTool;
// const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
// loadAnnotation(imageId, annotation);
//
// imageIds.push(imageId); const images = element.dataset.url.split(",");
// //cornerstone.loadImage(imageId).then(function(image) {
// // tempFunction(image); const annotation = element.dataset.annotations;
// //});
// } else { //console.log("Dicom - load imageId: ", imageIds);
// let url; //for (let index = 0; index < imageIds.length; index++) {
// // This doesn't seem to have any benefit // if (imageIds[index].endsWith("dcm")) {
// //if (data_url.startsWith("http")) { // imageIds[index] = "wadouri:" + imageIds[index];
// // url = data_url; // }
// //} else {
// // url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url //}
// //} let imageIds = [];
// url = data_url; for (let i = 0; i < images.length; i++) {
// let data_url = images[i];
// if (url.endsWith("dcm")) { // check stack type
// url = "wadouri:" + url; if (data_url.startsWith("data:image")) {
// } let imageId = "base64://" + data_url.split(",")[1];
//
// // if there is no extension treat it as a dicom imageIds.push(imageId);
// if (/(?:\/|^)[^.\/]+$/.test(url)) { } else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) {
// url = "wadouri:" + url;
// } imageIds.push(data_url);
//
// imageIds.push(url); // Treat application/octet-stream as if they are dicoms
// } else if (data_url.startsWith("data:application/dicom") || data_url.startsWith("data:application/octet-stream")) {
// //stack = stack.split(";")[1];
// }
// } let dfile = await urltoFile(data_url, "dicom", "application/dicom");
//
// cornerstone.enable(element); const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
// cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) { dfile
// cornerstone.displayImage(element, image); );
//
// cornerstoneTools.addToolForElement(element, PanTool); loadAnnotation(imageId, annotation);
// cornerstoneTools.addToolForElement(element, ZoomTool);
// cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool); imageIds.push(imageId);
// cornerstoneTools.addToolForElement(element, WwwcTool); //cornerstone.loadImage(imageId).then(function(image) {
// cornerstoneTools.addToolForElement(element, WwwcRegionTool); // tempFunction(image);
// cornerstoneTools.addToolForElement(element, RotateTool); //});
// cornerstoneTools.addToolForElement(element, StackScrollTool); } else {
// cornerstoneTools.addToolForElement(element, MagnifyTool); let url;
// // This doesn't seem to have any benefit
// cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, { //if (data_url.startsWith("http")) {
// configuration: { // url = data_url;
// getTextCallback: () => {}, //} else {
// changeTextCallback: () => {}, // url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
// allowEmptyLabel: true, //}
// renderDashed: false, url = data_url;
// drawHandles: false,
// drawHandlesOnHover: true, if (url.endsWith("dcm")) {
// }, url = "wadouri:" + url;
// }); }
//
// // if there is no extension treat it as a dicom
// if (/(?:\/|^)[^.\/]+$/.test(url)) {
// // Enable our tools url = "wadouri:" + url;
// // Avoid incorrect aspect ratio }
// cornerstoneTools.setToolActiveForElement(element, "Pan", {
// mouseButtonMask: 1 imageIds.push(url);
// });
// cornerstoneTools.setToolActiveForElement(element, "Wwwc", {
// mouseButtonMask: 2 }
// }); }
// cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", {
// mouseButtonMask: 3 cornerstone.enable(element);
// }); cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
// cornerstoneTools.setToolActiveForElement(element, "Zoom", { cornerstone.displayImage(element, image);
// mouseButtonMask: 4
// }); cornerstoneTools.addToolForElement(element, PanTool);
// cornerstoneTools.addToolForElement(element, ZoomTool);
// if (element.dataset.edit_annotation == "true") { cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool);
// cornerstoneTools.setToolActiveForElement(element, "ArrowAnnotate", { cornerstoneTools.addToolForElement(element, WwwcTool);
// mouseButtonMask: 2 cornerstoneTools.addToolForElement(element, WwwcRegionTool);
// }); cornerstoneTools.addToolForElement(element, RotateTool);
// } else { cornerstoneTools.addToolForElement(element, StackScrollTool);
// cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate"); cornerstoneTools.addToolForElement(element, MagnifyTool);
// }
// cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
// if (element.dataset.annotations) { configuration: {
// loadJsonToolStateOnCurrentImage(element.dataset.annotations) getTextCallback: () => {},
// } changeTextCallback: () => {},
// allowEmptyLabel: true,
// cornerstone.resize(element); renderDashed: false,
// drawHandles: false,
// drawHandlesOnHover: true,
// }).catch((err, err2) => { },
// console.log(err); });
// });
//
//}
//console.log("setUpDicom defined"); // Enable our tools
// // Avoid incorrect aspect ratio
//// keep reference to the existing 2D setup cornerstoneTools.setToolActiveForElement(element, "Pan", {
//window.setUpDicomClassic = setUpDicom; mouseButtonMask: 1
});
cornerstoneTools.setToolActiveForElement(element, "Wwwc", {
mouseButtonMask: 2
});
cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", {
mouseButtonMask: 3
});
cornerstoneTools.setToolActiveForElement(element, "Zoom", {
mouseButtonMask: 4
});
if (element.dataset.edit_annotation == "true") {
cornerstoneTools.setToolActiveForElement(element, "ArrowAnnotate", {
mouseButtonMask: 2
});
} else {
cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate");
}
if (element.dataset.annotations) {
loadJsonToolStateOnCurrentImage(element, element.dataset.annotations)
}
cornerstone.resize(element);
}).catch((err, err2) => {
console.log(err);
});
}
console.log("setUpDicom defined");
// keep reference to the existing 2D setup
window.setUpDicomLegacy = setUpDicomLegacy;
async function setUpDicom3d(element) { async function setUpDicom3d(element) {
console.log("setUpDicom3d (cornerstone3d) for", element); console.log("setUpDicom3d (cornerstone3d) for", element);
@@ -552,7 +598,22 @@ async function setUpDicom3d(element) {
//viewportType: Enums.ViewportType.STACK, // Stack viewport for slice stacks //viewportType: Enums.ViewportType.STACK, // Stack viewport for slice stacks
}; };
try {
renderingEngine.enableElement(viewportInput); renderingEngine.enableElement(viewportInput);
} catch (err) {
// Some bundlings / integrations (dv3d wrapper) may reject the provided
// viewport type (e.g. "Viewport is not a valid type"). In that case
// fall back to the 2D cornerstone loader which is more permissive.
console.warn('renderingEngine.enableElement failed, falling back to 2D loader:', err);
try {
dicomViewer.loadCornerstone($(element), null, images, undefined, images.length > 5);
element._cornerstone3d = { fallback: 'cornerstone2d', imageCount: images.length };
return element._cornerstone3d;
} catch (err2) {
console.error('2D fallback after enableElement failure also failed:', err2);
throw err;
}
}
const viewport = renderingEngine.getViewport(viewportId); const viewport = renderingEngine.getViewport(viewportId);
@@ -597,10 +658,50 @@ async function setUpDicom3d(element) {
// Prepare imageIds: ensure remote URLs are usable by dicom-image-loader (wadouri:) // Prepare imageIds: ensure remote URLs are usable by dicom-image-loader (wadouri:)
// but avoid forcing wadouri: for standard web images (png/jpg/etc) because
// the dicom parser will fail when given non-DICOM files. For such images
// return the raw URL so the appropriate web-image loader can handle them.
// If any image looks like a plain web image (png/jpg/etc) prefer the
// existing 2D cornerstone loader which handles http(s) image schemes.
// This avoids a situation where the 3D dicom loader has no handler for
// the 'https' scheme and throws "No image loader found for scheme 'https'".
const looksLikeWebImage = (u) => {
if (!u) return false;
try { u = u.trim(); } catch (e) {}
if (u.startsWith('data:image')) return true;
if (u.match(/\.(png|jpe?g|gif|bmp|webp)$/i)) return true;
return false;
};
if (images.some(looksLikeWebImage)) {
// Fallback: use the 2D cornerstone loader which has web-image handling
// and is more tolerant of raw http(s) URLs. We reuse the project's
// dicomViewer helper (it expects a jQuery element).
console.warn('setUpDicom3d: contains web-image(s) — falling back to 2D loader');
try {
// Remove any partially-created dv3d DOM and ensure the element is a
// clean container for the 2D viewer. This ensures the `.canvas-panel`
// appended by `loadCornerstone` overlays exactly the element area.
try { element.innerHTML = ""; } catch (e) { $(element).empty(); }
element.style.position = element.style.position || 'relative';
// annotations are not available in this scope in the 3d code path; pass
// undefined for annotations and let the loader handle it.
dicomViewer.loadCornerstone($(element), null, images, undefined, images.length > 5);
// store a lightweight marker so callers can inspect what happened
element._cornerstone3d = { fallback: 'cornerstone2d', imageCount: images.length };
return element._cornerstone3d;
} catch (err) {
console.warn('Fallback 2D loader failed; will continue to try 3D loader', err);
// fallthrough to attempt 3D loading below
}
}
const imageIds = images.map((url) => { const imageIds = images.map((url) => {
url = url.trim(); url = url.trim();
// if it's already a scheme we pass through; otherwise treat as wadouri // if it's already a scheme we pass through
if (/^(wadouri:|base64:|data:)/i.test(url)) return url; if (/^(wadouri:|base64:|data:|http:|https:)/i.test(url)) return url;
// default: treat as wadouri (likely a DICOM file)
return `wadouri:${url}`; return `wadouri:${url}`;
}); });
@@ -617,7 +718,23 @@ async function setUpDicom3d(element) {
} }
} }
try {
await viewport.setStack(imageIds, 0); await viewport.setStack(imageIds, 0);
} catch (err) {
// If the 3D loader fails because it doesn't know how to load 'https'
// URLs, fallback to the 2D cornerstone loader which handles web images.
console.warn('viewport.setStack failed, attempting 2D fallback:', err);
try {
try { element.innerHTML = ""; } catch (e) { $(element).empty(); }
element.style.position = element.style.position || 'relative';
dicomViewer.loadCornerstone($(element), null, images, undefined, images.length > 5);
element._cornerstone3d = { fallback: 'cornerstone2d', imageCount: images.length };
return element._cornerstone3d;
} catch (err2) {
console.error('2D fallback also failed:', err2);
throw err; // rethrow original error
}
}
viewport.render(); viewport.render();
@@ -738,3 +855,44 @@ function initializeClock(id, endtime) {
window.initializeClock = initializeClock window.initializeClock = initializeClock
// Unwrap single-root <p> elements from TinyMCE content before any form submit.
// This prevents a single wrapping <p> from being sent to the server while
// preserving other markup. We use a delegated submit listener so it works
// for dynamically-added forms as well (e.g. admin inlines).
document.addEventListener('submit', function(evt) {
try {
var form = evt.target;
if (!form || !form.querySelector) return;
// Find all tinyMCE textareas inside the form
var areas = form.querySelectorAll('textarea.tinymce');
areas.forEach(function(area) {
var id = area.id;
if (!id) return;
var ed = (typeof tinyMCE !== 'undefined') ? tinyMCE.get(id) : null;
if (!ed) return;
var html = (ed.getContent && typeof ed.getContent === 'function') ? ed.getContent({format: 'html'}) : area.value;
if (!html) return;
// Use a temporary container to inspect top-level elements safely
var tmp = document.createElement('div');
tmp.innerHTML = html.trim();
// If there is exactly one top-level element and it is a <p>, unwrap it
if (tmp.childElementCount === 1 && tmp.firstElementChild && tmp.firstElementChild.tagName.toLowerCase() === 'p') {
var inner = tmp.firstElementChild.innerHTML;
// Update editor content and the underlying textarea value
try {
ed.setContent(inner);
} catch (e) {
// fallback: set textarea value directly
area.value = inner;
}
// Ensure the textarea value matches (some setups sync on save, some don't)
area.value = inner;
}
});
} catch (e) {
// be resilient; don't prevent form submission if something goes wrong here
console.error('TinyMCE submit postprocess error', e);
}
}, true);
@@ -23,7 +23,7 @@
<pre>{{ans}}</pre> <pre>{{ans}}</pre>
</span> </span>
{% if exam.publish_results or view_all_results%} {% if exam.publish_results or view_all_results%}
<span class="answer-score">{{score}}</span> Marks: <span class="answer-score">{{score}}</span>
{% endif %} {% endif %}
</span> </span>
<span class="view-question-link" data-qn={{forloop.counter0}}>View</span> <span class="view-question-link" data-qn={{forloop.counter0}}>View</span>
@@ -1,15 +1,27 @@
<div class="card mb-3"> <div class="card mb-3" hx-on="htmx:afterSwap: initAnatomyFragment(this)">
<div class="card-body"> <div class="card-body">
<div class="mb-2"><span class="badge bg-dark">Anatomy</span></div> <p class="lead">{{ question.question_type }}</p>
<h5 class="card-title">Question</h5> <div id="anatomy-dicom-image" class="dicom-image-legacy" data-url="{{ question.get_image_url }}"
<p class="lead">{{ question }}</p> data-annotations='{{question.image_annotations}}' data-edit_annotation=true>
</div>
<p class="mt-2">
Answer: {{ question.get_primary_answer }}
</p>
<p> <p>
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a> <a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
</p> </p>
<div class="mt-3"> <div class="mt-3">
<h6>Marked answers</h6> <div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">Marked answers</h6>
<div class="d-flex align-items-center gap-2">
<div class="small text-muted">{{ question.answers.all|length }}</div>
<button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#marked-answers-{{ question.pk }}" aria-expanded="false" aria-controls="marked-answers-{{ question.pk }}">Show answers</button>
</div>
</div>
<div class="collapse mt-2" id="marked-answers-{{ question.pk }}">
<ul class="list-group"> <ul class="list-group">
{% for answer in question.answers.all|dictsortreversed:"status" %} {% for answer in question.answers.all|dictsortreversed:"status" %}
<li class="list-group-item d-flex justify-content-between align-items-start{% if answer.status == '2' %} list-group-item-success{% elif answer.status == '1' %} list-group-item-warning{% elif answer.status == '0' %} list-group-item-secondary{% endif %}"> <li class="list-group-item d-flex justify-content-between align-items-start{% if answer.status == '2' %} list-group-item-success{% elif answer.status == '1' %} list-group-item-warning{% elif answer.status == '0' %} list-group-item-secondary{% endif %}">
@@ -34,6 +46,7 @@
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
</div>
{# Aggregated summary placeholder — auto-load via HTMX #} {# Aggregated summary placeholder — auto-load via HTMX #}
<div class="mt-3"> <div class="mt-3">
@@ -41,7 +54,13 @@
<div id="anatomy-response-summary" <div id="anatomy-response-summary"
hx-get="{% url 'anatomy:exam_review_question_summary' exam.pk q_index %}" hx-get="{% url 'anatomy:exam_review_question_summary' exam.pk q_index %}"
hx-trigger="load" hx-trigger="load"
hx-swap="innerHTML"> hx-swap="innerHTML"
hx-indicator="#anatomy-summary-indicator-{{ question.pk }}">
<div id="anatomy-summary-indicator-{{ question.pk }}" style="display:none">
<span class="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading summary</span>
</div>
<style> #anatomy-summary-indicator-{{ question.pk }}.htmx-request{display:inline-block !important;} </style>
<div class="small text-muted">Loading summary…</div> <div class="small text-muted">Loading summary…</div>
</div> </div>
</div> </div>
@@ -54,10 +73,16 @@
<button class="btn btn-sm btn-outline-primary" <button class="btn btn-sm btn-outline-primary"
hx-get="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}" hx-get="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}"
hx-target="#anatomy-responses-container" hx-target="#anatomy-responses-container"
hx-swap="innerHTML">Show responses</button> hx-swap="innerHTML"
hx-indicator="#anatomy-responses-indicator-{{ question.pk }}">Show responses</button>
</div> </div>
</div> </div>
<div class="mt-2" id="anatomy-responses-container"> <div class="mt-2" id="anatomy-responses-container">
<div id="anatomy-responses-indicator-{{ question.pk }}" style="display:none">
<span class="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading responses</span>
</div>
<style> #anatomy-responses-indicator-{{ question.pk }}.htmx-request{display:inline-block !important;} </style>
<div class="text-muted small">Responses are hidden — click “Show responses” to load.</div> <div class="text-muted small">Responses are hidden — click “Show responses” to load.</div>
</div> </div>
</div> </div>
@@ -86,36 +111,4 @@
</div> </div>
</div> </div>
<div class="card mb-3">
<div class="card-body">
<div class="mb-2"><span class="badge bg-info">Anatomy</span></div>
<h5 class="card-title">Question</h5>
<p class="lead">{{ question }}</p>
<p>
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
</p>
<div class="d-flex justify-content-between mt-3">
{% if prev_index is not None %}
<a class="btn btn-outline-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk prev_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">&larr; Previous</a>
{% else %}
<span></span>
{% endif %}
{% if next_index is not None %}
<a class="btn btn-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk next_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">Next &rarr;</a>
{% else %}
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
{% endif %}
</div>
</div>
</div>
@@ -1,15 +1,41 @@
{# Responses partial for anatomy question within an exam. HTMX-loadable. #} {# Responses partial for anatomy question within an exam. HTMX-loadable. #}
<div id="anatomy-responses-container"> <div id="anatomy-responses-container">
<h6>Responses in this exam</h6> <div class="d-flex justify-content-between align-items-center mb-2">
<div class="small text-muted mb-2">List of candidate answers recorded for this question in the current exam.</div> <div>
<h6 class="mb-0">Responses in this exam</h6>
<div class="small text-muted">List of candidate answers recorded for this question in the current exam.</div>
</div>
<div>
{% if not reveal_respondents %}
<button type="button" class="btn btn-sm btn-outline-primary show-respondents-btn"
hx-get="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}?reveal=1"
hx-target="#anatomy-responses-container"
hx-swap="outerHTML"
data-url="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}?reveal=1"
>Show respondents</button>
{% else %}
<button type="button" class="btn btn-sm btn-outline-secondary show-respondents-btn"
hx-get="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}"
hx-target="#anatomy-responses-container"
hx-swap="outerHTML"
data-url="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}"
>Hide respondents</button>
{% endif %}
</div>
</div>
<ul class="list-group"> <ul class="list-group">
{% for ua in exam_user_answers %} {% for item in exam_user_answers %}
{% with ua=item.ua %}
<li class="list-group-item d-flex justify-content-between align-items-start"> <li class="list-group-item d-flex justify-content-between align-items-start">
<div> <div>
{% if ua.user %} {% if reveal_respondents %}
<strong>{{ ua.user.get_full_name|default:ua.user.username }}</strong> {% if item.display_name_raw %}
<strong>{{ item.display_name_raw }}</strong>
{% else %} {% else %}
<strong>Anonymous</strong> <strong>{{ ua.get_candidate_masked }}</strong>
{% endif %}
{% else %}
<strong>Respondent {{ forloop.counter }}</strong>
{% endif %} {% endif %}
<div>Answer: <strong>{{ ua.answer }}</strong></div> <div>Answer: <strong>{{ ua.answer }}</strong></div>
{% if ua.created_date %} {% if ua.created_date %}
@@ -28,8 +54,39 @@
{% endif %} {% endif %}
</div> </div>
</li> </li>
{% endwith %}
{% empty %} {% empty %}
<li class="list-group-item">No responses recorded for this exam.</li> <li class="list-group-item">No responses recorded for this exam.</li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
<script>
// Fallback for environments where HTMX isn't active or the hx-* attributes don't trigger.
// Attach a click handler to buttons with class .show-respondents-btn that will fetch
// the fragment URL (from data-url) and replace the container content.
(function(){
try {
var els = document.querySelectorAll('.show-respondents-btn');
els.forEach(function(btn){
btn.addEventListener('click', function(ev){
// If HTMX is present it will handle the request; only run fallback when not handled.
if (window.htmx) return;
ev.preventDefault();
var url = btn.getAttribute('data-url');
if (!url) return;
fetch(url, {credentials: 'same-origin'})
.then(function(r){ if (!r.ok) throw new Error('Network response was not ok'); return r.text(); })
.then(function(html){
var container = document.querySelector('#anatomy-responses-container');
if (container) container.outerHTML = html;
}).catch(function(err){
console.error('Failed to load responses fragment', err);
});
}, {passive: false});
});
} catch (e) {
// ignore
}
})();
</script>
@@ -21,7 +21,7 @@
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center"> <div class="d-flex w-100 justify-content-between align-items-center">
<div>Correct</div> <div>Correct</div>
<div class="text-end"><strong>{{ exam_response_score_counts.2|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts.2|default:0 }}%</small></div> <div class="text-end"><strong>{{ exam_response_score_counts|get_item:'2'|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts|get_item:'2'|default:0 }}%</small></div>
</div> </div>
<div class="progress mt-2" style="height:12px;"> <div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-success" role="progressbar" style="width: {{ exam_response_score_pcts.2|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.2|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div> <div class="progress-bar bg-success" role="progressbar" style="width: {{ exam_response_score_pcts.2|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.2|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
@@ -32,7 +32,7 @@
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center"> <div class="d-flex w-100 justify-content-between align-items-center">
<div>Half mark</div> <div>Half mark</div>
<div class="text-end"><strong>{{ exam_response_score_counts.1|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts.1|default:0 }}%</small></div> <div class="text-end"><strong>{{ exam_response_score_counts|get_item:'1'|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts|get_item:'1'|default:0 }}%</small></div>
</div> </div>
<div class="progress mt-2" style="height:12px;"> <div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-warning" role="progressbar" style="width: {{ exam_response_score_pcts.1|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.1|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div> <div class="progress-bar bg-warning" role="progressbar" style="width: {{ exam_response_score_pcts.1|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.1|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
@@ -43,7 +43,7 @@
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center"> <div class="d-flex w-100 justify-content-between align-items-center">
<div>Incorrect</div> <div>Incorrect</div>
<div class="text-end"><strong>{{ exam_response_score_counts.0|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts.0|default:0 }}%</small></div> <div class="text-end"><strong>{{ exam_response_score_counts|get_item:'0'|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts|get_item:'0'|default:0 }}%</small></div>
</div> </div>
<div class="progress mt-2" style="height:12px;"> <div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ exam_response_score_pcts.0|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.0|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div> <div class="progress-bar bg-danger" role="progressbar" style="width: {{ exam_response_score_pcts.0|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.0|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
@@ -54,7 +54,7 @@
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center"> <div class="d-flex w-100 justify-content-between align-items-center">
<div>Unmarked</div> <div>Unmarked</div>
<div class="text-end"><strong>{{ exam_response_score_counts.empty|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts.empty|default:0 }}%</small></div> <div class="text-end"><strong>{{ exam_response_score_counts|get_item:'empty'|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts|get_item:'empty'|default:0 }}%</small></div>
</div> </div>
<div class="progress mt-2" style="height:12px;"> <div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-secondary" role="progressbar" style="width: {{ exam_response_score_pcts.empty|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.empty|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div> <div class="progress-bar bg-secondary" role="progressbar" style="width: {{ exam_response_score_pcts.empty|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.empty|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
@@ -63,13 +63,32 @@
</div> </div>
<div> <div>
{# Collapsible Top submitted answers - closed by default. #}
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-1">Top submitted answers</h6> <h6 class="mb-1">Top submitted answers</h6>
<button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#top-answers-{{ q_index }}" aria-expanded="false" aria-controls="top-answers-{{ q_index }}">
Show answers
</button>
</div>
<div class="collapse mt-2" id="top-answers-{{ q_index }}">
{% if top_answers %} {% if top_answers %}
<ul class="list-group"> <ul class="list-group">
{% for a in top_answers %} {% for a in top_answers %}
<li class="list-group-item d-flex justify-content-between align-items-start"> <li class="list-group-item d-flex justify-content-between align-items-center">
<div>{{ a.answer_compare }}</div> <div class="flex-grow-1">{{ a.answer_compare }}</div>
<div class="small text-muted">{{ a.count }}</div> <div class="d-flex align-items-center gap-2">
{% if a.dominant_score == '2' %}
<span class="badge bg-success">Correct</span>
{% elif a.dominant_score == '1' %}
<span class="badge bg-warning text-dark">Half</span>
{% elif a.dominant_score == '0' %}
<span class="badge bg-danger">Incorrect</span>
{% else %}
<span class="badge bg-secondary">Unmarked</span>
{% endif %}
<div class="small text-muted badge bg-primary" title="Submission Count">{{ a.count }}</div>
</div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@@ -77,6 +96,7 @@
<div class="small text-muted">No submitted answers yet.</div> <div class="small text-muted">No submitted answers yet.</div>
{% endif %} {% endif %}
</div> </div>
</div>
{% else %} {% else %}
<div class="small text-muted">No responses recorded for this question in the exam yet.</div> <div class="small text-muted">No responses recorded for this question in the exam yet.</div>
{% endif %} {% endif %}
@@ -3,7 +3,7 @@
{% block content %} {% block content %}
{% load static %} {% load static %}
{% include 'anatomy/question_link_header.html' %} {% include 'anatomy/question_link_header.html' %}
<div id="dicom-image" class="dicom-image" data-url="{{ remote_url }}{{ question.image.url}}" <div id="anatomy-dicom-image" class="dicom-image-legacy" data-url="{{ question.get_image_url }}"
data-annotations='{{question.image_annotations}}' data-edit_annotation=true> data-annotations='{{question.image_annotations}}' data-edit_annotation=true>
<details class="help-text"> <details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary> <summary><i class="bi bi-info-circle"></i> Help</summary>
@@ -158,7 +158,7 @@
function getJsonToolStateNoId() { function getJsonToolStateNoId() {
const el = document.getElementById("dicom-image"); const el = document.getElementById("anatomy-dicom-image");
const c = cornerstone.getEnabledElement(el); const c = cornerstone.getEnabledElement(el);
+131 -6
View File
@@ -95,9 +95,43 @@ class AuthorOrCheckerRequiredMixin(object):
@register.filter @register.filter
def get_item(dictionary, key, default=None): def get_item(dictionary, key, default=None):
"""Safe template filter to fetch an item by key/index from various containers.
Supports dict-like objects (uses .get), lists/tuples (indexing by integer
keys), and other indexable objects. Returns ``default`` when the lookup
fails or the input is None.
"""
if dictionary is None: if dictionary is None:
return default return default
return dictionary.get(key)
# If it's dict-like, prefer .get
try:
if hasattr(dictionary, "get") and callable(getattr(dictionary, "get")):
# Allow .get default behaviour
try:
return dictionary.get(key, default)
except Exception:
# Fall through to other strategies
pass
# If it's a list/tuple and key is integer-like, return by index
if isinstance(dictionary, (list, tuple)):
try:
idx = int(key)
except Exception:
return default
try:
return dictionary[idx]
except Exception:
return default
# Generic attempt: try __getitem__ with key (handles QueryDict, lists with int keys, etc.)
try:
return dictionary[key]
except Exception:
return default
except Exception:
return default
def user_is_admin(user): def user_is_admin(user):
@@ -274,9 +308,55 @@ def exam_review_question_responses(request, pk: int, q_index: int):
raise Http404("Question not found in exam") raise Http404("Question not found in exam")
try: try:
exam_user_answers = question.cid_user_answers.filter(exam=exam).select_related("user") exam_user_answers_qs = question.cid_user_answers.filter(exam=exam).select_related("user")
except Exception: except Exception:
exam_user_answers = question.cid_user_answers.all() exam_user_answers_qs = question.cid_user_answers.all()
# Allow an HTMX-driven reveal of respondent names. Default: anonymised.
reveal = request.GET.get("reveal", "0")
reveal_respondents = str(reveal).lower() in ("1", "true", "yes", "on")
# Build a small list that includes a sensible display name for each respondent.
# If the answer is from a regular user use their full name/username. If it's a CID
# candidate try to resolve the CidUser and show CID + name if available. If neither
# is present fall back to empty/anonymous.
# Optimize CID lookups: collect distinct CID values and fetch all CidUser rows
# in a single query to avoid an N+1 query pattern.
cids_qs = exam_user_answers_qs.values_list("cid", flat=True).distinct()
cids = [c for c in cids_qs if c is not None and str(c).strip() != ""]
cid_map = {}
if cids:
for cu in CidUser.objects.filter(cid__in=cids):
cid_map[cu.cid] = cu
exam_user_answers = []
for ua in exam_user_answers_qs:
# default display
display = None
is_cid = False
try:
if getattr(ua, "user", None):
user = ua.user
# prefer full name where available
full = getattr(user, "get_full_name", None)
if full:
name = user.get_full_name() or user.username
else:
name = getattr(user, "username", str(user))
display = name
elif getattr(ua, "cid", None) is not None:
cid_val = ua.cid
cu = cid_map.get(cid_val)
if cu is not None and cu.name:
display = f"CID{cid_val} ({cu.name})"
else:
display = f"CID{cid_val}"
is_cid = True
except Exception:
display = None
exam_user_answers.append({"ua": ua, "display_name_raw": display, "is_cid": is_cid})
context = { context = {
"exam": exam, "exam": exam,
@@ -284,6 +364,7 @@ def exam_review_question_responses(request, pk: int, q_index: int):
"q_index": q_index, "q_index": q_index,
"app_name": "anatomy", "app_name": "anatomy",
"exam_user_answers": exam_user_answers, "exam_user_answers": exam_user_answers,
"reveal_respondents": reveal_respondents,
} }
return render(request, "anatomy/partials/exam_review_question_responses_fragment.html", context) return render(request, "anatomy/partials/exam_review_question_responses_fragment.html", context)
@@ -309,10 +390,54 @@ def exam_review_question_summary(request, pk: int, q_index: int):
pass pass
# simpler: compute via values and normalise unmarked -> 'empty' # simpler: compute via values and normalise unmarked -> 'empty'
score_counts = {str(row["score"]) if row["score"] is not None else "empty": row["count"] for row in ua_qs.values("score").annotate(count=Count("id"))} def _norm_score_key(v):
# Treat None or empty-string scores as the 'empty' bucket
if v is None:
return "empty"
if isinstance(v, str) and v.strip() == "":
return "empty"
return str(v)
# top submitted answers (by answer_compare) score_counts = {_norm_score_key(row["score"]): row["count"] for row in ua_qs.values("score").annotate(count=Count("id"))}
top_answers = list(ua_qs.values("answer_compare").annotate(count=Count("id")).order_by("-count")[:10])
# top submitted answers (by answer_compare) with dominant score per answer
# First, get counts grouped by answer_compare and score
grouped = ua_qs.values("answer_compare", "score").annotate(count=Count("id"))
# Aggregate totals and score breakdown per answer_compare
answer_map = {}
for row in grouped:
ans = row["answer_compare"]
sc = _norm_score_key(row["score"])
cnt = row["count"]
if ans not in answer_map:
answer_map[ans] = {"total": 0, "score_counts": {}}
answer_map[ans]["total"] += cnt
answer_map[ans]["score_counts"][sc] = answer_map[ans]["score_counts"].get(sc, 0) + cnt
# Build list sorted by total count desc, and choose a dominant score per answer
top_answers = []
for ans, meta in answer_map.items():
# pick dominant score by highest count; tie-break prefer higher numeric score
ans_score_counts = meta["score_counts"]
# sort by (count desc, score desc) where 'empty' maps to -1
def score_key(item):
s, c = item
# Map 'empty' (or any non-numeric key) to a low value so numeric scores win ties
if s == "empty":
s_val = -1
else:
try:
s_val = int(s)
except Exception:
s_val = -1
return (c, s_val)
dominant = max(ans_score_counts.items(), key=score_key)[0]
top_answers.append({"answer_compare": ans, "count": meta["total"], "dominant_score": dominant})
top_answers.sort(key=lambda x: x["count"], reverse=True)
top_answers = top_answers[:10]
correct_count = None correct_count = None
correct_pct = None correct_pct = None
@@ -1,4 +1,4 @@
{% extends 'generic/examcollection_base.html' %} {% extends exam.app_name|add:'/exams.html' %}
{% block content %} {% block content %}
<div class="container my-4"> <div class="container my-4">
@@ -6,9 +6,6 @@
<div id="review-root"> <div id="review-root">
{# The #review-content element is replaced by HTMX when navigating between questions. #} {# The #review-content element is replaced by HTMX when navigating between questions. #}
<div id="review-content"> <div id="review-content">
{# The view selects an app-specific fragment if present and passes its name
in `fragment_template`. Include that fragment here so the full-page
render and the HTMX partials both use the same template. #}
{% include fragment_template %} {% include fragment_template %}
</div> </div>
</div> </div>
@@ -0,0 +1,53 @@
{% extends exam.app_name|add:'/exams.html' %}
{% block content %}
<div class="container my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Review overview: {{ exam.name }}</h2>
<div>
<a class="btn btn-primary" href="{% url app_name|add:':exam_review_question' exam.pk 0 %}">Start sequential review</a>
</div>
</div>
<p class="text-muted small">Colour indicates question results (for exam): green (good), yellow (check), red (needs attention). Hover for details.</p>
<div class="row">
{% for qs in question_summaries %}
<div class="col-md-6 mb-3">
<div class="card border-{{ qs.colour }} h-100">
<div class="card-body">
<div class="d-flex justify-content-between">
<h5 class="card-title">Question {{ qs.q_index|add:1 }}: {{ qs.question |safe }}</h5>
<span class="badge bg-{{ qs.colour }} align-self-start">{% if qs.correct_pct is not None %}{{ qs.correct_pct }}% correct{% elif qs.answered_pct is not None %}{{ qs.answered_pct }}% answered{% else %}No data{% endif %}</span>
</div>
<p class="card-text small text-muted mb-2">Responses: {{ qs.total_responses }}{% if qs.top_answer %} — top: "{{ qs.top_answer }}"{% endif %}</p>
{% if qs.correct_answer %}
<p class="card-text small mb-2"><strong>Correct:</strong> <span class="text-success">{{ qs.correct_answer|safe }}</span></p>
{% endif %}
<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-primary" href="{% url app_name|add:':exam_review_question' exam.pk qs.q_index %}">Review</a>
<a class="btn btn-sm btn-outline-secondary" href="#" onclick="document.location.href='{% url app_name|add:':question_detail' qs.question.pk %}';return false;">Question detail</a>
</div>
<details class="mt-2">
<summary class="small">Answer breakdown</summary>
<ul class="list-unstyled small mt-2">
{% if qs.breakdown %}
{% for ans in qs.breakdown %}
<li>{{ ans.0 }}: {{ ans.1 }} ({{ ans.2 }}%)</li>
{% endfor %}
{% else %}
<li class="text-muted">No responses yet</li>
{% endif %}
</ul>
</details>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
+335 -13
View File
@@ -1165,7 +1165,14 @@ class ExamViews(View, LoginRequiredMixin):
@method_decorator(login_required) @method_decorator(login_required)
def exam_review_start(self, request, pk): def exam_review_start(self, request, pk):
"""Start a per-question review for an exam. Redirects to the first question.""" """Render an overview page for per-question review.
This shows a compact summary for every question in the exam (response counts,
percent answered / percent correct where applicable) and colour-codes each
question so the reviewer can quickly triage items. From here the reviewer
can jump to an individual question's review page or start the sequential
review (existing behavior).
"""
# Ensure the user can edit the exam (authors only) # Ensure the user can edit the exam (authors only)
if not self.check_user_edit_access(request.user, exam_id=pk): if not self.check_user_edit_access(request.user, exam_id=pk):
raise PermissionDenied raise PermissionDenied
@@ -1177,8 +1184,249 @@ class ExamViews(View, LoginRequiredMixin):
if not questions: if not questions:
return render(request, "generic/exam_review_complete.html", {"exam": exam, "app_name": self.app_name}) return render(request, "generic/exam_review_complete.html", {"exam": exam, "app_name": self.app_name})
# Redirect (render) the first question # Build per-question summary metrics
return self.exam_review_question(request, pk, q_index=0) question_summaries = []
# Candidate pool size (used to compute percent answered)
candidate_total = 0
try:
candidate_total = (
exam.valid_cid_users.count() + exam.valid_user_users.count()
)
except Exception:
candidate_total = 0
for i, q in enumerate(questions):
try:
ua_qs = q.cid_user_answers.filter(exam=exam)
except Exception:
# If relationship is different for some app types, try a fallback
ua_qs = self.UserAnswer.objects.filter(question=q, exam=exam)
total_responses = ua_qs.count()
counts = {}
# Some UserAnswer models (e.g. physics) do not have a single 'answer'
# field. Try the simple values_list first; on failure fall back to
# iterating objects and using get_answer() or composing from
# per-field parts (a,b,c,d,e, or longs sections).
try:
for ans in ua_qs.values_list("answer", flat=True):
counts[ans] = counts.get(ans, 0) + 1
except Exception:
for ua in ua_qs:
ans_val = None
# Prefer model helper if available
try:
if hasattr(ua, "get_answer"):
ans_val = ua.get_answer()
except Exception:
ans_val = None
if not ans_val:
parts = []
# Common single-letter fields used by physics
for fld in ("a", "b", "c", "d", "e"):
if hasattr(ua, fld):
v = getattr(ua, fld)
if v is None:
continue
if isinstance(v, (list, tuple)):
parts.extend([str(x) for x in v])
else:
parts.append(str(v))
# Long-form answer fields (longs)
for fld in (
"answer_observations",
"answer_interpretation",
"answer_principle_diagnosis",
"answer_differential_diagnosis",
"answer_management",
):
if hasattr(ua, fld):
v = getattr(ua, fld)
if v:
parts.append(str(v))
if parts:
ans_val = ", ".join(parts)
# Final fallback
if ans_val is None:
ans_val = ""
counts[ans_val] = counts.get(ans_val, 0) + 1
if total_responses:
pcts = {k: round(100.0 * v / total_responses, 1) for k, v in counts.items()}
else:
pcts = {k: 0.0 for k in counts.keys()}
# Compute percent correct using per-answer scoring when available.
# Different apps use different scoring scales (e.g. anatomy/rapids=2, longs=8,
# physics/shorts=5, sbas=1). We determine the per-question maximum based on
# the app and count user answers whose get_answer_score() equals that max.
correct_pct = None
correct_count = None
try:
# Determine per-question maximum score for this app
if self.app_name in ("rapids", "anatomy"):
per_question_max = 2
elif self.app_name == "longs":
per_question_max = 8
elif self.app_name in ("physics", "shorts"):
per_question_max = 5
else:
per_question_max = 1
# If we have responses, iterate and count fully-correct answers using
# the model's get_answer_score() which encapsulates per-app logic.
if total_responses:
# Special-case physics: count each true/false sub-question individually.
if self.app_name == "physics":
# Determine number of sub-items per question
try:
n_sub = len(q.get_questions())
except Exception:
n_sub = None
total_subitems = 0
correct_subitems = 0
for ua in ua_qs:
try:
score = ua.get_answer_score()
except Exception:
continue
# score is expected to be iterable (tuple/list) for physics
if isinstance(score, (list, tuple)) and score:
# count numeric-typed positive/1 values as correct
for s in score:
if isinstance(s, (int, float)) and s > 0:
correct_subitems += 1
total_subitems += len(score)
else:
# fallback: treat scalar score as single subitem
if isinstance(score, (int, float)):
if score > 0:
correct_subitems += 1
total_subitems += 1
# Compute percent over all subitems seen
correct_count = correct_subitems
if total_subitems:
correct_pct = round(100.0 * correct_subitems / total_subitems, 1)
else:
correct_pct = 0.0
else:
cnt = 0
for ua in ua_qs:
try:
score = ua.get_answer_score()
except Exception:
# If get_answer_score fails for any answer, skip it
continue
if score == per_question_max:
cnt += 1
correct_count = cnt
correct_pct = round(100.0 * correct_count / total_responses, 1) if total_responses else 0.0
else:
correct_count = 0
correct_pct = 0.0
except Exception:
# Best-effort: fall back to None if something unexpected happens
correct_pct = None
correct_count = None
# Percent answered relative to candidate pool (if known)
answered_pct = None
if candidate_total:
try:
answered_pct = round(100.0 * total_responses / candidate_total, 1)
except Exception:
answered_pct = None
# Pick a simple colour classification: prefer percent-correct when available,
# otherwise percent-answered.
pct_for_colour = None
if correct_pct is not None:
pct_for_colour = correct_pct
elif answered_pct is not None:
pct_for_colour = answered_pct
colour = "secondary"
if pct_for_colour is not None:
if pct_for_colour >= 75:
colour = "success"
elif pct_for_colour >= 50:
colour = "warning"
else:
colour = "danger"
# Compute top answer (most common)
top_answer = None
top_count = 0
for k, v in counts.items():
if v > top_count:
top_count = v
top_answer = k
# Build a simple breakdown list of (answer, count, pct) for template rendering
breakdown = []
for k, v in counts.items():
pct_val = pcts.get(k, 0.0) if isinstance(pcts, dict) else 0.0
breakdown.append((k, v, pct_val))
# Determine the canonical correct answer representation for display.
correct_answer = None
try:
if self.app_name == "sbas":
correct_answer = q.get_correct_answer()
elif self.app_name == "physics":
correct_answer = q.get_answers()
else:
# get_primary_answer is used elsewhere as the generic primary answer
try:
correct_answer = q.get_primary_answer()
except Exception:
correct_answer = getattr(q, "best_answer", None)
# Normalise iterable answers into a readable string
if isinstance(correct_answer, (list, tuple)):
try:
correct_answer = ", ".join([str(x) for x in correct_answer])
except Exception:
correct_answer = str(correct_answer)
elif correct_answer is not None:
correct_answer = str(correct_answer)
except Exception:
correct_answer = None
question_summaries.append(
{
"question": q,
"q_index": i,
"total_responses": total_responses,
"counts": counts,
"pcts": pcts,
"correct_pct": correct_pct,
"correct_answer": correct_answer,
"answered_pct": answered_pct,
"colour": colour,
"top_answer": top_answer,
"breakdown": breakdown,
}
)
return render(
request,
"generic/exam_review_start.html",
{
"exam": exam,
"question_summaries": question_summaries,
"question_number": len(questions),
"app_name": self.app_name,
},
)
@method_decorator(login_required) @method_decorator(login_required)
def exam_review_question(self, request, pk, q_index=0): def exam_review_question(self, request, pk, q_index=0):
@@ -1223,8 +1471,49 @@ class ExamViews(View, LoginRequiredMixin):
ua_qs = question.cid_user_answers.filter(exam=exam) ua_qs = question.cid_user_answers.filter(exam=exam)
total_responses = ua_qs.count() total_responses = ua_qs.count()
counts = {} counts = {}
try:
for ans in ua_qs.values_list("answer", flat=True): for ans in ua_qs.values_list("answer", flat=True):
counts[ans] = counts.get(ans, 0) + 1 counts[ans] = counts.get(ans, 0) + 1
except Exception:
for ua in ua_qs:
ans_val = None
try:
if hasattr(ua, "get_answer"):
ans_val = ua.get_answer()
except Exception:
ans_val = None
if not ans_val:
parts = []
for fld in ("a", "b", "c", "d", "e"):
if hasattr(ua, fld):
v = getattr(ua, fld)
if v is None:
continue
if isinstance(v, (list, tuple)):
parts.extend([str(x) for x in v])
else:
parts.append(str(v))
for fld in (
"answer_observations",
"answer_interpretation",
"answer_principle_diagnosis",
"answer_differential_diagnosis",
"answer_management",
):
if hasattr(ua, fld):
v = getattr(ua, fld)
if v:
parts.append(str(v))
if parts:
ans_val = ", ".join(parts)
if ans_val is None:
ans_val = ""
counts[ans_val] = counts.get(ans_val, 0) + 1
# Compute percentages per answer key # Compute percentages per answer key
pcts = {} pcts = {}
@@ -1235,19 +1524,50 @@ class ExamViews(View, LoginRequiredMixin):
for k in counts.keys(): for k in counts.keys():
pcts[k] = 0.0 pcts[k] = 0.0
# If the question exposes a best_answer, compute correct count # Compute correct count using per-answer scoring when available.
# Different apps use different scoring scales (e.g. anatomy/rapids=2,
# longs=8, physics/shorts=5, sbas=1). Determine per-question max
# and count answers where get_answer_score() equals that max.
correct_count = None correct_count = None
if hasattr(question, "best_answer") and question.best_answer:
correct_count = ua_qs.filter(answer=question.best_answer).count()
else:
correct_count = None
# percent correct
correct_pct = None correct_pct = None
if correct_count is not None and total_responses:
try: try:
correct_pct = round(100.0 * correct_count / total_responses, 1) if self.app_name in ("rapids", "anatomy"):
per_question_max = 2
elif self.app_name == "longs":
per_question_max = 8
elif self.app_name in ("physics", "shorts"):
per_question_max = 5
else:
per_question_max = 1
if total_responses:
cnt = 0
for ua in ua_qs:
try:
score = ua.get_answer_score()
except Exception: except Exception:
continue
# Handle tuple/list scores (physics) by summing parts
if isinstance(score, (list, tuple)):
try:
ssum = sum([s for s in score if isinstance(s, (int, float))])
except Exception:
continue
if ssum == per_question_max:
cnt += 1
else:
# Numeric scores only; skip 'unmarked' / None
if isinstance(score, (int, float)) and score == per_question_max:
cnt += 1
correct_count = cnt
correct_pct = round(100.0 * correct_count / total_responses, 1) if total_responses else 0.0
else:
correct_count = 0
correct_pct = 0.0
except Exception:
correct_count = None
correct_pct = None correct_pct = None
context.update( context.update(
@@ -3610,7 +3930,9 @@ class GenericViewBase:
def question_detail(self, request, pk): def question_detail(self, request, pk):
question: QuestionBase = get_object_or_404(self.question_object, pk=pk) question: QuestionBase = get_object_or_404(self.question_object, pk=pk)
if not question.open_access: if request.user.is_superuser:
pass
elif not question.open_access:
if ( if (
not request.user.groups.filter(name=self.checker_group).exists() not request.user.groups.filter(name=self.checker_group).exists()
and request.user not in question.author.all() and request.user not in question.author.all()
+9
View File
@@ -129,6 +129,15 @@ class Question(QuestionBase):
def get_questions(self): def get_questions(self):
return [self.a, self.b, self.c, self.d, self.e] return [self.a, self.b, self.c, self.d, self.e]
def get_questions_and_answers_and_feedback(self):
return [
(self.a, self.a_answer, self.a_feedback),
(self.b, self.b_answer, self.b_feedback),
(self.c, self.c_answer, self.c_feedback),
(self.d, self.d_answer, self.d_feedback),
(self.e, self.e_answer, self.e_feedback),
]
def get_unanswered_mark_and_text(self) -> tuple[int, tuple[str, str, str, str, str]]: def get_unanswered_mark_and_text(self) -> tuple[int, tuple[str, str, str, str, str]]:
""" """
@@ -1,3 +1,95 @@
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Question</h5>
<p class="lead">{{ question|safe }}</p>
<div class="mt-3">
<h6>Parts</h6>
<ul class="list-group">
{% if question.get_questions %}
{% for question, answer, feedback in question.get_questions_and_answers_and_feedback %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>
<strong>Part {{ forloop.counter }}.</strong>
<span class="ms-2">{{ question }}</span>
</div>
<div>
<span class="badge bg-primary">Answer: {{ answer }}</span>
<span class="ms-2">{{ feedback }}</span>
</div>
</li>
{% endfor %}
{% else %}
<li class="list-group-item">No parts defined for this question.</li>
{% endif %}
</ul>
</div>
<p class="mt-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
</p>
<div class="mt-3">
<h6>Response summary</h6>
<div id="physics-response-summary"
hx-get="{% url 'physics:exam_review_question_summary' exam.pk q_index %}"
hx-trigger="load"
hx-swap="innerHTML"
hx-indicator="#physics-summary-indicator-{{ question.pk }}">
<div id="physics-summary-indicator-{{ question.pk }}" style="display:none">
<span class="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading summary</span>
</div>
<style> #physics-summary-indicator-{{ question.pk }}.htmx-request{display:inline-block !important;} </style>
<div class="small text-muted">Loading summary…</div>
</div>
</div>
<div class="mt-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">Responses in this exam</h6>
<div>
<button class="btn btn-sm btn-outline-primary"
hx-get="{% url 'physics:exam_review_question_responses' exam.pk q_index %}"
hx-target="#physics-responses-container"
hx-swap="innerHTML"
hx-indicator="#physics-responses-indicator-{{ question.pk }}">Show responses</button>
</div>
</div>
<div class="mt-2" id="physics-responses-container">
<div id="physics-responses-indicator-{{ question.pk }}" style="display:none">
<span class="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading responses</span>
</div>
<style> #physics-responses-indicator-{{ question.pk }}.htmx-request{display:inline-block !important;} </style>
<div class="text-muted small">Responses are hidden — click “Show responses” to load.</div>
</div>
</div>
<div class="d-flex justify-content-between mt-3">
{% if prev_index is not None %}
<a class="btn btn-outline-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk prev_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">&larr; Previous</a>
{% else %}
<span></span>
{% endif %}
{% if next_index is not None %}
<a class="btn btn-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk next_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">Next &rarr;</a>
{% else %}
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
{% endif %}
</div>
</div>
</div>
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<div class="mb-2"><span class="badge bg-primary">Physics</span></div> <div class="mb-2"><span class="badge bg-primary">Physics</span></div>
@@ -0,0 +1,104 @@
{# Responses partial for physics question within an exam. HTMX-loadable. #}
<div id="physics-responses-container">
<h6>Responses in this exam</h6>
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="small text-muted">List of candidate answers recorded for this question in the current exam.</div>
<div>
{% if reveal_respondents %}
<button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'physics:exam_review_question_responses' exam.pk q_index %}" hx-target="#physics-responses-container" hx-swap="outerHTML">Hide respondents</button>
{% else %}
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'physics:exam_review_question_responses' exam.pk q_index %}?reveal=1" hx-target="#physics-responses-container" hx-swap="outerHTML">Show respondents</button>
{% endif %}
</div>
</div>
<ul class="list-group">
{% for ua in exam_user_answers %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>
{% if reveal_respondents %}
{% if ua.user %}
<strong>{{ ua.user.get_full_name|default:ua.user.username }}</strong>
{% else %}
<strong>Anonymous</strong>
{% endif %}
{% else %}
<strong>Respondent {{ forloop.counter }}</strong>
{% endif %}
<div class="mt-1">Answer:
<div class="d-flex gap-1 mt-1">
{# Show parts A-E as coloured badges indicating correctness #}
<div class="d-flex align-items-center">
<span class="me-1 small text-muted">A</span>
{% if ua.a is None %}
<span class="badge border border-secondary text-muted bg-white">N/A</span>
{% elif ua.a == question.a_answer %}
<span class="badge border border-success text-success bg-white">{{ ua.a|yesno:"T,F" }}</span>
{% else %}
<span class="badge border border-danger text-danger bg-white">{{ ua.a|yesno:"T,F" }}</span>
{% endif %}
</div>
<div class="d-flex align-items-center">
<span class="me-1 small text-muted">B</span>
{% if ua.b is None %}
<span class="badge border border-secondary text-muted bg-white">N/A</span>
{% elif ua.b == question.b_answer %}
<span class="badge border border-success text-success bg-white">{{ ua.b|yesno:"T,F" }}</span>
{% else %}
<span class="badge border border-danger text-danger bg-white">{{ ua.b|yesno:"T,F" }}</span>
{% endif %}
</div>
<div class="d-flex align-items-center">
<span class="me-1 small text-muted">C</span>
{% if ua.c is None %}
<span class="badge border border-secondary text-muted bg-white">N/A</span>
{% elif ua.c == question.c_answer %}
<span class="badge border border-success text-success bg-white">{{ ua.c|yesno:"T,F" }}</span>
{% else %}
<span class="badge border border-danger text-danger bg-white">{{ ua.c|yesno:"T,F" }}</span>
{% endif %}
</div>
<div class="d-flex align-items-center">
<span class="me-1 small text-muted">D</span>
{% if ua.d is None %}
<span class="badge border border-secondary text-muted bg-white">N/A</span>
{% elif ua.d == question.d_answer %}
<span class="badge border border-success text-success bg-white">{{ ua.d|yesno:"T,F" }}</span>
{% else %}
<span class="badge border border-danger text-danger bg-white">{{ ua.d|yesno:"T,F" }}</span>
{% endif %}
</div>
<div class="d-flex align-items-center">
<span class="me-1 small text-muted">E</span>
{% if ua.e is None %}
<span class="badge border border-secondary text-muted bg-white">N/A</span>
{% elif ua.e == question.e_answer %}
<span class="badge border border-success text-success bg-white">{{ ua.e|yesno:"T,F" }}</span>
{% else %}
<span class="badge border border-danger text-danger bg-white">{{ ua.e|yesno:"T,F" }}</span>
{% endif %}
</div>
</div>
</div>
{% if ua.created_date %}
<div class="small text-muted">Answered: {{ ua.created_date }}</div>
{% endif %}
</div>
<div>
{# Physics scoring may be numeric; show a simple badge if fully correct according to score field where present #}
{% if ua.score %}
{% if ua.score|stringformat:'s' == '5' %}
<span class="badge bg-success">Correct</span>
{% else %}
<span class="badge bg-secondary">Score: {{ ua.score }}</span>
{% endif %}
{% else %}
<span class="badge bg-light text-muted">Unmarked</span>
{% endif %}
</div>
</li>
{% empty %}
<li class="list-group-item">No responses recorded for this exam.</li>
{% endfor %}
</ul>
</div>
@@ -0,0 +1,97 @@
{# Aggregated response summary partial for physics questions (HTMX-loadable) #}
<div id="physics-response-summary-content">
{% if exam_response_total %}
<div class="small text-muted mb-2">Total responses: {{ exam_response_total }}</div>
{# Per-part TF breakdown (A-E) #}
{% if per_part_stats %}
<div class="row mb-3">
{% for part in per_part_stats %}
{# colour by percent-correct: green >=75, warning >=50, red otherwise #}
<div class="col-md-2">
<div class="card">
<div class="card-body p-2 text-center {% if part.correct_pct >= 75 %}border border-success{% elif part.correct_pct >= 50 %}border border-warning{% elif part.correct_pct > 0 %}border border-danger{% endif %}">
<div class="small text-muted">{{ part.part|upper }}</div>
<div class="h5 mb-0">{{ part.correct_count }} / {{ part.total }}</div>
<div class="small text-muted">Correct: {{ part.correct_pct }}%</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<div class="row mb-2">
<div class="col-auto">Correct:</div>
<div class="col">
{% if exam_response_correct_count is not None %}
<strong>{{ exam_response_correct_count }}</strong>
{% if exam_response_correct_pct is not None %}
<span class="text-muted">({{ exam_response_correct_pct }}%)</span>
{% endif %}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</div>
</div>
<div class="list-group mb-2">
{# Render top-level answer breakdown from exam_response_counts and pcts #}
{% for item in exam_response_items %}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>
{% if item.parts %}
<div class="d-flex gap-1 align-items-center">
{% for part in item.parts %}
{% with pstats=per_part_stats|get_item:forloop.counter0 %}
{% if pstats %}
{% if pstats.correct_pct >= 75 %}
<span class="badge border border-success text-success bg-white">{{ part.value|yesno:"T,F" }}</span>
{% elif pstats.correct_pct >= 50 %}
<span class="badge border border-warning text-warning bg-white">{{ part.value|yesno:"T,F" }}</span>
{% else %}
<span class="badge border border-danger text-danger bg-white">{{ part.value|yesno:"T,F" }}</span>
{% endif %}
{% else %}
{% if part.is_correct == True %}
<span class="badge border border-success text-success bg-white">{{ part.value|yesno:"T,F" }}</span>
{% elif part.is_correct == False %}
<span class="badge border border-danger text-danger bg-white">{{ part.value|yesno:"T,F" }}</span>
{% else %}
<span class="badge border border-secondary text-muted bg-white">{{ part.value|default:"-" }}</span>
{% endif %}
{% endif %}
{% endwith %}
{% endfor %}
</div>
{% else %}
<div>{{ item.key }}</div>
{% endif %}
</div>
<div class="text-end"><strong>{{ item.count }}</strong> <small class="text-muted">{{ item.pct }}%</small></div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-primary" role="progressbar" style="width: {{ item.pct }}%;" aria-valuenow="{{ item.pct }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
{% endfor %}
</div>
<div>
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-1">Correct answer</h6>
<button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#correct-answer-{{ q_index }}" aria-expanded="false" aria-controls="correct-answer-{{ q_index }}">
Show
</button>
</div>
<div class="collapse mt-2" id="correct-answer-{{ q_index }}">
{% if correct_answer %}
<div class="small">{{ correct_answer }}</div>
{% else %}
<div class="small text-muted">No correct answer available.</div>
{% endif %}
</div>
</div>
{% else %}
<div class="small text-muted">No responses recorded for this question in the exam yet.</div>
{% endif %}
</div>
+10
View File
@@ -71,6 +71,16 @@ urlpatterns.extend(
views.UserAnswerDelete.as_view(), views.UserAnswerDelete.as_view(),
name="user_answer_delete", name="user_answer_delete",
), ),
path(
"exam/<int:pk>/review/<int:q_index>/responses",
views.exam_review_question_responses,
name="exam_review_question_responses",
),
path(
"exam/<int:pk>/review/<int:q_index>/summary",
views.exam_review_question_summary,
name="exam_review_question_summary",
),
# TODO: consider merging with generic... # TODO: consider merging with generic...
#path( #path(
# "exam/<int:pk>/scores/<int:cid>/<str:passcode>/", # "exam/<int:pk>/scores/<int:cid>/<str:passcode>/",
+265
View File
@@ -414,3 +414,268 @@ class UserAnswerDelete(SuperuserRequiredMixin, DeleteView):
model = UserAnswer model = UserAnswer
template_name = "user_answer_delete.html" template_name = "user_answer_delete.html"
success_url = reverse_lazy("physics:user_answer_table_view") success_url = reverse_lazy("physics:user_answer_table_view")
@login_required
def exam_review_question_responses(request, pk: int, q_index: int):
"""Return the responses partial for a given exam question (HTMX-friendly)."""
exam = get_object_or_404(Exam, pk=pk)
questions = exam.get_questions()
try:
question = questions[q_index]
except Exception:
raise Http404("Question not found in exam")
context = {
"exam": exam,
"question": question,
"q_index": q_index,
"app_name": "physics",
}
# Prefilter user answers for this exam to avoid template-side filtering
try:
exam_user_answers = question.cid_user_answers.filter(exam=exam).select_related("user")
except Exception:
exam_user_answers = question.cid_user_answers.all()
# Default: anonymise unless reveal=1 is provided
reveal_flag = str(request.GET.get("reveal", "")).lower() in ("1", "true", "on")
context["reveal_respondents"] = reveal_flag
context["exam_user_answers"] = exam_user_answers
return render(request, "physics/partials/exam_review_question_responses_fragment.html", context)
@login_required
def exam_review_question_summary(request, pk: int, q_index: int):
"""Return the aggregated response summary partial for a given exam question (HTMX-friendly)."""
exam = get_object_or_404(Exam, pk=pk)
questions = exam.get_questions()
try:
question = questions[q_index]
except Exception:
raise Http404("Question not found in exam")
# Aggregate user answers for this question & exam
ua_qs = question.cid_user_answers.filter(exam=exam)
total_responses = ua_qs.count()
# Physics answers may be stored as tuples/lists or named fields; attempt to aggregate
counts = defaultdict(int)
other_count = 0
# Try simple 'answer' field aggregation first; if not present, fall
# back to iterating objects and using get_answer() or composing parts.
try:
for ans in ua_qs.values_list("answer", flat=True):
if not ans:
other_count += 1
continue
if isinstance(ans, (list, tuple)):
key = ", ".join([str(x) for x in ans])
else:
key = str(ans)
counts[key] += 1
except Exception:
for ua in ua_qs:
ans_val = None
try:
if hasattr(ua, "get_answer"):
ans_val = ua.get_answer()
except Exception:
ans_val = None
if not ans_val:
parts = []
for fld in ("a", "b", "c", "d", "e"):
if hasattr(ua, fld):
v = getattr(ua, fld)
if v is None:
continue
if isinstance(v, (list, tuple)):
parts.extend([str(x) for x in v])
else:
parts.append(str(v))
if parts:
ans_val = ", ".join(parts)
if not ans_val:
other_count += 1
continue
counts[ans_val] += 1
counts["unanswered"] = other_count
pcts = {}
if total_responses:
for k, v in counts.items():
pcts[k] = round(100.0 * v / total_responses, 1)
else:
for k in counts.keys():
pcts[k] = 0.0
# Provide an explicit iterable of structured items for templates to consume.
# Each item includes the original key, count, pct and optional per-part
# breakdown (list of {value, is_correct}) for physics multi-part answers.
exam_response_items = []
parts_names = ["a", "b", "c", "d", "e"]
for k, v in sorted(counts.items(), key=lambda x: x[1], reverse=True):
item = {"key": k, "count": v, "pct": pcts.get(k, 0.0), "parts": None}
# Attempt to parse comma-separated boolean-like keys into parts
try:
if isinstance(k, str) and "," in k:
parts_raw = [p.strip() for p in k.split(",")]
if len(parts_raw) == 5:
parts_details = []
for idx, sval in enumerate(parts_raw):
sval_l = sval.lower()
if sval_l in ("true", "t", "1", "yes"):
val = True
elif sval_l in ("false", "f", "0", "no"):
val = False
elif sval_l in ("none", "", "not answered", "not_answered"):
val = None
else:
# leave as string fallback
val = sval
# determine correctness where possible
is_correct = None
try:
correct_val = getattr(question, f"{parts_names[idx]}_answer")
if isinstance(val, bool) and isinstance(correct_val, bool):
is_correct = val == correct_val
except Exception:
is_correct = None
parts_details.append({"value": val, "is_correct": is_correct})
item["parts"] = parts_details
except Exception:
item["parts"] = None
exam_response_items.append(item)
# Compute percent correct using per-answer scoring
correct_count = None
correct_pct = None
try:
# For physics, count true/false sub-questions individually rather than
# requiring a full-match across all parts.
if total_responses:
correct_subitems = 0
total_subitems = 0
for ua in ua_qs:
try:
score = ua.get_answer_score()
except Exception:
continue
if isinstance(score, (list, tuple)) and score:
for s in score:
if isinstance(s, (int, float)) and s > 0:
correct_subitems += 1
total_subitems += len(score)
else:
if isinstance(score, (int, float)):
if score > 0:
correct_subitems += 1
total_subitems += 1
correct_count = correct_subitems
if total_subitems:
correct_pct = round(100.0 * correct_subitems / total_subitems, 1)
else:
correct_pct = 0.0
else:
correct_count = 0
correct_pct = 0.0
except Exception:
correct_count = None
correct_pct = None
# Per-part (a-e) true/false aggregation. Each physics question has 5 parts
# stored as boolean fields on UserAnswer (a,b,c,d,e). Build counts and
# percentages for each part so templates can render them as separate TF
# sub-questions.
per_part_stats = []
try:
parts = ["a", "b", "c", "d", "e"]
# initialize counters
for idx, part in enumerate(parts):
per_part_stats.append({
"part": part,
"label": getattr(question, part).strip() if getattr(question, part, None) else f"Part {idx+1}",
"true_count": 0,
"false_count": 0,
"total": 0,
"correct_count": 0,
"correct_pct": 0.0,
"pct_true": 0.0,
})
for ua in ua_qs:
for idx, part in enumerate(parts):
try:
val = getattr(ua, part)
except Exception:
continue
if val is True:
per_part_stats[idx]["true_count"] += 1
elif val is False:
per_part_stats[idx]["false_count"] += 1
# count only explicit booleans as total
if isinstance(val, bool):
per_part_stats[idx]["total"] += 1
# correct counting
try:
correct_val = getattr(question, f"{part}_answer")
except Exception:
correct_val = None
if isinstance(val, bool) and isinstance(correct_val, bool):
if val == correct_val:
per_part_stats[idx]["correct_count"] += 1
# compute percentages
for stats in per_part_stats:
if stats["total"]:
stats["pct_true"] = round(100.0 * stats["true_count"] / stats["total"], 1)
stats["correct_pct"] = round(100.0 * stats["correct_count"] / stats["total"], 1)
else:
stats["pct_true"] = 0.0
stats["correct_pct"] = 0.0
except Exception:
per_part_stats = []
# Provide a best-effort correct answer display
correct_answer = None
try:
correct_answer = question.get_answers()
if isinstance(correct_answer, (list, tuple)):
correct_answer = ", ".join([str(x) for x in correct_answer])
else:
correct_answer = str(correct_answer)
except Exception:
correct_answer = None
context = {
"exam": exam,
"question": question,
"q_index": q_index,
"app_name": "physics",
"exam_response_counts": counts,
"exam_response_items": exam_response_items,
"exam_response_pcts": pcts,
"exam_response_total": total_responses,
"exam_response_correct_count": correct_count,
"exam_response_correct_pct": correct_pct,
"exam_response_texts": {},
"correct_answer": correct_answer,
"per_part_stats": per_part_stats,
}
return render(request, "physics/partials/exam_review_question_summary_fragment.html", context)
+4
View File
@@ -303,6 +303,10 @@ TINYMCE_DEFAULT_CONFIG = {
"skin": "oxide-dark", "skin": "oxide-dark",
"menubar": "tools", "menubar": "tools",
"content_css": "dark", "content_css": "dark",
# Disable the "Powered by Tiny" branding badge in the editor UI
# (TinyMCE option: https://www.tiny.cloud/docs/configure/editor-appearance/#branding)
"branding": False,
"promotion": False,
} }
DEFAULT_AUTO_FIELD = "django.db.models.AutoField" DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
+6 -1
View File
@@ -184,7 +184,12 @@ button a {
float: right; float: right;
} }
#dicom-image { #dicom-image-legacy {
width: 100%;
height: 600px;
}
#anatomy-dicom-image {
width: 100%; width: 100%;
height: 600px; height: 600px;
} }
@@ -23,7 +23,7 @@
{% for image in question.images.all %} {% for image in question.images.all %}
<span class="image-block"> <span class="image-block">
Image {{ forloop.counter }}{% if image.description %} ({{image.description}}){% endif %}{% if image.feedback_image %} [feedback image]{% endif %}: Image {{ forloop.counter }}{% if image.description %} ({{image.description}}){% endif %}{% if image.feedback_image %} [feedback image]{% endif %}:
<div class="dicom-image rapid-img {% if image.feedback_image %}feedback-img{% endif %}" <div class="dicom-image-legacy rapid-img {% if image.feedback_image %}feedback-img{% endif %}"
data-url="https://www.penracourses.org.uk{{ image.image.url}}"></div> data-url="https://www.penracourses.org.uk{{ image.image.url}}"></div>
</span> </span>
{% endfor %} {% endfor %}
+40
View File
@@ -22,6 +22,7 @@ from django.contrib.admin.widgets import FilteredSelectMultiple
from django.forms.widgets import RadioSelect, TextInput, Textarea from django.forms.widgets import RadioSelect, TextInput, Textarea
from tinymce.widgets import TinyMCE from tinymce.widgets import TinyMCE
import re
from dal import autocomplete from dal import autocomplete
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
@@ -160,6 +161,45 @@ class QuestionForm(ModelForm):
return instance return instance
def clean(self):
"""Strip a single wrapping <p>...</p> from TinyMCE-provided HTML fields.
This handles the common TinyMCE behaviour where the editor wraps
inline content in a root <p>. We only unwrap when the entire value
is a single <p> element (so multi-paragraph content is preserved).
"""
cleaned = super(QuestionForm, self).clean()
def _unwrap_single_p(html: str) -> str:
if not html or not isinstance(html, str):
return html
# Match a single <p ...>...</p> that spans the whole string
m = re.fullmatch(r"\s*<p\b[^>]*>(.*)</p>\s*", html, flags=re.DOTALL | re.IGNORECASE)
if m:
return m.group(1)
return html
fields_to_unwrap = [
"stem",
"feedback",
"a_answer",
"a_feedback",
"b_answer",
"b_feedback",
"c_answer",
"c_feedback",
"d_answer",
"d_feedback",
"e_answer",
"e_feedback",
]
for fname in fields_to_unwrap:
if fname in cleaned:
cleaned[fname] = _unwrap_single_p(cleaned.get(fname))
return cleaned
class Meta: class Meta:
model = Question model = Question
View File
@@ -0,0 +1,100 @@
from django.core.management.base import BaseCommand
from django.db import transaction
from sbas.models import Question
import re
from textwrap import shorten
UNWRAP_FIELDS = [
"stem",
"feedback",
"a_answer",
"a_feedback",
"b_answer",
"b_feedback",
"c_answer",
"c_feedback",
"d_answer",
"d_feedback",
"e_answer",
"e_feedback",
]
RE_SINGLE_P = re.compile(r"\s*<p\b[^>]*>(.*)</p>\s*$", flags=re.IGNORECASE | re.DOTALL)
def unwrap_single_p(html: str) -> str:
if not html or not isinstance(html, str):
return html
m = RE_SINGLE_P.fullmatch(html)
return m.group(1) if m else html
class Command(BaseCommand):
help = "Normalize Question HTML fields by unwrapping a single root <p>...</p>. Safe: supports --dry-run and batching."
def add_arguments(self, parser):
parser.add_argument("--commit", action="store_true", help="Apply changes; without this flag it's a dry-run.")
parser.add_argument("--batch-size", type=int, default=200, help="How many objects to fetch per loop.")
parser.add_argument("--limit", type=int, default=0, help="Limit number of questions processed (0 = all).")
parser.add_argument("--verbose", action="store_true", help="Show full before/after for each change.")
parser.add_argument("--start-id", type=int, default=0, help="Start from Question.id >= start-id (useful for resuming).")
def handle(self, *args, **opts):
commit = opts["commit"]
batch_size = opts["batch_size"]
limit = opts["limit"] or None
verbose = opts["verbose"]
start_id = opts["start_id"]
qs = Question.objects.all().order_by("id")
if start_id:
qs = qs.filter(id__gte=start_id)
total = qs.count()
self.stdout.write(f"Found {total} questions to inspect (start_id={start_id}).")
processed = 0
changed = 0
it = qs.iterator(chunk_size=batch_size)
for q in it:
processed += 1
if limit and processed > limit:
break
changed_any = False
updates = {}
for f in UNWRAP_FIELDS:
orig = getattr(q, f, None)
new = unwrap_single_p(orig)
if new is not None and new != orig:
changed_any = True
updates[f] = new
if changed_any:
changed += 1
if verbose:
self.stdout.write(f"Question id={q.id} will change fields: {', '.join(updates.keys())}")
for f, newval in updates.items():
oldval = getattr(q, f)
self.stdout.write(" Field: %s\n BEFORE: %s\n AFTER : %s\n" % (
f,
(oldval or "")[:400],
(newval or "")[:400],
))
else:
sample = ", ".join(f"{k}({shorten((getattr(q,k) or ''), width=60)})" for k in updates.keys())
self.stdout.write(f"[DRY] id={q.id} -> {sample}" if not commit else f"[APPLY] id={q.id} -> {', '.join(updates.keys())}")
if commit:
for k, v in updates.items():
setattr(q, k, v)
try:
with transaction.atomic():
q.save(update_fields=list(updates.keys()))
except Exception as e:
self.stderr.write(f"Failed to save Question id={q.id}: {e}")
if processed % max(1, batch_size) == 0:
self.stdout.write(f"Processed {processed} questions...")
self.stdout.write(f"Done. Processed {processed} questions, {changed} would change{' (committed)' if commit else ' (dry-run)'}.")
@@ -1,20 +1,19 @@
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<div class="mb-2"><span class="badge bg-dark">SBAs</span></div>
<h5 class="card-title">Question</h5> <h5 class="card-title">Question</h5>
<p class="lead">{{ question }}</p> <p class="lead">{{ question|safe }}</p>
{# Use per-field answers (a..e) from sbas Question model and highlight the best answer #} {# Use per-field answers (a..e) from sbas Question model and highlight the best answer #}
<div class="mt-3"> <div class="mt-3">
<h6>Answers</h6> <h6>Answers</h6>
<ul class="list-group"> <ul class="list-group">
{% if question.a_answer %} {% if question.a_answer %}
<li class="list-group-item d-flex justify-content-between align-items-start{% if question.best_answer == 'a' %} list-group-item-success{% endif %}"> <li class="list-group-item d-flex flex-column flex-md-row justify-content-between align-items-start{% if question.best_answer == 'a' %} list-group-item-success{% endif %}">
<div> <div class="mb-2 mb-md-0">
<strong class="me-2">A.</strong> <strong class="me-2">A.</strong>
<span>{{ question.a_answer }}</span> <span class="answer-content">{{ question.a_answer|safe }}</span>
{% if question.a_feedback %} {% if question.a_feedback %}
<div class="small text-muted">Feedback: {{ question.a_feedback }}</div> <div class="small text-muted mt-1">Feedback: <span class="feedback-content">{{ question.a_feedback|safe }}</span></div>
{% endif %} {% endif %}
</div> </div>
<div> <div>
@@ -28,12 +27,12 @@
{% endif %} {% endif %}
{% if question.b_answer %} {% if question.b_answer %}
<li class="list-group-item d-flex justify-content-between align-items-start{% if question.best_answer == 'b' %} list-group-item-success{% endif %}"> <li class="list-group-item d-flex flex-column flex-md-row justify-content-between align-items-start{% if question.best_answer == 'b' %} list-group-item-success{% endif %}">
<div> <div class="mb-2 mb-md-0">
<strong class="me-2">B.</strong> <strong class="me-2">B.</strong>
<span>{{ question.b_answer }}</span> <span class="answer-content">{{ question.b_answer|safe }}</span>
{% if question.b_feedback %} {% if question.b_feedback %}
<div class="small text-muted">Feedback: {{ question.b_feedback }}</div> <div class="small text-muted mt-1">Feedback: <span class="feedback-content">{{ question.b_feedback|safe }}</span></div>
{% endif %} {% endif %}
</div> </div>
<div> <div>
@@ -47,12 +46,12 @@
{% endif %} {% endif %}
{% if question.c_answer %} {% if question.c_answer %}
<li class="list-group-item d-flex justify-content-between align-items-start{% if question.best_answer == 'c' %} list-group-item-success{% endif %}"> <li class="list-group-item d-flex flex-column flex-md-row justify-content-between align-items-start{% if question.best_answer == 'c' %} list-group-item-success{% endif %}">
<div> <div class="mb-2 mb-md-0">
<strong class="me-2">C.</strong> <strong class="me-2">C.</strong>
<span>{{ question.c_answer }}</span> <span class="answer-content">{{ question.c_answer|safe }}</span>
{% if question.c_feedback %} {% if question.c_feedback %}
<div class="small text-muted">Feedback: {{ question.c_feedback }}</div> <div class="small text-muted mt-1">Feedback: <span class="feedback-content">{{ question.c_feedback|safe }}</span></div>
{% endif %} {% endif %}
</div> </div>
<div> <div>
@@ -66,12 +65,12 @@
{% endif %} {% endif %}
{% if question.d_answer %} {% if question.d_answer %}
<li class="list-group-item d-flex justify-content-between align-items-start{% if question.best_answer == 'd' %} list-group-item-success{% endif %}"> <li class="list-group-item d-flex flex-column flex-md-row justify-content-between align-items-start{% if question.best_answer == 'd' %} list-group-item-success{% endif %}">
<div> <div class="mb-2 mb-md-0">
<strong class="me-2">D.</strong> <strong class="me-2">D.</strong>
<span>{{ question.d_answer }}</span> <span class="answer-content">{{ question.d_answer|safe }}</span>
{% if question.d_feedback %} {% if question.d_feedback %}
<div class="small text-muted">Feedback: {{ question.d_feedback }}</div> <div class="small text-muted mt-1">Feedback: <span class="feedback-content">{{ question.d_feedback|safe }}</span></div>
{% endif %} {% endif %}
</div> </div>
<div> <div>
@@ -85,12 +84,12 @@
{% endif %} {% endif %}
{% if question.e_answer %} {% if question.e_answer %}
<li class="list-group-item d-flex justify-content-between align-items-start{% if question.best_answer == 'e' %} list-group-item-success{% endif %}"> <li class="list-group-item d-flex flex-column flex-md-row justify-content-between align-items-start{% if question.best_answer == 'e' %} list-group-item-success{% endif %}">
<div> <div class="mb-2 mb-md-0">
<strong class="me-2">E.</strong> <strong class="me-2">E.</strong>
<span>{{ question.e_answer }}</span> <span class="answer-content">{{ question.e_answer|safe }}</span>
{% if question.e_feedback %} {% if question.e_feedback %}
<div class="small text-muted">Feedback: {{ question.e_feedback }}</div> <div class="small text-muted mt-1">Feedback: <span class="feedback-content">{{ question.e_feedback|safe }}</span></div>
{% endif %} {% endif %}
</div> </div>
<div> <div>
@@ -156,4 +155,4 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -1,17 +1,30 @@
{# Partial: responses for a question within an exam. Rendered server-side or via HTMX. #} {# Partial: responses for a question within an exam. Rendered server-side or via HTMX. #}
<div id="responses-container"> <div id="responses-container">
<h6>Responses in this exam</h6> <h6>Responses in this exam</h6>
<div class="small text-muted mb-2">List of candidate answers recorded for this question in the current exam.</div> <div class="d-flex justify-content-between align-items-center mb-2">
<ul class="list-group"> <div class="small text-muted">List of candidate answers recorded for this question in the current exam.</div>
<div>
{% if reveal_respondents %}
<button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'sbas:exam_review_question_responses' exam.pk q_index %}" hx-target="#responses-container" hx-swap="outerHTML">Hide respondents</button>
{% else %}
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'sbas:exam_review_question_responses' exam.pk q_index %}?reveal=1" hx-target="#responses-container" hx-swap="outerHTML">Show respondents</button>
{% endif %}
</div>
</div>
<ul class="list-group"> <ul class="list-group">
{% for ua in exam_user_answers %} {% for ua in exam_user_answers %}
<li class="list-group-item d-flex justify-content-between align-items-start"> <li class="list-group-item d-flex justify-content-between align-items-start">
<div> <div>
{% if reveal_respondents %}
{% if ua.user %} {% if ua.user %}
<strong>{{ ua.user.get_full_name|default:ua.user.username }}</strong> <strong>{{ ua.user.get_full_name|default:ua.user.username }}</strong>
{% else %} {% else %}
<strong>Anonymous</strong> <strong>Anonymous</strong>
{% endif %} {% endif %}
{% else %}
<strong>Respondent {{ forloop.counter }}</strong>
{% endif %}
<div>Answer: <strong>{{ ua.answer }}</strong></div> <div>Answer: <strong>{{ ua.answer }}</strong></div>
{% if ua.created_date %} {% if ua.created_date %}
<div class="small text-muted">Answered: {{ ua.created_date }}</div> <div class="small text-muted">Answered: {{ ua.created_date }}</div>
@@ -19,7 +19,12 @@
{# Choice A #} {# Choice A #}
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center"> <div class="d-flex w-100 justify-content-between align-items-center">
<div>A</div> <div class="d-flex align-items-center">
<div class="fw-bold me-3">A</div>
{% if exam_response_texts.a %}
<div class="small text-muted text-wrap">{{ exam_response_texts.a|safe }}</div>
{% endif %}
</div>
<div class="text-end"> <div class="text-end">
<span class="me-2">{{ exam_response_counts.a|default:0 }}</span> <span class="me-2">{{ exam_response_counts.a|default:0 }}</span>
<small class="text-muted">{{ exam_response_pcts.a|default:0 }}%</small> <small class="text-muted">{{ exam_response_pcts.a|default:0 }}%</small>
@@ -35,7 +40,12 @@
{# Choice B #} {# Choice B #}
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center"> <div class="d-flex w-100 justify-content-between align-items-center">
<div>B</div> <div class="d-flex align-items-center">
<div class="fw-bold me-3">B</div>
{% if exam_response_texts.b %}
<div class="small text-muted text-wrap">{{ exam_response_texts.b|safe }}</div>
{% endif %}
</div>
<div class="text-end"> <div class="text-end">
<span class="me-2">{{ exam_response_counts.b|default:0 }}</span> <span class="me-2">{{ exam_response_counts.b|default:0 }}</span>
<small class="text-muted">{{ exam_response_pcts.b|default:0 }}%</small> <small class="text-muted">{{ exam_response_pcts.b|default:0 }}%</small>
@@ -51,7 +61,12 @@
{# Choice C #} {# Choice C #}
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center"> <div class="d-flex w-100 justify-content-between align-items-center">
<div>C</div> <div class="d-flex align-items-center">
<div class="fw-bold me-3">C</div>
{% if exam_response_texts.c %}
<div class="small text-muted text-wrap">{{ exam_response_texts.c|safe }}</div>
{% endif %}
</div>
<div class="text-end"> <div class="text-end">
<span class="me-2">{{ exam_response_counts.c|default:0 }}</span> <span class="me-2">{{ exam_response_counts.c|default:0 }}</span>
<small class="text-muted">{{ exam_response_pcts.c|default:0 }}%</small> <small class="text-muted">{{ exam_response_pcts.c|default:0 }}%</small>
@@ -67,7 +82,12 @@
{# Choice D #} {# Choice D #}
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center"> <div class="d-flex w-100 justify-content-between align-items-center">
<div>D</div> <div class="d-flex align-items-center">
<div class="fw-bold me-3">D</div>
{% if exam_response_texts.d %}
<div class="small text-muted text-wrap">{{ exam_response_texts.d|safe }}</div>
{% endif %}
</div>
<div class="text-end"> <div class="text-end">
<span class="me-2">{{ exam_response_counts.d|default:0 }}</span> <span class="me-2">{{ exam_response_counts.d|default:0 }}</span>
<small class="text-muted">{{ exam_response_pcts.d|default:0 }}%</small> <small class="text-muted">{{ exam_response_pcts.d|default:0 }}%</small>
@@ -83,7 +103,12 @@
{# Choice E #} {# Choice E #}
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center"> <div class="d-flex w-100 justify-content-between align-items-center">
<div>E</div> <div class="d-flex align-items-center">
<div class="fw-bold me-3">E</div>
{% if exam_response_texts.e %}
<div class="small text-muted text-wrap">{{ exam_response_texts.e|safe }}</div>
{% endif %}
</div>
<div class="text-end"> <div class="text-end">
<span class="me-2">{{ exam_response_counts.e|default:0 }}</span> <span class="me-2">{{ exam_response_counts.e|default:0 }}</span>
<small class="text-muted">{{ exam_response_pcts.e|default:0 }}%</small> <small class="text-muted">{{ exam_response_pcts.e|default:0 }}%</small>
@@ -96,6 +121,23 @@
</div> </div>
</div> </div>
</div> </div>
{# Unanswered / other responses (blank, None, or unexpected labels) #}
{% if exam_response_counts.unanswered|default:0 %}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>No answer / Other</div>
<div class="text-end">
<span class="me-2">{{ exam_response_counts.unanswered|default:0 }}</span>
<small class="text-muted">{{ exam_response_pcts.unanswered|default:0 }}%</small>
</div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-secondary" role="progressbar"
style="width: {{ exam_response_pcts.unanswered|default:0 }}%;"
aria-valuenow="{{ exam_response_pcts.unanswered|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
{% endif %}
{% else %} {% else %}
<div class="small text-muted">No responses recorded for this question in the exam yet.</div> <div class="small text-muted">No responses recorded for this question in the exam yet.</div>
{% endif %} {% endif %}
+30 -2
View File
@@ -115,6 +115,12 @@ def exam_review_question_responses(request, pk: int, q_index: int):
except Exception: except Exception:
exam_user_answers = question.cid_user_answers.all() exam_user_answers = question.cid_user_answers.all()
# Optionally reveal actual respondent identities when requested (HTMX or query param).
# Default behaviour is anonymous listing; when `reveal=1` is present we'll include
# the real user names in the fragment.
reveal_flag = str(request.GET.get("reveal", "")).lower() in ("1", "true", "on")
context["reveal_respondents"] = reveal_flag
context["exam_user_answers"] = exam_user_answers context["exam_user_answers"] = exam_user_answers
return render(request, "sbas/partials/exam_review_question_responses_fragment.html", context) return render(request, "sbas/partials/exam_review_question_responses_fragment.html", context)
@@ -138,13 +144,24 @@ def exam_review_question_summary(request, pk: int, q_index: int):
ua_qs = question.cid_user_answers.filter(exam=exam) ua_qs = question.cid_user_answers.filter(exam=exam)
total_responses = ua_qs.count() total_responses = ua_qs.count()
# Track counts for the five canonical choices and aggregate any
# empty / unexpected answers into an 'unanswered' bucket so the
# template can show them explicitly and percentages use the same
# denominator (total_responses).
counts = {"a": 0, "b": 0, "c": 0, "d": 0, "e": 0} counts = {"a": 0, "b": 0, "c": 0, "d": 0, "e": 0}
other_count = 0
for ans in ua_qs.values_list("answer", flat=True): for ans in ua_qs.values_list("answer", flat=True):
# Normalize None/empty strings to be considered 'unanswered'
if not ans:
other_count += 1
continue
if ans in counts: if ans in counts:
counts[ans] += 1 counts[ans] += 1
else: else:
# Unexpected answer labels — store them as-is (rare) # Unexpected answer labels — treat as 'other/unanswered'
counts[ans] = counts.get(ans, 0) + 1 other_count += 1
# Expose 'unanswered' so templates can report it
counts['unanswered'] = other_count
pcts = {} pcts = {}
if total_responses: if total_responses:
@@ -161,6 +178,16 @@ def exam_review_question_summary(request, pk: int, q_index: int):
if total_responses: if total_responses:
correct_pct = round(100.0 * correct_count / total_responses, 1) correct_pct = round(100.0 * correct_count / total_responses, 1)
# Provide the actual answer text for each canonical choice so the
# summary template can display the full answer alongside the letter.
exam_response_texts = {
"a": (getattr(question, "a_answer", None) or "") ,
"b": (getattr(question, "b_answer", None) or "") ,
"c": (getattr(question, "c_answer", None) or "") ,
"d": (getattr(question, "d_answer", None) or "") ,
"e": (getattr(question, "e_answer", None) or "") ,
}
context = { context = {
"exam": exam, "exam": exam,
"question": question, "question": question,
@@ -171,6 +198,7 @@ def exam_review_question_summary(request, pk: int, q_index: int):
"exam_response_total": total_responses, "exam_response_total": total_responses,
"exam_response_correct_count": correct_count, "exam_response_correct_count": correct_count,
"exam_response_correct_pct": correct_pct, "exam_response_correct_pct": correct_pct,
"exam_response_texts": exam_response_texts,
} }
return render(request, "sbas/partials/exam_review_question_summary_fragment.html", context) return render(request, "sbas/partials/exam_review_question_summary_fragment.html", context)