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)
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):
unmarked_answers = self.get_unmarked_user_answers(exam_pk)
+321 -163
View File
@@ -16,6 +16,8 @@ $(document).ready(function () {
clearableTextAreaSetup();
document.body.addEventListener("htmx:responseError", function(e) {
let el = document.getElementById("htmx-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();
@@ -235,6 +245,7 @@ function loadDicomViewer(images_to_load, annotations_to_load) {
console.log("loadDicomViewer", images_to_load);
let single_dicom = document.getElementById("single-dicom-viewer");
if (single_dicom) {
console.log("Load single dicom viewer");
$(single_dicom).empty()
let images = single_dicom.dataset.images;
@@ -307,8 +318,8 @@ function prepAnswerData() {
$("#id_marked_answers").val(JSON.stringify(window.marked_answers));
}
function loadJsonToolStateOnCurrentImage(json) {
let el = document.getElementById("dicom-image");
function loadJsonToolStateOnCurrentImage(element, json) {
let el = element;
const toolStateManager = cornerstoneTools.globalImageIdSpecificToolStateManager;
@@ -330,160 +341,195 @@ function loadJsonToolStateOnCurrentImage(json) {
}
//async function setUpDicom(element) {
// console.log("setUpDicom (original)", element);
//
// $(element).bind('contextmenu', function (e) {
// return false;
// });
//
// $(element).dblclick((evt) => {
// element.requestFullscreen();
// })
//
// cornerstoneBase64ImageLoader.external.cornerstone = cornerstone;
// cornerstoneWebImageLoader.external.cornerstone = cornerstone;
// cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
//
// cornerstoneTools.init();
// const PanTool = cornerstoneTools.PanTool;
// const ZoomTool = cornerstoneTools.ZoomTool;
// const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool;
// const WwwcTool = cornerstoneTools.WwwcTool;
// const WwwcRegionTool = cornerstoneTools.WwwcRegionTool;
// const RotateTool = cornerstoneTools.RotateTool;
// const StackScrollTool = cornerstoneTools.StackScrollTool;
// const MagnifyTool = cornerstoneTools.MagnifyTool;
// const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
//
//
// const images = element.dataset.url.split(",");
//
// //console.log("Dicom - load imageId: ", imageIds);
// //for (let index = 0; index < imageIds.length; index++) {
// // if (imageIds[index].endsWith("dcm")) {
// // imageIds[index] = "wadouri:" + imageIds[index];
// // }
//
// //}
// let imageIds = [];
// for (let i = 0; i < images.length; i++) {
// let data_url = images[i];
// // check stack type
// if (data_url.startsWith("data:image")) {
// let imageId = "base64://" + data_url.split(",")[1];
//
// imageIds.push(imageId);
// } else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) {
//
// imageIds.push(data_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");
//
// const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
// dfile
// );
//
// loadAnnotation(imageId, annotation);
//
// imageIds.push(imageId);
// //cornerstone.loadImage(imageId).then(function(image) {
// // tempFunction(image);
// //});
// } else {
// let url;
// // This doesn't seem to have any benefit
// //if (data_url.startsWith("http")) {
// // url = data_url;
// //} else {
// // url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
// //}
// url = data_url;
//
// if (url.endsWith("dcm")) {
// url = "wadouri:" + url;
// }
//
// // if there is no extension treat it as a dicom
// if (/(?:\/|^)[^.\/]+$/.test(url)) {
// url = "wadouri:" + url;
// }
//
// imageIds.push(url);
//
//
// }
// }
//
// cornerstone.enable(element);
// cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
// cornerstone.displayImage(element, image);
//
// cornerstoneTools.addToolForElement(element, PanTool);
// cornerstoneTools.addToolForElement(element, ZoomTool);
// cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool);
// cornerstoneTools.addToolForElement(element, WwwcTool);
// cornerstoneTools.addToolForElement(element, WwwcRegionTool);
// cornerstoneTools.addToolForElement(element, RotateTool);
// cornerstoneTools.addToolForElement(element, StackScrollTool);
// cornerstoneTools.addToolForElement(element, MagnifyTool);
//
// cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
// configuration: {
// getTextCallback: () => {},
// changeTextCallback: () => {},
// allowEmptyLabel: true,
// renderDashed: false,
// drawHandles: false,
// drawHandlesOnHover: true,
// },
// });
//
//
//
// // Enable our tools
// // Avoid incorrect aspect ratio
// cornerstoneTools.setToolActiveForElement(element, "Pan", {
// 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.dataset.annotations)
// }
//
// cornerstone.resize(element);
//
//
// }).catch((err, err2) => {
// console.log(err);
// });
//
//}
//console.log("setUpDicom defined");
//
//// keep reference to the existing 2D setup
//window.setUpDicomClassic = setUpDicom;
// Called by HTMX after a fragment swap; initializes any dicom-image-legacy viewers
window.initAnatomyFragment = function(fragmentRoot) {
console.log("initAnatomyFragment", fragmentRoot);
try {
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) => {
try {
// Avoid double initialization
if (el.dataset && (el.dataset.dicomInitialised === '1' || el.dataset.dicomInitialised === 'true')) return;
if (typeof window.setUpDicomLegacy === 'function') {
window.setUpDicomLegacy(el);
if (el.dataset) el.dataset.dicomInitialised = '1';
}
} catch (err) {
console.warn('initAnatomyFragment: setUpDicomLegacy failed for element', el, err);
}
});
} catch (err) {
console.warn('initAnatomyFragment error', err);
}
}
// Global fallback: listen for HTMX swaps and initialise any viewers inside
document.addEventListener('htmx:afterSwap', function (e) {
try {
const root = e && e.target ? e.target : (e && e.detail && e.detail.target ? e.detail.target : document);
if (window.initAnatomyFragment) window.initAnatomyFragment(root);
} catch (err) {
console.warn('htmx afterSwap listener error', err);
}
});
async function setUpDicomLegacy(element) {
console.log("setUpDicom (original)", element);
$(element).bind('contextmenu', function (e) {
return false;
});
$(element).dblclick((evt) => {
element.requestFullscreen();
})
cornerstoneBase64ImageLoader.external.cornerstone = cornerstone;
cornerstoneWebImageLoader.external.cornerstone = cornerstone;
cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
cornerstoneTools.init();
const PanTool = cornerstoneTools.PanTool;
const ZoomTool = cornerstoneTools.ZoomTool;
const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool;
const WwwcTool = cornerstoneTools.WwwcTool;
const WwwcRegionTool = cornerstoneTools.WwwcRegionTool;
const RotateTool = cornerstoneTools.RotateTool;
const StackScrollTool = cornerstoneTools.StackScrollTool;
const MagnifyTool = cornerstoneTools.MagnifyTool;
const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
const images = element.dataset.url.split(",");
const annotation = element.dataset.annotations;
//console.log("Dicom - load imageId: ", imageIds);
//for (let index = 0; index < imageIds.length; index++) {
// if (imageIds[index].endsWith("dcm")) {
// imageIds[index] = "wadouri:" + imageIds[index];
// }
//}
let imageIds = [];
for (let i = 0; i < images.length; i++) {
let data_url = images[i];
// check stack type
if (data_url.startsWith("data:image")) {
let imageId = "base64://" + data_url.split(",")[1];
imageIds.push(imageId);
} else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) {
imageIds.push(data_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");
const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
dfile
);
loadAnnotation(imageId, annotation);
imageIds.push(imageId);
//cornerstone.loadImage(imageId).then(function(image) {
// tempFunction(image);
//});
} else {
let url;
// This doesn't seem to have any benefit
//if (data_url.startsWith("http")) {
// url = data_url;
//} else {
// url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
//}
url = data_url;
if (url.endsWith("dcm")) {
url = "wadouri:" + url;
}
// if there is no extension treat it as a dicom
if (/(?:\/|^)[^.\/]+$/.test(url)) {
url = "wadouri:" + url;
}
imageIds.push(url);
}
}
cornerstone.enable(element);
cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
cornerstone.displayImage(element, image);
cornerstoneTools.addToolForElement(element, PanTool);
cornerstoneTools.addToolForElement(element, ZoomTool);
cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool);
cornerstoneTools.addToolForElement(element, WwwcTool);
cornerstoneTools.addToolForElement(element, WwwcRegionTool);
cornerstoneTools.addToolForElement(element, RotateTool);
cornerstoneTools.addToolForElement(element, StackScrollTool);
cornerstoneTools.addToolForElement(element, MagnifyTool);
cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
configuration: {
getTextCallback: () => {},
changeTextCallback: () => {},
allowEmptyLabel: true,
renderDashed: false,
drawHandles: false,
drawHandlesOnHover: true,
},
});
// Enable our tools
// Avoid incorrect aspect ratio
cornerstoneTools.setToolActiveForElement(element, "Pan", {
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) {
console.log("setUpDicom3d (cornerstone3d) for", element);
@@ -546,13 +592,28 @@ async function setUpDicom3d(element) {
console.log(renderingEngine)
const viewportInput = {
viewportId,
type: Enums.ViewportType.STACK,
element,
viewportId,
type: Enums.ViewportType.STACK,
element,
//viewportType: Enums.ViewportType.STACK, // Stack viewport for slice stacks
};
renderingEngine.enableElement(viewportInput);
try {
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);
@@ -597,10 +658,50 @@ async function setUpDicom3d(element) {
// 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) => {
url = url.trim();
// if it's already a scheme we pass through; otherwise treat as wadouri
if (/^(wadouri:|base64:|data:)/i.test(url)) return url;
// if it's already a scheme we pass through
if (/^(wadouri:|base64:|data:|http:|https:)/i.test(url)) return url;
// default: treat as wadouri (likely a DICOM file)
return `wadouri:${url}`;
});
@@ -617,7 +718,23 @@ async function setUpDicom3d(element) {
}
}
await viewport.setStack(imageIds, 0);
try {
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();
@@ -738,3 +855,44 @@ function initializeClock(id, endtime) {
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>
</span>
{% if exam.publish_results or view_all_results%}
<span class="answer-score">{{score}}</span>
Marks: <span class="answer-score">{{score}}</span>
{% endif %}
</span>
<span class="view-question-link" data-qn={{forloop.counter0}}>View</span>
@@ -1,38 +1,51 @@
<div class="card mb-3">
<div class="card mb-3" hx-on="htmx:afterSwap: initAnatomyFragment(this)">
<div class="card-body">
<div class="mb-2"><span class="badge bg-dark">Anatomy</span></div>
<h5 class="card-title">Question</h5>
<p class="lead">{{ question }}</p>
<p class="lead">{{ question.question_type }}</p>
<div id="anatomy-dicom-image" class="dicom-image-legacy" data-url="{{ question.get_image_url }}"
data-annotations='{{question.image_annotations}}' data-edit_annotation=true>
</div>
<p class="mt-2">
Answer: {{ question.get_primary_answer }}
</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="mt-3">
<h6>Marked answers</h6>
<ul class="list-group">
{% 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 %}">
<div>
{% if answer.proposed %}
<small class="text-muted me-2">(proposed)</small>
{% endif %}
<span>{{ answer.answer }}</span>
</div>
<div>
{% if answer.status == '2' %}
<span class="badge bg-success">Correct</span>
{% elif answer.status == '1' %}
<span class="badge bg-warning text-dark">Half mark</span>
{% elif answer.status == '0' %}
<span class="badge bg-secondary">Incorrect</span>
{% else %}
<span class="badge bg-light text-muted">Unmarked</span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
<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">
{% 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 %}">
<div>
{% if answer.proposed %}
<small class="text-muted me-2">(proposed)</small>
{% endif %}
<span>{{ answer.answer }}</span>
</div>
<div>
{% if answer.status == '2' %}
<span class="badge bg-success">Correct</span>
{% elif answer.status == '1' %}
<span class="badge bg-warning text-dark">Half mark</span>
{% elif answer.status == '0' %}
<span class="badge bg-secondary">Incorrect</span>
{% else %}
<span class="badge bg-light text-muted">Unmarked</span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
{# Aggregated summary placeholder — auto-load via HTMX #}
@@ -41,7 +54,13 @@
<div id="anatomy-response-summary"
hx-get="{% url 'anatomy:exam_review_question_summary' exam.pk q_index %}"
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>
</div>
@@ -54,10 +73,16 @@
<button class="btn btn-sm btn-outline-primary"
hx-get="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}"
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 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>
</div>
@@ -86,36 +111,4 @@
</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,35 +1,92 @@
{# Responses partial for anatomy question within an exam. HTMX-loadable. #}
<div id="anatomy-responses-container">
<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">
<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">
{% for ua in exam_user_answers %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>
{% if ua.user %}
<strong>{{ ua.user.get_full_name|default:ua.user.username }}</strong>
{% else %}
<strong>Anonymous</strong>
{% endif %}
<div>Answer: <strong>{{ ua.answer }}</strong></div>
{% if ua.created_date %}
<div class="small text-muted">Answered: {{ ua.created_date }}</div>
{% endif %}
</div>
<div>
{% if ua.score == '2' %}
<span class="badge bg-success">Correct</span>
{% elif ua.score == '1' %}
<span class="badge bg-warning text-dark">Half mark</span>
{% elif ua.score == '0' %}
<span class="badge bg-secondary">Incorrect</span>
{% else %}
<span class="badge bg-light text-muted">Unmarked</span>
{% endif %}
</div>
</li>
{% for item in exam_user_answers %}
{% with ua=item.ua %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>
{% if reveal_respondents %}
{% if item.display_name_raw %}
<strong>{{ item.display_name_raw }}</strong>
{% else %}
<strong>{{ ua.get_candidate_masked }}</strong>
{% endif %}
{% else %}
<strong>Respondent {{ forloop.counter }}</strong>
{% endif %}
<div>Answer: <strong>{{ ua.answer }}</strong></div>
{% if ua.created_date %}
<div class="small text-muted">Answered: {{ ua.created_date }}</div>
{% endif %}
</div>
<div>
{% if ua.score == '2' %}
<span class="badge bg-success">Correct</span>
{% elif ua.score == '1' %}
<span class="badge bg-warning text-dark">Half mark</span>
{% elif ua.score == '0' %}
<span class="badge bg-secondary">Incorrect</span>
{% else %}
<span class="badge bg-light text-muted">Unmarked</span>
{% endif %}
</div>
</li>
{% endwith %}
{% empty %}
<li class="list-group-item">No responses recorded for this exam.</li>
{% endfor %}
</ul>
</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="d-flex w-100 justify-content-between align-items-center">
<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 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>
@@ -32,7 +32,7 @@
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<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 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>
@@ -43,7 +43,7 @@
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<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 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>
@@ -54,7 +54,7 @@
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<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 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>
@@ -63,19 +63,39 @@
</div>
<div>
<h6 class="mb-1">Top submitted answers</h6>
{% if top_answers %}
<ul class="list-group">
{% for a in top_answers %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>{{ a.answer_compare }}</div>
<div class="small text-muted">{{ a.count }}</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="small text-muted">No submitted answers yet.</div>
{% endif %}
{# 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>
<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 %}
<ul class="list-group">
{% for a in top_answers %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div class="flex-grow-1">{{ a.answer_compare }}</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>
{% endfor %}
</ul>
{% else %}
<div class="small text-muted">No submitted answers yet.</div>
{% endif %}
</div>
</div>
{% else %}
<div class="small text-muted">No responses recorded for this question in the exam yet.</div>
@@ -3,7 +3,7 @@
{% block content %}
{% load static %}
{% 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>
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
@@ -158,7 +158,7 @@
function getJsonToolStateNoId() {
const el = document.getElementById("dicom-image");
const el = document.getElementById("anatomy-dicom-image");
const c = cornerstone.getEnabledElement(el);
+131 -6
View File
@@ -95,9 +95,43 @@ class AuthorOrCheckerRequiredMixin(object):
@register.filter
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:
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):
@@ -274,9 +308,55 @@ def exam_review_question_responses(request, pk: int, q_index: int):
raise Http404("Question not found in exam")
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:
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 = {
"exam": exam,
@@ -284,6 +364,7 @@ def exam_review_question_responses(request, pk: int, q_index: int):
"q_index": q_index,
"app_name": "anatomy",
"exam_user_answers": exam_user_answers,
"reveal_respondents": reveal_respondents,
}
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
# 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)
top_answers = list(ua_qs.values("answer_compare").annotate(count=Count("id")).order_by("-count")[:10])
score_counts = {_norm_score_key(row["score"]): row["count"] for row in ua_qs.values("score").annotate(count=Count("id"))}
# 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_pct = None
@@ -1,4 +1,4 @@
{% extends 'generic/examcollection_base.html' %}
{% extends exam.app_name|add:'/exams.html' %}
{% block content %}
<div class="container my-4">
@@ -6,9 +6,6 @@
<div id="review-root">
{# The #review-content element is replaced by HTMX when navigating between questions. #}
<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 %}
</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 %}
+340 -18
View File
@@ -1165,7 +1165,14 @@ class ExamViews(View, LoginRequiredMixin):
@method_decorator(login_required)
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)
if not self.check_user_edit_access(request.user, exam_id=pk):
raise PermissionDenied
@@ -1177,8 +1184,249 @@ class ExamViews(View, LoginRequiredMixin):
if not questions:
return render(request, "generic/exam_review_complete.html", {"exam": exam, "app_name": self.app_name})
# Redirect (render) the first question
return self.exam_review_question(request, pk, q_index=0)
# Build per-question summary metrics
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)
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)
total_responses = ua_qs.count()
counts = {}
for ans in ua_qs.values_list("answer", flat=True):
counts[ans] = counts.get(ans, 0) + 1
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
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
pcts = {}
@@ -1235,20 +1524,51 @@ class ExamViews(View, LoginRequiredMixin):
for k in counts.keys():
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
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
if correct_count is not None and total_responses:
try:
correct_pct = round(100.0 * correct_count / total_responses, 1)
except Exception:
correct_pct = None
try:
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:
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
context.update(
{
@@ -3610,7 +3930,9 @@ class GenericViewBase:
def question_detail(self, request, 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 (
not request.user.groups.filter(name=self.checker_group).exists()
and request.user not in question.author.all()
+9
View File
@@ -129,6 +129,15 @@ class Question(QuestionBase):
def get_questions(self):
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]]:
"""
@@ -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-body">
<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(),
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...
#path(
# "exam/<int:pk>/scores/<int:cid>/<str:passcode>/",
+265
View File
@@ -414,3 +414,268 @@ class UserAnswerDelete(SuperuserRequiredMixin, DeleteView):
model = UserAnswer
template_name = "user_answer_delete.html"
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",
"menubar": "tools",
"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"
+6 -1
View File
@@ -184,7 +184,12 @@ button a {
float: right;
}
#dicom-image {
#dicom-image-legacy {
width: 100%;
height: 600px;
}
#anatomy-dicom-image {
width: 100%;
height: 600px;
}
@@ -23,7 +23,7 @@
{% for image in question.images.all %}
<span class="image-block">
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>
</span>
{% endfor %}
+40
View File
@@ -22,6 +22,7 @@ from django.contrib.admin.widgets import FilteredSelectMultiple
from django.forms.widgets import RadioSelect, TextInput, Textarea
from tinymce.widgets import TinyMCE
import re
from dal import autocomplete
from crispy_forms.helper import FormHelper
@@ -160,6 +161,45 @@ class QuestionForm(ModelForm):
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:
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-body">
<div class="mb-2"><span class="badge bg-dark">SBAs</span></div>
<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 #}
<div class="mt-3">
<h6>Answers</h6>
<ul class="list-group">
{% 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 %}">
<div>
<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 class="mb-2 mb-md-0">
<strong class="me-2">A.</strong>
<span>{{ question.a_answer }}</span>
<span class="answer-content">{{ question.a_answer|safe }}</span>
{% 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 %}
</div>
<div>
@@ -28,12 +27,12 @@
{% endif %}
{% 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 %}">
<div>
<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 class="mb-2 mb-md-0">
<strong class="me-2">B.</strong>
<span>{{ question.b_answer }}</span>
<span class="answer-content">{{ question.b_answer|safe }}</span>
{% 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 %}
</div>
<div>
@@ -47,12 +46,12 @@
{% endif %}
{% 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 %}">
<div>
<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 class="mb-2 mb-md-0">
<strong class="me-2">C.</strong>
<span>{{ question.c_answer }}</span>
<span class="answer-content">{{ question.c_answer|safe }}</span>
{% 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 %}
</div>
<div>
@@ -66,12 +65,12 @@
{% endif %}
{% 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 %}">
<div>
<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 class="mb-2 mb-md-0">
<strong class="me-2">D.</strong>
<span>{{ question.d_answer }}</span>
<span class="answer-content">{{ question.d_answer|safe }}</span>
{% 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 %}
</div>
<div>
@@ -85,12 +84,12 @@
{% endif %}
{% 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 %}">
<div>
<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 class="mb-2 mb-md-0">
<strong class="me-2">E.</strong>
<span>{{ question.e_answer }}</span>
<span class="answer-content">{{ question.e_answer|safe }}</span>
{% 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 %}
</div>
<div>
@@ -112,7 +111,7 @@
{# Aggregated summary: HTMX-loaded automatically on fragment load #}
<div class="mt-3">
<h6>Response summary</h6>
<div id="response-summary"
<div id="response-summary"
hx-get="{% url 'sbas:exam_review_question_summary' exam.pk q_index %}"
hx-trigger="load"
hx-swap="innerHTML">
@@ -124,7 +123,7 @@
<div class="mt-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<button class="btn btn-sm btn-outline-primary"
<button class="btn btn-sm btn-outline-primary"
hx-get="{% url 'sbas:exam_review_question_responses' exam.pk q_index %}"
hx-target="#responses-container"
hx-swap="innerHTML">Show responses</button>
@@ -134,26 +133,26 @@
<div class="text-muted small">Responses are hidden — click “Show responses” to load.</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 %}
<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 %}
{% 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>
@@ -1,30 +1,43 @@
{# Partial: responses for a question within an exam. Rendered server-side or via HTMX. #}
<div id="responses-container">
<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">
<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 %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>
{% if ua.user %}
<strong>{{ ua.user.get_full_name|default:ua.user.username }}</strong>
{% else %}
<strong>Anonymous</strong>
{% endif %}
<div>Answer: <strong>{{ ua.answer }}</strong></div>
{% if ua.created_date %}
<div class="small text-muted">Answered: {{ ua.created_date }}</div>
{% endif %}
</div>
<div>
{% if ua.answer == question.best_answer %}
<span class="badge bg-success">Correct</span>
{% else %}
<span class="badge bg-secondary">Incorrect</span>
{% endif %}
</div>
</li>
{% empty %}
<li class="list-group-item">No responses recorded for this exam.</li>
{% endfor %}
{% 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>Answer: <strong>{{ ua.answer }}</strong></div>
{% if ua.created_date %}
<div class="small text-muted">Answered: {{ ua.created_date }}</div>
{% endif %}
</div>
<div>
{% if ua.answer == question.best_answer %}
<span class="badge bg-success">Correct</span>
{% else %}
<span class="badge bg-secondary">Incorrect</span>
{% endif %}
</div>
</li>
{% empty %}
<li class="list-group-item">No responses recorded for this exam.</li>
{% endfor %}
@@ -19,7 +19,12 @@
{# Choice A #}
<div class="list-group-item">
<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">
<span class="me-2">{{ exam_response_counts.a|default:0 }}</span>
<small class="text-muted">{{ exam_response_pcts.a|default:0 }}%</small>
@@ -35,7 +40,12 @@
{# Choice B #}
<div class="list-group-item">
<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">
<span class="me-2">{{ exam_response_counts.b|default:0 }}</span>
<small class="text-muted">{{ exam_response_pcts.b|default:0 }}%</small>
@@ -51,7 +61,12 @@
{# Choice C #}
<div class="list-group-item">
<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">
<span class="me-2">{{ exam_response_counts.c|default:0 }}</span>
<small class="text-muted">{{ exam_response_pcts.c|default:0 }}%</small>
@@ -67,7 +82,12 @@
{# Choice D #}
<div class="list-group-item">
<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">
<span class="me-2">{{ exam_response_counts.d|default:0 }}</span>
<small class="text-muted">{{ exam_response_pcts.d|default:0 }}%</small>
@@ -83,7 +103,12 @@
{# Choice E #}
<div class="list-group-item">
<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">
<span class="me-2">{{ exam_response_counts.e|default:0 }}</span>
<small class="text-muted">{{ exam_response_pcts.e|default:0 }}%</small>
@@ -96,6 +121,23 @@
</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 %}
<div class="small text-muted">No responses recorded for this question in the exam yet.</div>
{% endif %}
+30 -2
View File
@@ -115,6 +115,12 @@ def exam_review_question_responses(request, pk: int, q_index: int):
except Exception:
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
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)
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}
other_count = 0
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:
counts[ans] += 1
else:
# Unexpected answer labels — store them as-is (rare)
counts[ans] = counts.get(ans, 0) + 1
# Unexpected answer labels — treat as 'other/unanswered'
other_count += 1
# Expose 'unanswered' so templates can report it
counts['unanswered'] = other_count
pcts = {}
if total_responses:
@@ -161,6 +178,16 @@ def exam_review_question_summary(request, pk: int, q_index: int):
if total_responses:
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 = {
"exam": exam,
"question": question,
@@ -171,6 +198,7 @@ def exam_review_question_summary(request, pk: int, q_index: int):
"exam_response_total": total_responses,
"exam_response_correct_count": correct_count,
"exam_response_correct_pct": correct_pct,
"exam_response_texts": exam_response_texts,
}
return render(request, "sbas/partials/exam_review_question_summary_fragment.html", context)