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