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 \
# gfortran musl-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
+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):
raise HttpError(HTTPStatus.UNAUTHORIZED,"Exam author required")
print(exam_id, 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])
-1
View File
@@ -492,7 +492,6 @@ class Exam(ExamBase):
return answer
def get_question_user_user_answer(self, question_index, user):
print(user)
question = self.get_question_by_index(question_index)
answer = UserAnswer.objects.filter(question=question, user=user.pk, exam=self)
+325 -238
View File
@@ -76,7 +76,7 @@ $(document).ready(function () {
if (dicom_images.length) {
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');
@@ -330,266 +361,321 @@ function loadJsonToolStateOnCurrentImage(json) {
}
async function setUpDicom(element) {
console.log("Dicom element found ", $(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);
//async function setUpDicom(element) {
// console.log("setUpDicom (original)", element);
//
// $(element).bind('contextmenu', function (e) {
// return false;
// });
} else {
let url;
// This doesn't seem to have any benefit
//if (data_url.startsWith("http")) {
//
// $(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 {
// url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
// cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate");
// }
url = data_url;
//
// 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;
if (url.endsWith("dcm")) {
url = "wadouri:" + url;
async function setUpDicom3d(element) {
console.log("setUpDicom3d (cornerstone3d) for", element);
// parse images from dataset
let raw = element.dataset.url || "";
if (!raw) return;
let images;
try {
images = JSON.parse(raw);
} catch (e) {
images = raw.split(",").map(s => s.trim()).filter(Boolean);
}
if (!images || images.length === 0) return;
// if there is no extension treat it as a dicom
if (/(?:\/|^)[^.\/]+$/.test(url)) {
url = "wadouri:" + url;
// ensure element has a viewport-like appearance
element.style.width = element.style.width || "500px";
element.style.height = element.style.height || "500px";
element.style.position = element.style.position || "relative";
element.innerHTML = ""; // clear
// Disable right-click context menu on the element
element.addEventListener('contextmenu', function (e) {
e.preventDefault();
});
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();
}
});
imageIds.push(url);
// 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);
}
}
cornerstone.enable(element);
cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
cornerstone.displayImage(element, image);
await viewport.setStack(imageIds, 0);
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,
},
});
viewport.render();
// store references for debugging/use elsewhere
element._cornerstone3d = {
renderingEngineId,
viewportId,
renderingEngine,
viewport,
imageIds,
};
// 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");
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;
}
}
if (element.dataset.annotations) {
loadJsonToolStateOnCurrentImage(element.dataset.annotations)
}
cornerstone.resize(element);
// expose globally like existing code expects
window.setUpDicom = setUpDicom3d;
}).catch((err, err2) => {
console.log(err);
});
}
//function keyUpHandler(e) {
// if (e.key == "Control") {
// 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) {
@@ -682,3 +768,4 @@ function initializeClock(id, endtime) {
}
window.initializeClock = initializeClock
@@ -2,6 +2,7 @@
{% load static %}
{% load crispy_forms_tags %}
{% load help_tags %}
{% block title %}
Anatomy
@@ -226,14 +227,13 @@
{% endif %}
<h2>{{object|yesno:"Update,Create"}} Question</h2>
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
{% help "Help" %}
<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>
<p>Correct and incorrect answers can be defined once the question has been created.</p>
</details>
{% endhelp %}
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %}
<a href="/anatomy/examination/create" id="add_examination" class="add-popup"
-2
View File
@@ -194,8 +194,6 @@ def test_exams(db, client):
exam.save()
res = client.get(exam_metadata["url"])
print("PRE ERRER", exam_metadata["url"])
print(res.content)
assert res.status_code == 404
exam.active = True
-17
View File
@@ -646,22 +646,6 @@ class AnatomyQuestionUpdate(UpdateQuestionMixin):
context = self.get_context_data(form=form)
#formset = context["answer_formset"]
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):
@@ -797,7 +781,6 @@ def question_save_annotation(request, pk):
question = get_object_or_404(AnatomyQuestion, pk=pk)
question.image_annotations = request.POST.get("annotation")
print(question.image_annotations)
question.save()
data = {"status": "success"}
+3 -1
View File
@@ -16,7 +16,8 @@ from .models import (
Presentation,
UncategorisedDicom,
SeriesDetail,
Resource
Resource,
CaseDisplaySet,
)
from django.forms import ModelForm
@@ -46,6 +47,7 @@ admin.site.register(CaseDetail)
admin.site.register(UncategorisedDicom)
admin.site.register(SeriesDetail)
admin.site.register(Resource)
admin.site.register(CaseDisplaySet)
class DifferentialInline(admin.TabularInline):
+52 -66
View File
@@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404
from django.urls import reverse
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 django.core.exceptions import PermissionDenied
@@ -109,7 +109,6 @@ def clear_dicoms(request):
dicoms = UncategorisedDicom.objects.filter(user=request.user)
dicoms.delete()
print(dicoms)
return True
@@ -129,15 +128,10 @@ def uncategorised_dicoms(request):
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:
dicoms = UncategorisedDicom.objects.filter(
series_instance_uid__in=request.POST.getlist("selection")
)
else:
dicoms = UncategorisedDicom.objects.filter(user=request.user)
@@ -146,67 +140,66 @@ def import_dicoms_helper(request, case_id: int | None = None):
else:
order = None
data = defaultdict(list)
for d in dicoms:
# Group dicoms by SeriesInstanceUID, but don't keep all tags in memory
series_map = {}
for d in dicoms.iterator(): # Use iterator() to avoid loading all at once
tags = d.basic_dicom_tags
series_uid = tags["SeriesInstanceUID"]
data[tags["SeriesInstanceUID"]].append((d, tags))
series_list = []
for series_uid in data:
# Hmm probably not a good idea to blinding create a modality...
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"])
tags = data[series_uid][0][1]
# 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:
series = Series(
modality=modality,
description=tags["SeriesDescription"],
series_instance_uid=tags["SeriesInstanceUID"],
series_tags = tags # Save the first tag for this series
try:
with transaction.atomic():
series, created = Series.objects.get_or_create(
series_instance_uid=series_uid,
defaults={
"modality": modality,
"description": tags.get("SeriesDescription", ""),
}
)
if tags["StudyDescription"]:
examination, created = Examination.objects.get_or_create(
if created and tags.get("StudyDescription"):
examination, created_exam = Examination.objects.get_or_create(
examination=tags["StudyDescription"]
)
if created:
if created_exam:
examination.modality = modality
examination.save()
series.examination = examination
series.save()
except IntegrityError:
series = Series.objects.get(series_instance_uid=series_uid)
# We might only want to add the author during creation....
# 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)
if not series.case.contains(case_object):
try:
series.case.add(case_object)
except IntegrityError:
pass
for dicom, dicom_tags in data[series_uid]:
series_map[series_uid] = series
else:
series = series_map[series_uid]
# Create and save SeriesImage immediately
series_image = SeriesImage(
image=dicom.image,
image=d.image,
series=series,
is_dicom=True,
image_blake3_hash=dicom.image_blake3_hash,
image_blake3_hash=d.image_blake3_hash,
)
series_image.save()
# dicom.image.delete()
dicom.delete()
d.delete() # Remove UncategorisedDicom immediately
# Optionally, re-order series if needed
for series in series_map.values():
match order:
case "order-series-instance-number":
series.order_by_dicom("InstanceNumber")
@@ -215,9 +208,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
case _:
pass
series_list.append((series, series.get_link()))
return series_list
return [(series, series.get_link()) for series in series_map.values()]
@router.post(
@@ -238,7 +229,6 @@ def import_dicoms_case(request, case_id: int):
@router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema])
def orphan_series(request):
# print(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)
def series_truncate(request, series_id: int, start: int, end: int):
print(start, end)
series = get_object_or_404(Series, pk=series_id)
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:
continue
else:
print(n, image)
image.removed = True
image.image = None
image.save()
@@ -291,7 +279,6 @@ def get_cases_user(request):
@router.get("/check_image_hash/{hash}", auth=django_auth)
def check_image_hash(request, hash: str):
try:
print(hash)
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
data = {
"status": "success",
@@ -370,38 +357,37 @@ def series_split_by_tag(request, series_id: int, dicom_tag: str):
image_map = defaultdict(list)
# Split teh associated images
for image in series.images.all():
print(image)
# Use iterator to avoid loading all images into memory
for image in series.images.iterator():
ds = image.get_dicom_data()
# print("2a", ds[dicom_tag])
if dicom_tag in ds:
val = ds[dicom_tag].value
if type(val) == pydicom.multival.MultiValue:
image_map[",".join((val))].append(image)
if isinstance(val, pydicom.multival.MultiValue):
key = ",".join(map(str, val))
else:
image_map[val].append(image)
key = str(val)
image_map[key].append(image)
else:
return [f"{dicom_tag} not found {image}"]
case = list(series.case.all())
authors = list(series.author.all())
new_series = []
case = series.case.all()
authors = series.author.all()
for n, option in enumerate(image_map):
# Use a transaction for all DB changes
with transaction.atomic():
for n, (option, images) in enumerate(image_map.items()):
if n == 0:
series.images.set(image_map[option])
# Update the original series
series.images.set(images)
new_series.append(series.pk)
else:
# Clone the series
series.pk = None
series.save()
series.case.set(case)
series.author.set(authors)
series.images.set(image_map[option])
series.images.set(images)
new_series.append(series.pk)
return new_series
+2
View File
@@ -80,6 +80,8 @@ def user_is_collection_author_or_atlas_editor(function):
def wrap(request, *args, **kwargs):
if "exam_id" in kwargs:
atlas = CaseCollection.objects.get(pk=kwargs["exam_id"])
elif "collection_id" in kwargs:
atlas = CaseCollection.objects.get(pk=kwargs["collection_id"])
else:
atlas = CaseCollection.objects.get(pk=kwargs["pk"])
if (
+351 -92
View File
@@ -12,7 +12,8 @@ from django.forms import (
CharField,
ValidationError,
modelformset_factory,
CheckboxSelectMultiple
CheckboxSelectMultiple,
SplitDateTimeWidget,
)
from django.forms import inlineformset_factory
from django.shortcuts import get_object_or_404
@@ -32,6 +33,7 @@ from atlas.models import (
Resource,
SelfReview,
Series,
SeriesDetail,
SeriesImage,
SeriesFinding,
Condition,
@@ -39,6 +41,7 @@ from atlas.models import (
Subspecialty,
UncategorisedDicom,
UserReportAnswer,
CaseDisplaySet,
)
from anatomy.models import Modality
@@ -56,11 +59,22 @@ from tinymce.widgets import TinyMCE
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
from generic.forms import ExamAuthorFormMixin, ExamGroupsFormMixin
from generic.forms import (
ExamAuthorFormMixin,
ExamGroupsFormMixin,
SplitDateTimeFieldDefaultTime,
SplitDateTimeFieldDefaultTimeEnd,
)
from crispy_forms.helper import FormHelper
@@ -71,31 +85,30 @@ from typing import Any
from helpers.cimar import CimarAPI, NotFoundError
from rad.settings import CIMAR_USERNAME, CIMAR_PASSWORD
class ClearableInput(TextInput):
template_name = "crispy_forms/widgets/clearable_input_single_line.html"
class ClearableTextarea(Textarea):
template_name = "crispy_forms/widgets/clearable_input.html"
class CaseSelect(Select):
template_name = "atlas/case_select_widget.html"
def create_option(self, *args, **kwargs):
option = super().create_option(*args, **kwargs)
print(option)
return option
def get_context(self, name: str, value, attrs) -> dict[str, Any]:
context = super().get_context(name, value, attrs)
return context
pass
class ConditionForm(ModelForm):
class Meta:
model = Condition
@@ -120,26 +133,130 @@ class CaseCollectionForm(ModelForm):
exclude = ["cases", "valid_cid_users", "valid_user_users", "author"]
def __init__(self, *args, **kwargs):
self.user = kwargs.pop(
"user"
) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
self.user = kwargs.pop("user")
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", {})
# 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)
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):
# Get the unsaved Case instance
instance = ModelForm.save(self, False)
# Do we need to save all changes now?
# if commit:
instance.save()
@@ -175,7 +292,14 @@ class StructureForm(ModelForm):
class SeriesFindingForm(ModelForm):
class Meta:
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 = {
"findings": autocomplete.ModelSelect2Multiple(
@@ -187,7 +311,6 @@ class SeriesFindingForm(ModelForm):
"conditions": autocomplete.ModelSelect2Multiple(
url="atlas:condition-autocomplete"
),
}
def __init__(self, *args, **kwargs):
@@ -298,9 +421,31 @@ class SeriesForm(ModelForm):
"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 Media:
# Django also includes a few javascript files necessary
# for the operation of this form element. You need to
@@ -338,21 +483,17 @@ class CaseForm(ModelForm):
# 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)
super(CaseForm, self).__init__(*args, **kwargs)
# self.fields["series"].queryset =
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:
case_queryset = get_cases_available_to_user(self.user)
self.fields["previous_case"].queryset = case_queryset
self.helper = FormHelper()
self.helper.form_id = "id-case-form"
self.helper.form_class = "case-form"
@@ -369,18 +510,16 @@ class CaseForm(ModelForm):
"history",
"presentation",
"discussion",
"report",
"condition",
"pathological_process",
"report",
"open_access",
"previous_case",
"diagnostic_certainty",
"cimar_uuid",
),
)
def get_queryset(self, request):
return (
super()
@@ -409,7 +548,7 @@ class CaseForm(ModelForm):
"open_access",
"previous_case",
"diagnostic_certainty",
"cimar_uuid"
"cimar_uuid",
]
# fields = ['question', 'findings', 'subspecialty', 'references']
widgets = {
@@ -450,7 +589,6 @@ class CaseForm(ModelForm):
# Get the unsaved Case instance
instance = ModelForm.save(self, False)
# Prepare a 'save_m2m' method for the form,
old_save_m2m = self.save_m2m
@@ -478,7 +616,9 @@ class CaseForm(ModelForm):
# Create through model
# 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
@@ -496,6 +636,7 @@ CaseDifferentialFormSet = inlineformset_factory(
},
)
class ResourceForm(ModelForm):
class Meta:
model = Resource
@@ -508,7 +649,6 @@ class ResourceForm(ModelForm):
class CaseResourceForm(ModelForm):
def __init__(self, *args, user, **kwargs):
super(CaseResourceForm, self).__init__(*args, **kwargs)
@@ -526,23 +666,28 @@ class CaseResourceForm(ModelForm):
self.fields["pre_review"] = BooleanField(required=False)
class CaseSeriesForm(ModelForm):
def __init__(self, *args, user, **kwargs):
super(CaseSeriesForm, self).__init__(*args, **kwargs)
if not user.groups.filter(name="atlas_editor").exists():
queryset = Series.objects.filter(author__id=user.id)
else:
queryset = Series.objects.all()
self.fields["series"] = ModelChoiceField(
required=False,
queryset=queryset,
# widget=Select(verbose_name="Series", is_stacked=False),
)
# class CaseSeriesForm(ModelForm):
#
# def __init__(self, *args, user, **kwargs):
# super(CaseSeriesForm, self).__init__(*args, **kwargs)
#
# if not user.groups.filter(name="atlas_editor").exists():
# queryset = Series.objects.filter(author__id=user.id)
# else:
# queryset = Series.objects.all()
#
# # Optimize the queryset for the ModelChoiceField
# queryset = queryset.select_related(
# "modality"
# ).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):
@@ -558,15 +703,15 @@ class CaseCollectionCaseForm(ModelForm):
)
SeriesFormSet = inlineformset_factory(
Case,
Series.case.through,
form=CaseSeriesForm,
exclude=[],
can_delete=True,
extra=0,
max_num=10,
)
# SeriesFormSet = inlineformset_factory(
# Case,
# Series.case.through,
# form=CaseSeriesForm,
# exclude=[],
# can_delete=True,
# extra=0,
# max_num=10,
# )
CaseCollectionCaseFormSet = inlineformset_factory(
CaseCollection,
@@ -591,7 +736,13 @@ CaseResourceFormSet = inlineformset_factory(
SeriesImageFormSet = inlineformset_factory(
Series,
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,
extra=0,
max_num=2000,
@@ -602,17 +753,21 @@ class JsonAnswerForm(Form):
json_answer = JSONSchemaField(
schema={},
# options = 'schema/options.json'
options = 'schema/options.json'
options="schema/options.json",
)
class Meta:
fields = ["json_answer"]
def __init__(self, *args, question_schema=None, **kwargs):
super(JsonAnswerForm, self).__init__(*args, **kwargs)
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
class BaseReportAnswerForm(ModelForm):
class Meta:
fields = ["answer"]
@@ -623,7 +778,9 @@ class BaseReportAnswerForm(ModelForm):
# (False, 'No')])
# "findings": TinyMCE(attrs={"cols": 80, "rows": 20}),
# "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 = {
@@ -634,11 +791,10 @@ class BaseReportAnswerForm(ModelForm):
super(BaseReportAnswerForm, self).__init__(*args, **kwargs)
self.fields["answer"].required = False
class BaseQuestionAnswerForm(ModelForm):
json_answer = JSONSchemaField(
schema = {},
options = 'schema/options.json'
)
json_answer = JSONSchemaField(schema={}, options="schema/options.json")
class Meta:
fields = ["json_answer"]
labels = {"json_answer": ""}
@@ -651,7 +807,9 @@ class BaseQuestionAnswerForm(ModelForm):
super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs)
# self.fields["json_answer"].schema = case_detail.question_schema
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 = ""
@@ -659,18 +817,22 @@ class CidReportAnswerForm(BaseReportAnswerForm):
class Meta(BaseReportAnswerForm.Meta):
model = CidReportAnswer
class UserReportAnswerForm(BaseReportAnswerForm):
class Meta(BaseReportAnswerForm.Meta):
model = UserReportAnswer
class CidQuestionAnswerForm(BaseQuestionAnswerForm):
class Meta(BaseQuestionAnswerForm.Meta):
model = CidReportAnswer
class UserQuestionAnswerForm(BaseQuestionAnswerForm):
class Meta(BaseQuestionAnswerForm.Meta):
model = UserReportAnswer
class CidReportAnswerMarkForm(ModelForm):
class Meta:
model = CidReportAnswer
@@ -694,7 +856,6 @@ class CidReportAnswerMarkForm(ModelForm):
#
# def __init__(self, *args, user, **kwargs):
# super(AddCollectionToCaseForm, self).__init__(*args, **kwargs)
# print("INIT", args, kwargs)
#
# if not user.groups.filter(name="atlas_editor").exists():
# queryset = CaseCollection.objects.filter(author__id=user.id)
@@ -732,11 +893,10 @@ class CidReportAnswerMarkForm(ModelForm):
#
# return collection_set
class AddCollectionToCaseForm(Form):
class AddCollectionToCaseForm(Form):
def __init__(self, *args, user, **kwargs):
super(AddCollectionToCaseForm, self).__init__(*args, **kwargs)
print("INIT", args, kwargs)
if not user.groups.filter(name="atlas_editor").exists():
queryset = CaseCollection.objects.filter(author__id=user.id)
@@ -752,21 +912,16 @@ class AddCollectionToCaseForm(Form):
)
def save(self, *args, **kwargs):
print("SAVE", args, kwargs)
instance = super(AddCollectionToCaseForm, self).save(*args, **kwargs)
# Here we save the modified project selection back into the database
instance.casecollection_set.set(self.cleaned_data['casecollection'])
print(f"{instance=}")
instance.casecollection_set.set(self.cleaned_data["casecollection"])
return instance
def clean_casecollection(self):
collection_set = self.cleaned_data["casecollection"]
print(f"{collection_set=}")
for col in collection_set:
if col not in self.valid_collections:
raise ValidationError("Invalid collection")
@@ -775,7 +930,6 @@ class AddCollectionToCaseForm(Form):
class SelfReviewForm(ModelForm):
class Meta:
model = SelfReview
fields = ["user_exam", "case", "comments", "findings", "interpretation"]
@@ -810,46 +964,53 @@ class UncategorisedDicomForm(ModelForm):
# )
# }
class ConditionAutocomplete(ModelAutocomplete):
model = Condition
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)
class CaseCollectionAuthorForm(ExamAuthorFormMixin):
class Meta(ExamAuthorFormMixin.Meta):
model = CaseCollection
class CaseAuthorForm(ExamAuthorFormMixin):
class Meta(ExamAuthorFormMixin.Meta):
model = Case
class SeriesAuthorForm(ExamAuthorFormMixin):
class Meta(ExamAuthorFormMixin.Meta):
model = Series
class ExamGroupsForm(ExamGroupsFormMixin):
class Meta(ExamGroupsFormMixin.Meta):
model = CaseCollection
class AnswerJSONForm(Form):
json = JSONSchemaField(
schema = 'schema/schema.json',
options = 'schema/options.json'
)
class AnswerJSONForm(Form):
json = JSONSchemaField(schema="schema/schema.json", options="schema/options.json")
class SvelteJSONEditorWidgetOverride(SvelteJSONEditorWidget):
template_name = "atlas/svelte_jsoneditor_widget_override.html"
class CaseDetailForm(ModelForm):
class CaseDetailForm(ModelForm):
class Meta:
model = CaseDetail
fields = ["question_schema", "question_answers"] # , "user"]
@@ -869,19 +1030,18 @@ class QuestionSchemaForm(ModelForm):
"schema": SvelteJSONEditorWidgetOverride(),
}
# class ModelPriorCaseForm(ModelForm):
# class Meta:
# model = CasePrior
# fields = ["case_detail", "prior_case", "relation_text", "prior_visibility"]
class PriorCaseForm(Form):
class PriorCaseForm(Form):
relation = CharField(max_length=255, required=True)
prior_visibility = CharField(max_length=2, required=True)
# def __init__(self, *args, **kwargs):
# self.case_detail = kwargs.pop(
# "case_detail"
@@ -898,3 +1058,102 @@ class PriorCaseForm(Form):
# required=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)
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):
return "atlas"
@@ -533,7 +549,15 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
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:
return True
@@ -542,9 +566,20 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
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):
@@ -1091,6 +1126,46 @@ class SeriesDetail(models.Model):
class Meta:
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):
"""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
#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)
class Meta:
@@ -1129,6 +1210,9 @@ class CaseDetail(models.Model):
except UserReportAnswer.DoesNotExist:
return None
def default_viewerstate_string(self):
return json.dumps(self.default_viewerstate) if self.default_viewerstate else "{}"
class CasePrior(models.Model):
case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
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:
return []
answers = []
print("1", self.json_answer)
print("2", self.question.question_answers)
for name, value in self.question.question_schema["properties"].items():
print(name, value)
try:
user_answer = self.json_answer[name]
@@ -1209,11 +1290,8 @@ class BaseReportAnswer(models.Model):
except KeyError: # If the schema has changed?
correct_answer = ""
print(correct_answer)
match value:
case {"type": "string", "enum": _}:
print("YES", value)
automark = True
answer_is_correct = user_answer == correct_answer
case {"type": "string"}:
@@ -1346,8 +1424,6 @@ class UncategorisedDicom(models.Model):
# duplicate = obj
return obj
print(image_hash)
if obj := SeriesImage.objects.filter(image_blake3_hash=image_hash).first():
duplicate = obj
@@ -1389,14 +1465,12 @@ class UncategorisedDicom(models.Model):
ds = pydicom.dcmread(self.image)
return ds
except pydicom.errors.InvalidDicomError:
print("Error getting dicom dataset")
print(self.image)
return {"SeriesInstanceUID": "1234"}
def get_basic_dicom_tags(self, dataset=None):
try:
if dataset is None:
dataset = pydicom.read_file(self.image)
dataset = pydicom.dcmread(self.image)
to_include = (
"StudyDescription",
"Modality",
+28 -6
View File
@@ -133,7 +133,14 @@ class CaseTable(tables.Table):
return "No associated cases"
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:
links = [
format_html(
@@ -183,6 +190,8 @@ class CaseTable(tables.Table):
)
sequence = ("view", )
order_by = "-created_date"
attrs = {"class": "table row-selector"}
class PopupLinkColumn(tables.Column):
@@ -196,11 +205,6 @@ class PopupLinkColumn(tables.Column):
return obj.get_thumbnail()[0]
def create_link(test):
print(test)
return "Hello"
class SeriesTable(tables.Table):
edit = tables.LinkColumn(
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False
@@ -236,6 +240,8 @@ class SeriesTable(tables.Table):
)
sequence = ("view", "popup", "images", "case")
order_by = "-created_date"
attrs = {"class": "table row-selector"}
def __init__(self, data=None, *args, **kwargs):
super().__init__(
@@ -276,6 +282,8 @@ class ConditionTable(tables.Table):
template_name = "django_tables2/bootstrap4.html"
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
sequence = ("name",)
attrs = {"class": "table row-selector"}
def __init__(self, data=None, *args, **kwargs):
super().__init__(
@@ -312,6 +320,8 @@ class FindingTable(tables.Table):
template_name = "django_tables2/bootstrap4.html"
fields = ("name", "primary")
sequence = ("name",)
attrs = {"class": "table row-selector"}
def render_synonym(self, value, record):
return format_html(record.get_synonym_link())
@@ -339,6 +349,8 @@ class StructureTable(tables.Table):
template_name = "django_tables2/bootstrap4.html"
fields = ("name", "primary")
sequence = ("name",)
attrs = {"class": "table row-selector"}
def render_synonym(self, value, record):
return format_html(record.get_synonym_link())
@@ -365,6 +377,8 @@ class PresentationTable(tables.Table):
template_name = "django_tables2/bootstrap4.html"
fields = ("name",)
sequence = ("name",)
attrs = {"class": "table row-selector"}
class PathologicalProcessTable(tables.Table):
@@ -386,6 +400,8 @@ class PathologicalProcessTable(tables.Table):
template_name = "django_tables2/bootstrap4.html"
fields = ("name",)
sequence = ("name",)
attrs = {"class": "table row-selector"}
class SubspecialtyTable(tables.Table):
@@ -407,6 +423,8 @@ class SubspecialtyTable(tables.Table):
template_name = "django_tables2/bootstrap4.html"
fields = ()
sequence = ("name",)
attrs = {"class": "table row-selector"}
def render_synonym(self, value, record):
return format_html(record.get_synonym_link())
@@ -441,6 +459,8 @@ class CaseCollectionTable(tables.Table):
"author",
)
sequence = ("view", )#, "series")
attrs = {"class": "table row-selector"}
def __init__(self, data=None, *args, **kwargs):
super().__init__(
@@ -468,6 +488,8 @@ class QuestionSchemaTable(tables.Table):
model = QuestionSchema
template_name = "django_tables2/bootstrap4.html"
fields = ("name", "description", "schema")
attrs = {"class": "table row-selector"}
def render_schema(self, value, record):
return format_html("<details><summary>Schema</summary>{}</details>", value)
@@ -1,4 +1,5 @@
<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">
{% csrf_token %}
<label for="case">Select Case to add:</label>
+297 -31
View File
@@ -1,7 +1,7 @@
{% load partials %}
{% 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>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
@@ -9,7 +9,13 @@
{{series.get_block}}
</a>
<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">
<a href="#"
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
@@ -113,6 +119,16 @@
ID: {{ case.id }}
</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>
@@ -144,22 +160,30 @@
{% partial case-series %}
</form>
<span>
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Add new series</a><br />
<a href="{% url 'atlas:user_uploads_case' case_id=case.pk %}">Import new uploads</a><br />
<button type="button"
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">
<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 %}"
title="order dicom by slice location"
hx-target="this"
hx-swap="outerHTML"
hx-target="#series-action-results"
hx-swap="innerHTML"
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 hx-post="{% url 'atlas:combine_series' %}"
title="merge series"
hx-include="[name='series-ids']"
hx-target="#series-merge-results"
hx-target="#series-action-results"
hx-swap="innerHTML"
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 %}"
title="merge series"
hx-include="[name='series-ids']"
hx-target="#series-merge-results"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will use the dicom date as the series description"
>
Use dates as description
</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/>
<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>
</span>
@@ -203,36 +246,35 @@
</details>
{% endif %}
{% if case.series.all %}
{% for series in case.series.all %}
<!-- Series Findings -->
<h5>Series Findings</h5>
{% if case.ordered_series %}
{% for series in case.ordered_series %}
{% for finding in series.findings.all %}
<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"
>
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}">
<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 }}">
View in Modal
</button>
</span>
{% if finding.findings.all %}
{% if finding.findings %}
<span>
Findings: {% for f in finding.findings.all %}
{{f.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.conditions.all %}
{% if finding.conditions %}
<span>
Conditions: {% for c in finding.conditions.all %}
{{c.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.structures.all %}
{% if finding.structures %}
<span>
Structure: {% for s in finding.structures.all %}
{{s.get_link}},&nbsp;
@@ -250,6 +292,75 @@
{% else %}
No series associated with case.
{% 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>
</div>
<div>
@@ -346,6 +457,40 @@
</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>
// From series_viewer.html
@@ -381,19 +526,129 @@
}
document.addEventListener("DOMContentLoaded", function () {
const detailsElement = document.getElementById("series-actions");
const checkboxes = document.querySelectorAll("input[name='series-ids']");
function toggleCheckboxes() {
const isOpen = detailsElement.hasAttribute("open");
checkboxes.forEach(checkbox => {
checkbox.style.display = isOpen ? "inline-block" : "none";
const dicomDetails = document.getElementById("dicom-viewer-details");
let dicomViewerLoaded = false;
dicomDetails.addEventListener("toggle", function() {
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
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
detailsElement.addEventListener("toggle", toggleCheckboxes);
@@ -440,8 +695,7 @@
}, 200);
document.getElementById("reload-finding").addEventListener("click", function () {
importAnnotations_root(annotationjson3d)
importViewerState_root(viewerState);
window.mountDicomViewers();
});
// Initialize the DICOM viewer with the images and annotations
@@ -467,4 +721,16 @@
background-color: blue;
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>
+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 %}
+30 -20
View File
@@ -143,24 +143,19 @@
{% 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">
<div id="series_formset" class="list_formset">
<ol>
{% for form in series_formset %}
<li class="no-error series-formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form | crispy}}
</li>
{% endfor %}
</ol>
<!-- Differential Collapsible Panel -->
<div class="card my-3 bg-dark text-white">
<div class="card-header">
<a class="collapsed text-white" data-bs-toggle="collapse" href="#differentialPanel" role="button" aria-expanded="false" aria-controls="differentialPanel">
<h3 class="d-inline">Differential</h3>
<span class="ms-2">(click to expand/collapse)</span>
</a>
</div>
{{ series_formset.management_form | crispy }}
<h3>Differential:</h3>
Add differential here.
<input type="button" value="Add Another Differential" id="add_more_differential">
<div class="collapse" id="differentialPanel">
<input type="button" value="Add Another Differential" id="add_more_differential" class="btn btn-sm btn-primary float-end">
<div class="card-body">
<div id="casedifferential_formset" class="list_formset">
<ol>
{% for form in casedifferential_formset %}
@@ -173,9 +168,21 @@
</ol>
</div>
{{ casedifferential_formset.management_form | crispy }}
<h3>Resource:</h3>
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>
</div>
</div>
<!-- Resource Collapsible Panel -->
<div class="card my-3 bg-dark text-white">
<div class="card-header">
<a class="collapsed text-white" data-bs-toggle="collapse" href="#resourcePanel" role="button" aria-expanded="false" aria-controls="resourcePanel">
<h3 class="d-inline">Resource</h3>
<span class="ms-2">(click to expand/collapse)</span>
</a>
</div>
<div class="collapse" id="resourcePanel">
<input type="button" value="Add Another Resource" id="add_more_resource" class="btn btn-sm btn-primary float-end">
<div class="card-body">
<div id="caseresource_formset" class="list_formset">
<ol>
{% for form in caseresource_formset %}
@@ -188,6 +195,9 @@
</ol>
</div>
{{ caseresource_formset.management_form | crispy}}
</div>
</div>
</div>
<br/>
<input type="submit" class="submit-button" value="Submit" name="submit">
</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 %}
<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>
<summary>
<h4>Filter</h4>
@@ -20,8 +22,65 @@
</div>
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
</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 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>
{% endblock %}
@@ -1,5 +1,6 @@
{% extends "atlas/exams.html" %}
<!-- {% load static from static %} -->
{% load crispy_forms_tags %}
{% block css %}
{% endblock %}
@@ -36,8 +37,8 @@
<form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form">
{% csrf_token %}
{{ form }}
<h3>Cases:</h3>
{% crispy form form.helper %}
{% 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.
<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" />
@@ -54,13 +55,14 @@
</div>
{{ case_formset.management_form }}
<input type="submit" class="submit-button" value="Submit" name="submit">
</form>
<div id="empty_case_form" style="display:none">
<li class='no_error case-formset'>
{{ case_formset.empty_form }}
</li>
</div>
</div> {% endcomment %}
{% comment %} <input type="submit" class="submit-button" value="Submit" name="submit"> {% endcomment %}
</form>
<script>
$(document).ready(() => {
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 %}
+52 -31
View File
@@ -1,47 +1,24 @@
{% extends 'atlas/exams.html' %}
{% load partials %}
{% block content %}
<h2>{{collection.name}}</h2>
{% include 'exam_notes.html' %}
<div>
Exam mode: {{collection.exam_mode}} (Exam open acess: {{collection.exam_open_access}})<br />
Publish results: {{collection.publish_results}}<br />
Active: {{collection.active}}<br />
Exam mode: {{collection.exam_mode}} (open access: {{collection.exam_open_access}})<br />
{% include "generic/partials/exams/exam_status.html#publish-results" %}
{% include "generic/partials/exams/exam_status.html#exam-active" %}
Collection Type: {{collection.get_collection_type_display}}<br />
Self review: {{collection.self_review}}<br />
Open access: {{collection.open_access}}<br />
</div>
<h3>Cases</h3>
<ol id="full-question-list" class="sortable">
{% 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>
: {{casedetail.case.title}}
{% if collection.collection_type == "QUE" %}
(<a href='{% url "atlas:collection_case_details" casedetail.collection.pk casedetail.case.pk %}'>edit questions</a>
&nbsp;
{% if casedetail.question_schema %}
<i class="bi bi-question-square text-success" title="This case has questions defined."></i>
{% else %}
<i class="bi bi-question-square text-danger" title="This case has no questions defined."></i>
{% endif %}
)
{% endif %}
{% if casedetail.case.previous_case %}
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>manage priors</a>)
{% endif %}
</li>
{% endfor %}
</ol>
{% if collection.collection_type == "VIV" %}
<p>View as a viva collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
<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
@@ -53,8 +30,52 @@
<p>Review collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
{% endif %}
<h3>Cases</h3>
<ol id="full-question-list" class="sortable">
{% for casedetail in casesdetails %}
<li data-question_pk={{casedetail.case.pk}}>
<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" %}
(<a href='{% url "atlas:collection_case_details" casedetail.collection.pk casedetail.case.pk %}'>
{% if casedetail.question_schema %}
<i class="bi bi-question-square text-success" title="This case has questions defined."></i>
{% else %}
<i class="bi bi-question-square text-danger" title="This case has no questions defined."></i>
{% endif %}
</a>
)
{% endif %}
{% if casedetail.case.previous_case %}
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>
<i class="bi bi-link-45deg" title="Manage priors"></i></a>)
{% endif %}
</li>
{% endfor %}
</ol>
{% 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'
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">
@@ -1,5 +1,6 @@
{% if request.user.is_authenticated %}
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_mark_overview' collection.pk %}">Mark</a> /
<a href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a> /
+201 -17
View File
@@ -2,30 +2,41 @@
{% block content %}
<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>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>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>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>
<div class="container">
<div class="row">
<div class="col-sm">
<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>
<ol id="" class="sortable">
{% for case in cases %}
{% for casedetail in casedetails %}
{% with case=casedetail.case %}
<li data-question_pk={{case.pk}}>
{{case.casedetail.sort_order}}
<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;
{% 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 %}
{% 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 %}
{% comment %} <button class="open-case no-padding" data-target="{% url 'atlas:collection_viva_case' collection.pk forloop.counter0 %}" data-type="case">Load</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</button>
<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}}>
Case {{forloop.counter}}: {{case.title}} &nbsp;
<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>
<button class="open-case-local no-padding" data-target=case.pk data-type="case">Load</button>
<!-- Edit mode link for case -->
<button class="view-link-case btn btn-outline-primary btn-sm ms-2" style="display:none;">
<a href="{% url 'atlas:case_detail' case.pk %}" style="color: inherit; text-decoration: none;">View Case</a>
</button>
<button class="edit-link-case btn btn-outline-warning btn-sm ms-2" style="display:none;">
<a href="{% url 'atlas:case_update' case.pk %}" style="color: inherit; text-decoration: none;">Edit Case</a>
</button>
<br />
<div class="pre-whitespace multi-image-block">
<details class="series-detail"><summary>Series:</summary>
@@ -33,24 +44,64 @@
<span class="series-block">
<span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
test
{{series.get_block}}
<br />
{% 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 %}">
show
</button> {% endcomment %}
{% 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 %}
<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 %}">
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>
{% endfor %}
</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>
</li>
{% endwith %}
{% endfor %}
</ol>
</div>
@@ -77,8 +128,60 @@
{% block js %}
<script type="text/javascript">
var win2 = false;
let editMode = false;
$(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");
bc.onmessage = (event) => {
console.log("message received");
@@ -88,6 +191,12 @@
$("#open-viewer").removeClass("flash-button");
//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')
$('.show-collection, .show-case, .show-series').click(function() {
@@ -96,6 +205,68 @@
$('#case-title').text($(this).data('title'));
$('#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() {
var url = $(this).data('target');
console.log('clicked', url)
@@ -103,7 +274,7 @@
let c = $(this).closest(".case-item");
case_details = c.data('case-json');
case_details = c.data('casejson');
console.log(case_details);
$('#loading-case').show()
@@ -137,6 +308,11 @@
openSecondaryWindow("{% url 'atlas:collection_viewer' %}");
//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() {
$(".series-detail").attr("open", (_, attr) => !attr);
//document.getElementsByClassName("series-detail").toggleAttribute("open");
@@ -146,7 +322,11 @@
});
function openSecondaryWindow(url) {
//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>
@@ -183,5 +363,9 @@
.title {
font-weight: bold;
}
.edit-link-case, .edit-link-series, .edit-link-displayset {
vertical-align: middle;
}
</style>
{% endblock css %}
+44 -6
View File
@@ -19,18 +19,56 @@
<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 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>
<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>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>
<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>
<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>
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>
<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>
<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.
<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 %}
@@ -16,7 +16,7 @@
<a href="{{ prev_case.get_absolute_url }}">{{ case.title }}</a>
</li>
{% partial case-series %}
{% include "atlas/case_display_block.html#case-series" %}
{% endfor %}
</ul>
{% else %}
+182 -24
View File
@@ -11,20 +11,22 @@
</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>
<details>
<summary>Help</summary>
<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 class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<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>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>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>
<summary>Settings</summary>
<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-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>
</div>
@@ -40,8 +42,11 @@
<h5>Uploaded</h5>
<ul id="uploaded-files"></ul>
<h5>Duplicates</h5>
<ul id="duplicate-files"></ul>
<ul id="duplicate-series"></ul>
<details>
<summary>Files</summary>
<ul id="duplicate-files"></ul>
</details>
<h5>Failed</h5>
<ul id="failed-files"></ul>
</div>
@@ -58,6 +63,7 @@
</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>
@@ -84,12 +90,145 @@
{% block js %}
<script src="https://cdn.jsdelivr.net/npm/hash-wasm@4"></script>
<script src="{% static 'js/upload_form_helpers.js' %}"></script>
<script>
window.to_upload = [];
window.upload_count = 1;
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) {
for (let i = 0; i < arr.length; i += n) {
yield arr.slice(i, i + n);
@@ -170,12 +309,32 @@
if (current_chunk + 1 == total_chunks) {
$("#loading").hide();
// If there are duplicates, open the details and highlight the series
if (window.duplicate_series.size > 0) {
$("#upload-results").attr("open", true);
$("#duplicate-series").closest("details").attr("open", true);
// 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) => {
let item = document.createElement("a");
item.href = element;
item.textContent = element;
$("#duplicate-series").append(item, "<br/>");
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/>"));
alert("Uploading complete")
}
@@ -220,7 +379,6 @@
errors.push(`Patient ID starts with ${site_codes[i]} (${patient_id})`);
}
}
console.log(errors)
if (not_anon) {
toastr.warning(`File does not appear to be annonymised<br/>Accession: ${accession_number} <br/>Patient ID: ${patient_id}<br/>`, 'Anonymisation warning', {
@@ -453,20 +611,10 @@
}
$(document).ready(function () {
document.getElementById("filepicker").addEventListener(
"change",
(event) => {
//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,
);
document.getElementById("filepicker").addEventListener("change", async () => {
await loadDicomViewerUploads();
await processFilesWithDuplicateCheck();
});
// Add event listener to the button element
@@ -482,3 +630,13 @@
end
</script>
{% endblock %}
{% block css %}
<style>
.duplicate-file-highlight {
background: #fff3cd !important;
border-left: 5px solid #dc3545 !important;
color: #856404 !important;
}
</style>
{% 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">
<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_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 %}"
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>
+49 -2
View File
@@ -1,8 +1,6 @@
{% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %}
{% block css %}
{% endblock %}
{% block content %}
@@ -20,8 +18,57 @@
</div>
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
</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 %}
</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>
{% endblock %}
{% block js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
});
</script>
{% endblock %}
{% block css %}
<style>
</style>
{% endblock css %}
+113 -35
View File
@@ -38,14 +38,15 @@ Associated case:
</details>
{% endif %}
{% endfor %}
<div>Author: {{ series.get_author_display }}</div>
{% 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=''>
</div> {% endcomment %}
<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;"
data-auto-cache-stack=true
style="max-width: 1000px; height: 600px; box-sizing: border-box; background: #222;"
data-auto-cache-stack=false
></div>
@@ -82,13 +83,9 @@ Associated case:
<button id="reset-viewport-button">Reset viewport</button>
{% endif %}
<details open>
<details class="styled-detail open">
<summary>Findings</summary>
{% for finding in series.findings.all %}
<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>
@@ -121,11 +118,13 @@ Associated case:
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="delete-finding-link">Delete</a>
{% endif %}
</div>
{% empty %}
<p>No findings available.</p>
{% endfor %}
</details>
{% if can_edit %}
<details>
<details class="styled-detail">
<summary>Truncate series</summary>
<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.
@@ -145,23 +144,29 @@ Associated case:
{% endwith %}
<div>Author: {{ series.get_author_display }}</div>
{% if can_edit %}
<details>
<details class="styled-detail">
<summary>Series info</summary>
<div>
<a href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}"
title="Anonymise dicom images">Anonymise dicoms</a><br />
<a href="{% url 'atlas:series_order_dicom' pk=series.pk %}" title="orders dicom by slice location">Order dicoms
by slice location</a>
<a href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}"
title="orders dicom by instance number">Order dicoms by instance number</a>
<a href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}"
title="orders dicom by instance number">Order dicoms by SeriesInstanceUID</a>
<a href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}"
title="orders dicom by uploaded filename">Order by uploaded filename</a><br/>
<a href="{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}"
title="split series by dicom tag ImageType">Split the series by the ImageType dicom tag</a>
<div class="d-flex flex-wrap gap-2 mb-2">
<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">
Anonymise dicoms
</button>
<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">
Order by slice location
</button>
<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">
Order by instance number
</button>
<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">
Order by SeriesInstanceUID
</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>
<details>
<summary>
@@ -172,28 +177,17 @@ Associated case:
>
{% 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}}
{% if image.image %}
[{{image.get_file_size|filesizeformat}}]
{% endif %}
{{image.image_md5_hash}} ({{image.is_dicom}}) {{image.image_blake3_hash}}
<input type="checkbox" name="image-id" value="{{image.id}}">
<br />
{% comment %} {{image.get_dicom_info|safe}}<br /> {% endcomment %}
{% endfor %}
<button>Diff files</button>
</form>
<span id="temp"></span>
</details>
<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>
</details>
<span id="diff-controls">show <span id="diff-show-all" _="on click
log '1'
for row in .diff.rows
@@ -209,8 +203,18 @@ Associated case:
end
">diff</span></span>
</details>
<p>Total image size: {{series.get_total_image_size|filesizeformat}}</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>
{% endif %}
<div id="clone-findings-modal"
class="modal modal-blur fade"
style="display: none"
@@ -386,4 +390,78 @@ Associated case:
#finding-form #hidden-form:focus-within {
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>
+81 -16
View File
@@ -68,23 +68,16 @@
hx-confirm="This will clear selected uploads, Continue?"
title="Deleted selected uploads"
>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-button"
hx-post="{% url 'api-1:import_dicoms' %}"
hx-target=".imported"
{% comment %} hx-indicator=".indicator" {% endcomment %}
hx-include=".selected,[name='order-series']"
>Import</button>
{% endif %}
<button id="import-dicoms-sequential-button"
type="button"
class="btn btn-primary"
>Import {% if case %}into case{% endif %} (one at a time)</button>
<div id="import-status"></div>
<div class="indicator"><div class="loader"></div>Loading...</div>
<div id="import-status"></div>
<div class="imported">
@@ -96,6 +89,75 @@
<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 %}
@@ -155,7 +217,10 @@
.series-block-popup-link {
float: right;
}
#series-list li.imported {
border-color: #e6ffe6;
opacity: 0.7;
}
</style>
{% 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 parent in child2.get_parents()
print(parent.get_descendents())
#@pytest.fixture
#def create_basic_user(db, django_user_model):
+55
View File
@@ -70,6 +70,7 @@ urlpatterns = [
name="exam_update",
),
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>/viva", views.collection_viva, name="collection_viva"),
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",
),
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(
"collection/<int:exam_id>/cids",
views.GenericExamViews.exam_cids,
@@ -90,6 +101,28 @@ urlpatterns = [
views.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(
"collection/<int:exam_id>/history/<int:user_pk>/user",
views.collection_history_user,
@@ -218,6 +251,11 @@ urlpatterns = [
views.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(
"collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>",
views.collection_case_view_take,
@@ -244,6 +282,11 @@ urlpatterns = [
name="collection_dicom_json",
),
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(
"case/<int:pk>/dicom_json",
views.case_dicom_json,
@@ -259,6 +302,12 @@ urlpatterns = [
views.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(
"series/<int:pk>/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>/<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/migration-status/', views.seriesfinding_migration_status, name='seriesfinding_migration_status'),
path("uncategorised_dicoms/", views.uncategorised_dicoms, name="uncategorised_dicoms_view"),
@@ -412,6 +462,11 @@ urlpatterns = [
views.AtlasUpdate.as_view(),
name="case_update",
),
path(
"case/<int:pk>/update/series",
views.AtlasSeriesUpdate.as_view(),
name="case_series_update",
),
path(
"series/<int:pk>/update",
views.SeriesUpdate.as_view(),
+533 -82
View File
@@ -4,10 +4,14 @@ from typing import Any
from dal import autocomplete
from django.db.models.base import Model as Model
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 import forms
from django.utils import timezone
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.decorators import login_required, user_passes_test
@@ -24,7 +28,7 @@ from django.views.generic import View
from django.views.generic.detail import DetailView
import pydicom
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 import ListView
@@ -45,9 +49,12 @@ from .forms import (
CaseCollectionAuthorForm,
CaseCollectionCaseFormSet,
CaseCollectionForm,
CaseCollectionUpdateCaseForm,
CaseDetailForm,
CaseDisplaySetForm,
CaseForm,
CaseResourceFormSet,
CaseUpdateSeriesForm,
CidQuestionAnswerForm,
CidReportAnswerForm,
CidReportAnswerMarkForm,
@@ -75,7 +82,9 @@ from .models import (
Case,
CaseCollection,
CaseDetail,
CaseDisplaySet,
CasePrior,
CaseResource,
CidReportAnswer,
Condition,
Differential,
@@ -87,6 +96,7 @@ from .models import (
Series,
Examination,
Finding,
SeriesDetail,
Structure,
Subspecialty,
SeriesFinding,
@@ -193,18 +203,148 @@ class AtlasEditorRequiredMixin(object):
return obj
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
@user_has_case_view_access
def case_detail(request, pk):
case = get_object_or_404(Case, pk=pk)
# if request.user not in atlas.author.all():
# raise PermissionDenied
# Prefetch all related objects needed for the template
case = (
Case.objects
.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)
# logging.debug(atlas.subspecialty.first().name.all())
return render(
request,
"atlas/case_detail.html",
@@ -215,7 +355,6 @@ def case_detail(request, pk):
},
)
@login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_thumbnail_fail(request, pk):
@@ -498,6 +637,91 @@ def user_collections(request):
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):
if not request.htmx:
@@ -507,9 +731,12 @@ def add_case_to_collection(request, collection_id):
if request.method == "POST":
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():
return HttpResponse("Case already in collection")
print(case)
collection.add_case(case)
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):
context = super(CaseCollectionUpdate, self).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},
)
#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
@@ -823,6 +1050,7 @@ class CaseCollectionUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateVi
form.instance.author.add(self.request.user.id)
return super().form_valid(form)
context = self.get_context_data(form=form)
case_formset = context["case_formset"]
# logger.debug(formset.is_valid())
@@ -956,11 +1184,11 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
def get_context_data(self, **kwargs):
context = super(AtlasCreateBase, self).get_context_data(**kwargs)
if self.request.POST:
context["series_formset"] = SeriesFormSet(
self.request.POST,
self.request.FILES,
form_kwargs={"user": self.request.user},
)
#context["series_formset"] = SeriesFormSet(
# self.request.POST,
# self.request.FILES,
# form_kwargs={"user": self.request.user},
#)
context["casedifferential_formset"] = CaseDifferentialFormSet(
self.request.POST, self.request.FILES
)
@@ -969,14 +1197,14 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
self.request.FILES,
form_kwargs={"user": self.request.user},
)
context["series_formset"].full_clean()
#context["series_formset"].full_clean()
context["casedifferential_formset"].full_clean()
context["caseresource_formset"].full_clean()
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(
form_kwargs={"user": self.request.user}
)
#context["series_formset"] = SeriesFormSet(
# form_kwargs={"user": self.request.user}
#)
context["casedifferential_formset"] = CaseDifferentialFormSet()
context["caseresource_formset"] = CaseResourceFormSet(
form_kwargs={"user": self.request.user}
@@ -996,17 +1224,17 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
# CimarAPI().get_study_by_uuid(self.object.cimar_uuid)
context = self.get_context_data(form=form)
series_formset = context["series_formset"]
#series_formset = context["series_formset"]
casedifferential_formset = context["casedifferential_formset"]
caseresource_formset = context["caseresource_formset"]
if (
series_formset.is_valid()
and casedifferential_formset.is_valid()
#series_formset.is_valid()
casedifferential_formset.is_valid()
and caseresource_formset.is_valid()
):
response = super().form_valid(form)
series_formset.instance = self.object
series_formset.save()
#series_formset.instance = self.object
#series_formset.save()
casedifferential_formset.instance = self.object
casedifferential_formset.save()
caseresource_formset.instance = self.object
@@ -1023,6 +1251,7 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
# @login_required
# CaseCreate
class AtlasCreate(AtlasCreateBase, CreateView):
def get_initial(self):
if "pk" in self.kwargs:
@@ -1041,6 +1270,7 @@ class AtlasCreate(AtlasCreateBase, CreateView):
# return self.initial
# CaseUpdate
class AtlasUpdate(
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
):
@@ -1052,6 +1282,25 @@ class AtlasUpdate(
# #initial = {'date_of_death': '05/01/2018'}
# 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):
kwargs = super(AtlasUpdate, self).get_form_kwargs()
kwargs.update({"user": self.request.user})
@@ -1060,12 +1309,12 @@ class AtlasUpdate(
def get_context_data(self, **kwargs):
context = super(AtlasUpdate, self).get_context_data(**kwargs)
if self.request.POST:
context["series_formset"] = SeriesFormSet(
self.request.POST,
self.request.FILES,
instance=self.object,
form_kwargs={"user": self.request.user},
)
#context["series_formset"] = SeriesFormSet(
# self.request.POST,
# self.request.FILES,
# instance=self.object,
# form_kwargs={"user": self.request.user},
#)
context["casedifferential_formset"] = CaseDifferentialFormSet(
self.request.POST, self.request.FILES, instance=self.object
)
@@ -1075,13 +1324,13 @@ class AtlasUpdate(
instance=self.object,
form_kwargs={"user": self.request.user},
)
context["series_formset"].full_clean()
#context["series_formset"].full_clean()
context["casedifferential_formset"].full_clean()
context["caseresource_formset"].full_clean()
else:
context["series_formset"] = SeriesFormSet(
instance=self.object, form_kwargs={"user": self.request.user}
)
#context["series_formset"] = SeriesFormSet(
# instance=self.object, form_kwargs={"user": self.request.user}
#)
context["casedifferential_formset"] = CaseDifferentialFormSet(
instance=self.object
)
@@ -1098,18 +1347,18 @@ class AtlasUpdate(
form.instance.author.add(self.request.user.id)
context = self.get_context_data(form=form)
series_formset = context["series_formset"]
#series_formset = context["series_formset"]
casedifferential_formset = context["casedifferential_formset"]
caseresource_formset = context["caseresource_formset"]
# logger.debug(formset.is_valid())
if (
series_formset.is_valid()
and casedifferential_formset.is_valid()
#series_formset.is_valid()
casedifferential_formset.is_valid()
and caseresource_formset.is_valid()
):
response = super().form_valid(form)
series_formset.instance = self.object
series_formset.save()
#series_formset.instance = self.object
#series_formset.save()
casedifferential_formset.instance = self.object
casedifferential_formset.save()
caseresource_formset.instance = self.object
@@ -1119,6 +1368,110 @@ class AtlasUpdate(
else:
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):
if request.method == "POST":
@@ -1230,7 +1583,7 @@ def get_examination_id(request):
return HttpResponse("/")
class CaseView(LoginRequiredMixin, SingleTableMixin, FilterView):
class CaseView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Case
table_class = CaseTable
template_name = "atlas/case_view.html"
@@ -1238,7 +1591,7 @@ class CaseView(LoginRequiredMixin, SingleTableMixin, FilterView):
filterset_class = CaseFilter
class SeriesView(LoginRequiredMixin, SingleTableMixin, FilterView):
class SeriesView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Series
table_class = SeriesTable
template_name = "atlas/series_view.html"
@@ -1246,7 +1599,7 @@ class SeriesView(LoginRequiredMixin, SingleTableMixin, FilterView):
filterset_class = SeriesFilter
class ConditionView(LoginRequiredMixin, SingleTableMixin, FilterView):
class ConditionView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Condition
table_class = ConditionTable
template_name = "atlas/view.html"
@@ -1254,7 +1607,7 @@ class ConditionView(LoginRequiredMixin, SingleTableMixin, FilterView):
filterset_class = ConditionFilter
class QuestionSchemaView(LoginRequiredMixin, SingleTableMixin, FilterView):
class QuestionSchemaView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = QuestionSchema
table_class = QuestionSchemaTable
template_name = "atlas/schema_table_view.html"
@@ -1262,7 +1615,7 @@ class QuestionSchemaView(LoginRequiredMixin, SingleTableMixin, FilterView):
filterset_class = QuestionSchemaFilter
class SubspecialtyView(LoginRequiredMixin, SingleTableMixin, FilterView):
class SubspecialtyView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Subspecialty
table_class = SubspecialtyTable
template_name = "atlas/view.html"
@@ -1270,7 +1623,7 @@ class SubspecialtyView(LoginRequiredMixin, SingleTableMixin, FilterView):
filterset_class = SubspecialtyFilter
class PresentationView(LoginRequiredMixin, SingleTableMixin, FilterView):
class PresentationView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Presentation
table_class = PresentationTable
template_name = "atlas/view.html"
@@ -1278,7 +1631,7 @@ class PresentationView(LoginRequiredMixin, SingleTableMixin, FilterView):
filterset_class = PresentationFilter
class PathologicalProcessView(LoginRequiredMixin, SingleTableMixin, FilterView):
class PathologicalProcessView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = PathologicalProcess
table_class = PathologicalProcessTable
template_name = "atlas/view.html"
@@ -1286,7 +1639,7 @@ class PathologicalProcessView(LoginRequiredMixin, SingleTableMixin, FilterView):
filterset_class = PathologicalProcessFilter
class StructureView(LoginRequiredMixin, SingleTableMixin, FilterView):
class StructureView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Structure
table_class = StructureTable
template_name = "atlas/view.html"
@@ -1294,7 +1647,7 @@ class StructureView(LoginRequiredMixin, SingleTableMixin, FilterView):
filterset_class = StructureFilter
class FindingView(LoginRequiredMixin, SingleTableMixin, FilterView):
class FindingView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Finding
table_class = FindingTable
template_name = "atlas/view.html"
@@ -1305,7 +1658,7 @@ class FindingView(LoginRequiredMixin, SingleTableMixin, FilterView):
@login_required
def case_order_dicom(request, pk):
if request.htmx:
case = get_object_or_404(Case, pk=pk)
case: Case = get_object_or_404(Case, pk=pk)
fail = []
# 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():
series.order_by_dicom()
else:
fail.append(series.pk)
fail.append(f"{series.pk}: invalid permission")
except:
fail.append(series.pk)
fail.append(f"{series.pk}: error")
if fail:
return HttpResponse(
@@ -1349,12 +1702,12 @@ def image_diff(request):
first: SeriesImage = get_object_or_404(SeriesImage, pk=image_ids[0])
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
# j1 = first_ds.to_json_dict()
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
# j2 = second_ds.to_json_dict()
s = print_dicom(second_ds, join=False)
@@ -1371,7 +1724,7 @@ def image_diff(request):
ds_set = []
for pk in image_ids:
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))
return HttpResponse(f"<pre>{dict_diff}</pre>")
@@ -1611,22 +1964,39 @@ class CollectionView(LoginRequiredMixin, SingleTableMixin, FilterView):
def collection_viva(request, 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")
# .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(
"series",
"series__images",
"series__contrast",
"series__plane",
"series__examination",
# Prefetch series and their images directly on the case
"case__series",
"case__series__modality",
"case__series__examination",
"case__series__plane",
"case__series__contrast",
"case__series__images",
)
)
return render(
request,
"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)
# cases = collection.cases.all().order_by("casedetail__sort_order")
casedetails = CaseDetail.objects.filter(collection=collection).order_by(
"sort_order"
casedetails = (
CaseDetail.objects
.filter(collection=collection)
.select_related("case")
.annotate(series_count=Count("case__seriesdetail"))
.order_by("sort_order")
)
return render(
request,
"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
Args:
@@ -1737,13 +2113,18 @@ def collection_take_start(request, pk):
Returns:
_type_: _description_
"""
print(f"Collection take start: {pk}, cid: {cid}, passcode: {passcode}")
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
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}")
@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):
"""
View to display an overview of linked cases via the previous_case field.
@@ -3182,3 +3593,43 @@ def seriesfinding_migration_status(request):
return render(request, "atlas/seriesfinding_migration_status.html", {
"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 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 Exam as LongsExam
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 dal import autocomplete
from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
@@ -497,6 +502,7 @@ class UserGroupExamForm(ModelForm):
GROUP_TYPES = [
"anatomy_user_user_groups",
"rapid_user_user_groups",
"shorts_user_user_groups",
"longs_user_user_groups",
"physics_user_user_groups",
"sba_user_user_groups",
@@ -537,6 +543,11 @@ class UserGroupExamForm(ModelForm):
queryset=RapidsExam.objects.filter(archive=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(
required=False,
queryset=LongsExam.objects.filter(archive=False),
@@ -736,6 +747,7 @@ class SupervisorForm(ModelForm):
class ExamCollectionForm(ModelForm):
GROUP_TYPES = (
("Rapid Exams", "rapids_exams", RapidsExam),
("Shorts Exams", "shorts_exams", ShortsExam),
("Long Exams", "longs_exams", LongsExam),
("SBA Exams", "sbas_exams", SbasExam),
("Physic Exams", "physics_exams", PhysicsExam),
@@ -808,7 +820,6 @@ class ExamCollectionCloneForm(ModelForm):
model = ExamCollection
fields = ("name", "date")
from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register
class UserAutocomplete(ModelAutocomplete):
model = User
+19
View File
@@ -62,3 +62,22 @@ class AuthorMixin():
def remove_author(self, user: User):
"""Remove an author from the object"""
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
+44 -49
View File
@@ -58,6 +58,17 @@ import requests
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):
"""Function to display pretty version of our data"""
if not data:
@@ -279,7 +290,7 @@ class SeriesImageBase(models.Model):
# 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
# (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
# This is much faster when using pydicom
@@ -316,14 +327,14 @@ class SeriesImageBase(models.Model):
def get_dicom_json(self):
try:
json = pydicom.read_file(self.image).to_json()
json = pydicom.dcmread(self.image).to_json()
except pydicom.errors.InvalidDicomError:
return {}
return json
def get_dicom_info(self):
try:
info = pretty_print_dicom(pydicom.read_file(self.image))
info = pretty_print_dicom(pydicom.dcmread(self.image))
except pydicom.errors.InvalidDicomError:
info = "File is not a dicom."
return info
@@ -496,6 +507,11 @@ class SeriesBase(models.Model):
return format_html('", "'.join(images))
def get_thumbnail(self, recreate=False, fail_loudly=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:
@@ -568,48 +584,35 @@ class SeriesBase(models.Model):
n = n + 1
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)
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 = []
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:
if not sortable:
return
# ensure they are in the correct order
slices = sorted(slices, key=lambda s: s[field].value)
# Sort by the DICOM field value
sortable.sort(key=lambda x: x[0])
# print(slices)
n = 1
for f in slices:
i = map[f[field].value]
i.position = n
print(f"Setting position {n} for {i}")
i.save()
n = n + 1
# Assign new positions and collect for bulk update
for idx, (value, img) in enumerate(sortable, start=1):
img.position = idx
# Bulk update all changed images
SeriesImage = self.images.model
SeriesImage.objects.bulk_update([img for _, img in sortable], ['position'])
def get_total_image_size(self, refresh=False):
"""Returns the total size of all images in the series.
@@ -1614,6 +1617,7 @@ CID_GROUP_EXAMS = (
("Physics", "physics_cid_user_groups"),
("Anatomy", "anatomy_cid_user_groups"),
("Rapids", "rapid_cid_user_groups"),
("Shorts", "shorts_cid_user_groups"),
("Longs", "longs_cid_user_groups"),
)
@@ -1661,6 +1665,7 @@ USER_GROUP_EXAMS = (
("Physics", "physics_user_user_groups"),
("Anatomy", "anatomy_user_user_groups"),
("Rapids", "rapid_user_user_groups"),
("Shorts", "shorts_user_user_groups"),
("Longs", "longs_user_user_groups"),
)
@@ -1701,16 +1706,6 @@ class UserGrades(models.Model):
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):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
supervisor = models.ForeignKey(
+10
View File
@@ -104,6 +104,15 @@
</select>
</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>
CaseCollection Exams<br />
<select id="casecollection-exams" multiple="multiple">
@@ -213,6 +222,7 @@
sba_exams: JSON.stringify($("#sbas-exams").val()),
longs_exams: JSON.stringify($("#longs-exams").val()),
anatomy_exams: JSON.stringify($("#anatomy-exams").val()),
shorts_exams: JSON.stringify($("#shorts-exams").val()),
casecollection_exams: JSON.stringify($("#casecollection-exams").val()),
cid_groups: JSON.stringify($("#cid-groups").val()),
delete: false,
@@ -96,22 +96,10 @@ Open access: {{ exam.open_access }}<br />
{% endif %}
<div class="parent-help" title="Click to enable / disable the exam">
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>
{% include "generic/partials/exams/exam_status.html#exam-active" %}
{% if exam.exam_mode %}
<div class="parent-help" title="Click to enable / disable the exam results">
Publish results: <input type="checkbox"
{% 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>
{% include "generic/partials/exams/exam_status.html#publish-results" %}
{% 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>
{% endif %}
@@ -58,6 +58,17 @@
</div>
{% 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 %}
<h2>Physics Exams:</h2>
{% 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(
"exam/<int:pk>/toggle_active",
generic_exam_view.exam_toggle_active,
generic_exam_view.exam_toggle_active_htmx,
name="exam_toggle_active",
),
path(
"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",
),
path(
+63 -5
View File
@@ -146,6 +146,9 @@ import plotly.express as px
from django.db.models import Prefetch
from loguru import logger
class RedirectMixin:
def get_success_url(self) -> str:
if "redirect" in self.request.GET:
@@ -692,6 +695,58 @@ class ExamViews(View, LoginRequiredMixin):
data = {"status": "error"}
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)
def exam_toggle_results_published(self, request, pk):
if request.method == "POST":
@@ -796,6 +851,7 @@ class ExamViews(View, LoginRequiredMixin):
author__id=request.user.id, exam_mode=True
).order_by("name")
exam_list = exam_list | self.Exam.objects.filter(
markers__id=request.user.id, exam_mode=True
).order_by("name")
@@ -1396,6 +1452,7 @@ class ExamViews(View, LoginRequiredMixin):
app_exam_map = {}
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["longs"] = user_user.user_longs_exams
app_exam_map["physics"] = user_user.user_physics_exams
@@ -1608,7 +1665,9 @@ class ExamViews(View, LoginRequiredMixin):
):
exams = self.Exam.objects.all()
filter = self.ExtraExamFilter(request.GET, queryset=exams)
print("1")
else:
print("2")
exams = self.Exam.objects.filter(
author__id=request.user.id
) | self.Exam.objects.filter(open_access=True)
@@ -2237,7 +2296,7 @@ class ExamViews(View, LoginRequiredMixin):
ans = user_answer.get_answer()
answer_score = user_answer.get_answer_score()
if self.app_name == "longs":
if self.app_name in ("longs", "shorts"):
feedback = user_answer.candidate_feedback
match self.app_name:
@@ -2273,10 +2332,11 @@ class ExamViews(View, LoginRequiredMixin):
if not exam.publish_results and not view_all_results:
correct_answer = "*****"
answer_score = 0
logger.debug(f"{ans=} {answer_score=} {correct_answer=}")
answers.append(ans)
answers_marks.append(answer_score)
if self.app_name == "longs":
if self.app_name in ("longs", "shorts"):
answers_and_marks.append(
(ans, answer_score, correct_answer, feedback)
)
@@ -2289,7 +2349,7 @@ class ExamViews(View, LoginRequiredMixin):
case "physics":
total_score = sum(sum(i) for i in answers_marks)
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]
total_score = sum(answered)
unmarked_number = len(answers_marks) - len(answered)
@@ -2377,7 +2437,6 @@ class ExamViews(View, LoginRequiredMixin):
cached_scores = 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))
if self.app_name in ("rapids"):
@@ -2403,7 +2462,6 @@ class ExamViews(View, LoginRequiredMixin):
user_ids = set()
cids_user_id_map = {}
cids_name_map = {}
# Loop through all candidates
for cid_user_answer in cid_user_answers:
+42 -11
View File
@@ -2,6 +2,7 @@ import base64
from collections import defaultdict
import hashlib
import mimetypes
from pydicom.uid import ExplicitVRLittleEndian
from io import BytesIO
@@ -19,6 +20,7 @@ try:
from .pydicom_PIL import get_PIL_image
except:
from pydicom_PIL import get_PIL_image
import numpy as np
def image_as_base64(image_file):
"""
@@ -93,38 +95,29 @@ def get_dicom_order(path):
files = []
for fname in glob.glob(path, recursive=False):
print("loading: {}".format(fname))
files.append((fname, pydicom.dcmread(fname)))
print("file count: {}".format(len(files)))
# skip files with no SliceLocation (eg scout views)
slices = []
map = {}
skipcount = 0
for fname, f in files:
print(dir(f))
print(fname)
if hasattr(f, 'SliceLocation'):
slices.append(f)
map[f.SliceLocation] = fname
else:
skipcount = skipcount + 1
print("skipped, no SliceLocation: {}".format(skipcount))
# ensure they are in the correct order
slices = sorted(slices, key=lambda s: s.SliceLocation)
#print(slices)
for i in slices:
print(map[i.SliceLocation])
return slices
#get_dicom_order("/home/ross/Downloads/DICOM HEAD/STD4/SER1/*.dcm")
def pretty_print_dicom(dataset, indent=0):
l = print_dicom(dataset, indent)
print(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)
except AttributeError as e:
print("Error getting pixel data hash from dataset")
print(dataset)
raise InvalidDicomError("Error getting pixel data hash from dataset")
else:
@@ -294,3 +286,42 @@ def compare_dicom_datasets(*datasets: pydicom.Dataset):
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:
>>> import pydicom
>>> from pydicom.contrib.pydicom_PIL import show_PIL
>>> ds = pydicom.read_file("filename")
>>> ds = pydicom.dcmread("filename")
>>> show_PIL(ds)
Requires Numpy:
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 settings.DEBUG_CONTAINER:
import debugpy
print("DEBUG LISTEN")
debugpy.listen(("0.0.0.0", 3459))
try:
+26
View File
@@ -1388,3 +1388,29 @@ span#user-id {
margin-left: 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
+3 -4
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