Compare commits

...
64 Commits
Author SHA1 Message Date
Ross 779559997e Enhance DICOM viewer interaction by adding context menu prevention and fullscreen support; register basic interaction tools for improved user experience 2025-09-08 14:00:19 +01:00
Ross 0db1e4c689 Implement code changes to enhance functionality and improve performance 2025-09-08 12:09:16 +01:00
Ross 6b1db6b3c5 Enhance image display in mark_answer.html and add remote URL to context in mark_answer view for improved layout and functionality 2025-09-08 11:25:28 +01:00
Ross 9ed8fd90b3 Enhance feedback display in exam scores template and update question data retrieval for improved user experience 2025-09-08 11:01:17 +01:00
Ross 30523c9296 Enhance exam scores and marking templates with improved feedback display and structured question details 2025-09-08 10:56:37 +01:00
Ross 83e8c14f31 Enhance navigation buttons for previous and next candidates with improved styling and accessibility features 2025-09-08 10:50:18 +01:00
Ross d7a410e8f8 Enhance marking completion message and candidate display in mark_answer.html for improved clarity 2025-09-08 10:47:58 +01:00
Ross 7cc6b2959a Add help tags for enhanced user guidance in templates and improve feedback descriptions in models 2025-09-08 10:45:33 +01:00
Ross 7ff5d1d37e Enhance shorts exam functionality by adding history tracking and updating feedback display in exam scores template 2025-09-08 10:26:16 +01:00
Ross 5270d5cd36 Refactor feedback image handling and enhance sample answers display in exam scores user template 2025-09-08 10:16:53 +01:00
Ross 214b531757 Add exam scores user template with dynamic answer display and feedback 2025-09-08 09:47:34 +01:00
Ross 963221633a Add Shorts exam type to USER_EXAM_TYPES and update index.html for course/exam navigation 2025-09-08 09:39:35 +01:00
Ross 149b95a7e4 Fix URL for viewing question details by adjusting index in mark_answer.html 2025-09-02 21:38:21 +01:00
Ross 8a92460cb2 Increase max_length of image_md5_hash field to 64 in SeriesImage and LongSeriesImage models 2025-09-02 21:22:06 +01:00
Ross a7e0438290 Add Shorts Exams support in ExamCollection forms and detail view 2025-09-01 22:07:27 +01:00
Ross 2fca5a098d Add Shorts category to USER_GROUP_EXAMS for exam grouping 2025-09-01 22:04:22 +01:00
Ross 6a8bded825 Add Shorts category to CID_GROUP_EXAMS for exam grouping 2025-09-01 22:02:55 +01:00
Ross 15c5b2e234 Add Shorts exam models and integrate into UserGroupExamForm and ExamViews 2025-09-01 22:00:04 +01:00
Ross 964bce8233 Enhance user experience by adding informative messages and edit mode functionality across various templates 2025-09-01 14:47:15 +01:00
Ross 16a8bf3d3a Refactor DICOM file reading to use dcmread for improved compatibility across models and helpers 2025-08-18 10:45:34 +01:00
Ross 9bda3f22db Refactor details elements to use styled-detail class for consistent styling and add empty state message for findings 2025-08-18 10:45:02 +01:00
Ross 61c3fb8a82 Enhance series viewer layout with improved author display and button interactions for DICOM operations 2025-08-18 10:41:00 +01:00
Ross f4ed53dedd Refactor DICOM file reading to use dcmread for improved compatibility 2025-08-18 10:29:13 +01:00
Ross 125cbef6ef Enhance case display with improved button interactions and add detailed instructions for reordering dicoms in help section 2025-08-18 10:18:12 +01:00
Ross 4afcfb6b26 Refactor case_order_dicom function to add type hint for case and improve error handling messages 2025-08-18 09:43:58 +01:00
Ross 2a351a8283 Change hx-swap attribute from outerHTML to innerHTML for case series reordering button 2025-08-18 09:40:54 +01:00
Ross d61ecaac66 Enhance check_user_can_edit method with detailed docstring for clarity 2025-08-18 09:40:49 +01:00
Ross e71aa4c802 Fix collection visibility logic to allow multiple authors in CaseTable 2025-08-11 17:18:31 +01:00
Ross a8840290f5 Add user-configurable pagination and enhance case view filters 2025-08-11 17:12:22 +01:00
Ross 99bc89fe20 Update help documentation and improve duplicate detection in upload process 2025-08-11 16:38:05 +01:00
Ross 754d46080d Enhance help documentation by adding detailed descriptions of collection types and improving uploader tool instructions 2025-08-11 11:32:44 +01:00
Ross 43d98d7adb bump d3vd (persist mouse settings) 2025-08-11 11:02:40 +01:00
Ross bc5749a308 Refactor collection detail and viva templates to improve link handling and enhance user instructions with modal display 2025-08-11 10:55:32 +01:00
Ross eb194aa1e6 Add case display setup template with viewer state management functionality 2025-08-11 10:55:23 +01:00
Ross 63c342ff21 Add duplicate check functionality to upload form with user alerts for detected duplicates 2025-08-11 10:42:08 +01:00
Ross 8e63dcc7c5 Enhance upload results display by organizing duplicate files in a collapsible section and adding alerts for detected duplicates 2025-08-11 09:57:19 +01:00
Ross ddb5783f7f Refactor CaseCollectionForm layout and add date fields with custom widgets for improved user experience 2025-08-11 09:57:14 +01:00
Ross 816b2adde2 Refactor case collection form to use crispy forms for improved layout and styling 2025-08-04 13:19:19 +01:00
Ross b3304b6113 Enhance case collection update form with additional instructions and improve case detail links with icons 2025-08-04 12:59:42 +01:00
Ross 0af6b25349 Add case collection update functionality with form handling and template (move from CaseCollection form) 2025-08-04 12:21:37 +01:00
Ross c33e8c0240 Add open access display to collection detail page 2025-08-04 11:48:43 +01:00
Ross a2fa94722a Add initial documentation for PenraCourses platform and features 2025-08-04 11:48:35 +01:00
Ross 9200e9aae5 Filter collections in CaseTable based on user access and open access status 2025-07-29 10:03:10 +01:00
Ross 8806e48eb2 Add open access management buttons and views for cases 2025-07-29 09:40:19 +01:00
Ross 4460545442 Too many changes :( 2025-07-29 09:20:58 +01:00
Ross ad4be1e9a6 improve collection overview page 2025-07-21 12:52:45 +01:00
Ross 6a919ffc53 all cid collectio naccess 2025-07-21 12:52:34 +01:00
Ross 8d80090af0 switch to watchman 2025-07-21 12:52:13 +01:00
Ross f5258d7e6f git displaysets working in viva view 2025-07-21 10:51:34 +01:00
Ross 5187885c3b lots of improvements 2025-07-14 13:26:11 +01:00
Ross ec97fa0c71 . 2025-07-07 20:44:14 +01:00
Ross 8fc38557ac . 2025-07-07 13:31:26 +01:00
Ross 804cff33f4 . 2025-07-07 12:58:17 +01:00
Ross 0cdccbcdfb . 2025-07-07 12:09:17 +01:00
Ross c8939cf385 . 2025-07-07 11:42:53 +01:00
Ross d7499e9166 some updates 2025-07-07 11:41:44 +01:00
Ross f3b6f8ef59 update bootstrap 2025-07-07 10:45:36 +01:00
Ross 2ac8344b13 improve case form 2025-06-30 13:33:35 +01:00
Ross 4df7e5aebd start moking case display sets 2025-06-30 13:04:05 +01:00
Ross 8229813eba improve vivas 2025-06-30 12:58:32 +01:00
Ross 153045a3e4 optimise some dicom manipulations 2025-06-30 11:04:39 +01:00
Ross bc26209f07 start removing lots of print 2025-06-30 10:39:42 +01:00
Ross afa3b0c0ef . 2025-06-30 09:46:48 +01:00
Ross 2f8601aa1a i should do these more often 2025-06-23 09:25:48 +01:00
122 changed files with 64887 additions and 1524 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ ENV PYTHONUNBUFFERED 1
# python3 python3-dev gcc \ # python3 python3-dev gcc \
# gfortran musl-dev \ # gfortran musl-dev \
# libffi-dev openssl-dev # libffi-dev openssl-dev
RUN apt-get update && apt-get -y install postgresql postgresql-contrib git libcairo2-dev pkg-config gcc redis-tools RUN apt-get update && apt-get -y install postgresql postgresql-contrib git libcairo2-dev pkg-config gcc redis-tools watchman
# install dependencies # install dependencies
+75
View File
@@ -0,0 +1,75 @@
PenraCourses
# Access to the platform
URL: https://www.penracourses.org.uk/
Peninsula trainees are now automatically given access as numerous assessments / courses are run via the platform.
Other uses can have accounts created on request. Please contact ross.kruger@nhs.net
If available accounts will be set up with a users @nhs.net email
# Features
## Simulation of the RCR exam formats.
This includes the ability to mimic the pratique/risr software used in the actual exam. This web based so can be accessed from anywhere.
Supported exam types
- Physics
- Anatomy
- Rapids (old 2b format)
- Shorts (new 2b format)
- Longs
In each case exams/packets can be designed to either be "self marked" (user can take the exam and self review answers with no answer data submitted back to the platform) or "exam mode" where user answers are submitted back to the platform to be marked. The platform offers features to make marking as simple and quick as possible.
Once marked results can be published for the user to review.
Exams can be restricted to individual users or groups of users. Access can be restricted by date / time or manual activation.
Viva support is achieved via collections (see below).
Candidates for packets / exams can either be registered users of the platform or generated using a CID/Passcode (in which case no further user data need be stored on the platform, although an email address allows automatically messaging)
## Atlas
The platform supports uploading cases into a virtual "Atlas".
These cases can be categorised and tagged to allow easy retrieval via searching / other methods.
Each case can contain multiple image stacks/series. Related cases can be linked to allow display as a time series.
Cases can be viewed via the built in dicom viewer.
Images can be annotated and categorised (via finding, condition or anatomical location). It is also possible to create more complex display sets (analogous to bookmarks in Insight)
Resources (videos, pdfs, etc) can be attached to cases.
### Case Collections
Case collections are sets of cases. These can take a number of different formats that alters how they are displayed to the end user.
Once collections have been completed a user can share their results / progress with nominated supervisors.
- **Review**:
Cases are displayed sequentially to a user. No user input is stored. User are given predefined feedback (either after each case or at the end of the collection).
- **Report**:
Cases are displayed sequentially to the user. The user is able to enter a report for each case. Feedback can be given automatically (and a user self review) or the reports can be reviewed (and marked/assessed) by the collection author. Feedback can be given once the whole collection is complete or after each case.
- **Question**:
Cases are displayed sequentially to the user. Each case is linked to a predefined set of questions. Supported question types include
- Text answers
- True / False (or Yes / No)
- Multiple choice (single or multiselect)
- Numerical
Each case can have multiple questions associated, these may be the same or different for each case in the series.
- **Viva**:
Viva collections are designed to make the delivery of viva type sessions as easy as possible. This collection type allows the synchronisation of two windows. This allows one to be shared (via teams or other screen sharing software) whilst information pertinent to the case is displayed in the other window. Cases can be show as a case or series and it is possible to recreate predefined display sets.
-2
View File
@@ -113,8 +113,6 @@ def get_exam_user_status_cid_user(request, exam_id: int, cid: int):
if not exam.check_user_can_edit(request.user): if not exam.check_user_can_edit(request.user):
raise HttpError(HTTPStatus.UNAUTHORIZED,"Exam author required") raise HttpError(HTTPStatus.UNAUTHORIZED,"Exam author required")
print(exam_id, cid)
return exam.exam_user_status.filter(cid_user_exam__cid_user__cid=cid) return exam.exam_user_status.filter(cid_user_exam__cid_user__cid=cid)
@router.get('/exam/{exam_id}/user_status/{user_id}/user', response=List[ExamStatusSchema]) @router.get('/exam/{exam_id}/user_status/{user_id}/user', response=List[ExamStatusSchema])
-1
View File
@@ -492,7 +492,6 @@ class Exam(ExamBase):
return answer return answer
def get_question_user_user_answer(self, question_index, user): def get_question_user_user_answer(self, question_index, user):
print(user)
question = self.get_question_by_index(question_index) question = self.get_question_by_index(question_index)
answer = UserAnswer.objects.filter(question=question, user=user.pk, exam=self) answer = UserAnswer.objects.filter(question=question, user=user.pk, exam=self)
+340 -253
View File
@@ -76,7 +76,7 @@ $(document).ready(function () {
if (dicom_images.length) { if (dicom_images.length) {
for (let element of dicom_images) { for (let element of dicom_images) {
setUpDicom(element) setUpDicom3d(element)
} }
} }
@@ -176,6 +176,37 @@ $(document).ready(function () {
}); });
// Table row selection
// Delegate click event to all table rows
document.querySelectorAll('.row-selector tbody tr').forEach(function(row) {
row.addEventListener('click', function(e) {
// Ignore clicks on links or checkboxes
if (e.target.tagName === 'A' || e.target.tagName === 'INPUT') return;
// Find the checkbox in this row
const checkbox = row.querySelector('input[name="selection"]');
if (checkbox) {
checkbox.checked = !checkbox.checked;
row.classList.toggle('selected', checkbox.checked);
}
});
// Keep row visually in sync with checkbox state (e.g. after page reload)
const checkbox = row.querySelector('input[name="selection"]');
if (checkbox && checkbox.checked) {
row.classList.add('selected');
}
// Also toggle row selection when checkbox is clicked directly
if (checkbox) {
checkbox.addEventListener('click', function(e) {
row.classList.toggle('selected', checkbox.checked);
// Prevent row click event from firing
e.stopPropagation();
});
}
});
}); });
window.loadDicomViewerEvent = new Event('loadDicomViewer'); window.loadDicomViewerEvent = new Event('loadDicomViewer');
@@ -330,266 +361,321 @@ function loadJsonToolStateOnCurrentImage(json) {
} }
async function setUpDicom(element) { //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;
console.log("Dicom element found ", $(element)); async function setUpDicom3d(element) {
console.log("setUpDicom3d (cornerstone3d) for", element);
$(element).bind('contextmenu', function (e) { // parse images from dataset
return false; let raw = element.dataset.url || "";
}); if (!raw) return;
let images;
$(element).dblclick((evt) => { try {
element.requestFullscreen(); images = JSON.parse(raw);
}) } catch (e) {
images = raw.split(",").map(s => s.trim()).filter(Boolean);
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);
}
} }
if (!images || images.length === 0) return;
cornerstone.enable(element); // ensure element has a viewport-like appearance
cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) { element.style.width = element.style.width || "500px";
cornerstone.displayImage(element, image); element.style.height = element.style.height || "500px";
element.style.position = element.style.position || "relative";
element.innerHTML = ""; // clear
cornerstoneTools.addToolForElement(element, PanTool); // Disable right-click context menu on the element
cornerstoneTools.addToolForElement(element, ZoomTool); element.addEventListener('contextmenu', function (e) {
cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool); e.preventDefault();
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);
}); });
element.addEventListener('dblclick', function (e) {
if (element.requestFullscreen) {
element.requestFullscreen();
} else if (element.webkitRequestFullscreen) {
element.webkitRequestFullscreen();
} else if (element.mozRequestFullScreen) {
element.mozRequestFullScreen();
} else if (element.msRequestFullscreen) {
element.msRequestFullscreen();
}
});
// create a unique rendering engine / viewport ids per element
const renderingEngineId = `rendingEngine`;
const viewportId = `1`;
try {
// dynamic import of cornerstone3D core + dicom loader
const coreModule = window.cornerstone3d
const dicomLoaderModule = window.cornerstoneDICOMImageLoader
const toolsModule = window.cornerstone3dTools;
await coreModule.init();
await dicomLoaderModule.init();
await toolsModule.init();
const { RenderingEngine, Enums } = coreModule;
const { chunkManager } = dicomLoaderModule; // may exist for some setups
// create engine and enable a Stack viewport
const renderingEngine = new RenderingEngine(renderingEngineId);
console.log(renderingEngine)
const viewportInput = {
viewportId,
type: Enums.ViewportType.STACK,
element,
//viewportType: Enums.ViewportType.STACK, // Stack viewport for slice stacks
};
renderingEngine.enableElement(viewportInput);
const viewport = renderingEngine.getViewport(viewportId);
// --- Register basic interaction tools (best-effort with @cornerstonejs/tools) ---
// dynamic import so bundlers/CDNs provide the package
// prefer module import, fall back to global if present
const { ToolGroupManager } = toolsModule;
toolsModule.addTool(toolsModule.PanTool)
toolsModule.addTool(toolsModule.WindowLevelTool)
//toolsModule.addTool(toolsModule.StackScrollTool)
toolsModule.addTool(toolsModule.ZoomTool)
const toolGroupId = `toolgroup-${viewportId}`;
console.log("ToolGroup id", toolGroupId);
console.log("ToolGroupManager", ToolGroupManager);
if (!ToolGroupManager.getToolGroup(toolGroupId)) {
ToolGroupManager.createToolGroup(toolGroupId);
}
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
toolGroup.addTool(toolsModule.PanTool.toolName);
toolGroup.addTool(toolsModule.WindowLevelTool.toolName);
//toolGroup.addTool(toolsModule.StackScrollTool.toolName);
toolGroup.addTool(toolsModule.ZoomTool.toolName);
// use actual tool names if available, else fall back to common strings
//const stackName = (StackScrollTool && (StackScrollTool.toolName || StackScrollTool.name)) || 'StackScroll';
//toolGroup.setToolEnabled(toolsModule.PanTool.toolName, { bindings: [{ mouseButton: 1 }] });
toolGroup.setToolActive(toolsModule.PanTool.toolName, { bindings: [{ mouseButton: 1 }] });
toolGroup.setToolActive(toolsModule.WindowLevelTool.toolName, { bindings: [{ mouseButton: 2 }] });
toolGroup.setToolActive(toolsModule.ZoomTool.toolName, { bindings: [{ mouseButton: 4 }] });
toolGroup.setToolActive(toolsModule.ZoomTool.toolName, { bindings: [{ mouseButton: 524288 }] });
toolGroup.addViewport(viewportId, renderingEngineId);
window.toolGroup = toolGroup; // expose for debugging
// Prepare imageIds: ensure remote URLs are usable by dicom-image-loader (wadouri:)
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;
return `wadouri:${url}`;
});
// If dicom-image-loader provides a metadata cache factory, pre-cache metadata
if (typeof dicomLoaderModule.createImageIdsAndCacheMetaData === "function") {
try {
// attempt to call helper to cache meta-data for the first image (best-effort)
await dicomLoaderModule.createImageIdsAndCacheMetaData({
imageIds,
});
} catch (e) {
// not fatal — continue
console.warn("createImageIdsAndCacheMetaData failed (continuing):", e);
}
}
await viewport.setStack(imageIds, 0);
viewport.render();
// store references for debugging/use elsewhere
element._cornerstone3d = {
renderingEngineId,
viewportId,
renderingEngine,
viewport,
imageIds,
};
console.log("setUpDicom3d complete", element._cornerstone3d);
return element._cornerstone3d;
} catch (err) {
console.error("setUpDicom3d failed:", err);
// per request: no fallback here — rethrow so caller can handle if desired
throw err;
}
} }
//function keyUpHandler(e) { // expose globally like existing code expects
// if (e.key == "Control") { window.setUpDicom = setUpDicom3d;
// dicomViewer.registerPrimaryDicomInterface(e);
// window.control_pressed = false;
// }
//}
//
//function keyDownHandler(e) {
// // 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 == false) {
// window.control_pressed = true;
// dicomViewer.registerAltDicomInterface(e);
// }
// }
//
// var charCode = typeof e.which == "number" ? e.which : e.keyCode;
// console.log(e, charCode);
//
// function numberKeyPressed(e, x) {
// if (e.altKey ? true : false) {
// dicomViewer.selectThumb(x);
// e.preventDefault();
// } else {}
// }
//
// switch (charCode) {
// case 13: // Return
// 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 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 create_popup_window(url, title) { function create_popup_window(url, title) {
@@ -681,4 +767,5 @@ function initializeClock(id, endtime) {
updateClock(); updateClock();
} }
window.initializeClock = initializeClock window.initializeClock = initializeClock
@@ -2,6 +2,7 @@
{% load static %} {% load static %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load help_tags %}
{% block title %} {% block title %}
Anatomy Anatomy
@@ -11,69 +12,69 @@
{% block js %} {% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>--> <!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
<script type="text/javascript"> <script type="text/javascript">
function showEditPopup(url) { function showEditPopup(url) {
var win = window.open(url, "Edit", var win = window.open(url, "Edit",
'height=500,width=800,resizable=yes,scrollbars=yes'); 'height=500,width=800,resizable=yes,scrollbars=yes');
return false; return false;
} }
function showAddPopup(triggeringLink) { function showAddPopup(triggeringLink) {
var name = triggeringLink.id.replace(/^add_/, ''); var name = triggeringLink.id.replace(/^add_/, '');
href = triggeringLink.href; href = triggeringLink.href;
var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
win.focus(); win.focus();
return false; return false;
} }
function closePopup(win, newID, newRepr, id) { function closePopup(win, newID, newRepr, id) {
console.log(id) console.log(id)
$(id).append('<option value=' + newID + ` title=${newRepr} >` + newRepr + '</option>').removeAttr("selected").val(newID) $(id).append('<option value=' + newID + ` title=${newRepr} >` + newRepr + '</option>').removeAttr("selected").val(newID)
win.close(); win.close();
} }
document.addEventListener('drop', function (e) { e.preventDefault(); }, false); document.addEventListener('drop', function (e) { e.preventDefault(); }, false);
$(document).ready(function () { $(document).ready(function () {
$("#add_examination").appendTo($("label[for='id_examination']")); $("#add_examination").appendTo($("label[for='id_examination']"));
$("#add_body_part").appendTo($("label[for='id_body_part']")); $("#add_body_part").appendTo($("label[for='id_body_part']"));
dropContainer = document.getElementById("drop-container"); dropContainer = document.getElementById("drop-container");
dropContainer.ondragover = function (evt) { dropContainer.ondragover = function (evt) {
evt.preventDefault(); evt.preventDefault();
evt.stopPropagation(); evt.stopPropagation();
}; };
dropContainer.ondragenter = function (evt) { dropContainer.ondragenter = function (evt) {
$(evt.target).addClass("drop-target-active") $(evt.target).addClass("drop-target-active")
evt.preventDefault(); evt.preventDefault();
evt.stopPropagation(); evt.stopPropagation();
}; };
dropContainer.ondragleave = function (evt) { dropContainer.ondragleave = function (evt) {
$(evt.target).removeClass("drop-target-active") $(evt.target).removeClass("drop-target-active")
evt.preventDefault(); evt.preventDefault();
evt.stopPropagation(); evt.stopPropagation();
}; };
dropContainer.ondrop = function (evt) { dropContainer.ondrop = function (evt) {
console.log("drop event, ", evt); console.log("drop event, ", evt);
$(evt.target).removeClass("drop-target-active"); $(evt.target).removeClass("drop-target-active");
// Get all input elements // Get all input elements
input = document.getElementById("id_image"); input = document.getElementById("id_image");
//fileInput = document.getElementById("id_images-0-image"); //fileInput = document.getElementById("id_images-0-image");
f = evt.dataTransfer.files[0] f = evt.dataTransfer.files[0]
let dT = new DataTransfer(); let dT = new DataTransfer();
dT.clearData(); dT.clearData();
dT.items.add(f); dT.items.add(f);
input.files = dT.files; input.files = dT.files;
ocr(input); ocr(input);
// Make sure we have enough input targets // Make sure we have enough input targets
//input_diff = (evt.dataTransfer.files.length - inputs.length) //input_diff = (evt.dataTransfer.files.length - inputs.length)
@@ -119,50 +120,50 @@
// dT.items.add(evt.dataTransfer.files[3]); // dT.items.add(evt.dataTransfer.files[3]);
// fileInput.files = dT.files; // fileInput.files = dT.files;
evt.preventDefault(); evt.preventDefault();
evt.stopPropagation(); evt.stopPropagation();
updateFileList(); updateFileList();
}; };
$('#add_more').click(() => { add_input_form() }); $('#add_more').click(() => { add_input_form() });
function updateFileList() { function updateFileList() {
$("#drop-filenames").empty() $("#drop-filenames").empty()
$("#id_image").each((n, el) => { $("#id_image").each((n, el) => {
console.log(el); console.log(el);
if (el.files.length > 0) { if (el.files.length > 0) {
extra_class = " image-ident-loading"; extra_class = " image-ident-loading";
if ($(el).hasClass("image-ident-warning")) { if ($(el).hasClass("image-ident-warning")) {
extra_class = " image-ident-warning" extra_class = " image-ident-warning"
} else if ($(el).hasClass("image-ident-ok")) { } else if ($(el).hasClass("image-ident-ok")) {
extra_class = " image-ident-ok" extra_class = " image-ident-ok"
}
$("#drop-filenames").append(
`<span>${n}: ${el.files[0].name}</span><img class='uploading${extra_class}' data-input-id='${el.id}' src=${URL.createObjectURL(el.files[0])}>`
)
} }
$("#drop-filenames").append( })
`<span>${n}: ${el.files[0].name}</span><img class='uploading${extra_class}' data-input-id='${el.id}' src=${URL.createObjectURL(el.files[0])}>`
)
}
})
} }
$("input[type=file]").on('change', function () { $("input[type=file]").on('change', function () {
$(this).removeClass("image-ident-warning"); $(this).removeClass("image-ident-warning");
$(this).removeClass("image-ident-ok"); $(this).removeClass("image-ident-ok");
updateFileList(); updateFileList();
console.log("input change1"); console.log("input change1");
console.log("input change", this); console.log("input change", this);
ocr(this); ocr(this);
}); });
//document.getElementById("id_question_type").selectedIndex = 1; //document.getElementById("id_question_type").selectedIndex = 1;
answer_1_status = document.getElementById("id_answers-0-status").selectedIndex; answer_1_status = document.getElementById("id_answers-0-status").selectedIndex;
if (answer_1_status == 0) { if (answer_1_status == 0) {
document.getElementById("id_answers-0-status").selectedIndex = 3; document.getElementById("id_answers-0-status").selectedIndex = 3;
} }
}); });
function ocr(input) { function ocr(input) {
// Tesseract.recognize(f, "eng",{ logger: m => console.log(m) } // Tesseract.recognize(f, "eng",{ logger: m => console.log(m) }
// ).then(({ data: { text } }) => { // ).then(({ data: { text } }) => {
// console.log(text); // console.log(text);
@@ -172,79 +173,78 @@
// } // }
// }) // })
const worker = Tesseract.createWorker({ const worker = Tesseract.createWorker({
workerPath: '{% static "worker.min.js" %}', workerPath: '{% static "worker.min.js" %}',
langPath: '{% static "" %}', langPath: '{% static "" %}',
//langPath: '{{ STATIC_URL }}', //langPath: '{{ STATIC_URL }}',
corePath: '{% static "tesseract-core.wasm.js" %}', corePath: '{% static "tesseract-core.wasm.js" %}',
}); });
const url = URL.createObjectURL(input.files[0]); const url = URL.createObjectURL(input.files[0]);
let img = new Image; let img = new Image;
img.src = url; img.src = url;
img.onload = function () { img.onload = function () {
width = img.width; width = img.width;
// const rectangle = { left: 0, top: 0, width: width, height: 10 } // const rectangle = { left: 0, top: 0, width: width, height: 10 }
(async () => { (async () => {
await worker.load(); await worker.load();
await worker.loadLanguage('eng'); await worker.loadLanguage('eng');
await worker.initialize('eng'); await worker.initialize('eng');
// const { data: { text } } = await worker.recognize(input.files[0], { rectangle }); // const { data: { text } } = await worker.recognize(input.files[0], { rectangle });
const { data: { text } } = await worker.recognize(input.files[0]); const { data: { text } } = await worker.recognize(input.files[0]);
//console.log(text); //console.log(text);
l = text.toLowerCase(); l = text.toLowerCase();
$(`img[data-input-id='${input.id}'`).removeClass("image-ident-loading"); $(`img[data-input-id='${input.id}'`).removeClass("image-ident-loading");
console.log(l); console.log(l);
if (l.includes("accesion") || l.includes("number") || l.search(/(ref|rk9|rh8|ra9|rbz)\d+/g) > -1) { if (l.includes("accesion") || l.includes("number") || l.search(/(ref|rk9|rh8|ra9|rbz)\d+/g) > -1) {
console.log("SHIT", input); console.log("SHIT", input);
$(input).addClass("image-ident-warning"); $(input).addClass("image-ident-warning");
$(`img[data-input-id='${input.id}'`).addClass("image-ident-warning"); $(`img[data-input-id='${input.id}'`).addClass("image-ident-warning");
} else { } else {
$(input).addClass("image-ident-ok"); $(input).addClass("image-ident-ok");
} }
await worker.terminate(); await worker.terminate();
})(); })();
}
} }
}
</script> </script>
{{ form.media }} {{ form.media }}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if object %} {% if object %}
{% include 'anatomy/question_link_header.html' %} {% include 'anatomy/question_link_header.html' %}
{% endif %} {% endif %}
<h2>{{object|yesno:"Update,Create"}} Question</h2>
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>This form can be used to create an Anatomy question.</p>
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p> <h2>{{object|yesno:"Update,Create"}} Question</h2>
{% help "Help" %}
<p>This form can be used to create an Anatomy question.</p>
<p>Correct and incorrect answers can be defined once the question has been created.</p> <p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p>
</details>
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %}
<a href="/anatomy/examination/create" id="add_examination" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
{{ form|crispy }} <p>Correct and incorrect answers can be defined once the question has been created.</p>
<div id="drop-container" class="drop-target">Drop image here {% endhelp %}
<div id="drop-filenames"></div> <form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
</div> {% csrf_token %}
<input type="submit" class="submit-button" value="Submit" name="submit"> <a href="/anatomy/examination/create" id="add_examination" class="add-popup"
</form> onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
{{ form|crispy }}
<div id="drop-container" class="drop-target">Drop image here
<div id="drop-filenames"></div>
</div>
<input type="submit" class="submit-button" value="Submit" name="submit">
</form>
{% endblock %} {% endblock %}
-2
View File
@@ -194,8 +194,6 @@ def test_exams(db, client):
exam.save() exam.save()
res = client.get(exam_metadata["url"]) res = client.get(exam_metadata["url"])
print("PRE ERRER", exam_metadata["url"])
print(res.content)
assert res.status_code == 404 assert res.status_code == 404
exam.active = True exam.active = True
-17
View File
@@ -646,22 +646,6 @@ class AnatomyQuestionUpdate(UpdateQuestionMixin):
context = self.get_context_data(form=form) context = self.get_context_data(form=form)
#formset = context["answer_formset"] #formset = context["answer_formset"]
return super().form_valid(form) return super().form_valid(form)
## logger.debug(formset.is_valid())
#if formset.is_valid():
# response = super().form_valid(form)
# formset.instance = self.object
# formset.save()
# # restore exam orders
# #for exam in self.object.exams.all():
# # if exam in exam_orders and self.object in exam_orders[exam]:
# # print(exam_orders[exam])
# # exam.exam_questions.set(exam_orders[exam])
# # exam.save()
# return response
#else:
# return super().form_invalid(form)
class QuestionClone(AnatomyQuestionCreateBase): class QuestionClone(AnatomyQuestionCreateBase):
@@ -797,7 +781,6 @@ def question_save_annotation(request, pk):
question = get_object_or_404(AnatomyQuestion, pk=pk) question = get_object_or_404(AnatomyQuestion, pk=pk)
question.image_annotations = request.POST.get("annotation") question.image_annotations = request.POST.get("annotation")
print(question.image_annotations)
question.save() question.save()
data = {"status": "success"} data = {"status": "success"}
+3 -1
View File
@@ -16,7 +16,8 @@ from .models import (
Presentation, Presentation,
UncategorisedDicom, UncategorisedDicom,
SeriesDetail, SeriesDetail,
Resource Resource,
CaseDisplaySet,
) )
from django.forms import ModelForm from django.forms import ModelForm
@@ -46,6 +47,7 @@ admin.site.register(CaseDetail)
admin.site.register(UncategorisedDicom) admin.site.register(UncategorisedDicom)
admin.site.register(SeriesDetail) admin.site.register(SeriesDetail)
admin.site.register(Resource) admin.site.register(Resource)
admin.site.register(CaseDisplaySet)
class DifferentialInline(admin.TabularInline): class DifferentialInline(admin.TabularInline):
+77 -91
View File
@@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404
from django.urls import reverse from django.urls import reverse
from ninja import ModelSchema, Router, Schema, Field from ninja import ModelSchema, Router, Schema, Field
from django.db import transaction from django.db import transaction, IntegrityError
# from .decorators import user_is_author_or_rapid_checker # from .decorators import user_is_author_or_rapid_checker
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
@@ -109,7 +109,6 @@ def clear_dicoms(request):
dicoms = UncategorisedDicom.objects.filter(user=request.user) dicoms = UncategorisedDicom.objects.filter(user=request.user)
dicoms.delete() dicoms.delete()
print(dicoms)
return True return True
@@ -129,15 +128,10 @@ def uncategorised_dicoms(request):
def import_dicoms_helper(request, case_id: int | None = None): def import_dicoms_helper(request, case_id: int | None = None):
# TODO: move this out of the api (would be better with HTMX)
# dicoms = UncategorisedDicom.objects.filter(user=request.user)
if "selection" in request.POST: if "selection" in request.POST:
dicoms = UncategorisedDicom.objects.filter( dicoms = UncategorisedDicom.objects.filter(
series_instance_uid__in=request.POST.getlist("selection") series_instance_uid__in=request.POST.getlist("selection")
) )
else: else:
dicoms = UncategorisedDicom.objects.filter(user=request.user) dicoms = UncategorisedDicom.objects.filter(user=request.user)
@@ -146,67 +140,66 @@ def import_dicoms_helper(request, case_id: int | None = None):
else: else:
order = None order = None
data = defaultdict(list) # Group dicoms by SeriesInstanceUID, but don't keep all tags in memory
for d in dicoms: series_map = {}
for d in dicoms.iterator(): # Use iterator() to avoid loading all at once
tags = d.basic_dicom_tags tags = d.basic_dicom_tags
series_uid = tags["SeriesInstanceUID"]
data[tags["SeriesInstanceUID"]].append((d, tags)) if series_uid not in series_map:
# Get or create Series and related objects as needed
try:
modality = Modality.objects.get(short_code=tags["Modality"])
except Modality.DoesNotExist:
modality = Modality.objects.create(short_code=tags["Modality"], modality=tags["Modality"])
series_list = [] series_tags = tags # Save the first tag for this series
for series_uid in data: try:
# Hmm probably not a good idea to blinding create a modality... with transaction.atomic():
try: series, created = Series.objects.get_or_create(
modality = Modality.objects.get(short_code=tags["Modality"]) series_instance_uid=series_uid,
except Modality.DoesNotExist: defaults={
modality = Modality.objects.create(short_code=tags["Modality"], modality=tags["Modality"]) "modality": modality,
"description": tags.get("SeriesDescription", ""),
}
)
if created and tags.get("StudyDescription"):
examination, created_exam = Examination.objects.get_or_create(
examination=tags["StudyDescription"]
)
if created_exam:
examination.modality = modality
examination.save()
series.examination = examination
series.save()
except IntegrityError:
series = Series.objects.get(series_instance_uid=series_uid)
# Add author and case if needed
series.author.add(request.user)
if case_id is not None:
case_object = get_object_or_404(Case, pk=case_id)
try:
series.case.add(case_object)
except IntegrityError:
pass
tags = data[series_uid][0][1] series_map[series_uid] = series
# Check if series with the id already exists (in which case we just add to htat)
if Series.objects.filter(
series_instance_uid=tags["SeriesInstanceUID"]
).exists():
series = Series.objects.get(series_instance_uid=tags["SeriesInstanceUID"])
else: else:
series = Series( series = series_map[series_uid]
modality=modality,
description=tags["SeriesDescription"],
series_instance_uid=tags["SeriesInstanceUID"],
)
if tags["StudyDescription"]: # Create and save SeriesImage immediately
examination, created = Examination.objects.get_or_create( series_image = SeriesImage(
examination=tags["StudyDescription"] image=d.image,
) series=series,
if created: is_dicom=True,
examination.modality = modality image_blake3_hash=d.image_blake3_hash,
examination.save() )
series.examination = examination series_image.save()
d.delete() # Remove UncategorisedDicom immediately
series.save()
# We might only want to add the author during creation....
series.author.add(request.user)
if case_id is not None:
case_object = get_object_or_404(Case, pk=case_id)
if not series.case.contains(case_object):
series.case.add(case_object)
for dicom, dicom_tags in data[series_uid]:
series_image = SeriesImage(
image=dicom.image,
series=series,
is_dicom=True,
image_blake3_hash=dicom.image_blake3_hash,
)
series_image.save()
# dicom.image.delete()
dicom.delete()
# Optionally, re-order series if needed
for series in series_map.values():
match order: match order:
case "order-series-instance-number": case "order-series-instance-number":
series.order_by_dicom("InstanceNumber") series.order_by_dicom("InstanceNumber")
@@ -214,10 +207,8 @@ def import_dicoms_helper(request, case_id: int | None = None):
series.order_by_dicom("SliceLocation") series.order_by_dicom("SliceLocation")
case _: case _:
pass pass
series_list.append((series, series.get_link()))
return series_list return [(series, series.get_link()) for series in series_map.values()]
@router.post( @router.post(
@@ -238,7 +229,6 @@ def import_dicoms_case(request, case_id: int):
@router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema]) @router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema])
def orphan_series(request): def orphan_series(request):
# print(request.user.series.filter(case=None))
return request.user.series.filter(case=None) return request.user.series.filter(case=None)
@@ -261,7 +251,6 @@ def series_remove_duplicate_images(request, series_id: int):
@router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth) @router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth)
def series_truncate(request, series_id: int, start: int, end: int): def series_truncate(request, series_id: int, start: int, end: int):
print(start, end)
series = get_object_or_404(Series, pk=series_id) series = get_object_or_404(Series, pk=series_id)
if not series.check_user_can_edit(request.user): if not series.check_user_can_edit(request.user):
@@ -272,7 +261,6 @@ def series_truncate(request, series_id: int, start: int, end: int):
if n >= start and n <= end: if n >= start and n <= end:
continue continue
else: else:
print(n, image)
image.removed = True image.removed = True
image.image = None image.image = None
image.save() image.save()
@@ -291,7 +279,6 @@ def get_cases_user(request):
@router.get("/check_image_hash/{hash}", auth=django_auth) @router.get("/check_image_hash/{hash}", auth=django_auth)
def check_image_hash(request, hash: str): def check_image_hash(request, hash: str):
try: try:
print(hash)
series_image = SeriesImage.objects.get(image_blake3_hash=hash) series_image = SeriesImage.objects.get(image_blake3_hash=hash)
data = { data = {
"status": "success", "status": "success",
@@ -370,39 +357,38 @@ def series_split_by_tag(request, series_id: int, dicom_tag: str):
image_map = defaultdict(list) image_map = defaultdict(list)
# Split teh associated images # Use iterator to avoid loading all images into memory
for image in series.images.all(): for image in series.images.iterator():
print(image)
ds = image.get_dicom_data() ds = image.get_dicom_data()
# print("2a", ds[dicom_tag])
if dicom_tag in ds: if dicom_tag in ds:
val = ds[dicom_tag].value val = ds[dicom_tag].value
if isinstance(val, pydicom.multival.MultiValue):
if type(val) == pydicom.multival.MultiValue: key = ",".join(map(str, val))
image_map[",".join((val))].append(image)
else: else:
image_map[val].append(image) key = str(val)
image_map[key].append(image)
else: else:
return [f"{dicom_tag} not found {image}"] return [f"{dicom_tag} not found {image}"]
case = list(series.case.all())
authors = list(series.author.all())
new_series = [] new_series = []
case = series.case.all()
authors = series.author.all()
for n, option in enumerate(image_map): # Use a transaction for all DB changes
if n == 0: with transaction.atomic():
series.images.set(image_map[option]) for n, (option, images) in enumerate(image_map.items()):
if n == 0:
else: # Update the original series
series.pk = None series.images.set(images)
series.save() new_series.append(series.pk)
series.case.set(case) else:
series.author.set(authors) # Clone the series
series.images.set(image_map[option]) series.pk = None
series.save()
new_series.append(series.pk) series.case.set(case)
series.author.set(authors)
series.images.set(images)
new_series.append(series.pk)
return new_series return new_series
+2
View File
@@ -80,6 +80,8 @@ def user_is_collection_author_or_atlas_editor(function):
def wrap(request, *args, **kwargs): def wrap(request, *args, **kwargs):
if "exam_id" in kwargs: if "exam_id" in kwargs:
atlas = CaseCollection.objects.get(pk=kwargs["exam_id"]) atlas = CaseCollection.objects.get(pk=kwargs["exam_id"])
elif "collection_id" in kwargs:
atlas = CaseCollection.objects.get(pk=kwargs["collection_id"])
else: else:
atlas = CaseCollection.objects.get(pk=kwargs["pk"]) atlas = CaseCollection.objects.get(pk=kwargs["pk"])
if ( if (
+376 -117
View File
@@ -12,7 +12,8 @@ from django.forms import (
CharField, CharField,
ValidationError, ValidationError,
modelformset_factory, modelformset_factory,
CheckboxSelectMultiple CheckboxSelectMultiple,
SplitDateTimeWidget,
) )
from django.forms import inlineformset_factory from django.forms import inlineformset_factory
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
@@ -32,6 +33,7 @@ from atlas.models import (
Resource, Resource,
SelfReview, SelfReview,
Series, Series,
SeriesDetail,
SeriesImage, SeriesImage,
SeriesFinding, SeriesFinding,
Condition, Condition,
@@ -39,11 +41,12 @@ from atlas.models import (
Subspecialty, Subspecialty,
UncategorisedDicom, UncategorisedDicom,
UserReportAnswer, UserReportAnswer,
CaseDisplaySet,
) )
from anatomy.models import Modality from anatomy.models import Modality
from generic.models import Examination#, Sign from generic.models import Examination # , Sign
# from generic.models import Examination, Site, Condition, Sign # from generic.models import Examination, Site, Condition, Sign
@@ -56,11 +59,22 @@ from tinymce.widgets import TinyMCE
from dal import autocomplete from dal import autocomplete
from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register from autocomplete import (
widgets as htmx_widgets,
Autocomplete,
AutocompleteWidget,
ModelAutocomplete,
register as autocomplete_register,
)
import logging import logging
from generic.forms import ExamAuthorFormMixin, ExamGroupsFormMixin from generic.forms import (
ExamAuthorFormMixin,
ExamGroupsFormMixin,
SplitDateTimeFieldDefaultTime,
SplitDateTimeFieldDefaultTimeEnd,
)
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
@@ -71,31 +85,30 @@ from typing import Any
from helpers.cimar import CimarAPI, NotFoundError from helpers.cimar import CimarAPI, NotFoundError
from rad.settings import CIMAR_USERNAME, CIMAR_PASSWORD from rad.settings import CIMAR_USERNAME, CIMAR_PASSWORD
class ClearableInput(TextInput): class ClearableInput(TextInput):
template_name = "crispy_forms/widgets/clearable_input_single_line.html" template_name = "crispy_forms/widgets/clearable_input_single_line.html"
class ClearableTextarea(Textarea): class ClearableTextarea(Textarea):
template_name = "crispy_forms/widgets/clearable_input.html" template_name = "crispy_forms/widgets/clearable_input.html"
class CaseSelect(Select): class CaseSelect(Select):
template_name = "atlas/case_select_widget.html" template_name = "atlas/case_select_widget.html"
def create_option(self, *args, **kwargs): def create_option(self, *args, **kwargs):
option = super().create_option(*args, **kwargs) option = super().create_option(*args, **kwargs)
print(option)
return option return option
def get_context(self, name: str, value, attrs) -> dict[str, Any]: def get_context(self, name: str, value, attrs) -> dict[str, Any]:
context = super().get_context(name, value, attrs) context = super().get_context(name, value, attrs)
return context return context
pass pass
class ConditionForm(ModelForm): class ConditionForm(ModelForm):
class Meta: class Meta:
model = Condition model = Condition
@@ -120,26 +133,130 @@ class CaseCollectionForm(ModelForm):
exclude = ["cases", "valid_cid_users", "valid_user_users", "author"] exclude = ["cases", "valid_cid_users", "valid_user_users", "author"]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.user = kwargs.pop( self.user = kwargs.pop("user")
"user"
) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
if kwargs.get("instance"): if kwargs.get("instance"):
# We get the 'initial' keyword argument or initialize it
# as a dict if it didn't exist.
initial = kwargs.setdefault("initial", {}) initial = kwargs.setdefault("initial", {})
# The widget for a ModelMultipleChoiceField expects
# a list of primary key for the selected data.
# initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()]
ModelForm.__init__(self, *args, **kwargs) ModelForm.__init__(self, *args, **kwargs)
super(CaseCollectionForm, self).__init__(*args, **kwargs) super(CaseCollectionForm, self).__init__(*args, **kwargs)
# Identify show_ fields
show_fields = [f for f in self.fields if f.startswith("show_")]
show_pre_fields = [f for f in show_fields if f.endswith("_pre")]
show_post_fields = [f for f in show_fields if f.endswith("_post")]
self.helper = FormHelper()
self.helper.form_id = "id-casecollection-form"
self.helper.form_class = "casecollection-form"
self.helper.form_method = "post"
self.helper.form_action = ""
self.helper.form_tag = True
# Group cid_user_groups and user_user_groups together if they exist in the form
user_fields = []
for f in ["cid_user_groups", "user_user_groups"]:
if f in self.fields:
user_fields.append(f)
# Explicitly define show fields for pre and post
show_pre_fields = [
f
for f in [
"show_title_pre",
"show_history_pre",
"show_description_pre",
"show_discussion_pre",
"show_report_pre",
]
if f in self.fields
]
show_post_fields = [
f
for f in [
"show_title_post",
"show_history_post",
"show_description_post",
"show_discussion_post",
"show_report_post",
]
if f in self.fields
]
self.helper.layout = Layout(
Fieldset(
"Collection Details",
"name",
Fieldset(
"Dates",
"restrict_to_dates",
Div(
Field(
"start_date",
wrapper_class="col-md-6",
css_class="form-group",
),
Field(
"end_date", wrapper_class="col-md-6", css_class="form-group"
),
css_class="form-row d-flex",
),
),
"active",
"publish_results",
"archive",
"open_access",
"candidates_only",
"authors_only",
"results_supervisor_visible",
"exam_open_access",
"markers",
"exam_mode",
"self_review",
"feedback_once_collection_complete",
"collection_type",
"viewer_mode",
),
Fieldset("Valid User Groups", *user_fields) if user_fields else None,
Div(
Div(
Fieldset("Show fields (pre-exam)", *show_pre_fields),
css_class="col-md-6",
style="float:left;",
),
Div(
Fieldset("Show fields (post-exam)", *show_post_fields),
css_class="col-md-6",
style="float:left;",
),
css_class="row",
style="display: flex; flex-wrap: wrap;",
),
Submit("submit", "Save Collection", css_class="btn btn-primary"),
)
self.fields["start_date"] = SplitDateTimeFieldDefaultTime(
widget=SplitDateTimeWidget(
date_attrs={"type": "date", "class": "datepicker"},
time_attrs={"type": "time", "class": "timepicker"},
date_format="%Y-%m-%d",
time_format="%H:%M",
),
required=False,
help_text="The date the exam is due to start (time is optional)",
)
self.fields["end_date"] = SplitDateTimeFieldDefaultTimeEnd(
widget=SplitDateTimeWidget(
date_attrs={"type": "date", "class": "datepicker"},
time_attrs={"type": "time", "class": "timepicker"},
date_format="%Y-%m-%d",
time_format="%H:%M",
),
required=False,
help_text="The date the exam is due to start (time is optional)",
)
def save(self, commit=True): def save(self, commit=True):
# Get the unsaved Case instance # Get the unsaved Case instance
instance = ModelForm.save(self, False) instance = ModelForm.save(self, False)
# Do we need to save all changes now? # Do we need to save all changes now?
# if commit: # if commit:
instance.save() instance.save()
@@ -175,7 +292,14 @@ class StructureForm(ModelForm):
class SeriesFindingForm(ModelForm): class SeriesFindingForm(ModelForm):
class Meta: class Meta:
model = SeriesFinding model = SeriesFinding
exclude = ["series", "annotation_json", "viewport_json", "current_image_id_index", "annotation_json_3d", "viewer_state_3d"] exclude = [
"series",
"annotation_json",
"viewport_json",
"current_image_id_index",
"annotation_json_3d",
"viewer_state_3d",
]
widgets = { widgets = {
"findings": autocomplete.ModelSelect2Multiple( "findings": autocomplete.ModelSelect2Multiple(
@@ -187,7 +311,6 @@ class SeriesFindingForm(ModelForm):
"conditions": autocomplete.ModelSelect2Multiple( "conditions": autocomplete.ModelSelect2Multiple(
url="atlas:condition-autocomplete" url="atlas:condition-autocomplete"
), ),
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -298,18 +421,40 @@ class SeriesForm(ModelForm):
"description": Textarea(attrs={"maxlength": 1000, "rows": 5, "cols": 80}), "description": Textarea(attrs={"maxlength": 1000, "rows": 5, "cols": 80}),
} }
# def save(self, commit=True):
# # Get the unsaved Case instance
# instance = ModelForm.save(self, False)
# # Prepare a 'save_m2m' method for the form,
# old_save_m2m = self.save_m2m
# def save_m2m():
# old_save_m2m()
# # instance.exams.clear()
# # for exam in self.cleaned_data["exams"]:
# # # We need the id here
# # instance.exams.add(exam.pk)
# self.save_m2m = save_m2m
# # Do we need to save all changes now?
# # if commit:
# instance.save()
# self.save_m2m()
# return instance
class CaseForm(ModelForm): class CaseForm(ModelForm):
class Media: class Media:
# Django also includes a few javascript files necessary # Django also includes a few javascript files necessary
# for the operation of this form element. You need to # for the operation of this form element. You need to
# include <script src="/admin/jsi18n"></script> # include <script src="/admin/jsi18n"></script>
# in the template. # in the template.
#history = forms.CharField( # history = forms.CharField(
# widget=ClearableTextarea(attrs={"maxlength": 1000, "rows": 5, "cols": 80}), # widget=ClearableTextarea(attrs={"maxlength": 1000, "rows": 5, "cols": 80}),
# required=False, # required=False,
#) # )
css = { css = {
"all": ["css/widgets.css"], "all": ["css/widgets.css"],
@@ -324,7 +469,7 @@ class CaseForm(ModelForm):
# This is populated if we create the case from an exam/collection # This is populated if we create the case from an exam/collection
self.collection = None self.collection = None
if kwargs["initial"] is not None and "exams" in kwargs["initial"]: if kwargs["initial"] is not None and "exams" in kwargs["initial"]:
collections = kwargs["initial"].pop("exams") collections = kwargs["initial"].pop("exams")
logging.debug(collections) logging.debug(collections)
@@ -338,20 +483,16 @@ class CaseForm(ModelForm):
# a list of primary key for the selected data. # a list of primary key for the selected data.
# initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()] # initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()]
ModelForm.__init__(self, *args, **kwargs) ModelForm.__init__(self, *args, **kwargs)
super(CaseForm, self).__init__(*args, **kwargs) super(CaseForm, self).__init__(*args, **kwargs)
# self.fields["series"].queryset = # self.fields["series"].queryset =
if self.user.groups.filter(name="atlas_editor").exists(): if self.user.groups.filter(name="atlas_editor").exists():
case_queryset = Case.objects.all().order_by("pk").prefetch_related( case_queryset = Case.objects.all().order_by("pk").prefetch_related()
)
else: else:
case_queryset = get_cases_available_to_user(self.user) case_queryset = get_cases_available_to_user(self.user)
self.fields["previous_case"].queryset=case_queryset self.fields["previous_case"].queryset = case_queryset
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_id = "id-case-form" self.helper.form_id = "id-case-form"
@@ -369,18 +510,16 @@ class CaseForm(ModelForm):
"history", "history",
"presentation", "presentation",
"discussion", "discussion",
"report",
"condition", "condition",
"pathological_process", "pathological_process",
"report",
"open_access", "open_access",
"previous_case", "previous_case",
"diagnostic_certainty", "diagnostic_certainty",
"cimar_uuid", "cimar_uuid",
), ),
) )
def get_queryset(self, request): def get_queryset(self, request):
return ( return (
super() super()
@@ -409,7 +548,7 @@ class CaseForm(ModelForm):
"open_access", "open_access",
"previous_case", "previous_case",
"diagnostic_certainty", "diagnostic_certainty",
"cimar_uuid" "cimar_uuid",
] ]
# fields = ['question', 'findings', 'subspecialty', 'references'] # fields = ['question', 'findings', 'subspecialty', 'references']
widgets = { widgets = {
@@ -433,7 +572,7 @@ class CaseForm(ModelForm):
"pathological_process": CheckboxSelectMultiple(), "pathological_process": CheckboxSelectMultiple(),
"previous_case": CaseSelect(), "previous_case": CaseSelect(),
} }
def clean_cimar_uuid(self): def clean_cimar_uuid(self):
cimar_uuid = self.cleaned_data["cimar_uuid"] cimar_uuid = self.cleaned_data["cimar_uuid"]
if cimar_uuid: if cimar_uuid:
@@ -450,7 +589,6 @@ class CaseForm(ModelForm):
# Get the unsaved Case instance # Get the unsaved Case instance
instance = ModelForm.save(self, False) instance = ModelForm.save(self, False)
# Prepare a 'save_m2m' method for the form, # Prepare a 'save_m2m' method for the form,
old_save_m2m = self.save_m2m old_save_m2m = self.save_m2m
@@ -477,8 +615,10 @@ class CaseForm(ModelForm):
logging.debug(f"{instance=}") logging.debug(f"{instance=}")
# Create through model # Create through model
#cd = CaseDetail(case=instance, collection=exam, sort_order=case_no) # cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
self.collection.cases.add(instance, through_defaults={"sort_order": case_no}) self.collection.cases.add(
instance, through_defaults={"sort_order": case_no}
)
return instance return instance
@@ -496,6 +636,7 @@ CaseDifferentialFormSet = inlineformset_factory(
}, },
) )
class ResourceForm(ModelForm): class ResourceForm(ModelForm):
class Meta: class Meta:
model = Resource model = Resource
@@ -508,7 +649,6 @@ class ResourceForm(ModelForm):
class CaseResourceForm(ModelForm): class CaseResourceForm(ModelForm):
def __init__(self, *args, user, **kwargs): def __init__(self, *args, user, **kwargs):
super(CaseResourceForm, self).__init__(*args, **kwargs) super(CaseResourceForm, self).__init__(*args, **kwargs)
@@ -526,23 +666,28 @@ class CaseResourceForm(ModelForm):
self.fields["pre_review"] = BooleanField(required=False) self.fields["pre_review"] = BooleanField(required=False)
class CaseSeriesForm(ModelForm): # class CaseSeriesForm(ModelForm):
#
def __init__(self, *args, user, **kwargs): # def __init__(self, *args, user, **kwargs):
super(CaseSeriesForm, self).__init__(*args, **kwargs) # super(CaseSeriesForm, self).__init__(*args, **kwargs)
#
if not user.groups.filter(name="atlas_editor").exists(): # if not user.groups.filter(name="atlas_editor").exists():
queryset = Series.objects.filter(author__id=user.id) # queryset = Series.objects.filter(author__id=user.id)
else: # else:
queryset = Series.objects.all() # queryset = Series.objects.all()
#
self.fields["series"] = ModelChoiceField( # # Optimize the queryset for the ModelChoiceField
required=False, # queryset = queryset.select_related(
queryset=queryset, # "modality"
# widget=Select(verbose_name="Series", is_stacked=False), # ).prefetch_related(
) # "examination", "plane", "contrast", "images"
# )
#
# self.fields["series"] = ModelChoiceField(
# required=False,
# queryset=queryset,
# # widget=Select(verbose_name="Series", is_stacked=False),
# )
class CaseCollectionCaseForm(ModelForm): class CaseCollectionCaseForm(ModelForm):
@@ -558,15 +703,15 @@ class CaseCollectionCaseForm(ModelForm):
) )
SeriesFormSet = inlineformset_factory( # SeriesFormSet = inlineformset_factory(
Case, # Case,
Series.case.through, # Series.case.through,
form=CaseSeriesForm, # form=CaseSeriesForm,
exclude=[], # exclude=[],
can_delete=True, # can_delete=True,
extra=0, # extra=0,
max_num=10, # max_num=10,
) # )
CaseCollectionCaseFormSet = inlineformset_factory( CaseCollectionCaseFormSet = inlineformset_factory(
CaseCollection, CaseCollection,
@@ -591,7 +736,13 @@ CaseResourceFormSet = inlineformset_factory(
SeriesImageFormSet = inlineformset_factory( SeriesImageFormSet = inlineformset_factory(
Series, Series,
SeriesImage, SeriesImage,
exclude=["dicom_tags_ohif", "replaced", "image_md5_hash", "image_blake3_hash", "is_dicom"], exclude=[
"dicom_tags_ohif",
"replaced",
"image_md5_hash",
"image_blake3_hash",
"is_dicom",
],
can_delete=True, can_delete=True,
extra=0, extra=0,
max_num=2000, max_num=2000,
@@ -600,19 +751,23 @@ SeriesImageFormSet = inlineformset_factory(
class JsonAnswerForm(Form): class JsonAnswerForm(Form):
json_answer = JSONSchemaField( json_answer = JSONSchemaField(
schema = {}, schema={},
#options = 'schema/options.json' # options = 'schema/options.json'
options = 'schema/options.json' options="schema/options.json",
) )
class Meta: class Meta:
fields = ["json_answer"] fields = ["json_answer"]
def __init__(self, *args, question_schema=None, **kwargs): def __init__(self, *args, question_schema=None, **kwargs):
super(JsonAnswerForm, self).__init__(*args, **kwargs) super(JsonAnswerForm, self).__init__(*args, **kwargs)
if question_schema is not None: if question_schema is not None:
self.fields["json_answer"] = JSONSchemaField(schema=question_schema, options='schema/options.json') self.fields["json_answer"] = JSONSchemaField(
schema=question_schema, options="schema/options.json"
)
self.fields["json_answer"].label = False self.fields["json_answer"].label = False
class BaseReportAnswerForm(ModelForm): class BaseReportAnswerForm(ModelForm):
class Meta: class Meta:
fields = ["answer"] fields = ["answer"]
@@ -623,22 +778,23 @@ class BaseReportAnswerForm(ModelForm):
# (False, 'No')]) # (False, 'No')])
# "findings": TinyMCE(attrs={"cols": 80, "rows": 20}), # "findings": TinyMCE(attrs={"cols": 80, "rows": 20}),
# "mark_scheme": TinyMCE(attrs={"cols": 80, "rows": 30}), # "mark_scheme": TinyMCE(attrs={"cols": 80, "rows": 30}),
"answer": Textarea(attrs={"cols": 100, "rows": 5, "placeholder":"Report text"}), "answer": Textarea(
attrs={"cols": 100, "rows": 5, "placeholder": "Report text"}
),
} }
# help_texts = { # help_texts = {
# "answer": "Write your answer in here." # "answer": "Write your answer in here."
# } # }
def __init__(self, *args, case_detail, **kwargs): def __init__(self, *args, case_detail, **kwargs):
super(BaseReportAnswerForm, self).__init__(*args, **kwargs) super(BaseReportAnswerForm, self).__init__(*args, **kwargs)
self.fields["answer"].required = False self.fields["answer"].required = False
class BaseQuestionAnswerForm(ModelForm): class BaseQuestionAnswerForm(ModelForm):
json_answer = JSONSchemaField( json_answer = JSONSchemaField(schema={}, options="schema/options.json")
schema = {},
options = 'schema/options.json'
)
class Meta: class Meta:
fields = ["json_answer"] fields = ["json_answer"]
labels = {"json_answer": ""} labels = {"json_answer": ""}
@@ -647,11 +803,13 @@ class BaseQuestionAnswerForm(ModelForm):
# "answer": "Write your answer in here." # "answer": "Write your answer in here."
# } # }
def __init__(self, *args, case_detail, **kwargs): def __init__(self, *args, case_detail, **kwargs):
super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs) super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs)
#self.fields["json_answer"].schema = case_detail.question_schema # self.fields["json_answer"].schema = case_detail.question_schema
if case_detail.question_schema is not None: if case_detail.question_schema is not None:
self.fields["json_answer"] = JSONSchemaField(schema=case_detail.question_schema, options="schema/options.json") self.fields["json_answer"] = JSONSchemaField(
schema=case_detail.question_schema, options="schema/options.json"
)
self.fields["json_answer"].label = "" self.fields["json_answer"].label = ""
@@ -659,18 +817,22 @@ class CidReportAnswerForm(BaseReportAnswerForm):
class Meta(BaseReportAnswerForm.Meta): class Meta(BaseReportAnswerForm.Meta):
model = CidReportAnswer model = CidReportAnswer
class UserReportAnswerForm(BaseReportAnswerForm): class UserReportAnswerForm(BaseReportAnswerForm):
class Meta(BaseReportAnswerForm.Meta): class Meta(BaseReportAnswerForm.Meta):
model = UserReportAnswer model = UserReportAnswer
class CidQuestionAnswerForm(BaseQuestionAnswerForm): class CidQuestionAnswerForm(BaseQuestionAnswerForm):
class Meta(BaseQuestionAnswerForm.Meta): class Meta(BaseQuestionAnswerForm.Meta):
model = CidReportAnswer model = CidReportAnswer
class UserQuestionAnswerForm(BaseQuestionAnswerForm): class UserQuestionAnswerForm(BaseQuestionAnswerForm):
class Meta(BaseQuestionAnswerForm.Meta): class Meta(BaseQuestionAnswerForm.Meta):
model = UserReportAnswer model = UserReportAnswer
class CidReportAnswerMarkForm(ModelForm): class CidReportAnswerMarkForm(ModelForm):
class Meta: class Meta:
model = CidReportAnswer model = CidReportAnswer
@@ -690,17 +852,16 @@ class CidReportAnswerMarkForm(ModelForm):
self.fields["score"].required = False self.fields["score"].required = False
#class AddCollectionToCaseForm(Form): # class AddCollectionToCaseForm(Form):
# #
# def __init__(self, *args, user, **kwargs): # def __init__(self, *args, user, **kwargs):
# super(AddCollectionToCaseForm, self).__init__(*args, **kwargs) # super(AddCollectionToCaseForm, self).__init__(*args, **kwargs)
# print("INIT", args, kwargs)
# #
# if not user.groups.filter(name="atlas_editor").exists(): # if not user.groups.filter(name="atlas_editor").exists():
# queryset = CaseCollection.objects.filter(author__id=user.id) # queryset = CaseCollection.objects.filter(author__id=user.id)
# else: # else:
# queryset = CaseCollection.objects.all() # queryset = CaseCollection.objects.all()
# #
# self.valid_collections = queryset # self.valid_collections = queryset
# self.case = get_object_or_404(Case, pk=pk) # self.case = get_object_or_404(Case, pk=pk)
# #
@@ -732,17 +893,16 @@ class CidReportAnswerMarkForm(ModelForm):
# #
# return collection_set # return collection_set
class AddCollectionToCaseForm(Form):
class AddCollectionToCaseForm(Form):
def __init__(self, *args, user, **kwargs): def __init__(self, *args, user, **kwargs):
super(AddCollectionToCaseForm, self).__init__(*args, **kwargs) super(AddCollectionToCaseForm, self).__init__(*args, **kwargs)
print("INIT", args, kwargs)
if not user.groups.filter(name="atlas_editor").exists(): if not user.groups.filter(name="atlas_editor").exists():
queryset = CaseCollection.objects.filter(author__id=user.id) queryset = CaseCollection.objects.filter(author__id=user.id)
else: else:
queryset = CaseCollection.objects.all() queryset = CaseCollection.objects.all()
self.valid_collections = queryset self.valid_collections = queryset
self.fields["casecollection"] = ModelMultipleChoiceField( self.fields["casecollection"] = ModelMultipleChoiceField(
@@ -752,21 +912,16 @@ class AddCollectionToCaseForm(Form):
) )
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
print("SAVE", args, kwargs)
instance = super(AddCollectionToCaseForm, self).save(*args, **kwargs) instance = super(AddCollectionToCaseForm, self).save(*args, **kwargs)
# Here we save the modified project selection back into the database # Here we save the modified project selection back into the database
instance.casecollection_set.set(self.cleaned_data['casecollection']) instance.casecollection_set.set(self.cleaned_data["casecollection"])
print(f"{instance=}")
return instance return instance
def clean_casecollection(self): def clean_casecollection(self):
collection_set = self.cleaned_data["casecollection"] collection_set = self.cleaned_data["casecollection"]
print(f"{collection_set=}")
for col in collection_set: for col in collection_set:
if col not in self.valid_collections: if col not in self.valid_collections:
raise ValidationError("Invalid collection") raise ValidationError("Invalid collection")
@@ -775,7 +930,6 @@ class AddCollectionToCaseForm(Form):
class SelfReviewForm(ModelForm): class SelfReviewForm(ModelForm):
class Meta: class Meta:
model = SelfReview model = SelfReview
fields = ["user_exam", "case", "comments", "findings", "interpretation"] fields = ["user_exam", "case", "comments", "findings", "interpretation"]
@@ -790,16 +944,16 @@ class SelfReviewForm(ModelForm):
ModelForm.__init__(self, *args, **kwargs) ModelForm.__init__(self, *args, **kwargs)
super(SelfReviewForm, self).__init__(*args, **kwargs) super(SelfReviewForm, self).__init__(*args, **kwargs)
class UncategorisedDicomForm(ModelForm): class UncategorisedDicomForm(ModelForm):
class Meta: class Meta:
model = UncategorisedDicom model = UncategorisedDicom
fields = ["image"]#, "user"] fields = ["image"] # , "user"]
#UncategorisedDicomFormset = formset_factory(UncategorisedDicomForm) # UncategorisedDicomFormset = formset_factory(UncategorisedDicomForm)
#class ConditionAutocompleteFormModel(forms.ModelForm): # class ConditionAutocompleteFormModel(forms.ModelForm):
# class Meta: # class Meta:
# model = Condition # model = Condition
# fields = ["name"] # fields = ["name"]
@@ -810,49 +964,56 @@ class UncategorisedDicomForm(ModelForm):
# ) # )
# } # }
class ConditionAutocomplete(ModelAutocomplete): class ConditionAutocomplete(ModelAutocomplete):
model = Condition model = Condition
search_attrs = ["name"] search_attrs = ["name"]
class ConditionAutocompleteForm(Form):
condition = ModelChoiceField(queryset=Condition.objects.all(), widget=AutocompleteWidget(
ac_class=ConditionAutocomplete#, options=dict(model=Condition)
)) class ConditionAutocompleteForm(Form):
condition = ModelChoiceField(
queryset=Condition.objects.all(),
widget=AutocompleteWidget(
ac_class=ConditionAutocomplete # , options=dict(model=Condition)
),
)
autocomplete_register(ConditionAutocomplete) autocomplete_register(ConditionAutocomplete)
class CaseCollectionAuthorForm(ExamAuthorFormMixin): class CaseCollectionAuthorForm(ExamAuthorFormMixin):
class Meta(ExamAuthorFormMixin.Meta): class Meta(ExamAuthorFormMixin.Meta):
model = CaseCollection model = CaseCollection
class CaseAuthorForm(ExamAuthorFormMixin): class CaseAuthorForm(ExamAuthorFormMixin):
class Meta(ExamAuthorFormMixin.Meta): class Meta(ExamAuthorFormMixin.Meta):
model = Case model = Case
class SeriesAuthorForm(ExamAuthorFormMixin): class SeriesAuthorForm(ExamAuthorFormMixin):
class Meta(ExamAuthorFormMixin.Meta): class Meta(ExamAuthorFormMixin.Meta):
model = Series model = Series
class ExamGroupsForm(ExamGroupsFormMixin): class ExamGroupsForm(ExamGroupsFormMixin):
class Meta(ExamGroupsFormMixin.Meta): class Meta(ExamGroupsFormMixin.Meta):
model = CaseCollection model = CaseCollection
class AnswerJSONForm(Form):
json = JSONSchemaField( class AnswerJSONForm(Form):
schema = 'schema/schema.json', json = JSONSchemaField(schema="schema/schema.json", options="schema/options.json")
options = 'schema/options.json'
)
class SvelteJSONEditorWidgetOverride(SvelteJSONEditorWidget): class SvelteJSONEditorWidgetOverride(SvelteJSONEditorWidget):
template_name = "atlas/svelte_jsoneditor_widget_override.html" template_name = "atlas/svelte_jsoneditor_widget_override.html"
class CaseDetailForm(ModelForm):
class CaseDetailForm(ModelForm):
class Meta: class Meta:
model = CaseDetail model = CaseDetail
fields = ["question_schema", "question_answers"]#, "user"] fields = ["question_schema", "question_answers"] # , "user"]
widgets = { widgets = {
"question_schema": SvelteJSONEditorWidgetOverride(), "question_schema": SvelteJSONEditorWidgetOverride(),
@@ -869,20 +1030,19 @@ class QuestionSchemaForm(ModelForm):
"schema": SvelteJSONEditorWidgetOverride(), "schema": SvelteJSONEditorWidgetOverride(),
} }
#class ModelPriorCaseForm(ModelForm):
# class ModelPriorCaseForm(ModelForm):
# class Meta: # class Meta:
# model = CasePrior # model = CasePrior
# fields = ["case_detail", "prior_case", "relation_text", "prior_visibility"] # fields = ["case_detail", "prior_case", "relation_text", "prior_visibility"]
class PriorCaseForm(Form):
class PriorCaseForm(Form):
relation = CharField(max_length=255, required=True) relation = CharField(max_length=255, required=True)
prior_visibility = CharField(max_length=2, required=True) prior_visibility = CharField(max_length=2, required=True)
# def __init__(self, *args, **kwargs):
#def __init__(self, *args, **kwargs):
# self.case_detail = kwargs.pop( # self.case_detail = kwargs.pop(
# "case_detail" # "case_detail"
# ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole # ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
@@ -897,4 +1057,103 @@ class PriorCaseForm(Form):
# self.fields["case"] = ChoiceField( # self.fields["case"] = ChoiceField(
# required=False, # required=False,
# # widget=Select(verbose_name="Series", is_stacked=False), # # widget=Select(verbose_name="Series", is_stacked=False),
# ) # )
class CaseSeriesForm(forms.ModelForm):
def __init__(self, *args, queryset, user=None, is_atlas_editor=False, **kwargs):
super().__init__(*args, **kwargs)
self.fields["series"].queryset = queryset
class Meta:
model = SeriesDetail # through model for Case.series
fields = ["series", "sort_order", "feedback"]
class CaseUpdateSeriesForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_id = "id-case-form"
self.helper.form_class = "case-form"
self.helper.form_method = "post"
self.helper.form_action = "submit"
self.helper.form_tag = False
self.helper.layout = Layout()
class Meta:
model = Case
fields = [] # No fields, just for compatibility with UpdateView
class BaseSeriesFormSet(BaseInlineFormSet):
def __init__(self, *args, queryset=None, **kwargs):
self.series_queryset = queryset
super().__init__(*args, **kwargs)
def get_form_kwargs(self, index):
kwargs = super().get_form_kwargs(index)
kwargs["queryset"] = self.series_queryset
return kwargs
SeriesFormSet = inlineformset_factory(
Case,
SeriesDetail,
form=CaseSeriesForm,
formset=BaseSeriesFormSet,
extra=1,
can_delete=True,
)
class CaseDisplaySetForm(forms.ModelForm):
class Meta:
model = CaseDisplaySet
fields = [
"name",
"description",
"findings",
"structures",
"conditions",
]
widgets = {
"name": forms.TextInput(
attrs={"class": "form-control", "placeholder": "Display Set Name"}
),
"description": forms.Textarea(
attrs={"class": "form-control", "rows": 3, "placeholder": "Description"}
),
"findings": autocomplete.ModelSelect2Multiple(
url="atlas:finding-autocomplete"
),
"structures": autocomplete.ModelSelect2Multiple(
url="atlas:structure-autocomplete"
),
"conditions": autocomplete.ModelSelect2Multiple(
url="atlas:condition-autocomplete"
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Optionally, you can filter queryset for findings/structures/conditions here if needed
class CaseCollectionUpdateCaseForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
self.collection = kwargs.pop("collection", None)
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_id = "id-case-form"
self.helper.form_class = "case-form"
self.helper.form_method = "post"
self.helper.form_action = "submit"
self.helper.form_tag = False
self.helper.layout = Layout()
class Meta:
model = CaseCollection
fields = [] # No fields, just for compatibility with UpdateView
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-06-16 12:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0070_seriesfinding_annotation_json_3d_and_more'),
]
operations = [
migrations.AddField(
model_name='casedetail',
name='default_viewerstate',
field=models.JSONField(blank=True, help_text='Default viewer state for the case', null=True),
),
]
+27
View File
@@ -0,0 +1,27 @@
# Generated by Django 5.1.4 on 2025-06-30 12:03
import django.db.models.deletion
import generic.mixins
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0071_casedetail_default_viewerstate'),
]
operations = [
migrations.CreateModel(
name='CaseDisplaySet',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Name of the display set', max_length=255)),
('description', models.TextField(blank=True, help_text='Description of the display set')),
('viewerstate', models.JSONField(blank=True, help_text='Viewer state for the display set', null=True)),
('annotations', models.JSONField(blank=True, help_text='Annotations for the display set', null=True)),
('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='atlas.case')),
],
bases=(models.Model, generic.mixins.AuthorMixin),
),
]
@@ -0,0 +1,34 @@
# Generated by Django 5.1.4 on 2025-07-14 09:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0072_casedisplayset'),
]
operations = [
migrations.AddField(
model_name='casedisplayset',
name='conditions',
field=models.ManyToManyField(blank=True, to='atlas.condition'),
),
migrations.AddField(
model_name='casedisplayset',
name='findings',
field=models.ManyToManyField(blank=True, to='atlas.finding'),
),
migrations.AddField(
model_name='casedisplayset',
name='structures',
field=models.ManyToManyField(blank=True, to='atlas.structure'),
),
migrations.AlterField(
model_name='casedisplayset',
name='case',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='display_sets', to='atlas.case'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-09-02 20:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0073_casedisplayset_conditions_casedisplayset_findings_and_more'),
]
operations = [
migrations.AlterField(
model_name='seriesimage',
name='image_md5_hash',
field=models.CharField(blank=True, max_length=64, null=True),
),
]
+88 -14
View File
@@ -396,6 +396,22 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
cimar_uuid = models.CharField(max_length=255, null=True, blank=True) cimar_uuid = models.CharField(max_length=255, null=True, blank=True)
def get_ordered_series_details(self):
# If the series are already prefetched, use them directly
if hasattr(self, '_prefetched_objects_cache') and 'series' in self._prefetched_objects_cache:
# Get the through model and sort by sort_order
through_model = self.series.through
# Build a mapping from series pk to through instance
through_objs = list(through_model.objects.filter(case=self).select_related('series'))
through_objs.sort(key=lambda x: x.sort_order)
return through_objs
# Otherwise, query as before
return self.series.through.objects.filter(case=self).select_related('series').order_by('sort_order')
def get_ordered_series(self):
"""Returns the series in the case in order of the SeriesDetail sort_order"""
return [sd.series for sd in self.get_ordered_series_details()]
def get_app_name(self): def get_app_name(self):
return "atlas" return "atlas"
@@ -533,7 +549,15 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
return size return size
def check_user_can_edit(self, user): def check_user_can_edit(self, user) -> bool:
"""Check if the user can edit the case.
Args:
user (User): The user to check.
Returns:
bool: True if the user can edit the case, False otherwise.
"""
if user.is_superuser: if user.is_superuser:
return True return True
@@ -541,10 +565,21 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
return True return True
return False return False
def get_series_images_nested(self, as_json=True):
"""
Returns a list of lists, where each inner list contains the image URLs
for a single series in this case, in the order of the series.
"""
images = [
[image.image.url for image in series.images.all()]
for series in self.series.all()
]
if as_json:
return json.dumps(images)
else:
return images
def extract_image_dicom_json_from_ds(ds, url, image_index): def extract_image_dicom_json_from_ds(ds, url, image_index):
@@ -1091,6 +1126,46 @@ class SeriesDetail(models.Model):
class Meta: class Meta:
ordering = ("sort_order",) ordering = ("sort_order",)
class CaseDisplaySet(models.Model, AuthorMixin):
"""
This is analogous to a SeriesFinding but for a Case (it has
access to all the series stacks).
"""
case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="display_sets")
name = models.CharField(
max_length=255,
help_text="Name of the display set",
)
description = models.TextField(
blank=True,
help_text="Description of the display set",
)
viewerstate = models.JSONField(
null=True,
blank=True,
help_text="Viewer state for the display set",
)
annotations = models.JSONField(
null=True,
blank=True,
help_text="Annotations for the display set",
)
findings = models.ManyToManyField(Finding, blank=True)
structures = models.ManyToManyField(Structure, blank=True)
conditions = models.ManyToManyField(Condition, blank=True)
def viewerstate_string(self):
return json.dumps(self.viewerstate) if self.viewerstate else "{}"
def annotations_string(self):
return json.dumps(self.annotations) if self.annotations else "{}"
class CaseDetail(models.Model): class CaseDetail(models.Model):
"""A through table that stores the relationship between a case and a collection. """A through table that stores the relationship between a case and a collection.
@@ -1107,6 +1182,12 @@ class CaseDetail(models.Model):
# TODO add feedback for questions # TODO add feedback for questions
#question_feedback = models.JSONField(null=True, blank=True) #question_feedback = models.JSONField(null=True, blank=True)
default_viewerstate = models.JSONField(
null=True,
blank=True,
help_text="Default viewer state for the case"
)
sort_order = models.IntegerField(default=1000) sort_order = models.IntegerField(default=1000)
class Meta: class Meta:
@@ -1129,6 +1210,9 @@ class CaseDetail(models.Model):
except UserReportAnswer.DoesNotExist: except UserReportAnswer.DoesNotExist:
return None return None
def default_viewerstate_string(self):
return json.dumps(self.default_viewerstate) if self.default_viewerstate else "{}"
class CasePrior(models.Model): class CasePrior(models.Model):
case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE) case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case") prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case")
@@ -1195,10 +1279,7 @@ class BaseReportAnswer(models.Model):
if self.question.question_schema is None or not "properties" in self.question.question_schema: if self.question.question_schema is None or not "properties" in self.question.question_schema:
return [] return []
answers = [] answers = []
print("1", self.json_answer)
print("2", self.question.question_answers)
for name, value in self.question.question_schema["properties"].items(): for name, value in self.question.question_schema["properties"].items():
print(name, value)
try: try:
user_answer = self.json_answer[name] user_answer = self.json_answer[name]
@@ -1209,11 +1290,8 @@ class BaseReportAnswer(models.Model):
except KeyError: # If the schema has changed? except KeyError: # If the schema has changed?
correct_answer = "" correct_answer = ""
print(correct_answer)
match value: match value:
case {"type": "string", "enum": _}: case {"type": "string", "enum": _}:
print("YES", value)
automark = True automark = True
answer_is_correct = user_answer == correct_answer answer_is_correct = user_answer == correct_answer
case {"type": "string"}: case {"type": "string"}:
@@ -1346,8 +1424,6 @@ class UncategorisedDicom(models.Model):
# duplicate = obj # duplicate = obj
return obj return obj
print(image_hash)
if obj := SeriesImage.objects.filter(image_blake3_hash=image_hash).first(): if obj := SeriesImage.objects.filter(image_blake3_hash=image_hash).first():
duplicate = obj duplicate = obj
@@ -1389,14 +1465,12 @@ class UncategorisedDicom(models.Model):
ds = pydicom.dcmread(self.image) ds = pydicom.dcmread(self.image)
return ds return ds
except pydicom.errors.InvalidDicomError: except pydicom.errors.InvalidDicomError:
print("Error getting dicom dataset")
print(self.image)
return {"SeriesInstanceUID": "1234"} return {"SeriesInstanceUID": "1234"}
def get_basic_dicom_tags(self, dataset=None): def get_basic_dicom_tags(self, dataset=None):
try: try:
if dataset is None: if dataset is None:
dataset = pydicom.read_file(self.image) dataset = pydicom.dcmread(self.image)
to_include = ( to_include = (
"StudyDescription", "StudyDescription",
"Modality", "Modality",
+28 -6
View File
@@ -133,7 +133,14 @@ class CaseTable(tables.Table):
return "No associated cases" return "No associated cases"
def render_collections(self, record): def render_collections(self, record):
collections = record.casecollection_set.all() collections = [
collection
for collection in record.casecollection_set.all()
if (
hasattr(self, 'request')
and self.request.user in collection.author.all()
) or collection.open_access
]
if collections: if collections:
links = [ links = [
format_html( format_html(
@@ -183,6 +190,8 @@ class CaseTable(tables.Table):
) )
sequence = ("view", ) sequence = ("view", )
order_by = "-created_date" order_by = "-created_date"
attrs = {"class": "table row-selector"}
class PopupLinkColumn(tables.Column): class PopupLinkColumn(tables.Column):
@@ -196,11 +205,6 @@ class PopupLinkColumn(tables.Column):
return obj.get_thumbnail()[0] return obj.get_thumbnail()[0]
def create_link(test):
print(test)
return "Hello"
class SeriesTable(tables.Table): class SeriesTable(tables.Table):
edit = tables.LinkColumn( edit = tables.LinkColumn(
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False "atlas:series_update", text="Edit", args=[A("pk")], orderable=False
@@ -236,6 +240,8 @@ class SeriesTable(tables.Table):
) )
sequence = ("view", "popup", "images", "case") sequence = ("view", "popup", "images", "case")
order_by = "-created_date" order_by = "-created_date"
attrs = {"class": "table row-selector"}
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
super().__init__( super().__init__(
@@ -276,6 +282,8 @@ class ConditionTable(tables.Table):
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum") fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
sequence = ("name",) sequence = ("name",)
attrs = {"class": "table row-selector"}
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
super().__init__( super().__init__(
@@ -312,6 +320,8 @@ class FindingTable(tables.Table):
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = ("name", "primary") fields = ("name", "primary")
sequence = ("name",) sequence = ("name",)
attrs = {"class": "table row-selector"}
def render_synonym(self, value, record): def render_synonym(self, value, record):
return format_html(record.get_synonym_link()) return format_html(record.get_synonym_link())
@@ -339,6 +349,8 @@ class StructureTable(tables.Table):
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = ("name", "primary") fields = ("name", "primary")
sequence = ("name",) sequence = ("name",)
attrs = {"class": "table row-selector"}
def render_synonym(self, value, record): def render_synonym(self, value, record):
return format_html(record.get_synonym_link()) return format_html(record.get_synonym_link())
@@ -365,6 +377,8 @@ class PresentationTable(tables.Table):
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = ("name",) fields = ("name",)
sequence = ("name",) sequence = ("name",)
attrs = {"class": "table row-selector"}
class PathologicalProcessTable(tables.Table): class PathologicalProcessTable(tables.Table):
@@ -386,6 +400,8 @@ class PathologicalProcessTable(tables.Table):
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = ("name",) fields = ("name",)
sequence = ("name",) sequence = ("name",)
attrs = {"class": "table row-selector"}
class SubspecialtyTable(tables.Table): class SubspecialtyTable(tables.Table):
@@ -407,6 +423,8 @@ class SubspecialtyTable(tables.Table):
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = () fields = ()
sequence = ("name",) sequence = ("name",)
attrs = {"class": "table row-selector"}
def render_synonym(self, value, record): def render_synonym(self, value, record):
return format_html(record.get_synonym_link()) return format_html(record.get_synonym_link())
@@ -441,6 +459,8 @@ class CaseCollectionTable(tables.Table):
"author", "author",
) )
sequence = ("view", )#, "series") sequence = ("view", )#, "series")
attrs = {"class": "table row-selector"}
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
super().__init__( super().__init__(
@@ -468,6 +488,8 @@ class QuestionSchemaTable(tables.Table):
model = QuestionSchema model = QuestionSchema
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = ("name", "description", "schema") fields = ("name", "description", "schema")
attrs = {"class": "table row-selector"}
def render_schema(self, value, record): def render_schema(self, value, record):
return format_html("<details><summary>Schema</summary>{}</details>", value) return format_html("<details><summary>Schema</summary>{}</details>", value)
@@ -1,4 +1,5 @@
<span id="add-case-form"> <span id="add-case-form">
This form allows you to add an existing case to the collection. You may find it easier to do this from the individual case pages or the <a href="{% url 'atlas:case_view' %}">case overview view</a> which allows you to add multiple cases at once.
<form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML"> <form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML">
{% csrf_token %} {% csrf_token %}
<label for="case">Select Case to add:</label> <label for="case">Select Case to add:</label>
+297 -31
View File
@@ -1,7 +1,7 @@
{% load partials %} {% load partials %}
{% partialdef case-series %} {% partialdef case-series %}
{% for series in case.series.all %} {% for series in case.get_ordered_series %}
<span class="series-block" id="series-block-{{ series.pk }}"> <span class="series-block" id="series-block-{{ series.pk }}">
<span> <span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br> <span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
@@ -9,7 +9,13 @@
{{series.get_block}} {{series.get_block}}
</a> </a>
<br> <br>
<input type="checkbox" name="series-ids" value="{{series.pk}}"> <button type="button"
class="btn btn-outline-primary btn-sm select-series-btn"
style="display: none;"
data-series-id="{{series.pk}}">
Select
</button>
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
<span class="series-block-popup-link"> <span class="series-block-popup-link">
<a href="#" <a href="#"
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a> onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
@@ -113,6 +119,16 @@
ID: {{ case.id }} ID: {{ case.id }}
</div> </div>
<details id="dicom-viewer-details">
<summary>Viewer</summary>
<div id="main_viewer" class="dicom-viewer-root"
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
data-auto-cache-stack="false"
data-images='{{case.get_series_images_nested}}'
></div>
</details>
<p class="pre-whitespace"><b>Description:</b> {{ case.description }}</p> <p class="pre-whitespace"><b>Description:</b> {{ case.description }}</p>
<p> <p>
@@ -144,22 +160,30 @@
{% partial case-series %} {% partial case-series %}
</form> </form>
<span> <span>
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Add new series</a><br /> <button type="button"
<a href="{% url 'atlas:user_uploads_case' case_id=case.pk %}">Import new uploads</a><br /> class="btn btn-info"
onclick="window.location.href='{% url 'atlas:user_uploads_case' case_id=case.pk %}'">
Import new uploads
</button><br />
<details class="series-actions" id="series-actions"> <details class="series-actions" id="series-actions">
<summary>+</summary> <summary>
<button type="button" class="btn btn-primary" title="Click to manage series" onclick="this.closest('details').open = !this.closest('details').open;">
Manage Series
</button>
</summary>
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Create and add new series</a><br />
<button hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}" <button hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
title="order dicom by slice location" title="order dicom by slice location"
hx-target="this" hx-target="#series-action-results"
hx-swap="outerHTML" hx-swap="innerHTML"
hx-confirm="This will reorder all case series based upon slice location" hx-confirm="This will reorder all case series based upon slice location"
> >
Order all series dicoms by slice location Order dicoms by slice location
</button> </button>
<button hx-post="{% url 'atlas:combine_series' %}" <button hx-post="{% url 'atlas:combine_series' %}"
title="merge series" title="merge series"
hx-include="[name='series-ids']" hx-include="[name='series-ids']"
hx-target="#series-merge-results" hx-target="#series-action-results"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-confirm="This will merge all selected series into one" hx-confirm="This will merge all selected series into one"
> >
@@ -168,14 +192,33 @@
<button hx-post="{% url 'atlas:use_dates_as_descriptions' case.pk %}" <button hx-post="{% url 'atlas:use_dates_as_descriptions' case.pk %}"
title="merge series" title="merge series"
hx-include="[name='series-ids']" hx-include="[name='series-ids']"
hx-target="#series-merge-results" hx-target="#series-action-results"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-confirm="This will use the dicom date as the series description" hx-confirm="This will use the dicom date as the series description"
> >
Use dates as description Use dates as description
</button> </button>
<button hx-post="{% url 'atlas:remove_selected_series_from_case' case.pk %}"
title="remove selected series from this case"
hx-include="[name='series-ids']:checked"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to remove the selected series from this case? They will not be deleted from the database."
class="btn btn-warning mt-2"
type="button"
>
Remove selected series from case
</button>
<button type="button"
class="btn btn-secondary mt-2"
data-bs-toggle="modal"
data-bs-target="#reorderSeriesModal">
Reorder series
</button>
<br/> <br/>
<span id="series-merge-results"></span> <div class="alert alert-info mt-2" id="series-action-alert" style="display:none;">
<span id="series-action-results" ></span>
</div>
</details> </details>
</span> </span>
@@ -203,36 +246,35 @@
</details> </details>
{% endif %} {% endif %}
{% if case.series.all %} <!-- Series Findings -->
{% for series in case.series.all %} <h5>Series Findings</h5>
{% if case.ordered_series %}
{% for series in case.ordered_series %}
{% for finding in series.findings.all %} {% for finding in series.findings.all %}
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}" <div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}">
_="on mouseenter
toggle .highlight-series
on #series-block-{{ series.pk }}
until mouseleave"
>
<span> <span>
<a href="{{series.get_absolute_url}}?show_finding={{finding.pk}}"><button class="btn btn-primary btn-sm">View</button></a> <a href="{{series.get_absolute_url}}?show_finding={{finding.pk}}">
<button class="btn btn-primary btn-sm">View</button>
</a>
<button class="btn btn-secondary btn-sm view-finding-modal" data-finding-id="{{ finding.pk }}" data-series-id="{{ series.pk }}"> <button class="btn btn-secondary btn-sm view-finding-modal" data-finding-id="{{ finding.pk }}" data-series-id="{{ series.pk }}">
View in Modal View in Modal
</button> </button>
</span> </span>
{% if finding.findings.all %} {% if finding.findings %}
<span> <span>
Findings: {% for f in finding.findings.all %} Findings: {% for f in finding.findings.all %}
{{f.get_link}},&nbsp; {{f.get_link}},&nbsp;
{% endfor %} {% endfor %}
</span> </span>
{% endif %} {% endif %}
{% if finding.conditions.all %} {% if finding.conditions %}
<span> <span>
Conditions: {% for c in finding.conditions.all %} Conditions: {% for c in finding.conditions.all %}
{{c.get_link}},&nbsp; {{c.get_link}},&nbsp;
{% endfor %} {% endfor %}
</span> </span>
{% endif %} {% endif %}
{% if finding.structures.all %} {% if finding.structures %}
<span> <span>
Structure: {% for s in finding.structures.all %} Structure: {% for s in finding.structures.all %}
{{s.get_link}},&nbsp; {{s.get_link}},&nbsp;
@@ -250,6 +292,75 @@
{% else %} {% else %}
No series associated with case. No series associated with case.
{% endif %} {% endif %}
<!-- Case Display Sets -->
<h5 class="mt-3">Case Display Sets</h5>
{% if case.display_sets %}
<ul class="list-group">
{% for ds in case.display_sets.all %}
<li class="list-group-item">
<b>Name: {{ ds.name }}</b>
{% if ds.description %}
<span class="text-muted">({{ ds.description }})</span>
{% endif %}
<span>
<a href="{% url 'atlas:case_displaysets_detail' ds.pk %}">View Display Set</a>
</span>
<div>
<strong>Findings:</strong>
{% if ds.findings.all %}
<ul>
{% for finding in ds.findings.all %}
<li>{{ finding }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<strong>Structures:</strong>
{% if ds.structures.all %}
<ul>
{% for structure in ds.structures.all %}
<li>{{ structure }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<strong>Conditions:</strong>
{% if ds.conditions.all %}
<ul>
{% for condition in ds.conditions.all %}
<li>{{ condition }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<details>
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
<div>
<strong>Viewer State:</strong>
<pre class="small">{{ ds.viewerstate|default:"{}" }}</pre>
</div>
<div>
<strong>Annotations:</strong>
<pre class="small">{{ ds.annotations|default:"{}" }}</pre>
</div>
</details>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">No display sets for this case.</span>
{% endif %}
</details> </details>
</div> </div>
<div> <div>
@@ -346,6 +457,40 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Reorder Series Modal -->
<div class="modal fade" id="reorderSeriesModal" tabindex="-1" aria-labelledby="reorderSeriesModalLabel" aria-hidden="true">
<div class="modal-dialog">
<form method="post" action="{% url 'atlas:reorder_series' case.pk %}" id="reorder-series-form">
{% csrf_token %}
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="reorderSeriesModalLabel">Reorder Series</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<ul id="series-reorder-list" class="list-group">
{% for series in case.get_ordered_series %}
<li class="list-group-item d-flex justify-content-between align-items-center" data-series-id="{{ series.pk }}">
<span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span>
{{ series.get_block|default:series }}
</span>
<span>
<button type="button" class="btn btn-sm btn-outline-secondary move-up"></button>
<button type="button" class="btn btn-sm btn-outline-secondary move-down"></button>
</span>
<input type="hidden" name="series_order" value="{{ series.pk }}">
</li>
{% endfor %}
</ul>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Save order</button>
</div>
</div>
</form>
</div>
</div>
<script> <script>
// From series_viewer.html // From series_viewer.html
@@ -381,19 +526,129 @@
} }
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const detailsElement = document.getElementById("series-actions");
const checkboxes = document.querySelectorAll("input[name='series-ids']");
function toggleCheckboxes() { const dicomDetails = document.getElementById("dicom-viewer-details");
const isOpen = detailsElement.hasAttribute("open"); let dicomViewerLoaded = false;
checkboxes.forEach(checkbox => { dicomDetails.addEventListener("toggle", function() {
checkbox.style.display = isOpen ? "inline-block" : "none"; if (!dicomViewerLoaded && dicomDetails.open) {
window.mountDicomViewers();
dicomViewerLoaded = true;
}
});
// Move up/down logic
document.querySelectorAll("#series-reorder-list .move-up, #series-reorder-list .move-down").forEach(function(btn) {
btn.addEventListener("click", function(e) {
e.preventDefault();
const li = btn.closest("li");
if (btn.classList.contains("move-up") && li.previousElementSibling) {
li.parentNode.insertBefore(li, li.previousElementSibling);
}
if (btn.classList.contains("move-down") && li.nextElementSibling) {
li.parentNode.insertBefore(li.nextElementSibling, li);
}
});
});
// On submit, update the hidden inputs to match the new order
document.getElementById("reorder-series-form").addEventListener("submit", function(e) {
const lis = document.querynelectorAll("#series-reorder-list li");
lis.forEach((li, idx) => {
li.querySelector("input[name='series_order']").setAttribute("name", "series_order_" + idx);
});
});
// Drag and drop reordering for series list
const reorderList = document.getElementById("series-reorder-list");
let draggedItem = null;
if (reorderList) {
reorderList.querySelectorAll("li").forEach(function(li) {
li.setAttribute("draggable", "true");
li.addEventListener("dragstart", function(e) {
draggedItem = li;
li.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
});
li.addEventListener("dragend", function() {
draggedItem = null;
li.classList.remove("dragging");
});
li.addEventListener("dragover", function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
// Visual feedback
li.classList.add("drag-over");
});
li.addEventListener("dragleave", function() {
li.classList.remove("drag-over");
});
li.addEventListener("drop", function(e) {
e.preventDefault();
li.classList.remove("drag-over");
if (draggedItem && draggedItem !== li) {
// Insert before or after depending on mouse position
const bounding = li.getBoundingClientRect();
const offset = e.clientY - bounding.top;
if (offset < bounding.height / 2) {
li.parentNode.insertBefore(draggedItem, li);
} else {
li.parentNode.insertBefore(draggedItem, li.nextSibling);
}
}
});
}); });
} }
const detailsElement = document.getElementById("series-actions");
const buttons = document.querySelectorAll(".select-series-btn");
function toggleCheckboxes() {
const isOpen = detailsElement.hasAttribute("open");
buttons.forEach(button => {
button.style.display = isOpen ? "inline-block" : "none";
});
}
document.querySelectorAll(".select-series-btn").forEach(function(btn) {
btn.addEventListener("click", function() {
const parent = btn.closest(".series-block");
const checkbox = parent.querySelector("input[type='checkbox'][name='series-ids']");
checkbox.checked = !checkbox.checked;
btn.classList.toggle("btn-primary", checkbox.checked);
btn.classList.toggle("btn-outline-primary", !checkbox.checked);
btn.textContent = checkbox.checked ? "Selected" : "Select";
// Highlight the series-block when selected
if (checkbox.checked) {
parent.classList.add("highlight-series");
} else {
parent.classList.remove("highlight-series");
}
});
});
// Initial state // Initial state
toggleCheckboxes(); toggleCheckboxes();
// Show the alert when a result is received
const resultsSpan = document.getElementById("series-action-results");
const alertDiv = document.getElementById("series-action-alert");
const observer = new MutationObserver(function(mutations) {
if (resultsSpan.textContent.trim() !== "") {
alertDiv.style.display = "block";
} else {
alertDiv.style.display = "none";
}
});
observer.observe(resultsSpan, { childList: true, subtree: true });
// Listen for toggle events on the <details> element // Listen for toggle events on the <details> element
detailsElement.addEventListener("toggle", toggleCheckboxes); detailsElement.addEventListener("toggle", toggleCheckboxes);
@@ -440,8 +695,7 @@
}, 200); }, 200);
document.getElementById("reload-finding").addEventListener("click", function () { document.getElementById("reload-finding").addEventListener("click", function () {
importAnnotations_root(annotationjson3d) window.mountDicomViewers();
importViewerState_root(viewerState);
}); });
// Initialize the DICOM viewer with the images and annotations // Initialize the DICOM viewer with the images and annotations
@@ -467,4 +721,16 @@
background-color: blue; background-color: blue;
transition: background-color 0.3s ease, border 0.3s ease; transition: background-color 0.3s ease, border 0.3s ease;
} }
#series-reorder-list .dragging {
opacity: 0.5;
}
#series-reorder-list .drag-over {
border-top: 2px solid #0d6efd;
}
.series-actions[open] {
border: 2px solid #0d6efd;
border-radius: 6px;
padding: 8px;
margin-top: 8px;
}
</style> </style>
+84
View File
@@ -0,0 +1,84 @@
{% extends 'atlas/base.html' %}
{% block content %}
{% include 'atlas/question_link_header.html' %}
<h2>Display Sets: {{ case.title }}</h2>
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>This page shows the display sets associated with the case.</p>
<p>If you have access you can add and edit display sets.</p>
</details>
<div id="main_viewer" class="dicom-viewer-root"
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
data-auto-cache-stack="false"
data-images='{{case.get_series_images_nested}}'
></div>
<div class="mb-3">
<button hx-get="{% url 'atlas:case_displaysets_add' case.pk %}" hx-target="#displayset-add-form" hx-swap="innerHTML">Add Display Set</button>
<div id="displayset-add-form"></div>
</div>
<ul class="list-group mb-4" id="display-set-list">
{% for ds in case.display_sets.all %}
{% include 'atlas/partials/displayset_row.html' %}
{% empty %}
<li class="list-group-item text-muted">No display sets for this case.</li>
{% endfor %}
</ul>
{% endblock %}
{% block js %}
<script>
document.addEventListener("DOMContentLoaded", function () {
window.mountDicomViewers();
{% if selected_displayset %}
setTimeout(function() {
importAnnotations_main_viewer({{ selected_displayset.annotations|safe }});
importViewerState_main_viewer({{ selected_displayset.viewerstate|safe }});
// Highlight the selected displayset row
var selectedRow = document.getElementById("displayset-{{ selected_displayset.pk }}");
if (selectedRow) {
selectedRow.style.border = "2px solid #ffc107";
}
}, 1000);
{% if edit %}
var editButton = document.getElementById("displayset-{{ selected_displayset.pk }}-edit-btn");
if (editButton) {
editButton.click();
}
{% endif %}
{% endif %}
});
document.addEventListener("htmx:beforeRequest", function(evt) {
console.log("htmx:beforeRequest", evt.detail);
// Only for the display set add form
var form = evt.target;
console.log("Form:", form);
console.log("Form matches:", form.matches("form[hx-post*='{% url 'atlas:case_displaysets_add' case.pk %}']"));
console.log("Form matches:", form.matches("form[hx-post*='display_sets/edit']"));
if (form && (form.matches("form[hx-post*='{% url 'atlas:case_displaysets_add' case.pk %}']") ||form.matches("form[hx-post*='display_sets/edit']" ))) {
// Set hidden fields from JS functions
var viewerState = window.exportViewerState_main_viewer ? window.exportViewerState_main_viewer() : "{}";
var annotations = window.exportAnnotations_main_viewer ? window.exportAnnotations_main_viewer() : "{}";
evt.detail.requestConfig.parameters["annotations"] = annotations;
evt.detail.requestConfig.parameters["viewerstate"] = viewerState;
console.log(evt)
}
});
</script>
{% endblock %}
+53 -43
View File
@@ -143,51 +143,61 @@
{% comment %} {{ form }} {% endcomment %} {% comment %} {{ form }} {% endcomment %}
<h3>Series:</h3>
Add image sets here. These can only be added once created (they can also be added to cases on creation).
<input type="button" value="Add More Series" id="add_more_series"> <!-- Differential Collapsible Panel -->
<div id="series_formset" class="list_formset"> <div class="card my-3 bg-dark text-white">
<ol> <div class="card-header">
{% for form in series_formset %} <a class="collapsed text-white" data-bs-toggle="collapse" href="#differentialPanel" role="button" aria-expanded="false" aria-controls="differentialPanel">
<li class="no-error series-formset"> <h3 class="d-inline">Differential</h3>
{{form.non_field_errors}} <span class="ms-2">(click to expand/collapse)</span>
{{form.errors}} </a>
{{ form | crispy}} </div>
</li> <div class="collapse" id="differentialPanel">
{% endfor %} <input type="button" value="Add Another Differential" id="add_more_differential" class="btn btn-sm btn-primary float-end">
</ol> <div class="card-body">
<div id="casedifferential_formset" class="list_formset">
<ol>
{% for form in casedifferential_formset %}
<li class="no-error casedifferential_formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form | crispy}}
</li>
{% endfor %}
</ol>
</div>
{{ casedifferential_formset.management_form | crispy }}
</div> </div>
{{ series_formset.management_form | crispy }} </div>
<h3>Differential:</h3> </div>
Add differential here.
<input type="button" value="Add Another Differential" id="add_more_differential"> <!-- Resource Collapsible Panel -->
<div id="casedifferential_formset" class="list_formset"> <div class="card my-3 bg-dark text-white">
<ol> <div class="card-header">
{% for form in casedifferential_formset %} <a class="collapsed text-white" data-bs-toggle="collapse" href="#resourcePanel" role="button" aria-expanded="false" aria-controls="resourcePanel">
<li class="no-error casedifferential_formset"> <h3 class="d-inline">Resource</h3>
{{form.non_field_errors}} <span class="ms-2">(click to expand/collapse)</span>
{{form.errors}} </a>
{{ form | crispy}} </div>
</li> <div class="collapse" id="resourcePanel">
{% endfor %} <input type="button" value="Add Another Resource" id="add_more_resource" class="btn btn-sm btn-primary float-end">
</ol> <div class="card-body">
<div id="caseresource_formset" class="list_formset">
<ol>
{% for form in caseresource_formset %}
<li class="no-error caseresource_formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form | crispy}}
</li>
{% endfor %}
</ol>
</div>
{{ caseresource_formset.management_form | crispy}}
</div> </div>
{{ casedifferential_formset.management_form | crispy }} </div>
<h3>Resource:</h3> </div>
Add resource here. Resource can be anything that is useful for the case (videos, pdf, etc). This can either be avalaible before attempting the case (such a for a pre reading list) or after.
<input type="button" value="Add Another Resource" id="add_more_resource">
<div id="caseresource_formset" class="list_formset">
<ol>
{% for form in caseresource_formset %}
<li class="no-error caseresource_formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form | crispy}}
</li>
{% endfor %}
</ol>
</div>
{{ caseresource_formset.management_form | crispy}}
<br/> <br/>
<input type="submit" class="submit-button" value="Submit" name="submit"> <input type="submit" class="submit-button" value="Submit" name="submit">
</form> </form>
@@ -0,0 +1,65 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% load static %}
{% block content %}
{% include "atlas/question_link_header.html" %}
<h2>Update Series for Case: {{ object.title }}</h2>
<form method="post" enctype="multipart/form-data" data-bs-theme="dark">
{% csrf_token %}
{% crispy form form.helper %}
{{ series_formset.management_form }}
<div id="series_formset">
{% for form in series_formset %}
<div class="card mb-3 series-form">
<div class="card-body">
{{ form.non_field_errors }}
{{ form.errors }}
{{ form|crispy }}
{% if form.can_delete %}
<div class="form-check mt-2">
{{ form.DELETE }} <label for="{{ form.DELETE.id_for_label }}">Delete</label>
</div>
{% endif %}
</div>
</div>
{% endfor %}
<!-- Empty form template for JS -->
<div id="empty-form" style="display:none;">
<div class="card mb-3 series-form">
<div class="card-body">
{{ series_formset.empty_form|crispy }}
{% if series_formset.empty_form.can_delete %}
<div class="form-check mt-2">
{{ series_formset.empty_form.DELETE }} <label for="{{ series_formset.empty_form.DELETE.id_for_label }}">Delete</label>
</div>
{% endif %}
</div>
</div>
</div>
<button type="button" id="add-series" class="btn btn-secondary mt-2">Add Series</button>
<button type="submit" class="btn btn-primary">Save</button>
</form>
<script>
document.addEventListener("DOMContentLoaded", function() {
const addBtn = document.getElementById("add-series");
const formsetDiv = document.getElementById("series_formset");
const emptyFormDiv = document.getElementById("empty-form");
let totalForms = document.getElementById("id_seriesdetail_set-TOTAL_FORMS");
addBtn.addEventListener("click", function() {
let formIdx = parseInt(totalForms.value);
let newFormHtml = emptyFormDiv.innerHTML.replace(/__prefix__/g, formIdx);
let tempDiv = document.createElement('div');
tempDiv.innerHTML = newFormHtml;
// Clear the id field value for the new form
let idInput = tempDiv.querySelector('input[name$="-id"]');
if (idInput) {
idInput.value = "";
}
formsetDiv.appendChild(tempDiv.firstElementChild);
totalForms.value = formIdx + 1;
});
});
</script>
{% endblock %}
+61 -2
View File
@@ -7,7 +7,9 @@
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<h2>My Cases</h2> <h2>Cases</h2>
Quick filters:<br/>
Show <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>my cases only</a>, <a href='{% url "atlas:case_view" %}'>all cases</a>
<details> <details>
<summary> <summary>
<h4>Filter</h4> <h4>Filter</h4>
@@ -20,8 +22,65 @@
</div> </div>
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>. View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
</details> </details>
{% render_table table %} <form>
<details id="actions-detail" class="mt-3">
<summary>
<strong>Actions</strong>
</summary>
<div class="row mb-2">
<div class="col-auto">
<select id="collection-select" name="collection_id" class="form-select form-select-sm">
<!-- Dynamically load collection options via HTMX -->
<option value="">Loading collections...</option>
<option hx-trigger="every 1s[document.getElementById('actions-detail').open] once" hx-get="{% url 'atlas:collection_options' %}" hx-target="#collection-select" hx-swap="innerHTML"></option>
</select>
</div> </div>
<div class="col-auto">
<button id="add-to-collection-btn"
class="btn btn-sm btn-outline-primary"
hx-post="{% url 'atlas:add_cases_to_collection' %}"
hx-include="[name='selection']:checked, #collection-select"
hx-target="#action-result"
hx-swap="innerHTML"
>
Add selected cases to collection
</button>
<button id="remove-from-collection-btn"
class="btn btn-sm btn-outline-danger ms-2"
hx-post="{% url 'atlas:remove_cases_from_collection' %}"
hx-include="[name='selection']:checked, #collection-select"
hx-target="#action-result"
hx-swap="innerHTML"
>
Remove selected cases from collection
</button>
<br/>
<button id="set-open-access-btn"
class="btn btn-sm btn-outline-success ms-2"
hx-post="{% url 'atlas:set_open_access' %}"
hx-include="[name='selection']:checked"
hx-target="#action-result"
hx-swap="innerHTML"
>
Set Open Access
</button>
<button id="remove-open-access-btn"
class="btn btn-sm btn-outline-warning ms-2"
hx-post="{% url 'atlas:remove_open_access' %}"
hx-include="[name='selection']:checked"
hx-target="#action-result"
hx-swap="innerHTML"
>
Remove Open Access
</button>
<span id="action-result"></span>
</div>
</div>
</details>
{% render_table table %}
</form>
{% include "generic/partials/page_size_form.html" %}
<div id="exam-options"></div> <div id="exam-options"></div>
{% endblock %} {% endblock %}
@@ -1,5 +1,6 @@
{% extends "atlas/exams.html" %} {% extends "atlas/exams.html" %}
<!-- {% load static from static %} --> <!-- {% load static from static %} -->
{% load crispy_forms_tags %}
{% block css %} {% block css %}
{% endblock %} {% endblock %}
@@ -36,8 +37,8 @@
<form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form"> <form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form">
{% csrf_token %} {% csrf_token %}
{{ form }} {% crispy form form.helper %}
<h3>Cases:</h3> {% comment %} <h3>Cases:</h3>
Add cases here. These can only be added once created (they can also be added to cases on creation). Click and drag to change order. Add cases here. These can only be added once created (they can also be added to cases on creation). Click and drag to change order.
<input type="button" value="Add More Cases" id="add_more_case"> <input type="button" value="Add More Cases" id="add_more_case">
<input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" /> <input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" />
@@ -54,13 +55,14 @@
</div> </div>
{{ case_formset.management_form }} {{ case_formset.management_form }}
<input type="submit" class="submit-button" value="Submit" name="submit">
</form> </form>
<div id="empty_case_form" style="display:none"> <div id="empty_case_form" style="display:none">
<li class='no_error case-formset'> <li class='no_error case-formset'>
{{ case_formset.empty_form }} {{ case_formset.empty_form }}
</li> </li>
</div> </div> {% endcomment %}
{% comment %} <input type="submit" class="submit-button" value="Submit" name="submit"> {% endcomment %}
</form>
<script> <script>
$(document).ready(() => { $(document).ready(() => {
sortable('.sortable'); sortable('.sortable');
@@ -0,0 +1,78 @@
{% extends "atlas/exams.html" %}
{% load crispy_forms_tags %}
{% load static %}
{% block content %}
<h2>Update Cases for Case: {{ object.title }}</h2>
This form allows you to update the cases associated with this collection. You can add, remove, or reorder cases as needed. This functionality is also available on the collection overview page (which is likely easier to use).
<form method="post" enctype="multipart/form-data" data-bs-theme="dark">
{% csrf_token %}
{% crispy form form.helper %}
{{ case_formset.management_form }}
<div id="case_formset">
{% for form in case_formset %}
<div class="card mb-3 case-form">
<div class="card-body">
{{ form.non_field_errors }}
{{ form.errors }}
{{ form|crispy }}
{% if form.can_delete %}
<div class="form-check mt-2">
{{ form.DELETE }} <label for="{{ form.DELETE.id_for_label }}">Delete</label>
</div>
{% endif %}
</div>
</div>
{% endfor %}
<!-- Empty form template for JS -->
<div id="empty-form" style="display:none;">
<div class="card mb-3 case-form">
<div class="card-body">
{{ case_formset.empty_form|crispy }}
{% if case_formset.empty_form.can_delete %}
<div class="form-check mt-2">
{{ case_formset.empty_form.DELETE }} <label for="{{ case_formset.empty_form.DELETE.id_for_label }}">Delete</label>
</div>
{% endif %}
</div>
</div>
</div>
<button type="button" id="add-case" class="btn btn-secondary mt-2">Add Case</button>
<button type="submit" class="btn btn-primary">Save</button>
{% comment %} <input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" /> {% endcomment %}
</form>
<script>
document.addEventListener("DOMContentLoaded", function() {
const addBtn = document.getElementById("add-case");
const formsetDiv = document.getElementById("case_formset");
const emptyFormDiv = document.getElementById("empty-form");
let totalForms = document.getElementById("id_casedetail_set-TOTAL_FORMS");
addBtn.addEventListener("click", function() {
let formIdx = parseInt(totalForms.value);
let newFormHtml = emptyFormDiv.innerHTML.replace(/__prefix__/g, formIdx);
let tempDiv = document.createElement('div');
tempDiv.innerHTML = newFormHtml;
// Clear the id field value for the new form
let idInput = tempDiv.querySelector('input[name$="-id"]');
if (idInput) {
idInput.value = "";
}
formsetDiv.appendChild(tempDiv.firstElementChild);
totalForms.value = formIdx + 1;
});
document.getElementById("case-order-button").addEventListener("click", function(e) {
const cases = document.querySelectorAll("#case_formset ul");
cases.forEach((el, n) => {
const inputEl = el.querySelector("input[name$='sort_order']");
if (inputEl) {
inputEl.value = n + 1;
}
});
e.preventDefault();
});
});
</script>
{% endblock %}
@@ -0,0 +1,68 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<h2>Setup default display for case</h2>
<div id="root" class="dicom-viewer-root" data-images="{{case_detail.case.get_series_images_nested}}"
style="box-sizing: border-box; background: #222; width: 100vw; height: 800px; max-width: 1600px;"
data-auto-cache-stack="true"
{% if case_detail.default_viewerstate %}
data-viewerstate={{ case_detail.default_viewerstate }}
{% endif %}
></div>
<button id="save-viewerstate" type="button" class="btn btn-primary mt-3">
Save Viewer State
</button>
<button id="reset-viewerstate" type="button" class="btn btn-secondary mt-3 ms-2">
Reset to Default
</button>
<div id="viewerstate-save-response" class="mt-2"></div>
<script>
document.getElementById('save-viewerstate').addEventListener('click', function() {
let viewerState = window.exportViewerState_root();
if (!viewerState) {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
return;
}
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token }}"
},
body: JSON.stringify({viewerstate: viewerState})
})
.then(response => response.text())
.then(html => {
document.getElementById('viewerstate-save-response').innerHTML = html;
})
.catch(error => {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error saving viewer state.</span>";
});
});
document.getElementById('reset-viewerstate').addEventListener('click', function() {
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token }}"
},
body: JSON.stringify({reset_viewerstate: true})
})
.then(response => response.text())
.then(html => {
document.getElementById('viewerstate-save-response').innerHTML = html;
// Optionally reload the page or viewer here if needed
})
.catch(error => {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error resetting viewer state.</span>";
});
});
</script>
{% endblock %}
+49 -28
View File
@@ -1,37 +1,70 @@
{% extends 'atlas/exams.html' %} {% extends 'atlas/exams.html' %}
{% load partials %}
{% block content %} {% block content %}
<h2>{{collection.name}}</h2> <h2>{{collection.name}}</h2>
{% include 'exam_notes.html' %} {% include 'exam_notes.html' %}
<div> <div>
Exam mode: {{collection.exam_mode}} (Exam open acess: {{collection.exam_open_access}})<br /> Exam mode: {{collection.exam_mode}} (open access: {{collection.exam_open_access}})<br />
Publish results: {{collection.publish_results}}<br /> {% include "generic/partials/exams/exam_status.html#publish-results" %}
Active: {{collection.active}}<br /> {% include "generic/partials/exams/exam_status.html#exam-active" %}
Collection Type: {{collection.get_collection_type_display}}<br /> Collection Type: {{collection.get_collection_type_display}}<br />
Self review: {{collection.self_review}}<br /> Self review: {{collection.self_review}}<br />
Open access: {{collection.open_access}}<br />
</div> </div>
{% if collection.collection_type == "VIV" %}
<p><a href='{% url "atlas:collection_viva" collection.pk %}'><button class="btn btn-primary">Start Viva</button></a>
{% else %}
<p>This collection will be available to view/take <a href='{{collection.get_take_url}}' id="take-url">here</a>{% if not collection.active %} (when active){% endif %} <button
_="on click
writeText(#take-url's href) on navigator.clipboard
put 'copied url!' into me
wait 1s
put 'copy url' into me"
class="btn btn-sm">copy link</button>
<p>Review collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
{% endif %}
<h3>Cases</h3> <h3>Cases</h3>
<ol id="full-question-list" class="sortable"> <ol id="full-question-list" class="sortable">
{% for casedetail in casesdetails %} {% for casedetail in casesdetails %}
<li data-question_pk={{casedetail.case.pk}}><a title="sort_order: {{casedetail.sort_order}}" href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a> <li data-question_pk={{casedetail.case.pk}}>
: {{casedetail.case.title}}
<a href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a>
:
{% if casedetail.case.series.count == 0 %}
&nbsp;<i class="bi bi-exclamation-circle text-warning" title="No series attached to this case"></i>&nbsp;
{% endif %}
{{casedetail.case.title}}
(<a href="{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-display" title="Setup default display"></i></a>
{% if casedetail.default_viewerstate %}
<i class="bi bi-check text-success" title="This case has a default viewerstate defined"></i>
{% endif %}
)
{% if collection.collection_type == "QUE" %} {% if collection.collection_type == "QUE" %}
(<a href='{% url "atlas:collection_case_details" casedetail.collection.pk casedetail.case.pk %}'>edit questions</a> (<a href='{% url "atlas:collection_case_details" casedetail.collection.pk casedetail.case.pk %}'>
&nbsp; {% if casedetail.question_schema %}
{% if casedetail.question_schema %} <i class="bi bi-question-square text-success" title="This case has questions defined."></i>
<i class="bi bi-question-square text-success" title="This case has questions defined."></i> {% else %}
{% else %} <i class="bi bi-question-square text-danger" title="This case has no questions defined."></i>
<i class="bi bi-question-square text-danger" title="This case has no questions defined."></i> {% endif %}
{% endif %} </a>
) )
{% endif %} {% endif %}
{% if casedetail.case.previous_case %} {% if casedetail.case.previous_case %}
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>manage priors</a>) (<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>
{% endif %} <i class="bi bi-link-45deg" title="Manage priors"></i></a>)
{% endif %}
</li> </li>
@@ -40,21 +73,9 @@
{% if collection.collection_type == "VIV" %}
<p>View as a viva collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
{% else %}
<p>This collection will be available to view/take <a href='{{collection.get_take_url}}' id="take-url">here</a>{% if not collection.active %} (when active){% endif %} <button
_="on click
writeText(#take-url's href) on navigator.clipboard
put 'copied url!' into me
wait 1s
put 'copy url' into me"
class="btn btn-sm">copy link</button>
<p>Review collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
{% endif %}
{% if can_edit %} {% if can_edit %}
<p><button id='button-edit-order' title='click and drag questions to change order' data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}">Edit case order / Delete cases</button></p> <p><button id='button-edit-order' title='click and drag questions to change order' data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}">Edit case order / Remove cases</button></p>
<p><button id='button-add-case' <p><button id='button-add-case'
title='click to add a case to this collection' title='click to add a case to this collection'
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list"> hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list">
@@ -1,5 +1,6 @@
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a> / Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a> /
<a href="{% url 'atlas:collection_case_update' collection.pk %}">Cases</a> /
<a href="{% url 'atlas:collection_history' collection.pk %}">History</a> / <a href="{% url 'atlas:collection_history' collection.pk %}">History</a> /
<a href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a> / <a href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a> /
<a href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a> / <a href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a> /
+220 -36
View File
@@ -2,55 +2,106 @@
{% block content %} {% block content %}
<details class="help-text"> <details class="help-text">
<summary title="Click to view instructions">Instructions</summary> <summary><i class="bi bi-info-circle"></i> Help</summary>
<p>This set up is designed to allow you to project/share cases in a seperate window whilst controlling/tracking their display from this page.</p> <p>This set up is designed to allow you to project/share cases in a seperate window whilst controlling/tracking their display from this page.</p>
<p>First you will need to open the linked viewer window by clicking the 'Open Viewer' button. This will open a new window/tab with the viewer in it. You will probably need to detach this from your current window.</p> <p>First you will need to open the linked viewer window by clicking the 'Open Viewer' button. This will open a new window/tab with the viewer in it. You will probably need to detach this from your current window.</p>
<p>If you are sharing the window you can maximise the screen area by hiding the tab/bookmark bar and the address bar. In Microsoft Edge this can be done by installing as an app, in Chrome you can use an extension such as <a href='https://chromewebstore.google.com/detail/ncppfjladdkdaemaghochfikpmghbcpc'>Open-as-Popup</a>.</p> <p>If you are sharing the window you can maximise the screen area by hiding the tab/bookmark bar and the address bar. In Microsoft Edge this can be done by installing as an app, in Chrome you can use an extension such as <a href='https://chromewebstore.google.com/detail/ncppfjladdkdaemaghochfikpmghbcpc'>Open-as-Popup</a>.</p>
<p>Click on the 'Load' button to open the case in a the linked window. Loading subsequent cases/series will replace the first case in the window.</p> <p>Click on the 'Load' button to open the case in a the linked window. Loading subsequent cases/series will replace the first case in the window.</p>
<p>It is also possible to open a single series by clicking on the 'Load' button next to the individual series.</p> <p>It is also possible to open a single series by clicking on the 'Load' button next to the individual series.</p>
<p>Once opened the case details will be show on the right hand side of the page.</p> <p>Once opened the case details will be show on the right hand side of the page.</p>
<div class="mb-2">
<button id="toggle-load-mode" class="btn btn-secondary">Switch to Load OHIF</button>
<button id="toggle-edit-mode" class="btn btn-warning ms-2">Enable Edit Mode</button>
</div>
</details> </details>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<h3>Cases</h3> <h3>Cases</h3>
<button id="open-viewer" >Open Viewer</button> <button id="open-viewer" >Open Viewer OHIF</button>
<button id="open-viewer-local" >Open Viewer Local</button>
<button id="view-series" class="text-dark border-dark" >Toggle series</button> <button id="view-series" class="text-dark border-dark" >Toggle series</button>
<ol id="" class="sortable"> <ol id="" class="sortable">
{% for case in cases %} {% for casedetail in casedetails %}
{% with case=casedetail.case %}
<li data-question_pk={{case.pk}}> <li data-question_pk={{case.pk}}>
{{case.casedetail.sort_order}} <div class="case-item" data-title="{{case.title}}" data-case={{case.pk}} data-casejson='{{case.get_viva_details_json}}' data-images='{{case.get_series_images_nested}}' data-viewerstate={{casedetail.default_viewerstate_string}}>
<div class="case-item" data-title="{{case.title}}" data-case={{case.pk}} data-case-json='{{case.get_viva_details_json}}'>Case {{forloop.counter}}: {{case.title}} &nbsp; Case {{forloop.counter}}: {{case.title}} &nbsp;
{% comment %} <button class="show-case no-padding" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Show</button> {% endcomment %} <button class="open-case no-padding" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}" data-type="case">Load OHIF</button>
{% comment %} <button class="send-message no-padding" data-title="{{case.title}}" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Open</button> {% endcomment %} <button class="open-case-local no-padding" data-target=case.pk data-type="case">Load</button>
{% comment %} <button class="open-case no-padding" data-target="{% url 'atlas:collection_viva_case' collection.pk forloop.counter0 %}" data-type="case">Load</button> {% endcomment %} <!-- Edit mode link for case -->
<button class="open-case no-padding" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}" data-type="case">Load</button> <button class="view-link-case btn btn-outline-primary btn-sm ms-2" style="display:none;">
<br /> <a href="{% url 'atlas:case_detail' case.pk %}" style="color: inherit; text-decoration: none;">View Case</a>
<div class="pre-whitespace multi-image-block"> </button>
<details class="series-detail"><summary>Series:</summary> <button class="edit-link-case btn btn-outline-warning btn-sm ms-2" style="display:none;">
{% for series in case.series.all %} <a href="{% url 'atlas:case_update' case.pk %}" style="color: inherit; text-decoration: none;">Edit Case</a>
<span class="series-block"> </button>
<span> <br />
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br> <div class="pre-whitespace multi-image-block">
{{series.get_block}} <details class="series-detail"><summary>Series:</summary>
<br /> {% for series in case.series.all %}
{% comment %} <button class="show-series no-padding text-secondary border-secondary" data-case={{case.pk}} data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:series_dicom_json' series.pk %}"> <span class="series-block">
show <span>
</button> {% endcomment %} <span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
{% comment %} <button class="open-series no-padding text-secondary border-secondary" data-type="series" data-series={{forloop.counter}} data-case={{case.pk}} data-target="{% url 'atlas:collection_viva_series' collection.pk series.pk %}"> {% endcomment %} test
<button class="open-series no-padding text-secondary border-secondary" data-type="series" data-series={{forloop.counter}} data-case={{case.pk}} data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:series_dicom_json' series.pk %}"> {{series.get_block}}
Load <br />
</button> <button class="open-series no-padding text-secondary border-secondary" data-type="series" data-series={{forloop.counter}} data-case={{case.pk}} data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:series_dicom_json' series.pk %}">
<br> Load
</button>
<button class="open-series-local no-padding text-secondary border-secondary"
data-type="series"
data-series="{{ forloop.counter }}"
data-case="{{ case.pk }}"
data-target="{{ series.pk }}">
Load
</button>
<!-- Edit mode link for series -->
<button class="edit-link-series btn btn-outline-warning btn-sm ms-2" style="display:none;">
<a href="{% url 'atlas:series_update' series.pk %}" style="color: inherit; text-decoration: none;">Edit Series</a>
</button>
<br>
</span>
</span> </span>
</span> {% endfor %}
{% endfor %} </details>
</details> </div>
{% if case.display_sets.all %}
<details class="displayset-detail"><summary>Display Sets:</summary>
{% for ds in case.display_sets.all %}
<div class="displayset-block">
<span>
<b>{{ ds.name }}</b>
{% if ds.description %}
<span class="text-muted">({{ ds.description }})</span>
{% endif %}
<button class="open-displayset no-padding text-secondary border-secondary"
data-type="displayset"
data-displayset="{{ ds.pk }}"
data-case="{{ case.pk }}"
data-title="{{ ds.name }}"
data-casejson='{{case.get_viva_details_json}}'
data-images='{{case.get_series_images_nested}}'
data-viewerstate={{ds.viewerstate_string}}
data-annotations='{{ds.annotations_string}}'
>
Load Display Set
</button>
<!-- Edit mode link for display set -->
<button class="edit-link-displayset btn btn-outline-warning btn-sm ms-2" style="display:none;">
<a href="{% url 'atlas:case_displaysets' case.pk %}?displayset={{ ds.pk }}&edit=true" style="color: inherit; text-decoration: none;">Edit Display Set</a>
</button>
</span>
</div>
{% empty %}
<span class="text-muted">No display sets for this case.</span>
{% endfor %}
</details>
{% endif %}
</div> </div>
</div> </li>
</li> {% endwith %}
{% endfor %} {% endfor %}
</ol> </ol>
</div> </div>
@@ -77,8 +128,60 @@
{% block js %} {% block js %}
<script type="text/javascript"> <script type="text/javascript">
var win2 = false; var win2 = false;
let editMode = false;
$(document).ready(function() { $(document).ready(function() {
let loadLocalMode = true;
// Edit mode toggle
$('#toggle-edit-mode').click(function() {
editMode = !editMode;
if (editMode) {
$('.edit-link-case').show();
$('.view-link-case').show();
$('.edit-link-series').show();
$('.edit-link-displayset').show();
$(this).text('Disable Edit Mode').removeClass('btn-warning').addClass('btn-danger');
} else {
$('.edit-link-case').hide();
$('.view-link-case').hide();
$('.edit-link-series').hide();
$('.edit-link-displayset').hide();
$(this).text('Enable Edit Mode').removeClass('btn-danger').addClass('btn-warning');
}
});
// Update all "Load" buttons to reflect the current mode
function updateLoadButtons() {
if (loadLocalMode) {
$('.open-case').hide();
$('.open-series').hide();
$('.open-case-local').show();
$('.open-series-local').show();
$('#toggle-load-mode').text('Switch to Load OHIF');
$("#open-viewer").hide()
$("#open-viewer-local").show()
} else {
$('.open-case').show();
$('.open-series').show();
$('.open-case-local').hide();
$('.open-series-local').hide();
$("#open-viewer").show()
$("#open-viewer-local").hide()
$('#toggle-load-mode').text('Switch to Load Local');
}
}
// Initial state: show only normal Load buttons
updateLoadButtons();
// Toggle button click handler
$('#toggle-load-mode').click(function() {
loadLocalMode = !loadLocalMode;
updateLoadButtons();
});
const bc = new BroadcastChannel("viva_load"); const bc = new BroadcastChannel("viva_load");
bc.onmessage = (event) => { bc.onmessage = (event) => {
console.log("message received"); console.log("message received");
@@ -88,6 +191,12 @@
$("#open-viewer").removeClass("flash-button"); $("#open-viewer").removeClass("flash-button");
//bc.postMessage({"type": "open-complete", "url" : event.data.url}); //bc.postMessage({"type": "open-complete", "url" : event.data.url});
} }
if (event.data.type == "open-complete-local") {
$("#loading-case").hide();
$("#open-viewer-local").removeClass("flash-button");
//bc.postMessage({"type": "open-complete-local"});
}
}; };
console.log('ready') console.log('ready')
$('.show-collection, .show-case, .show-series').click(function() { $('.show-collection, .show-case, .show-series').click(function() {
@@ -96,6 +205,68 @@
$('#case-title').text($(this).data('title')); $('#case-title').text($(this).data('title'));
$('#viewer').attr('src', url) $('#viewer').attr('src', url)
}); });
$('.open-case-local').click(function() {
let c = this.closest(".case-item");
console.log('clicked local', c)
console.log('open local', c.dataset.case)
case_details = JSON.parse(c.dataset.casejson);
let viewerstate = JSON.parse(c.dataset.viewerstate) || {};
$('#loading-case').show()
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
bc.postMessage({"type": "open",
"case" : c.dataset.case, "images": c.dataset.images,
"viewerstate": viewerstate
});
});
$('.open-displayset').click(function() {
let c = this;
console.log('clicked local', c)
console.log('open local', c.dataset.case)
case_details = JSON.parse(c.dataset.casejson);
let viewerstate = JSON.parse(c.dataset.viewerstate) || {};
let annotations = JSON.parse(c.dataset.annotations) || {};
$('#loading-case').show()
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
bc.postMessage({"type": "open",
"case" : c.dataset.case, "images": c.dataset.images,
"viewerstate": viewerstate,
"annotations": annotations
});
});
$('.open-series-local').click(function() {
let c = $(this).closest(".case-item")[0];
let seriesNumber = $(this).data('series');
let seriesPk = $(this).data('target');
let caseDetails = JSON.parse(c.dataset.casejson);
let images = JSON.parse(c.dataset.images)[seriesNumber - 1] || [];
$('#loading-case').show();
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> " + c.dataset.title);
$('#current-case-series').html("<span class='title'>Series:</span> " + seriesNumber);
$("#current-case-history").html("<span class='title'>History:</span> " + caseDetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> " + caseDetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> " + caseDetails['report']);
// Send only the images for this series
bc.postMessage({
"type": "open",
"case": c.dataset.case,
"series": seriesPk,
"images": JSON.stringify(images),
});
});
$('.open-case, .open-series').click(function() { $('.open-case, .open-series').click(function() {
var url = $(this).data('target'); var url = $(this).data('target');
console.log('clicked', url) console.log('clicked', url)
@@ -103,7 +274,7 @@
let c = $(this).closest(".case-item"); let c = $(this).closest(".case-item");
case_details = c.data('case-json'); case_details = c.data('casejson');
console.log(case_details); console.log(case_details);
$('#loading-case').show() $('#loading-case').show()
@@ -137,6 +308,11 @@
openSecondaryWindow("{% url 'atlas:collection_viewer' %}"); openSecondaryWindow("{% url 'atlas:collection_viewer' %}");
//bc.postMessage(url); //bc.postMessage(url);
}) })
$('#open-viewer-local').click(function() {
$("#open-viewer-local").removeClass("flash-button");
openSecondaryWindow("{% url 'atlas:collection_viewer_local' %}");
//bc.postMessage(url);
})
$('#view-series').click(function() { $('#view-series').click(function() {
$(".series-detail").attr("open", (_, attr) => !attr); $(".series-detail").attr("open", (_, attr) => !attr);
//document.getElementsByClassName("series-detail").toggleAttribute("open"); //document.getElementsByClassName("series-detail").toggleAttribute("open");
@@ -146,7 +322,11 @@
}); });
function openSecondaryWindow(url) { function openSecondaryWindow(url) {
//return win2 = window.open(url,'secondary','width=300,height=100'); //return win2 = window.open(url,'secondary','width=300,height=100');
return win2 = window.open(url,'secondary'); return win2 = window.open(
url,
'secondary',
'toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=1200,height=800'
);
} }
</script> </script>
@@ -183,5 +363,9 @@
.title { .title {
font-weight: bold; font-weight: bold;
} }
.edit-link-case, .edit-link-series, .edit-link-displayset {
vertical-align: middle;
}
</style> </style>
{% endblock css %} {% endblock css %}
+44 -6
View File
@@ -19,18 +19,56 @@
<h3>Collection</h3> <h3>Collection</h3>
<p>A collection is a group of cases. It can be used to group cases. Cases can below to more than one group.</p> <p>A collection is a group of cases. It can be used to group cases. Cases can below to more than one group.</p>
<p>A number of different collection types are available, including:</p>
<ul>
<li>Review</li>
<li>Report</li>
<li>Question</li>
<li>Viva</li>
</ul>
<h5>Review</h5>
<p>A Review collection is used to group cases for the purpose of review or teaching sessions. It allows users to easily access a curated set of cases. No data is collected as part of the review, although it is possible for users to add a "Self Review" component.</p>
<h5>Report</h5>
<p>A Report collection is designed for cases that users are expected to complete a report for. This can be used for audit, teaching, or assessment purposes.</p>
<h5>Question</h5>
<p>A Question collection contains cases that are used for quiz or question-based learning. These cases may have associated questions and answers for self-assessment or examination.</p>
<h5>Viva</h5>
<p>A Viva collection is intended for oral examination practice. Cases in this collection can be used to simulate viva-style assessments. These are designed to be presented by the "examiner" and features a number of features to make this as easy as possible.</p>
<h2>Uploading/creating cases</h2> <h2>Uploading/creating cases</h2>
<p>The easiest way to upload cases is to use the uploader tool. This can export, anonnyimize and upload cases from Insignia Insight, however it may need setting up manually.</p> <p>If set up, the easiest way to upload cases is to use the uploader tool. This can export, anonnyimize and upload cases from Insignia Insight, however it may need setting up manually. If this is not possible you can upload files directly to the site.</p>
<p>Note: due to restrictions in Insight it is not possible to export dicom files from teaching folders. It is possible to export jpg/png files which can be uploaded and will function, but as they do not store the full range of dicom/pixel data some functionality will be limited (e.g. windowing/levelling will be restriction, automatic ordering will not be possible).</p> <div class="alert alert-warning" role="alert">
<strong>Note:</strong> Due to restrictions in Insight, it is not possible to export DICOM files from teaching folders. It is possible to export JPG/PNG files which can be uploaded and will function, but as they do not store the full range of DICOM/pixel data, some functionality will be limited (e.g. windowing/levelling will be restricted, automatic ordering will not be possible).
</div>
<h3>Setting up the Uploader tool</h3> <h3>Setting up the Uploader tool</h3>
Requirements: Full Insight installation (not WebPacs) Requirements: Full Insight installation (not WebPacs)
<h4>Step 1: Download the uploader tool</h4> <h5>Step 1: Download the uploader tool</h4>
This is available <a href="https://www.penracourses.org.uk/uploader/anon_gui.exe">here</a> This is available <a href="https://www.penracourses.org.uk/uploader/anon_gui.exe">here</a>
<h4>Step 2: Download insight integration xml file</h4> <h5>Step 2: Download insight integration xml file</h4>
Available <a href="https://www.penracourses.org.uk/uploader/insight_rad_up.xml">here</a> Available <a href="https://www.penracourses.org.uk/uploader/insight_rad_up.xml">here</a>
<h4>Step 3: Integrate the uploader with PACs</h4> <h5>Step 3: Integrate the uploader with PACs</h4>
Whilst in PACs open the settings menu with the keyboard shortcut 'Ctrl+Shift+C'. In the 'General' tab, select 'External Tools' and click 'Add'. Press the Import button and select the xml file you downloaded in Step 2. Modify the file to run path to point the uploader exe downloaded in step 1 (clicking on the 3 dots will allow you to browse and select this file). The remaining fields should have been set via the xml file and should not require modification. Whilst in PACs open the settings menu with the keyboard shortcut 'Ctrl+Shift+C'. In the 'General' tab, select 'External Tools' and click 'Add'. Press the Import button and select the xml file you downloaded in Step 2. Modify the file to run path to point the uploader exe downloaded in step 1 (clicking on the 3 dots will allow you to browse and select this file). The remaining fields should have been set via the xml file and should not require modification.
<h3>Uploading without the uploader tool</h3> <h3>Uploading without the uploader tool</h3>
If you have <b>annonymised</b> dicom files already exported you can uploading them directly using this <a href='{% url "atlas:new_uploads" %}'>form</a>. <p>If you have <b>annonymised</b> dicom files already exported you can uploading them directly on this page using this <a href='{% url "atlas:new_uploads" %}'>page</a></p>
<p>To do so you will have to first export the files from the PACs system.</p>
<p>Once uploaded the series will be available to be imported into cases
<h2>Cases</h2>
<h3>Ordering dicoms within a series</h3>
<p>Depending on how dicoms are exported images within a stack may appear in the wrong order. This is apparent when viewing the stacks/series as jumping of images.</p>
<p>In most cases this can be fixed by re-ordering based on series metadata (usually slice location).</p>
<p>It is possible to reorder all the stacks in a case at once, or to reorder individual stacks.</p>
<h4>Reorder all stacks in a case</h4>
<p>Go to the case page, click "Manage Series" then "Order dicoms by slice location". This will reorder all the stacks in the case based on slice location.</p>
<h4>Reorder individual stacks</h4>
<p>More ordering options (if the above does not work) are available on the series page.</p>
<p>These can be accessed by opening the "Series info" section and selecting the appropriate button.</p>
{% endblock %} {% endblock %}
@@ -16,7 +16,7 @@
<a href="{{ prev_case.get_absolute_url }}">{{ case.title }}</a> <a href="{{ prev_case.get_absolute_url }}">{{ case.title }}</a>
</li> </li>
{% partial case-series %} {% include "atlas/case_display_block.html#case-series" %}
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
+212 -54
View File
@@ -11,20 +11,22 @@
</div> </div>
<div> <div>
<p>This form allows you to quickly upload dicom files to the server. Select the folder containing the dicom files below. If you wish to upload files directly from Insight you may rather use the <a href='{% url "atlas:help" %}'>Uploader tool.</a></p> <p>This form allows you to quickly upload dicom files to the server. Select the folder containing the dicom files below. If you wish to upload files directly from Insight you may prefer to use the <a href='{% url "atlas:help" %}'>Uploader tool.</a></p>
<details> <details class="help-text">
<summary>Help</summary> <summary><i class="bi bi-info-circle"></i> Help</summary>
<p>Only valid dicom files will be uploaded. </p> <p>Only valid dicom files will be uploaded. </p>
<p>It is possible to upload multiple files by selecting the parent (containing) folder. Folders within will then be searched recursively for any valid dicom files.</p> <p>It is possible to upload multiple files by selecting the parent (containing) folder. Folders within will then be searched recursively for any valid dicom files.</p>
<p>Files to be uploaded will be displayed below. </p> <p>Files to be uploaded will be displayed below. </p>
<p>DICOM tags will be checked to see if valid accession or hospital numbers are present but no annonymisation will be performed.</p> <p>DICOM tags will be checked to see if valid accession or hospital numbers are present but no annonymisation will be performed.</p>
<p>By default a basic OCR will also be performed to check for burned in details on images, the is resource intensive and can be disabled if you know the content you are uploading does not feature any patient details.</p> <p>An attempt to detect duplicates will be made by calculating a hash of the pixel data in the DICOM files. If this check is not run and the files are uploaded they will be rejected.</p>
<p>If enabled a basic OCR will also be performed to check for burned in details on images, this is resource intensive and can be disabled if you know the content you are uploading does not feature any patient details.</p>
</details> </details>
<details> <details>
<summary>Settings</summary> <summary>Settings</summary>
<input type="checkbox" id="check-annonymisation-ocr"><label for="check-annonymisation-ocr">Check for annonymisation (OCR)</label> <input type="checkbox" id="check-annonymisation-ocr"><label for="check-annonymisation-ocr">Check for annonymisation (OCR)</label>
<input type="checkbox" id="show-thumbnails"><label for="Show thumbnails">Show thumbnails</label> <input type="checkbox" id="show-thumbnails"><label for="Show thumbnails">Show thumbnails</label>
<input type="checkbox" id="show-viewer"><label for="Show Viewer">Show viewer</label> <input type="checkbox" id="show-viewer"><label for="Show Viewer">Show viewer</label>
<input type="checkbox" id="check-duplicates" checked><label for="check-duplicates">Check for duplicates before upload</label>
</details> </details>
</div> </div>
@@ -40,8 +42,11 @@
<h5>Uploaded</h5> <h5>Uploaded</h5>
<ul id="uploaded-files"></ul> <ul id="uploaded-files"></ul>
<h5>Duplicates</h5> <h5>Duplicates</h5>
<ul id="duplicate-files"></ul>
<ul id="duplicate-series"></ul> <ul id="duplicate-series"></ul>
<details>
<summary>Files</summary>
<ul id="duplicate-files"></ul>
</details>
<h5>Failed</h5> <h5>Failed</h5>
<ul id="failed-files"></ul> <ul id="failed-files"></ul>
</div> </div>
@@ -55,41 +60,175 @@
<details><summary>Override annonymisation check</summary> <details><summary>Override annonymisation check</summary>
<p>By clicking the button below you can override the annonymisation check and upload the files anyway. Only do this if you are sure this is a false positive.</p> <p>By clicking the button below you can override the annonymisation check and upload the files anyway. Only do this if you are sure this is a false positive.</p>
<button id="override-anon" _="on click remove @disabled from #uploadButton then alert('Upload enabled')">Override annonymisation check</button> <button id="override-anon" _="on click remove @disabled from #uploadButton then alert('Upload enabled')">Override annonymisation check</button>
</div>
<h4>Selected files:</h4>
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''></div>
<ol id="listing"></ol>
<div id="loading" class="fullscreen-overlay">
<div class="progress-block">
<div id="progress"></div>
<div class="sk-cube-grid full">
<div class="sk-cube sk-cube1"></div>
<div class="sk-cube sk-cube2"></div>
<div class="sk-cube sk-cube3"></div>
<div class="sk-cube sk-cube4"></div>
<div class="sk-cube sk-cube5"></div>
<div class="sk-cube sk-cube6"></div>
<div class="sk-cube sk-cube7"></div>
<div class="sk-cube sk-cube8"></div>
<div class="sk-cube sk-cube9"></div>
</div>
<div id="progress-text">Uploading...</div>
</div> </div>
</div>
<h4>Selected files:</h4>
<div id="duplicate-progress"></div>
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''></div>
<ol id="listing"></ol>
<div id="loading" class="fullscreen-overlay">
<div class="progress-block">
<div id="progress"></div>
<div class="sk-cube-grid full">
<div class="sk-cube sk-cube1"></div>
<div class="sk-cube sk-cube2"></div>
<div class="sk-cube sk-cube3"></div>
<div class="sk-cube sk-cube4"></div>
<div class="sk-cube sk-cube5"></div>
<div class="sk-cube sk-cube6"></div>
<div class="sk-cube sk-cube7"></div>
<div class="sk-cube sk-cube8"></div>
<div class="sk-cube sk-cube9"></div>
</div>
<div id="progress-text">Uploading...</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block js %} {% block js %}
<script src="https://cdn.jsdelivr.net/npm/hash-wasm@4"></script>
<script src="{% static 'js/upload_form_helpers.js' %}"></script> <script src="{% static 'js/upload_form_helpers.js' %}"></script>
<script> <script>
window.to_upload = []; window.to_upload = [];
window.upload_count = 1; window.upload_count = 1;
window.duplicate_series = new Set() window.duplicate_series = new Set()
async function calculateBlake3Hash(file) {
// Read file as ArrayBuffer
const buffer = await file.arrayBuffer();
// Parse as DICOM
let pixelDataElement;
try {
const byteArray = new Uint8Array(buffer);
const dataSet = dicomParser.parseDicom(byteArray);
pixelDataElement = dataSet.elements.x7fe00010; // Pixel Data tag
if (!pixelDataElement) {
throw new Error("No Pixel Data found in DICOM file.");
}
// Extract pixel data bytes
const pixelData = byteArray.subarray(pixelDataElement.dataOffset, pixelDataElement.dataOffset + pixelDataElement.length);
// Hash just the pixel data
return await hashwasm.blake3(pixelData);
} catch (err) {
console.error("DICOM parse/hash error:", err, file.name);
// Fallback: hash the whole file if pixel data is missing or parsing fails
return await hashwasm.blake3(new Uint8Array(buffer));
}
}
async function checkForDuplicates(files) {
const hashes = [];
for (const file of files) {
const hash = await calculateBlake3Hash(file);
hashes.push(hash);
file._blake3_hash = hash; // Store for later use if needed
}
// Call the API
return fetch("{% url 'api-1:check_images_hashes' %}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token }}"
},
body: JSON.stringify(hashes)
}).then(res => res.json());
}
async function processFilesWithDuplicateCheck() {
const file_set = document.getElementById("filepicker").files;
let hasDuplicates = false;
const $dupProgress = $("#duplicate-progress");
$dupProgress.empty();
if ($("#check-duplicates").is(":checked")) {
$dupProgress.html('<span style="color:#0d6efd;">Calculating hashes...</span>');
$("#progress-text").text("Checking for duplicates...");
// Calculate hashes and update progress
const hashes = [];
let n = 0;
for (const file of file_set) {
n += 1;
$dupProgress.html(`<span style="color:#0d6efd;">Hashing: ${file.webkitRelativePath} (${n} of ${file_set.length})</span>`);
const hash = await calculateBlake3Hash(file);
hashes.push(hash);
file._blake3_hash = hash;
}
$dupProgress.html('<span style="color:#0d6efd;">Checking hashes with server...</span>');
const result = await fetch("{% url 'api-1:check_images_hashes' %}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token }}"
},
body: JSON.stringify(hashes)
}).then(res => res.json());
// Build a map from hash to file for quick lookup
const hashToFile = {};
for (const file of file_set) {
if (file._blake3_hash) {
hashToFile[file._blake3_hash] = file;
}
}
let duplicateCount = 0;
// Map of unique series url to {url, id}
const duplicateSeriesMap = {};
for (const hash in result) {
if (result[hash].id) {
hasDuplicates = true;
duplicateCount += 1;
// Highlight the file in the listing
const file = hashToFile[hash];
if (file) {
const selector = `.dicom-file[data-path='${file.webkitRelativePath}']`;
const li = document.querySelector(selector);
if (li) {
li.classList.add("duplicate-file-highlight");
}
}
// Collect unique series by url
if (result[hash].url) {
duplicateSeriesMap[result[hash].url] = {
url: result[hash].url,
id: result[hash].id
};
}
}
}
if (hasDuplicates) {
$("#uploadButton").prop("disabled", true);
// Show summary in red and list duplicate series as links if any
let html = `<div style="color:#dc3545;font-weight:bold;">Found ${duplicateCount} duplicate file(s). Please review before uploading.</div>`;
const seriesUrls = Object.values(duplicateSeriesMap);
if (seriesUrls.length > 0) {
html += `<div style="margin-top:8px;">Duplicate series:</div><ul>`;
seriesUrls.forEach(series => {
html += `<li><a href="${series.url}" target="_blank" style="color:#dc3545;text-decoration:underline;">Series ${series.id}</a></li>`;
});
html += `</ul>`;
}
$dupProgress.html(html);
toastr.error("Duplicates detected. Please review before uploading.");
} else {
$("#uploadButton").prop("disabled", false);
$dupProgress.html('<span style="color:#198754;">No duplicates detected.</span>');
}
} else {
$("#uploadButton").prop("disabled", false);
$dupProgress.empty();
}
}
function* chunks(arr, n) { function* chunks(arr, n) {
for (let i = 0; i < arr.length; i += n) { for (let i = 0; i < arr.length; i += n) {
yield arr.slice(i, i + n); yield arr.slice(i, i + n);
@@ -170,12 +309,32 @@
if (current_chunk + 1 == total_chunks) { if (current_chunk + 1 == total_chunks) {
$("#loading").hide(); $("#loading").hide();
window.duplicate_series.forEach((element) => { // If there are duplicates, open the details and highlight the series
let item = document.createElement("a"); if (window.duplicate_series.size > 0) {
item.href = element; $("#upload-results").attr("open", true);
item.textContent = element; $("#duplicate-series").closest("details").attr("open", true);
$("#duplicate-series").append(item, "<br/>"); // Show a global alert for duplicate files
}); toastr.warning(
"Some duplicate files have been detected and not re-imported.",
"Duplicate Files Warning",
{
"closeButton": true,
"progressBar": true,
"positionClass": "toast-top-full-width",
"timeOut": 7000
}
);
window.duplicate_series.forEach((element) => {
const wrapper = document.createElement("div");
wrapper.className = "alert alert-warning duplicate-series-highlight";
const link = document.createElement("a");
link.href = element;
link.textContent = `Series exists: ${element}`;
link.target = "_blank";
wrapper.appendChild(link);
$("#duplicate-series").append(wrapper);
});
};
//$("#duplicate-series").append([...window.duplicate_series].join("<br/>")); //$("#duplicate-series").append([...window.duplicate_series].join("<br/>"));
alert("Uploading complete") alert("Uploading complete")
} }
@@ -220,7 +379,6 @@
errors.push(`Patient ID starts with ${site_codes[i]} (${patient_id})`); errors.push(`Patient ID starts with ${site_codes[i]} (${patient_id})`);
} }
} }
console.log(errors)
if (not_anon) { if (not_anon) {
toastr.warning(`File does not appear to be annonymised<br/>Accession: ${accession_number} <br/>Patient ID: ${patient_id}<br/>`, 'Anonymisation warning', { toastr.warning(`File does not appear to be annonymised<br/>Accession: ${accession_number} <br/>Patient ID: ${patient_id}<br/>`, 'Anonymisation warning', {
@@ -244,11 +402,11 @@
$(".anon-alert").show(); $(".anon-alert").show();
setTimeout( setTimeout(
function(errors) { function(errors) {
console.log(errors) console.log(errors)
// We delay this a few seconds otherwise it does not work // We delay this a few seconds otherwise it does not work
$(`.dicom-file[data-path='${file.webkitRelativePath}']`).addClass("image-ident-warning").append($(`<div>${errors.join("<br/>")}</div>`)); $(`.dicom-file[data-path='${file.webkitRelativePath}']`).addClass("image-ident-warning").append($(`<div>${errors.join("<br/>")}</div>`));
}, 2000, errors }, 2000, errors
) )
} }
@@ -453,20 +611,10 @@
} }
$(document).ready(function () { $(document).ready(function () {
document.getElementById("filepicker").addEventListener( document.getElementById("filepicker").addEventListener("change", async () => {
"change", await loadDicomViewerUploads();
(event) => { await processFilesWithDuplicateCheck();
//let output = document.getElementById("listing"); });
//for (const file of event.target.files) {
// let item = document.createElement("li");
// item.textContent = file.webkitRelativePath;
// output.appendChild(item);
//}
loadDicomViewerUploads();
},
false,
);
// Add event listener to the button element // Add event listener to the button element
@@ -478,7 +626,17 @@
<script type="text/hyperscript"> <script type="text/hyperscript">
init init
add @disabled to #uploadButton add @disabled to #uploadButton
end end
</script> </script>
{% endblock %}
{% block css %}
<style>
.duplicate-file-highlight {
background: #fff3cd !important;
border-left: 5px solid #dc3545 !important;
color: #856404 !important;
}
</style>
{% endblock %} {% endblock %}
@@ -0,0 +1,5 @@
{% for collection in collections %}
<option value="{{ collection.pk }}">{{ collection.name }}</option>
{% empty %}
<option value="">No collections available</option>
{% endfor %}
@@ -0,0 +1,19 @@
<form
hx-post="{% if form.instance.pk %}{% url 'atlas:case_displaysets_edit' form.instance.pk %}{% else %}{% url 'atlas:case_displaysets_add' case.pk %}{% endif %}"
{% if form.instance.pk %}
hx-swap="outerHTML"
hx-target="closest li"
{% else %}
hx-target="#display-set-list"
hx-swap="beforeend"
hx-on::after-request= "this.remove()"
{% endif %}
>
{% csrf_token %}
{{ form.as_p }}
{{ form.media }}
<input type="hidden" name="viewerstate" id="viewerstate-input">
<input type="hidden" name="annotations" id="annotations-input">
<button type="submit">Save</button>
</form>
@@ -0,0 +1,110 @@
<li class="list-group-item d-flex align-items-start" id="displayset-{{ ds.pk }}">
<div class="me-3">
{% if ds.viewerstate and ds.annotations %}
<button
type="button"
class="btn btn-primary btn-lg"
style="min-width: 100px;"
onclick='importAnnotations_main_viewer({{ ds.annotations|safe }}); importViewerState_main_viewer({{ ds.viewerstate|safe }});'
>Load</button>
{% endif %}
</div>
<div class="flex-grow-1">
<b>{{ ds.name }}</b>
{% if ds.description %}
<span class="text-muted">({{ ds.description }})</span>
{% endif %}
<div class="mt-2">
<strong>Findings:</strong>
{% if ds.findings.all %}
<ul>
{% for finding in ds.findings.all %}
<li>{{ finding }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<strong>Structures:</strong>
{% if ds.structures.all %}
<ul>
{% for structure in ds.structures.all %}
<li>{{ structure }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<strong>Conditions:</strong>
{% if ds.conditions.all %}
<ul>
{% for condition in ds.conditions.all %}
<li>{{ condition }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<details>
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
<div>
<strong>Viewer State:</strong>
<pre class="small">{{ ds.viewerstate|default:"{}" }}</pre>
</div>
<div>
<strong>Annotations:</strong>
<pre class="small">{{ ds.annotations|default:"{}" }}</pre>
</div>
</details>
</div>
<div class="mt-2">
<button
id="displayset-{{ ds.pk }}-edit-btn"
hx-get="{% url 'atlas:case_displaysets_edit' ds.pk %}"
hx-target="#displayset-edit-form-{{ ds.pk }}"
hx-swap="innerHTML"
hx-after
class="btn btn-warning btn-sm"
onclick="
this.style.display='none';
var li = this.closest('li');
var ul = li.parentNode;
ul.insertBefore(li, ul.firstChild);
li.scrollIntoView({behavior: 'smooth', block: 'center'});
setTimeout(function() {
var form = document.querySelector('#displayset-edit-form-{{ ds.pk }} form');
if (form) {
var cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'btn btn-secondary btn-sm ms-2';
cancelBtn.textContent = 'Cancel';
cancelBtn.onclick = function() {
document.querySelector('#displayset-edit-form-{{ ds.pk }}').innerHTML = '';
document.querySelector('#displayset-{{ ds.pk }}-edit-btn').style.display = '';
};
form.appendChild(cancelBtn);
}
}, 100);
"
>Edit</button>
<button
hx-delete="{% url 'atlas:case_displaysets_delete' ds.pk %}"
hx-target="closest li"
hx-swap="outerHTML"
class="btn btn-danger btn-sm"
onclick="return confirm('Are you sure you want to delete this display set?');"
>Delete</button>
</div>
</div>
<div id="displayset-edit-form-{{ ds.pk }}"></div>
<div class="align-self-start ms-3">
<span class="text-muted small">ID: {{ ds.pk }}</span>
</div>
</li>
@@ -1,6 +1,8 @@
<div class="floating-header"> <div class="floating-header">
<a href="{% url 'atlas:case_detail' pk=case.pk %}" title="View the Case">View</a> <a href="{% url 'atlas:case_detail' pk=case.pk %}" title="View the Case">View</a>
<a href="{% url 'atlas:case_update' pk=case.pk %}" title="Edit the Case">Edit</a> <a href="{% url 'atlas:case_update' pk=case.pk %}" title="Edit the Case">Edit</a>
<a href="{% url 'atlas:case_series_update' pk=case.pk %}" title="Edit the Cases Series">Series</a>
<a href="{% url 'atlas:case_displaysets' pk=case.pk %}" title="View and edit case display sets">Display Sets</a>
<a href="{% url 'atlas:case_clone' pk=case.pk %}" <a href="{% url 'atlas:case_clone' pk=case.pk %}"
title="Clone the Case (duplicate everything but the images)">Clone</a> title="Clone the Case (duplicate everything but the images)">Clone</a>
<a href="{% url 'atlas:case_delete' pk=case.pk %}" title="Delete the Case">Delete</a> <a href="{% url 'atlas:case_delete' pk=case.pk %}" title="Delete the Case">Delete</a>
+49 -2
View File
@@ -1,8 +1,6 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block css %}
{% endblock %}
{% block content %} {% block content %}
@@ -20,8 +18,57 @@
</div> </div>
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>. View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
</details> </details>
<details class="mt-3">
<summary>
<strong>Actions</strong>
</summary>
<form id="bulk-delete-form" method="post" action="{% url 'atlas:series_bulk_delete' %}">
{% csrf_token %}
<button type="submit" class="btn btn-danger mb-2" onclick="return confirm('Delete selected series?');">
Delete Selected
</button>
</form>
</details>
{% render_table table %} {% render_table table %}
</div> </div>
<script>
document.getElementById('bulk-delete-form').addEventListener('submit', function(e) {
// Remove any previously added hidden inputs
document.querySelectorAll('#bulk-delete-form input[name="series_ids"]').forEach(el => el.remove());
// Find all checked checkboxes
const checked = document.querySelectorAll('input[name="selection"]:checked');
if (checked.length === 0) {
alert("Please select at least one series to delete.");
e.preventDefault();
return false;
}
// Add hidden inputs for each checked series
checked.forEach(cb => {
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'series_ids';
hidden.value = cb.value;
this.appendChild(hidden);
});
});
</script>
<div id="exam-options"></div> <div id="exam-options"></div>
{% endblock %} {% endblock %}
{% block js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
});
</script>
{% endblock %}
{% block css %}
<style>
</style>
{% endblock css %}
+126 -48
View File
@@ -38,14 +38,15 @@ Associated case:
</details> </details>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<div>Author: {{ series.get_author_display }}</div>
{% with image_url_array_and_count=series.get_image_url_array_and_count %} {% with image_url_array_and_count=series.get_image_url_array_and_count %}
{% comment %} <div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ image_url_array_and_count.0 }}" data-annotations=''> {% comment %} <div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ image_url_array_and_count.0 }}" data-annotations=''>
</div> {% endcomment %} </div> {% endcomment %}
<div id="root" class="dicom-viewer-root" data-images="{{ image_url_array_and_count.0 }}" <div id="root" class="dicom-viewer-root" data-images="{{ image_url_array_and_count.0 }}"
style="width: 1000px; height: 600px; box-sizing: border-box; background: #222;" style="max-width: 1000px; height: 600px; box-sizing: border-box; background: #222;"
data-auto-cache-stack=true data-auto-cache-stack=false
></div> ></div>
@@ -82,13 +83,9 @@ Associated case:
<button id="reset-viewport-button">Reset viewport</button> <button id="reset-viewport-button">Reset viewport</button>
{% endif %} {% endif %}
<details open> <details class="styled-detail open">
<summary>Findings</summary> <summary>Findings</summary>
{% for finding in series.findings.all %} {% for finding in series.findings.all %}
<div class="finding-box"> <div class="finding-box">
<button id="finding-{{finding.pk}}-3d" class="view-finding-button-3d" data-annotationjson3d='{{finding.annotation_json_3d}}' data-viewerstatejson='{{finding.viewer_state_3d}}' data-findingid='{{finding.pk}}'>Click to view</button> <button id="finding-{{finding.pk}}-3d" class="view-finding-button-3d" data-annotationjson3d='{{finding.annotation_json_3d}}' data-viewerstatejson='{{finding.viewer_state_3d}}' data-findingid='{{finding.pk}}'>Click to view</button>
@@ -121,11 +118,13 @@ Associated case:
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="delete-finding-link">Delete</a> <a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="delete-finding-link">Delete</a>
{% endif %} {% endif %}
</div> </div>
{% empty %}
<p>No findings available.</p>
{% endfor %} {% endfor %}
</details> </details>
{% if can_edit %} {% if can_edit %}
<details> <details class="styled-detail">
<summary>Truncate series</summary> <summary>Truncate series</summary>
<p> <p>
This will limit the series to the selected bounds (the rest of the images will be deleted). This is useful when you have a large volume of which only a small area is relevant to the case. NOTE: once deleted the images cannot be recovered on the site (they would have to be reuploaded if required). Make sure your images are shown in the correct order above and use test the test truncate button first. This will limit the series to the selected bounds (the rest of the images will be deleted). This is useful when you have a large volume of which only a small area is relevant to the case. NOTE: once deleted the images cannot be recovered on the site (they would have to be reuploaded if required). Make sure your images are shown in the correct order above and use test the test truncate button first.
@@ -133,7 +132,7 @@ Associated case:
<div role="alert" class="alert alert-warning"> <div role="alert" class="alert alert-warning">
<strong>Warning:</strong> Please note: if you have reordered the series this may result in the incorrect images being deleted. <strong>Warning:</strong> Please note: if you have reordered the series this may result in the incorrect images being deleted.
</div> </div>
Start <input id="lower-truncation-bound-input" type="number" value="1"> <button id="set-lower-truncation-bound-button">Set</button><br/> Start <input id="lower-truncation-bound-input" type="number" value="1"> <button id="set-lower-truncation-bound-button">Set</button><br/>
End <input id="upper-truncation-bound-input"type="number" value="{{image_url_array_and_count.1}}"> <button id="set-upper-truncation-bound-button">Set</button><br/> End <input id="upper-truncation-bound-input"type="number" value="{{image_url_array_and_count.1}}"> <button id="set-upper-truncation-bound-button">Set</button><br/>
<button id="truncate-test-button">Test truncate</button> <button id="truncate-test-button">Test truncate</button>
@@ -145,23 +144,29 @@ Associated case:
{% endwith %} {% endwith %}
<div>Author: {{ series.get_author_display }}</div>
{% if can_edit %} {% if can_edit %}
<details> <details class="styled-detail">
<summary>Series info</summary> <summary>Series info</summary>
<div> <div class="d-flex flex-wrap gap-2 mb-2">
<a href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}" <button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_anonymise_dicom' pk=series.pk %}'" title="Anonymise dicom images">
title="Anonymise dicom images">Anonymise dicoms</a><br /> Anonymise dicoms
<a href="{% url 'atlas:series_order_dicom' pk=series.pk %}" title="orders dicom by slice location">Order dicoms </button>
by slice location</a> <button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_dicom' pk=series.pk %}'" title="orders dicom by slice location">
<a href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}" Order by slice location
title="orders dicom by instance number">Order dicoms by instance number</a> </button>
<a href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}" <button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_dicom_instance' pk=series.pk %}'" title="orders dicom by instance number">
title="orders dicom by instance number">Order dicoms by SeriesInstanceUID</a> Order by instance number
<a href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}" </button>
title="orders dicom by uploaded filename">Order by uploaded filename</a><br/> <button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}'" title="orders dicom by SeriesInstanceUID">
<a href="{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}" Order by SeriesInstanceUID
title="split series by dicom tag ImageType">Split the series by the ImageType dicom tag</a> </button>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_upload_filename' pk=series.pk %}'" title="orders dicom by uploaded filename">
Order by uploaded filename
</button>
<button type="button" class="btn btn-outline-warning btn-sm" onclick="window.location.href='{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}'" title="split series by dicom tag ImageType">
Split by ImageType tag
</button>
</div> </div>
<details> <details>
<summary> <summary>
@@ -172,45 +177,44 @@ Associated case:
> >
{% for image in series.get_images %} {% for image in series.get_images %}
[{{ image.id }}] <a href='{% url "atlas:series_image_dicom" image.pk %}'>{{image.image.url}}</a>, pos: {{image.position}}, {{image.upload_filename}} [{{ image.id }}] <a href='{% url "atlas:series_image_dicom" image.pk %}'>{{image.image.url}}</a>, pos: {{image.position}}, {{image.upload_filename}}
{% if image.image %} {% if image.image %}
[{{image.get_file_size|filesizeformat}}] [{{image.get_file_size|filesizeformat}}]
{% endif %} {% endif %}
{{image.image_md5_hash}} ({{image.is_dicom}}) {{image.image_blake3_hash}} {{image.image_md5_hash}} ({{image.is_dicom}}) {{image.image_blake3_hash}}
<input type="checkbox" name="image-id" value="{{image.id}}"> <input type="checkbox" name="image-id" value="{{image.id}}">
<br /> <br />
{% comment %} {{image.get_dicom_info|safe}}<br /> {% endcomment %}
{% endfor %} {% endfor %}
<button>Diff files</button> <button>Diff files</button>
</form> </form>
<span id="temp"></span> <span id="temp"></span>
<span id="diff-controls">show <span id="diff-show-all" _="on click
log '1'
for row in .diff.rows
show row
end">all</span> <span id="diff-show-diff" _="
on click
log 'go'
for row in <.diff tr/>
log row.children[0].innerHTML
if row.children[0].innerHTML is empty
hide row
end
end
">diff</span></span>
</details> </details>
<p>Total image size: {{series.get_total_image_size|filesizeformat}}</p> <p>Total image size: {{series.get_total_image_size|filesizeformat}}</p>
<p><a href="{% url 'atlas:series_download' pk=series.pk %}">Download images</a></p> <p>
<button type="button" class="btn btn-outline-success btn-sm" onclick="window.location.href='{% url 'atlas:series_download' pk=series.pk %}'">
Download images
</button>
</p>
</details> </details>
<span id="diff-controls">show <span id="diff-show-all" _="on click
log '1'
for row in .diff.rows
show row
end">all</span> <span id="diff-show-diff" _="
on click
log 'go'
for row in <.diff tr/>
log row.children[0].innerHTML
if row.children[0].innerHTML is empty
hide row
end
end
">diff</span></span>
{% endif %} {% endif %}
<div id="clone-findings-modal" <div id="clone-findings-modal"
class="modal modal-blur fade" class="modal modal-blur fade"
style="display: none" style="display: none"
@@ -386,4 +390,78 @@ Associated case:
#finding-form #hidden-form:focus-within { #finding-form #hidden-form:focus-within {
box-shadow: 0 0 24px #198754aa; box-shadow: 0 0 24px #198754aa;
} }
#diff-show-all {
color: red;
cursor: pointer;
}
#diff-show-diff {
color: #0d6efd;
cursor: pointer;
}
details.styled-detail {
background: #23272b;
border-radius: 0.7em;
margin: 1.2em 0;
padding: 1.2em 1.5em 1em 1.5em;
box-shadow: 0 2px 12px rgba(0,0,0,0.10);
border: 1px solid #343a40;
transition: box-shadow 0.2s;
}
details.styled-detail[open] {
box-shadow: 0 4px 24px rgba(13,110,253,0.10);
border-color: #0d6efd;
}
details.styled-detail > summary {
font-size: 1.15em;
font-weight: 600;
color: #0d6efd;
cursor: pointer;
margin: -1.2em -1.5em 1em -1.5em;
padding: 1.2em 1.5em 0.5em 1.5em;
border-bottom: 1px solid #343a40;
background: linear-gradient(90deg, #23272b 80%, #0d6efd22 100%);
border-radius: 0.7em 0.7em 0 0;
transition: background 0.2s, font-size 0.2s;
}
details.styled-detail:not([open]) {
padding-bottom: 0;
min-height: 0;
}
details.styled-detail:not([open]) > *:not(summary) {
display: none;
}
details.styled-detail:not([open]) > summary {
padding: 0.7em 1.2em 0.3em 1.2em;
}
details.styled-detail > summary:hover {
background: linear-gradient(90deg, #23272b 60%, #0d6efd44 100%);
color: #198754;
}
details.styled-detail > div, details.styled-detail > p, details.styled-detail > form, details.styled-detail > ul {
margin-top: 0.7em;
}
.alert {
margin-top: 1em;
}
/* Nested details */
details.styled-detail details {
background: #181a1b;
border: 1px solid #444;
margin: 0.7em 0;
box-shadow: none;
}
details.styled-detail details > summary {
color: #ffc107;
background: linear-gradient(90deg, #181a1b 80%, #ffc10722 100%);
border-bottom: 1px solid #444;
}
details.styled-detail details > summary:hover {
color: #0d6efd;
background: linear-gradient(90deg, #181a1b 60%, #0d6efd22 100%);
}
</style> </style>
+136 -71
View File
@@ -39,8 +39,8 @@
{% endif %} {% endif %}
<details><summary>Upload settings</summary> <details><summary>Upload settings</summary>
<form> <form>
<div> <div>
<fieldset><legend>Select how series should be ordered when uploaded</legend> <fieldset><legend>Select how series should be ordered when uploaded</legend>
<div class="helptext"> <div class="helptext">
This will affect how series are displayed when viewing with the built in site viewer. By default when exporting from Insight the order is not maintained so you should choose to order either by slice location or instance number.<br/> This will affect how series are displayed when viewing with the built in site viewer. By default when exporting from Insight the order is not maintained so you should choose to order either by slice location or instance number.<br/>
If you are not sure, leave this as the default. If you are not sure, leave this as the default.
@@ -57,8 +57,8 @@
<input type="radio" id="order-series-none" value="order-series-none" name="order-series" > <input type="radio" id="order-series-none" value="order-series-none" name="order-series" >
<label for="order-series-none">None</label><br/> <label for="order-series-none">None</label><br/>
</div> </div>
</fieldset> </fieldset>
</div> </div>
</form> </form>
</details> </details>
@@ -68,23 +68,16 @@
hx-confirm="This will clear selected uploads, Continue?" hx-confirm="This will clear selected uploads, Continue?"
title="Deleted selected uploads" title="Deleted selected uploads"
>Delete Uploads</button> >Delete Uploads</button>
{% if case %}
<button id="import-dicoms-case-button"
hx-post="{% url 'api-1:import_dicoms_case' case.id %}"
hx-target=".imported"
{% comment %} hx-indicator=".indicator" {% endcomment %}
hx-include=".selected,[name='order-series']"
>Import into case</button>
{% else %} <button id="import-dicoms-sequential-button"
<button id="import-dicoms-button" type="button"
hx-post="{% url 'api-1:import_dicoms' %}" class="btn btn-primary"
hx-target=".imported" >Import {% if case %}into case{% endif %} (one at a time)</button>
{% comment %} hx-indicator=".indicator" {% endcomment %}
hx-include=".selected,[name='order-series']" <div id="import-status"></div>
>Import</button>
{% endif %}
<div class="indicator"><div class="loader"></div>Loading...</div> <div class="indicator"><div class="loader"></div>Loading...</div>
<div id="import-status"></div>
<div class="imported"> <div class="imported">
@@ -96,66 +89,138 @@
<p><a href="{% url 'atlas:series_view' %}?case=null&sort=-modified_date">View orphan series</a></p> <p><a href="{% url 'atlas:series_view' %}?case=null&sort=-modified_date">View orphan series</a></p>
<script>
const importButton = document.getElementById('import-dicoms-sequential-button');
if (importButton) {
document.getElementById('import-dicoms-sequential-button').addEventListener('click', async function() {
const checkboxes = Array.from(document.querySelectorAll('input[name="selection"]:checked'));
const allCheckboxes = Array.from(document.querySelectorAll('input[name="selection"]'));
// Only include checkboxes that are not disabled (i.e., not already imported)
const selectableCheckboxes = allCheckboxes.filter(cb => !cb.disabled);
const toImport = checkboxes.length
? checkboxes.filter(cb => !cb.disabled)
: selectableCheckboxes;
if (toImport.length === 0) {
alert("Please select at least one series to import (not already imported).");
return;
}
const statusDiv = document.getElementById('import-status');
statusDiv.innerHTML = '';
const orderSeries = document.querySelector('input[name="order-series"]:checked').value;
const csrfToken = '{{ csrf_token }}';
{% if case %}
const importUrl = "{% url 'api-1:import_dicoms_case' case.id %}";
{% else %}
const importUrl = "{% url 'api-1:import_dicoms' %}";
{% endif %}
for (let i = 0; i < toImport.length; i++) {
const seriesId = toImport[i].value;
statusDiv.innerHTML += `<div id="import-status-${seriesId}">Importing series ${seriesId}...</div>`;
try {
const response = await fetch(importUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": csrfToken,
},
body: `selection=${encodeURIComponent(seriesId)}&order-series=${encodeURIComponent(orderSeries)}`
});
const text = await response.text();
document.getElementById(`import-status-${seriesId}`).innerHTML = `Series ${seriesId}: ${text}`;
// Mark as imported in the UI and make unselectable
const li = document.querySelector(`#series-list input[value="${seriesId}"]`)?.closest('li');
if (li && !li.classList.contains('imported')) {
li.classList.add('imported');
// Add badge if not already present
if (!li.querySelector('.badge.bg-success')) {
const badge = document.createElement('span');
badge.className = 'badge bg-success ms-2';
badge.textContent = 'Imported';
li.appendChild(badge);
}
// Disable checkbox and prevent further selection
const checkbox = li.querySelector('input[name="selection"]');
if (checkbox) {
checkbox.disabled = true;
checkbox.checked = false;
}
// Remove click handler for selection
li.querySelector('span').onclick = null;
li.style.pointerEvents = "none";
li.style.opacity = "0.7";
}
} catch (e) {
document.getElementById(`import-status-${seriesId}`).innerHTML = `Series ${seriesId}: <span style="color:red;">Failed</span>`;
}
}
statusDiv.innerHTML += "<div>All done.</div>";
});
}
</script>
{% endblock %} {% endblock %}
{% block css %} {% block css %}
<style> <style>
.indicator { .indicator {
display: none; display: none;
} }
.indicator.htmx-request { .indicator.htmx-request {
display: block; display: block;
} }
.loader { .loader {
width: 50px; width: 50px;
aspect-ratio: 1; aspect-ratio: 1;
display: grid; display: grid;
} }
.loader::before, .loader::before,
.loader::after { .loader::after {
content:""; content:"";
grid-area: 1/1; grid-area: 1/1;
--c:no-repeat radial-gradient(farthest-side,#25b09b 92%,#0000); --c:no-repeat radial-gradient(farthest-side,#25b09b 92%,#0000);
background: background:
var(--c) 50% 0, var(--c) 50% 0,
var(--c) 50% 100%, var(--c) 50% 100%,
var(--c) 100% 50%, var(--c) 100% 50%,
var(--c) 0 50%; var(--c) 0 50%;
background-size: 12px 12px; background-size: 12px 12px;
animation: l12 1s infinite; animation: l12 1s infinite;
} }
.loader::before { .loader::before {
margin: 4px; margin: 4px;
filter: hue-rotate(45deg); filter: hue-rotate(45deg);
background-size: 8px 8px; background-size: 8px 8px;
animation-timing-function: linear animation-timing-function: linear
} }
@keyframes l12 { @keyframes l12 {
100%{transform: rotate(.5turn)} 100%{transform: rotate(.5turn)}
} }
#series-list { #series-list {
user-select: none; user-select: none;
cursor: pointer; cursor: pointer;
} }
#series-list li { #series-list li {
} }
#series-list .selected { #series-list .selected {
color: white; color: white;
border: 1px solid #52057b; border: 1px solid #52057b;
user-select: none; user-select: none;
margin: -1px; margin: -1px;
}
.series-block-popup-link {
float: right;
}
#series-list li.imported {
border-color: #e6ffe6;
opacity: 0.7;
}
</style>
}
.series-block-popup-link {
float: right;
}
</style>
{% endblock css %} {% endblock css %}
+117
View File
@@ -0,0 +1,117 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>Viewer</title>
<style type="text/css">
body, html {
margin: 0; padding: 0; height: 100%; overflow: hidden;
}
#viewer {
position:absolute; left: 0; right: 0; bottom: 0; top: 0px;
}
/* Modal styles */
.center-modal {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
width: 100vw; height: 100vh;
background: rgba(20,20,30,0.96);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.center-modal-content {
background: #23232b;
color: #eee;
padding: 2em 2.5em;
border-radius: 12px;
box-shadow: 0 4px 32px rgba(0,0,0,0.4);
max-width: 90vw;
max-height: 80vh;
text-align: center;
font-size: 1.2em;
}
.highlight-action {
display: inline-block;
background: #0d6efd;
color: #fff;
font-weight: bold;
padding: 0.4em 1em;
border-radius: 6px;
margin: 0.7em 0 1.2em 0;
font-size: 1.3em;
box-shadow: 0 2px 8px rgba(13,110,253,0.15);
letter-spacing: 0.03em;
}
.center-modal-content ul {
color: #bdbdbd;
}
.center-modal-content h2 {
color: #fff;
}
</style>
<script src="{% static 'dv3d/index.js' %}" type="module" defer="defer"></script>
</head>
<body>
<!-- Centered modal for instructions -->
<div id="awaiting-modal" class="center-modal">
<div class="center-modal-content">
<span class="highlight-action">Share this screen</span>
<h2>Awaiting Load Request</h2>
<p>This page will display a DICOM series when loaded from another page.</p>
<ul style="text-align:left; margin: 1em auto; max-width: 30em;">
<li>Return to the previous page and select a series to view.</li>
<li>This viewer will update automatically when a series is loaded.</li>
<li>Keep this tab open to receive data.</li>
</ul>
</div>
</div>
<div id="root" class="dicom-viewer-root"
style="box-sizing: border-box; background: #222; position: absolute; top: 0; left: 0; right: 0; bottom: 0; width: 100vw; height: 100vh;"
data-auto-cache-stack="true"
></div>
<script type="text/javascript">
var win2 = false;
document.addEventListener("DOMContentLoaded", function() {
document.title = "Case: {{current_case.title}}";
console.log('ready');
const bc = new BroadcastChannel("viva_load");
bc.onmessage = (event) => {
console.log("message received");
console.log(event);
if (event.data.type == "open") {
// Hide modal when data is loaded
document.getElementById("awaiting-modal").style.display = "none";
document.getElementById("root").dataset.images = event.data.images;
if (
event.data.viewerstate &&
Object.keys(event.data.viewerstate).length > 0
) {
document.getElementById("root").dataset.viewerstate = event.data.viewerstate;
} else {
delete document.getElementById("root").dataset.viewerstate;
}
console.log("Annotations:", event.data.annotations);
if (
event.data.annotations &&
Object.keys(event.data.annotations).length > 0
) {
document.getElementById("root").dataset.annotationjson = event.data.annotations;
} else {
delete document.getElementById("root").dataset.annotationjson;
}
window.mountDicomViewers();
bc.postMessage({"type": "open-complete-local"});
}
};
});
</script>
</body>
</html>
-1
View File
@@ -37,7 +37,6 @@ def test_condition_tree_structure(db):
assert len(child2.get_parents()) == 1 assert len(child2.get_parents()) == 1
assert parent in child2.get_parents() assert parent in child2.get_parents()
print(parent.get_descendents())
#@pytest.fixture #@pytest.fixture
#def create_basic_user(db, django_user_model): #def create_basic_user(db, django_user_model):
+55
View File
@@ -70,6 +70,7 @@ urlpatterns = [
name="exam_update", name="exam_update",
), ),
path("collection/viewer", TemplateView.as_view(template_name="atlas/viewer.html"), name="collection_viewer"), path("collection/viewer", TemplateView.as_view(template_name="atlas/viewer.html"), name="collection_viewer"),
path("collection/viewer_local", TemplateView.as_view(template_name="atlas/viewer_local.html"), name="collection_viewer_local"),
path("collection/<int:pk>", views.collection_detail, name="collection_detail"), path("collection/<int:pk>", views.collection_detail, name="collection_detail"),
path("collection/<int:pk>/viva", views.collection_viva, name="collection_viva"), path("collection/<int:pk>/viva", views.collection_viva, name="collection_viva"),
path("collection/<int:pk>/viva/<int:case_number>", views.collection_viva_case, name="collection_viva_case"), path("collection/<int:pk>/viva/<int:case_number>", views.collection_viva_case, name="collection_viva_case"),
@@ -80,6 +81,16 @@ urlpatterns = [
name="collection_take_start", name="collection_take_start",
), ),
path("collection/<int:pk>/authors", views.CaseCollectionAuthorUpdate.as_view(), name="collection_authors"), path("collection/<int:pk>/authors", views.CaseCollectionAuthorUpdate.as_view(), name="collection_authors"),
path(
"collection/<int:pk>/toggle_results_published",
views.GenericExamViews.exam_toggle_results_published_htmx,
name="exam_toggle_results_published",
),
path(
"collection/<int:pk>/toggle_active",
views.GenericExamViews.exam_toggle_active_htmx,
name="exam_toggle_active",
),
path( path(
"collection/<int:exam_id>/cids", "collection/<int:exam_id>/cids",
views.GenericExamViews.exam_cids, views.GenericExamViews.exam_cids,
@@ -90,6 +101,28 @@ urlpatterns = [
views.collection_history, views.collection_history,
name="collection_history", name="collection_history",
), ),
path(
"collection/<int:pk>/cases/update",
views.CollectionCaseUpdate.as_view(),
name="collection_case_update",
),
path(
"collection/add_cases",
views.add_cases_to_collection,
name="add_cases_to_collection",
),
path(
"collection/remove_cases",
views.remove_cases_from_collection,
name="remove_cases_from_collection",
),
path("case/set_open_access", views.set_open_access, name="set_open_access"),
path("case/remove_open_access", views.remove_open_access, name="remove_open_access"),
path(
"collection/options",
views.collection_options,
name="collection_options",
),
path( path(
"collection/<int:exam_id>/history/<int:user_pk>/user", "collection/<int:exam_id>/history/<int:user_pk>/user",
views.collection_history_user, views.collection_history_user,
@@ -218,6 +251,11 @@ urlpatterns = [
views.collection_case_view, views.collection_case_view,
name="collection_case_view", name="collection_case_view",
), ),
path(
"collection/<int:collection_id>/<int:case_id>/display_setup",
views.collection_case_displaysetup,
name="collection_case_displaysetup",
),
path( path(
"collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>", "collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>",
views.collection_case_view_take, views.collection_case_view_take,
@@ -244,6 +282,11 @@ urlpatterns = [
name="collection_dicom_json", name="collection_dicom_json",
), ),
path("case/<int:pk>/authors", views.CaseAuthorUpdate.as_view(), name="case_authors"), path("case/<int:pk>/authors", views.CaseAuthorUpdate.as_view(), name="case_authors"),
path("case/<int:pk>/display_sets", views.case_displaysets, name="case_displaysets"),
path("case/<int:pk>/display_sets/add", views.case_displaysets_add, name="case_displaysets_add"),
path("<int:pk>/display_sets/edit", views.case_displaysets_edit, name="case_displaysets_edit"),
path("<int:pk>/display_sets/detail", views.case_displaysets_detail, name="case_displaysets_detail"),
path("<int:pk>/display_sets/delete", views.case_displaysets_delete, name="case_displaysets_delete"),
path( path(
"case/<int:pk>/dicom_json", "case/<int:pk>/dicom_json",
views.case_dicom_json, views.case_dicom_json,
@@ -259,6 +302,12 @@ urlpatterns = [
views.remove_case_from_collection, views.remove_case_from_collection,
name="remove_case_from_collection", name="remove_case_from_collection",
), ),
path(
"case/<int:case_pk>/series_remove/",
views.remove_selected_series_from_case,
name="remove_selected_series_from_case",
),
path('case/<int:case_pk>/reorder_series/', views.reorder_series, name='reorder_series'),
path( path(
"series/<int:pk>/dicom_json", "series/<int:pk>/dicom_json",
views.series_dicom_json, views.series_dicom_json,
@@ -266,6 +315,7 @@ urlpatterns = [
), ),
path('series/<int:series_pk>/add-to-case/', views.add_series_to_case, name='add_series_to_case'), path('series/<int:series_pk>/add-to-case/', views.add_series_to_case, name='add_series_to_case'),
path('series/<int:series_pk>/<int:case_pk>/remove-from-case/', views.remove_series_from_case, name='remove_series_from_case'), path('series/<int:series_pk>/<int:case_pk>/remove-from-case/', views.remove_series_from_case, name='remove_series_from_case'),
path('series/bulk-delete/', views.series_bulk_delete, name='series_bulk_delete'),
path('series_finding/<int:finding_id>/details/', views.series_finding_details, name='series_finding_details'), path('series_finding/<int:finding_id>/details/', views.series_finding_details, name='series_finding_details'),
path('series_finding/migration-status/', views.seriesfinding_migration_status, name='seriesfinding_migration_status'), path('series_finding/migration-status/', views.seriesfinding_migration_status, name='seriesfinding_migration_status'),
path("uncategorised_dicoms/", views.uncategorised_dicoms, name="uncategorised_dicoms_view"), path("uncategorised_dicoms/", views.uncategorised_dicoms, name="uncategorised_dicoms_view"),
@@ -412,6 +462,11 @@ urlpatterns = [
views.AtlasUpdate.as_view(), views.AtlasUpdate.as_view(),
name="case_update", name="case_update",
), ),
path(
"case/<int:pk>/update/series",
views.AtlasSeriesUpdate.as_view(),
name="case_series_update",
),
path( path(
"series/<int:pk>/update", "series/<int:pk>/update",
views.SeriesUpdate.as_view(), views.SeriesUpdate.as_view(),
+534 -83
View File
@@ -4,10 +4,14 @@ from typing import Any
from dal import autocomplete from dal import autocomplete
from django.db.models.base import Model as Model from django.db.models.base import Model as Model
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.db.models import Prefetch
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django import forms from django import forms
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.views.decorators.http import require_http_methods
from django.template.loader import render_to_string
from django.db.models import Count
# from django.contrib.auth.models import User # from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.decorators import login_required, user_passes_test
@@ -24,7 +28,7 @@ from django.views.generic import View
from django.views.generic.detail import DetailView from django.views.generic.detail import DetailView
import pydicom import pydicom
from pydicom.uid import generate_uid from pydicom.uid import generate_uid
from generic.mixins import SuperuserRequiredMixin from generic.mixins import SuperuserRequiredMixin, UserConfigurablePaginationMixin
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
from django.views.generic import ListView from django.views.generic import ListView
@@ -45,9 +49,12 @@ from .forms import (
CaseCollectionAuthorForm, CaseCollectionAuthorForm,
CaseCollectionCaseFormSet, CaseCollectionCaseFormSet,
CaseCollectionForm, CaseCollectionForm,
CaseCollectionUpdateCaseForm,
CaseDetailForm, CaseDetailForm,
CaseDisplaySetForm,
CaseForm, CaseForm,
CaseResourceFormSet, CaseResourceFormSet,
CaseUpdateSeriesForm,
CidQuestionAnswerForm, CidQuestionAnswerForm,
CidReportAnswerForm, CidReportAnswerForm,
CidReportAnswerMarkForm, CidReportAnswerMarkForm,
@@ -75,7 +82,9 @@ from .models import (
Case, Case,
CaseCollection, CaseCollection,
CaseDetail, CaseDetail,
CaseDisplaySet,
CasePrior, CasePrior,
CaseResource,
CidReportAnswer, CidReportAnswer,
Condition, Condition,
Differential, Differential,
@@ -87,6 +96,7 @@ from .models import (
Series, Series,
Examination, Examination,
Finding, Finding,
SeriesDetail,
Structure, Structure,
Subspecialty, Subspecialty,
SeriesFinding, SeriesFinding,
@@ -193,18 +203,148 @@ class AtlasEditorRequiredMixin(object):
return obj return obj
raise PermissionDenied("You must be an atlas editor to do this.") # or Http404 raise PermissionDenied("You must be an atlas editor to do this.") # or Http404
@login_required
@user_has_case_view_access
def case_displaysets(request, pk):
case = get_object_or_404(Case, pk=pk)
can_edit = case.check_user_can_edit(request.user)
# Get displayset id from GET parameter
displayset_id = request.GET.get("displayset")
edit = request.GET.get("edit", False)
selected_displayset = None
if displayset_id:
try:
selected_displayset = case.display_sets.get(pk=displayset_id)
except CaseDisplaySet.DoesNotExist:
selected_displayset = None
return render(
request,
"atlas/case_displaysets.html",
{
"case": case,
"edit": edit,
"can_edit": can_edit,
"selected_displayset": selected_displayset,
},
)
@login_required
@user_has_case_view_access
@require_http_methods(["GET", "POST"])
def case_displaysets_add(request, pk):
case = get_object_or_404(Case, pk=pk)
form = CaseDisplaySetForm(request.POST or None)
if request.method == "POST":
if form.is_valid():
ds = form.save(commit=False)
ds.case = case
ds.author = request.user
ds.viewerstate = request.POST.get("viewerstate") or None
ds.annotations = request.POST.get("annotations") or None
ds.save()
form.save_m2m()
# HTMX: return the new display set row HTML fragment
html = render_to_string("atlas/partials/displayset_row.html", {"ds": ds, "can_edit": True})
return HttpResponse(html)
else:
# HTMX: return the form HTML fragment with errors
html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request)
return HttpResponse(html, status=400)
else:
# HTMX: return the blank form HTML fragment
html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request)
return HttpResponse(html)
@login_required
@require_http_methods(["GET", "POST"])
def case_displaysets_edit(request, pk):
ds = get_object_or_404(CaseDisplaySet, pk=pk)
case = ds.case
if not case.check_user_can_edit(request.user):
return HttpResponse("You do not have permission to delete this display set.")
form = CaseDisplaySetForm(request.POST or None, instance=ds)
if request.method == "POST":
if form.is_valid():
ds = form.save(commit=False)
ds.viewerstate = request.POST.get("viewerstate") or ds.viewerstate
ds.annotations = request.POST.get("annotations") or ds.annotations
ds.save()
form.save_m2m()
# Return updated row HTML
html = render_to_string("atlas/partials/displayset_row.html", {"ds": ds, "can_edit": True}, request=request)
return HttpResponse(html)
else:
html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request)
return HttpResponse(html, status=400)
else:
html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request)
return HttpResponse(html)
@require_http_methods(["GET", "POST"])
def case_displaysets_detail(request, pk):
displayset = get_object_or_404(CaseDisplaySet, pk=pk)
case = displayset.case
# Redirect to the case_displaysets page with ?displayset=<pk>
return redirect(f"{reverse('atlas:case_displaysets', args=[case.pk])}?displayset={pk}")
def case_displaysets_delete(request, pk):
try:
displayset = CaseDisplaySet.objects.get(pk=pk)
except CaseDisplaySet.DoesNotExist:
raise Http404("Display set not found.")
if not displayset.case.check_user_can_edit(request.user):
raise PermissionDenied("You do not have permission to delete this display set.")
displayset.delete()
return HttpResponse("<li class='list-group-item' >Display set deleted successfully.")
@login_required @login_required
@user_has_case_view_access @user_has_case_view_access
def case_detail(request, pk): def case_detail(request, pk):
case = get_object_or_404(Case, pk=pk) # Prefetch all related objects needed for the template
case = (
# if request.user not in atlas.author.all(): Case.objects
# raise PermissionDenied .select_related() # Add any FK fields if needed
.prefetch_related(
Prefetch(
"seriesdetail_set",
queryset=SeriesDetail.objects.select_related("series").order_by("sort_order").prefetch_related(
Prefetch(
"series",
queryset=Series.objects.select_related("modality", "examination", "plane", "contrast")
.prefetch_related(
Prefetch(
"findings",
queryset=SeriesFinding.objects.prefetch_related(
"findings", "conditions", "structures"
)
),
Prefetch("images", queryset=SeriesImage.objects.filter(removed=False).order_by("position")),
)
),
),
),
Prefetch("subspecialty", queryset=Subspecialty.objects.all()),
Prefetch("condition", queryset=Condition.objects.all()),
Prefetch("presentation", queryset=Presentation.objects.all()),
Prefetch("pathological_process", queryset=PathologicalProcess.objects.all()),
Prefetch("caseresource_set", queryset=CaseResource.objects.all()),
Prefetch("casecollection_set", queryset=CaseCollection.objects.all()),
Prefetch("display_sets", queryset=CaseDisplaySet.objects.prefetch_related(
Prefetch("findings", queryset=Finding.objects.all()),
Prefetch("structures", queryset=Structure.objects.all()),
Prefetch("conditions", queryset=Condition.objects.all()),
)),
)
.get(pk=pk)
)
case.ordered_series = [sd.series for sd in case.seriesdetail_set.all()]
can_edit = case.check_user_can_edit(request.user) can_edit = case.check_user_can_edit(request.user)
# logging.debug(atlas.subspecialty.first().name.all())
return render( return render(
request, request,
"atlas/case_detail.html", "atlas/case_detail.html",
@@ -215,7 +355,6 @@ def case_detail(request, pk):
}, },
) )
@login_required @login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access @user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_thumbnail_fail(request, pk): def series_thumbnail_fail(request, pk):
@@ -498,6 +637,91 @@ def user_collections(request):
return render(request, "atlas/user_collections.html", {"collections": collections}) return render(request, "atlas/user_collections.html", {"collections": collections})
@login_required
def collection_options(request):
if not request.htmx:
return Http404
collections = CaseCollection.objects.filter(author=request.user).order_by("name")
html = render_to_string("atlas/partials/collection_options.html", {"collections": collections}, request=request)
return HttpResponse(html)
@login_required
def remove_cases_from_collection(request):
if not request.htmx:
return Http404
case_ids = request.POST.getlist('selection')
collection_id = request.POST.get('collection_id')
if not case_ids or not collection_id:
return HttpResponse("No cases selected or collection ID provided.")
collection = get_object_or_404(CaseCollection, pk=collection_id)
if request.user not in collection.author.all():
return HttpResponse("You do not have permission to remove cases from this collection.")
cases = Case.objects.filter(pk__in=case_ids)
for case_ in cases:
collection.cases.remove(case_)
return HttpResponse(f"Removed {len(cases)} cases from collection {collection.name} (refresh to see)")
@login_required
def add_cases_to_collection(request):
if not request.htmx:
return Http404
case_ids = request.POST.getlist('selection')
collection_id = request.POST.get('collection_id')
if not case_ids or not collection_id:
return HttpResponse("No cases selected or collection ID provided.")
collection = get_object_or_404(CaseCollection, pk=collection_id)
if request.user not in collection.author.all():
return HttpResponse("You do not have permission to add cases to this collection.")
cases = Case.objects.filter(pk__in=case_ids)
for case_ in cases:
collection.cases.add(case_)
return HttpResponse(f"Added {len(cases)} cases to collection {collection.name} (refresh to see)")
@login_required
def set_open_access(request):
if not request.htmx:
return Http404
case_ids = request.POST.getlist("selection")
if not case_ids:
return HttpResponse("No case ID(s) provided.")
cases = Case.objects.filter(pk__in=case_ids)
for case in cases:
# This will not let give accurate permission errors if the user does not have permission to edit any of the cases
# but it is simpler to just check the first case.
if not case.check_user_can_edit(request.user):
return HttpResponse(f"You do not have permission to set case {case.name} as open access.")
case.open_access = True
case.save()
return HttpResponse(f"Cases {', '.join([case.title for case in cases])} set to open access (refresh to see changes).")
@login_required
def remove_open_access(request):
if not request.htmx:
return Http404
case_ids = request.POST.getlist("selection")
if not case_ids:
return HttpResponse("No case ID(s) provided.")
cases = Case.objects.filter(pk__in=case_ids)
for case in cases:
# This will not let give accurate permission errors if the user does not have permission to edit any of the cases
# but it is simpler to just check the first case.
if not case.check_user_can_edit(request.user):
return HttpResponse(f"You do not have permission to remove open access from case {case.name}.")
case.open_access = False
case.save()
return HttpResponse(f"Cases {', '.join([case.title for case in cases])} removed from open access (refresh to see changes).")
def add_case_to_collection(request, collection_id): def add_case_to_collection(request, collection_id):
if not request.htmx: if not request.htmx:
@@ -507,9 +731,12 @@ def add_case_to_collection(request, collection_id):
if request.method == "POST": if request.method == "POST":
case = get_object_or_404(Case, pk=request.POST["case"]) case = get_object_or_404(Case, pk=request.POST["case"])
if request.user not in collection.author.all():
return HttpResponse("You do not have permission to add cases to this collection.")
if case in collection.cases.all(): if case in collection.cases.all():
return HttpResponse("Case already in collection") return HttpResponse("Case already in collection")
print(case) print(case)
collection.add_case(case) collection.add_case(case)
return HttpResponse(f"Case added to collection {collection.name} (refresh to see)") return HttpResponse(f"Case added to collection {collection.name} (refresh to see)")
@@ -800,19 +1027,19 @@ class CaseCollectionUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateVi
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(CaseCollectionUpdate, self).get_context_data(**kwargs) context = super(CaseCollectionUpdate, self).get_context_data(**kwargs)
if self.request.POST: #if self.request.POST:
context["case_formset"] = CaseCollectionCaseFormSet( # context["case_formset"] = CaseCollectionCaseFormSet(
self.request.POST, # self.request.POST,
self.request.FILES, # self.request.FILES,
instance=self.object, # instance=self.object,
form_kwargs={"user": self.request.user, "collection": self.object}, # form_kwargs={"user": self.request.user, "collection": self.object},
) # )
context["case_formset"].full_clean() # context["case_formset"].full_clean()
else: #else:
context["case_formset"] = CaseCollectionCaseFormSet( # context["case_formset"] = CaseCollectionCaseFormSet(
instance=self.object, # instance=self.object,
form_kwargs={"user": self.request.user, "collection": self.object}, # form_kwargs={"user": self.request.user, "collection": self.object},
) # )
context["collection"] = context["casecollection"] context["collection"] = context["casecollection"]
return context return context
@@ -823,6 +1050,7 @@ class CaseCollectionUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateVi
form.instance.author.add(self.request.user.id) form.instance.author.add(self.request.user.id)
return super().form_valid(form)
context = self.get_context_data(form=form) context = self.get_context_data(form=form)
case_formset = context["case_formset"] case_formset = context["case_formset"]
# logger.debug(formset.is_valid()) # logger.debug(formset.is_valid())
@@ -956,11 +1184,11 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(AtlasCreateBase, self).get_context_data(**kwargs) context = super(AtlasCreateBase, self).get_context_data(**kwargs)
if self.request.POST: if self.request.POST:
context["series_formset"] = SeriesFormSet( #context["series_formset"] = SeriesFormSet(
self.request.POST, # self.request.POST,
self.request.FILES, # self.request.FILES,
form_kwargs={"user": self.request.user}, # form_kwargs={"user": self.request.user},
) #)
context["casedifferential_formset"] = CaseDifferentialFormSet( context["casedifferential_formset"] = CaseDifferentialFormSet(
self.request.POST, self.request.FILES self.request.POST, self.request.FILES
) )
@@ -969,14 +1197,14 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
self.request.FILES, self.request.FILES,
form_kwargs={"user": self.request.user}, form_kwargs={"user": self.request.user},
) )
context["series_formset"].full_clean() #context["series_formset"].full_clean()
context["casedifferential_formset"].full_clean() context["casedifferential_formset"].full_clean()
context["caseresource_formset"].full_clean() context["caseresource_formset"].full_clean()
else: else:
# context["series_formset"] = SeriesFormSet(instance=Case.objects.filter(author__id=self.request.user.id), queryset=Series.objects.filter(author__id=self.request.user.id)) # context["series_formset"] = SeriesFormSet(instance=Case.objects.filter(author__id=self.request.user.id), queryset=Series.objects.filter(author__id=self.request.user.id))
context["series_formset"] = SeriesFormSet( #context["series_formset"] = SeriesFormSet(
form_kwargs={"user": self.request.user} # form_kwargs={"user": self.request.user}
) #)
context["casedifferential_formset"] = CaseDifferentialFormSet() context["casedifferential_formset"] = CaseDifferentialFormSet()
context["caseresource_formset"] = CaseResourceFormSet( context["caseresource_formset"] = CaseResourceFormSet(
form_kwargs={"user": self.request.user} form_kwargs={"user": self.request.user}
@@ -996,17 +1224,17 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
# CimarAPI().get_study_by_uuid(self.object.cimar_uuid) # CimarAPI().get_study_by_uuid(self.object.cimar_uuid)
context = self.get_context_data(form=form) context = self.get_context_data(form=form)
series_formset = context["series_formset"] #series_formset = context["series_formset"]
casedifferential_formset = context["casedifferential_formset"] casedifferential_formset = context["casedifferential_formset"]
caseresource_formset = context["caseresource_formset"] caseresource_formset = context["caseresource_formset"]
if ( if (
series_formset.is_valid() #series_formset.is_valid()
and casedifferential_formset.is_valid() casedifferential_formset.is_valid()
and caseresource_formset.is_valid() and caseresource_formset.is_valid()
): ):
response = super().form_valid(form) response = super().form_valid(form)
series_formset.instance = self.object #series_formset.instance = self.object
series_formset.save() #series_formset.save()
casedifferential_formset.instance = self.object casedifferential_formset.instance = self.object
casedifferential_formset.save() casedifferential_formset.save()
caseresource_formset.instance = self.object caseresource_formset.instance = self.object
@@ -1023,6 +1251,7 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
# @login_required # @login_required
# CaseCreate
class AtlasCreate(AtlasCreateBase, CreateView): class AtlasCreate(AtlasCreateBase, CreateView):
def get_initial(self): def get_initial(self):
if "pk" in self.kwargs: if "pk" in self.kwargs:
@@ -1041,6 +1270,7 @@ class AtlasCreate(AtlasCreateBase, CreateView):
# return self.initial # return self.initial
# CaseUpdate
class AtlasUpdate( class AtlasUpdate(
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
): ):
@@ -1052,6 +1282,25 @@ class AtlasUpdate(
# #initial = {'date_of_death': '05/01/2018'} # #initial = {'date_of_death': '05/01/2018'}
# exclude = [ 'created_date', 'published_date' ] # exclude = [ 'created_date', 'published_date' ]
#def get_object(self, queryset=None):
# # Prefetch all related objects needed for the form and formsets
# qs = self.model.objects.select_related(
# # Add all FK fields here
# ).prefetch_related(
# "series__examination",
# "series__plane",
# "series__contrast",
# "series__images",
# "subspecialty",
# "condition",
# "presentation",
# "pathological_process",
# "differentialcase",
# "caseresource_set",
# )
# return qs.get(pk=self.kwargs["pk"])
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super(AtlasUpdate, self).get_form_kwargs() kwargs = super(AtlasUpdate, self).get_form_kwargs()
kwargs.update({"user": self.request.user}) kwargs.update({"user": self.request.user})
@@ -1060,12 +1309,12 @@ class AtlasUpdate(
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(AtlasUpdate, self).get_context_data(**kwargs) context = super(AtlasUpdate, self).get_context_data(**kwargs)
if self.request.POST: if self.request.POST:
context["series_formset"] = SeriesFormSet( #context["series_formset"] = SeriesFormSet(
self.request.POST, # self.request.POST,
self.request.FILES, # self.request.FILES,
instance=self.object, # instance=self.object,
form_kwargs={"user": self.request.user}, # form_kwargs={"user": self.request.user},
) #)
context["casedifferential_formset"] = CaseDifferentialFormSet( context["casedifferential_formset"] = CaseDifferentialFormSet(
self.request.POST, self.request.FILES, instance=self.object self.request.POST, self.request.FILES, instance=self.object
) )
@@ -1075,13 +1324,13 @@ class AtlasUpdate(
instance=self.object, instance=self.object,
form_kwargs={"user": self.request.user}, form_kwargs={"user": self.request.user},
) )
context["series_formset"].full_clean() #context["series_formset"].full_clean()
context["casedifferential_formset"].full_clean() context["casedifferential_formset"].full_clean()
context["caseresource_formset"].full_clean() context["caseresource_formset"].full_clean()
else: else:
context["series_formset"] = SeriesFormSet( #context["series_formset"] = SeriesFormSet(
instance=self.object, form_kwargs={"user": self.request.user} # instance=self.object, form_kwargs={"user": self.request.user}
) #)
context["casedifferential_formset"] = CaseDifferentialFormSet( context["casedifferential_formset"] = CaseDifferentialFormSet(
instance=self.object instance=self.object
) )
@@ -1098,18 +1347,18 @@ class AtlasUpdate(
form.instance.author.add(self.request.user.id) form.instance.author.add(self.request.user.id)
context = self.get_context_data(form=form) context = self.get_context_data(form=form)
series_formset = context["series_formset"] #series_formset = context["series_formset"]
casedifferential_formset = context["casedifferential_formset"] casedifferential_formset = context["casedifferential_formset"]
caseresource_formset = context["caseresource_formset"] caseresource_formset = context["caseresource_formset"]
# logger.debug(formset.is_valid()) # logger.debug(formset.is_valid())
if ( if (
series_formset.is_valid() #series_formset.is_valid()
and casedifferential_formset.is_valid() casedifferential_formset.is_valid()
and caseresource_formset.is_valid() and caseresource_formset.is_valid()
): ):
response = super().form_valid(form) response = super().form_valid(form)
series_formset.instance = self.object #series_formset.instance = self.object
series_formset.save() #series_formset.save()
casedifferential_formset.instance = self.object casedifferential_formset.instance = self.object
casedifferential_formset.save() casedifferential_formset.save()
caseresource_formset.instance = self.object caseresource_formset.instance = self.object
@@ -1119,6 +1368,110 @@ class AtlasUpdate(
else: else:
return super().form_invalid(form) return super().form_invalid(form)
class CollectionCaseUpdate(
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView):
model = CaseCollection
form_class = CaseCollectionUpdateCaseForm
template_name = "atlas/casecollection_update_case.html"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs.update({"user": self.request.user, "collection": self.object})
return kwargs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.POST:
context["case_formset"] = CaseCollectionCaseFormSet(
self.request.POST,
self.request.FILES,
instance=self.object,
form_kwargs={"user": self.request.user, "collection": self.object},
)
context["case_formset"].full_clean()
else:
context["case_formset"] = CaseCollectionCaseFormSet(
instance=self.object,
form_kwargs={"user": self.request.user, "collection": self.object},
)
context["collection"] = context["casecollection"]
return context
def form_valid(self, form):
context = self.get_context_data(form=form)
case_formset = context["case_formset"]
if case_formset.is_valid():
case_formset.instance = self.object
case_formset.save()
return super().form_valid(form)
else:
return self.form_invalid(form)
class AtlasSeriesUpdate(
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
):
model = Case
form_class = CaseUpdateSeriesForm
template_name = "atlas/case_update_series.html"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
is_atlas_editor = self.request.user.groups.filter(name="atlas_editor").exists() or self.request.user.is_superuser
form_kwargs = {"user": self.request.user, "is_atlas_editor": is_atlas_editor}
if not is_atlas_editor:
queryset = Series.objects.filter(author__id=self.request.user.id)
else:
queryset = Series.objects.all()
queryset = queryset.select_related("modality").prefetch_related(
"examination", "plane", "contrast",
Prefetch("images", queryset=SeriesImage.objects.filter(removed=False).order_by("position"))
)
if self.request.POST:
context["series_formset"] = SeriesFormSet(
self.request.POST,
self.request.FILES,
queryset=queryset,
instance=self.object,
form_kwargs=form_kwargs,
)
context["series_formset"].full_clean()
else:
context["series_formset"] = SeriesFormSet(
queryset=queryset,
instance=self.object,
form_kwargs=form_kwargs,
)
return context
def form_valid(self, form):
context = self.get_context_data(form=form)
series_formset = context["series_formset"]
if series_formset.is_valid():
series_formset.instance = self.object
series_formset.save()
return super().form_valid(form)
else:
return self.form_invalid(form)
def get_object(self, queryset=None):
qs = Case.objects.select_related().prefetch_related(
"seriesdetail_set__series__modality",
"seriesdetail_set__series__examination",
"seriesdetail_set__series__plane",
"seriesdetail_set__series__contrast",
"seriesdetail_set__series__images",
)
return qs.get(pk=self.kwargs["pk"])
def add_collection_to_case_form(request, case_id): def add_collection_to_case_form(request, case_id):
if request.method == "POST": if request.method == "POST":
@@ -1230,7 +1583,7 @@ def get_examination_id(request):
return HttpResponse("/") return HttpResponse("/")
class CaseView(LoginRequiredMixin, SingleTableMixin, FilterView): class CaseView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Case model = Case
table_class = CaseTable table_class = CaseTable
template_name = "atlas/case_view.html" template_name = "atlas/case_view.html"
@@ -1238,7 +1591,7 @@ class CaseView(LoginRequiredMixin, SingleTableMixin, FilterView):
filterset_class = CaseFilter filterset_class = CaseFilter
class SeriesView(LoginRequiredMixin, SingleTableMixin, FilterView): class SeriesView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Series model = Series
table_class = SeriesTable table_class = SeriesTable
template_name = "atlas/series_view.html" template_name = "atlas/series_view.html"
@@ -1246,7 +1599,7 @@ class SeriesView(LoginRequiredMixin, SingleTableMixin, FilterView):
filterset_class = SeriesFilter filterset_class = SeriesFilter
class ConditionView(LoginRequiredMixin, SingleTableMixin, FilterView): class ConditionView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Condition model = Condition
table_class = ConditionTable table_class = ConditionTable
template_name = "atlas/view.html" template_name = "atlas/view.html"
@@ -1254,7 +1607,7 @@ class ConditionView(LoginRequiredMixin, SingleTableMixin, FilterView):
filterset_class = ConditionFilter filterset_class = ConditionFilter
class QuestionSchemaView(LoginRequiredMixin, SingleTableMixin, FilterView): class QuestionSchemaView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = QuestionSchema model = QuestionSchema
table_class = QuestionSchemaTable table_class = QuestionSchemaTable
template_name = "atlas/schema_table_view.html" template_name = "atlas/schema_table_view.html"
@@ -1262,7 +1615,7 @@ class QuestionSchemaView(LoginRequiredMixin, SingleTableMixin, FilterView):
filterset_class = QuestionSchemaFilter filterset_class = QuestionSchemaFilter
class SubspecialtyView(LoginRequiredMixin, SingleTableMixin, FilterView): class SubspecialtyView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Subspecialty model = Subspecialty
table_class = SubspecialtyTable table_class = SubspecialtyTable
template_name = "atlas/view.html" template_name = "atlas/view.html"
@@ -1270,7 +1623,7 @@ class SubspecialtyView(LoginRequiredMixin, SingleTableMixin, FilterView):
filterset_class = SubspecialtyFilter filterset_class = SubspecialtyFilter
class PresentationView(LoginRequiredMixin, SingleTableMixin, FilterView): class PresentationView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Presentation model = Presentation
table_class = PresentationTable table_class = PresentationTable
template_name = "atlas/view.html" template_name = "atlas/view.html"
@@ -1278,7 +1631,7 @@ class PresentationView(LoginRequiredMixin, SingleTableMixin, FilterView):
filterset_class = PresentationFilter filterset_class = PresentationFilter
class PathologicalProcessView(LoginRequiredMixin, SingleTableMixin, FilterView): class PathologicalProcessView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = PathologicalProcess model = PathologicalProcess
table_class = PathologicalProcessTable table_class = PathologicalProcessTable
template_name = "atlas/view.html" template_name = "atlas/view.html"
@@ -1286,7 +1639,7 @@ class PathologicalProcessView(LoginRequiredMixin, SingleTableMixin, FilterView):
filterset_class = PathologicalProcessFilter filterset_class = PathologicalProcessFilter
class StructureView(LoginRequiredMixin, SingleTableMixin, FilterView): class StructureView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Structure model = Structure
table_class = StructureTable table_class = StructureTable
template_name = "atlas/view.html" template_name = "atlas/view.html"
@@ -1294,7 +1647,7 @@ class StructureView(LoginRequiredMixin, SingleTableMixin, FilterView):
filterset_class = StructureFilter filterset_class = StructureFilter
class FindingView(LoginRequiredMixin, SingleTableMixin, FilterView): class FindingView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Finding model = Finding
table_class = FindingTable table_class = FindingTable
template_name = "atlas/view.html" template_name = "atlas/view.html"
@@ -1305,7 +1658,7 @@ class FindingView(LoginRequiredMixin, SingleTableMixin, FilterView):
@login_required @login_required
def case_order_dicom(request, pk): def case_order_dicom(request, pk):
if request.htmx: if request.htmx:
case = get_object_or_404(Case, pk=pk) case: Case = get_object_or_404(Case, pk=pk)
fail = [] fail = []
# TODO: don't try to order non stack series # TODO: don't try to order non stack series
@@ -1314,9 +1667,9 @@ def case_order_dicom(request, pk):
if request.user in series.get_author_objects(): if request.user in series.get_author_objects():
series.order_by_dicom() series.order_by_dicom()
else: else:
fail.append(series.pk) fail.append(f"{series.pk}: invalid permission")
except: except:
fail.append(series.pk) fail.append(f"{series.pk}: error")
if fail: if fail:
return HttpResponse( return HttpResponse(
@@ -1349,12 +1702,12 @@ def image_diff(request):
first: SeriesImage = get_object_or_404(SeriesImage, pk=image_ids[0]) first: SeriesImage = get_object_or_404(SeriesImage, pk=image_ids[0])
second: SeriesImage = get_object_or_404(SeriesImage, pk=image_ids[1]) second: SeriesImage = get_object_or_404(SeriesImage, pk=image_ids[1])
first_ds = pydicom.read_file(first.image.file) first_ds = pydicom.dcmread(first.image.file)
# del first_ds.PixelData # del first_ds.PixelData
# j1 = first_ds.to_json_dict() # j1 = first_ds.to_json_dict()
f = print_dicom(first_ds, join=False) f = print_dicom(first_ds, join=False)
second_ds = pydicom.read_file(second.image.file) second_ds = pydicom.dcmread(second.image.file)
# del second_ds.PixelData # del second_ds.PixelData
# j2 = second_ds.to_json_dict() # j2 = second_ds.to_json_dict()
s = print_dicom(second_ds, join=False) s = print_dicom(second_ds, join=False)
@@ -1371,7 +1724,7 @@ def image_diff(request):
ds_set = [] ds_set = []
for pk in image_ids: for pk in image_ids:
series_image = get_object_or_404(SeriesImage, pk=pk) series_image = get_object_or_404(SeriesImage, pk=pk)
ds_set.append(pydicom.read_file(series_image.image.file)) ds_set.append(pydicom.dcmread(series_image.image.file))
dict_diff = pprint.pformat(compare_dicom_datasets(*ds_set)) dict_diff = pprint.pformat(compare_dicom_datasets(*ds_set))
return HttpResponse(f"<pre>{dict_diff}</pre>") return HttpResponse(f"<pre>{dict_diff}</pre>")
@@ -1611,22 +1964,39 @@ class CollectionView(LoginRequiredMixin, SingleTableMixin, FilterView):
def collection_viva(request, pk): def collection_viva(request, pk):
collection = get_object_or_404(CaseCollection, pk=pk) collection = get_object_or_404(CaseCollection, pk=pk)
cases = ( #cases = (
collection.cases.all() # collection.cases.all()
.order_by("casedetail__sort_order") # .order_by("casedetail__sort_order")
# .prefetch_related(
# "series",
# "series__images",
# "series__contrast",
# "series__plane",
# "series__examination",
# )
#)
casedetails = (
CaseDetail.objects
.filter(collection=collection)
.select_related("case")
.order_by("sort_order")
.prefetch_related( .prefetch_related(
"series", # Prefetch series and their images directly on the case
"series__images", "case__series",
"series__contrast", "case__series__modality",
"series__plane", "case__series__examination",
"series__examination", "case__series__plane",
"case__series__contrast",
"case__series__images",
) )
) )
return render( return render(
request, request,
"atlas/collection_viva.html", "atlas/collection_viva.html",
{"collection": collection, "cases": cases, "can_edit": True}, {"collection": collection, "casedetails": casedetails, "can_edit": True},
) )
@@ -1662,14 +2032,18 @@ def collection_detail(request, pk):
collection = get_object_or_404(CaseCollection, pk=pk) collection = get_object_or_404(CaseCollection, pk=pk)
# cases = collection.cases.all().order_by("casedetail__sort_order") # cases = collection.cases.all().order_by("casedetail__sort_order")
casedetails = CaseDetail.objects.filter(collection=collection).order_by( casedetails = (
"sort_order" CaseDetail.objects
.filter(collection=collection)
.select_related("case")
.annotate(series_count=Count("case__seriesdetail"))
.order_by("sort_order")
) )
return render( return render(
request, request,
"atlas/collection_detail.html", "atlas/collection_detail.html",
{"collection": collection, "casesdetails": casedetails, "can_edit": True}, {"collection": collection, "casesdetails": casedetails, "can_edit": True, "exam": collection},
) )
@@ -1727,7 +2101,9 @@ def collection_history_user(request, exam_id: int, user_pk: int):
) )
def collection_take_start(request, pk):
def collection_take_start(request, pk, cid=None, passcode=None):
"""The starting page for taking / reviewing a case collection """The starting page for taking / reviewing a case collection
Args: Args:
@@ -1737,13 +2113,18 @@ def collection_take_start(request, pk):
Returns: Returns:
_type_: _description_ _type_: _description_
""" """
print(f"Collection take start: {pk}, cid: {cid}, passcode: {passcode}")
collection = get_object_or_404(CaseCollection, pk=pk) collection = get_object_or_404(CaseCollection, pk=pk)
print(f"Collection: {collection}")
try:
collection.check_user_can_take(cid, passcode, request.user)
valid_user = True
except Http404:
valid_user = False
#valid_user = collection.check_logged_in_user(request)
valid_user = collection.check_logged_in_user(request)
# for now we only allow registered users....
if not valid_user:
raise PermissionDenied
cid_exam = None cid_exam = None
if collection.collection_type == "REV" or valid_user: if collection.collection_type == "REV" or valid_user:
@@ -3084,6 +3465,36 @@ def remove_case_from_collection(request, case_pk, collection_pk):
return HttpResponse(f"Case removed from collection: {collection.name}") return HttpResponse(f"Case removed from collection: {collection.name}")
@require_POST
def remove_selected_series_from_case(request, case_pk):
ids = request.POST.getlist('series-ids')
case = Case.objects.get(pk=case_pk)
if not case.check_user_can_edit(request.user):
return HttpResponse("You do not have permission to edit this case.", status=403)
if not ids:
return HttpResponse("No series selected for removal.")
# Remove the association (for a through model)
SeriesDetail.objects.filter(case=case, series_id__in=ids).delete()
return HttpResponse(f"Series removed from case.")
@require_POST
def reorder_series(request, case_pk):
case = get_object_or_404(Case, pk=case_pk)
if not case.check_user_can_edit(request.user):
return HttpResponse("You do not have permission to edit this case.", status=403)
# Get new order from POST
order = []
for key in sorted([k for k in request.POST if k.startswith("series_order_")]):
order.append(int(request.POST[key]))
# Update sort_order in SeriesDetail
for idx, series_id in enumerate(order):
SeriesDetail.objects.filter(case=case, series_id=series_id).update(sort_order=idx)
return redirect(request.META.get("HTTP_REFERER", "/"))
def linked_cases_overview(request, case_id): def linked_cases_overview(request, case_id):
""" """
View to display an overview of linked cases via the previous_case field. View to display an overview of linked cases via the previous_case field.
@@ -3181,4 +3592,44 @@ def seriesfinding_migration_status(request):
}) })
return render(request, "atlas/seriesfinding_migration_status.html", { return render(request, "atlas/seriesfinding_migration_status.html", {
"findings_status": findings_status "findings_status": findings_status
}) })
def series_bulk_delete(request):
if request.method == "POST":
ids = request.POST.getlist("series_ids")
# Only delete series the user has permission to delete
series_to_delete = Series.objects.filter(pk__in=ids)
deleted_ids = []
for series in series_to_delete:
if series.check_user_can_edit(request.user):
series.delete()
deleted_ids.append(series.pk)
# Optionally, you could add a message here about which were deleted
return redirect('atlas:series_view')
@user_is_collection_author_or_atlas_editor
@csrf_exempt # Only if you have CSRF issues; otherwise, keep CSRF protection
def collection_case_displaysetup(request, collection_id, case_id):
"""
View to set up the display options for a specific case in a collection.
This can include setting which series to display, annotations, etc.
"""
case_detail = get_object_or_404(CaseDetail, case__pk=case_id, collection__pk=collection_id)
if request.method == "POST":
data = json.loads(request.body.decode("utf-8"))
if data.get("reset_viewerstate"):
case_detail.default_viewerstate = None
case_detail.save()
return HttpResponse("<span class='text-success'>Viewer state reset to default.</span>")
viewerstate = data.get("viewerstate")
if viewerstate is not None:
case_detail.default_viewerstate = viewerstate
case_detail.save()
return HttpResponse("<span class='text-success'>Viewer state saved.</span>")
else:
return HttpResponse("<span class='text-danger'>No viewer state provided.</span>", status=400)
return render(request, "atlas/collection_case_displaysetup.html", {
"case_detail": case_detail
})
+12 -1
View File
@@ -54,6 +54,10 @@ from rapids.models import Exam as RapidsExam
from rapids.models import Rapid from rapids.models import Rapid
from rapids.models import Answer as RapidAnswer from rapids.models import Answer as RapidAnswer
from shorts.models import UserAnswer as ShortsUserAnswer
from shorts.models import Exam as ShortsExam
from shorts.models import Question as ShortsQuestion
from longs.models import UserAnswer as LongsUserAnswer from longs.models import UserAnswer as LongsUserAnswer
from longs.models import Exam as LongsExam from longs.models import Exam as LongsExam
from longs.models import Long from longs.models import Long
@@ -70,6 +74,7 @@ from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div
from crispy_forms.bootstrap import Accordion, AccordionGroup from crispy_forms.bootstrap import Accordion, AccordionGroup
from dal import autocomplete from dal import autocomplete
from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register
class SplitDateTimeFieldDefaultTime(SplitDateTimeField): class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
@@ -497,6 +502,7 @@ class UserGroupExamForm(ModelForm):
GROUP_TYPES = [ GROUP_TYPES = [
"anatomy_user_user_groups", "anatomy_user_user_groups",
"rapid_user_user_groups", "rapid_user_user_groups",
"shorts_user_user_groups",
"longs_user_user_groups", "longs_user_user_groups",
"physics_user_user_groups", "physics_user_user_groups",
"sba_user_user_groups", "sba_user_user_groups",
@@ -537,6 +543,11 @@ class UserGroupExamForm(ModelForm):
queryset=RapidsExam.objects.filter(archive=False), queryset=RapidsExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name="Rapid Exams", is_stacked=False), widget=FilteredSelectMultiple(verbose_name="Rapid Exams", is_stacked=False),
) )
self.fields["shorts_user_user_groups"] = ModelMultipleChoiceField(
required=False,
queryset=ShortsExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name="Shorts Exams", is_stacked=False),
)
self.fields["longs_user_user_groups"] = ModelMultipleChoiceField( self.fields["longs_user_user_groups"] = ModelMultipleChoiceField(
required=False, required=False,
queryset=LongsExam.objects.filter(archive=False), queryset=LongsExam.objects.filter(archive=False),
@@ -736,6 +747,7 @@ class SupervisorForm(ModelForm):
class ExamCollectionForm(ModelForm): class ExamCollectionForm(ModelForm):
GROUP_TYPES = ( GROUP_TYPES = (
("Rapid Exams", "rapids_exams", RapidsExam), ("Rapid Exams", "rapids_exams", RapidsExam),
("Shorts Exams", "shorts_exams", ShortsExam),
("Long Exams", "longs_exams", LongsExam), ("Long Exams", "longs_exams", LongsExam),
("SBA Exams", "sbas_exams", SbasExam), ("SBA Exams", "sbas_exams", SbasExam),
("Physic Exams", "physics_exams", PhysicsExam), ("Physic Exams", "physics_exams", PhysicsExam),
@@ -808,7 +820,6 @@ class ExamCollectionCloneForm(ModelForm):
model = ExamCollection model = ExamCollection
fields = ("name", "date") fields = ("name", "date")
from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register
class UserAutocomplete(ModelAutocomplete): class UserAutocomplete(ModelAutocomplete):
model = User model = User
+19
View File
@@ -62,3 +62,22 @@ class AuthorMixin():
def remove_author(self, user: User): def remove_author(self, user: User):
"""Remove an author from the object""" """Remove an author from the object"""
self.author.remove(user) self.author.remove(user)
class UserConfigurablePaginationMixin:
default_per_page = 25
allowed_per_page = [25, 50, 100, 250, 500]
def get_table_pagination(self, table):
try:
per_page = int(self.request.GET.get("per_page", self.default_per_page))
if per_page not in self.allowed_per_page:
per_page = self.default_per_page
except (TypeError, ValueError):
per_page = self.default_per_page
return {"per_page": per_page}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["page_sizes"] = [25, 50, 100, 250, 500]
return context
+45 -50
View File
@@ -58,6 +58,17 @@ import requests
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
USER_EXAM_TYPES = (
("Physics", "user_physics_exams"),
("Rapids", "user_rapid_exams"),
("Shorts", "user_shorts_exams"),
("SBAs", "user_sba_exams"),
("Anatomy", "user_anatomy_exams"),
("Longs", "user_longs_exams"),
# ("CaseCollection", "user_casecollection_exams"),
)
def get_pretty_json(data): def get_pretty_json(data):
"""Function to display pretty version of our data""" """Function to display pretty version of our data"""
if not data: if not data:
@@ -279,7 +290,7 @@ class SeriesImageBase(models.Model):
# The first is a md5 hash of the pixel array data # The first is a md5 hash of the pixel array data
# this is used because I can't get the direct pixel data to match from cornerstone # this is used because I can't get the direct pixel data to match from cornerstone
# (and struggle to get blake3 working in the browser) # (and struggle to get blake3 working in the browser)
image_md5_hash = models.CharField(max_length=32, null=True, blank=True) image_md5_hash = models.CharField(max_length=64, null=True, blank=True)
# The second is a blake3 hash of the direct pixel data # The second is a blake3 hash of the direct pixel data
# This is much faster when using pydicom # This is much faster when using pydicom
@@ -316,14 +327,14 @@ class SeriesImageBase(models.Model):
def get_dicom_json(self): def get_dicom_json(self):
try: try:
json = pydicom.read_file(self.image).to_json() json = pydicom.dcmread(self.image).to_json()
except pydicom.errors.InvalidDicomError: except pydicom.errors.InvalidDicomError:
return {} return {}
return json return json
def get_dicom_info(self): def get_dicom_info(self):
try: try:
info = pretty_print_dicom(pydicom.read_file(self.image)) info = pretty_print_dicom(pydicom.dcmread(self.image))
except pydicom.errors.InvalidDicomError: except pydicom.errors.InvalidDicomError:
info = "File is not a dicom." info = "File is not a dicom."
return info return info
@@ -496,7 +507,12 @@ class SeriesBase(models.Model):
return format_html('", "'.join(images)) return format_html('", "'.join(images))
def get_thumbnail(self, recreate=False, fail_loudly=False): def get_thumbnail(self, recreate=False, fail_loudly=False):
images = self.images.filter(removed=False) # Use prefetched images if available
images = getattr(self, '_prefetched_objects_cache', {}).get('images')
if images is not None:
images = [img for img in images if not img.removed]
else:
images = self.images.filter(removed=False)
if len(images) < 1: if len(images) < 1:
return "No images", 0 return "No images", 0
@@ -568,48 +584,35 @@ class SeriesBase(models.Model):
n = n + 1 n = n + 1
def order_by_dicom(self, field="SliceLocation"): def order_by_dicom(self, field="SliceLocation"):
print(f"Ordering by dicom field {field}") """
Orders images in the series by the given DICOM field (default: SliceLocation).
Only keeps minimal data in memory and uses bulk_update for efficiency.
"""
images = self.images.filter(removed=False) images = self.images.filter(removed=False)
sortable = []
for img in images:
try:
ds = pydicom.dcmread(img.image.path, stop_before_pixels=True)
value = getattr(ds, field, None)
if value is not None:
sortable.append((value, img))
except Exception:
continue # Skip unreadable or invalid DICOMs
files = [] if not sortable:
for i in images:
files.append((i, pydicom.dcmread(i.image.path)))
print("images to order")
print(files)
# print("file count: {}".format(len(files)))
# skip files with no SliceLocation (eg scout views)
slices = []
map = {}
skipcount = 0
for i, f in files:
if hasattr(f, field):
slices.append(f)
# map[f.SliceLocation] = i
map[f[field].value] = i
else:
skipcount = skipcount + 1
print("skipped, no {}: {}".format(field, skipcount))
if not slices:
return return
# ensure they are in the correct order # Sort by the DICOM field value
slices = sorted(slices, key=lambda s: s[field].value) sortable.sort(key=lambda x: x[0])
# print(slices) # Assign new positions and collect for bulk update
n = 1 for idx, (value, img) in enumerate(sortable, start=1):
for f in slices: img.position = idx
i = map[f[field].value]
i.position = n # Bulk update all changed images
print(f"Setting position {n} for {i}") SeriesImage = self.images.model
i.save() SeriesImage.objects.bulk_update([img for _, img in sortable], ['position'])
n = n + 1
def get_total_image_size(self, refresh=False): def get_total_image_size(self, refresh=False):
"""Returns the total size of all images in the series. """Returns the total size of all images in the series.
@@ -1614,6 +1617,7 @@ CID_GROUP_EXAMS = (
("Physics", "physics_cid_user_groups"), ("Physics", "physics_cid_user_groups"),
("Anatomy", "anatomy_cid_user_groups"), ("Anatomy", "anatomy_cid_user_groups"),
("Rapids", "rapid_cid_user_groups"), ("Rapids", "rapid_cid_user_groups"),
("Shorts", "shorts_cid_user_groups"),
("Longs", "longs_cid_user_groups"), ("Longs", "longs_cid_user_groups"),
) )
@@ -1661,6 +1665,7 @@ USER_GROUP_EXAMS = (
("Physics", "physics_user_user_groups"), ("Physics", "physics_user_user_groups"),
("Anatomy", "anatomy_user_user_groups"), ("Anatomy", "anatomy_user_user_groups"),
("Rapids", "rapid_user_user_groups"), ("Rapids", "rapid_user_user_groups"),
("Shorts", "shorts_user_user_groups"),
("Longs", "longs_user_user_groups"), ("Longs", "longs_user_user_groups"),
) )
@@ -1701,16 +1706,6 @@ class UserGrades(models.Model):
return self.name return self.name
USER_EXAM_TYPES = (
("Physics", "user_physics_exams"),
("Rapids", "user_rapid_exams"),
("SBAs", "user_sba_exams"),
("Anatomy", "user_anatomy_exams"),
("Longs", "user_longs_exams"),
# ("CaseCollection", "user_casecollection_exams"),
)
class UserProfile(models.Model): class UserProfile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
supervisor = models.ForeignKey( supervisor = models.ForeignKey(
+10
View File
@@ -104,6 +104,15 @@
</select> </select>
</span> </span>
<span>
Shorts Exams<br />
<select id="shorts-exams" multiple="multiple">
{% for name, id in shorts_exams %}
<option value="{{id}}">{{name}}</option>
{% endfor %}
</select>
</span>
<span> <span>
CaseCollection Exams<br /> CaseCollection Exams<br />
<select id="casecollection-exams" multiple="multiple"> <select id="casecollection-exams" multiple="multiple">
@@ -213,6 +222,7 @@
sba_exams: JSON.stringify($("#sbas-exams").val()), sba_exams: JSON.stringify($("#sbas-exams").val()),
longs_exams: JSON.stringify($("#longs-exams").val()), longs_exams: JSON.stringify($("#longs-exams").val()),
anatomy_exams: JSON.stringify($("#anatomy-exams").val()), anatomy_exams: JSON.stringify($("#anatomy-exams").val()),
shorts_exams: JSON.stringify($("#shorts-exams").val()),
casecollection_exams: JSON.stringify($("#casecollection-exams").val()), casecollection_exams: JSON.stringify($("#casecollection-exams").val()),
cid_groups: JSON.stringify($("#cid-groups").val()), cid_groups: JSON.stringify($("#cid-groups").val()),
delete: false, delete: false,
@@ -96,22 +96,10 @@ Open access: {{ exam.open_access }}<br />
{% endif %} {% endif %}
<div class="parent-help" title="Click to enable / disable the exam"> {% include "generic/partials/exams/exam_status.html#exam-active" %}
Exam active: <input type="checkbox"
{% if not can_edit %}
disabled
{% endif %}
id="exam-active-switch" {% if exam.active %}checked{% endif %} data-posturl="{% url exam.get_app_name|add:':exam_toggle_active' pk=exam.pk %}"> <span class="help-text">[When checked the exam will be available to take in the test system]</span>
</div>
{% if exam.exam_mode %} {% if exam.exam_mode %}
<div class="parent-help" title="Click to enable / disable the exam results">
Publish results: <input type="checkbox" {% include "generic/partials/exams/exam_status.html#publish-results" %}
{% if not can_edit %}
disabled
{% endif %}
id="exam-publish-results-switch" data-posturl="{% url exam.get_app_name|add:':exam_toggle_results_published' pk=exam.pk %}"
{% if exam.publish_results %}checked{% endif %}> <span class="help-text">[When checked the exam results will be available to users on this site]</span>
</div>
{% if exam.get_app_name == "anatomy" or exam.get_app_name == "longs" %} {% if exam.get_app_name == "anatomy" or exam.get_app_name == "longs" %}
<p><a href="{% url exam.get_app_name|add:':mark_overview' pk=exam.pk %}"><button>Mark exam</button></a></p> <p><a href="{% url exam.get_app_name|add:':mark_overview' pk=exam.pk %}"><button>Mark exam</button></a></p>
{% endif %} {% endif %}
@@ -58,6 +58,17 @@
</div> </div>
{% endif %} {% endif %}
{% if object.shorts_exams.all %}
<h2>Shorts Exams:</h2>
{% with exams=object.shorts_exams.all app_name="shorts" %}
{% include "exam_list.html#exam-list" %}
{% endwith %}
<a href="{% url 'shorts:exam_list_collection' object.pk %}">View exam list</a>
<div class="add-marker">
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'shorts' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
</div>
{% endif %}
{% if object.physics_exams.all %} {% if object.physics_exams.all %}
<h2>Physics Exams:</h2> <h2>Physics Exams:</h2>
{% with exams=object.physics_exams.all app_name="physics" %} {% with exams=object.physics_exams.all app_name="physics" %}
@@ -0,0 +1,30 @@
{% load partials %}
{% partialdef publish-results %}
<div id="publish-results-status" >
Publish results: <span>{{exam.publish_results}}</span>
<button
hx-post="{% url exam.get_app_name|add:':exam_toggle_results_published' exam.pk %}"
hx-swap="outerHTML"
hx-target="#publish-results-status"
class="btn btn-sm btn-outline-primary reduce-opacity"
>
{% if exam.publish_results %}Unpublish Results{% else %}Publish Results{% endif %}
</button>
</div>
{% endpartialdef publish-results %}
{% partialdef exam-active %}
<div id="exam-active-status">
Exam active: <span>{{exam.active}}</span>
<button
hx-post="{% url exam.get_app_name|add:':exam_toggle_active' exam.pk %}"
hx-swap="outerHTML"
hx-target="#exam-active-status"
class="btn btn-sm btn-outline-primary reduce-opacity"
>
{% if exam.active %}Deactivate Exam{% else %}Activate Exam{% endif %}
</button>
</div>
{% endpartialdef exam-active %}
@@ -0,0 +1,18 @@
<form method="get" class="row align-items-center mb-3">
{# Keep existing filters/searches #}
{% for key, value in request.GET.items %}
{% if key != 'per_page' %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}
<div class="col-auto">
<label for="per_page" class="form-label mb-0 ">Rows per page:</label>
</div>
<div class="col-auto">
<select name="per_page" id="per_page" class="form-select form-select-sm" onchange="this.form.submit()">
{% for size in page_sizes %}
<option value="{{ size }}" {% if request.GET.per_page|stringformat:"s" == size|stringformat:"s" %}selected{% endif %}>{{ size }}</option>
{% endfor %}
</select>
</div>
</form>
+57
View File
@@ -0,0 +1,57 @@
from django import template
from django.template import Node, TemplateSyntaxError, Variable
register = template.Library()
class HelpNode(Node):
def __init__(self, nodelist, title, icon, extra_class):
self.nodelist = nodelist
self.title = title
self.icon = icon
self.extra_class = extra_class
def resolve(self, val, context):
if isinstance(val, Variable):
try:
return val.resolve(context)
except Exception:
return ''
return val
def render(self, context):
title = self.resolve(self.title, context) or "Help"
icon = self.resolve(self.icon, context) or "bi bi-info-circle"
extra_class = self.resolve(self.extra_class, context) or "help-text"
body = self.nodelist.render(context)
return f'<details class="{extra_class}"><summary><i class="{icon}"></i> {title}</summary>{body}</details>'
@register.tag(name="help")
def do_help(parser, token):
bits = token.split_contents()
title = "Help"
icon = "bi bi-info-circle"
extra_class = "help-text"
for bit in bits[1:]:
if "=" in bit:
key, val = bit.split("=", 1)
if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
val = val[1:-1]
else:
val = Variable(val)
if key == "icon":
icon = val
elif key in ("class", "extra_class"):
extra_class = val
else:
raise TemplateSyntaxError(f"Unknown keyword for help tag: {key}")
else:
v = bit
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
title = v[1:-1]
else:
title = Variable(v)
nodelist = parser.parse(("endhelp",))
parser.delete_first_token()
return HelpNode(nodelist, title, icon, extra_class)
+2 -2
View File
@@ -388,12 +388,12 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
), ),
path( path(
"exam/<int:pk>/toggle_active", "exam/<int:pk>/toggle_active",
generic_exam_view.exam_toggle_active, generic_exam_view.exam_toggle_active_htmx,
name="exam_toggle_active", name="exam_toggle_active",
), ),
path( path(
"exam/<int:pk>/toggle_results_published", "exam/<int:pk>/toggle_results_published",
generic_exam_view.exam_toggle_results_published, generic_exam_view.exam_toggle_results_published_htmx,
name="exam_toggle_results_published", name="exam_toggle_results_published",
), ),
path( path(
+63 -5
View File
@@ -146,6 +146,9 @@ import plotly.express as px
from django.db.models import Prefetch from django.db.models import Prefetch
from loguru import logger
class RedirectMixin: class RedirectMixin:
def get_success_url(self) -> str: def get_success_url(self) -> str:
if "redirect" in self.request.GET: if "redirect" in self.request.GET:
@@ -692,6 +695,58 @@ class ExamViews(View, LoginRequiredMixin):
data = {"status": "error"} data = {"status": "error"}
return JsonResponse(data, status=400) return JsonResponse(data, status=400)
@method_decorator(login_required)
def exam_toggle_results_published_htmx(self, request, pk):
if request.method == "POST":
if not self.check_user_edit_access(request.user, exam_id=pk):
return HttpResponse(
format_html(
'<div class="alert alert-danger" role="alert">Error toggling results published.</div>'
)
)
exam = get_object_or_404(self.Exam, pk=pk)
exam.publish_results = not exam.publish_results
exam.save()
return render(
request, "generic/partials/exams/exam_status.html#publish-results", context={
"exam": exam,
"collection": exam
})
else:
HttpResponse(
format_html(
'<div class="alert alert-danger" role="alert">Error toggling results published.</div>'
)
)
@method_decorator(login_required)
def exam_toggle_active_htmx(self, request, pk):
if request.method == "POST":
if not self.check_user_edit_access(request.user, exam_id=pk):
return HttpResponse(
format_html(
'<div class="alert alert-danger" role="alert">Error toggling exam active.</div>'
)
)
exam = get_object_or_404(self.Exam, pk=pk)
exam.active = not exam.active
exam.save()
return render(
request, "generic/partials/exams/exam_status.html#exam-active", context={
"exam": exam,
"collection": exam
})
else:
HttpResponse(
format_html(
'<div class="alert alert-danger" role="alert">Error toggling exam active.</div>'
)
)
@method_decorator(login_required) @method_decorator(login_required)
def exam_toggle_results_published(self, request, pk): def exam_toggle_results_published(self, request, pk):
if request.method == "POST": if request.method == "POST":
@@ -796,6 +851,7 @@ class ExamViews(View, LoginRequiredMixin):
author__id=request.user.id, exam_mode=True author__id=request.user.id, exam_mode=True
).order_by("name") ).order_by("name")
exam_list = exam_list | self.Exam.objects.filter( exam_list = exam_list | self.Exam.objects.filter(
markers__id=request.user.id, exam_mode=True markers__id=request.user.id, exam_mode=True
).order_by("name") ).order_by("name")
@@ -1396,6 +1452,7 @@ class ExamViews(View, LoginRequiredMixin):
app_exam_map = {} app_exam_map = {}
app_exam_map["rapids"] = user_user.user_rapid_exams app_exam_map["rapids"] = user_user.user_rapid_exams
app_exam_map["shorts"] = user_user.user_shorts_exams
app_exam_map["anatomy"] = user_user.user_anatomy_exams app_exam_map["anatomy"] = user_user.user_anatomy_exams
app_exam_map["longs"] = user_user.user_longs_exams app_exam_map["longs"] = user_user.user_longs_exams
app_exam_map["physics"] = user_user.user_physics_exams app_exam_map["physics"] = user_user.user_physics_exams
@@ -1608,7 +1665,9 @@ class ExamViews(View, LoginRequiredMixin):
): ):
exams = self.Exam.objects.all() exams = self.Exam.objects.all()
filter = self.ExtraExamFilter(request.GET, queryset=exams) filter = self.ExtraExamFilter(request.GET, queryset=exams)
print("1")
else: else:
print("2")
exams = self.Exam.objects.filter( exams = self.Exam.objects.filter(
author__id=request.user.id author__id=request.user.id
) | self.Exam.objects.filter(open_access=True) ) | self.Exam.objects.filter(open_access=True)
@@ -2237,7 +2296,7 @@ class ExamViews(View, LoginRequiredMixin):
ans = user_answer.get_answer() ans = user_answer.get_answer()
answer_score = user_answer.get_answer_score() answer_score = user_answer.get_answer_score()
if self.app_name == "longs": if self.app_name in ("longs", "shorts"):
feedback = user_answer.candidate_feedback feedback = user_answer.candidate_feedback
match self.app_name: match self.app_name:
@@ -2273,10 +2332,11 @@ class ExamViews(View, LoginRequiredMixin):
if not exam.publish_results and not view_all_results: if not exam.publish_results and not view_all_results:
correct_answer = "*****" correct_answer = "*****"
answer_score = 0 answer_score = 0
logger.debug(f"{ans=} {answer_score=} {correct_answer=}")
answers.append(ans) answers.append(ans)
answers_marks.append(answer_score) answers_marks.append(answer_score)
if self.app_name == "longs": if self.app_name in ("longs", "shorts"):
answers_and_marks.append( answers_and_marks.append(
(ans, answer_score, correct_answer, feedback) (ans, answer_score, correct_answer, feedback)
) )
@@ -2289,7 +2349,7 @@ class ExamViews(View, LoginRequiredMixin):
case "physics": case "physics":
total_score = sum(sum(i) for i in answers_marks) total_score = sum(sum(i) for i in answers_marks)
case _: case _:
if "unmarked" in answers_marks: if "unmarked" in answers_marks or None in answers_marks:
answered = [i for i in answers_marks if type(i) == int] answered = [i for i in answers_marks if type(i) == int]
total_score = sum(answered) total_score = sum(answered)
unmarked_number = len(answers_marks) - len(answered) unmarked_number = len(answers_marks) - len(answered)
@@ -2377,7 +2437,6 @@ class ExamViews(View, LoginRequiredMixin):
cached_scores = True cached_scores = True
valid_cid_users = set(exam.valid_cid_users.all().values_list("cid", flat=True)) valid_cid_users = set(exam.valid_cid_users.all().values_list("cid", flat=True))
print(valid_cid_users)
valid_user_users = set(exam.valid_user_users.all().values_list("pk", flat=True)) valid_user_users = set(exam.valid_user_users.all().values_list("pk", flat=True))
if self.app_name in ("rapids"): if self.app_name in ("rapids"):
@@ -2403,7 +2462,6 @@ class ExamViews(View, LoginRequiredMixin):
user_ids = set() user_ids = set()
cids_user_id_map = {} cids_user_id_map = {}
cids_name_map = {}
# Loop through all candidates # Loop through all candidates
for cid_user_answer in cid_user_answers: for cid_user_answer in cid_user_answers:
+42 -11
View File
@@ -2,6 +2,7 @@ import base64
from collections import defaultdict from collections import defaultdict
import hashlib import hashlib
import mimetypes import mimetypes
from pydicom.uid import ExplicitVRLittleEndian
from io import BytesIO from io import BytesIO
@@ -19,6 +20,7 @@ try:
from .pydicom_PIL import get_PIL_image from .pydicom_PIL import get_PIL_image
except: except:
from pydicom_PIL import get_PIL_image from pydicom_PIL import get_PIL_image
import numpy as np
def image_as_base64(image_file): def image_as_base64(image_file):
""" """
@@ -93,38 +95,29 @@ def get_dicom_order(path):
files = [] files = []
for fname in glob.glob(path, recursive=False): for fname in glob.glob(path, recursive=False):
print("loading: {}".format(fname))
files.append((fname, pydicom.dcmread(fname))) files.append((fname, pydicom.dcmread(fname)))
print("file count: {}".format(len(files)))
# skip files with no SliceLocation (eg scout views) # skip files with no SliceLocation (eg scout views)
slices = [] slices = []
map = {} map = {}
skipcount = 0 skipcount = 0
for fname, f in files: for fname, f in files:
print(dir(f))
print(fname)
if hasattr(f, 'SliceLocation'): if hasattr(f, 'SliceLocation'):
slices.append(f) slices.append(f)
map[f.SliceLocation] = fname map[f.SliceLocation] = fname
else: else:
skipcount = skipcount + 1 skipcount = skipcount + 1
print("skipped, no SliceLocation: {}".format(skipcount))
# ensure they are in the correct order # ensure they are in the correct order
slices = sorted(slices, key=lambda s: s.SliceLocation) slices = sorted(slices, key=lambda s: s.SliceLocation)
#print(slices) return slices
for i in slices:
print(map[i.SliceLocation])
#get_dicom_order("/home/ross/Downloads/DICOM HEAD/STD4/SER1/*.dcm") #get_dicom_order("/home/ross/Downloads/DICOM HEAD/STD4/SER1/*.dcm")
def pretty_print_dicom(dataset, indent=0): def pretty_print_dicom(dataset, indent=0):
l = print_dicom(dataset, indent) l = print_dicom(dataset, indent)
print(l)
return "".join(l) return "".join(l)
@@ -183,7 +176,6 @@ def get_image_dicom_hash(img, dataset=None, hash_type="md5", direct_pixel_data=T
hasher.update(dataset.PixelData) hasher.update(dataset.PixelData)
except AttributeError as e: except AttributeError as e:
print("Error getting pixel data hash from dataset") print("Error getting pixel data hash from dataset")
print(dataset)
raise InvalidDicomError("Error getting pixel data hash from dataset") raise InvalidDicomError("Error getting pixel data hash from dataset")
else: else:
@@ -294,3 +286,42 @@ def compare_dicom_datasets(*datasets: pydicom.Dataset):
return differences return differences
def combine_dicom_images_side_by_side(dicom_path1, dicom_path2):
"""
Combines two DICOM images side by side, using the metadata from the first image.
If the heights differ, pads the shorter image with zeros at the bottom.
Returns a new pydicom.Dataset with the combined image.
"""
ds1 = pydicom.dcmread(dicom_path1)
ds2 = pydicom.dcmread(dicom_path2)
arr1 = ds1.pixel_array
arr2 = ds2.pixel_array
h1, w1 = arr1.shape[:2]
h2, w2 = arr2.shape[:2]
# Pad arrays if heights differ
if h1 != h2:
max_h = max(h1, h2)
def pad(arr, target_h):
pad_height = target_h - arr.shape[0]
if pad_height > 0:
pad_shape = ((0, pad_height), (0, 0)) if arr.ndim == 2 else ((0, pad_height), (0, 0), (0, 0))
arr = np.pad(arr, pad_shape, mode='constant', constant_values=0)
return arr
arr1 = pad(arr1, max_h)
arr2 = pad(arr2, max_h)
combined = np.hstack((arr1, arr2))
ds1.PixelData = combined.tobytes()
ds1.Rows, ds1.Columns = combined.shape[:2]
if hasattr(ds1, 'PhotometricInterpretation'):
ds1.PhotometricInterpretation = ds1.PhotometricInterpretation
# Ensure output is uncompressed
ds1.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian
return ds1
+1 -1
View File
@@ -4,7 +4,7 @@
Usage: Usage:
>>> import pydicom >>> import pydicom
>>> from pydicom.contrib.pydicom_PIL import show_PIL >>> from pydicom.contrib.pydicom_PIL import show_PIL
>>> ds = pydicom.read_file("filename") >>> ds = pydicom.dcmread("filename")
>>> show_PIL(ds) >>> show_PIL(ds)
Requires Numpy: Requires Numpy:
http://numpy.scipy.org/ http://numpy.scipy.org/
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-09-02 20:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('longs', '0032_longseries_total_image_size'),
]
operations = [
migrations.AlterField(
model_name='longseriesimage',
name='image_md5_hash',
field=models.CharField(blank=True, max_length=64, null=True),
),
]
-1
View File
@@ -11,7 +11,6 @@ if __name__ == "__main__":
if os.environ.get('RUN_MAIN'): if os.environ.get('RUN_MAIN'):
if settings.DEBUG_CONTAINER: if settings.DEBUG_CONTAINER:
import debugpy import debugpy
print("DEBUG LISTEN")
debugpy.listen(("0.0.0.0", 3459)) debugpy.listen(("0.0.0.0", 3459))
try: try:
+26
View File
@@ -1387,4 +1387,30 @@ span#user-id {
width: auto; width: auto;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
}
.btn.reduce-opacity {
opacity: 0.3;
transition: opacity 0.2s;
}
.btn.reduce-opacity:hover, .btn.reduce-opacity:focus {
opacity: 1;
}
/* Highlight row on hover */
.row-selector tbody tr:hover td {
background-color: darkblue;
cursor: pointer;
}
/* Highlight selected row */
.row-selector tbody tr.selected td {
background-color: #b3d7ff !important;
color: #333;
}
.image-block {
min-width: 100px;
display: inline-block;
} }
+4085
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+597
View File
@@ -0,0 +1,597 @@
/*!
* Bootstrap Reboot v5.3.7 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
line-height: inherit;
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+594
View File
@@ -0,0 +1,594 @@
/*!
* Bootstrap Reboot v5.3.7 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
line-height: inherit;
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12043
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+4 -5
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12016
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+628 -201
View File
File diff suppressed because one or more lines are too long
+6315
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4450
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More