diff --git a/anatomy/filters.py b/anatomy/filters.py index 5bd121f1..0778d15f 100644 --- a/anatomy/filters.py +++ b/anatomy/filters.py @@ -51,6 +51,7 @@ class AnatomyUserAnswerFilter(django_filters.FilterSet): model= CidUserAnswer fields = ( "cid", + #"user", "question", "answer", #"answer_compare", diff --git a/anatomy/migrations/0061_rename_valid_users_exam_valid_cid_users.py b/anatomy/migrations/0061_rename_valid_users_exam_valid_cid_users.py new file mode 100644 index 00000000..0a1de09c --- /dev/null +++ b/anatomy/migrations/0061_rename_valid_users_exam_valid_cid_users.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-05-20 10:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('anatomy', '0060_exam_authors_only'), + ] + + operations = [ + migrations.RenameField( + model_name='exam', + old_name='valid_users', + new_name='valid_cid_users', + ), + ] diff --git a/anatomy/migrations/0062_exam_valid_user_users.py b/anatomy/migrations/0062_exam_valid_user_users.py new file mode 100644 index 00000000..5ad7fef2 --- /dev/null +++ b/anatomy/migrations/0062_exam_valid_user_users.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.13 on 2022-05-20 12:42 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('anatomy', '0061_rename_valid_users_exam_valid_cid_users'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='valid_user_users', + field=models.ManyToManyField(blank=True, related_name='user_anatomy_exams', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/anatomy/models.py b/anatomy/models.py index cb3503cd..b4427e07 100644 --- a/anatomy/models.py +++ b/anatomy/models.py @@ -383,10 +383,14 @@ class Exam(ExamBase): related_name="anatomy_exam_author", ) - valid_users = models.ManyToManyField( + valid_cid_users = models.ManyToManyField( CidUser, blank=True, related_name="anatomy_exams" ) + valid_user_users = models.ManyToManyField( + settings.AUTH_USER_MODEL, blank=True, related_name="user_anatomy_exams" + ) + def get_exam_json(self, based=True): questions = self.exam_questions.all() @@ -448,6 +452,10 @@ class CidUserAnswer(models.Model): help_text="Candidate ID (limitied by BigIntegerField size)", ) + #user = models.ForeignKey( + # settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True#, related_name="user_anatomy_user_answers" + #) + # Each user answer is associated with a particular exam exam = models.ForeignKey( Exam, related_name="cid_user_answers", on_delete=models.CASCADE, null=True diff --git a/anatomy/static/js/dicomViewer.js b/anatomy/static/js/dicomViewer.js deleted file mode 120000 index 6c36bb7e..00000000 --- a/anatomy/static/js/dicomViewer.js +++ /dev/null @@ -1 +0,0 @@ -/home/ross/dv/dicomViewer.js \ No newline at end of file diff --git a/anatomy/static/js/dicomViewer.js b/anatomy/static/js/dicomViewer.js new file mode 100644 index 00000000..13bfbf12 --- /dev/null +++ b/anatomy/static/js/dicomViewer.js @@ -0,0 +1,1310 @@ +// Dicom saved settings +var dicom_settings_db = new Dexie("dicom_settings"); +dicom_settings_db.version(1).stores({ + mouse_bindings: "button,mode,tool", +}); + + +cornerstoneBase64ImageLoader.external.cornerstone = cornerstone; +cornerstoneWebImageLoader.external.cornerstone = cornerstone; +cornerstoneWADOImageLoader.external.cornerstone = cornerstone; + +cornerstoneTools.init({ + keyboardEnabled: true +}); + +let tool_state = {}; + +export function loadCornerstone(main_element, db, images, annotations_to_load, load_as_stack = false) { + // canvas-panel holds the enabled elements and all tools / menus + const canvas_panel = $("
"); + main_element.append(canvas_panel); + main_element[0].dataset.collapsed = "none"; + + + const cornerstone_element = $("
"); + const element = cornerstone_element.get(0); + canvas_panel.append(cornerstone_element); + canvas_panel.append($(`
+ close +
+

DICOM Summary

+ +
+
+

Patient Information

+
+
+
+
+ Patient Name: +
+
+ Patient ID: +
+
+
+
+ Patient Birth Date: +
+
+ Patient Sex: +
+
+
+
+ +
+
+

Study Information

+
+
+
+
+ Study Description: +
+
+ Protocol Name: +
+
+
+
+ Accession #: +
+
+ Study Id: +
+
+
+
+ Study Date: +
+
+ Study Time: +
+
+ +
+
+ +
+
+

Series Information

+
+
+
+
+ Series Description: +
+
+ Series #: +
+
+
+
+ Modality: +
+
+ Body Part: +
+
+
+
+ Series Date: +
+
+ Series Time: +
+
+ +
+
+ +
+
+

Instance Information

+
+
+
+
+ Instance #: +
+
+ Acquisition #: +
+
+
+
+ Acquisition Date: +
+
+ Acquisition Time: +
+
+
+
+ Content Date: +
+
+ Content Time: +
+
+ + + +
+
+ +
+
+

Image Information

+
+
+
+
+ Rows: +
+
+ Columns: +
+
+ +
+
+ Photometric Interpretation: +
+
+ Image Type: +
+ +
+ +
+
+ Bits Allocated: +
+
+ Bits Stored: +
+
+ +
+
+ HighBit: +
+
+ Pixel Representation (0=us): +
+
+ +
+
+ Rescale Slope: +
+
+ Rescale Intercept: +
+
+
+
+ Image Position Patient: +
+
+ Image Orientation Patient: +
+
+
+
+ Pixel Spacing: +
+
+ Samples Per Pixel: +
+
+ +
+
+ +
+
+

Equipment Information

+
+
+
+
+ Manufacturer: +
+
+ Model: +
+
+
+
+ Station Name: +
+
+ AE Title: +
+
+
+
+ Institution Name: +
+
+
+
+ Software Version: +
+
+
+
+ Implementation Version Name: +
+
+
+
+ +
+
+

UIDS

+
+
+
+
+ Study UID: +
+
+
+
+ Series UID: +
+
+
+
+ Instance UID: +
+
+
+
+ SOP Class UID: +
+
+
+
+ Transfer Syntax UID: +
+
+
+
+ Frame of Reference UID: +
+
+
+
+ WADO URL: +
+ http://localhost:3333/wado?requestType=WADO&studyUID=&seriesUID=&objectUID=&contentType=application%2Fdicom&transferSyntax=1.2.840.10008.1.2.1 +
+
+
+
+
+
+
`)); + + + canvas_panel.append($(`
+ close +
`)); + + // Add generic settings menus to canvas-panel + $(".canvas-panel").append( + $(`
+ close + view log +

Image viewer settings:

+ + Primary +
+ + + + + + + + +
+
+ Secondary (Ctrl) +
+ + + + + + + + +
+ +
`) + ) + .append($("")) + .append($("")) + .append($("")) + .append($("")) + .append($("🛈")) + .append( + $(`
+ + + + + + + +
`) + ); + + cornerstone_element.append( + $( + "
Image of
wc: ww:
" + ) + ); + // Add buttons + + let window_presets = { + "window-btn-abdomen": [400, 50], + "window-btn-liver": [150, 30], + "window-btn-mediastinum": [350, 50], + "window-btn-lung": [1500, -600], + "window-btn-brain": [80, 40], + "window-btn-stroke": [40, 40], + "window-btn-bone": [1800, 400] + } + + $("#dicom-window-panel button").each((n, el) => { + $(el).click((e) => { + let button_id = e.target.id; + let [ww, wc] = window_presets[button_id]; + + let viewport = cornerstone.getViewport(element); + viewport.voi.windowWidth = parseFloat(ww); + viewport.voi.windowCenter = parseFloat(wc); + cornerstone.setViewport(element, viewport); + + e.preventDefault(); + e.stopPropagation(); + + }) + }) + + $("#dicom-settings-close").click(e => { + $("#dicom-settings-panel").hide(); + e.preventDefault(); + }); + $("#dicom-summary-close").click(e => { + $("#dicom-summary-panel").hide(); + e.preventDefault(); + }); + $("#dicom-log-close").click(e => { + $("#dicom-log-panel").hide(); + e.preventDefault(); + }); + $(".view-log-button").click(e => { + $("#dicom-settings-panel").hide(); + $("#dicom-log-panel").show(); + e.preventDefault(); + }); + $("#dicom-settings-button").click(e => { + $("#dicom-settings-panel").toggle(); + }); + $("#dicom-info-button").click(e => { + try { + // todo improve + dumpDataSet(cornerstone.getEnabledElement(element).image.data); + } catch { + console.log("unable to extract dicom data") + + } + $("#dicom-summary-panel").toggle(); + }); + $("#dicom-fullscreen-button").click(e => { + if (!document.fullscreenElement) { + $(".canvas-panel").get(0).requestFullscreen().catch(err => { + alert(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`); + }); + } else { + document.exitFullscreen(); + } + }); + $("#dicom-toggle-mode-button").click(e => { + //$("#dicom-settings-panel").toggle(); + let stack = !load_as_stack; + $(main_element).empty(); + loadCornerstone(main_element, db, images, annotations_to_load, stack) + }); + + $("#dicom-collapse-button").click(e => { + //$("#dicom-settings-panel").toggle(); + //let stack = !load_as_stack; + if (main_element[0].dataset.collapsed == "none") { + main_element[0].dataset.collapsed = main_element.width(); + main_element.width("50px"); + $("#dicom-window-panel").hide(); + $(e.target).addClass("dicom-button-highlight"); + } else { + main_element.width(main_element[0].dataset.collapsed); + main_element[0].dataset.collapsed = "none" + $("#dicom-window-panel").show(); + $(e.target).removeClass("dicom-button-highlight"); + } + resizeHandler(element); + //loadCornerstone(main_element, db, images, annotations_to_load, stack) + }); + + //let images = data["images"]; + + // Make sure we have an array + if (!Array.isArray(images)) { + images = [images]; + } + + + //let annotations = []; + //if (annotations_to_load) { + //annotations = annotations_to_load[figure_to_load.split("-")[1]]; + //if (!Array.isArray(annotations)) { + //annotations = [annotations]; + //} + //} + let annotations = annotations_to_load; + if (!Array.isArray(annotations)) { + annotations = [annotations]; + } + + + function loadAnnotation(imageId, annotation) { + const toolStateManager = cornerstoneTools.globalImageIdSpecificToolStateManager; + + if (annotation == undefined || annotation.length < 1 || annotation == [undefined]) { + return + } + + let tool_state_no_id = JSON.parse(annotation); + + tool_state[imageId] = tool_state_no_id; + + toolStateManager.restoreToolState(tool_state); + } + + async function load(images, annotations) { + let imageIds = []; + for (let i = 0; i < images.length; i++) { + let data_url = images[i]; + const annotation = annotations[i]; + // check stack type + if (data_url.startsWith("data:image")) { + let imageId = "base64://" + data_url.split(",")[1]; + + loadAnnotation(imageId, annotation); + + imageIds.push(imageId); + } else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) { + loadAnnotation(data_url, annotation); + + 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; + } + + loadAnnotation(url, annotation); + + imageIds.push(url); + + + } + } + const stack = { + currentImageIdIndex: 0, + imageIds + }; + //cornerstone.loadAndCacheImage(imageIds[0]).then(function(image) { + cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) { + + //console.log("LOAD and cache, then", image); + loadCornerstoneMainImage(element, image, stack, db, load_as_stack); + checkAnonStatus(image.data) + + let load_annotation_image = true; + if (load_annotation_image) { + getNextAnnotationImage(element) + } + }) + .catch((err, err2) => { + console.log(err); + $("#dicom-log-panel").append("
").append(err.stack); + + }); + } + + function loadCornerstoneThumb(image, id) { + let img = $("
").get(0); + img.id = "thumb-image-" + id; + img.class = "thumbnail"; + img.title = + "Click on the thumbnail to view and manipulate the image."; + img.draggable = "false"; + img.style = "height: 100px; width: 100px"; + $("#thumb-" + id).append(img); + + const element = document.getElementById("thumb-image-" + id); + cornerstone.enable(element); + cornerstone.displayImage(element, image); + cornerstone.resize(element); + } + + if (images.length > 1 && load_as_stack == false) { + $(".canvas-panel").append("
"); + for (let id = 0; id < images.length; id++) { + let n = id + 1; + let thumb = $( + "
" + + n + + "
" + ); + $("#image-thumbs").append(thumb); + $("#thumb-" + id).click((evt) => { + selectThumbClick(evt, element) + }).mousedown(stopEvent); + + let image_url = images[id]; + + let img; + + + if (image_url.startsWith("data")) { + // based (image) data url, just load the image directly + if (image_url.startsWith("data:image/")) { + img = $("", { + src: image_url, + id: "thumb-image-" + id, + class: "thumbnail", + title: "Click on the thumbnail to view and manipulate the image.", + draggable: "false", + style: "height: 100px;" + }); + + $("#thumb-" + id).append(img); + + // otherwise try to load it as a dicom + } else { + // convert the data url to a file + urltoFile(image_url, "dicom", "application/dicom").then(function ( + dfile + ) { + // load the file using cornerstoneWADO file loader + const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add( + dfile + ); + cornerstone.loadAndCacheImage(imageId).then(function (image) { + loadCornerstoneThumb(image, id); + }); + }); + } + } else { + if (image_url.startsWith("wadouri") || image_url.startsWith("base64")) { + cornerstone.loadAndCacheImage(image_url).then(function (image) { + loadCornerstoneThumb(image, id); + }); + } else if ((!image_url.endsWith("dicom") && !image_url.endsWith("dcm"))) { + img = $("", { + src: image_url, + id: "thumb-image-" + id, + class: "thumbnail", + title: "Click on the thumbnail to view and manipulate the image.", + draggable: "false", + style: "height: 100px;" + }); + + $("#thumb-" + id).append(img); + + // otherwise try to load it as a dicom + } else { + + let url = "wadouri:" + image_url; + cornerstone.loadAndCacheImage(url).then(function (image) { + loadCornerstoneThumb(image, id); + }); + } + } + } + } + + load(images, annotations); + + main_element.keydown((evt) => { + keyDownHandler(evt, element) + }); + main_element.keyup((evt) => { + keyUpHandler(evt, element) + }); + + +} + +function keyUpHandler(e, element) { + if (e.key == "Control") { + registerPrimaryDicomInterface(element); + window.control_pressed = false; + } +} + +function keyDownHandler(e, element) { + //// Ignore our custom keybindings if we are currently in a field that + //// accepts some kind of input + //if ($("*:focus:not(disabled)").is("textarea, input")) { + // // unless a modifier key is pressed (not shift) + // if (e.altKey ? true : false || e.ctrlKey ? true : false) {} else { + // return; + // } + //} + + if (e.key == "Control") { + if (window.control_pressed != true) { + window.control_pressed = true; + registerAltDicomInterface(e, element); + } + } + + var charCode = typeof e.which == "number" ? e.which : e.keyCode; + + function numberKeyPressed(e, x) { + if (e.altKey ? true : false) { + loadStackIndex(x, element); + e.preventDefault(); + } else {} + } + + switch (charCode) { + case 13: // Return + getNextAnnotationImage(element); + if (e.shiftKey ? true : false) { + //$(".next-button:last").click(); + } else { + //$(".check-button:last").click(); + } + break; + case 32: // Space + if (e.shiftKey ? true : false) { + //$(".previous-button:last").click(); + } else { + //$(".next-button:last").click(); + } + e.preventDefault(); // Needed to stop the default action (scroll) + break; + case 38: // Up + manualScrollDicom(1, element); + e.preventDefault(); + break; + case 40: // down + manualScrollDicom(-1, element); + e.preventDefault(); + break; + case 46: // . + //toggleFlagged(); + break; + + // Numbers 1-9 select the corresponding answer (if it exists) + // TODO: fix for multi question questions + case 49: // 1 + numberKeyPressed(e, 0); + break; + case 50: // 2 + numberKeyPressed(e, 1); + break; + case 51: // 3 + numberKeyPressed(e, 2); + break; + case 52: // 4 + numberKeyPressed(e, 3); + break; + case 53: // 5 + numberKeyPressed(e, 4); + break; + case 54: // 6 + numberKeyPressed(e, 5); + break; + case 55: // 7 + numberKeyPressed(e, 6); + break; + case 56: // 8 + numberKeyPressed(e, 7); + break; + case 57: // 9 + numberKeyPressed(e, 8); + break; + case 72: // H + previousQuestion(); + break; + case 76: // L + //nextQuestion(); + break; + + case 102: // f + //$("#filter-toggle").click(); + break; + case 103: // g + //$("#options").slideDown("slow"); + //$("#goto-question-input").focus(); + //e.preventDefault(); + break; + + // Vim like scrolling (incredibly important) + case 106: // j + //window.scrollBy(0, 25); + break; + case 107: // k + //window.scrollBy(0, -25); + break; + } +} + + +function loadCornerstoneMainImage(element, image, stack, db, load_as_stack) { + // It is probably silly to do this each time we load a question + const PanTool = cornerstoneTools.PanTool; + const ZoomTool = cornerstoneTools.ZoomTool; + const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool; + const StackScrollMouseWheelTool = cornerstoneTools.StackScrollMouseWheelTool; + 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 RectangleRoiTool = cornerstoneTools.RectangleRoiTool; + const LengthTool = cornerstoneTools.LengthTool; + const AngleTool = cornerstoneTools.AngleTool; + const CobbAngleTool = cornerstoneTools.CobbAngleTool; + const EraserTool = cornerstoneTools.EraserTool; + + cornerstone.enable(element); + element.tabIndex = 0; + + cornerstone.displayImage(element, image); + + //cornerstoneTools.keyboardInput.enable(element); + + cornerstoneTools.addStackStateManager(element, ["stack"]); + cornerstoneTools.addToolState(element, "stack", stack); + + cornerstoneTools.addToolForElement(element, PanTool); + cornerstoneTools.addToolForElement(element, ZoomTool); + cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool); + cornerstoneTools.addToolForElement(element, StackScrollMouseWheelTool); + cornerstoneTools.addToolForElement(element, WwwcTool); + cornerstoneTools.addToolForElement(element, WwwcRegionTool); + cornerstoneTools.addToolForElement(element, RotateTool); + cornerstoneTools.addToolForElement(element, StackScrollTool); + cornerstoneTools.addToolForElement(element, MagnifyTool); + cornerstoneTools.addToolForElement(element, LengthTool); + cornerstoneTools.addToolForElement(element, AngleTool); + cornerstoneTools.addToolForElement(element, CobbAngleTool); + cornerstoneTools.addToolForElement(element, EraserTool); + //cornerstoneTools.addTool(ArrowAnnotateTool); + //cornerstoneTools.addTool(RectangleRoiTool); + + cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, { + configuration: { + getTextCallback: () => {}, + changeTextCallback: () => {}, + allowEmptyLabel: true, + renderDashed: false, + drawHandles: false, + drawHandlesOnHover: true, + }, + }); + cornerstoneTools.addToolForElement(element, RectangleRoiTool, { + configuration: { + showHounsfieldUnits: false, + showMinMax: false, + allowEmptyLabel: true, + renderDashed: false, + drawHandles: false, + drawHandlesOnHover: true, + }, + }); + + cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate"); + + + let available_tools = [ + "Pan", + "Zoom", + "Wwwc", + "WwwcRegion", + "Rotate", + "StackScroll", + "Magnify", + "Length", + "ArrowAnnotate", + "RectangleRoi", + "Angle", + "CobbAngle", + "Eraser", + ]; + $(".mouse-binding-select option").remove(); + + available_tools.forEach(function (tool) { + let option = ""; + $(".mouse-binding-select").append(option); + //$("#left-mouse-dicom").append(option); + //$("#middle-mouse-dicom").append(option); + //$("#right-mouse-dicom").append(option); + }); + $(".mouse-binding-select").on("change", function (evt) { + changeMouseBinding(evt, db, element); + }); + + // Set default tools + $("#primary-mouse-binding .mouse-binding-select[data-button=1]").val("Pan"); + $("#primary-mouse-binding .mouse-binding-select[data-button=2]").val("Wwwc"); + $("#primary-mouse-binding .mouse-binding-select[data-button=3]").val("Zoom"); + $("#primary-mouse-binding .mouse-binding-select[data-button=4]").val("Zoom"); + //cornerstoneTools.setToolActive("Pan", { mouseButtonMask: 1 }); + + if (load_as_stack) { + cornerstoneTools.setToolActiveForElement(element, "StackScrollMouseWheel", { + //mouseButtonMask: [4] + }); + } else { + cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", { + //mouseButtonMask: [4] + }); + } + //cornerstoneTools.setToolActive("Zoom", { mouseButtonMask: 4 }); + //cornerstoneTools.setToolActive("Wwwc", { mouseButtonMask: 2 }); + $("#secondary-mouse-binding .mouse-binding-select[data-button=1]").val( + "Magnify" + ); + $("#secondary-mouse-binding .mouse-binding-select[data-button=2]").val( + "Rotate" + ); + $("#secondary-mouse-binding .mouse-binding-select[data-button=3]").val( + "StackScroll" + ); + $("#secondary-mouse-binding .mouse-binding-select[data-button=4]").val( + "WwwcRegion" + ); + + loadPrimaryDicomInterface(db, element); + loadAltDicomInterface(db); + + element.addEventListener("cornerstoneimagerendered", onImageRendered); + element.addEventListener("cornerstonenewimage", onNewImage); + //element.addEventListener("cornerstonetoolskeydown", keyDownHandler); + + //setDicomCanvasNonFullscreen(element); + cornerstone.reset(element); + //element.scrollIntoView(false); + //element.scrollTo(0); + + $(element).dblclick(function () { + cornerstone.reset(this); + }); + + element.removeEventListener("wheel", element.wheelEventHandler); + + // Add tool selector + //$(".canvas-panel").append( + // '' + //); + + //$(".control-overlay") + // .get(0) + // .addEventListener("change", changeControlSelection); + + resizeHandler(element); + + window.addEventListener("resize", () => { + resizeHandler(element) + }); + element.addEventListener( + "contextmenu", + function (e) { + // do something here... + e.preventDefault(); + }, + false + ); + + + $(element).on("mouseup", function (event) { + console.log(event) + if (event.which == 4) { + getNextAnnotationImage(element) + } + $(".magnifyTool").hide(); + }); +} + +function resizeHandler(element) { + let h = window.innerHeight - $("#header").height() - 16; + $(element).height(h); + cornerstone.resize(element, true); +} + +async function loadPrimaryDicomInterface(db, element) { + const bindings = await dicom_settings_db.mouse_bindings.where({ + mode: "0" + }).toArray().catch((err) => { + console.log(err); + }); + bindings.forEach(function (b) { + let sel = $("#primary-mouse-binding select[data-button=" + b.button + "]").get( + 0 + ); + sel.value = b.tool; + //sel.dispatchEvent(new Event("change")); + }); + registerPrimaryDicomInterface(element); +} + +async function loadAltDicomInterface(db) { + const bindings = await dicom_settings_db.mouse_bindings.where({ + mode: "1" + }).toArray().catch((err) => { + console.log(err); + }); + bindings.forEach(function (b) { + let sel = $( + "#secondary-mouse-binding select[data-button=" + b.button + "]" + ).get(0); + sel.value = b.tool; + }); +} + +function onNewImage(e) { + let dataSet = e.detail.image.data; + checkAnonStatus(dataSet) +} + +function checkAnonStatus(dataSet) { + let accession_number = dataSet.string('x00080050').toLowerCase(); + let patient_id = dataSet.string('x00100020').toLowerCase(); + + let not_anon = false; + + let site_codes = ["ref", "rk9", "ra9", "rh8", "rbz", "rba"] + + for (let i = 0; i < site_codes.length; i++) { + if ( + accession_number.startsWith(site_codes[i]) || + patient_id.startsWith(site_codes[i])) { + not_anon = true; + } + } + + if (not_anon) { + toastr.warning(`File does not appear to be annonymised
Accession: ${accession_number}
Patient ID: ${patient_id}
`, 'Anonymisation warning', { + "closeButton": false, + "debug": false, + "newestOnTop": false, + "progressBar": false, + "positionClass": "toast-top-full-width", + "preventDuplicates": false, + "onclick": null, + "showDuration": "0", + "hideDuration": "0", + "timeOut": 0, + "extendedTimeOut": 0, + "showEasing": "swing", + "hideEasing": "linear", + "showMethod": "fadeIn", + "hideMethod": "fadeOut", + }); + } +} + +// Called when the dicom image is loaded / rendered +function onImageRendered(e) { + const eventData = e.detail; + + // Update ww/wl + $("#wc").text(Math.round(eventData.viewport.voi.windowCenter)); + $("#ww").text(Math.round(eventData.viewport.voi.windowWidth)); + + // update stack data + let stack = eventData.enabledElement.toolStateManager.toolState.stack.data[0]; + + $("#total_image_number").text(stack.imageIds.length); + $("#current_image_number").text(parseInt(stack.currentImageIdIndex) + 1); + + if (stack.imageIds.length > 1) { + $(".thumb").removeClass("thumb-active"); + $("#thumb-" + stack.currentImageIdIndex).addClass("thumb-active"); + } +} + +function stopEvent(evt) { + //console.log("stop", evt) + evt.preventDefault(); + evt.stopPropagation(); +} + +function selectThumbClick(evt, element) { + let new_index = evt.currentTarget.dataset.id; + loadStackIndex(new_index, element); + evt.preventDefault(); +} + + +export function registerPrimaryDicomInterface(element) { + console.log("reg primary") + // Set mousetools based upon the selected options + let selections = $("#primary-mouse-binding select"); + selections.each((i, option) => { + cornerstoneTools.setToolActiveForElement(element, option.value, { + mouseButtonMask: parseInt(option.dataset.button) + }); + }); +} + +export function registerAltDicomInterface(e, element) { + // Called when control is pressed + + // Set mousetools based upon the selected options + //let element = document.getElementsByClassName("cornerstone-element")[0]; + console.log("reg alt", element) + let selections = $("#secondary-mouse-binding select"); + selections.each((i, option) => { + cornerstoneTools.setToolActiveForElement(element, option.value, { + mouseButtonMask: parseInt(option.dataset.button) + }); + }); +} + +function changeMouseBinding(evt, db, element) { + let select = evt.currentTarget; + let button = select.dataset.button; + let mode = select.dataset.mode; + let tool = select.value; + + // Directly activate primary tools (secondary will be activated when modifier + // key is pressed + if (mode == "0") { + cornerstoneTools.setToolActiveForElement(element, tool, { + mouseButtonMask: parseInt(button) + }); + } + + dicom_settings_db.mouse_bindings.put({ + button: button, + mode: mode, + tool: tool + }); + //db.mouse_bindings.put({button: button, mode: mode, tool: tool}).then(loadPrimaryDicomInterface()); +} + +export function loadStackIndex(new_index, dicom_element) { + console.log("select new stack index", new_index); + // There must be a better way to do this... + if (dicom_element == null) { + return; + } + let c = cornerstone.getEnabledElement(dicom_element); + // let current_index = + // c.toolStateManager.toolState.stack.data[0].currentImageIdIndex; + c.toolStateManager.toolState.stack.data[0].currentImageIdIndex = new_index; + let id = c.toolStateManager.toolState.stack.data[0].imageIds[new_index]; + ////console.log("select thumb id", id); + //console.log("select thumb el", dicom_element); + cornerstone.loadImage(id).then(b => { + //console.log("b", b); + cornerstone.displayImage(dicom_element, b); + }); + //c = cornerstone.getEnabledElement(dicom_element) +} + +function urltoFile(url, filename, mimeType) { + return fetch(url) + .then(function (res) { + return res.arrayBuffer(); + }) + .then(function (buf) { + return new File([buf], filename, { + type: mimeType + }); + }); +} + +function dumpDataSet(dataSet) { + $('span[data-dicom]').each(function (index, value) { + var attr = $(value).attr('data-dicom'); + var element = dataSet.elements[attr]; + var text = ""; + if (element !== undefined) { + var str = dataSet.string(attr); + if (str !== undefined) { + text = str; + } + } + $(value).text(text); + }); + + $('span[data-dicomUint]').each(function (index, value) { + var attr = $(value).attr('data-dicomUint'); + var element = dataSet.elements[attr]; + var text = ""; + if (element !== undefined) { + if (element.length === 2) { + text += dataSet.uint16(attr); + } else if (element.length === 4) { + text += dataSet.uint32(attr); + } + } + + $(value).text(text); + }); + +} + +export function getNextAnnotationImage(element) { + const enabled_element = cornerstone.getEnabledElement(element) + const image_ids = enabled_element.toolStateManager.toolState.stack.data[0].imageIds; + const current_image = enabled_element.toolStateManager.toolState.stack.data[0].currentImageIdIndex; + const annotated_images = Object.getOwnPropertyNames(cornerstoneTools.globalImageIdSpecificToolStateManager.saveToolState()); + + if (annotated_images.length > 0) { + let offset = current_image; + for (let i = 0; i < image_ids.length; i++) { + let pointer = (i + offset) % image_ids.length; + if (annotated_images.includes(image_ids[pointer])) { + loadStackIndex(pointer, element); + + } + } + } +} + +function manualScrollDicom(n, dicom_element) { + // There must be a better way to do this... + //dicom_element = document.getElementById("dicom-image"); + let c = cornerstone.getEnabledElement(dicom_element); + + let max = c.toolStateManager.toolState.stack.data[0].imageIds.length; + + if (max < 2) { + return; + } + + let current_index = + c.toolStateManager.toolState.stack.data[0].currentImageIdIndex; + + let new_index = current_index + n + + let corrected_index = Math.min(Math.max(new_index, 0), max) + + // TODO: if loop + //if (new_index >= max) { + // corrected_index = new_index - max; + //} else if (new_index < 0) { + // corrected_index = new_index + max; + //} + + c.toolStateManager.toolState.stack.data[0].currentImageIdIndex = corrected_index; + let id = c.toolStateManager.toolState.stack.data[0].imageIds[corrected_index]; + cornerstone.loadImage(id).then((b) => { + cornerstone.displayImage(dicom_element, b); + }); + // c = cornerstone.getEnabledElement(dicom_element) +} \ No newline at end of file diff --git a/anatomy/tables.py b/anatomy/tables.py index 8225be17..8d369fe6 100644 --- a/anatomy/tables.py +++ b/anatomy/tables.py @@ -69,6 +69,7 @@ class AnatomyUserAnswerTable(tables.Table): template_name = "django_tables2/bootstrap4.html" fields = ( "cid", + #"user", "question", "answer", #"answer_compare", diff --git a/atlas/forms.py b/atlas/forms.py index 963977a8..9f81b346 100755 --- a/atlas/forms.py +++ b/atlas/forms.py @@ -55,7 +55,7 @@ class ConditionForm(ModelForm): class CaseCollectionForm(ModelForm): class Meta: model = CaseCollection - exclude = ["cases", "valid_users"] + exclude = ["cases", "valid_cid_users"] def __init__(self, *args, **kwargs): self.user = kwargs.pop( diff --git a/atlas/migrations/0045_rename_valid_users_casecollection_valid_cid_users.py b/atlas/migrations/0045_rename_valid_users_casecollection_valid_cid_users.py new file mode 100644 index 00000000..d25e3b40 --- /dev/null +++ b/atlas/migrations/0045_rename_valid_users_casecollection_valid_cid_users.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-05-20 10:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('atlas', '0044_userreportanswer'), + ] + + operations = [ + migrations.RenameField( + model_name='casecollection', + old_name='valid_users', + new_name='valid_cid_users', + ), + ] diff --git a/atlas/models.py b/atlas/models.py index ec24f9f9..062b9247 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -645,7 +645,7 @@ class CaseCollection(models.Model): cid_users = GenericRelation("generic.CidUserExam") - valid_users = models.ManyToManyField( + valid_cid_users = models.ManyToManyField( CidUser, blank=True, related_name="casecollection_exams" ) @@ -694,9 +694,9 @@ class CaseCollection(models.Model): if request is not None and request.user.is_superuser: return True - if self.valid_users.exists(): + if self.valid_cid_users.exists(): - user = self.valid_users.filter(cid=cid).first() + user = self.valid_cid_users.filter(cid=cid).first() if not user or user.passcode != passcode: return False diff --git a/atlas/views.py b/atlas/views.py index 7b6a1e5b..beb681de 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -1172,7 +1172,7 @@ def exam_cids(request, pk): request.user in collection.author.all() or request.user.groups.filter(name="cid_user_manager").exists() ): - cid_users = collection.valid_users.all() + cid_users = collection.valid_cid_users.all() cid_user_count = cid_users.count() return render( diff --git a/generic/forms.py b/generic/forms.py index 58810487..d562ef67 100755 --- a/generic/forms.py +++ b/generic/forms.py @@ -9,8 +9,9 @@ from django.forms import ( ) from django.forms import inlineformset_factory from atlas.models import CaseCollection +from django.contrib.auth.models import User -from generic.models import CidUser, CidUserGroup, Examination, QuestionNote +from generic.models import CidUser, CidUserGroup, Examination, QuestionNote, UserUserGroup from django.contrib.admin.widgets import FilteredSelectMultiple from django.forms.widgets import RadioSelect, TextInput, Textarea @@ -205,4 +206,14 @@ class CidUserForm(ModelForm): class CidUserGroupForm(ModelForm): class Meta: model = CidUserGroup - fields = ["name", "archive"] \ No newline at end of file + fields = ["name", "archive"] + +class UserUserGroupForm(ModelForm): + users = ModelMultipleChoiceField( + required=False, + queryset=User.objects.all(), + widget=FilteredSelectMultiple(verbose_name="Users", is_stacked=False), + ) + class Meta: + model = UserUserGroup + fields = ["name", "archive", "users"] \ No newline at end of file diff --git a/generic/migrations/0027_auto_20220520_1705.py b/generic/migrations/0027_auto_20220520_1705.py new file mode 100644 index 00000000..51382a1a --- /dev/null +++ b/generic/migrations/0027_auto_20220520_1705.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.13 on 2022-05-20 16:05 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('generic', '0026_ciduser_generic_cid_cid_291418_idx'), + ] + + operations = [ + migrations.AddField( + model_name='ciduserexam', + name='user_user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_userexam', to='generic.ciduser'), + ), + migrations.AlterField( + model_name='ciduserexam', + name='cid_user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cid_userexam', to='generic.ciduser'), + ), + ] diff --git a/generic/migrations/0028_alter_ciduserexam_user_user.py b/generic/migrations/0028_alter_ciduserexam_user_user.py new file mode 100644 index 00000000..e6353fe2 --- /dev/null +++ b/generic/migrations/0028_alter_ciduserexam_user_user.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.13 on 2022-05-20 16:12 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('generic', '0027_auto_20220520_1705'), + ] + + operations = [ + migrations.AlterField( + model_name='ciduserexam', + name='user_user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_userexam', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/generic/migrations/0029_userusergroup.py b/generic/migrations/0029_userusergroup.py new file mode 100644 index 00000000..aeceebc3 --- /dev/null +++ b/generic/migrations/0029_userusergroup.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.13 on 2022-05-20 17:44 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('generic', '0028_alter_ciduserexam_user_user'), + ] + + operations = [ + migrations.CreateModel( + name='UserUserGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, max_length=50)), + ('archive', models.BooleanField(default=False)), + ('users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/generic/migrations/0030_alter_userusergroup_users.py b/generic/migrations/0030_alter_userusergroup_users.py new file mode 100644 index 00000000..7ff51d87 --- /dev/null +++ b/generic/migrations/0030_alter_userusergroup_users.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.13 on 2022-05-20 18:27 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('generic', '0029_userusergroup'), + ] + + operations = [ + migrations.AlterField( + model_name='userusergroup', + name='users', + field=models.ManyToManyField(blank=True, related_name='user_groups', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/generic/migrations/0031_userprofile.py b/generic/migrations/0031_userprofile.py new file mode 100644 index 00000000..e1dfb80f --- /dev/null +++ b/generic/migrations/0031_userprofile.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.13 on 2022-05-20 19:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('generic', '0030_alter_userusergroup_users'), + ] + + operations = [ + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('supervisor_email', models.EmailField(blank=True, max_length=254)), + ('supervisor_name', models.CharField(blank=True, max_length=100)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/generic/models.py b/generic/models.py index 0e1b7810..0453c329 100644 --- a/generic/models.py +++ b/generic/models.py @@ -20,6 +20,10 @@ from django.contrib.contenttypes.models import ContentType from django.core.mail import send_mail from django.core.mail import EmailMessage +from django.contrib.auth.models import User + +from django.db.models.signals import post_save +from django.dispatch import receiver class Plane(models.Model): plane = models.CharField(max_length=200, unique=True) @@ -138,13 +142,9 @@ class ExamBase(models.Model): user_scores = models.JSONField(default=dict) + # Is this actually used? cid_users = GenericRelation("generic.CidUserExam") - # time_limit = models.IntegerField( - # help_text="Exam time limit (in seconds). Default is 2100 secondse (35 minutes)", - # default=2100, - # ) - class Meta: abstract = True @@ -189,49 +189,85 @@ class ExamBase(models.Model): authors = [i for i in self.author.all()] return authors - def check_cid_user(self, cid, passcode, request=None): + def check_cid_user(self, cid, passcode, request=None, user_id=None): if request is not None and request.user.is_superuser: return True - if self.valid_users.exists(): + if not self.valid_cid_users.exists() and not self.valid_user_users.exists(): + return True - user = self.valid_users.filter(cid=cid).first() + if user_id is not None: + if self.valid_user_users.filter(pk=user_id).exists(): + return True - if not user or user.passcode != passcode: - return False + if self.valid_cid_users.exists(): - return True + cid_user = self.valid_cid_users.filter(cid=cid).first() - def get_or_create_cid_user_exam(self, cid_user, start_time=None): + if cid_user and cid_user.passcode == passcode: + return True + + return False + + return False + + def get_or_create_cid_user_exam(self, cid_user=None, user_user=None, start_time=None): content_type = ContentType.objects.get_for_model(self) - c = CidUserExam.objects.filter( - content_type=content_type, object_id=self.pk, cid_user=cid_user - ).first() - if c: - return c + + if cid_user is not None: + c = CidUserExam.objects.filter( + content_type=content_type, object_id=self.pk, cid_user=cid_user + ).first() + if c: + return c + + if user_user is not None: + c = CidUserExam.objects.filter( + content_type=content_type, object_id=self.pk, user_user=cid_user + ).first() + if c: + return c + if start_time is None: start_time = timezone.now() - new = CidUserExam( - content_type=content_type, - object_id=self.pk, - cid_user=cid_user, - start_time=start_time, - ) + + + if cid_user is not None: + new = CidUserExam( + content_type=content_type, + object_id=self.pk, + cid_user=cid_user, + start_time=start_time, + ) + + if user_user is not None: + new = CidUserExam( + content_type=content_type, + object_id=self.pk, + user_user=user_user, + start_time=start_time, + ) + new.save() return new - def get_cid_user_exams(self, cid_user=None): + def get_cid_user_exams(self, cid_user=None, user_user=None): content_type = ContentType.objects.get_for_model(self) - if cid_user is None: + if cid_user is None and user_user is None: return CidUserExam.objects.filter( content_type=content_type, object_id=self.pk, ) else: - return CidUserExam.objects.filter( - content_type=content_type, object_id=self.pk, cid_user=cid_user - ) + if cid_user is not None: + return CidUserExam.objects.filter( + content_type=content_type, object_id=self.pk, cid_user=cid_user + ) + else: + return CidUserExam.objects.filter( + content_type=content_type, object_id=self.pk, user_user=user_user + ) def get_cid_user_score(self, cid_user): c = str(cid_user) @@ -482,7 +518,8 @@ class CidUserExam(models.Model): start_time = models.DateTimeField(blank=True, null=True) end_time = models.DateTimeField(blank=True, null=True) - cid_user = models.ForeignKey(CidUser, on_delete=models.CASCADE) + cid_user = models.ForeignKey(CidUser, on_delete=models.CASCADE, blank=True, null=True, related_name="cid_userexam") + user_user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, related_name="user_userexam") manual_submission = models.BooleanField(default=False) @@ -501,5 +538,42 @@ class CidUserGroup(models.Model): return s def get_absolute_url(self): - return reverse("generic:group_update", kwargs={"pk": self.pk}) + return reverse("generic:cid_group_update", kwargs={"pk": self.pk}) + +class UserUserGroup(models.Model): + name = models.CharField(blank=True, max_length=50) + archive = models.BooleanField(default=False) + + users = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="user_groups" + ) + + def __str__(self) -> str: + return str(self.name) + + def GetGroupUsers(self): + users = self.users.all() + + s = ", ".join([f"{user.id} - {user.username} ({user.email})" for user in users]) + return s + + def get_absolute_url(self): + return reverse("generic:user_group_update", kwargs={"pk": self.pk}) + + +class UserProfile(models.Model): + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + supervisor_email = models.EmailField(blank=True) + supervisor_name = models.CharField(max_length=100, blank=True) + +@receiver(post_save, sender=User) +def create_user_profile(sender, instance, created, **kwargs): + if created: + UserProfile.objects.create(user=instance) + +@receiver(post_save, sender=User) +def save_user_profile(sender, instance, **kwargs): + instance.profile.save() \ No newline at end of file diff --git a/generic/templates/generic/base.html b/generic/templates/generic/base.html index 2de4720e..a180f3ca 100755 --- a/generic/templates/generic/base.html +++ b/generic/templates/generic/base.html @@ -19,7 +19,8 @@ {% if request.user.is_authenticated %} Cids / Cids (exams) / - Groups / + Cid Groups / + User Groups / {% endif %} {% endblock %} diff --git a/generic/templates/generic/group_view.html b/generic/templates/generic/cid_group_view.html similarity index 80% rename from generic/templates/generic/group_view.html rename to generic/templates/generic/cid_group_view.html index c6202ece..05eb5365 100755 --- a/generic/templates/generic/group_view.html +++ b/generic/templates/generic/cid_group_view.html @@ -1,9 +1,9 @@ {% extends 'generic/base.html' %} {% block content %} -

Groups

+

Cid Groups

- Create new group + Create new group

{% for group in groups %}

{{group.name}} @@ -15,7 +15,7 @@ ">Email candidate results -

{% endfor %} @@ -29,8 +29,8 @@ {% if view_all %} - View active + View active {% else %} - View all + View all {% endif %} {% endblock %} diff --git a/generic/templates/generic/exam_cids.html b/generic/templates/generic/exam_cids.html index b6186908..bcfd6b11 100644 --- a/generic/templates/generic/exam_cids.html +++ b/generic/templates/generic/exam_cids.html @@ -1,6 +1,8 @@ +
+

CID candidates

{% if cid_users %} -

{{cid_user_count}} candidates.

+

{{cid_user_count}} CID candidates.

    {% for cid in cid_users %} @@ -16,8 +18,32 @@ {% endfor %}
{% else %} -

This exam has no candidates. Add some with the below link.

+

This exam has no CID candidates. Add some with the below link.

{% endif %} Edit Exam CIDs +
+
+ +
+

User candidates

+{% if user_users %} +

{{user_user_count}} User candidates.

+ +
    + {% for user in user_users %} + +
  1. {{user.username}} + Email: {{user.email}} +
  2. + + {% endfor %} +
+{% else %} +

This exam has no User candidates. Add some with the below link.

+{% endif %} +Edit Exam Users + + +
\ No newline at end of file diff --git a/generic/templates/generic/exam_users_edit.html b/generic/templates/generic/exam_users_edit.html new file mode 100644 index 00000000..154fc32e --- /dev/null +++ b/generic/templates/generic/exam_users_edit.html @@ -0,0 +1,72 @@ + +

Users

+ {% if groups %} + Users from the following groups are available: {% for group in groups %}{{group}}{% endfor %} . +
    + {% for user in current_user_users %} + +
  1. {{user.username}} / + Email: {{user.email}} +
  2. + + {% endfor %} + {% for user in available_user_users %} + +
  3. {{user.username}} / + Email: {{user.email}} +
  4. + + {% endfor %} +
+ {% else %} + This exam is not associated with any user groups. Edit and add a group to enable user management. + {% endif %} + + + \ No newline at end of file diff --git a/generic/templates/generic/user_group_view.html b/generic/templates/generic/user_group_view.html new file mode 100755 index 00000000..8a7a77bb --- /dev/null +++ b/generic/templates/generic/user_group_view.html @@ -0,0 +1,36 @@ +{% extends 'generic/base.html' %} + +{% block content %} +

User Groups

+

+ Create new group +

+ {% for group in groups %} +

{{group.name}} +{% comment %} + + + {% endcomment %} + +

+ {% endfor %} + + + + {% if view_all %} + View active + {% else %} + View all + {% endif %} +{% endblock %} diff --git a/generic/templates/generic/userusergroup_form.html b/generic/templates/generic/userusergroup_form.html new file mode 100755 index 00000000..71bfbf4a --- /dev/null +++ b/generic/templates/generic/userusergroup_form.html @@ -0,0 +1,31 @@ +{% extends "generic/base.html" %} + + +{% block css %} +{% endblock %} +{% block js %} + +{{form.media}} + + + + +{% endblock %} +{% block content %} +

Add / Edit Group / {{userusergroup.name}}

+Use this form to create / edit a CID user group. +
+ {% csrf_token %} + + + {{ form.as_table }} +
+ + +

Users

+ + {{userusergroup.GetGroupUsers}} + +
+{% endblock %} \ No newline at end of file diff --git a/generic/urls.py b/generic/urls.py index 39eab1cc..476bb39b 100755 --- a/generic/urls.py +++ b/generic/urls.py @@ -41,8 +41,8 @@ urlpatterns = [ ), path("cids/manage/", views.CidUserView.as_view(), name="manage_cids"), path("cids/manage/exams", views.CidUserExamView.as_view(), name="manage_cid_exams"), - path("cids/group/", views.group_view, name="group_view"), - path("cids/group/all", views.group_view_all, name="group_view_all"), + path("cids/group/", views.cid_group_view, name="cid_group_view"), + path("cids/group/all", views.cid_group_view_all, name="cid_group_view_all"), path("cids/group//email", views.group_email, name="group_email"), path( "cids/group//email_results", @@ -60,10 +60,18 @@ urlpatterns = [ name="group_email_resend", ), path( - "cids/group//update", views.CidUserGroupUpdate.as_view(), name="group_update" + "cids/group//update", views.CidUserGroupUpdate.as_view(), name="cid_group_update" ), path( - "cids/group/create", views.CidUserGroupCreate.as_view(), name="group_create" + "cids/group/create", views.CidUserGroupCreate.as_view(), name="cid_group_create" + ), + path("user/group/", views.user_group_view, name="user_group_view"), + path("user/group/all", views.user_group_view_all, name="user_group_view_all"), + path( + "user/group//update", views.UserUserGroupUpdate.as_view(), name="user_group_update" + ), + path( + "user/group/create", views.UserUserGroupCreate.as_view(), name="user_group_create" ), path("cids/create", views.manage_cid_users, name="manage_cid_users"), ] diff --git a/generic/views.py b/generic/views.py index 82412259..d638b467 100644 --- a/generic/views.py +++ b/generic/views.py @@ -43,9 +43,10 @@ from .forms import ( CidUserForm, ExaminationForm, CidUserGroupForm, + UserUserGroupForm, ) -from .models import CidUser, CidUserGroup, Examination, QuestionNote +from .models import CidUser, CidUserGroup, Examination, QuestionNote, UserUserGroup from rapids.models import Rapid as RapidQuestion from rapids.models import Exam as RapidExam @@ -623,14 +624,18 @@ class ExamViews(View, LoginRequiredMixin): if not self.check_user_access(request.user, exam_id): raise PermissionDenied - cid_users = exam.valid_users.all() - + cid_users = exam.valid_cid_users.all() cid_user_count = cid_users.count() + user_users = exam.valid_user_users.all() + user_user_count = user_users.count() + context = { "exam": exam, "cid_users": cid_users, "cid_user_count": cid_user_count, + "user_users": user_users, + "user_user_count": user_user_count, "app_name": self.app_name, } @@ -644,6 +649,41 @@ class ExamViews(View, LoginRequiredMixin): ) + def exam_users_edit(self, request, exam_id): + exam = get_object_or_404(self.Exam, pk=exam_id) + + if not request.user.groups.filter(name="cid_user_manager").exists(): + #raise PermissionDenied + if request.user not in exam.author.all(): + if not self.check_user_access(request.user, exam_id): + raise PermissionDenied + + current_user_users = exam.valid_user_users.all() + + exam_groups = exam.user_user_groups.all() + + available_user_users = list(User.objects.filter(user_groups__in=exam_groups).difference(current_user_users)) + print(available_user_users) + #available_user_users = User.objects.all() + + context = { + "exam": exam, + "groups": exam_groups, + "current_user_users": current_user_users, + "available_user_users": available_user_users, + "app_name": self.app_name, + } + + if self.app_name == "atlas": + context["collection"] = exam + + return render( + request, + "exam_users_edit.html", + context + ) + + def exam_cids_edit(self, request, exam_id): exam = get_object_or_404(self.Exam, pk=exam_id) @@ -653,7 +693,7 @@ class ExamViews(View, LoginRequiredMixin): if not self.check_user_access(request.user, exam_id): raise PermissionDenied - current_cid_users = exam.valid_users.all() + current_cid_users = exam.valid_cid_users.all() exam_groups = exam.cid_user_groups.all() @@ -725,6 +765,26 @@ class ExamViews(View, LoginRequiredMixin): data = {"status": "success", "added": add} return JsonResponse(data, status=200) + if "edit_user_user" in request.POST: + user_id = request.POST.get("edit_user_user") + + add = request.POST.get("add") == "true" + + user_user = User.objects.get(pk=user_id) + + app_exam_map = {} + app_exam_map["rapids"] = user_user.user_rapid_exams + #app_exam_map["atlas"] = user_user.casecollection_exams + + if add: + app_exam_map[self.app_name].add(pk) + else: + app_exam_map[self.app_name].remove(pk) + + + data = {"status": "success", "added": add} + return JsonResponse(data, status=200) + if "add_exam_questions" in request.POST: question_ids = json.loads(request.POST.get("add_exam_questions")) question_objects = self.Question.objects.filter(pk__in=question_ids) @@ -932,7 +992,7 @@ class ExamViews(View, LoginRequiredMixin): for exam in exams: if exam.active or self.check_user_access(request.user, exam.pk): print(exam.name, cid, passcode) - if exam.exam_mode and not exam.check_cid_user(cid, passcode): + if exam.exam_mode and not exam.check_cid_user(cid, passcode, user_id=request.user.pk): print(exam.name, "fail") continue @@ -979,7 +1039,7 @@ class ExamViews(View, LoginRequiredMixin): n = 0 for answer in json.loads(request.POST.get("answers")): - ret_status, ret = self.loadJsonAnswer(answer) + ret_status, ret = self.loadJsonAnswer(answer, user=request.user) # try: # ret_status, ret = self.loadJsonAnswer(answer) # except: @@ -996,15 +1056,18 @@ class ExamViews(View, LoginRequiredMixin): # print(UserAnswer.objects.filter(exam__id=q["eid"])) # print(request.urlencode()) - exam_type, exam_id = request.POST.get("eid").split("/") - c = CidUser.objects.filter(cid=request.POST.get("cid")).first() exam = self.Exam.objects.get(pk=exam_id) t = timezone.make_aware( datetime.fromtimestamp(float(request.POST.get("start_time"))) ) - cid_user_exam = exam.get_or_create_cid_user_exam(cid_user=c, start_time=t) + + if request.POST.get("cid").startswith("u-"): + cid_user_exam = exam.get_or_create_cid_user_exam(user_user=request.user, start_time=t) + else: + c = CidUser.objects.filter(cid=request.POST.get("cid")).first() + cid_user_exam = exam.get_or_create_cid_user_exam(cid_user=c, start_time=t) cid_user_exam.end_time = timezone.now() cid_user_exam.save() @@ -1021,7 +1084,11 @@ class ExamViews(View, LoginRequiredMixin): if not exam.active and not self.check_user_access(request.user, pk): raise Http404("No available exam") + time = datetime.now() + exam_json = exam.get_exam_json(based=False) + exam_json["generated"] = time.isoformat() + exam_json["exam_json_id"] = exam.exam_json_id # exam_json["exam_active"] = False return JsonResponse(exam_json) @@ -1115,10 +1182,10 @@ class ExamViews(View, LoginRequiredMixin): if not exam.exam_mode: raise Http404("Packet not in exam mode") - user_answers_and_marks = defaultdict(list) + #user_answers_and_marks = defaultdict(list) user_answers_marks = defaultdict(list) user_answers = defaultdict(list) - user_names = {} + #user_names = {} # cid_passcodes = {} by_question = defaultdict(dict) @@ -1136,11 +1203,16 @@ class ExamViews(View, LoginRequiredMixin): # Loop through all candidates for cid_user_answer in cid_user_answers: # Convoluted (probably...) - cid = cid_user_answer.cid - # cid_passcodes[cid] = cid_user_answer.passcode - cids.add(cid) + if cid_user_answer.user is None: + cid = cid_user_answer.cid + # cid_passcodes[cid] = cid_user_answer.passcode + cids.add(cid) + else: + cid = f"u/{cid_user_answer.user.username}" + cids.add(cid) + s = cid_user_answer - user_names[cid] = cid + #user_names[cid] = cid q = cid_user_answer.question @@ -1159,7 +1231,7 @@ class ExamViews(View, LoginRequiredMixin): unmarked.add(index) user_answers[cid].append(ans) user_answers_marks[cid].append(answer_score) - user_answers_and_marks[cid].append((ans, answer_score)) + #user_answers_and_marks[cid].append((ans, answer_score)) if self.app_name in ("rapids", "anatomy", "sbas"): by_question[q][cid] = (ans, answer_score) @@ -1247,13 +1319,13 @@ class ExamViews(View, LoginRequiredMixin): "unmarked": unmarked, "questions": questions, "by_question": by_question, - "user_answers": dict(user_answers), - "user_answers_marks": dict(user_answers_marks), + #"user_answers": dict(user_answers), + #"user_answers_marks": dict(user_answers_marks), "user_scores": user_scores, "user_scores_normalised": user_scores_normalised, - "user_scores_list": user_scores_list, - "user_names": user_names, - "user_answers_and_marks": user_answers_and_marks, + #"user_scores_list": user_scores_list, + #"user_names": user_names, + #"user_answers_and_marks": user_answers_and_marks, "max_score": max_score, "mean": mean, "median": median, @@ -1480,36 +1552,43 @@ class CidUserView(CidManagerRequiredMixin, SingleTableMixin, FilterView): return context -@login_required @user_is_cid_user_manager -def group_view(request): +def cid_group_view(request): groups = CidUserGroup.objects.filter(archive=False) return render( request, - "generic/group_view.html", + "generic/cid_group_view.html", {"groups": groups}, ) - @user_is_cid_user_manager -def group_view(request): - groups = CidUserGroup.objects.filter(archive=False) - - return render( - request, - "generic/group_view.html", - {"groups": groups, "view_all": False}, - ) - - -@user_is_cid_user_manager -def group_view_all(request): +def cid_group_view_all(request): groups = CidUserGroup.objects.filter() return render( request, - "generic/group_view.html", + "generic/cid_group_view.html", + {"groups": groups, "view_all": True}, + ) + +@user_is_cid_user_manager +def user_group_view(request): + groups = UserUserGroup.objects.filter(archive=False) + + return render( + request, + "generic/user_group_view.html", + {"groups": groups}, + ) + +@user_is_cid_user_manager +def user_group_view_all(request): + groups = UserUserGroup.objects.filter() + + return render( + request, + "generic/user_group_view.html", {"groups": groups, "view_all": True}, ) @@ -1543,21 +1622,6 @@ def group_email(request, pk, resend=False): {"users": users, "users_count": users.count(), "results": False}, ) - # sent = [] - # not_sent = [] - # for u in users: - # success, msg = u.email_details(resend=resend) - # if success: - # sent.append(u) - # else: - # not_sent.append((u, msg)) - - # return render( - # request, - # "generic/group_emailed.html", - # {"sent": sent, "not_sent": not_sent, "users_count": users.count()}, - # ) - @user_is_cid_user_manager def group_email_results(request, pk, resend=False): @@ -1788,3 +1852,12 @@ class CidUserGroupCreate(RevisionMixin, CidManagerRequiredMixin, CreateView): class CidUserGroupUpdate(RevisionMixin, CidManagerRequiredMixin, UpdateView): model = CidUserGroup form_class = CidUserGroupForm + +class UserUserGroupCreate(RevisionMixin, CidManagerRequiredMixin, CreateView): + model = UserUserGroup + form_class = UserUserGroupForm + + +class UserUserGroupUpdate(RevisionMixin, CidManagerRequiredMixin, UpdateView): + model = UserUserGroup + form_class = UserUserGroupForm \ No newline at end of file diff --git a/longs/migrations/0060_rename_valid_users_exam_valid_cid_users.py b/longs/migrations/0060_rename_valid_users_exam_valid_cid_users.py new file mode 100644 index 00000000..3d47742f --- /dev/null +++ b/longs/migrations/0060_rename_valid_users_exam_valid_cid_users.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-05-20 10:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('longs', '0059_exam_authors_only'), + ] + + operations = [ + migrations.RenameField( + model_name='exam', + old_name='valid_users', + new_name='valid_cid_users', + ), + ] diff --git a/longs/migrations/0061_exam_valid_user_users.py b/longs/migrations/0061_exam_valid_user_users.py new file mode 100644 index 00000000..4e787e20 --- /dev/null +++ b/longs/migrations/0061_exam_valid_user_users.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.13 on 2022-05-20 12:42 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('longs', '0060_rename_valid_users_exam_valid_cid_users'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='valid_user_users', + field=models.ManyToManyField(blank=True, related_name='user_longs_exams', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/longs/models.py b/longs/models.py index 49671f00..a1b966f0 100644 --- a/longs/models.py +++ b/longs/models.py @@ -595,7 +595,11 @@ class Exam(ExamBase): double_mark = models.BooleanField(default=True, help_text="Defines if an exam is expected to be double marked") - valid_users = models.ManyToManyField(CidUser, blank=True, related_name="longs_exams") + valid_cid_users = models.ManyToManyField(CidUser, blank=True, related_name="longs_exams") + + valid_user_users = models.ManyToManyField( + settings.AUTH_USER_MODEL, blank=True, related_name="user_longs_exams" + ) def get_exam_question_json(self, question_id): q = get_object_or_404(Long, pk=question_id) diff --git a/physics/admin.py b/physics/admin.py index 9725fda9..8f34d382 100644 --- a/physics/admin.py +++ b/physics/admin.py @@ -24,7 +24,7 @@ class QuestionAdmin(VersionAdmin): #) class CidUserInline(admin.TabularInline): - model = Exam.valid_users.through + model = Exam.valid_cid_users.through extra = 1 class ExamAdmin(VersionAdmin, admin.ModelAdmin): diff --git a/physics/migrations/0020_rename_valid_users_exam_valid_cid_users.py b/physics/migrations/0020_rename_valid_users_exam_valid_cid_users.py new file mode 100644 index 00000000..b8bcfd67 --- /dev/null +++ b/physics/migrations/0020_rename_valid_users_exam_valid_cid_users.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-05-20 10:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('physics', '0019_exam_authors_only'), + ] + + operations = [ + migrations.RenameField( + model_name='exam', + old_name='valid_users', + new_name='valid_cid_users', + ), + ] diff --git a/physics/migrations/0021_exam_valid_user_users.py b/physics/migrations/0021_exam_valid_user_users.py new file mode 100644 index 00000000..37bbd597 --- /dev/null +++ b/physics/migrations/0021_exam_valid_user_users.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.13 on 2022-05-20 12:42 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('physics', '0020_rename_valid_users_exam_valid_cid_users'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='valid_user_users', + field=models.ManyToManyField(blank=True, related_name='user_physics_exams', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/physics/models.py b/physics/models.py index b1d7ea60..6248e6b7 100644 --- a/physics/models.py +++ b/physics/models.py @@ -152,10 +152,14 @@ class Exam(ExamBase): related_name="physics_exam_author", ) - valid_users = models.ManyToManyField( + valid_cid_users = models.ManyToManyField( CidUser, blank=True, related_name="physics_exams" ) + valid_user_users = models.ManyToManyField( + settings.AUTH_USER_MODEL, blank=True, related_name="user_physics_exams" + ) + def get_take_url(self): return reverse("physics:exam_start", kwargs={"pk": self.pk}) diff --git a/rad/settings.py b/rad/settings.py index 2ab39b77..fc24626f 100644 --- a/rad/settings.py +++ b/rad/settings.py @@ -76,13 +76,13 @@ MIDDLEWARE = [ "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", "django.middleware.csrf.CsrfViewMiddleware", + "debug_toolbar.middleware.DebugToolbarMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "reversion.middleware.RevisionMiddleware", - "debug_toolbar.middleware.DebugToolbarMiddleware", ] ROOT_URLCONF = "rad.urls" diff --git a/rad/urls.py b/rad/urls.py index 04b90722..2467921b 100644 --- a/rad/urls.py +++ b/rad/urls.py @@ -98,6 +98,8 @@ if settings.DEBUG: document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + urlpatterns += static("/rts/", + document_root="rts") import debug_toolbar urlpatterns = [ diff --git a/rad/views.py b/rad/views.py index c2ce256f..6fe17c97 100644 --- a/rad/views.py +++ b/rad/views.py @@ -185,6 +185,7 @@ def active_exams(request, cid=None, passcode=None): if request.user: active_exams["user"] = request.user.username + active_exams["user_id"] = request.user.pk else: active_exams["user"] = False @@ -205,6 +206,7 @@ def active_exams_unbased(request): if request.user: active_exams["user"] = request.user.username + active_exams["user_id"] = request.user.pk else: active_exams["user"] = False diff --git a/rapids/filters.py b/rapids/filters.py index 81de43a6..89d0822f 100755 --- a/rapids/filters.py +++ b/rapids/filters.py @@ -46,6 +46,7 @@ class RapidUserAnswerFilter(django_filters.FilterSet): model = CidUserAnswer fields = ( "cid", + "user", "normal", "answer", #"answer_compare", diff --git a/rapids/forms.py b/rapids/forms.py index 43fa4161..430af0f8 100755 --- a/rapids/forms.py +++ b/rapids/forms.py @@ -243,6 +243,7 @@ class ExamForm(ExamFormMixin, ModelForm): "publish_results", "archive", "cid_user_groups", + "user_user_groups", #"author", ] diff --git a/rapids/migrations/0054_rename_valid_users_exam_valid_cid_users.py b/rapids/migrations/0054_rename_valid_users_exam_valid_cid_users.py new file mode 100644 index 00000000..20547203 --- /dev/null +++ b/rapids/migrations/0054_rename_valid_users_exam_valid_cid_users.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-05-20 10:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('rapids', '0053_ciduseranswer_user'), + ] + + operations = [ + migrations.RenameField( + model_name='exam', + old_name='valid_users', + new_name='valid_cid_users', + ), + ] diff --git a/rapids/migrations/0055_exam_valid_user_users.py b/rapids/migrations/0055_exam_valid_user_users.py new file mode 100644 index 00000000..7f682610 --- /dev/null +++ b/rapids/migrations/0055_exam_valid_user_users.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.13 on 2022-05-20 10:59 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('rapids', '0054_rename_valid_users_exam_valid_cid_users'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='valid_user_users', + field=models.ManyToManyField(blank=True, related_name='user_rapid_exams', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/rapids/migrations/0056_exam_user_user_groups.py b/rapids/migrations/0056_exam_user_user_groups.py new file mode 100644 index 00000000..a7eedf15 --- /dev/null +++ b/rapids/migrations/0056_exam_user_user_groups.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.13 on 2022-05-20 17:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('generic', '0029_userusergroup'), + ('rapids', '0055_exam_valid_user_users'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='user_user_groups', + field=models.ManyToManyField(blank=True, help_text='These groups define which candidates are able to be added to the exams/collection.', to='generic.UserUserGroup'), + ), + ] diff --git a/rapids/models.py b/rapids/models.py index 01db6b0f..314879ef 100644 --- a/rapids/models.py +++ b/rapids/models.py @@ -26,7 +26,16 @@ from helpers.images import image_as_base64 from django.contrib.contenttypes.fields import GenericRelation -from generic.models import CidUser, CidUserGroup, Site, Condition, Sign, ExamBase, QuestionNote +from generic.models import ( + CidUser, + CidUserGroup, + Site, + Condition, + Sign, + ExamBase, + QuestionNote, + UserUserGroup, +) import reversion @@ -245,7 +254,10 @@ class Rapid(models.Model): if self.normal: return "Normal" else: - if hasattr(self, "prefetched_primary_answer") and self.prefetched_primary_answer: + if ( + hasattr(self, "prefetched_primary_answer") + and self.prefetched_primary_answer + ): ans = self.prefetched_primary_answer[0] else: ans = self.answers.filter( @@ -257,7 +269,7 @@ class Rapid(models.Model): else: return ans.answer - #def get_primary_answer(self): + # def get_primary_answer(self): # if self.normal: # return "Normal" # elif ( @@ -302,7 +314,6 @@ class Rapid(models.Model): marked_answers = self.get_marked_answers() - unmarked_answers = set( [ i @@ -449,7 +460,7 @@ class Rapid(models.Model): feedback_images.append( "{}/{}".format(settings.REMOTE_URL, i.image.url) ) - #feedback_images.append("{}/{}".format(settings.REMOTE_URL, i.image.url)) + # feedback_images.append("{}/{}".format(settings.REMOTE_URL, i.image.url)) else: if based: images.append(image_as_base64(i.image)) @@ -471,7 +482,6 @@ class Rapid(models.Model): if feedback: json["feedback"] = self.feedback - return json def anonymise_images(self): @@ -487,6 +497,7 @@ class Rapid(models.Model): except pydicom.errors.InvalidDicomError: pass + # reversion.register(Rapid, follow=["images"]) # @reversion.register class RapidImage(models.Model): @@ -501,7 +512,12 @@ class RapidImage(models.Model): feedback_image = models.BooleanField(default=False) - filename = models.CharField(max_length=255, null=True, blank=True, help_text="Name of the original file when uploaded") + filename = models.CharField( + max_length=255, + null=True, + blank=True, + help_text="Name of the original file when uploaded", + ) description = models.CharField(max_length=255, null=True, blank=True) @@ -591,11 +607,25 @@ class Exam(ExamBase): related_name="rapid_exam_author", ) - valid_users = models.ManyToManyField( + valid_cid_users = models.ManyToManyField( CidUser, blank=True, related_name="rapid_exams" ) - cid_user_groups = models.ManyToManyField(CidUserGroup, blank=True, help_text="These groups define which candidates are able to be added to the exams/collection.") + valid_user_users = models.ManyToManyField( + settings.AUTH_USER_MODEL, blank=True, related_name="user_rapid_exams" + ) + + cid_user_groups = models.ManyToManyField( + CidUserGroup, + blank=True, + help_text="These groups define which candidates are able to be added to the exams/collection.", + ) + + user_user_groups = models.ManyToManyField( + UserUserGroup, + blank=True, + help_text="These groups define which candidates are able to be added to the exams/collection.", + ) def get_normal_abnormal_breakdown(self): # Inefficient but more extendible @@ -638,7 +668,7 @@ class Exam(ExamBase): images.append(image_as_base64(i.image)) else: images.append("{}/{}".format(settings.REMOTE_URL, i.image.url)) - + if i.description: image_titles.append(i.description) else: @@ -664,8 +694,6 @@ class Exam(ExamBase): if any(image_titles): exam_questions[q.id]["image_titles"] = image_titles - - # if feedback_images: # exam_questions[q.id]["feedback_image"] = feedback_images @@ -709,10 +737,12 @@ class CidUserAnswer(models.Model): ) user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, # , related_name="user_rapid_user_answers" ) - # Each user answer is associated with a particular exam exam = models.ForeignKey( Exam, related_name="cid_user_answers", on_delete=models.CASCADE, null=True @@ -752,7 +782,9 @@ class CidUserAnswer(models.Model): self.answer = self.answer.strip() s = self.answer.lower() - s = s.translate(str.maketrans("", "", string.punctuation.replace("#", ""))).strip() + s = s.translate( + str.maketrans("", "", string.punctuation.replace("#", "")) + ).strip() self.answer_compare = s else: @@ -805,7 +837,6 @@ class CidUserAnswer(models.Model): self.save() return 0 - # TODO: this should be cleaned up as we don't want duplicates... # try: # marked_ans = q.answers.get(answer_compare__iexact=ans) @@ -825,4 +856,4 @@ class CidUserAnswer(models.Model): elif marked_ans.status == Answer.MarkOptions.INCORRECT: mark = 0 - return mark \ No newline at end of file + return mark diff --git a/rapids/serializers.py b/rapids/serializers.py index 785fde5a..06114db7 100644 --- a/rapids/serializers.py +++ b/rapids/serializers.py @@ -24,7 +24,7 @@ class UserAnswerSerializer(serializers.HyperlinkedModelSerializer): ) class Meta: model = CidUserAnswer - fields = ('id', 'question', 'normal', "answer", "answer_compare", "cid", "exam", "created", "updated") + fields = ('id', 'question', 'normal', "answer", "answer_compare", "cid", "user", "exam", "created", "updated") class RapidSerializer(serializers.ModelSerializer): class Meta: diff --git a/rapids/tables.py b/rapids/tables.py index 728991c0..78846f9c 100755 --- a/rapids/tables.py +++ b/rapids/tables.py @@ -80,6 +80,7 @@ class RapidUserAnswerTable(tables.Table): template_name = "django_tables2/bootstrap4.html" fields = ( "cid", + "user", "question", "normal", "answer", diff --git a/rapids/templates/rapids/ciduseranswer_detail.html b/rapids/templates/rapids/ciduseranswer_detail.html index 1da0bb3f..6f69872a 100644 --- a/rapids/templates/rapids/ciduseranswer_detail.html +++ b/rapids/templates/rapids/ciduseranswer_detail.html @@ -3,6 +3,7 @@ {% block content %} CID: {{ciduseranswer.cid}}
+User: {{ciduseranswer.user}}
Exam: {{ciduseranswer.exam}}
{{ciduseranswer.question}}
Normal: {{ciduseranswer.normal}}
diff --git a/rapids/templates/rapids/exam_scores_new.html b/rapids/templates/rapids/exam_scores_new.html index 89ba0a4a..e211c950 100644 --- a/rapids/templates/rapids/exam_scores_new.html +++ b/rapids/templates/rapids/exam_scores_new.html @@ -33,7 +33,11 @@ {% for cid in cids %} + {% if cid|slice:":1" == "u" %} + {{cid}} + {% else %} {{cid}} + {% endif %} {{user_scores|get_item:cid}} {{user_scores_normalised|get_item:cid}} diff --git a/rapids/urls.py b/rapids/urls.py index acca7001..406a0687 100755 --- a/rapids/urls.py +++ b/rapids/urls.py @@ -113,6 +113,7 @@ urlpatterns = [ path("exam//clone", views.ExamClone.as_view(), name="exam_clone"), path("exam//cids", views.GenericExamViews.exam_cids, name="exam_cids"), path("exam//cids/edit", views.GenericExamViews.exam_cids_edit, name="exam_cids_edit"), + path("exam//users/edit", views.GenericExamViews.exam_users_edit, name="exam_users_edit"), path("exam//update", views.ExamUpdate.as_view(), name="exam_update"), path("exam//delete", views.ExamDelete.as_view(), name="exam_delete"), path("exam/json/", views.GenericExamViews.active_exams, name="active_exams"), diff --git a/rapids/views.py b/rapids/views.py index ec1403d6..57a4fd35 100755 --- a/rapids/views.py +++ b/rapids/views.py @@ -509,26 +509,48 @@ class RapidView(LoginRequiredMixin, SingleTableMixin, FilterView): filterset_class = RapidFilter -def loadJsonAnswer(answer): +def loadJsonAnswer(answer, user=None): eid = int(answer["eid"].split("/")[1]) - cid = int(answer["cid"]) + uid = False - # As access is not restricted make sure the data appears valid - if (not isinstance(cid, int)) or ( - not isinstance(eid, int) or (not isinstance(answer["ans"], str)) - ): - return False, JsonResponse( - {"success": False, "error": "cid or eid or answers not defined"} - ) + try: + cid = int(answer["cid"]) + except ValueError: + if answer["cid"].startswith("u-"): + cid = False + uid = int(answer["cid"][2:]) - # The model should catch invalid data but this should be less intensive - max_int = 999999999999999999999 - if cid > max_int or eid > max_int: - return False, JsonResponse({"success": False, "error": "invalid cid"}) + print(f"{uid=}") + print(f"{uid=}") + if not uid == user.id: + return False, JsonResponse( + {"success": False, "error": "user / uid mismatch"} + ) + + else: + return False, JsonResponse( + {"success": False, "error": "cid or eid or answers not defined"} + ) + + #uid = int(answer["uid"]) + + if not uid: + # As access is not restricted make sure the data appears valid + if (not isinstance(cid, int)) or ( + not isinstance(eid, int) or (not isinstance(answer["ans"], str)) + ): + return False, JsonResponse( + {"success": False, "error": "cid or eid or answers not defined"} + ) + + ## The model should catch invalid data but this should be less intensive + #max_int = 999999999999999999999 + #if cid > max_int or eid > max_int: + # return False, JsonResponse({"success": False, "error": "invalid cid"}) exam = get_object_or_404(Exam, pk=eid) - if not exam.check_cid_user(cid, answer["passcode"]): + if not exam.check_cid_user(cid, answer["passcode"], user_id=uid): return False, JsonResponse( {"success": False, "error": "invalid cid / passcode"} ) @@ -538,9 +560,14 @@ def loadJsonAnswer(answer): # {"success": False, "error": "No active exam: {}".format(eid)} # ) - exiting_answers = CidUserAnswer.objects.filter( - question__id=answer["qid"], exam__id=eid, cid=cid - ) + if uid: + exiting_answers = CidUserAnswer.objects.filter( + question__id=answer["qid"], exam__id=eid, user__id=uid + ) + else: + exiting_answers = CidUserAnswer.objects.filter( + question__id=answer["qid"], exam__id=eid, cid=cid + ) posted_answer = answer["ans"] @@ -554,7 +581,11 @@ def loadJsonAnswer(answer): normal = True if not exiting_answers: - ans = CidUserAnswer(answer=posted_answer, normal=normal, cid=cid) + if uid: + ans = CidUserAnswer(answer=posted_answer, normal=normal, user=User.objects.get(id=uid)) + else: + ans = CidUserAnswer(answer=posted_answer, normal=normal, cid=cid) + ans.question_id = answer["qid"] ans.exam_id = eid diff --git a/sbas/migrations/0012_rename_valid_users_exam_valid_cid_users.py b/sbas/migrations/0012_rename_valid_users_exam_valid_cid_users.py new file mode 100644 index 00000000..a68660f1 --- /dev/null +++ b/sbas/migrations/0012_rename_valid_users_exam_valid_cid_users.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-05-20 10:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('sbas', '0011_exam_authors_only'), + ] + + operations = [ + migrations.RenameField( + model_name='exam', + old_name='valid_users', + new_name='valid_cid_users', + ), + ] diff --git a/sbas/migrations/0013_exam_valid_user_users.py b/sbas/migrations/0013_exam_valid_user_users.py new file mode 100644 index 00000000..7d134e1e --- /dev/null +++ b/sbas/migrations/0013_exam_valid_user_users.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.13 on 2022-05-20 12:42 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('sbas', '0012_rename_valid_users_exam_valid_cid_users'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='valid_user_users', + field=models.ManyToManyField(blank=True, related_name='user_sba_exams', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/sbas/models.py b/sbas/models.py index e931f853..e1672e3a 100644 --- a/sbas/models.py +++ b/sbas/models.py @@ -164,7 +164,11 @@ class Exam(ExamBase): related_name="sba_exam_author", ) - valid_users = models.ManyToManyField(CidUser, blank=True, related_name="sba_exams") + valid_cid_users = models.ManyToManyField(CidUser, blank=True, related_name="sba_exams") + + valid_user_users = models.ManyToManyField( + settings.AUTH_USER_MODEL, blank=True, related_name="user_sba_exams" + ) def get_take_url(self): return reverse("sbas:exam_start", kwargs={"pk": self.pk}) diff --git a/static/autocomplete_light/autocomplete_light.js b/static/autocomplete_light/autocomplete_light.js index c58c837b..4ead99e3 100644 --- a/static/autocomplete_light/autocomplete_light.js +++ b/static/autocomplete_light/autocomplete_light.js @@ -3,7 +3,7 @@ */ var yl = yl || {}; -yl.functions = {}; +yl.functions = yl.functions || {}; /** * Register your own JS function for DAL. * @@ -125,7 +125,7 @@ window.addEventListener("load", function () { // these are used by django-nested-admin for nested template formsets // note that the filter also ensures that 'empty' is not actually the related_name for some relation // by ensuring that it is not surrounded by numbers on both sides - return !this.id.match(/(?i?n[i]:null)?r(o):null)},yl.getForwards=function(a){var e,d,t="div.dal-forward-conf#dal-forward-conf-for-"+a.attr("id")+", div.dal-forward-conf#dal-forward-conf-for_"+a.attr("id"),n=(0e?n[e]:null;return null!==e?r(e):null};return"multiple"===t?n.map(r):"exists"===t?0').text(text).addClass('dal-create'); + var $result = $('').addClass('dal-create'); + if (is_data_html){ + return $result.html(item.text); + } else { + return $result.text(item.text); + } } else { - return text + return template(item.text, is_data_html); } } @@ -71,9 +74,23 @@ document.addEventListener('dal-init-function', function () { cache: true }; } - + use_tags = false; + tokenSeparators = null; + // Option 1: 'data-tags' + if ($element.attr('data-tags')) { + tokenSeparators = [',']; + use_tags = true; + } + // Option 2: 'data-token-separators' + if ($element.attr('data-token-separators')) { + use_tags = true + tokenSeparators = $element.attr('data-token-separators') + if (tokenSeparators == 'null') { + tokenSeparators = null; + } + } $element.select2({ - tokenSeparators: $element.attr('data-tags') ? [','] : null, + tokenSeparators: tokenSeparators, debug: true, containerCssClass: ':all:', placeholder: $element.attr('data-placeholder') || '', @@ -84,7 +101,7 @@ document.addEventListener('dal-init-function', function () { templateSelection: selected_template, ajax: ajax, with: null, - tags: Boolean($element.attr('data-tags')), + tags: use_tags, }); $element.on('select2:selecting', function (e) { @@ -119,4 +136,3 @@ document.addEventListener('dal-init-function', function () { }); }); }) - diff --git a/static/autocomplete_light/select2.min.js b/static/autocomplete_light/select2.min.js index f880ea2b..5799716f 100644 --- a/static/autocomplete_light/select2.min.js +++ b/static/autocomplete_light/select2.min.js @@ -1 +1 @@ -document.addEventListener("dal-init-function",function(){yl.registerFunction("select2",function($,t){var a=$(t);function r(t,e){if(e){var a=$("");return a.html(t),a}return t}function e(t){var e=r(t.text,(void 0!==a.attr("data-html")||void 0!==a.attr("data-result-html"))&&!t.create_id);return t.create_id?$("").text(e).addClass("dal-create"):e}var l=null;a.attr("data-autocomplete-light-url")&&(l={url:a.attr("data-autocomplete-light-url"),dataType:"json",delay:250,data:function(t){return{q:t.term,page:t.page,create:a.attr("data-autocomplete-light-create")&&!a.attr("data-tags"),forward:yl.getForwards(a)}},processResults:function(t,e){return a.attr("data-tags")&&$.each(t.results,function(t,e){e.id=e.text}),t},cache:!0}),a.select2({tokenSeparators:a.attr("data-tags")?[","]:null,debug:!0,containerCssClass:":all:",placeholder:a.attr("data-placeholder")||"",language:a.attr("data-autocomplete-light-language"),minimumInputLength:a.attr("data-minimum-input-length")||0,allowClear:!a.is("[required]"),templateResult:e,templateSelection:function(t){return void 0!==t.selected_text?r(t.selected_text,void 0!==a.attr("data-html")||void 0!==a.attr("data-selected-html")):e(t)},ajax:l,with:null,tags:Boolean(a.attr("data-tags"))}),a.on("select2:selecting",function(t){var r,e=t.params.args.data;!0===e.create_id&&(t.preventDefault(),r=a,$.ajax({url:a.attr("data-autocomplete-light-url"),type:"POST",dataType:"json",data:{text:e.id,forward:yl.getForwards(a)},beforeSend:function(t,e){t.setRequestHeader("X-CSRFToken",document.csrftoken)},success:function(t,e,a){r.append($("