Compare commits
64
Commits
820d0bc030
...
779559997e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
779559997e | ||
|
|
0db1e4c689 | ||
|
|
6b1db6b3c5 | ||
|
|
9ed8fd90b3 | ||
|
|
30523c9296 | ||
|
|
83e8c14f31 | ||
|
|
d7a410e8f8 | ||
|
|
7cc6b2959a | ||
|
|
7ff5d1d37e | ||
|
|
5270d5cd36 | ||
|
|
214b531757 | ||
|
|
963221633a | ||
|
|
149b95a7e4 | ||
|
|
8a92460cb2 | ||
|
|
a7e0438290 | ||
|
|
2fca5a098d | ||
|
|
6a8bded825 | ||
|
|
15c5b2e234 | ||
|
|
964bce8233 | ||
|
|
16a8bf3d3a | ||
|
|
9bda3f22db | ||
|
|
61c3fb8a82 | ||
|
|
f4ed53dedd | ||
|
|
125cbef6ef | ||
|
|
4afcfb6b26 | ||
|
|
2a351a8283 | ||
|
|
d61ecaac66 | ||
|
|
e71aa4c802 | ||
|
|
a8840290f5 | ||
|
|
99bc89fe20 | ||
|
|
754d46080d | ||
|
|
43d98d7adb | ||
|
|
bc5749a308 | ||
|
|
eb194aa1e6 | ||
|
|
63c342ff21 | ||
|
|
8e63dcc7c5 | ||
|
|
ddb5783f7f | ||
|
|
816b2adde2 | ||
|
|
b3304b6113 | ||
|
|
0af6b25349 | ||
|
|
c33e8c0240 | ||
|
|
a2fa94722a | ||
|
|
9200e9aae5 | ||
|
|
8806e48eb2 | ||
|
|
4460545442 | ||
|
|
ad4be1e9a6 | ||
|
|
6a919ffc53 | ||
|
|
8d80090af0 | ||
|
|
f5258d7e6f | ||
|
|
5187885c3b | ||
|
|
ec97fa0c71 | ||
|
|
8fc38557ac | ||
|
|
804cff33f4 | ||
|
|
0cdccbcdfb | ||
|
|
c8939cf385 | ||
|
|
d7499e9166 | ||
|
|
f3b6f8ef59 | ||
|
|
2ac8344b13 | ||
|
|
4df7e5aebd | ||
|
|
8229813eba | ||
|
|
153045a3e4 | ||
|
|
bc26209f07 | ||
|
|
afa3b0c0ef | ||
|
|
2f8601aa1a |
+1
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+340
-253
@@ -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) {
|
||||
//async function setUpDicom(element) {
|
||||
// console.log("setUpDicom (original)", element);
|
||||
//
|
||||
// $(element).bind('contextmenu', function (e) {
|
||||
// return false;
|
||||
// });
|
||||
//
|
||||
// $(element).dblclick((evt) => {
|
||||
// element.requestFullscreen();
|
||||
// })
|
||||
//
|
||||
// cornerstoneBase64ImageLoader.external.cornerstone = cornerstone;
|
||||
// cornerstoneWebImageLoader.external.cornerstone = cornerstone;
|
||||
// cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
|
||||
//
|
||||
// cornerstoneTools.init();
|
||||
// const PanTool = cornerstoneTools.PanTool;
|
||||
// const ZoomTool = cornerstoneTools.ZoomTool;
|
||||
// const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool;
|
||||
// const WwwcTool = cornerstoneTools.WwwcTool;
|
||||
// const WwwcRegionTool = cornerstoneTools.WwwcRegionTool;
|
||||
// const RotateTool = cornerstoneTools.RotateTool;
|
||||
// const StackScrollTool = cornerstoneTools.StackScrollTool;
|
||||
// const MagnifyTool = cornerstoneTools.MagnifyTool;
|
||||
// const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
|
||||
//
|
||||
//
|
||||
// const images = element.dataset.url.split(",");
|
||||
//
|
||||
// //console.log("Dicom - load imageId: ", imageIds);
|
||||
// //for (let index = 0; index < imageIds.length; index++) {
|
||||
// // if (imageIds[index].endsWith("dcm")) {
|
||||
// // imageIds[index] = "wadouri:" + imageIds[index];
|
||||
// // }
|
||||
//
|
||||
// //}
|
||||
// let imageIds = [];
|
||||
// for (let i = 0; i < images.length; i++) {
|
||||
// let data_url = images[i];
|
||||
// // check stack type
|
||||
// if (data_url.startsWith("data:image")) {
|
||||
// let imageId = "base64://" + data_url.split(",")[1];
|
||||
//
|
||||
// imageIds.push(imageId);
|
||||
// } else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) {
|
||||
//
|
||||
// imageIds.push(data_url);
|
||||
//
|
||||
// // Treat application/octet-stream as if they are dicoms
|
||||
// } else if (data_url.startsWith("data:application/dicom") || data_url.startsWith("data:application/octet-stream")) {
|
||||
// //stack = stack.split(";")[1];
|
||||
//
|
||||
// let dfile = await urltoFile(data_url, "dicom", "application/dicom");
|
||||
//
|
||||
// const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
|
||||
// dfile
|
||||
// );
|
||||
//
|
||||
// loadAnnotation(imageId, annotation);
|
||||
//
|
||||
// imageIds.push(imageId);
|
||||
// //cornerstone.loadImage(imageId).then(function(image) {
|
||||
// // tempFunction(image);
|
||||
// //});
|
||||
// } else {
|
||||
// let url;
|
||||
// // This doesn't seem to have any benefit
|
||||
// //if (data_url.startsWith("http")) {
|
||||
// // url = data_url;
|
||||
// //} else {
|
||||
// // url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
|
||||
// //}
|
||||
// url = data_url;
|
||||
//
|
||||
// if (url.endsWith("dcm")) {
|
||||
// url = "wadouri:" + url;
|
||||
// }
|
||||
//
|
||||
// // if there is no extension treat it as a dicom
|
||||
// if (/(?:\/|^)[^.\/]+$/.test(url)) {
|
||||
// url = "wadouri:" + url;
|
||||
// }
|
||||
//
|
||||
// imageIds.push(url);
|
||||
//
|
||||
//
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// cornerstone.enable(element);
|
||||
// cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
|
||||
// cornerstone.displayImage(element, image);
|
||||
//
|
||||
// cornerstoneTools.addToolForElement(element, PanTool);
|
||||
// cornerstoneTools.addToolForElement(element, ZoomTool);
|
||||
// cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool);
|
||||
// cornerstoneTools.addToolForElement(element, WwwcTool);
|
||||
// cornerstoneTools.addToolForElement(element, WwwcRegionTool);
|
||||
// cornerstoneTools.addToolForElement(element, RotateTool);
|
||||
// cornerstoneTools.addToolForElement(element, StackScrollTool);
|
||||
// cornerstoneTools.addToolForElement(element, MagnifyTool);
|
||||
//
|
||||
// cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
|
||||
// configuration: {
|
||||
// getTextCallback: () => {},
|
||||
// changeTextCallback: () => {},
|
||||
// allowEmptyLabel: true,
|
||||
// renderDashed: false,
|
||||
// drawHandles: false,
|
||||
// drawHandlesOnHover: true,
|
||||
// },
|
||||
// });
|
||||
//
|
||||
//
|
||||
//
|
||||
// // Enable our tools
|
||||
// // Avoid incorrect aspect ratio
|
||||
// cornerstoneTools.setToolActiveForElement(element, "Pan", {
|
||||
// mouseButtonMask: 1
|
||||
// });
|
||||
// cornerstoneTools.setToolActiveForElement(element, "Wwwc", {
|
||||
// mouseButtonMask: 2
|
||||
// });
|
||||
// cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", {
|
||||
// mouseButtonMask: 3
|
||||
// });
|
||||
// cornerstoneTools.setToolActiveForElement(element, "Zoom", {
|
||||
// mouseButtonMask: 4
|
||||
// });
|
||||
//
|
||||
// if (element.dataset.edit_annotation == "true") {
|
||||
// cornerstoneTools.setToolActiveForElement(element, "ArrowAnnotate", {
|
||||
// mouseButtonMask: 2
|
||||
// });
|
||||
// } else {
|
||||
// cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate");
|
||||
// }
|
||||
//
|
||||
// if (element.dataset.annotations) {
|
||||
// loadJsonToolStateOnCurrentImage(element.dataset.annotations)
|
||||
// }
|
||||
//
|
||||
// cornerstone.resize(element);
|
||||
//
|
||||
//
|
||||
// }).catch((err, err2) => {
|
||||
// console.log(err);
|
||||
// });
|
||||
//
|
||||
//}
|
||||
//console.log("setUpDicom defined");
|
||||
//
|
||||
//// keep reference to the existing 2D setup
|
||||
//window.setUpDicomClassic = setUpDicom;
|
||||
|
||||
console.log("Dicom element found ", $(element));
|
||||
async function setUpDicom3d(element) {
|
||||
console.log("setUpDicom3d (cornerstone3d) for", element);
|
||||
|
||||
$(element).bind('contextmenu', function (e) {
|
||||
return false;
|
||||
});
|
||||
|
||||
$(element).dblclick((evt) => {
|
||||
element.requestFullscreen();
|
||||
})
|
||||
|
||||
cornerstoneBase64ImageLoader.external.cornerstone = cornerstone;
|
||||
cornerstoneWebImageLoader.external.cornerstone = cornerstone;
|
||||
cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
|
||||
|
||||
cornerstoneTools.init();
|
||||
const PanTool = cornerstoneTools.PanTool;
|
||||
const ZoomTool = cornerstoneTools.ZoomTool;
|
||||
const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool;
|
||||
const WwwcTool = cornerstoneTools.WwwcTool;
|
||||
const WwwcRegionTool = cornerstoneTools.WwwcRegionTool;
|
||||
const RotateTool = cornerstoneTools.RotateTool;
|
||||
const StackScrollTool = cornerstoneTools.StackScrollTool;
|
||||
const MagnifyTool = cornerstoneTools.MagnifyTool;
|
||||
const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
|
||||
|
||||
|
||||
const images = element.dataset.url.split(",");
|
||||
|
||||
//console.log("Dicom - load imageId: ", imageIds);
|
||||
//for (let index = 0; index < imageIds.length; index++) {
|
||||
// if (imageIds[index].endsWith("dcm")) {
|
||||
// imageIds[index] = "wadouri:" + imageIds[index];
|
||||
// }
|
||||
|
||||
//}
|
||||
let imageIds = [];
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
let data_url = images[i];
|
||||
// check stack type
|
||||
if (data_url.startsWith("data:image")) {
|
||||
let imageId = "base64://" + data_url.split(",")[1];
|
||||
|
||||
imageIds.push(imageId);
|
||||
} else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) {
|
||||
|
||||
imageIds.push(data_url);
|
||||
|
||||
// Treat application/octet-stream as if they are dicoms
|
||||
} else if (data_url.startsWith("data:application/dicom") || data_url.startsWith("data:application/octet-stream")) {
|
||||
//stack = stack.split(";")[1];
|
||||
|
||||
let dfile = await urltoFile(data_url, "dicom", "application/dicom");
|
||||
|
||||
const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
|
||||
dfile
|
||||
);
|
||||
|
||||
loadAnnotation(imageId, annotation);
|
||||
|
||||
imageIds.push(imageId);
|
||||
//cornerstone.loadImage(imageId).then(function(image) {
|
||||
// tempFunction(image);
|
||||
//});
|
||||
} else {
|
||||
let url;
|
||||
// This doesn't seem to have any benefit
|
||||
//if (data_url.startsWith("http")) {
|
||||
// url = data_url;
|
||||
//} else {
|
||||
// url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
|
||||
//}
|
||||
url = data_url;
|
||||
|
||||
if (url.endsWith("dcm")) {
|
||||
url = "wadouri:" + url;
|
||||
}
|
||||
|
||||
// if there is no extension treat it as a dicom
|
||||
if (/(?:\/|^)[^.\/]+$/.test(url)) {
|
||||
url = "wadouri:" + url;
|
||||
}
|
||||
|
||||
imageIds.push(url);
|
||||
|
||||
|
||||
}
|
||||
// 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;
|
||||
|
||||
cornerstone.enable(element);
|
||||
cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
|
||||
cornerstone.displayImage(element, image);
|
||||
// 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
|
||||
|
||||
cornerstoneTools.addToolForElement(element, PanTool);
|
||||
cornerstoneTools.addToolForElement(element, ZoomTool);
|
||||
cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool);
|
||||
cornerstoneTools.addToolForElement(element, WwwcTool);
|
||||
cornerstoneTools.addToolForElement(element, WwwcRegionTool);
|
||||
cornerstoneTools.addToolForElement(element, RotateTool);
|
||||
cornerstoneTools.addToolForElement(element, StackScrollTool);
|
||||
cornerstoneTools.addToolForElement(element, MagnifyTool);
|
||||
|
||||
cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
|
||||
configuration: {
|
||||
getTextCallback: () => {},
|
||||
changeTextCallback: () => {},
|
||||
allowEmptyLabel: true,
|
||||
renderDashed: false,
|
||||
drawHandles: false,
|
||||
drawHandlesOnHover: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Enable our tools
|
||||
// Avoid incorrect aspect ratio
|
||||
cornerstoneTools.setToolActiveForElement(element, "Pan", {
|
||||
mouseButtonMask: 1
|
||||
});
|
||||
cornerstoneTools.setToolActiveForElement(element, "Wwwc", {
|
||||
mouseButtonMask: 2
|
||||
});
|
||||
cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", {
|
||||
mouseButtonMask: 3
|
||||
});
|
||||
cornerstoneTools.setToolActiveForElement(element, "Zoom", {
|
||||
mouseButtonMask: 4
|
||||
});
|
||||
|
||||
if (element.dataset.edit_annotation == "true") {
|
||||
cornerstoneTools.setToolActiveForElement(element, "ArrowAnnotate", {
|
||||
mouseButtonMask: 2
|
||||
});
|
||||
} else {
|
||||
cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate");
|
||||
}
|
||||
|
||||
if (element.dataset.annotations) {
|
||||
loadJsonToolStateOnCurrentImage(element.dataset.annotations)
|
||||
}
|
||||
|
||||
cornerstone.resize(element);
|
||||
|
||||
|
||||
}).catch((err, err2) => {
|
||||
console.log(err);
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
// create a unique rendering engine / viewport ids per element
|
||||
const renderingEngineId = `rendingEngine`;
|
||||
const viewportId = `1`;
|
||||
|
||||
try {
|
||||
// dynamic import of cornerstone3D core + dicom loader
|
||||
const coreModule = window.cornerstone3d
|
||||
const dicomLoaderModule = window.cornerstoneDICOMImageLoader
|
||||
const toolsModule = window.cornerstone3dTools;
|
||||
|
||||
await coreModule.init();
|
||||
await dicomLoaderModule.init();
|
||||
|
||||
|
||||
await toolsModule.init();
|
||||
|
||||
const { RenderingEngine, Enums } = coreModule;
|
||||
const { chunkManager } = dicomLoaderModule; // may exist for some setups
|
||||
|
||||
// create engine and enable a Stack viewport
|
||||
const renderingEngine = new RenderingEngine(renderingEngineId);
|
||||
console.log(renderingEngine)
|
||||
|
||||
const viewportInput = {
|
||||
viewportId,
|
||||
type: Enums.ViewportType.STACK,
|
||||
element,
|
||||
//viewportType: Enums.ViewportType.STACK, // Stack viewport for slice stacks
|
||||
};
|
||||
|
||||
renderingEngine.enableElement(viewportInput);
|
||||
|
||||
const viewport = renderingEngine.getViewport(viewportId);
|
||||
|
||||
|
||||
// --- Register basic interaction tools (best-effort with @cornerstonejs/tools) ---
|
||||
// dynamic import so bundlers/CDNs provide the package
|
||||
// prefer module import, fall back to global if present
|
||||
|
||||
const { ToolGroupManager } = toolsModule;
|
||||
|
||||
toolsModule.addTool(toolsModule.PanTool)
|
||||
toolsModule.addTool(toolsModule.WindowLevelTool)
|
||||
//toolsModule.addTool(toolsModule.StackScrollTool)
|
||||
toolsModule.addTool(toolsModule.ZoomTool)
|
||||
|
||||
|
||||
|
||||
const toolGroupId = `toolgroup-${viewportId}`;
|
||||
console.log("ToolGroup id", toolGroupId);
|
||||
console.log("ToolGroupManager", ToolGroupManager);
|
||||
if (!ToolGroupManager.getToolGroup(toolGroupId)) {
|
||||
ToolGroupManager.createToolGroup(toolGroupId);
|
||||
}
|
||||
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
|
||||
|
||||
toolGroup.addTool(toolsModule.PanTool.toolName);
|
||||
toolGroup.addTool(toolsModule.WindowLevelTool.toolName);
|
||||
//toolGroup.addTool(toolsModule.StackScrollTool.toolName);
|
||||
toolGroup.addTool(toolsModule.ZoomTool.toolName);
|
||||
|
||||
// use actual tool names if available, else fall back to common strings
|
||||
//const stackName = (StackScrollTool && (StackScrollTool.toolName || StackScrollTool.name)) || 'StackScroll';
|
||||
|
||||
//toolGroup.setToolEnabled(toolsModule.PanTool.toolName, { bindings: [{ mouseButton: 1 }] });
|
||||
toolGroup.setToolActive(toolsModule.PanTool.toolName, { bindings: [{ mouseButton: 1 }] });
|
||||
toolGroup.setToolActive(toolsModule.WindowLevelTool.toolName, { bindings: [{ mouseButton: 2 }] });
|
||||
toolGroup.setToolActive(toolsModule.ZoomTool.toolName, { bindings: [{ mouseButton: 4 }] });
|
||||
toolGroup.setToolActive(toolsModule.ZoomTool.toolName, { bindings: [{ mouseButton: 524288 }] });
|
||||
toolGroup.addViewport(viewportId, renderingEngineId);
|
||||
|
||||
window.toolGroup = toolGroup; // expose for debugging
|
||||
|
||||
|
||||
// Prepare imageIds: ensure remote URLs are usable by dicom-image-loader (wadouri:)
|
||||
const imageIds = images.map((url) => {
|
||||
url = url.trim();
|
||||
// if it's already a scheme we pass through; otherwise treat as wadouri
|
||||
if (/^(wadouri:|base64:|data:)/i.test(url)) return url;
|
||||
return `wadouri:${url}`;
|
||||
});
|
||||
|
||||
// If dicom-image-loader provides a metadata cache factory, pre-cache metadata
|
||||
if (typeof dicomLoaderModule.createImageIdsAndCacheMetaData === "function") {
|
||||
try {
|
||||
// attempt to call helper to cache meta-data for the first image (best-effort)
|
||||
await dicomLoaderModule.createImageIdsAndCacheMetaData({
|
||||
imageIds,
|
||||
});
|
||||
} catch (e) {
|
||||
// not fatal — continue
|
||||
console.warn("createImageIdsAndCacheMetaData failed (continuing):", e);
|
||||
}
|
||||
}
|
||||
|
||||
await viewport.setStack(imageIds, 0);
|
||||
|
||||
viewport.render();
|
||||
|
||||
|
||||
// store references for debugging/use elsewhere
|
||||
element._cornerstone3d = {
|
||||
renderingEngineId,
|
||||
viewportId,
|
||||
renderingEngine,
|
||||
viewport,
|
||||
imageIds,
|
||||
};
|
||||
|
||||
console.log("setUpDicom3d complete", element._cornerstone3d);
|
||||
return element._cornerstone3d;
|
||||
} catch (err) {
|
||||
console.error("setUpDicom3d failed:", err);
|
||||
// per request: no fallback here — rethrow so caller can handle if desired
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
//function keyUpHandler(e) {
|
||||
// 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;
|
||||
// }
|
||||
//}
|
||||
// expose globally like existing code expects
|
||||
window.setUpDicom = setUpDicom3d;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function create_popup_window(url, title) {
|
||||
@@ -681,4 +767,5 @@ function initializeClock(id, endtime) {
|
||||
updateClock();
|
||||
}
|
||||
|
||||
window.initializeClock = initializeClock
|
||||
window.initializeClock = initializeClock
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load help_tags %}
|
||||
|
||||
{% block title %}
|
||||
Anatomy
|
||||
@@ -11,69 +12,69 @@
|
||||
{% block js %}
|
||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
||||
|
||||
<script type="text/javascript">
|
||||
function showEditPopup(url) {
|
||||
var win = window.open(url, "Edit",
|
||||
'height=500,width=800,resizable=yes,scrollbars=yes');
|
||||
return false;
|
||||
}
|
||||
function showAddPopup(triggeringLink) {
|
||||
var name = triggeringLink.id.replace(/^add_/, '');
|
||||
href = triggeringLink.href;
|
||||
var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
|
||||
win.focus();
|
||||
return false;
|
||||
}
|
||||
function closePopup(win, newID, newRepr, id) {
|
||||
console.log(id)
|
||||
$(id).append('<option value=' + newID + ` title=${newRepr} >` + newRepr + '</option>').removeAttr("selected").val(newID)
|
||||
win.close();
|
||||
}
|
||||
<script type="text/javascript">
|
||||
function showEditPopup(url) {
|
||||
var win = window.open(url, "Edit",
|
||||
'height=500,width=800,resizable=yes,scrollbars=yes');
|
||||
return false;
|
||||
}
|
||||
function showAddPopup(triggeringLink) {
|
||||
var name = triggeringLink.id.replace(/^add_/, '');
|
||||
href = triggeringLink.href;
|
||||
var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
|
||||
win.focus();
|
||||
return false;
|
||||
}
|
||||
function closePopup(win, newID, newRepr, id) {
|
||||
console.log(id)
|
||||
$(id).append('<option value=' + newID + ` title=${newRepr} >` + newRepr + '</option>').removeAttr("selected").val(newID)
|
||||
win.close();
|
||||
}
|
||||
|
||||
document.addEventListener('drop', function (e) { e.preventDefault(); }, false);
|
||||
document.addEventListener('drop', function (e) { e.preventDefault(); }, false);
|
||||
|
||||
|
||||
$(document).ready(function () {
|
||||
$("#add_examination").appendTo($("label[for='id_examination']"));
|
||||
$("#add_body_part").appendTo($("label[for='id_body_part']"));
|
||||
$(document).ready(function () {
|
||||
$("#add_examination").appendTo($("label[for='id_examination']"));
|
||||
$("#add_body_part").appendTo($("label[for='id_body_part']"));
|
||||
|
||||
|
||||
dropContainer = document.getElementById("drop-container");
|
||||
dropContainer = document.getElementById("drop-container");
|
||||
|
||||
dropContainer.ondragover = function (evt) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
};
|
||||
dropContainer.ondragover = function (evt) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
};
|
||||
|
||||
dropContainer.ondragenter = function (evt) {
|
||||
$(evt.target).addClass("drop-target-active")
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
};
|
||||
dropContainer.ondragenter = function (evt) {
|
||||
$(evt.target).addClass("drop-target-active")
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
};
|
||||
|
||||
dropContainer.ondragleave = function (evt) {
|
||||
$(evt.target).removeClass("drop-target-active")
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
};
|
||||
dropContainer.ondragleave = function (evt) {
|
||||
$(evt.target).removeClass("drop-target-active")
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
};
|
||||
|
||||
dropContainer.ondrop = function (evt) {
|
||||
console.log("drop event, ", evt);
|
||||
$(evt.target).removeClass("drop-target-active");
|
||||
dropContainer.ondrop = function (evt) {
|
||||
console.log("drop event, ", evt);
|
||||
$(evt.target).removeClass("drop-target-active");
|
||||
|
||||
// Get all input elements
|
||||
input = document.getElementById("id_image");
|
||||
input = document.getElementById("id_image");
|
||||
//fileInput = document.getElementById("id_images-0-image");
|
||||
|
||||
f = evt.dataTransfer.files[0]
|
||||
f = evt.dataTransfer.files[0]
|
||||
|
||||
let dT = new DataTransfer();
|
||||
dT.clearData();
|
||||
dT.items.add(f);
|
||||
let dT = new DataTransfer();
|
||||
dT.clearData();
|
||||
dT.items.add(f);
|
||||
|
||||
input.files = dT.files;
|
||||
input.files = dT.files;
|
||||
|
||||
ocr(input);
|
||||
ocr(input);
|
||||
|
||||
// Make sure we have enough input targets
|
||||
//input_diff = (evt.dataTransfer.files.length - inputs.length)
|
||||
@@ -119,50 +120,50 @@
|
||||
// dT.items.add(evt.dataTransfer.files[3]);
|
||||
// fileInput.files = dT.files;
|
||||
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
updateFileList();
|
||||
};
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
updateFileList();
|
||||
};
|
||||
|
||||
|
||||
$('#add_more').click(() => { add_input_form() });
|
||||
$('#add_more').click(() => { add_input_form() });
|
||||
|
||||
function updateFileList() {
|
||||
$("#drop-filenames").empty()
|
||||
$("#id_image").each((n, el) => {
|
||||
console.log(el);
|
||||
if (el.files.length > 0) {
|
||||
extra_class = " image-ident-loading";
|
||||
if ($(el).hasClass("image-ident-warning")) {
|
||||
extra_class = " image-ident-warning"
|
||||
} else if ($(el).hasClass("image-ident-ok")) {
|
||||
extra_class = " image-ident-ok"
|
||||
function updateFileList() {
|
||||
$("#drop-filenames").empty()
|
||||
$("#id_image").each((n, el) => {
|
||||
console.log(el);
|
||||
if (el.files.length > 0) {
|
||||
extra_class = " image-ident-loading";
|
||||
if ($(el).hasClass("image-ident-warning")) {
|
||||
extra_class = " image-ident-warning"
|
||||
} else if ($(el).hasClass("image-ident-ok")) {
|
||||
extra_class = " image-ident-ok"
|
||||
}
|
||||
$("#drop-filenames").append(
|
||||
`<span>${n}: ${el.files[0].name}</span><img class='uploading${extra_class}' data-input-id='${el.id}' src=${URL.createObjectURL(el.files[0])}>`
|
||||
)
|
||||
}
|
||||
$("#drop-filenames").append(
|
||||
`<span>${n}: ${el.files[0].name}</span><img class='uploading${extra_class}' data-input-id='${el.id}' src=${URL.createObjectURL(el.files[0])}>`
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
$("input[type=file]").on('change', function () {
|
||||
$(this).removeClass("image-ident-warning");
|
||||
$(this).removeClass("image-ident-ok");
|
||||
updateFileList();
|
||||
console.log("input change1");
|
||||
console.log("input change", this);
|
||||
ocr(this);
|
||||
});
|
||||
$("input[type=file]").on('change', function () {
|
||||
$(this).removeClass("image-ident-warning");
|
||||
$(this).removeClass("image-ident-ok");
|
||||
updateFileList();
|
||||
console.log("input change1");
|
||||
console.log("input change", this);
|
||||
ocr(this);
|
||||
});
|
||||
|
||||
//document.getElementById("id_question_type").selectedIndex = 1;
|
||||
answer_1_status = document.getElementById("id_answers-0-status").selectedIndex;
|
||||
if (answer_1_status == 0) {
|
||||
document.getElementById("id_answers-0-status").selectedIndex = 3;
|
||||
}
|
||||
answer_1_status = document.getElementById("id_answers-0-status").selectedIndex;
|
||||
if (answer_1_status == 0) {
|
||||
document.getElementById("id_answers-0-status").selectedIndex = 3;
|
||||
}
|
||||
|
||||
});
|
||||
function ocr(input) {
|
||||
});
|
||||
function ocr(input) {
|
||||
// Tesseract.recognize(f, "eng",{ logger: m => console.log(m) }
|
||||
// ).then(({ data: { text } }) => {
|
||||
// console.log(text);
|
||||
@@ -172,79 +173,78 @@
|
||||
// }
|
||||
// })
|
||||
|
||||
const worker = Tesseract.createWorker({
|
||||
workerPath: '{% static "worker.min.js" %}',
|
||||
langPath: '{% static "" %}',
|
||||
const worker = Tesseract.createWorker({
|
||||
workerPath: '{% static "worker.min.js" %}',
|
||||
langPath: '{% static "" %}',
|
||||
//langPath: '{{ STATIC_URL }}',
|
||||
corePath: '{% static "tesseract-core.wasm.js" %}',
|
||||
});
|
||||
corePath: '{% static "tesseract-core.wasm.js" %}',
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(input.files[0]);
|
||||
let img = new Image;
|
||||
img.src = url;
|
||||
const url = URL.createObjectURL(input.files[0]);
|
||||
let img = new Image;
|
||||
img.src = url;
|
||||
|
||||
img.onload = function () {
|
||||
img.onload = function () {
|
||||
|
||||
|
||||
width = img.width;
|
||||
width = img.width;
|
||||
|
||||
// const rectangle = { left: 0, top: 0, width: width, height: 10 }
|
||||
|
||||
|
||||
(async () => {
|
||||
await worker.load();
|
||||
await worker.loadLanguage('eng');
|
||||
await worker.initialize('eng');
|
||||
(async () => {
|
||||
await worker.load();
|
||||
await worker.loadLanguage('eng');
|
||||
await worker.initialize('eng');
|
||||
// const { data: { text } } = await worker.recognize(input.files[0], { rectangle });
|
||||
const { data: { text } } = await worker.recognize(input.files[0]);
|
||||
const { data: { text } } = await worker.recognize(input.files[0]);
|
||||
//console.log(text);
|
||||
l = text.toLowerCase();
|
||||
$(`img[data-input-id='${input.id}'`).removeClass("image-ident-loading");
|
||||
console.log(l);
|
||||
if (l.includes("accesion") || l.includes("number") || l.search(/(ref|rk9|rh8|ra9|rbz)\d+/g) > -1) {
|
||||
console.log("SHIT", input);
|
||||
$(input).addClass("image-ident-warning");
|
||||
$(`img[data-input-id='${input.id}'`).addClass("image-ident-warning");
|
||||
} else {
|
||||
$(input).addClass("image-ident-ok");
|
||||
l = text.toLowerCase();
|
||||
$(`img[data-input-id='${input.id}'`).removeClass("image-ident-loading");
|
||||
console.log(l);
|
||||
if (l.includes("accesion") || l.includes("number") || l.search(/(ref|rk9|rh8|ra9|rbz)\d+/g) > -1) {
|
||||
console.log("SHIT", input);
|
||||
$(input).addClass("image-ident-warning");
|
||||
$(`img[data-input-id='${input.id}'`).addClass("image-ident-warning");
|
||||
} else {
|
||||
$(input).addClass("image-ident-ok");
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
await worker.terminate();
|
||||
})();
|
||||
await worker.terminate();
|
||||
})();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
||||
{{ form.media }}
|
||||
{{ form.media }}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if object %}
|
||||
{% include 'anatomy/question_link_header.html' %}
|
||||
{% endif %}
|
||||
|
||||
<h2>{{object|yesno:"Update,Create"}} Question</h2>
|
||||
<details class="help-text">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p>This form can be used to create an Anatomy question.</p>
|
||||
{% if object %}
|
||||
{% include 'anatomy/question_link_header.html' %}
|
||||
{% endif %}
|
||||
|
||||
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p>
|
||||
<h2>{{object|yesno:"Update,Create"}} Question</h2>
|
||||
{% help "Help" %}
|
||||
<p>This form can be used to create an Anatomy question.</p>
|
||||
|
||||
<p>Correct and incorrect answers can be defined once the question has been created.</p>
|
||||
</details>
|
||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||
{% csrf_token %}
|
||||
<a href="/anatomy/examination/create" id="add_examination" class="add-popup"
|
||||
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
|
||||
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
|
||||
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
|
||||
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p>
|
||||
|
||||
{{ form|crispy }}
|
||||
<div id="drop-container" class="drop-target">Drop image here
|
||||
<div id="drop-filenames"></div>
|
||||
</div>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
<p>Correct and incorrect answers can be defined once the question has been created.</p>
|
||||
{% 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"
|
||||
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
|
||||
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
|
||||
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
|
||||
|
||||
{{ form|crispy }}
|
||||
<div id="drop-container" class="drop-target">Drop image here
|
||||
<div id="drop-filenames"></div>
|
||||
</div>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
+77
-91
@@ -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))
|
||||
if series_uid not in series_map:
|
||||
# Get or create Series and related objects as needed
|
||||
try:
|
||||
modality = Modality.objects.get(short_code=tags["Modality"])
|
||||
except Modality.DoesNotExist:
|
||||
modality = Modality.objects.create(short_code=tags["Modality"], modality=tags["Modality"])
|
||||
|
||||
series_list = []
|
||||
for series_uid in data:
|
||||
# Hmm probably not a good idea to blinding create a modality...
|
||||
try:
|
||||
modality = Modality.objects.get(short_code=tags["Modality"])
|
||||
except Modality.DoesNotExist:
|
||||
modality = Modality.objects.create(short_code=tags["Modality"], modality=tags["Modality"])
|
||||
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 created and tags.get("StudyDescription"):
|
||||
examination, created_exam = Examination.objects.get_or_create(
|
||||
examination=tags["StudyDescription"]
|
||||
)
|
||||
if created_exam:
|
||||
examination.modality = modality
|
||||
examination.save()
|
||||
series.examination = examination
|
||||
series.save()
|
||||
except IntegrityError:
|
||||
series = Series.objects.get(series_instance_uid=series_uid)
|
||||
|
||||
# Add author and case if needed
|
||||
series.author.add(request.user)
|
||||
if case_id is not None:
|
||||
case_object = get_object_or_404(Case, pk=case_id)
|
||||
try:
|
||||
series.case.add(case_object)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
tags = data[series_uid][0][1]
|
||||
|
||||
# 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"])
|
||||
series_map[series_uid] = series
|
||||
else:
|
||||
series = Series(
|
||||
modality=modality,
|
||||
description=tags["SeriesDescription"],
|
||||
series_instance_uid=tags["SeriesInstanceUID"],
|
||||
)
|
||||
series = series_map[series_uid]
|
||||
|
||||
if tags["StudyDescription"]:
|
||||
examination, created = Examination.objects.get_or_create(
|
||||
examination=tags["StudyDescription"]
|
||||
)
|
||||
if created:
|
||||
examination.modality = modality
|
||||
examination.save()
|
||||
series.examination = examination
|
||||
|
||||
series.save()
|
||||
|
||||
|
||||
# We might only want to add the author during creation....
|
||||
series.author.add(request.user)
|
||||
|
||||
if case_id is not None:
|
||||
case_object = get_object_or_404(Case, pk=case_id)
|
||||
if not series.case.contains(case_object):
|
||||
series.case.add(case_object)
|
||||
|
||||
for dicom, dicom_tags in data[series_uid]:
|
||||
series_image = SeriesImage(
|
||||
image=dicom.image,
|
||||
series=series,
|
||||
is_dicom=True,
|
||||
image_blake3_hash=dicom.image_blake3_hash,
|
||||
)
|
||||
series_image.save()
|
||||
# dicom.image.delete()
|
||||
|
||||
dicom.delete()
|
||||
# Create and save SeriesImage immediately
|
||||
series_image = SeriesImage(
|
||||
image=d.image,
|
||||
series=series,
|
||||
is_dicom=True,
|
||||
image_blake3_hash=d.image_blake3_hash,
|
||||
)
|
||||
series_image.save()
|
||||
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")
|
||||
@@ -214,10 +207,8 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
series.order_by_dicom("SliceLocation")
|
||||
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,39 +357,38 @@ 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):
|
||||
if n == 0:
|
||||
series.images.set(image_map[option])
|
||||
|
||||
else:
|
||||
series.pk = None
|
||||
series.save()
|
||||
series.case.set(case)
|
||||
series.author.set(authors)
|
||||
series.images.set(image_map[option])
|
||||
|
||||
new_series.append(series.pk)
|
||||
# Use a transaction for all DB changes
|
||||
with transaction.atomic():
|
||||
for n, (option, images) in enumerate(image_map.items()):
|
||||
if n == 0:
|
||||
# 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(images)
|
||||
new_series.append(series.pk)
|
||||
|
||||
return new_series
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+376
-117
@@ -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,11 +41,12 @@ from atlas.models import (
|
||||
Subspecialty,
|
||||
UncategorisedDicom,
|
||||
UserReportAnswer,
|
||||
CaseDisplaySet,
|
||||
)
|
||||
|
||||
from anatomy.models import Modality
|
||||
|
||||
from generic.models import Examination#, Sign
|
||||
from generic.models import Examination # , Sign
|
||||
|
||||
# from generic.models import Examination, Site, Condition, Sign
|
||||
|
||||
@@ -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)
|
||||
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,18 +421,40 @@ 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
|
||||
# include <script src="/admin/jsi18n"></script>
|
||||
# in the template.
|
||||
#history = forms.CharField(
|
||||
# history = forms.CharField(
|
||||
# widget=ClearableTextarea(attrs={"maxlength": 1000, "rows": 5, "cols": 80}),
|
||||
# required=False,
|
||||
#)
|
||||
# )
|
||||
|
||||
css = {
|
||||
"all": ["css/widgets.css"],
|
||||
@@ -324,7 +469,7 @@ class CaseForm(ModelForm):
|
||||
|
||||
# This is populated if we create the case from an exam/collection
|
||||
self.collection = None
|
||||
if kwargs["initial"] is not None and "exams" in kwargs["initial"]:
|
||||
if kwargs["initial"] is not None and "exams" in kwargs["initial"]:
|
||||
collections = kwargs["initial"].pop("exams")
|
||||
logging.debug(collections)
|
||||
|
||||
@@ -338,20 +483,16 @@ 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.fields["previous_case"].queryset = case_queryset
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_id = "id-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 = {
|
||||
@@ -433,7 +572,7 @@ class CaseForm(ModelForm):
|
||||
"pathological_process": CheckboxSelectMultiple(),
|
||||
"previous_case": CaseSelect(),
|
||||
}
|
||||
|
||||
|
||||
def clean_cimar_uuid(self):
|
||||
cimar_uuid = self.cleaned_data["cimar_uuid"]
|
||||
if cimar_uuid:
|
||||
@@ -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
|
||||
|
||||
@@ -477,8 +615,10 @@ class CaseForm(ModelForm):
|
||||
logging.debug(f"{instance=}")
|
||||
|
||||
# Create through model
|
||||
#cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
|
||||
self.collection.cases.add(instance, through_defaults={"sort_order": case_no})
|
||||
# cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
|
||||
self.collection.cases.add(
|
||||
instance, through_defaults={"sort_order": case_no}
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -600,19 +751,23 @@ SeriesImageFormSet = inlineformset_factory(
|
||||
|
||||
class JsonAnswerForm(Form):
|
||||
json_answer = JSONSchemaField(
|
||||
schema = {},
|
||||
#options = 'schema/options.json'
|
||||
options = 'schema/options.json'
|
||||
schema={},
|
||||
# options = 'schema/options.json'
|
||||
options="schema/options.json",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
fields = ["json_answer"]
|
||||
|
||||
def __init__(self, *args, question_schema=None, **kwargs):
|
||||
def __init__(self, *args, question_schema=None, **kwargs):
|
||||
super(JsonAnswerForm, self).__init__(*args, **kwargs)
|
||||
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,22 +778,23 @@ 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 = {
|
||||
# "answer": "Write your answer in here."
|
||||
# }
|
||||
|
||||
def __init__(self, *args, case_detail, **kwargs):
|
||||
def __init__(self, *args, case_detail, **kwargs):
|
||||
super(BaseReportAnswerForm, self).__init__(*args, **kwargs)
|
||||
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": ""}
|
||||
@@ -647,11 +803,13 @@ class BaseQuestionAnswerForm(ModelForm):
|
||||
# "answer": "Write your answer in here."
|
||||
# }
|
||||
|
||||
def __init__(self, *args, case_detail, **kwargs):
|
||||
def __init__(self, *args, case_detail, **kwargs):
|
||||
super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs)
|
||||
#self.fields["json_answer"].schema = case_detail.question_schema
|
||||
# self.fields["json_answer"].schema = case_detail.question_schema
|
||||
if case_detail.question_schema is not None:
|
||||
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
|
||||
@@ -690,17 +852,16 @@ class CidReportAnswerMarkForm(ModelForm):
|
||||
self.fields["score"].required = False
|
||||
|
||||
|
||||
#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)
|
||||
# else:
|
||||
# queryset = CaseCollection.objects.all()
|
||||
#
|
||||
#
|
||||
# self.valid_collections = queryset
|
||||
# self.case = get_object_or_404(Case, pk=pk)
|
||||
#
|
||||
@@ -732,17 +893,16 @@ 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)
|
||||
else:
|
||||
queryset = CaseCollection.objects.all()
|
||||
|
||||
|
||||
self.valid_collections = queryset
|
||||
|
||||
self.fields["casecollection"] = ModelMultipleChoiceField(
|
||||
@@ -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"]
|
||||
@@ -790,16 +944,16 @@ class SelfReviewForm(ModelForm):
|
||||
ModelForm.__init__(self, *args, **kwargs)
|
||||
super(SelfReviewForm, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
class UncategorisedDicomForm(ModelForm):
|
||||
class Meta:
|
||||
model = UncategorisedDicom
|
||||
fields = ["image"]#, "user"]
|
||||
fields = ["image"] # , "user"]
|
||||
|
||||
|
||||
#UncategorisedDicomFormset = formset_factory(UncategorisedDicomForm)
|
||||
# UncategorisedDicomFormset = formset_factory(UncategorisedDicomForm)
|
||||
|
||||
#class ConditionAutocompleteFormModel(forms.ModelForm):
|
||||
# class ConditionAutocompleteFormModel(forms.ModelForm):
|
||||
# class Meta:
|
||||
# model = Condition
|
||||
# fields = ["name"]
|
||||
@@ -810,49 +964,56 @@ 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"]
|
||||
fields = ["question_schema", "question_answers"] # , "user"]
|
||||
|
||||
widgets = {
|
||||
"question_schema": SvelteJSONEditorWidgetOverride(),
|
||||
@@ -869,20 +1030,19 @@ class QuestionSchemaForm(ModelForm):
|
||||
"schema": SvelteJSONEditorWidgetOverride(),
|
||||
}
|
||||
|
||||
#class ModelPriorCaseForm(ModelForm):
|
||||
|
||||
# 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):
|
||||
# def __init__(self, *args, **kwargs):
|
||||
# self.case_detail = kwargs.pop(
|
||||
# "case_detail"
|
||||
# ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
|
||||
@@ -897,4 +1057,103 @@ class PriorCaseForm(Form):
|
||||
# self.fields["case"] = ChoiceField(
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -541,10 +565,21 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
return True
|
||||
|
||||
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
@@ -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>
|
||||
|
||||
@@ -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}},
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if finding.conditions.all %}
|
||||
{% if finding.conditions %}
|
||||
<span>
|
||||
Conditions: {% for c in finding.conditions.all %}
|
||||
{{c.get_link}},
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if finding.structures.all %}
|
||||
{% if finding.structures %}
|
||||
<span>
|
||||
Structure: {% for s in finding.structures.all %}
|
||||
{{s.get_link}},
|
||||
@@ -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>
|
||||
Executable
+84
@@ -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 %}
|
||||
@@ -143,51 +143,61 @@
|
||||
|
||||
{% 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>
|
||||
<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 %}
|
||||
<li class="no-error casedifferential_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form | crispy}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{{ casedifferential_formset.management_form | crispy }}
|
||||
</div>
|
||||
{{ series_formset.management_form | crispy }}
|
||||
<h3>Differential:</h3>
|
||||
Add differential here.
|
||||
<input type="button" value="Add Another Differential" id="add_more_differential">
|
||||
<div id="casedifferential_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in casedifferential_formset %}
|
||||
<li class="no-error casedifferential_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form | crispy}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
</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 %}
|
||||
<li class="no-error caseresource_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form | crispy}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{{ caseresource_formset.management_form | crispy}}
|
||||
</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 id="caseresource_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in caseresource_formset %}
|
||||
<li class="no-error caseresource_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form | crispy}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{{ caseresource_formset.management_form | crispy}}
|
||||
</div>
|
||||
</div>
|
||||
<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 %}
|
||||
@@ -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 %}
|
||||
@@ -1,37 +1,70 @@
|
||||
{% 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>
|
||||
|
||||
|
||||
{% if collection.collection_type == "VIV" %}
|
||||
<p><a href='{% url "atlas:collection_viva" collection.pk %}'><button class="btn btn-primary">Start Viva</button></a>
|
||||
{% else %}
|
||||
<p>This collection will be available to view/take <a href='{{collection.get_take_url}}' id="take-url">here</a>{% if not collection.active %} (when active){% endif %} <button
|
||||
_="on click
|
||||
writeText(#take-url's href) on navigator.clipboard
|
||||
put 'copied url!' into me
|
||||
wait 1s
|
||||
put 'copy url' into me"
|
||||
class="btn btn-sm">copy link</button>
|
||||
<p>Review collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
|
||||
{% endif %}
|
||||
|
||||
<h3>Cases</h3>
|
||||
<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}}
|
||||
<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 %}
|
||||
<i class="bi bi-exclamation-circle text-warning" title="No series attached to this case"></i>
|
||||
{% 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 %}'>edit questions</a>
|
||||
|
||||
{% 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 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 %}'>manage priors</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>
|
||||
|
||||
@@ -40,21 +73,9 @@
|
||||
|
||||
|
||||
|
||||
{% if collection.collection_type == "VIV" %}
|
||||
<p>View as a viva collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
|
||||
{% else %}
|
||||
<p>This collection will be available to view/take <a href='{{collection.get_take_url}}' id="take-url">here</a>{% if not collection.active %} (when active){% endif %} <button
|
||||
_="on click
|
||||
writeText(#take-url's href) on navigator.clipboard
|
||||
put 'copied url!' into me
|
||||
wait 1s
|
||||
put 'copy url' into me"
|
||||
class="btn btn-sm">copy link</button>
|
||||
<p>Review collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
<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> /
|
||||
|
||||
@@ -2,55 +2,106 @@
|
||||
|
||||
{% 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 %}
|
||||
|
||||
<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}}
|
||||
{% 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>
|
||||
<br />
|
||||
<div class="pre-whitespace multi-image-block">
|
||||
<details class="series-detail"><summary>Series:</summary>
|
||||
{% for series in case.series.all %}
|
||||
<span class="series-block">
|
||||
<span>
|
||||
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
|
||||
{{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>
|
||||
<br>
|
||||
{% for casedetail in casedetails %}
|
||||
{% with case=casedetail.case %}
|
||||
<li data-question_pk={{case.pk}}>
|
||||
<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}}
|
||||
<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>
|
||||
{% for series in case.series.all %}
|
||||
<span class="series-block">
|
||||
<span>
|
||||
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
|
||||
test
|
||||
{{series.get_block}}
|
||||
<br />
|
||||
<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>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</details>
|
||||
{% 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>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
</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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
@@ -55,41 +60,175 @@
|
||||
<details><summary>Override annonymisation check</summary>
|
||||
<p>By clicking the button below you can override the annonymisation check and upload the files anyway. Only do this if you are sure this is a false positive.</p>
|
||||
<button id="override-anon" _="on click remove @disabled from #uploadButton then alert('Upload enabled')">Override annonymisation check</button>
|
||||
</div>
|
||||
|
||||
<h4>Selected files:</h4>
|
||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''></div>
|
||||
<ol id="listing"></ol>
|
||||
|
||||
|
||||
<div id="loading" class="fullscreen-overlay">
|
||||
<div class="progress-block">
|
||||
<div id="progress"></div>
|
||||
<div class="sk-cube-grid full">
|
||||
<div class="sk-cube sk-cube1"></div>
|
||||
<div class="sk-cube sk-cube2"></div>
|
||||
<div class="sk-cube sk-cube3"></div>
|
||||
<div class="sk-cube sk-cube4"></div>
|
||||
<div class="sk-cube sk-cube5"></div>
|
||||
<div class="sk-cube sk-cube6"></div>
|
||||
<div class="sk-cube sk-cube7"></div>
|
||||
<div class="sk-cube sk-cube8"></div>
|
||||
<div class="sk-cube sk-cube9"></div>
|
||||
</div>
|
||||
<div id="progress-text">Uploading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>Selected files:</h4>
|
||||
<div id="duplicate-progress"></div>
|
||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''></div>
|
||||
<ol id="listing"></ol>
|
||||
|
||||
|
||||
<div id="loading" class="fullscreen-overlay">
|
||||
<div class="progress-block">
|
||||
<div id="progress"></div>
|
||||
<div class="sk-cube-grid full">
|
||||
<div class="sk-cube sk-cube1"></div>
|
||||
<div class="sk-cube sk-cube2"></div>
|
||||
<div class="sk-cube sk-cube3"></div>
|
||||
<div class="sk-cube sk-cube4"></div>
|
||||
<div class="sk-cube sk-cube5"></div>
|
||||
<div class="sk-cube sk-cube6"></div>
|
||||
<div class="sk-cube sk-cube7"></div>
|
||||
<div class="sk-cube sk-cube8"></div>
|
||||
<div class="sk-cube sk-cube9"></div>
|
||||
</div>
|
||||
<div id="progress-text">Uploading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% 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();
|
||||
|
||||
window.duplicate_series.forEach((element) => {
|
||||
let item = document.createElement("a");
|
||||
item.href = element;
|
||||
item.textContent = element;
|
||||
$("#duplicate-series").append(item, "<br/>");
|
||||
});
|
||||
// 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) => {
|
||||
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', {
|
||||
@@ -244,11 +402,11 @@
|
||||
$(".anon-alert").show();
|
||||
|
||||
setTimeout(
|
||||
function(errors) {
|
||||
console.log(errors)
|
||||
function(errors) {
|
||||
console.log(errors)
|
||||
// We delay this a few seconds otherwise it does not work
|
||||
$(`.dicom-file[data-path='${file.webkitRelativePath}']`).addClass("image-ident-warning").append($(`<div>${errors.join("<br/>")}</div>`));
|
||||
}, 2000, errors
|
||||
$(`.dicom-file[data-path='${file.webkitRelativePath}']`).addClass("image-ident-warning").append($(`<div>${errors.join("<br/>")}</div>`));
|
||||
}, 2000, errors
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -478,7 +626,17 @@
|
||||
<script type="text/hyperscript">
|
||||
|
||||
init
|
||||
add @disabled to #uploadButton
|
||||
add @disabled to #uploadButton
|
||||
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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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.
|
||||
@@ -133,7 +132,7 @@ Associated case:
|
||||
<div role="alert" class="alert alert-warning">
|
||||
<strong>Warning:</strong> Please note: if you have reordered the series this may result in the incorrect images being deleted.
|
||||
</div>
|
||||
|
||||
|
||||
Start <input id="lower-truncation-bound-input" type="number" value="1"> <button id="set-lower-truncation-bound-button">Set</button><br/>
|
||||
End <input id="upper-truncation-bound-input"type="number" value="{{image_url_array_and_count.1}}"> <button id="set-upper-truncation-bound-button">Set</button><br/>
|
||||
<button id="truncate-test-button">Test truncate</button>
|
||||
@@ -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,45 +177,44 @@ 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>
|
||||
|
||||
<span id="diff-controls">show <span id="diff-show-all" _="on click
|
||||
log '1'
|
||||
for row in .diff.rows
|
||||
show row
|
||||
end">all</span> <span id="diff-show-diff" _="
|
||||
on click
|
||||
log 'go'
|
||||
for row in <.diff tr/>
|
||||
log row.children[0].innerHTML
|
||||
if row.children[0].innerHTML is empty
|
||||
hide row
|
||||
end
|
||||
end
|
||||
">diff</span></span>
|
||||
|
||||
</details>
|
||||
|
||||
<p>Total image size: {{series.get_total_image_size|filesizeformat}}</p>
|
||||
<p><a href="{% url 'atlas:series_download' pk=series.pk %}">Download images</a></p>
|
||||
<p>
|
||||
<button type="button" class="btn btn-outline-success btn-sm" onclick="window.location.href='{% url 'atlas:series_download' pk=series.pk %}'">
|
||||
Download images
|
||||
</button>
|
||||
</p>
|
||||
</details>
|
||||
<span id="diff-controls">show <span id="diff-show-all" _="on click
|
||||
log '1'
|
||||
for row in .diff.rows
|
||||
show row
|
||||
end">all</span> <span id="diff-show-diff" _="
|
||||
on click
|
||||
log 'go'
|
||||
for row in <.diff tr/>
|
||||
log row.children[0].innerHTML
|
||||
if row.children[0].innerHTML is empty
|
||||
hide row
|
||||
end
|
||||
end
|
||||
">diff</span></span>
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
<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>
|
||||
@@ -39,8 +39,8 @@
|
||||
{% endif %}
|
||||
<details><summary>Upload settings</summary>
|
||||
<form>
|
||||
<div>
|
||||
<fieldset><legend>Select how series should be ordered when uploaded</legend>
|
||||
<div>
|
||||
<fieldset><legend>Select how series should be ordered when uploaded</legend>
|
||||
<div class="helptext">
|
||||
This will affect how series are displayed when viewing with the built in site viewer. By default when exporting from Insight the order is not maintained so you should choose to order either by slice location or instance number.<br/>
|
||||
If you are not sure, leave this as the default.
|
||||
@@ -57,8 +57,8 @@
|
||||
<input type="radio" id="order-series-none" value="order-series-none" name="order-series" >
|
||||
<label for="order-series-none">None</label><br/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
@@ -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,66 +89,138 @@
|
||||
|
||||
<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 %}
|
||||
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.indicator {
|
||||
display: none;
|
||||
}
|
||||
<style>
|
||||
.indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.indicator.htmx-request {
|
||||
display: block;
|
||||
}
|
||||
.indicator.htmx-request {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 50px;
|
||||
aspect-ratio: 1;
|
||||
display: grid;
|
||||
}
|
||||
.loader::before,
|
||||
.loader::after {
|
||||
content:"";
|
||||
grid-area: 1/1;
|
||||
--c:no-repeat radial-gradient(farthest-side,#25b09b 92%,#0000);
|
||||
background:
|
||||
var(--c) 50% 0,
|
||||
var(--c) 50% 100%,
|
||||
var(--c) 100% 50%,
|
||||
var(--c) 0 50%;
|
||||
background-size: 12px 12px;
|
||||
animation: l12 1s infinite;
|
||||
}
|
||||
.loader::before {
|
||||
margin: 4px;
|
||||
filter: hue-rotate(45deg);
|
||||
background-size: 8px 8px;
|
||||
animation-timing-function: linear
|
||||
}
|
||||
.loader {
|
||||
width: 50px;
|
||||
aspect-ratio: 1;
|
||||
display: grid;
|
||||
}
|
||||
.loader::before,
|
||||
.loader::after {
|
||||
content:"";
|
||||
grid-area: 1/1;
|
||||
--c:no-repeat radial-gradient(farthest-side,#25b09b 92%,#0000);
|
||||
background:
|
||||
var(--c) 50% 0,
|
||||
var(--c) 50% 100%,
|
||||
var(--c) 100% 50%,
|
||||
var(--c) 0 50%;
|
||||
background-size: 12px 12px;
|
||||
animation: l12 1s infinite;
|
||||
}
|
||||
.loader::before {
|
||||
margin: 4px;
|
||||
filter: hue-rotate(45deg);
|
||||
background-size: 8px 8px;
|
||||
animation-timing-function: linear
|
||||
}
|
||||
|
||||
@keyframes l12 {
|
||||
100%{transform: rotate(.5turn)}
|
||||
}
|
||||
@keyframes l12 {
|
||||
100%{transform: rotate(.5turn)}
|
||||
}
|
||||
|
||||
#series-list {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
#series-list {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#series-list li {
|
||||
}
|
||||
#series-list .selected {
|
||||
color: white;
|
||||
border: 1px solid #52057b;
|
||||
user-select: none;
|
||||
margin: -1px;
|
||||
#series-list li {
|
||||
}
|
||||
#series-list .selected {
|
||||
color: white;
|
||||
border: 1px solid #52057b;
|
||||
user-select: none;
|
||||
margin: -1px;
|
||||
|
||||
}
|
||||
.series-block-popup-link {
|
||||
float: right;
|
||||
}
|
||||
#series-list li.imported {
|
||||
border-color: #e6ffe6;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
}
|
||||
.series-block-popup-link {
|
||||
float: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock css %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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):
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+534
-83
@@ -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.
|
||||
@@ -3181,4 +3592,44 @@ 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
@@ -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
|
||||
|
||||
@@ -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
|
||||
+45
-50
@@ -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,7 +507,12 @@ class SeriesBase(models.Model):
|
||||
return format_html('", "'.join(images))
|
||||
|
||||
def get_thumbnail(self, recreate=False, fail_loudly=False):
|
||||
images = self.images.filter(removed=False)
|
||||
# Use prefetched images if available
|
||||
images = getattr(self, '_prefetched_objects_cache', {}).get('images')
|
||||
if images is not None:
|
||||
images = [img for img in images if not img.removed]
|
||||
else:
|
||||
images = self.images.filter(removed=False)
|
||||
|
||||
if len(images) < 1:
|
||||
return "No images", 0
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -1387,4 +1387,30 @@ span#user-id {
|
||||
width: auto;
|
||||
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;
|
||||
}
|
||||
Vendored
+4085
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4084
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+597
@@ -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
+6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+594
@@ -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
+6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5406
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5397
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
Vendored
+12043
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Vendored
+4
-5
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+12016
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Vendored
+6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+628
-201
File diff suppressed because one or more lines are too long
Vendored
+6315
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+4450
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Vendored
+7
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
Reference in New Issue
Block a user