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