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 \
|
# python3 python3-dev gcc \
|
||||||
# gfortran musl-dev \
|
# gfortran musl-dev \
|
||||||
# libffi-dev openssl-dev
|
# libffi-dev openssl-dev
|
||||||
RUN apt-get update && apt-get -y install postgresql postgresql-contrib git libcairo2-dev pkg-config gcc redis-tools
|
RUN apt-get update && apt-get -y install postgresql postgresql-contrib git libcairo2-dev pkg-config gcc redis-tools watchman
|
||||||
|
|
||||||
|
|
||||||
# install dependencies
|
# install dependencies
|
||||||
|
|||||||
@@ -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):
|
if not exam.check_user_can_edit(request.user):
|
||||||
raise HttpError(HTTPStatus.UNAUTHORIZED,"Exam author required")
|
raise HttpError(HTTPStatus.UNAUTHORIZED,"Exam author required")
|
||||||
|
|
||||||
print(exam_id, cid)
|
|
||||||
|
|
||||||
return exam.exam_user_status.filter(cid_user_exam__cid_user__cid=cid)
|
return exam.exam_user_status.filter(cid_user_exam__cid_user__cid=cid)
|
||||||
|
|
||||||
@router.get('/exam/{exam_id}/user_status/{user_id}/user', response=List[ExamStatusSchema])
|
@router.get('/exam/{exam_id}/user_status/{user_id}/user', response=List[ExamStatusSchema])
|
||||||
|
|||||||
@@ -492,7 +492,6 @@ class Exam(ExamBase):
|
|||||||
return answer
|
return answer
|
||||||
|
|
||||||
def get_question_user_user_answer(self, question_index, user):
|
def get_question_user_user_answer(self, question_index, user):
|
||||||
print(user)
|
|
||||||
question = self.get_question_by_index(question_index)
|
question = self.get_question_by_index(question_index)
|
||||||
answer = UserAnswer.objects.filter(question=question, user=user.pk, exam=self)
|
answer = UserAnswer.objects.filter(question=question, user=user.pk, exam=self)
|
||||||
|
|
||||||
|
|||||||
+340
-253
@@ -76,7 +76,7 @@ $(document).ready(function () {
|
|||||||
if (dicom_images.length) {
|
if (dicom_images.length) {
|
||||||
|
|
||||||
for (let element of dicom_images) {
|
for (let element of dicom_images) {
|
||||||
setUpDicom(element)
|
setUpDicom3d(element)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -176,6 +176,37 @@ $(document).ready(function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Table row selection
|
||||||
|
// Delegate click event to all table rows
|
||||||
|
document.querySelectorAll('.row-selector tbody tr').forEach(function(row) {
|
||||||
|
row.addEventListener('click', function(e) {
|
||||||
|
// Ignore clicks on links or checkboxes
|
||||||
|
if (e.target.tagName === 'A' || e.target.tagName === 'INPUT') return;
|
||||||
|
|
||||||
|
// Find the checkbox in this row
|
||||||
|
const checkbox = row.querySelector('input[name="selection"]');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
row.classList.toggle('selected', checkbox.checked);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep row visually in sync with checkbox state (e.g. after page reload)
|
||||||
|
const checkbox = row.querySelector('input[name="selection"]');
|
||||||
|
if (checkbox && checkbox.checked) {
|
||||||
|
row.classList.add('selected');
|
||||||
|
}
|
||||||
|
// Also toggle row selection when checkbox is clicked directly
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.addEventListener('click', function(e) {
|
||||||
|
row.classList.toggle('selected', checkbox.checked);
|
||||||
|
// Prevent row click event from firing
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.loadDicomViewerEvent = new Event('loadDicomViewer');
|
window.loadDicomViewerEvent = new Event('loadDicomViewer');
|
||||||
@@ -330,266 +361,321 @@ function loadJsonToolStateOnCurrentImage(json) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setUpDicom(element) {
|
//async function setUpDicom(element) {
|
||||||
|
// console.log("setUpDicom (original)", element);
|
||||||
|
//
|
||||||
|
// $(element).bind('contextmenu', function (e) {
|
||||||
|
// return false;
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// $(element).dblclick((evt) => {
|
||||||
|
// element.requestFullscreen();
|
||||||
|
// })
|
||||||
|
//
|
||||||
|
// cornerstoneBase64ImageLoader.external.cornerstone = cornerstone;
|
||||||
|
// cornerstoneWebImageLoader.external.cornerstone = cornerstone;
|
||||||
|
// cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
|
||||||
|
//
|
||||||
|
// cornerstoneTools.init();
|
||||||
|
// const PanTool = cornerstoneTools.PanTool;
|
||||||
|
// const ZoomTool = cornerstoneTools.ZoomTool;
|
||||||
|
// const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool;
|
||||||
|
// const WwwcTool = cornerstoneTools.WwwcTool;
|
||||||
|
// const WwwcRegionTool = cornerstoneTools.WwwcRegionTool;
|
||||||
|
// const RotateTool = cornerstoneTools.RotateTool;
|
||||||
|
// const StackScrollTool = cornerstoneTools.StackScrollTool;
|
||||||
|
// const MagnifyTool = cornerstoneTools.MagnifyTool;
|
||||||
|
// const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// const images = element.dataset.url.split(",");
|
||||||
|
//
|
||||||
|
// //console.log("Dicom - load imageId: ", imageIds);
|
||||||
|
// //for (let index = 0; index < imageIds.length; index++) {
|
||||||
|
// // if (imageIds[index].endsWith("dcm")) {
|
||||||
|
// // imageIds[index] = "wadouri:" + imageIds[index];
|
||||||
|
// // }
|
||||||
|
//
|
||||||
|
// //}
|
||||||
|
// let imageIds = [];
|
||||||
|
// for (let i = 0; i < images.length; i++) {
|
||||||
|
// let data_url = images[i];
|
||||||
|
// // check stack type
|
||||||
|
// if (data_url.startsWith("data:image")) {
|
||||||
|
// let imageId = "base64://" + data_url.split(",")[1];
|
||||||
|
//
|
||||||
|
// imageIds.push(imageId);
|
||||||
|
// } else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) {
|
||||||
|
//
|
||||||
|
// imageIds.push(data_url);
|
||||||
|
//
|
||||||
|
// // Treat application/octet-stream as if they are dicoms
|
||||||
|
// } else if (data_url.startsWith("data:application/dicom") || data_url.startsWith("data:application/octet-stream")) {
|
||||||
|
// //stack = stack.split(";")[1];
|
||||||
|
//
|
||||||
|
// let dfile = await urltoFile(data_url, "dicom", "application/dicom");
|
||||||
|
//
|
||||||
|
// const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
|
||||||
|
// dfile
|
||||||
|
// );
|
||||||
|
//
|
||||||
|
// loadAnnotation(imageId, annotation);
|
||||||
|
//
|
||||||
|
// imageIds.push(imageId);
|
||||||
|
// //cornerstone.loadImage(imageId).then(function(image) {
|
||||||
|
// // tempFunction(image);
|
||||||
|
// //});
|
||||||
|
// } else {
|
||||||
|
// let url;
|
||||||
|
// // This doesn't seem to have any benefit
|
||||||
|
// //if (data_url.startsWith("http")) {
|
||||||
|
// // url = data_url;
|
||||||
|
// //} else {
|
||||||
|
// // url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
|
||||||
|
// //}
|
||||||
|
// url = data_url;
|
||||||
|
//
|
||||||
|
// if (url.endsWith("dcm")) {
|
||||||
|
// url = "wadouri:" + url;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // if there is no extension treat it as a dicom
|
||||||
|
// if (/(?:\/|^)[^.\/]+$/.test(url)) {
|
||||||
|
// url = "wadouri:" + url;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// imageIds.push(url);
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// cornerstone.enable(element);
|
||||||
|
// cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
|
||||||
|
// cornerstone.displayImage(element, image);
|
||||||
|
//
|
||||||
|
// cornerstoneTools.addToolForElement(element, PanTool);
|
||||||
|
// cornerstoneTools.addToolForElement(element, ZoomTool);
|
||||||
|
// cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool);
|
||||||
|
// cornerstoneTools.addToolForElement(element, WwwcTool);
|
||||||
|
// cornerstoneTools.addToolForElement(element, WwwcRegionTool);
|
||||||
|
// cornerstoneTools.addToolForElement(element, RotateTool);
|
||||||
|
// cornerstoneTools.addToolForElement(element, StackScrollTool);
|
||||||
|
// cornerstoneTools.addToolForElement(element, MagnifyTool);
|
||||||
|
//
|
||||||
|
// cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
|
||||||
|
// configuration: {
|
||||||
|
// getTextCallback: () => {},
|
||||||
|
// changeTextCallback: () => {},
|
||||||
|
// allowEmptyLabel: true,
|
||||||
|
// renderDashed: false,
|
||||||
|
// drawHandles: false,
|
||||||
|
// drawHandlesOnHover: true,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// // Enable our tools
|
||||||
|
// // Avoid incorrect aspect ratio
|
||||||
|
// cornerstoneTools.setToolActiveForElement(element, "Pan", {
|
||||||
|
// mouseButtonMask: 1
|
||||||
|
// });
|
||||||
|
// cornerstoneTools.setToolActiveForElement(element, "Wwwc", {
|
||||||
|
// mouseButtonMask: 2
|
||||||
|
// });
|
||||||
|
// cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", {
|
||||||
|
// mouseButtonMask: 3
|
||||||
|
// });
|
||||||
|
// cornerstoneTools.setToolActiveForElement(element, "Zoom", {
|
||||||
|
// mouseButtonMask: 4
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// if (element.dataset.edit_annotation == "true") {
|
||||||
|
// cornerstoneTools.setToolActiveForElement(element, "ArrowAnnotate", {
|
||||||
|
// mouseButtonMask: 2
|
||||||
|
// });
|
||||||
|
// } else {
|
||||||
|
// cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate");
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (element.dataset.annotations) {
|
||||||
|
// loadJsonToolStateOnCurrentImage(element.dataset.annotations)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// cornerstone.resize(element);
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// }).catch((err, err2) => {
|
||||||
|
// console.log(err);
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
//}
|
||||||
|
//console.log("setUpDicom defined");
|
||||||
|
//
|
||||||
|
//// keep reference to the existing 2D setup
|
||||||
|
//window.setUpDicomClassic = setUpDicom;
|
||||||
|
|
||||||
console.log("Dicom element found ", $(element));
|
async function setUpDicom3d(element) {
|
||||||
|
console.log("setUpDicom3d (cornerstone3d) for", element);
|
||||||
|
|
||||||
$(element).bind('contextmenu', function (e) {
|
// parse images from dataset
|
||||||
return false;
|
let raw = element.dataset.url || "";
|
||||||
});
|
if (!raw) return;
|
||||||
|
let images;
|
||||||
$(element).dblclick((evt) => {
|
try {
|
||||||
element.requestFullscreen();
|
images = JSON.parse(raw);
|
||||||
})
|
} catch (e) {
|
||||||
|
images = raw.split(",").map(s => s.trim()).filter(Boolean);
|
||||||
cornerstoneBase64ImageLoader.external.cornerstone = cornerstone;
|
|
||||||
cornerstoneWebImageLoader.external.cornerstone = cornerstone;
|
|
||||||
cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
|
|
||||||
|
|
||||||
cornerstoneTools.init();
|
|
||||||
const PanTool = cornerstoneTools.PanTool;
|
|
||||||
const ZoomTool = cornerstoneTools.ZoomTool;
|
|
||||||
const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool;
|
|
||||||
const WwwcTool = cornerstoneTools.WwwcTool;
|
|
||||||
const WwwcRegionTool = cornerstoneTools.WwwcRegionTool;
|
|
||||||
const RotateTool = cornerstoneTools.RotateTool;
|
|
||||||
const StackScrollTool = cornerstoneTools.StackScrollTool;
|
|
||||||
const MagnifyTool = cornerstoneTools.MagnifyTool;
|
|
||||||
const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
|
|
||||||
|
|
||||||
|
|
||||||
const images = element.dataset.url.split(",");
|
|
||||||
|
|
||||||
//console.log("Dicom - load imageId: ", imageIds);
|
|
||||||
//for (let index = 0; index < imageIds.length; index++) {
|
|
||||||
// if (imageIds[index].endsWith("dcm")) {
|
|
||||||
// imageIds[index] = "wadouri:" + imageIds[index];
|
|
||||||
// }
|
|
||||||
|
|
||||||
//}
|
|
||||||
let imageIds = [];
|
|
||||||
for (let i = 0; i < images.length; i++) {
|
|
||||||
let data_url = images[i];
|
|
||||||
// check stack type
|
|
||||||
if (data_url.startsWith("data:image")) {
|
|
||||||
let imageId = "base64://" + data_url.split(",")[1];
|
|
||||||
|
|
||||||
imageIds.push(imageId);
|
|
||||||
} else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) {
|
|
||||||
|
|
||||||
imageIds.push(data_url);
|
|
||||||
|
|
||||||
// Treat application/octet-stream as if they are dicoms
|
|
||||||
} else if (data_url.startsWith("data:application/dicom") || data_url.startsWith("data:application/octet-stream")) {
|
|
||||||
//stack = stack.split(";")[1];
|
|
||||||
|
|
||||||
let dfile = await urltoFile(data_url, "dicom", "application/dicom");
|
|
||||||
|
|
||||||
const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
|
|
||||||
dfile
|
|
||||||
);
|
|
||||||
|
|
||||||
loadAnnotation(imageId, annotation);
|
|
||||||
|
|
||||||
imageIds.push(imageId);
|
|
||||||
//cornerstone.loadImage(imageId).then(function(image) {
|
|
||||||
// tempFunction(image);
|
|
||||||
//});
|
|
||||||
} else {
|
|
||||||
let url;
|
|
||||||
// This doesn't seem to have any benefit
|
|
||||||
//if (data_url.startsWith("http")) {
|
|
||||||
// url = data_url;
|
|
||||||
//} else {
|
|
||||||
// url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
|
|
||||||
//}
|
|
||||||
url = data_url;
|
|
||||||
|
|
||||||
if (url.endsWith("dcm")) {
|
|
||||||
url = "wadouri:" + url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if there is no extension treat it as a dicom
|
|
||||||
if (/(?:\/|^)[^.\/]+$/.test(url)) {
|
|
||||||
url = "wadouri:" + url;
|
|
||||||
}
|
|
||||||
|
|
||||||
imageIds.push(url);
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (!images || images.length === 0) return;
|
||||||
|
|
||||||
cornerstone.enable(element);
|
// ensure element has a viewport-like appearance
|
||||||
cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
|
element.style.width = element.style.width || "500px";
|
||||||
cornerstone.displayImage(element, image);
|
element.style.height = element.style.height || "500px";
|
||||||
|
element.style.position = element.style.position || "relative";
|
||||||
|
element.innerHTML = ""; // clear
|
||||||
|
|
||||||
cornerstoneTools.addToolForElement(element, PanTool);
|
// Disable right-click context menu on the element
|
||||||
cornerstoneTools.addToolForElement(element, ZoomTool);
|
element.addEventListener('contextmenu', function (e) {
|
||||||
cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool);
|
e.preventDefault();
|
||||||
cornerstoneTools.addToolForElement(element, WwwcTool);
|
|
||||||
cornerstoneTools.addToolForElement(element, WwwcRegionTool);
|
|
||||||
cornerstoneTools.addToolForElement(element, RotateTool);
|
|
||||||
cornerstoneTools.addToolForElement(element, StackScrollTool);
|
|
||||||
cornerstoneTools.addToolForElement(element, MagnifyTool);
|
|
||||||
|
|
||||||
cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
|
|
||||||
configuration: {
|
|
||||||
getTextCallback: () => {},
|
|
||||||
changeTextCallback: () => {},
|
|
||||||
allowEmptyLabel: true,
|
|
||||||
renderDashed: false,
|
|
||||||
drawHandles: false,
|
|
||||||
drawHandlesOnHover: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Enable our tools
|
|
||||||
// Avoid incorrect aspect ratio
|
|
||||||
cornerstoneTools.setToolActiveForElement(element, "Pan", {
|
|
||||||
mouseButtonMask: 1
|
|
||||||
});
|
|
||||||
cornerstoneTools.setToolActiveForElement(element, "Wwwc", {
|
|
||||||
mouseButtonMask: 2
|
|
||||||
});
|
|
||||||
cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", {
|
|
||||||
mouseButtonMask: 3
|
|
||||||
});
|
|
||||||
cornerstoneTools.setToolActiveForElement(element, "Zoom", {
|
|
||||||
mouseButtonMask: 4
|
|
||||||
});
|
|
||||||
|
|
||||||
if (element.dataset.edit_annotation == "true") {
|
|
||||||
cornerstoneTools.setToolActiveForElement(element, "ArrowAnnotate", {
|
|
||||||
mouseButtonMask: 2
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.dataset.annotations) {
|
|
||||||
loadJsonToolStateOnCurrentImage(element.dataset.annotations)
|
|
||||||
}
|
|
||||||
|
|
||||||
cornerstone.resize(element);
|
|
||||||
|
|
||||||
|
|
||||||
}).catch((err, err2) => {
|
|
||||||
console.log(err);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
element.addEventListener('dblclick', function (e) {
|
||||||
|
if (element.requestFullscreen) {
|
||||||
|
element.requestFullscreen();
|
||||||
|
} else if (element.webkitRequestFullscreen) {
|
||||||
|
element.webkitRequestFullscreen();
|
||||||
|
} else if (element.mozRequestFullScreen) {
|
||||||
|
element.mozRequestFullScreen();
|
||||||
|
} else if (element.msRequestFullscreen) {
|
||||||
|
element.msRequestFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// create a unique rendering engine / viewport ids per element
|
||||||
|
const renderingEngineId = `rendingEngine`;
|
||||||
|
const viewportId = `1`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// dynamic import of cornerstone3D core + dicom loader
|
||||||
|
const coreModule = window.cornerstone3d
|
||||||
|
const dicomLoaderModule = window.cornerstoneDICOMImageLoader
|
||||||
|
const toolsModule = window.cornerstone3dTools;
|
||||||
|
|
||||||
|
await coreModule.init();
|
||||||
|
await dicomLoaderModule.init();
|
||||||
|
|
||||||
|
|
||||||
|
await toolsModule.init();
|
||||||
|
|
||||||
|
const { RenderingEngine, Enums } = coreModule;
|
||||||
|
const { chunkManager } = dicomLoaderModule; // may exist for some setups
|
||||||
|
|
||||||
|
// create engine and enable a Stack viewport
|
||||||
|
const renderingEngine = new RenderingEngine(renderingEngineId);
|
||||||
|
console.log(renderingEngine)
|
||||||
|
|
||||||
|
const viewportInput = {
|
||||||
|
viewportId,
|
||||||
|
type: Enums.ViewportType.STACK,
|
||||||
|
element,
|
||||||
|
//viewportType: Enums.ViewportType.STACK, // Stack viewport for slice stacks
|
||||||
|
};
|
||||||
|
|
||||||
|
renderingEngine.enableElement(viewportInput);
|
||||||
|
|
||||||
|
const viewport = renderingEngine.getViewport(viewportId);
|
||||||
|
|
||||||
|
|
||||||
|
// --- Register basic interaction tools (best-effort with @cornerstonejs/tools) ---
|
||||||
|
// dynamic import so bundlers/CDNs provide the package
|
||||||
|
// prefer module import, fall back to global if present
|
||||||
|
|
||||||
|
const { ToolGroupManager } = toolsModule;
|
||||||
|
|
||||||
|
toolsModule.addTool(toolsModule.PanTool)
|
||||||
|
toolsModule.addTool(toolsModule.WindowLevelTool)
|
||||||
|
//toolsModule.addTool(toolsModule.StackScrollTool)
|
||||||
|
toolsModule.addTool(toolsModule.ZoomTool)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const toolGroupId = `toolgroup-${viewportId}`;
|
||||||
|
console.log("ToolGroup id", toolGroupId);
|
||||||
|
console.log("ToolGroupManager", ToolGroupManager);
|
||||||
|
if (!ToolGroupManager.getToolGroup(toolGroupId)) {
|
||||||
|
ToolGroupManager.createToolGroup(toolGroupId);
|
||||||
|
}
|
||||||
|
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
|
||||||
|
|
||||||
|
toolGroup.addTool(toolsModule.PanTool.toolName);
|
||||||
|
toolGroup.addTool(toolsModule.WindowLevelTool.toolName);
|
||||||
|
//toolGroup.addTool(toolsModule.StackScrollTool.toolName);
|
||||||
|
toolGroup.addTool(toolsModule.ZoomTool.toolName);
|
||||||
|
|
||||||
|
// use actual tool names if available, else fall back to common strings
|
||||||
|
//const stackName = (StackScrollTool && (StackScrollTool.toolName || StackScrollTool.name)) || 'StackScroll';
|
||||||
|
|
||||||
|
//toolGroup.setToolEnabled(toolsModule.PanTool.toolName, { bindings: [{ mouseButton: 1 }] });
|
||||||
|
toolGroup.setToolActive(toolsModule.PanTool.toolName, { bindings: [{ mouseButton: 1 }] });
|
||||||
|
toolGroup.setToolActive(toolsModule.WindowLevelTool.toolName, { bindings: [{ mouseButton: 2 }] });
|
||||||
|
toolGroup.setToolActive(toolsModule.ZoomTool.toolName, { bindings: [{ mouseButton: 4 }] });
|
||||||
|
toolGroup.setToolActive(toolsModule.ZoomTool.toolName, { bindings: [{ mouseButton: 524288 }] });
|
||||||
|
toolGroup.addViewport(viewportId, renderingEngineId);
|
||||||
|
|
||||||
|
window.toolGroup = toolGroup; // expose for debugging
|
||||||
|
|
||||||
|
|
||||||
|
// Prepare imageIds: ensure remote URLs are usable by dicom-image-loader (wadouri:)
|
||||||
|
const imageIds = images.map((url) => {
|
||||||
|
url = url.trim();
|
||||||
|
// if it's already a scheme we pass through; otherwise treat as wadouri
|
||||||
|
if (/^(wadouri:|base64:|data:)/i.test(url)) return url;
|
||||||
|
return `wadouri:${url}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If dicom-image-loader provides a metadata cache factory, pre-cache metadata
|
||||||
|
if (typeof dicomLoaderModule.createImageIdsAndCacheMetaData === "function") {
|
||||||
|
try {
|
||||||
|
// attempt to call helper to cache meta-data for the first image (best-effort)
|
||||||
|
await dicomLoaderModule.createImageIdsAndCacheMetaData({
|
||||||
|
imageIds,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// not fatal — continue
|
||||||
|
console.warn("createImageIdsAndCacheMetaData failed (continuing):", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await viewport.setStack(imageIds, 0);
|
||||||
|
|
||||||
|
viewport.render();
|
||||||
|
|
||||||
|
|
||||||
|
// store references for debugging/use elsewhere
|
||||||
|
element._cornerstone3d = {
|
||||||
|
renderingEngineId,
|
||||||
|
viewportId,
|
||||||
|
renderingEngine,
|
||||||
|
viewport,
|
||||||
|
imageIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("setUpDicom3d complete", element._cornerstone3d);
|
||||||
|
return element._cornerstone3d;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("setUpDicom3d failed:", err);
|
||||||
|
// per request: no fallback here — rethrow so caller can handle if desired
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//function keyUpHandler(e) {
|
// expose globally like existing code expects
|
||||||
// if (e.key == "Control") {
|
window.setUpDicom = setUpDicom3d;
|
||||||
// dicomViewer.registerPrimaryDicomInterface(e);
|
|
||||||
// window.control_pressed = false;
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
//function keyDownHandler(e) {
|
|
||||||
// // Ignore our custom keybindings if we are currently in a field that
|
|
||||||
// // accepts some kind of input
|
|
||||||
// if ($("*:focus:not(disabled)").is("textarea, input")) {
|
|
||||||
// // unless a modifier key is pressed (not shift)
|
|
||||||
// if (e.altKey ? true : false || e.ctrlKey ? true : false) {} else {
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// if (e.key == "Control") {
|
|
||||||
// if (window.control_pressed == false) {
|
|
||||||
// window.control_pressed = true;
|
|
||||||
// dicomViewer.registerAltDicomInterface(e);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// var charCode = typeof e.which == "number" ? e.which : e.keyCode;
|
|
||||||
// console.log(e, charCode);
|
|
||||||
//
|
|
||||||
// function numberKeyPressed(e, x) {
|
|
||||||
// if (e.altKey ? true : false) {
|
|
||||||
// dicomViewer.selectThumb(x);
|
|
||||||
// e.preventDefault();
|
|
||||||
// } else {}
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// switch (charCode) {
|
|
||||||
// case 13: // Return
|
|
||||||
// if (e.shiftKey ? true : false) {
|
|
||||||
// //$(".next-button:last").click();
|
|
||||||
// } else {
|
|
||||||
// //$(".check-button:last").click();
|
|
||||||
// }
|
|
||||||
// break;
|
|
||||||
// case 32: // Space
|
|
||||||
// if (e.shiftKey ? true : false) {
|
|
||||||
// //$(".previous-button:last").click();
|
|
||||||
// } else {
|
|
||||||
// //$(".next-button:last").click();
|
|
||||||
// }
|
|
||||||
// e.preventDefault(); // Needed to stop the default action (scroll)
|
|
||||||
// break;
|
|
||||||
// case 46: // .
|
|
||||||
// //toggleFlagged();
|
|
||||||
// break;
|
|
||||||
//
|
|
||||||
// // Numbers 1-9 select the corresponding answer (if it exists)
|
|
||||||
// // TODO: fix for multi question questions
|
|
||||||
// case 49: // 1
|
|
||||||
// numberKeyPressed(e, 0);
|
|
||||||
// break;
|
|
||||||
// case 50: // 2
|
|
||||||
// numberKeyPressed(e, 1);
|
|
||||||
// break;
|
|
||||||
// case 51: // 3
|
|
||||||
// numberKeyPressed(e, 2);
|
|
||||||
// break;
|
|
||||||
// case 52: // 4
|
|
||||||
// numberKeyPressed(e, 3);
|
|
||||||
// break;
|
|
||||||
// case 53: // 5
|
|
||||||
// numberKeyPressed(e, 4);
|
|
||||||
// break;
|
|
||||||
// case 54: // 6
|
|
||||||
// numberKeyPressed(e, 5);
|
|
||||||
// break;
|
|
||||||
// case 55: // 7
|
|
||||||
// numberKeyPressed(e, 6);
|
|
||||||
// break;
|
|
||||||
// case 56: // 8
|
|
||||||
// numberKeyPressed(e, 7);
|
|
||||||
// break;
|
|
||||||
// case 57: // 9
|
|
||||||
// numberKeyPressed(e, 8);
|
|
||||||
// break;
|
|
||||||
// case 72: // H
|
|
||||||
// previousQuestion();
|
|
||||||
// break;
|
|
||||||
// case 76: // L
|
|
||||||
// //nextQuestion();
|
|
||||||
// break;
|
|
||||||
//
|
|
||||||
// case 102: // f
|
|
||||||
// //$("#filter-toggle").click();
|
|
||||||
// break;
|
|
||||||
// case 103: // g
|
|
||||||
// //$("#options").slideDown("slow");
|
|
||||||
// //$("#goto-question-input").focus();
|
|
||||||
// //e.preventDefault();
|
|
||||||
// break;
|
|
||||||
//
|
|
||||||
// // Vim like scrolling (incredibly important)
|
|
||||||
// case 106: // j
|
|
||||||
// //window.scrollBy(0, 25);
|
|
||||||
// break;
|
|
||||||
// case 107: // k
|
|
||||||
// //window.scrollBy(0, -25);
|
|
||||||
// break;
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
||||||
|
|
||||||
function create_popup_window(url, title) {
|
function create_popup_window(url, title) {
|
||||||
@@ -681,4 +767,5 @@ function initializeClock(id, endtime) {
|
|||||||
updateClock();
|
updateClock();
|
||||||
}
|
}
|
||||||
|
|
||||||
window.initializeClock = initializeClock
|
window.initializeClock = initializeClock
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load crispy_forms_tags %}
|
{% load crispy_forms_tags %}
|
||||||
|
{% load help_tags %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
Anatomy
|
Anatomy
|
||||||
@@ -11,69 +12,69 @@
|
|||||||
{% block js %}
|
{% block js %}
|
||||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function showEditPopup(url) {
|
function showEditPopup(url) {
|
||||||
var win = window.open(url, "Edit",
|
var win = window.open(url, "Edit",
|
||||||
'height=500,width=800,resizable=yes,scrollbars=yes');
|
'height=500,width=800,resizable=yes,scrollbars=yes');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
function showAddPopup(triggeringLink) {
|
function showAddPopup(triggeringLink) {
|
||||||
var name = triggeringLink.id.replace(/^add_/, '');
|
var name = triggeringLink.id.replace(/^add_/, '');
|
||||||
href = triggeringLink.href;
|
href = triggeringLink.href;
|
||||||
var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
|
var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
|
||||||
win.focus();
|
win.focus();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
function closePopup(win, newID, newRepr, id) {
|
function closePopup(win, newID, newRepr, id) {
|
||||||
console.log(id)
|
console.log(id)
|
||||||
$(id).append('<option value=' + newID + ` title=${newRepr} >` + newRepr + '</option>').removeAttr("selected").val(newID)
|
$(id).append('<option value=' + newID + ` title=${newRepr} >` + newRepr + '</option>').removeAttr("selected").val(newID)
|
||||||
win.close();
|
win.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('drop', function (e) { e.preventDefault(); }, false);
|
document.addEventListener('drop', function (e) { e.preventDefault(); }, false);
|
||||||
|
|
||||||
|
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$("#add_examination").appendTo($("label[for='id_examination']"));
|
$("#add_examination").appendTo($("label[for='id_examination']"));
|
||||||
$("#add_body_part").appendTo($("label[for='id_body_part']"));
|
$("#add_body_part").appendTo($("label[for='id_body_part']"));
|
||||||
|
|
||||||
|
|
||||||
dropContainer = document.getElementById("drop-container");
|
dropContainer = document.getElementById("drop-container");
|
||||||
|
|
||||||
dropContainer.ondragover = function (evt) {
|
dropContainer.ondragover = function (evt) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
dropContainer.ondragenter = function (evt) {
|
dropContainer.ondragenter = function (evt) {
|
||||||
$(evt.target).addClass("drop-target-active")
|
$(evt.target).addClass("drop-target-active")
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
dropContainer.ondragleave = function (evt) {
|
dropContainer.ondragleave = function (evt) {
|
||||||
$(evt.target).removeClass("drop-target-active")
|
$(evt.target).removeClass("drop-target-active")
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
dropContainer.ondrop = function (evt) {
|
dropContainer.ondrop = function (evt) {
|
||||||
console.log("drop event, ", evt);
|
console.log("drop event, ", evt);
|
||||||
$(evt.target).removeClass("drop-target-active");
|
$(evt.target).removeClass("drop-target-active");
|
||||||
|
|
||||||
// Get all input elements
|
// Get all input elements
|
||||||
input = document.getElementById("id_image");
|
input = document.getElementById("id_image");
|
||||||
//fileInput = document.getElementById("id_images-0-image");
|
//fileInput = document.getElementById("id_images-0-image");
|
||||||
|
|
||||||
f = evt.dataTransfer.files[0]
|
f = evt.dataTransfer.files[0]
|
||||||
|
|
||||||
let dT = new DataTransfer();
|
let dT = new DataTransfer();
|
||||||
dT.clearData();
|
dT.clearData();
|
||||||
dT.items.add(f);
|
dT.items.add(f);
|
||||||
|
|
||||||
input.files = dT.files;
|
input.files = dT.files;
|
||||||
|
|
||||||
ocr(input);
|
ocr(input);
|
||||||
|
|
||||||
// Make sure we have enough input targets
|
// Make sure we have enough input targets
|
||||||
//input_diff = (evt.dataTransfer.files.length - inputs.length)
|
//input_diff = (evt.dataTransfer.files.length - inputs.length)
|
||||||
@@ -119,50 +120,50 @@
|
|||||||
// dT.items.add(evt.dataTransfer.files[3]);
|
// dT.items.add(evt.dataTransfer.files[3]);
|
||||||
// fileInput.files = dT.files;
|
// fileInput.files = dT.files;
|
||||||
|
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
updateFileList();
|
updateFileList();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
$('#add_more').click(() => { add_input_form() });
|
$('#add_more').click(() => { add_input_form() });
|
||||||
|
|
||||||
function updateFileList() {
|
function updateFileList() {
|
||||||
$("#drop-filenames").empty()
|
$("#drop-filenames").empty()
|
||||||
$("#id_image").each((n, el) => {
|
$("#id_image").each((n, el) => {
|
||||||
console.log(el);
|
console.log(el);
|
||||||
if (el.files.length > 0) {
|
if (el.files.length > 0) {
|
||||||
extra_class = " image-ident-loading";
|
extra_class = " image-ident-loading";
|
||||||
if ($(el).hasClass("image-ident-warning")) {
|
if ($(el).hasClass("image-ident-warning")) {
|
||||||
extra_class = " image-ident-warning"
|
extra_class = " image-ident-warning"
|
||||||
} else if ($(el).hasClass("image-ident-ok")) {
|
} else if ($(el).hasClass("image-ident-ok")) {
|
||||||
extra_class = " image-ident-ok"
|
extra_class = " image-ident-ok"
|
||||||
|
}
|
||||||
|
$("#drop-filenames").append(
|
||||||
|
`<span>${n}: ${el.files[0].name}</span><img class='uploading${extra_class}' data-input-id='${el.id}' src=${URL.createObjectURL(el.files[0])}>`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
$("#drop-filenames").append(
|
})
|
||||||
`<span>${n}: ${el.files[0].name}</span><img class='uploading${extra_class}' data-input-id='${el.id}' src=${URL.createObjectURL(el.files[0])}>`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$("input[type=file]").on('change', function () {
|
$("input[type=file]").on('change', function () {
|
||||||
$(this).removeClass("image-ident-warning");
|
$(this).removeClass("image-ident-warning");
|
||||||
$(this).removeClass("image-ident-ok");
|
$(this).removeClass("image-ident-ok");
|
||||||
updateFileList();
|
updateFileList();
|
||||||
console.log("input change1");
|
console.log("input change1");
|
||||||
console.log("input change", this);
|
console.log("input change", this);
|
||||||
ocr(this);
|
ocr(this);
|
||||||
});
|
});
|
||||||
|
|
||||||
//document.getElementById("id_question_type").selectedIndex = 1;
|
//document.getElementById("id_question_type").selectedIndex = 1;
|
||||||
answer_1_status = document.getElementById("id_answers-0-status").selectedIndex;
|
answer_1_status = document.getElementById("id_answers-0-status").selectedIndex;
|
||||||
if (answer_1_status == 0) {
|
if (answer_1_status == 0) {
|
||||||
document.getElementById("id_answers-0-status").selectedIndex = 3;
|
document.getElementById("id_answers-0-status").selectedIndex = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
function ocr(input) {
|
function ocr(input) {
|
||||||
// Tesseract.recognize(f, "eng",{ logger: m => console.log(m) }
|
// Tesseract.recognize(f, "eng",{ logger: m => console.log(m) }
|
||||||
// ).then(({ data: { text } }) => {
|
// ).then(({ data: { text } }) => {
|
||||||
// console.log(text);
|
// console.log(text);
|
||||||
@@ -172,79 +173,78 @@
|
|||||||
// }
|
// }
|
||||||
// })
|
// })
|
||||||
|
|
||||||
const worker = Tesseract.createWorker({
|
const worker = Tesseract.createWorker({
|
||||||
workerPath: '{% static "worker.min.js" %}',
|
workerPath: '{% static "worker.min.js" %}',
|
||||||
langPath: '{% static "" %}',
|
langPath: '{% static "" %}',
|
||||||
//langPath: '{{ STATIC_URL }}',
|
//langPath: '{{ STATIC_URL }}',
|
||||||
corePath: '{% static "tesseract-core.wasm.js" %}',
|
corePath: '{% static "tesseract-core.wasm.js" %}',
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = URL.createObjectURL(input.files[0]);
|
const url = URL.createObjectURL(input.files[0]);
|
||||||
let img = new Image;
|
let img = new Image;
|
||||||
img.src = url;
|
img.src = url;
|
||||||
|
|
||||||
img.onload = function () {
|
img.onload = function () {
|
||||||
|
|
||||||
|
|
||||||
width = img.width;
|
width = img.width;
|
||||||
|
|
||||||
// const rectangle = { left: 0, top: 0, width: width, height: 10 }
|
// const rectangle = { left: 0, top: 0, width: width, height: 10 }
|
||||||
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await worker.load();
|
await worker.load();
|
||||||
await worker.loadLanguage('eng');
|
await worker.loadLanguage('eng');
|
||||||
await worker.initialize('eng');
|
await worker.initialize('eng');
|
||||||
// const { data: { text } } = await worker.recognize(input.files[0], { rectangle });
|
// const { data: { text } } = await worker.recognize(input.files[0], { rectangle });
|
||||||
const { data: { text } } = await worker.recognize(input.files[0]);
|
const { data: { text } } = await worker.recognize(input.files[0]);
|
||||||
//console.log(text);
|
//console.log(text);
|
||||||
l = text.toLowerCase();
|
l = text.toLowerCase();
|
||||||
$(`img[data-input-id='${input.id}'`).removeClass("image-ident-loading");
|
$(`img[data-input-id='${input.id}'`).removeClass("image-ident-loading");
|
||||||
console.log(l);
|
console.log(l);
|
||||||
if (l.includes("accesion") || l.includes("number") || l.search(/(ref|rk9|rh8|ra9|rbz)\d+/g) > -1) {
|
if (l.includes("accesion") || l.includes("number") || l.search(/(ref|rk9|rh8|ra9|rbz)\d+/g) > -1) {
|
||||||
console.log("SHIT", input);
|
console.log("SHIT", input);
|
||||||
$(input).addClass("image-ident-warning");
|
$(input).addClass("image-ident-warning");
|
||||||
$(`img[data-input-id='${input.id}'`).addClass("image-ident-warning");
|
$(`img[data-input-id='${input.id}'`).addClass("image-ident-warning");
|
||||||
} else {
|
} else {
|
||||||
$(input).addClass("image-ident-ok");
|
$(input).addClass("image-ident-ok");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await worker.terminate();
|
await worker.terminate();
|
||||||
})();
|
})();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{{ form.media }}
|
{{ form.media }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{% if object %}
|
{% if object %}
|
||||||
{% include 'anatomy/question_link_header.html' %}
|
{% include 'anatomy/question_link_header.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h2>{{object|yesno:"Update,Create"}} Question</h2>
|
|
||||||
<details class="help-text">
|
|
||||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
|
||||||
<p>This form can be used to create an Anatomy question.</p>
|
|
||||||
|
|
||||||
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p>
|
<h2>{{object|yesno:"Update,Create"}} Question</h2>
|
||||||
|
{% help "Help" %}
|
||||||
|
<p>This form can be used to create an Anatomy question.</p>
|
||||||
|
|
||||||
<p>Correct and incorrect answers can be defined once the question has been created.</p>
|
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p>
|
||||||
</details>
|
|
||||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
|
||||||
{% csrf_token %}
|
|
||||||
<a href="/anatomy/examination/create" id="add_examination" class="add-popup"
|
|
||||||
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
|
|
||||||
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
|
|
||||||
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
|
|
||||||
|
|
||||||
{{ form|crispy }}
|
<p>Correct and incorrect answers can be defined once the question has been created.</p>
|
||||||
<div id="drop-container" class="drop-target">Drop image here
|
{% endhelp %}
|
||||||
<div id="drop-filenames"></div>
|
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||||
</div>
|
{% csrf_token %}
|
||||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
<a href="/anatomy/examination/create" id="add_examination" class="add-popup"
|
||||||
</form>
|
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
|
||||||
|
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
|
||||||
|
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
|
||||||
|
|
||||||
|
{{ form|crispy }}
|
||||||
|
<div id="drop-container" class="drop-target">Drop image here
|
||||||
|
<div id="drop-filenames"></div>
|
||||||
|
</div>
|
||||||
|
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||||
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -194,8 +194,6 @@ def test_exams(db, client):
|
|||||||
exam.save()
|
exam.save()
|
||||||
|
|
||||||
res = client.get(exam_metadata["url"])
|
res = client.get(exam_metadata["url"])
|
||||||
print("PRE ERRER", exam_metadata["url"])
|
|
||||||
print(res.content)
|
|
||||||
assert res.status_code == 404
|
assert res.status_code == 404
|
||||||
|
|
||||||
exam.active = True
|
exam.active = True
|
||||||
|
|||||||
@@ -646,22 +646,6 @@ class AnatomyQuestionUpdate(UpdateQuestionMixin):
|
|||||||
context = self.get_context_data(form=form)
|
context = self.get_context_data(form=form)
|
||||||
#formset = context["answer_formset"]
|
#formset = context["answer_formset"]
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
## logger.debug(formset.is_valid())
|
|
||||||
#if formset.is_valid():
|
|
||||||
# response = super().form_valid(form)
|
|
||||||
# formset.instance = self.object
|
|
||||||
# formset.save()
|
|
||||||
|
|
||||||
# # restore exam orders
|
|
||||||
# #for exam in self.object.exams.all():
|
|
||||||
# # if exam in exam_orders and self.object in exam_orders[exam]:
|
|
||||||
# # print(exam_orders[exam])
|
|
||||||
# # exam.exam_questions.set(exam_orders[exam])
|
|
||||||
# # exam.save()
|
|
||||||
|
|
||||||
# return response
|
|
||||||
#else:
|
|
||||||
# return super().form_invalid(form)
|
|
||||||
|
|
||||||
|
|
||||||
class QuestionClone(AnatomyQuestionCreateBase):
|
class QuestionClone(AnatomyQuestionCreateBase):
|
||||||
@@ -797,7 +781,6 @@ def question_save_annotation(request, pk):
|
|||||||
question = get_object_or_404(AnatomyQuestion, pk=pk)
|
question = get_object_or_404(AnatomyQuestion, pk=pk)
|
||||||
|
|
||||||
question.image_annotations = request.POST.get("annotation")
|
question.image_annotations = request.POST.get("annotation")
|
||||||
print(question.image_annotations)
|
|
||||||
|
|
||||||
question.save()
|
question.save()
|
||||||
data = {"status": "success"}
|
data = {"status": "success"}
|
||||||
|
|||||||
+3
-1
@@ -16,7 +16,8 @@ from .models import (
|
|||||||
Presentation,
|
Presentation,
|
||||||
UncategorisedDicom,
|
UncategorisedDicom,
|
||||||
SeriesDetail,
|
SeriesDetail,
|
||||||
Resource
|
Resource,
|
||||||
|
CaseDisplaySet,
|
||||||
)
|
)
|
||||||
|
|
||||||
from django.forms import ModelForm
|
from django.forms import ModelForm
|
||||||
@@ -46,6 +47,7 @@ admin.site.register(CaseDetail)
|
|||||||
admin.site.register(UncategorisedDicom)
|
admin.site.register(UncategorisedDicom)
|
||||||
admin.site.register(SeriesDetail)
|
admin.site.register(SeriesDetail)
|
||||||
admin.site.register(Resource)
|
admin.site.register(Resource)
|
||||||
|
admin.site.register(CaseDisplaySet)
|
||||||
|
|
||||||
|
|
||||||
class DifferentialInline(admin.TabularInline):
|
class DifferentialInline(admin.TabularInline):
|
||||||
|
|||||||
+77
-91
@@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from ninja import ModelSchema, Router, Schema, Field
|
from ninja import ModelSchema, Router, Schema, Field
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction, IntegrityError
|
||||||
|
|
||||||
# from .decorators import user_is_author_or_rapid_checker
|
# from .decorators import user_is_author_or_rapid_checker
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
@@ -109,7 +109,6 @@ def clear_dicoms(request):
|
|||||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||||
|
|
||||||
dicoms.delete()
|
dicoms.delete()
|
||||||
print(dicoms)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -129,15 +128,10 @@ def uncategorised_dicoms(request):
|
|||||||
|
|
||||||
|
|
||||||
def import_dicoms_helper(request, case_id: int | None = None):
|
def import_dicoms_helper(request, case_id: int | None = None):
|
||||||
# TODO: move this out of the api (would be better with HTMX)
|
|
||||||
|
|
||||||
|
|
||||||
# dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
|
||||||
if "selection" in request.POST:
|
if "selection" in request.POST:
|
||||||
dicoms = UncategorisedDicom.objects.filter(
|
dicoms = UncategorisedDicom.objects.filter(
|
||||||
series_instance_uid__in=request.POST.getlist("selection")
|
series_instance_uid__in=request.POST.getlist("selection")
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||||
|
|
||||||
@@ -146,67 +140,66 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
|||||||
else:
|
else:
|
||||||
order = None
|
order = None
|
||||||
|
|
||||||
data = defaultdict(list)
|
# Group dicoms by SeriesInstanceUID, but don't keep all tags in memory
|
||||||
for d in dicoms:
|
series_map = {}
|
||||||
|
for d in dicoms.iterator(): # Use iterator() to avoid loading all at once
|
||||||
tags = d.basic_dicom_tags
|
tags = d.basic_dicom_tags
|
||||||
|
series_uid = tags["SeriesInstanceUID"]
|
||||||
|
|
||||||
data[tags["SeriesInstanceUID"]].append((d, tags))
|
if series_uid not in series_map:
|
||||||
|
# Get or create Series and related objects as needed
|
||||||
|
try:
|
||||||
|
modality = Modality.objects.get(short_code=tags["Modality"])
|
||||||
|
except Modality.DoesNotExist:
|
||||||
|
modality = Modality.objects.create(short_code=tags["Modality"], modality=tags["Modality"])
|
||||||
|
|
||||||
series_list = []
|
series_tags = tags # Save the first tag for this series
|
||||||
for series_uid in data:
|
try:
|
||||||
# Hmm probably not a good idea to blinding create a modality...
|
with transaction.atomic():
|
||||||
try:
|
series, created = Series.objects.get_or_create(
|
||||||
modality = Modality.objects.get(short_code=tags["Modality"])
|
series_instance_uid=series_uid,
|
||||||
except Modality.DoesNotExist:
|
defaults={
|
||||||
modality = Modality.objects.create(short_code=tags["Modality"], modality=tags["Modality"])
|
"modality": modality,
|
||||||
|
"description": tags.get("SeriesDescription", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if created and tags.get("StudyDescription"):
|
||||||
|
examination, created_exam = Examination.objects.get_or_create(
|
||||||
|
examination=tags["StudyDescription"]
|
||||||
|
)
|
||||||
|
if created_exam:
|
||||||
|
examination.modality = modality
|
||||||
|
examination.save()
|
||||||
|
series.examination = examination
|
||||||
|
series.save()
|
||||||
|
except IntegrityError:
|
||||||
|
series = Series.objects.get(series_instance_uid=series_uid)
|
||||||
|
|
||||||
|
# Add author and case if needed
|
||||||
|
series.author.add(request.user)
|
||||||
|
if case_id is not None:
|
||||||
|
case_object = get_object_or_404(Case, pk=case_id)
|
||||||
|
try:
|
||||||
|
series.case.add(case_object)
|
||||||
|
except IntegrityError:
|
||||||
|
pass
|
||||||
|
|
||||||
tags = data[series_uid][0][1]
|
series_map[series_uid] = series
|
||||||
|
|
||||||
# Check if series with the id already exists (in which case we just add to htat)
|
|
||||||
if Series.objects.filter(
|
|
||||||
series_instance_uid=tags["SeriesInstanceUID"]
|
|
||||||
).exists():
|
|
||||||
series = Series.objects.get(series_instance_uid=tags["SeriesInstanceUID"])
|
|
||||||
else:
|
else:
|
||||||
series = Series(
|
series = series_map[series_uid]
|
||||||
modality=modality,
|
|
||||||
description=tags["SeriesDescription"],
|
|
||||||
series_instance_uid=tags["SeriesInstanceUID"],
|
|
||||||
)
|
|
||||||
|
|
||||||
if tags["StudyDescription"]:
|
# Create and save SeriesImage immediately
|
||||||
examination, created = Examination.objects.get_or_create(
|
series_image = SeriesImage(
|
||||||
examination=tags["StudyDescription"]
|
image=d.image,
|
||||||
)
|
series=series,
|
||||||
if created:
|
is_dicom=True,
|
||||||
examination.modality = modality
|
image_blake3_hash=d.image_blake3_hash,
|
||||||
examination.save()
|
)
|
||||||
series.examination = examination
|
series_image.save()
|
||||||
|
d.delete() # Remove UncategorisedDicom immediately
|
||||||
series.save()
|
|
||||||
|
|
||||||
|
|
||||||
# We might only want to add the author during creation....
|
|
||||||
series.author.add(request.user)
|
|
||||||
|
|
||||||
if case_id is not None:
|
|
||||||
case_object = get_object_or_404(Case, pk=case_id)
|
|
||||||
if not series.case.contains(case_object):
|
|
||||||
series.case.add(case_object)
|
|
||||||
|
|
||||||
for dicom, dicom_tags in data[series_uid]:
|
|
||||||
series_image = SeriesImage(
|
|
||||||
image=dicom.image,
|
|
||||||
series=series,
|
|
||||||
is_dicom=True,
|
|
||||||
image_blake3_hash=dicom.image_blake3_hash,
|
|
||||||
)
|
|
||||||
series_image.save()
|
|
||||||
# dicom.image.delete()
|
|
||||||
|
|
||||||
dicom.delete()
|
|
||||||
|
|
||||||
|
# Optionally, re-order series if needed
|
||||||
|
for series in series_map.values():
|
||||||
match order:
|
match order:
|
||||||
case "order-series-instance-number":
|
case "order-series-instance-number":
|
||||||
series.order_by_dicom("InstanceNumber")
|
series.order_by_dicom("InstanceNumber")
|
||||||
@@ -214,10 +207,8 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
|||||||
series.order_by_dicom("SliceLocation")
|
series.order_by_dicom("SliceLocation")
|
||||||
case _:
|
case _:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
series_list.append((series, series.get_link()))
|
|
||||||
|
|
||||||
return series_list
|
return [(series, series.get_link()) for series in series_map.values()]
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -238,7 +229,6 @@ def import_dicoms_case(request, case_id: int):
|
|||||||
|
|
||||||
@router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema])
|
@router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema])
|
||||||
def orphan_series(request):
|
def orphan_series(request):
|
||||||
# print(request.user.series.filter(case=None))
|
|
||||||
return request.user.series.filter(case=None)
|
return request.user.series.filter(case=None)
|
||||||
|
|
||||||
|
|
||||||
@@ -261,7 +251,6 @@ def series_remove_duplicate_images(request, series_id: int):
|
|||||||
|
|
||||||
@router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth)
|
@router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth)
|
||||||
def series_truncate(request, series_id: int, start: int, end: int):
|
def series_truncate(request, series_id: int, start: int, end: int):
|
||||||
print(start, end)
|
|
||||||
series = get_object_or_404(Series, pk=series_id)
|
series = get_object_or_404(Series, pk=series_id)
|
||||||
|
|
||||||
if not series.check_user_can_edit(request.user):
|
if not series.check_user_can_edit(request.user):
|
||||||
@@ -272,7 +261,6 @@ def series_truncate(request, series_id: int, start: int, end: int):
|
|||||||
if n >= start and n <= end:
|
if n >= start and n <= end:
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
print(n, image)
|
|
||||||
image.removed = True
|
image.removed = True
|
||||||
image.image = None
|
image.image = None
|
||||||
image.save()
|
image.save()
|
||||||
@@ -291,7 +279,6 @@ def get_cases_user(request):
|
|||||||
@router.get("/check_image_hash/{hash}", auth=django_auth)
|
@router.get("/check_image_hash/{hash}", auth=django_auth)
|
||||||
def check_image_hash(request, hash: str):
|
def check_image_hash(request, hash: str):
|
||||||
try:
|
try:
|
||||||
print(hash)
|
|
||||||
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
|
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
|
||||||
data = {
|
data = {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@@ -370,39 +357,38 @@ def series_split_by_tag(request, series_id: int, dicom_tag: str):
|
|||||||
|
|
||||||
image_map = defaultdict(list)
|
image_map = defaultdict(list)
|
||||||
|
|
||||||
# Split teh associated images
|
# Use iterator to avoid loading all images into memory
|
||||||
for image in series.images.all():
|
for image in series.images.iterator():
|
||||||
print(image)
|
|
||||||
ds = image.get_dicom_data()
|
ds = image.get_dicom_data()
|
||||||
|
|
||||||
# print("2a", ds[dicom_tag])
|
|
||||||
|
|
||||||
if dicom_tag in ds:
|
if dicom_tag in ds:
|
||||||
val = ds[dicom_tag].value
|
val = ds[dicom_tag].value
|
||||||
|
if isinstance(val, pydicom.multival.MultiValue):
|
||||||
if type(val) == pydicom.multival.MultiValue:
|
key = ",".join(map(str, val))
|
||||||
image_map[",".join((val))].append(image)
|
|
||||||
else:
|
else:
|
||||||
image_map[val].append(image)
|
key = str(val)
|
||||||
|
image_map[key].append(image)
|
||||||
else:
|
else:
|
||||||
return [f"{dicom_tag} not found {image}"]
|
return [f"{dicom_tag} not found {image}"]
|
||||||
|
|
||||||
|
case = list(series.case.all())
|
||||||
|
authors = list(series.author.all())
|
||||||
new_series = []
|
new_series = []
|
||||||
case = series.case.all()
|
|
||||||
authors = series.author.all()
|
|
||||||
|
|
||||||
for n, option in enumerate(image_map):
|
# Use a transaction for all DB changes
|
||||||
if n == 0:
|
with transaction.atomic():
|
||||||
series.images.set(image_map[option])
|
for n, (option, images) in enumerate(image_map.items()):
|
||||||
|
if n == 0:
|
||||||
else:
|
# Update the original series
|
||||||
series.pk = None
|
series.images.set(images)
|
||||||
series.save()
|
new_series.append(series.pk)
|
||||||
series.case.set(case)
|
else:
|
||||||
series.author.set(authors)
|
# Clone the series
|
||||||
series.images.set(image_map[option])
|
series.pk = None
|
||||||
|
series.save()
|
||||||
new_series.append(series.pk)
|
series.case.set(case)
|
||||||
|
series.author.set(authors)
|
||||||
|
series.images.set(images)
|
||||||
|
new_series.append(series.pk)
|
||||||
|
|
||||||
return new_series
|
return new_series
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ def user_is_collection_author_or_atlas_editor(function):
|
|||||||
def wrap(request, *args, **kwargs):
|
def wrap(request, *args, **kwargs):
|
||||||
if "exam_id" in kwargs:
|
if "exam_id" in kwargs:
|
||||||
atlas = CaseCollection.objects.get(pk=kwargs["exam_id"])
|
atlas = CaseCollection.objects.get(pk=kwargs["exam_id"])
|
||||||
|
elif "collection_id" in kwargs:
|
||||||
|
atlas = CaseCollection.objects.get(pk=kwargs["collection_id"])
|
||||||
else:
|
else:
|
||||||
atlas = CaseCollection.objects.get(pk=kwargs["pk"])
|
atlas = CaseCollection.objects.get(pk=kwargs["pk"])
|
||||||
if (
|
if (
|
||||||
|
|||||||
+376
-117
@@ -12,7 +12,8 @@ from django.forms import (
|
|||||||
CharField,
|
CharField,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
modelformset_factory,
|
modelformset_factory,
|
||||||
CheckboxSelectMultiple
|
CheckboxSelectMultiple,
|
||||||
|
SplitDateTimeWidget,
|
||||||
)
|
)
|
||||||
from django.forms import inlineformset_factory
|
from django.forms import inlineformset_factory
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
@@ -32,6 +33,7 @@ from atlas.models import (
|
|||||||
Resource,
|
Resource,
|
||||||
SelfReview,
|
SelfReview,
|
||||||
Series,
|
Series,
|
||||||
|
SeriesDetail,
|
||||||
SeriesImage,
|
SeriesImage,
|
||||||
SeriesFinding,
|
SeriesFinding,
|
||||||
Condition,
|
Condition,
|
||||||
@@ -39,11 +41,12 @@ from atlas.models import (
|
|||||||
Subspecialty,
|
Subspecialty,
|
||||||
UncategorisedDicom,
|
UncategorisedDicom,
|
||||||
UserReportAnswer,
|
UserReportAnswer,
|
||||||
|
CaseDisplaySet,
|
||||||
)
|
)
|
||||||
|
|
||||||
from anatomy.models import Modality
|
from anatomy.models import Modality
|
||||||
|
|
||||||
from generic.models import Examination#, Sign
|
from generic.models import Examination # , Sign
|
||||||
|
|
||||||
# from generic.models import Examination, Site, Condition, Sign
|
# from generic.models import Examination, Site, Condition, Sign
|
||||||
|
|
||||||
@@ -56,11 +59,22 @@ from tinymce.widgets import TinyMCE
|
|||||||
|
|
||||||
from dal import autocomplete
|
from dal import autocomplete
|
||||||
|
|
||||||
from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register
|
from autocomplete import (
|
||||||
|
widgets as htmx_widgets,
|
||||||
|
Autocomplete,
|
||||||
|
AutocompleteWidget,
|
||||||
|
ModelAutocomplete,
|
||||||
|
register as autocomplete_register,
|
||||||
|
)
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from generic.forms import ExamAuthorFormMixin, ExamGroupsFormMixin
|
from generic.forms import (
|
||||||
|
ExamAuthorFormMixin,
|
||||||
|
ExamGroupsFormMixin,
|
||||||
|
SplitDateTimeFieldDefaultTime,
|
||||||
|
SplitDateTimeFieldDefaultTimeEnd,
|
||||||
|
)
|
||||||
|
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
|
|
||||||
@@ -71,31 +85,30 @@ from typing import Any
|
|||||||
from helpers.cimar import CimarAPI, NotFoundError
|
from helpers.cimar import CimarAPI, NotFoundError
|
||||||
from rad.settings import CIMAR_USERNAME, CIMAR_PASSWORD
|
from rad.settings import CIMAR_USERNAME, CIMAR_PASSWORD
|
||||||
|
|
||||||
|
|
||||||
class ClearableInput(TextInput):
|
class ClearableInput(TextInput):
|
||||||
template_name = "crispy_forms/widgets/clearable_input_single_line.html"
|
template_name = "crispy_forms/widgets/clearable_input_single_line.html"
|
||||||
|
|
||||||
|
|
||||||
class ClearableTextarea(Textarea):
|
class ClearableTextarea(Textarea):
|
||||||
template_name = "crispy_forms/widgets/clearable_input.html"
|
template_name = "crispy_forms/widgets/clearable_input.html"
|
||||||
|
|
||||||
|
|
||||||
class CaseSelect(Select):
|
class CaseSelect(Select):
|
||||||
template_name = "atlas/case_select_widget.html"
|
template_name = "atlas/case_select_widget.html"
|
||||||
|
|
||||||
|
|
||||||
def create_option(self, *args, **kwargs):
|
def create_option(self, *args, **kwargs):
|
||||||
option = super().create_option(*args, **kwargs)
|
option = super().create_option(*args, **kwargs)
|
||||||
|
|
||||||
print(option)
|
|
||||||
|
|
||||||
|
|
||||||
return option
|
return option
|
||||||
|
|
||||||
def get_context(self, name: str, value, attrs) -> dict[str, Any]:
|
def get_context(self, name: str, value, attrs) -> dict[str, Any]:
|
||||||
context = super().get_context(name, value, attrs)
|
context = super().get_context(name, value, attrs)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ConditionForm(ModelForm):
|
class ConditionForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Condition
|
model = Condition
|
||||||
@@ -120,26 +133,130 @@ class CaseCollectionForm(ModelForm):
|
|||||||
exclude = ["cases", "valid_cid_users", "valid_user_users", "author"]
|
exclude = ["cases", "valid_cid_users", "valid_user_users", "author"]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.user = kwargs.pop(
|
self.user = kwargs.pop("user")
|
||||||
"user"
|
|
||||||
) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
|
|
||||||
if kwargs.get("instance"):
|
if kwargs.get("instance"):
|
||||||
# We get the 'initial' keyword argument or initialize it
|
|
||||||
# as a dict if it didn't exist.
|
|
||||||
initial = kwargs.setdefault("initial", {})
|
initial = kwargs.setdefault("initial", {})
|
||||||
# The widget for a ModelMultipleChoiceField expects
|
|
||||||
# a list of primary key for the selected data.
|
|
||||||
# initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()]
|
|
||||||
|
|
||||||
ModelForm.__init__(self, *args, **kwargs)
|
ModelForm.__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
super(CaseCollectionForm, self).__init__(*args, **kwargs)
|
super(CaseCollectionForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Identify show_ fields
|
||||||
|
show_fields = [f for f in self.fields if f.startswith("show_")]
|
||||||
|
show_pre_fields = [f for f in show_fields if f.endswith("_pre")]
|
||||||
|
show_post_fields = [f for f in show_fields if f.endswith("_post")]
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_id = "id-casecollection-form"
|
||||||
|
self.helper.form_class = "casecollection-form"
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
self.helper.form_action = ""
|
||||||
|
self.helper.form_tag = True
|
||||||
|
# Group cid_user_groups and user_user_groups together if they exist in the form
|
||||||
|
user_fields = []
|
||||||
|
for f in ["cid_user_groups", "user_user_groups"]:
|
||||||
|
if f in self.fields:
|
||||||
|
user_fields.append(f)
|
||||||
|
|
||||||
|
# Explicitly define show fields for pre and post
|
||||||
|
show_pre_fields = [
|
||||||
|
f
|
||||||
|
for f in [
|
||||||
|
"show_title_pre",
|
||||||
|
"show_history_pre",
|
||||||
|
"show_description_pre",
|
||||||
|
"show_discussion_pre",
|
||||||
|
"show_report_pre",
|
||||||
|
]
|
||||||
|
if f in self.fields
|
||||||
|
]
|
||||||
|
show_post_fields = [
|
||||||
|
f
|
||||||
|
for f in [
|
||||||
|
"show_title_post",
|
||||||
|
"show_history_post",
|
||||||
|
"show_description_post",
|
||||||
|
"show_discussion_post",
|
||||||
|
"show_report_post",
|
||||||
|
]
|
||||||
|
if f in self.fields
|
||||||
|
]
|
||||||
|
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
Fieldset(
|
||||||
|
"Collection Details",
|
||||||
|
"name",
|
||||||
|
Fieldset(
|
||||||
|
"Dates",
|
||||||
|
"restrict_to_dates",
|
||||||
|
Div(
|
||||||
|
Field(
|
||||||
|
"start_date",
|
||||||
|
wrapper_class="col-md-6",
|
||||||
|
css_class="form-group",
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
"end_date", wrapper_class="col-md-6", css_class="form-group"
|
||||||
|
),
|
||||||
|
css_class="form-row d-flex",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"active",
|
||||||
|
"publish_results",
|
||||||
|
"archive",
|
||||||
|
"open_access",
|
||||||
|
"candidates_only",
|
||||||
|
"authors_only",
|
||||||
|
"results_supervisor_visible",
|
||||||
|
"exam_open_access",
|
||||||
|
"markers",
|
||||||
|
"exam_mode",
|
||||||
|
"self_review",
|
||||||
|
"feedback_once_collection_complete",
|
||||||
|
"collection_type",
|
||||||
|
"viewer_mode",
|
||||||
|
),
|
||||||
|
Fieldset("Valid User Groups", *user_fields) if user_fields else None,
|
||||||
|
Div(
|
||||||
|
Div(
|
||||||
|
Fieldset("Show fields (pre-exam)", *show_pre_fields),
|
||||||
|
css_class="col-md-6",
|
||||||
|
style="float:left;",
|
||||||
|
),
|
||||||
|
Div(
|
||||||
|
Fieldset("Show fields (post-exam)", *show_post_fields),
|
||||||
|
css_class="col-md-6",
|
||||||
|
style="float:left;",
|
||||||
|
),
|
||||||
|
css_class="row",
|
||||||
|
style="display: flex; flex-wrap: wrap;",
|
||||||
|
),
|
||||||
|
Submit("submit", "Save Collection", css_class="btn btn-primary"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fields["start_date"] = SplitDateTimeFieldDefaultTime(
|
||||||
|
widget=SplitDateTimeWidget(
|
||||||
|
date_attrs={"type": "date", "class": "datepicker"},
|
||||||
|
time_attrs={"type": "time", "class": "timepicker"},
|
||||||
|
date_format="%Y-%m-%d",
|
||||||
|
time_format="%H:%M",
|
||||||
|
),
|
||||||
|
required=False,
|
||||||
|
help_text="The date the exam is due to start (time is optional)",
|
||||||
|
)
|
||||||
|
self.fields["end_date"] = SplitDateTimeFieldDefaultTimeEnd(
|
||||||
|
widget=SplitDateTimeWidget(
|
||||||
|
date_attrs={"type": "date", "class": "datepicker"},
|
||||||
|
time_attrs={"type": "time", "class": "timepicker"},
|
||||||
|
date_format="%Y-%m-%d",
|
||||||
|
time_format="%H:%M",
|
||||||
|
),
|
||||||
|
required=False,
|
||||||
|
help_text="The date the exam is due to start (time is optional)",
|
||||||
|
)
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
# Get the unsaved Case instance
|
# Get the unsaved Case instance
|
||||||
instance = ModelForm.save(self, False)
|
instance = ModelForm.save(self, False)
|
||||||
|
|
||||||
|
|
||||||
# Do we need to save all changes now?
|
# Do we need to save all changes now?
|
||||||
# if commit:
|
# if commit:
|
||||||
instance.save()
|
instance.save()
|
||||||
@@ -175,7 +292,14 @@ class StructureForm(ModelForm):
|
|||||||
class SeriesFindingForm(ModelForm):
|
class SeriesFindingForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SeriesFinding
|
model = SeriesFinding
|
||||||
exclude = ["series", "annotation_json", "viewport_json", "current_image_id_index", "annotation_json_3d", "viewer_state_3d"]
|
exclude = [
|
||||||
|
"series",
|
||||||
|
"annotation_json",
|
||||||
|
"viewport_json",
|
||||||
|
"current_image_id_index",
|
||||||
|
"annotation_json_3d",
|
||||||
|
"viewer_state_3d",
|
||||||
|
]
|
||||||
|
|
||||||
widgets = {
|
widgets = {
|
||||||
"findings": autocomplete.ModelSelect2Multiple(
|
"findings": autocomplete.ModelSelect2Multiple(
|
||||||
@@ -187,7 +311,6 @@ class SeriesFindingForm(ModelForm):
|
|||||||
"conditions": autocomplete.ModelSelect2Multiple(
|
"conditions": autocomplete.ModelSelect2Multiple(
|
||||||
url="atlas:condition-autocomplete"
|
url="atlas:condition-autocomplete"
|
||||||
),
|
),
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -298,18 +421,40 @@ class SeriesForm(ModelForm):
|
|||||||
"description": Textarea(attrs={"maxlength": 1000, "rows": 5, "cols": 80}),
|
"description": Textarea(attrs={"maxlength": 1000, "rows": 5, "cols": 80}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# def save(self, commit=True):
|
||||||
|
# # Get the unsaved Case instance
|
||||||
|
# instance = ModelForm.save(self, False)
|
||||||
|
|
||||||
|
# # Prepare a 'save_m2m' method for the form,
|
||||||
|
# old_save_m2m = self.save_m2m
|
||||||
|
|
||||||
|
# def save_m2m():
|
||||||
|
# old_save_m2m()
|
||||||
|
# # instance.exams.clear()
|
||||||
|
# # for exam in self.cleaned_data["exams"]:
|
||||||
|
# # # We need the id here
|
||||||
|
# # instance.exams.add(exam.pk)
|
||||||
|
|
||||||
|
# self.save_m2m = save_m2m
|
||||||
|
|
||||||
|
# # Do we need to save all changes now?
|
||||||
|
# # if commit:
|
||||||
|
# instance.save()
|
||||||
|
# self.save_m2m()
|
||||||
|
|
||||||
|
# return instance
|
||||||
|
|
||||||
|
|
||||||
class CaseForm(ModelForm):
|
class CaseForm(ModelForm):
|
||||||
|
|
||||||
class Media:
|
class Media:
|
||||||
# Django also includes a few javascript files necessary
|
# Django also includes a few javascript files necessary
|
||||||
# for the operation of this form element. You need to
|
# for the operation of this form element. You need to
|
||||||
# include <script src="/admin/jsi18n"></script>
|
# include <script src="/admin/jsi18n"></script>
|
||||||
# in the template.
|
# in the template.
|
||||||
#history = forms.CharField(
|
# history = forms.CharField(
|
||||||
# widget=ClearableTextarea(attrs={"maxlength": 1000, "rows": 5, "cols": 80}),
|
# widget=ClearableTextarea(attrs={"maxlength": 1000, "rows": 5, "cols": 80}),
|
||||||
# required=False,
|
# required=False,
|
||||||
#)
|
# )
|
||||||
|
|
||||||
css = {
|
css = {
|
||||||
"all": ["css/widgets.css"],
|
"all": ["css/widgets.css"],
|
||||||
@@ -324,7 +469,7 @@ class CaseForm(ModelForm):
|
|||||||
|
|
||||||
# This is populated if we create the case from an exam/collection
|
# This is populated if we create the case from an exam/collection
|
||||||
self.collection = None
|
self.collection = None
|
||||||
if kwargs["initial"] is not None and "exams" in kwargs["initial"]:
|
if kwargs["initial"] is not None and "exams" in kwargs["initial"]:
|
||||||
collections = kwargs["initial"].pop("exams")
|
collections = kwargs["initial"].pop("exams")
|
||||||
logging.debug(collections)
|
logging.debug(collections)
|
||||||
|
|
||||||
@@ -338,20 +483,16 @@ class CaseForm(ModelForm):
|
|||||||
# a list of primary key for the selected data.
|
# a list of primary key for the selected data.
|
||||||
# initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()]
|
# initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ModelForm.__init__(self, *args, **kwargs)
|
ModelForm.__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
super(CaseForm, self).__init__(*args, **kwargs)
|
super(CaseForm, self).__init__(*args, **kwargs)
|
||||||
# self.fields["series"].queryset =
|
# self.fields["series"].queryset =
|
||||||
|
|
||||||
if self.user.groups.filter(name="atlas_editor").exists():
|
if self.user.groups.filter(name="atlas_editor").exists():
|
||||||
case_queryset = Case.objects.all().order_by("pk").prefetch_related(
|
case_queryset = Case.objects.all().order_by("pk").prefetch_related()
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
case_queryset = get_cases_available_to_user(self.user)
|
case_queryset = get_cases_available_to_user(self.user)
|
||||||
self.fields["previous_case"].queryset=case_queryset
|
self.fields["previous_case"].queryset = case_queryset
|
||||||
|
|
||||||
|
|
||||||
self.helper = FormHelper()
|
self.helper = FormHelper()
|
||||||
self.helper.form_id = "id-case-form"
|
self.helper.form_id = "id-case-form"
|
||||||
@@ -369,18 +510,16 @@ class CaseForm(ModelForm):
|
|||||||
"history",
|
"history",
|
||||||
"presentation",
|
"presentation",
|
||||||
"discussion",
|
"discussion",
|
||||||
|
"report",
|
||||||
"condition",
|
"condition",
|
||||||
"pathological_process",
|
"pathological_process",
|
||||||
"report",
|
|
||||||
"open_access",
|
"open_access",
|
||||||
"previous_case",
|
"previous_case",
|
||||||
"diagnostic_certainty",
|
"diagnostic_certainty",
|
||||||
"cimar_uuid",
|
"cimar_uuid",
|
||||||
),
|
),
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
return (
|
return (
|
||||||
super()
|
super()
|
||||||
@@ -409,7 +548,7 @@ class CaseForm(ModelForm):
|
|||||||
"open_access",
|
"open_access",
|
||||||
"previous_case",
|
"previous_case",
|
||||||
"diagnostic_certainty",
|
"diagnostic_certainty",
|
||||||
"cimar_uuid"
|
"cimar_uuid",
|
||||||
]
|
]
|
||||||
# fields = ['question', 'findings', 'subspecialty', 'references']
|
# fields = ['question', 'findings', 'subspecialty', 'references']
|
||||||
widgets = {
|
widgets = {
|
||||||
@@ -433,7 +572,7 @@ class CaseForm(ModelForm):
|
|||||||
"pathological_process": CheckboxSelectMultiple(),
|
"pathological_process": CheckboxSelectMultiple(),
|
||||||
"previous_case": CaseSelect(),
|
"previous_case": CaseSelect(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def clean_cimar_uuid(self):
|
def clean_cimar_uuid(self):
|
||||||
cimar_uuid = self.cleaned_data["cimar_uuid"]
|
cimar_uuid = self.cleaned_data["cimar_uuid"]
|
||||||
if cimar_uuid:
|
if cimar_uuid:
|
||||||
@@ -450,7 +589,6 @@ class CaseForm(ModelForm):
|
|||||||
# Get the unsaved Case instance
|
# Get the unsaved Case instance
|
||||||
instance = ModelForm.save(self, False)
|
instance = ModelForm.save(self, False)
|
||||||
|
|
||||||
|
|
||||||
# Prepare a 'save_m2m' method for the form,
|
# Prepare a 'save_m2m' method for the form,
|
||||||
old_save_m2m = self.save_m2m
|
old_save_m2m = self.save_m2m
|
||||||
|
|
||||||
@@ -477,8 +615,10 @@ class CaseForm(ModelForm):
|
|||||||
logging.debug(f"{instance=}")
|
logging.debug(f"{instance=}")
|
||||||
|
|
||||||
# Create through model
|
# Create through model
|
||||||
#cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
|
# cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
|
||||||
self.collection.cases.add(instance, through_defaults={"sort_order": case_no})
|
self.collection.cases.add(
|
||||||
|
instance, through_defaults={"sort_order": case_no}
|
||||||
|
)
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
@@ -496,6 +636,7 @@ CaseDifferentialFormSet = inlineformset_factory(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ResourceForm(ModelForm):
|
class ResourceForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Resource
|
model = Resource
|
||||||
@@ -508,7 +649,6 @@ class ResourceForm(ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class CaseResourceForm(ModelForm):
|
class CaseResourceForm(ModelForm):
|
||||||
|
|
||||||
def __init__(self, *args, user, **kwargs):
|
def __init__(self, *args, user, **kwargs):
|
||||||
super(CaseResourceForm, self).__init__(*args, **kwargs)
|
super(CaseResourceForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
@@ -526,23 +666,28 @@ class CaseResourceForm(ModelForm):
|
|||||||
self.fields["pre_review"] = BooleanField(required=False)
|
self.fields["pre_review"] = BooleanField(required=False)
|
||||||
|
|
||||||
|
|
||||||
class CaseSeriesForm(ModelForm):
|
# class CaseSeriesForm(ModelForm):
|
||||||
|
#
|
||||||
def __init__(self, *args, user, **kwargs):
|
# def __init__(self, *args, user, **kwargs):
|
||||||
super(CaseSeriesForm, self).__init__(*args, **kwargs)
|
# super(CaseSeriesForm, self).__init__(*args, **kwargs)
|
||||||
|
#
|
||||||
if not user.groups.filter(name="atlas_editor").exists():
|
# if not user.groups.filter(name="atlas_editor").exists():
|
||||||
queryset = Series.objects.filter(author__id=user.id)
|
# queryset = Series.objects.filter(author__id=user.id)
|
||||||
else:
|
# else:
|
||||||
queryset = Series.objects.all()
|
# queryset = Series.objects.all()
|
||||||
|
#
|
||||||
self.fields["series"] = ModelChoiceField(
|
# # Optimize the queryset for the ModelChoiceField
|
||||||
required=False,
|
# queryset = queryset.select_related(
|
||||||
queryset=queryset,
|
# "modality"
|
||||||
# widget=Select(verbose_name="Series", is_stacked=False),
|
# ).prefetch_related(
|
||||||
)
|
# "examination", "plane", "contrast", "images"
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# self.fields["series"] = ModelChoiceField(
|
||||||
|
# required=False,
|
||||||
|
# queryset=queryset,
|
||||||
|
# # widget=Select(verbose_name="Series", is_stacked=False),
|
||||||
|
# )
|
||||||
|
|
||||||
|
|
||||||
class CaseCollectionCaseForm(ModelForm):
|
class CaseCollectionCaseForm(ModelForm):
|
||||||
@@ -558,15 +703,15 @@ class CaseCollectionCaseForm(ModelForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
SeriesFormSet = inlineformset_factory(
|
# SeriesFormSet = inlineformset_factory(
|
||||||
Case,
|
# Case,
|
||||||
Series.case.through,
|
# Series.case.through,
|
||||||
form=CaseSeriesForm,
|
# form=CaseSeriesForm,
|
||||||
exclude=[],
|
# exclude=[],
|
||||||
can_delete=True,
|
# can_delete=True,
|
||||||
extra=0,
|
# extra=0,
|
||||||
max_num=10,
|
# max_num=10,
|
||||||
)
|
# )
|
||||||
|
|
||||||
CaseCollectionCaseFormSet = inlineformset_factory(
|
CaseCollectionCaseFormSet = inlineformset_factory(
|
||||||
CaseCollection,
|
CaseCollection,
|
||||||
@@ -591,7 +736,13 @@ CaseResourceFormSet = inlineformset_factory(
|
|||||||
SeriesImageFormSet = inlineformset_factory(
|
SeriesImageFormSet = inlineformset_factory(
|
||||||
Series,
|
Series,
|
||||||
SeriesImage,
|
SeriesImage,
|
||||||
exclude=["dicom_tags_ohif", "replaced", "image_md5_hash", "image_blake3_hash", "is_dicom"],
|
exclude=[
|
||||||
|
"dicom_tags_ohif",
|
||||||
|
"replaced",
|
||||||
|
"image_md5_hash",
|
||||||
|
"image_blake3_hash",
|
||||||
|
"is_dicom",
|
||||||
|
],
|
||||||
can_delete=True,
|
can_delete=True,
|
||||||
extra=0,
|
extra=0,
|
||||||
max_num=2000,
|
max_num=2000,
|
||||||
@@ -600,19 +751,23 @@ SeriesImageFormSet = inlineformset_factory(
|
|||||||
|
|
||||||
class JsonAnswerForm(Form):
|
class JsonAnswerForm(Form):
|
||||||
json_answer = JSONSchemaField(
|
json_answer = JSONSchemaField(
|
||||||
schema = {},
|
schema={},
|
||||||
#options = 'schema/options.json'
|
# options = 'schema/options.json'
|
||||||
options = 'schema/options.json'
|
options="schema/options.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = ["json_answer"]
|
fields = ["json_answer"]
|
||||||
|
|
||||||
def __init__(self, *args, question_schema=None, **kwargs):
|
def __init__(self, *args, question_schema=None, **kwargs):
|
||||||
super(JsonAnswerForm, self).__init__(*args, **kwargs)
|
super(JsonAnswerForm, self).__init__(*args, **kwargs)
|
||||||
if question_schema is not None:
|
if question_schema is not None:
|
||||||
self.fields["json_answer"] = JSONSchemaField(schema=question_schema, options='schema/options.json')
|
self.fields["json_answer"] = JSONSchemaField(
|
||||||
|
schema=question_schema, options="schema/options.json"
|
||||||
|
)
|
||||||
self.fields["json_answer"].label = False
|
self.fields["json_answer"].label = False
|
||||||
|
|
||||||
|
|
||||||
class BaseReportAnswerForm(ModelForm):
|
class BaseReportAnswerForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = ["answer"]
|
fields = ["answer"]
|
||||||
@@ -623,22 +778,23 @@ class BaseReportAnswerForm(ModelForm):
|
|||||||
# (False, 'No')])
|
# (False, 'No')])
|
||||||
# "findings": TinyMCE(attrs={"cols": 80, "rows": 20}),
|
# "findings": TinyMCE(attrs={"cols": 80, "rows": 20}),
|
||||||
# "mark_scheme": TinyMCE(attrs={"cols": 80, "rows": 30}),
|
# "mark_scheme": TinyMCE(attrs={"cols": 80, "rows": 30}),
|
||||||
"answer": Textarea(attrs={"cols": 100, "rows": 5, "placeholder":"Report text"}),
|
"answer": Textarea(
|
||||||
|
attrs={"cols": 100, "rows": 5, "placeholder": "Report text"}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# help_texts = {
|
# help_texts = {
|
||||||
# "answer": "Write your answer in here."
|
# "answer": "Write your answer in here."
|
||||||
# }
|
# }
|
||||||
|
|
||||||
def __init__(self, *args, case_detail, **kwargs):
|
def __init__(self, *args, case_detail, **kwargs):
|
||||||
super(BaseReportAnswerForm, self).__init__(*args, **kwargs)
|
super(BaseReportAnswerForm, self).__init__(*args, **kwargs)
|
||||||
self.fields["answer"].required = False
|
self.fields["answer"].required = False
|
||||||
|
|
||||||
|
|
||||||
class BaseQuestionAnswerForm(ModelForm):
|
class BaseQuestionAnswerForm(ModelForm):
|
||||||
json_answer = JSONSchemaField(
|
json_answer = JSONSchemaField(schema={}, options="schema/options.json")
|
||||||
schema = {},
|
|
||||||
options = 'schema/options.json'
|
|
||||||
)
|
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = ["json_answer"]
|
fields = ["json_answer"]
|
||||||
labels = {"json_answer": ""}
|
labels = {"json_answer": ""}
|
||||||
@@ -647,11 +803,13 @@ class BaseQuestionAnswerForm(ModelForm):
|
|||||||
# "answer": "Write your answer in here."
|
# "answer": "Write your answer in here."
|
||||||
# }
|
# }
|
||||||
|
|
||||||
def __init__(self, *args, case_detail, **kwargs):
|
def __init__(self, *args, case_detail, **kwargs):
|
||||||
super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs)
|
super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs)
|
||||||
#self.fields["json_answer"].schema = case_detail.question_schema
|
# self.fields["json_answer"].schema = case_detail.question_schema
|
||||||
if case_detail.question_schema is not None:
|
if case_detail.question_schema is not None:
|
||||||
self.fields["json_answer"] = JSONSchemaField(schema=case_detail.question_schema, options="schema/options.json")
|
self.fields["json_answer"] = JSONSchemaField(
|
||||||
|
schema=case_detail.question_schema, options="schema/options.json"
|
||||||
|
)
|
||||||
self.fields["json_answer"].label = ""
|
self.fields["json_answer"].label = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -659,18 +817,22 @@ class CidReportAnswerForm(BaseReportAnswerForm):
|
|||||||
class Meta(BaseReportAnswerForm.Meta):
|
class Meta(BaseReportAnswerForm.Meta):
|
||||||
model = CidReportAnswer
|
model = CidReportAnswer
|
||||||
|
|
||||||
|
|
||||||
class UserReportAnswerForm(BaseReportAnswerForm):
|
class UserReportAnswerForm(BaseReportAnswerForm):
|
||||||
class Meta(BaseReportAnswerForm.Meta):
|
class Meta(BaseReportAnswerForm.Meta):
|
||||||
model = UserReportAnswer
|
model = UserReportAnswer
|
||||||
|
|
||||||
|
|
||||||
class CidQuestionAnswerForm(BaseQuestionAnswerForm):
|
class CidQuestionAnswerForm(BaseQuestionAnswerForm):
|
||||||
class Meta(BaseQuestionAnswerForm.Meta):
|
class Meta(BaseQuestionAnswerForm.Meta):
|
||||||
model = CidReportAnswer
|
model = CidReportAnswer
|
||||||
|
|
||||||
|
|
||||||
class UserQuestionAnswerForm(BaseQuestionAnswerForm):
|
class UserQuestionAnswerForm(BaseQuestionAnswerForm):
|
||||||
class Meta(BaseQuestionAnswerForm.Meta):
|
class Meta(BaseQuestionAnswerForm.Meta):
|
||||||
model = UserReportAnswer
|
model = UserReportAnswer
|
||||||
|
|
||||||
|
|
||||||
class CidReportAnswerMarkForm(ModelForm):
|
class CidReportAnswerMarkForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CidReportAnswer
|
model = CidReportAnswer
|
||||||
@@ -690,17 +852,16 @@ class CidReportAnswerMarkForm(ModelForm):
|
|||||||
self.fields["score"].required = False
|
self.fields["score"].required = False
|
||||||
|
|
||||||
|
|
||||||
#class AddCollectionToCaseForm(Form):
|
# class AddCollectionToCaseForm(Form):
|
||||||
#
|
#
|
||||||
# def __init__(self, *args, user, **kwargs):
|
# def __init__(self, *args, user, **kwargs):
|
||||||
# super(AddCollectionToCaseForm, self).__init__(*args, **kwargs)
|
# super(AddCollectionToCaseForm, self).__init__(*args, **kwargs)
|
||||||
# print("INIT", args, kwargs)
|
|
||||||
#
|
#
|
||||||
# if not user.groups.filter(name="atlas_editor").exists():
|
# if not user.groups.filter(name="atlas_editor").exists():
|
||||||
# queryset = CaseCollection.objects.filter(author__id=user.id)
|
# queryset = CaseCollection.objects.filter(author__id=user.id)
|
||||||
# else:
|
# else:
|
||||||
# queryset = CaseCollection.objects.all()
|
# queryset = CaseCollection.objects.all()
|
||||||
#
|
#
|
||||||
# self.valid_collections = queryset
|
# self.valid_collections = queryset
|
||||||
# self.case = get_object_or_404(Case, pk=pk)
|
# self.case = get_object_or_404(Case, pk=pk)
|
||||||
#
|
#
|
||||||
@@ -732,17 +893,16 @@ class CidReportAnswerMarkForm(ModelForm):
|
|||||||
#
|
#
|
||||||
# return collection_set
|
# return collection_set
|
||||||
|
|
||||||
class AddCollectionToCaseForm(Form):
|
|
||||||
|
|
||||||
|
class AddCollectionToCaseForm(Form):
|
||||||
def __init__(self, *args, user, **kwargs):
|
def __init__(self, *args, user, **kwargs):
|
||||||
super(AddCollectionToCaseForm, self).__init__(*args, **kwargs)
|
super(AddCollectionToCaseForm, self).__init__(*args, **kwargs)
|
||||||
print("INIT", args, kwargs)
|
|
||||||
|
|
||||||
if not user.groups.filter(name="atlas_editor").exists():
|
if not user.groups.filter(name="atlas_editor").exists():
|
||||||
queryset = CaseCollection.objects.filter(author__id=user.id)
|
queryset = CaseCollection.objects.filter(author__id=user.id)
|
||||||
else:
|
else:
|
||||||
queryset = CaseCollection.objects.all()
|
queryset = CaseCollection.objects.all()
|
||||||
|
|
||||||
self.valid_collections = queryset
|
self.valid_collections = queryset
|
||||||
|
|
||||||
self.fields["casecollection"] = ModelMultipleChoiceField(
|
self.fields["casecollection"] = ModelMultipleChoiceField(
|
||||||
@@ -752,21 +912,16 @@ class AddCollectionToCaseForm(Form):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
print("SAVE", args, kwargs)
|
|
||||||
instance = super(AddCollectionToCaseForm, self).save(*args, **kwargs)
|
instance = super(AddCollectionToCaseForm, self).save(*args, **kwargs)
|
||||||
|
|
||||||
# Here we save the modified project selection back into the database
|
# Here we save the modified project selection back into the database
|
||||||
instance.casecollection_set.set(self.cleaned_data['casecollection'])
|
instance.casecollection_set.set(self.cleaned_data["casecollection"])
|
||||||
|
|
||||||
print(f"{instance=}")
|
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
def clean_casecollection(self):
|
def clean_casecollection(self):
|
||||||
collection_set = self.cleaned_data["casecollection"]
|
collection_set = self.cleaned_data["casecollection"]
|
||||||
|
|
||||||
print(f"{collection_set=}")
|
|
||||||
|
|
||||||
for col in collection_set:
|
for col in collection_set:
|
||||||
if col not in self.valid_collections:
|
if col not in self.valid_collections:
|
||||||
raise ValidationError("Invalid collection")
|
raise ValidationError("Invalid collection")
|
||||||
@@ -775,7 +930,6 @@ class AddCollectionToCaseForm(Form):
|
|||||||
|
|
||||||
|
|
||||||
class SelfReviewForm(ModelForm):
|
class SelfReviewForm(ModelForm):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SelfReview
|
model = SelfReview
|
||||||
fields = ["user_exam", "case", "comments", "findings", "interpretation"]
|
fields = ["user_exam", "case", "comments", "findings", "interpretation"]
|
||||||
@@ -790,16 +944,16 @@ class SelfReviewForm(ModelForm):
|
|||||||
ModelForm.__init__(self, *args, **kwargs)
|
ModelForm.__init__(self, *args, **kwargs)
|
||||||
super(SelfReviewForm, self).__init__(*args, **kwargs)
|
super(SelfReviewForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class UncategorisedDicomForm(ModelForm):
|
class UncategorisedDicomForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = UncategorisedDicom
|
model = UncategorisedDicom
|
||||||
fields = ["image"]#, "user"]
|
fields = ["image"] # , "user"]
|
||||||
|
|
||||||
|
|
||||||
#UncategorisedDicomFormset = formset_factory(UncategorisedDicomForm)
|
# UncategorisedDicomFormset = formset_factory(UncategorisedDicomForm)
|
||||||
|
|
||||||
#class ConditionAutocompleteFormModel(forms.ModelForm):
|
# class ConditionAutocompleteFormModel(forms.ModelForm):
|
||||||
# class Meta:
|
# class Meta:
|
||||||
# model = Condition
|
# model = Condition
|
||||||
# fields = ["name"]
|
# fields = ["name"]
|
||||||
@@ -810,49 +964,56 @@ class UncategorisedDicomForm(ModelForm):
|
|||||||
# )
|
# )
|
||||||
# }
|
# }
|
||||||
|
|
||||||
|
|
||||||
class ConditionAutocomplete(ModelAutocomplete):
|
class ConditionAutocomplete(ModelAutocomplete):
|
||||||
model = Condition
|
model = Condition
|
||||||
search_attrs = ["name"]
|
search_attrs = ["name"]
|
||||||
|
|
||||||
class ConditionAutocompleteForm(Form):
|
|
||||||
condition = ModelChoiceField(queryset=Condition.objects.all(), widget=AutocompleteWidget(
|
|
||||||
ac_class=ConditionAutocomplete#, options=dict(model=Condition)
|
|
||||||
|
|
||||||
))
|
class ConditionAutocompleteForm(Form):
|
||||||
|
condition = ModelChoiceField(
|
||||||
|
queryset=Condition.objects.all(),
|
||||||
|
widget=AutocompleteWidget(
|
||||||
|
ac_class=ConditionAutocomplete # , options=dict(model=Condition)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
autocomplete_register(ConditionAutocomplete)
|
autocomplete_register(ConditionAutocomplete)
|
||||||
|
|
||||||
|
|
||||||
class CaseCollectionAuthorForm(ExamAuthorFormMixin):
|
class CaseCollectionAuthorForm(ExamAuthorFormMixin):
|
||||||
class Meta(ExamAuthorFormMixin.Meta):
|
class Meta(ExamAuthorFormMixin.Meta):
|
||||||
model = CaseCollection
|
model = CaseCollection
|
||||||
|
|
||||||
|
|
||||||
class CaseAuthorForm(ExamAuthorFormMixin):
|
class CaseAuthorForm(ExamAuthorFormMixin):
|
||||||
class Meta(ExamAuthorFormMixin.Meta):
|
class Meta(ExamAuthorFormMixin.Meta):
|
||||||
model = Case
|
model = Case
|
||||||
|
|
||||||
|
|
||||||
class SeriesAuthorForm(ExamAuthorFormMixin):
|
class SeriesAuthorForm(ExamAuthorFormMixin):
|
||||||
class Meta(ExamAuthorFormMixin.Meta):
|
class Meta(ExamAuthorFormMixin.Meta):
|
||||||
model = Series
|
model = Series
|
||||||
|
|
||||||
|
|
||||||
class ExamGroupsForm(ExamGroupsFormMixin):
|
class ExamGroupsForm(ExamGroupsFormMixin):
|
||||||
class Meta(ExamGroupsFormMixin.Meta):
|
class Meta(ExamGroupsFormMixin.Meta):
|
||||||
model = CaseCollection
|
model = CaseCollection
|
||||||
|
|
||||||
class AnswerJSONForm(Form):
|
|
||||||
|
|
||||||
json = JSONSchemaField(
|
class AnswerJSONForm(Form):
|
||||||
schema = 'schema/schema.json',
|
json = JSONSchemaField(schema="schema/schema.json", options="schema/options.json")
|
||||||
options = 'schema/options.json'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SvelteJSONEditorWidgetOverride(SvelteJSONEditorWidget):
|
class SvelteJSONEditorWidgetOverride(SvelteJSONEditorWidget):
|
||||||
template_name = "atlas/svelte_jsoneditor_widget_override.html"
|
template_name = "atlas/svelte_jsoneditor_widget_override.html"
|
||||||
|
|
||||||
class CaseDetailForm(ModelForm):
|
|
||||||
|
|
||||||
|
class CaseDetailForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CaseDetail
|
model = CaseDetail
|
||||||
fields = ["question_schema", "question_answers"]#, "user"]
|
fields = ["question_schema", "question_answers"] # , "user"]
|
||||||
|
|
||||||
widgets = {
|
widgets = {
|
||||||
"question_schema": SvelteJSONEditorWidgetOverride(),
|
"question_schema": SvelteJSONEditorWidgetOverride(),
|
||||||
@@ -869,20 +1030,19 @@ class QuestionSchemaForm(ModelForm):
|
|||||||
"schema": SvelteJSONEditorWidgetOverride(),
|
"schema": SvelteJSONEditorWidgetOverride(),
|
||||||
}
|
}
|
||||||
|
|
||||||
#class ModelPriorCaseForm(ModelForm):
|
|
||||||
|
# class ModelPriorCaseForm(ModelForm):
|
||||||
# class Meta:
|
# class Meta:
|
||||||
# model = CasePrior
|
# model = CasePrior
|
||||||
# fields = ["case_detail", "prior_case", "relation_text", "prior_visibility"]
|
# fields = ["case_detail", "prior_case", "relation_text", "prior_visibility"]
|
||||||
|
|
||||||
class PriorCaseForm(Form):
|
|
||||||
|
|
||||||
|
class PriorCaseForm(Form):
|
||||||
relation = CharField(max_length=255, required=True)
|
relation = CharField(max_length=255, required=True)
|
||||||
|
|
||||||
prior_visibility = CharField(max_length=2, required=True)
|
prior_visibility = CharField(max_length=2, required=True)
|
||||||
|
|
||||||
|
# def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
#def __init__(self, *args, **kwargs):
|
|
||||||
# self.case_detail = kwargs.pop(
|
# self.case_detail = kwargs.pop(
|
||||||
# "case_detail"
|
# "case_detail"
|
||||||
# ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
|
# ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
|
||||||
@@ -897,4 +1057,103 @@ class PriorCaseForm(Form):
|
|||||||
# self.fields["case"] = ChoiceField(
|
# self.fields["case"] = ChoiceField(
|
||||||
# required=False,
|
# required=False,
|
||||||
# # widget=Select(verbose_name="Series", is_stacked=False),
|
# # widget=Select(verbose_name="Series", is_stacked=False),
|
||||||
# )
|
# )
|
||||||
|
|
||||||
|
|
||||||
|
class CaseSeriesForm(forms.ModelForm):
|
||||||
|
def __init__(self, *args, queryset, user=None, is_atlas_editor=False, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["series"].queryset = queryset
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SeriesDetail # through model for Case.series
|
||||||
|
fields = ["series", "sort_order", "feedback"]
|
||||||
|
|
||||||
|
|
||||||
|
class CaseUpdateSeriesForm(forms.ModelForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.user = kwargs.pop("user")
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_id = "id-case-form"
|
||||||
|
self.helper.form_class = "case-form"
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
self.helper.form_action = "submit"
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.layout = Layout()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Case
|
||||||
|
fields = [] # No fields, just for compatibility with UpdateView
|
||||||
|
|
||||||
|
|
||||||
|
class BaseSeriesFormSet(BaseInlineFormSet):
|
||||||
|
def __init__(self, *args, queryset=None, **kwargs):
|
||||||
|
self.series_queryset = queryset
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_form_kwargs(self, index):
|
||||||
|
kwargs = super().get_form_kwargs(index)
|
||||||
|
kwargs["queryset"] = self.series_queryset
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
SeriesFormSet = inlineformset_factory(
|
||||||
|
Case,
|
||||||
|
SeriesDetail,
|
||||||
|
form=CaseSeriesForm,
|
||||||
|
formset=BaseSeriesFormSet,
|
||||||
|
extra=1,
|
||||||
|
can_delete=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseDisplaySetForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = CaseDisplaySet
|
||||||
|
fields = [
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"findings",
|
||||||
|
"structures",
|
||||||
|
"conditions",
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
"name": forms.TextInput(
|
||||||
|
attrs={"class": "form-control", "placeholder": "Display Set Name"}
|
||||||
|
),
|
||||||
|
"description": forms.Textarea(
|
||||||
|
attrs={"class": "form-control", "rows": 3, "placeholder": "Description"}
|
||||||
|
),
|
||||||
|
"findings": autocomplete.ModelSelect2Multiple(
|
||||||
|
url="atlas:finding-autocomplete"
|
||||||
|
),
|
||||||
|
"structures": autocomplete.ModelSelect2Multiple(
|
||||||
|
url="atlas:structure-autocomplete"
|
||||||
|
),
|
||||||
|
"conditions": autocomplete.ModelSelect2Multiple(
|
||||||
|
url="atlas:condition-autocomplete"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# Optionally, you can filter queryset for findings/structures/conditions here if needed
|
||||||
|
|
||||||
|
|
||||||
|
class CaseCollectionUpdateCaseForm(forms.ModelForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.user = kwargs.pop("user")
|
||||||
|
self.collection = kwargs.pop("collection", None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_id = "id-case-form"
|
||||||
|
self.helper.form_class = "case-form"
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
self.helper.form_action = "submit"
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.layout = Layout()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CaseCollection
|
||||||
|
fields = [] # No fields, just for compatibility with UpdateView
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-06-16 12:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('atlas', '0070_seriesfinding_annotation_json_3d_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='casedetail',
|
||||||
|
name='default_viewerstate',
|
||||||
|
field=models.JSONField(blank=True, help_text='Default viewer state for the case', null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
cimar_uuid = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
|
||||||
|
def get_ordered_series_details(self):
|
||||||
|
# If the series are already prefetched, use them directly
|
||||||
|
if hasattr(self, '_prefetched_objects_cache') and 'series' in self._prefetched_objects_cache:
|
||||||
|
# Get the through model and sort by sort_order
|
||||||
|
through_model = self.series.through
|
||||||
|
# Build a mapping from series pk to through instance
|
||||||
|
through_objs = list(through_model.objects.filter(case=self).select_related('series'))
|
||||||
|
through_objs.sort(key=lambda x: x.sort_order)
|
||||||
|
return through_objs
|
||||||
|
# Otherwise, query as before
|
||||||
|
return self.series.through.objects.filter(case=self).select_related('series').order_by('sort_order')
|
||||||
|
|
||||||
|
def get_ordered_series(self):
|
||||||
|
"""Returns the series in the case in order of the SeriesDetail sort_order"""
|
||||||
|
return [sd.series for sd in self.get_ordered_series_details()]
|
||||||
|
|
||||||
def get_app_name(self):
|
def get_app_name(self):
|
||||||
return "atlas"
|
return "atlas"
|
||||||
|
|
||||||
@@ -533,7 +549,15 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
|||||||
|
|
||||||
return size
|
return size
|
||||||
|
|
||||||
def check_user_can_edit(self, user):
|
def check_user_can_edit(self, user) -> bool:
|
||||||
|
"""Check if the user can edit the case.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user (User): The user to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the user can edit the case, False otherwise.
|
||||||
|
"""
|
||||||
if user.is_superuser:
|
if user.is_superuser:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -541,10 +565,21 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_series_images_nested(self, as_json=True):
|
||||||
|
"""
|
||||||
|
Returns a list of lists, where each inner list contains the image URLs
|
||||||
|
for a single series in this case, in the order of the series.
|
||||||
|
"""
|
||||||
|
images = [
|
||||||
|
[image.image.url for image in series.images.all()]
|
||||||
|
for series in self.series.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
if as_json:
|
||||||
|
return json.dumps(images)
|
||||||
|
else:
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
def extract_image_dicom_json_from_ds(ds, url, image_index):
|
def extract_image_dicom_json_from_ds(ds, url, image_index):
|
||||||
@@ -1091,6 +1126,46 @@ class SeriesDetail(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
ordering = ("sort_order",)
|
ordering = ("sort_order",)
|
||||||
|
|
||||||
|
class CaseDisplaySet(models.Model, AuthorMixin):
|
||||||
|
"""
|
||||||
|
This is analogous to a SeriesFinding but for a Case (it has
|
||||||
|
access to all the series stacks).
|
||||||
|
|
||||||
|
"""
|
||||||
|
case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="display_sets")
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
help_text="Name of the display set",
|
||||||
|
)
|
||||||
|
|
||||||
|
description = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Description of the display set",
|
||||||
|
)
|
||||||
|
|
||||||
|
viewerstate = models.JSONField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Viewer state for the display set",
|
||||||
|
)
|
||||||
|
|
||||||
|
annotations = models.JSONField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Annotations for the display set",
|
||||||
|
)
|
||||||
|
|
||||||
|
findings = models.ManyToManyField(Finding, blank=True)
|
||||||
|
structures = models.ManyToManyField(Structure, blank=True)
|
||||||
|
conditions = models.ManyToManyField(Condition, blank=True)
|
||||||
|
|
||||||
|
def viewerstate_string(self):
|
||||||
|
return json.dumps(self.viewerstate) if self.viewerstate else "{}"
|
||||||
|
|
||||||
|
def annotations_string(self):
|
||||||
|
return json.dumps(self.annotations) if self.annotations else "{}"
|
||||||
|
|
||||||
|
|
||||||
class CaseDetail(models.Model):
|
class CaseDetail(models.Model):
|
||||||
"""A through table that stores the relationship between a case and a collection.
|
"""A through table that stores the relationship between a case and a collection.
|
||||||
@@ -1107,6 +1182,12 @@ class CaseDetail(models.Model):
|
|||||||
# TODO add feedback for questions
|
# TODO add feedback for questions
|
||||||
#question_feedback = models.JSONField(null=True, blank=True)
|
#question_feedback = models.JSONField(null=True, blank=True)
|
||||||
|
|
||||||
|
default_viewerstate = models.JSONField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Default viewer state for the case"
|
||||||
|
)
|
||||||
|
|
||||||
sort_order = models.IntegerField(default=1000)
|
sort_order = models.IntegerField(default=1000)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -1129,6 +1210,9 @@ class CaseDetail(models.Model):
|
|||||||
except UserReportAnswer.DoesNotExist:
|
except UserReportAnswer.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def default_viewerstate_string(self):
|
||||||
|
return json.dumps(self.default_viewerstate) if self.default_viewerstate else "{}"
|
||||||
|
|
||||||
class CasePrior(models.Model):
|
class CasePrior(models.Model):
|
||||||
case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
|
case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
|
||||||
prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case")
|
prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case")
|
||||||
@@ -1195,10 +1279,7 @@ class BaseReportAnswer(models.Model):
|
|||||||
if self.question.question_schema is None or not "properties" in self.question.question_schema:
|
if self.question.question_schema is None or not "properties" in self.question.question_schema:
|
||||||
return []
|
return []
|
||||||
answers = []
|
answers = []
|
||||||
print("1", self.json_answer)
|
|
||||||
print("2", self.question.question_answers)
|
|
||||||
for name, value in self.question.question_schema["properties"].items():
|
for name, value in self.question.question_schema["properties"].items():
|
||||||
print(name, value)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user_answer = self.json_answer[name]
|
user_answer = self.json_answer[name]
|
||||||
@@ -1209,11 +1290,8 @@ class BaseReportAnswer(models.Model):
|
|||||||
except KeyError: # If the schema has changed?
|
except KeyError: # If the schema has changed?
|
||||||
correct_answer = ""
|
correct_answer = ""
|
||||||
|
|
||||||
print(correct_answer)
|
|
||||||
|
|
||||||
match value:
|
match value:
|
||||||
case {"type": "string", "enum": _}:
|
case {"type": "string", "enum": _}:
|
||||||
print("YES", value)
|
|
||||||
automark = True
|
automark = True
|
||||||
answer_is_correct = user_answer == correct_answer
|
answer_is_correct = user_answer == correct_answer
|
||||||
case {"type": "string"}:
|
case {"type": "string"}:
|
||||||
@@ -1346,8 +1424,6 @@ class UncategorisedDicom(models.Model):
|
|||||||
# duplicate = obj
|
# duplicate = obj
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
print(image_hash)
|
|
||||||
|
|
||||||
if obj := SeriesImage.objects.filter(image_blake3_hash=image_hash).first():
|
if obj := SeriesImage.objects.filter(image_blake3_hash=image_hash).first():
|
||||||
duplicate = obj
|
duplicate = obj
|
||||||
|
|
||||||
@@ -1389,14 +1465,12 @@ class UncategorisedDicom(models.Model):
|
|||||||
ds = pydicom.dcmread(self.image)
|
ds = pydicom.dcmread(self.image)
|
||||||
return ds
|
return ds
|
||||||
except pydicom.errors.InvalidDicomError:
|
except pydicom.errors.InvalidDicomError:
|
||||||
print("Error getting dicom dataset")
|
|
||||||
print(self.image)
|
|
||||||
return {"SeriesInstanceUID": "1234"}
|
return {"SeriesInstanceUID": "1234"}
|
||||||
|
|
||||||
def get_basic_dicom_tags(self, dataset=None):
|
def get_basic_dicom_tags(self, dataset=None):
|
||||||
try:
|
try:
|
||||||
if dataset is None:
|
if dataset is None:
|
||||||
dataset = pydicom.read_file(self.image)
|
dataset = pydicom.dcmread(self.image)
|
||||||
to_include = (
|
to_include = (
|
||||||
"StudyDescription",
|
"StudyDescription",
|
||||||
"Modality",
|
"Modality",
|
||||||
|
|||||||
+28
-6
@@ -133,7 +133,14 @@ class CaseTable(tables.Table):
|
|||||||
return "No associated cases"
|
return "No associated cases"
|
||||||
|
|
||||||
def render_collections(self, record):
|
def render_collections(self, record):
|
||||||
collections = record.casecollection_set.all()
|
collections = [
|
||||||
|
collection
|
||||||
|
for collection in record.casecollection_set.all()
|
||||||
|
if (
|
||||||
|
hasattr(self, 'request')
|
||||||
|
and self.request.user in collection.author.all()
|
||||||
|
) or collection.open_access
|
||||||
|
]
|
||||||
if collections:
|
if collections:
|
||||||
links = [
|
links = [
|
||||||
format_html(
|
format_html(
|
||||||
@@ -183,6 +190,8 @@ class CaseTable(tables.Table):
|
|||||||
)
|
)
|
||||||
sequence = ("view", )
|
sequence = ("view", )
|
||||||
order_by = "-created_date"
|
order_by = "-created_date"
|
||||||
|
attrs = {"class": "table row-selector"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class PopupLinkColumn(tables.Column):
|
class PopupLinkColumn(tables.Column):
|
||||||
@@ -196,11 +205,6 @@ class PopupLinkColumn(tables.Column):
|
|||||||
return obj.get_thumbnail()[0]
|
return obj.get_thumbnail()[0]
|
||||||
|
|
||||||
|
|
||||||
def create_link(test):
|
|
||||||
print(test)
|
|
||||||
return "Hello"
|
|
||||||
|
|
||||||
|
|
||||||
class SeriesTable(tables.Table):
|
class SeriesTable(tables.Table):
|
||||||
edit = tables.LinkColumn(
|
edit = tables.LinkColumn(
|
||||||
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False
|
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False
|
||||||
@@ -236,6 +240,8 @@ class SeriesTable(tables.Table):
|
|||||||
)
|
)
|
||||||
sequence = ("view", "popup", "images", "case")
|
sequence = ("view", "popup", "images", "case")
|
||||||
order_by = "-created_date"
|
order_by = "-created_date"
|
||||||
|
attrs = {"class": "table row-selector"}
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -276,6 +282,8 @@ class ConditionTable(tables.Table):
|
|||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
attrs = {"class": "table row-selector"}
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -312,6 +320,8 @@ class FindingTable(tables.Table):
|
|||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name", "primary")
|
fields = ("name", "primary")
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
attrs = {"class": "table row-selector"}
|
||||||
|
|
||||||
|
|
||||||
def render_synonym(self, value, record):
|
def render_synonym(self, value, record):
|
||||||
return format_html(record.get_synonym_link())
|
return format_html(record.get_synonym_link())
|
||||||
@@ -339,6 +349,8 @@ class StructureTable(tables.Table):
|
|||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name", "primary")
|
fields = ("name", "primary")
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
attrs = {"class": "table row-selector"}
|
||||||
|
|
||||||
|
|
||||||
def render_synonym(self, value, record):
|
def render_synonym(self, value, record):
|
||||||
return format_html(record.get_synonym_link())
|
return format_html(record.get_synonym_link())
|
||||||
@@ -365,6 +377,8 @@ class PresentationTable(tables.Table):
|
|||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name",)
|
fields = ("name",)
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
attrs = {"class": "table row-selector"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class PathologicalProcessTable(tables.Table):
|
class PathologicalProcessTable(tables.Table):
|
||||||
@@ -386,6 +400,8 @@ class PathologicalProcessTable(tables.Table):
|
|||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name",)
|
fields = ("name",)
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
attrs = {"class": "table row-selector"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class SubspecialtyTable(tables.Table):
|
class SubspecialtyTable(tables.Table):
|
||||||
@@ -407,6 +423,8 @@ class SubspecialtyTable(tables.Table):
|
|||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ()
|
fields = ()
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
attrs = {"class": "table row-selector"}
|
||||||
|
|
||||||
|
|
||||||
def render_synonym(self, value, record):
|
def render_synonym(self, value, record):
|
||||||
return format_html(record.get_synonym_link())
|
return format_html(record.get_synonym_link())
|
||||||
@@ -441,6 +459,8 @@ class CaseCollectionTable(tables.Table):
|
|||||||
"author",
|
"author",
|
||||||
)
|
)
|
||||||
sequence = ("view", )#, "series")
|
sequence = ("view", )#, "series")
|
||||||
|
attrs = {"class": "table row-selector"}
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -468,6 +488,8 @@ class QuestionSchemaTable(tables.Table):
|
|||||||
model = QuestionSchema
|
model = QuestionSchema
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name", "description", "schema")
|
fields = ("name", "description", "schema")
|
||||||
|
attrs = {"class": "table row-selector"}
|
||||||
|
|
||||||
|
|
||||||
def render_schema(self, value, record):
|
def render_schema(self, value, record):
|
||||||
return format_html("<details><summary>Schema</summary>{}</details>", value)
|
return format_html("<details><summary>Schema</summary>{}</details>", value)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<span id="add-case-form">
|
<span id="add-case-form">
|
||||||
|
This form allows you to add an existing case to the collection. You may find it easier to do this from the individual case pages or the <a href="{% url 'atlas:case_view' %}">case overview view</a> which allows you to add multiple cases at once.
|
||||||
<form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML">
|
<form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<label for="case">Select Case to add:</label>
|
<label for="case">Select Case to add:</label>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% load partials %}
|
{% load partials %}
|
||||||
|
|
||||||
{% partialdef case-series %}
|
{% partialdef case-series %}
|
||||||
{% for series in case.series.all %}
|
{% for series in case.get_ordered_series %}
|
||||||
<span class="series-block" id="series-block-{{ series.pk }}">
|
<span class="series-block" id="series-block-{{ series.pk }}">
|
||||||
<span>
|
<span>
|
||||||
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
|
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
|
||||||
@@ -9,7 +9,13 @@
|
|||||||
{{series.get_block}}
|
{{series.get_block}}
|
||||||
</a>
|
</a>
|
||||||
<br>
|
<br>
|
||||||
<input type="checkbox" name="series-ids" value="{{series.pk}}">
|
<button type="button"
|
||||||
|
class="btn btn-outline-primary btn-sm select-series-btn"
|
||||||
|
style="display: none;"
|
||||||
|
data-series-id="{{series.pk}}">
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
|
||||||
<span class="series-block-popup-link">
|
<span class="series-block-popup-link">
|
||||||
<a href="#"
|
<a href="#"
|
||||||
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
|
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
|
||||||
@@ -113,6 +119,16 @@
|
|||||||
ID: {{ case.id }}
|
ID: {{ case.id }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details id="dicom-viewer-details">
|
||||||
|
<summary>Viewer</summary>
|
||||||
|
|
||||||
|
<div id="main_viewer" class="dicom-viewer-root"
|
||||||
|
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
|
||||||
|
data-auto-cache-stack="false"
|
||||||
|
data-images='{{case.get_series_images_nested}}'
|
||||||
|
></div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<p class="pre-whitespace"><b>Description:</b> {{ case.description }}</p>
|
<p class="pre-whitespace"><b>Description:</b> {{ case.description }}</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
@@ -144,22 +160,30 @@
|
|||||||
{% partial case-series %}
|
{% partial case-series %}
|
||||||
</form>
|
</form>
|
||||||
<span>
|
<span>
|
||||||
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Add new series</a><br />
|
<button type="button"
|
||||||
<a href="{% url 'atlas:user_uploads_case' case_id=case.pk %}">Import new uploads</a><br />
|
class="btn btn-info"
|
||||||
|
onclick="window.location.href='{% url 'atlas:user_uploads_case' case_id=case.pk %}'">
|
||||||
|
Import new uploads
|
||||||
|
</button><br />
|
||||||
<details class="series-actions" id="series-actions">
|
<details class="series-actions" id="series-actions">
|
||||||
<summary>+</summary>
|
<summary>
|
||||||
|
<button type="button" class="btn btn-primary" title="Click to manage series" onclick="this.closest('details').open = !this.closest('details').open;">
|
||||||
|
Manage Series
|
||||||
|
</button>
|
||||||
|
</summary>
|
||||||
|
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Create and add new series</a><br />
|
||||||
<button hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
|
<button hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
|
||||||
title="order dicom by slice location"
|
title="order dicom by slice location"
|
||||||
hx-target="this"
|
hx-target="#series-action-results"
|
||||||
hx-swap="outerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-confirm="This will reorder all case series based upon slice location"
|
hx-confirm="This will reorder all case series based upon slice location"
|
||||||
>
|
>
|
||||||
Order all series dicoms by slice location
|
Order dicoms by slice location
|
||||||
</button>
|
</button>
|
||||||
<button hx-post="{% url 'atlas:combine_series' %}"
|
<button hx-post="{% url 'atlas:combine_series' %}"
|
||||||
title="merge series"
|
title="merge series"
|
||||||
hx-include="[name='series-ids']"
|
hx-include="[name='series-ids']"
|
||||||
hx-target="#series-merge-results"
|
hx-target="#series-action-results"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-confirm="This will merge all selected series into one"
|
hx-confirm="This will merge all selected series into one"
|
||||||
>
|
>
|
||||||
@@ -168,14 +192,33 @@
|
|||||||
<button hx-post="{% url 'atlas:use_dates_as_descriptions' case.pk %}"
|
<button hx-post="{% url 'atlas:use_dates_as_descriptions' case.pk %}"
|
||||||
title="merge series"
|
title="merge series"
|
||||||
hx-include="[name='series-ids']"
|
hx-include="[name='series-ids']"
|
||||||
hx-target="#series-merge-results"
|
hx-target="#series-action-results"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-confirm="This will use the dicom date as the series description"
|
hx-confirm="This will use the dicom date as the series description"
|
||||||
>
|
>
|
||||||
Use dates as description
|
Use dates as description
|
||||||
</button>
|
</button>
|
||||||
|
<button hx-post="{% url 'atlas:remove_selected_series_from_case' case.pk %}"
|
||||||
|
title="remove selected series from this case"
|
||||||
|
hx-include="[name='series-ids']:checked"
|
||||||
|
hx-target="#series-action-results"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-confirm="Are you sure you want to remove the selected series from this case? They will not be deleted from the database."
|
||||||
|
class="btn btn-warning mt-2"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Remove selected series from case
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-secondary mt-2"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#reorderSeriesModal">
|
||||||
|
Reorder series
|
||||||
|
</button>
|
||||||
<br/>
|
<br/>
|
||||||
<span id="series-merge-results"></span>
|
<div class="alert alert-info mt-2" id="series-action-alert" style="display:none;">
|
||||||
|
<span id="series-action-results" ></span>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
@@ -203,36 +246,35 @@
|
|||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if case.series.all %}
|
<!-- Series Findings -->
|
||||||
{% for series in case.series.all %}
|
<h5>Series Findings</h5>
|
||||||
|
{% if case.ordered_series %}
|
||||||
|
{% for series in case.ordered_series %}
|
||||||
{% for finding in series.findings.all %}
|
{% for finding in series.findings.all %}
|
||||||
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}"
|
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}">
|
||||||
_="on mouseenter
|
|
||||||
toggle .highlight-series
|
|
||||||
on #series-block-{{ series.pk }}
|
|
||||||
until mouseleave"
|
|
||||||
>
|
|
||||||
<span>
|
<span>
|
||||||
<a href="{{series.get_absolute_url}}?show_finding={{finding.pk}}"><button class="btn btn-primary btn-sm">View</button></a>
|
<a href="{{series.get_absolute_url}}?show_finding={{finding.pk}}">
|
||||||
|
<button class="btn btn-primary btn-sm">View</button>
|
||||||
|
</a>
|
||||||
<button class="btn btn-secondary btn-sm view-finding-modal" data-finding-id="{{ finding.pk }}" data-series-id="{{ series.pk }}">
|
<button class="btn btn-secondary btn-sm view-finding-modal" data-finding-id="{{ finding.pk }}" data-series-id="{{ series.pk }}">
|
||||||
View in Modal
|
View in Modal
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
{% if finding.findings.all %}
|
{% if finding.findings %}
|
||||||
<span>
|
<span>
|
||||||
Findings: {% for f in finding.findings.all %}
|
Findings: {% for f in finding.findings.all %}
|
||||||
{{f.get_link}},
|
{{f.get_link}},
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if finding.conditions.all %}
|
{% if finding.conditions %}
|
||||||
<span>
|
<span>
|
||||||
Conditions: {% for c in finding.conditions.all %}
|
Conditions: {% for c in finding.conditions.all %}
|
||||||
{{c.get_link}},
|
{{c.get_link}},
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if finding.structures.all %}
|
{% if finding.structures %}
|
||||||
<span>
|
<span>
|
||||||
Structure: {% for s in finding.structures.all %}
|
Structure: {% for s in finding.structures.all %}
|
||||||
{{s.get_link}},
|
{{s.get_link}},
|
||||||
@@ -250,6 +292,75 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
No series associated with case.
|
No series associated with case.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Case Display Sets -->
|
||||||
|
<h5 class="mt-3">Case Display Sets</h5>
|
||||||
|
{% if case.display_sets %}
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for ds in case.display_sets.all %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<b>Name: {{ ds.name }}</b>
|
||||||
|
{% if ds.description %}
|
||||||
|
<span class="text-muted">({{ ds.description }})</span>
|
||||||
|
{% endif %}
|
||||||
|
<span>
|
||||||
|
<a href="{% url 'atlas:case_displaysets_detail' ds.pk %}">View Display Set</a>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>Findings:</strong>
|
||||||
|
{% if ds.findings.all %}
|
||||||
|
<ul>
|
||||||
|
{% for finding in ds.findings.all %}
|
||||||
|
<li>{{ finding }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Structures:</strong>
|
||||||
|
{% if ds.structures.all %}
|
||||||
|
<ul>
|
||||||
|
{% for structure in ds.structures.all %}
|
||||||
|
<li>{{ structure }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Conditions:</strong>
|
||||||
|
{% if ds.conditions.all %}
|
||||||
|
<ul>
|
||||||
|
{% for condition in ds.conditions.all %}
|
||||||
|
<li>{{ condition }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<details>
|
||||||
|
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
|
||||||
|
<div>
|
||||||
|
<strong>Viewer State:</strong>
|
||||||
|
<pre class="small">{{ ds.viewerstate|default:"{}" }}</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Annotations:</strong>
|
||||||
|
<pre class="small">{{ ds.annotations|default:"{}" }}</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">No display sets for this case.</span>
|
||||||
|
{% endif %}
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -346,6 +457,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Reorder Series Modal -->
|
||||||
|
<div class="modal fade" id="reorderSeriesModal" tabindex="-1" aria-labelledby="reorderSeriesModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<form method="post" action="{% url 'atlas:reorder_series' case.pk %}" id="reorder-series-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="reorderSeriesModalLabel">Reorder Series</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<ul id="series-reorder-list" class="list-group">
|
||||||
|
{% for series in case.get_ordered_series %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center" data-series-id="{{ series.pk }}">
|
||||||
|
<span>
|
||||||
|
<span class="series-block-series-number">Series {{ forloop.counter }}:</span>
|
||||||
|
{{ series.get_block|default:series }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary move-up">↑</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary move-down">↓</button>
|
||||||
|
</span>
|
||||||
|
<input type="hidden" name="series_order" value="{{ series.pk }}">
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="submit" class="btn btn-primary">Save order</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// From series_viewer.html
|
// From series_viewer.html
|
||||||
@@ -381,19 +526,129 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
const detailsElement = document.getElementById("series-actions");
|
|
||||||
const checkboxes = document.querySelectorAll("input[name='series-ids']");
|
|
||||||
|
|
||||||
function toggleCheckboxes() {
|
const dicomDetails = document.getElementById("dicom-viewer-details");
|
||||||
const isOpen = detailsElement.hasAttribute("open");
|
let dicomViewerLoaded = false;
|
||||||
checkboxes.forEach(checkbox => {
|
dicomDetails.addEventListener("toggle", function() {
|
||||||
checkbox.style.display = isOpen ? "inline-block" : "none";
|
if (!dicomViewerLoaded && dicomDetails.open) {
|
||||||
|
window.mountDicomViewers();
|
||||||
|
dicomViewerLoaded = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Move up/down logic
|
||||||
|
document.querySelectorAll("#series-reorder-list .move-up, #series-reorder-list .move-down").forEach(function(btn) {
|
||||||
|
btn.addEventListener("click", function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const li = btn.closest("li");
|
||||||
|
if (btn.classList.contains("move-up") && li.previousElementSibling) {
|
||||||
|
li.parentNode.insertBefore(li, li.previousElementSibling);
|
||||||
|
}
|
||||||
|
if (btn.classList.contains("move-down") && li.nextElementSibling) {
|
||||||
|
li.parentNode.insertBefore(li.nextElementSibling, li);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// On submit, update the hidden inputs to match the new order
|
||||||
|
document.getElementById("reorder-series-form").addEventListener("submit", function(e) {
|
||||||
|
const lis = document.querynelectorAll("#series-reorder-list li");
|
||||||
|
lis.forEach((li, idx) => {
|
||||||
|
li.querySelector("input[name='series_order']").setAttribute("name", "series_order_" + idx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag and drop reordering for series list
|
||||||
|
const reorderList = document.getElementById("series-reorder-list");
|
||||||
|
let draggedItem = null;
|
||||||
|
|
||||||
|
if (reorderList) {
|
||||||
|
reorderList.querySelectorAll("li").forEach(function(li) {
|
||||||
|
li.setAttribute("draggable", "true");
|
||||||
|
|
||||||
|
li.addEventListener("dragstart", function(e) {
|
||||||
|
draggedItem = li;
|
||||||
|
li.classList.add("dragging");
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
});
|
||||||
|
|
||||||
|
li.addEventListener("dragend", function() {
|
||||||
|
draggedItem = null;
|
||||||
|
li.classList.remove("dragging");
|
||||||
|
});
|
||||||
|
|
||||||
|
li.addEventListener("dragover", function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
// Visual feedback
|
||||||
|
li.classList.add("drag-over");
|
||||||
|
});
|
||||||
|
|
||||||
|
li.addEventListener("dragleave", function() {
|
||||||
|
li.classList.remove("drag-over");
|
||||||
|
});
|
||||||
|
|
||||||
|
li.addEventListener("drop", function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
li.classList.remove("drag-over");
|
||||||
|
if (draggedItem && draggedItem !== li) {
|
||||||
|
// Insert before or after depending on mouse position
|
||||||
|
const bounding = li.getBoundingClientRect();
|
||||||
|
const offset = e.clientY - bounding.top;
|
||||||
|
if (offset < bounding.height / 2) {
|
||||||
|
li.parentNode.insertBefore(draggedItem, li);
|
||||||
|
} else {
|
||||||
|
li.parentNode.insertBefore(draggedItem, li.nextSibling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const detailsElement = document.getElementById("series-actions");
|
||||||
|
const buttons = document.querySelectorAll(".select-series-btn");
|
||||||
|
|
||||||
|
function toggleCheckboxes() {
|
||||||
|
const isOpen = detailsElement.hasAttribute("open");
|
||||||
|
buttons.forEach(button => {
|
||||||
|
button.style.display = isOpen ? "inline-block" : "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelectorAll(".select-series-btn").forEach(function(btn) {
|
||||||
|
btn.addEventListener("click", function() {
|
||||||
|
const parent = btn.closest(".series-block");
|
||||||
|
const checkbox = parent.querySelector("input[type='checkbox'][name='series-ids']");
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
btn.classList.toggle("btn-primary", checkbox.checked);
|
||||||
|
btn.classList.toggle("btn-outline-primary", !checkbox.checked);
|
||||||
|
btn.textContent = checkbox.checked ? "Selected" : "Select";
|
||||||
|
// Highlight the series-block when selected
|
||||||
|
if (checkbox.checked) {
|
||||||
|
parent.classList.add("highlight-series");
|
||||||
|
} else {
|
||||||
|
parent.classList.remove("highlight-series");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
toggleCheckboxes();
|
toggleCheckboxes();
|
||||||
|
|
||||||
|
|
||||||
|
// Show the alert when a result is received
|
||||||
|
const resultsSpan = document.getElementById("series-action-results");
|
||||||
|
const alertDiv = document.getElementById("series-action-alert");
|
||||||
|
const observer = new MutationObserver(function(mutations) {
|
||||||
|
if (resultsSpan.textContent.trim() !== "") {
|
||||||
|
alertDiv.style.display = "block";
|
||||||
|
} else {
|
||||||
|
alertDiv.style.display = "none";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(resultsSpan, { childList: true, subtree: true });
|
||||||
|
|
||||||
// Listen for toggle events on the <details> element
|
// Listen for toggle events on the <details> element
|
||||||
detailsElement.addEventListener("toggle", toggleCheckboxes);
|
detailsElement.addEventListener("toggle", toggleCheckboxes);
|
||||||
|
|
||||||
@@ -440,8 +695,7 @@
|
|||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
document.getElementById("reload-finding").addEventListener("click", function () {
|
document.getElementById("reload-finding").addEventListener("click", function () {
|
||||||
importAnnotations_root(annotationjson3d)
|
window.mountDicomViewers();
|
||||||
importViewerState_root(viewerState);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize the DICOM viewer with the images and annotations
|
// Initialize the DICOM viewer with the images and annotations
|
||||||
@@ -467,4 +721,16 @@
|
|||||||
background-color: blue;
|
background-color: blue;
|
||||||
transition: background-color 0.3s ease, border 0.3s ease;
|
transition: background-color 0.3s ease, border 0.3s ease;
|
||||||
}
|
}
|
||||||
|
#series-reorder-list .dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
#series-reorder-list .drag-over {
|
||||||
|
border-top: 2px solid #0d6efd;
|
||||||
|
}
|
||||||
|
.series-actions[open] {
|
||||||
|
border: 2px solid #0d6efd;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
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 %}
|
{% comment %} {{ form }} {% endcomment %}
|
||||||
|
|
||||||
<h3>Series:</h3>
|
|
||||||
Add image sets here. These can only be added once created (they can also be added to cases on creation).
|
|
||||||
<input type="button" value="Add More Series" id="add_more_series">
|
<!-- Differential Collapsible Panel -->
|
||||||
<div id="series_formset" class="list_formset">
|
<div class="card my-3 bg-dark text-white">
|
||||||
<ol>
|
<div class="card-header">
|
||||||
{% for form in series_formset %}
|
<a class="collapsed text-white" data-bs-toggle="collapse" href="#differentialPanel" role="button" aria-expanded="false" aria-controls="differentialPanel">
|
||||||
<li class="no-error series-formset">
|
<h3 class="d-inline">Differential</h3>
|
||||||
{{form.non_field_errors}}
|
<span class="ms-2">(click to expand/collapse)</span>
|
||||||
{{form.errors}}
|
</a>
|
||||||
{{ form | crispy}}
|
</div>
|
||||||
</li>
|
<div class="collapse" id="differentialPanel">
|
||||||
{% endfor %}
|
<input type="button" value="Add Another Differential" id="add_more_differential" class="btn btn-sm btn-primary float-end">
|
||||||
</ol>
|
<div class="card-body">
|
||||||
|
<div id="casedifferential_formset" class="list_formset">
|
||||||
|
<ol>
|
||||||
|
{% for form in casedifferential_formset %}
|
||||||
|
<li class="no-error casedifferential_formset">
|
||||||
|
{{form.non_field_errors}}
|
||||||
|
{{form.errors}}
|
||||||
|
{{ form | crispy}}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{{ casedifferential_formset.management_form | crispy }}
|
||||||
</div>
|
</div>
|
||||||
{{ series_formset.management_form | crispy }}
|
</div>
|
||||||
<h3>Differential:</h3>
|
</div>
|
||||||
Add differential here.
|
|
||||||
<input type="button" value="Add Another Differential" id="add_more_differential">
|
<!-- Resource Collapsible Panel -->
|
||||||
<div id="casedifferential_formset" class="list_formset">
|
<div class="card my-3 bg-dark text-white">
|
||||||
<ol>
|
<div class="card-header">
|
||||||
{% for form in casedifferential_formset %}
|
<a class="collapsed text-white" data-bs-toggle="collapse" href="#resourcePanel" role="button" aria-expanded="false" aria-controls="resourcePanel">
|
||||||
<li class="no-error casedifferential_formset">
|
<h3 class="d-inline">Resource</h3>
|
||||||
{{form.non_field_errors}}
|
<span class="ms-2">(click to expand/collapse)</span>
|
||||||
{{form.errors}}
|
</a>
|
||||||
{{ form | crispy}}
|
</div>
|
||||||
</li>
|
<div class="collapse" id="resourcePanel">
|
||||||
{% endfor %}
|
<input type="button" value="Add Another Resource" id="add_more_resource" class="btn btn-sm btn-primary float-end">
|
||||||
</ol>
|
<div class="card-body">
|
||||||
|
<div id="caseresource_formset" class="list_formset">
|
||||||
|
<ol>
|
||||||
|
{% for form in caseresource_formset %}
|
||||||
|
<li class="no-error caseresource_formset">
|
||||||
|
{{form.non_field_errors}}
|
||||||
|
{{form.errors}}
|
||||||
|
{{ form | crispy}}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{{ caseresource_formset.management_form | crispy}}
|
||||||
</div>
|
</div>
|
||||||
{{ casedifferential_formset.management_form | crispy }}
|
</div>
|
||||||
<h3>Resource:</h3>
|
</div>
|
||||||
Add resource here. Resource can be anything that is useful for the case (videos, pdf, etc). This can either be avalaible before attempting the case (such a for a pre reading list) or after.
|
|
||||||
<input type="button" value="Add Another Resource" id="add_more_resource">
|
|
||||||
<div id="caseresource_formset" class="list_formset">
|
|
||||||
<ol>
|
|
||||||
{% for form in caseresource_formset %}
|
|
||||||
<li class="no-error caseresource_formset">
|
|
||||||
{{form.non_field_errors}}
|
|
||||||
{{form.errors}}
|
|
||||||
{{ form | crispy}}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
{{ caseresource_formset.management_form | crispy}}
|
|
||||||
<br/>
|
<br/>
|
||||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "atlas/base.html" %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
{% load static %}
|
||||||
|
{% block content %}
|
||||||
|
{% include "atlas/question_link_header.html" %}
|
||||||
|
<h2>Update Series for Case: {{ object.title }}</h2>
|
||||||
|
<form method="post" enctype="multipart/form-data" data-bs-theme="dark">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% crispy form form.helper %}
|
||||||
|
{{ series_formset.management_form }}
|
||||||
|
<div id="series_formset">
|
||||||
|
{% for form in series_formset %}
|
||||||
|
<div class="card mb-3 series-form">
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
{{ form.errors }}
|
||||||
|
{{ form|crispy }}
|
||||||
|
{% if form.can_delete %}
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
{{ form.DELETE }} <label for="{{ form.DELETE.id_for_label }}">Delete</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<!-- Empty form template for JS -->
|
||||||
|
<div id="empty-form" style="display:none;">
|
||||||
|
<div class="card mb-3 series-form">
|
||||||
|
<div class="card-body">
|
||||||
|
{{ series_formset.empty_form|crispy }}
|
||||||
|
{% if series_formset.empty_form.can_delete %}
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
{{ series_formset.empty_form.DELETE }} <label for="{{ series_formset.empty_form.DELETE.id_for_label }}">Delete</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="add-series" class="btn btn-secondary mt-2">Add Series</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
const addBtn = document.getElementById("add-series");
|
||||||
|
const formsetDiv = document.getElementById("series_formset");
|
||||||
|
const emptyFormDiv = document.getElementById("empty-form");
|
||||||
|
let totalForms = document.getElementById("id_seriesdetail_set-TOTAL_FORMS");
|
||||||
|
|
||||||
|
addBtn.addEventListener("click", function() {
|
||||||
|
let formIdx = parseInt(totalForms.value);
|
||||||
|
let newFormHtml = emptyFormDiv.innerHTML.replace(/__prefix__/g, formIdx);
|
||||||
|
let tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = newFormHtml;
|
||||||
|
// Clear the id field value for the new form
|
||||||
|
let idInput = tempDiv.querySelector('input[name$="-id"]');
|
||||||
|
if (idInput) {
|
||||||
|
idInput.value = "";
|
||||||
|
}
|
||||||
|
formsetDiv.appendChild(tempDiv.firstElementChild);
|
||||||
|
totalForms.value = formIdx + 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<h2>My Cases</h2>
|
<h2>Cases</h2>
|
||||||
|
Quick filters:<br/>
|
||||||
|
Show <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>my cases only</a>, <a href='{% url "atlas:case_view" %}'>all cases</a>
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
<h4>Filter</h4>
|
<h4>Filter</h4>
|
||||||
@@ -20,8 +22,65 @@
|
|||||||
</div>
|
</div>
|
||||||
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
|
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
|
||||||
</details>
|
</details>
|
||||||
{% render_table table %}
|
<form>
|
||||||
|
<details id="actions-detail" class="mt-3">
|
||||||
|
<summary>
|
||||||
|
<strong>Actions</strong>
|
||||||
|
</summary>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-auto">
|
||||||
|
<select id="collection-select" name="collection_id" class="form-select form-select-sm">
|
||||||
|
<!-- Dynamically load collection options via HTMX -->
|
||||||
|
<option value="">Loading collections...</option>
|
||||||
|
<option hx-trigger="every 1s[document.getElementById('actions-detail').open] once" hx-get="{% url 'atlas:collection_options' %}" hx-target="#collection-select" hx-swap="innerHTML"></option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button id="add-to-collection-btn"
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
hx-post="{% url 'atlas:add_cases_to_collection' %}"
|
||||||
|
hx-include="[name='selection']:checked, #collection-select"
|
||||||
|
hx-target="#action-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
Add selected cases to collection
|
||||||
|
</button>
|
||||||
|
<button id="remove-from-collection-btn"
|
||||||
|
class="btn btn-sm btn-outline-danger ms-2"
|
||||||
|
hx-post="{% url 'atlas:remove_cases_from_collection' %}"
|
||||||
|
hx-include="[name='selection']:checked, #collection-select"
|
||||||
|
hx-target="#action-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
Remove selected cases from collection
|
||||||
|
</button>
|
||||||
|
<br/>
|
||||||
|
<button id="set-open-access-btn"
|
||||||
|
class="btn btn-sm btn-outline-success ms-2"
|
||||||
|
hx-post="{% url 'atlas:set_open_access' %}"
|
||||||
|
hx-include="[name='selection']:checked"
|
||||||
|
hx-target="#action-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
Set Open Access
|
||||||
|
</button>
|
||||||
|
<button id="remove-open-access-btn"
|
||||||
|
class="btn btn-sm btn-outline-warning ms-2"
|
||||||
|
hx-post="{% url 'atlas:remove_open_access' %}"
|
||||||
|
hx-include="[name='selection']:checked"
|
||||||
|
hx-target="#action-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
Remove Open Access
|
||||||
|
</button>
|
||||||
|
<span id="action-result"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% render_table table %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% include "generic/partials/page_size_form.html" %}
|
||||||
|
|
||||||
<div id="exam-options"></div>
|
<div id="exam-options"></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "atlas/exams.html" %}
|
{% extends "atlas/exams.html" %}
|
||||||
<!-- {% load static from static %} -->
|
<!-- {% load static from static %} -->
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
{% block css %}
|
{% block css %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -36,8 +37,8 @@
|
|||||||
<form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form">
|
<form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{{ form }}
|
{% crispy form form.helper %}
|
||||||
<h3>Cases:</h3>
|
{% comment %} <h3>Cases:</h3>
|
||||||
Add cases here. These can only be added once created (they can also be added to cases on creation). Click and drag to change order.
|
Add cases here. These can only be added once created (they can also be added to cases on creation). Click and drag to change order.
|
||||||
<input type="button" value="Add More Cases" id="add_more_case">
|
<input type="button" value="Add More Cases" id="add_more_case">
|
||||||
<input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" />
|
<input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" />
|
||||||
@@ -54,13 +55,14 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
{{ case_formset.management_form }}
|
{{ case_formset.management_form }}
|
||||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
|
||||||
</form>
|
</form>
|
||||||
<div id="empty_case_form" style="display:none">
|
<div id="empty_case_form" style="display:none">
|
||||||
<li class='no_error case-formset'>
|
<li class='no_error case-formset'>
|
||||||
{{ case_formset.empty_form }}
|
{{ case_formset.empty_form }}
|
||||||
</li>
|
</li>
|
||||||
</div>
|
</div> {% endcomment %}
|
||||||
|
{% comment %} <input type="submit" class="submit-button" value="Submit" name="submit"> {% endcomment %}
|
||||||
|
</form>
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(() => {
|
$(document).ready(() => {
|
||||||
sortable('.sortable');
|
sortable('.sortable');
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends "atlas/exams.html" %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
{% load static %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Update Cases for Case: {{ object.title }}</h2>
|
||||||
|
This form allows you to update the cases associated with this collection. You can add, remove, or reorder cases as needed. This functionality is also available on the collection overview page (which is likely easier to use).
|
||||||
|
<form method="post" enctype="multipart/form-data" data-bs-theme="dark">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% crispy form form.helper %}
|
||||||
|
{{ case_formset.management_form }}
|
||||||
|
<div id="case_formset">
|
||||||
|
{% for form in case_formset %}
|
||||||
|
<div class="card mb-3 case-form">
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
{{ form.errors }}
|
||||||
|
{{ form|crispy }}
|
||||||
|
{% if form.can_delete %}
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
{{ form.DELETE }} <label for="{{ form.DELETE.id_for_label }}">Delete</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<!-- Empty form template for JS -->
|
||||||
|
<div id="empty-form" style="display:none;">
|
||||||
|
<div class="card mb-3 case-form">
|
||||||
|
<div class="card-body">
|
||||||
|
{{ case_formset.empty_form|crispy }}
|
||||||
|
{% if case_formset.empty_form.can_delete %}
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
{{ case_formset.empty_form.DELETE }} <label for="{{ case_formset.empty_form.DELETE.id_for_label }}">Delete</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="add-case" class="btn btn-secondary mt-2">Add Case</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
{% comment %} <input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" /> {% endcomment %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
const addBtn = document.getElementById("add-case");
|
||||||
|
const formsetDiv = document.getElementById("case_formset");
|
||||||
|
const emptyFormDiv = document.getElementById("empty-form");
|
||||||
|
let totalForms = document.getElementById("id_casedetail_set-TOTAL_FORMS");
|
||||||
|
|
||||||
|
addBtn.addEventListener("click", function() {
|
||||||
|
let formIdx = parseInt(totalForms.value);
|
||||||
|
let newFormHtml = emptyFormDiv.innerHTML.replace(/__prefix__/g, formIdx);
|
||||||
|
let tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = newFormHtml;
|
||||||
|
// Clear the id field value for the new form
|
||||||
|
let idInput = tempDiv.querySelector('input[name$="-id"]');
|
||||||
|
if (idInput) {
|
||||||
|
idInput.value = "";
|
||||||
|
}
|
||||||
|
formsetDiv.appendChild(tempDiv.firstElementChild);
|
||||||
|
totalForms.value = formIdx + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("case-order-button").addEventListener("click", function(e) {
|
||||||
|
const cases = document.querySelectorAll("#case_formset ul");
|
||||||
|
cases.forEach((el, n) => {
|
||||||
|
const inputEl = el.querySelector("input[name$='sort_order']");
|
||||||
|
if (inputEl) {
|
||||||
|
inputEl.value = n + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{% extends 'atlas/exams.html' %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Setup default display for case</h2>
|
||||||
|
|
||||||
|
<div id="root" class="dicom-viewer-root" data-images="{{case_detail.case.get_series_images_nested}}"
|
||||||
|
style="box-sizing: border-box; background: #222; width: 100vw; height: 800px; max-width: 1600px;"
|
||||||
|
data-auto-cache-stack="true"
|
||||||
|
{% if case_detail.default_viewerstate %}
|
||||||
|
data-viewerstate={{ case_detail.default_viewerstate }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<button id="save-viewerstate" type="button" class="btn btn-primary mt-3">
|
||||||
|
Save Viewer State
|
||||||
|
</button>
|
||||||
|
<button id="reset-viewerstate" type="button" class="btn btn-secondary mt-3 ms-2">
|
||||||
|
Reset to Default
|
||||||
|
</button>
|
||||||
|
<div id="viewerstate-save-response" class="mt-2"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('save-viewerstate').addEventListener('click', function() {
|
||||||
|
let viewerState = window.exportViewerState_root();
|
||||||
|
if (!viewerState) {
|
||||||
|
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRFToken": "{{ csrf_token }}"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({viewerstate: viewerState})
|
||||||
|
})
|
||||||
|
.then(response => response.text())
|
||||||
|
.then(html => {
|
||||||
|
document.getElementById('viewerstate-save-response').innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error saving viewer state.</span>";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('reset-viewerstate').addEventListener('click', function() {
|
||||||
|
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRFToken": "{{ csrf_token }}"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({reset_viewerstate: true})
|
||||||
|
})
|
||||||
|
.then(response => response.text())
|
||||||
|
.then(html => {
|
||||||
|
document.getElementById('viewerstate-save-response').innerHTML = html;
|
||||||
|
// Optionally reload the page or viewer here if needed
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error resetting viewer state.</span>";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -1,37 +1,70 @@
|
|||||||
{% extends 'atlas/exams.html' %}
|
{% extends 'atlas/exams.html' %}
|
||||||
|
|
||||||
|
{% load partials %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>{{collection.name}}</h2>
|
<h2>{{collection.name}}</h2>
|
||||||
|
|
||||||
{% include 'exam_notes.html' %}
|
{% include 'exam_notes.html' %}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
Exam mode: {{collection.exam_mode}} (Exam open acess: {{collection.exam_open_access}})<br />
|
Exam mode: {{collection.exam_mode}} (open access: {{collection.exam_open_access}})<br />
|
||||||
Publish results: {{collection.publish_results}}<br />
|
{% include "generic/partials/exams/exam_status.html#publish-results" %}
|
||||||
Active: {{collection.active}}<br />
|
{% include "generic/partials/exams/exam_status.html#exam-active" %}
|
||||||
Collection Type: {{collection.get_collection_type_display}}<br />
|
Collection Type: {{collection.get_collection_type_display}}<br />
|
||||||
Self review: {{collection.self_review}}<br />
|
Self review: {{collection.self_review}}<br />
|
||||||
|
Open access: {{collection.open_access}}<br />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% if collection.collection_type == "VIV" %}
|
||||||
|
<p><a href='{% url "atlas:collection_viva" collection.pk %}'><button class="btn btn-primary">Start Viva</button></a>
|
||||||
|
{% else %}
|
||||||
|
<p>This collection will be available to view/take <a href='{{collection.get_take_url}}' id="take-url">here</a>{% if not collection.active %} (when active){% endif %} <button
|
||||||
|
_="on click
|
||||||
|
writeText(#take-url's href) on navigator.clipboard
|
||||||
|
put 'copied url!' into me
|
||||||
|
wait 1s
|
||||||
|
put 'copy url' into me"
|
||||||
|
class="btn btn-sm">copy link</button>
|
||||||
|
<p>Review collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<h3>Cases</h3>
|
<h3>Cases</h3>
|
||||||
<ol id="full-question-list" class="sortable">
|
<ol id="full-question-list" class="sortable">
|
||||||
{% for casedetail in casesdetails %}
|
{% for casedetail in casesdetails %}
|
||||||
<li data-question_pk={{casedetail.case.pk}}><a title="sort_order: {{casedetail.sort_order}}" href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a>
|
<li data-question_pk={{casedetail.case.pk}}>
|
||||||
: {{casedetail.case.title}}
|
|
||||||
|
|
||||||
|
<a href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a>
|
||||||
|
:
|
||||||
|
{% if casedetail.case.series.count == 0 %}
|
||||||
|
<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" %}
|
{% if collection.collection_type == "QUE" %}
|
||||||
(<a href='{% url "atlas:collection_case_details" casedetail.collection.pk casedetail.case.pk %}'>edit questions</a>
|
(<a href='{% url "atlas:collection_case_details" casedetail.collection.pk casedetail.case.pk %}'>
|
||||||
|
{% if casedetail.question_schema %}
|
||||||
{% if casedetail.question_schema %}
|
<i class="bi bi-question-square text-success" title="This case has questions defined."></i>
|
||||||
<i class="bi bi-question-square text-success" title="This case has questions defined."></i>
|
{% else %}
|
||||||
{% else %}
|
<i class="bi bi-question-square text-danger" title="This case has no questions defined."></i>
|
||||||
<i class="bi bi-question-square text-danger" title="This case has no questions defined."></i>
|
{% endif %}
|
||||||
{% endif %}
|
</a>
|
||||||
)
|
)
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if casedetail.case.previous_case %}
|
{% if casedetail.case.previous_case %}
|
||||||
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>manage priors</a>)
|
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>
|
||||||
{% endif %}
|
<i class="bi bi-link-45deg" title="Manage priors"></i></a>)
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -40,21 +73,9 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% if collection.collection_type == "VIV" %}
|
|
||||||
<p>View as a viva collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
|
|
||||||
{% else %}
|
|
||||||
<p>This collection will be available to view/take <a href='{{collection.get_take_url}}' id="take-url">here</a>{% if not collection.active %} (when active){% endif %} <button
|
|
||||||
_="on click
|
|
||||||
writeText(#take-url's href) on navigator.clipboard
|
|
||||||
put 'copied url!' into me
|
|
||||||
wait 1s
|
|
||||||
put 'copy url' into me"
|
|
||||||
class="btn btn-sm">copy link</button>
|
|
||||||
<p>Review collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<p><button id='button-edit-order' title='click and drag questions to change order' data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}">Edit case order / Delete cases</button></p>
|
<p><button id='button-edit-order' title='click and drag questions to change order' data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}">Edit case order / Remove cases</button></p>
|
||||||
<p><button id='button-add-case'
|
<p><button id='button-add-case'
|
||||||
title='click to add a case to this collection'
|
title='click to add a case to this collection'
|
||||||
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list">
|
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a> /
|
Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a> /
|
||||||
|
<a href="{% url 'atlas:collection_case_update' collection.pk %}">Cases</a> /
|
||||||
<a href="{% url 'atlas:collection_history' collection.pk %}">History</a> /
|
<a href="{% url 'atlas:collection_history' collection.pk %}">History</a> /
|
||||||
<a href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a> /
|
<a href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a> /
|
||||||
<a href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a> /
|
<a href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a> /
|
||||||
|
|||||||
@@ -2,55 +2,106 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<details class="help-text">
|
<details class="help-text">
|
||||||
<summary title="Click to view instructions">Instructions</summary>
|
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||||
<p>This set up is designed to allow you to project/share cases in a seperate window whilst controlling/tracking their display from this page.</p>
|
<p>This set up is designed to allow you to project/share cases in a seperate window whilst controlling/tracking their display from this page.</p>
|
||||||
<p>First you will need to open the linked viewer window by clicking the 'Open Viewer' button. This will open a new window/tab with the viewer in it. You will probably need to detach this from your current window.</p>
|
<p>First you will need to open the linked viewer window by clicking the 'Open Viewer' button. This will open a new window/tab with the viewer in it. You will probably need to detach this from your current window.</p>
|
||||||
<p>If you are sharing the window you can maximise the screen area by hiding the tab/bookmark bar and the address bar. In Microsoft Edge this can be done by installing as an app, in Chrome you can use an extension such as <a href='https://chromewebstore.google.com/detail/ncppfjladdkdaemaghochfikpmghbcpc'>Open-as-Popup</a>.</p>
|
<p>If you are sharing the window you can maximise the screen area by hiding the tab/bookmark bar and the address bar. In Microsoft Edge this can be done by installing as an app, in Chrome you can use an extension such as <a href='https://chromewebstore.google.com/detail/ncppfjladdkdaemaghochfikpmghbcpc'>Open-as-Popup</a>.</p>
|
||||||
<p>Click on the 'Load' button to open the case in a the linked window. Loading subsequent cases/series will replace the first case in the window.</p>
|
<p>Click on the 'Load' button to open the case in a the linked window. Loading subsequent cases/series will replace the first case in the window.</p>
|
||||||
<p>It is also possible to open a single series by clicking on the 'Load' button next to the individual series.</p>
|
<p>It is also possible to open a single series by clicking on the 'Load' button next to the individual series.</p>
|
||||||
<p>Once opened the case details will be show on the right hand side of the page.</p>
|
<p>Once opened the case details will be show on the right hand side of the page.</p>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<button id="toggle-load-mode" class="btn btn-secondary">Switch to Load OHIF</button>
|
||||||
|
<button id="toggle-edit-mode" class="btn btn-warning ms-2">Enable Edit Mode</button>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<h3>Cases</h3>
|
<h3>Cases</h3>
|
||||||
<button id="open-viewer" >Open Viewer</button>
|
<button id="open-viewer" >Open Viewer OHIF</button>
|
||||||
|
<button id="open-viewer-local" >Open Viewer Local</button>
|
||||||
<button id="view-series" class="text-dark border-dark" >Toggle series</button>
|
<button id="view-series" class="text-dark border-dark" >Toggle series</button>
|
||||||
<ol id="" class="sortable">
|
<ol id="" class="sortable">
|
||||||
{% for case in cases %}
|
{% for casedetail in casedetails %}
|
||||||
|
{% with case=casedetail.case %}
|
||||||
<li data-question_pk={{case.pk}}>
|
<li data-question_pk={{case.pk}}>
|
||||||
{{case.casedetail.sort_order}}
|
<div class="case-item" data-title="{{case.title}}" data-case={{case.pk}} data-casejson='{{case.get_viva_details_json}}' data-images='{{case.get_series_images_nested}}' data-viewerstate={{casedetail.default_viewerstate_string}}>
|
||||||
<div class="case-item" data-title="{{case.title}}" data-case={{case.pk}} data-case-json='{{case.get_viva_details_json}}'>Case {{forloop.counter}}: {{case.title}}
|
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 %}
|
<button class="open-case no-padding" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}" data-type="case">Load OHIF</button>
|
||||||
{% comment %} <button class="send-message no-padding" data-title="{{case.title}}" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Open</button> {% endcomment %}
|
<button class="open-case-local no-padding" data-target=case.pk data-type="case">Load</button>
|
||||||
{% comment %} <button class="open-case no-padding" data-target="{% url 'atlas:collection_viva_case' collection.pk forloop.counter0 %}" data-type="case">Load</button> {% endcomment %}
|
<!-- Edit mode link for case -->
|
||||||
<button class="open-case no-padding" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}" data-type="case">Load</button>
|
<button class="view-link-case btn btn-outline-primary btn-sm ms-2" style="display:none;">
|
||||||
<br />
|
<a href="{% url 'atlas:case_detail' case.pk %}" style="color: inherit; text-decoration: none;">View Case</a>
|
||||||
<div class="pre-whitespace multi-image-block">
|
</button>
|
||||||
<details class="series-detail"><summary>Series:</summary>
|
<button class="edit-link-case btn btn-outline-warning btn-sm ms-2" style="display:none;">
|
||||||
{% for series in case.series.all %}
|
<a href="{% url 'atlas:case_update' case.pk %}" style="color: inherit; text-decoration: none;">Edit Case</a>
|
||||||
<span class="series-block">
|
</button>
|
||||||
<span>
|
<br />
|
||||||
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
|
<div class="pre-whitespace multi-image-block">
|
||||||
{{series.get_block}}
|
<details class="series-detail"><summary>Series:</summary>
|
||||||
<br />
|
{% for series in case.series.all %}
|
||||||
{% comment %} <button class="show-series no-padding text-secondary border-secondary" data-case={{case.pk}} data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:series_dicom_json' series.pk %}">
|
<span class="series-block">
|
||||||
show
|
<span>
|
||||||
</button> {% endcomment %}
|
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
|
||||||
{% comment %} <button class="open-series no-padding text-secondary border-secondary" data-type="series" data-series={{forloop.counter}} data-case={{case.pk}} data-target="{% url 'atlas:collection_viva_series' collection.pk series.pk %}"> {% endcomment %}
|
test
|
||||||
<button class="open-series no-padding text-secondary border-secondary" data-type="series" data-series={{forloop.counter}} data-case={{case.pk}} data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:series_dicom_json' series.pk %}">
|
{{series.get_block}}
|
||||||
Load
|
<br />
|
||||||
</button>
|
<button class="open-series no-padding text-secondary border-secondary" data-type="series" data-series={{forloop.counter}} data-case={{case.pk}} data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:series_dicom_json' series.pk %}">
|
||||||
<br>
|
Load
|
||||||
|
</button>
|
||||||
|
<button class="open-series-local no-padding text-secondary border-secondary"
|
||||||
|
data-type="series"
|
||||||
|
data-series="{{ forloop.counter }}"
|
||||||
|
data-case="{{ case.pk }}"
|
||||||
|
data-target="{{ series.pk }}">
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
<!-- Edit mode link for series -->
|
||||||
|
<button class="edit-link-series btn btn-outline-warning btn-sm ms-2" style="display:none;">
|
||||||
|
<a href="{% url 'atlas:series_update' series.pk %}" style="color: inherit; text-decoration: none;">Edit Series</a>
|
||||||
|
</button>
|
||||||
|
<br>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
{% endfor %}
|
||||||
{% endfor %}
|
</details>
|
||||||
</details>
|
</div>
|
||||||
|
{% if case.display_sets.all %}
|
||||||
|
<details class="displayset-detail"><summary>Display Sets:</summary>
|
||||||
|
{% for ds in case.display_sets.all %}
|
||||||
|
<div class="displayset-block">
|
||||||
|
<span>
|
||||||
|
<b>{{ ds.name }}</b>
|
||||||
|
{% if ds.description %}
|
||||||
|
<span class="text-muted">({{ ds.description }})</span>
|
||||||
|
{% endif %}
|
||||||
|
<button class="open-displayset no-padding text-secondary border-secondary"
|
||||||
|
data-type="displayset"
|
||||||
|
data-displayset="{{ ds.pk }}"
|
||||||
|
data-case="{{ case.pk }}"
|
||||||
|
data-title="{{ ds.name }}"
|
||||||
|
data-casejson='{{case.get_viva_details_json}}'
|
||||||
|
data-images='{{case.get_series_images_nested}}'
|
||||||
|
data-viewerstate={{ds.viewerstate_string}}
|
||||||
|
data-annotations='{{ds.annotations_string}}'
|
||||||
|
>
|
||||||
|
Load Display Set
|
||||||
|
</button>
|
||||||
|
<!-- Edit mode link for display set -->
|
||||||
|
<button class="edit-link-displayset btn btn-outline-warning btn-sm ms-2" style="display:none;">
|
||||||
|
<a href="{% url 'atlas:case_displaysets' case.pk %}?displayset={{ ds.pk }}&edit=true" style="color: inherit; text-decoration: none;">Edit Display Set</a>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<span class="text-muted">No display sets for this case.</span>
|
||||||
|
{% endfor %}
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
</li>
|
{% endwith %}
|
||||||
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,8 +128,60 @@
|
|||||||
{% block js %}
|
{% block js %}
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
var win2 = false;
|
var win2 = false;
|
||||||
|
let editMode = false;
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
|
let loadLocalMode = true;
|
||||||
|
|
||||||
|
|
||||||
|
// Edit mode toggle
|
||||||
|
$('#toggle-edit-mode').click(function() {
|
||||||
|
editMode = !editMode;
|
||||||
|
if (editMode) {
|
||||||
|
$('.edit-link-case').show();
|
||||||
|
$('.view-link-case').show();
|
||||||
|
$('.edit-link-series').show();
|
||||||
|
$('.edit-link-displayset').show();
|
||||||
|
$(this).text('Disable Edit Mode').removeClass('btn-warning').addClass('btn-danger');
|
||||||
|
} else {
|
||||||
|
$('.edit-link-case').hide();
|
||||||
|
$('.view-link-case').hide();
|
||||||
|
$('.edit-link-series').hide();
|
||||||
|
$('.edit-link-displayset').hide();
|
||||||
|
$(this).text('Enable Edit Mode').removeClass('btn-danger').addClass('btn-warning');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update all "Load" buttons to reflect the current mode
|
||||||
|
function updateLoadButtons() {
|
||||||
|
if (loadLocalMode) {
|
||||||
|
$('.open-case').hide();
|
||||||
|
$('.open-series').hide();
|
||||||
|
$('.open-case-local').show();
|
||||||
|
$('.open-series-local').show();
|
||||||
|
$('#toggle-load-mode').text('Switch to Load OHIF');
|
||||||
|
$("#open-viewer").hide()
|
||||||
|
$("#open-viewer-local").show()
|
||||||
|
} else {
|
||||||
|
$('.open-case').show();
|
||||||
|
$('.open-series').show();
|
||||||
|
$('.open-case-local').hide();
|
||||||
|
$('.open-series-local').hide();
|
||||||
|
$("#open-viewer").show()
|
||||||
|
$("#open-viewer-local").hide()
|
||||||
|
$('#toggle-load-mode').text('Switch to Load Local');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial state: show only normal Load buttons
|
||||||
|
updateLoadButtons();
|
||||||
|
|
||||||
|
// Toggle button click handler
|
||||||
|
$('#toggle-load-mode').click(function() {
|
||||||
|
loadLocalMode = !loadLocalMode;
|
||||||
|
updateLoadButtons();
|
||||||
|
});
|
||||||
|
|
||||||
const bc = new BroadcastChannel("viva_load");
|
const bc = new BroadcastChannel("viva_load");
|
||||||
bc.onmessage = (event) => {
|
bc.onmessage = (event) => {
|
||||||
console.log("message received");
|
console.log("message received");
|
||||||
@@ -88,6 +191,12 @@
|
|||||||
$("#open-viewer").removeClass("flash-button");
|
$("#open-viewer").removeClass("flash-button");
|
||||||
//bc.postMessage({"type": "open-complete", "url" : event.data.url});
|
//bc.postMessage({"type": "open-complete", "url" : event.data.url});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.data.type == "open-complete-local") {
|
||||||
|
$("#loading-case").hide();
|
||||||
|
$("#open-viewer-local").removeClass("flash-button");
|
||||||
|
//bc.postMessage({"type": "open-complete-local"});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
console.log('ready')
|
console.log('ready')
|
||||||
$('.show-collection, .show-case, .show-series').click(function() {
|
$('.show-collection, .show-case, .show-series').click(function() {
|
||||||
@@ -96,6 +205,68 @@
|
|||||||
$('#case-title').text($(this).data('title'));
|
$('#case-title').text($(this).data('title'));
|
||||||
$('#viewer').attr('src', url)
|
$('#viewer').attr('src', url)
|
||||||
});
|
});
|
||||||
|
$('.open-case-local').click(function() {
|
||||||
|
let c = this.closest(".case-item");
|
||||||
|
console.log('clicked local', c)
|
||||||
|
console.log('open local', c.dataset.case)
|
||||||
|
case_details = JSON.parse(c.dataset.casejson);
|
||||||
|
let viewerstate = JSON.parse(c.dataset.viewerstate) || {};
|
||||||
|
$('#loading-case').show()
|
||||||
|
$("#open-viewer-local").addClass("flash-button");
|
||||||
|
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
|
||||||
|
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']);
|
||||||
|
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
|
||||||
|
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
|
||||||
|
|
||||||
|
bc.postMessage({"type": "open",
|
||||||
|
"case" : c.dataset.case, "images": c.dataset.images,
|
||||||
|
"viewerstate": viewerstate
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
$('.open-displayset').click(function() {
|
||||||
|
let c = this;
|
||||||
|
console.log('clicked local', c)
|
||||||
|
console.log('open local', c.dataset.case)
|
||||||
|
case_details = JSON.parse(c.dataset.casejson);
|
||||||
|
let viewerstate = JSON.parse(c.dataset.viewerstate) || {};
|
||||||
|
let annotations = JSON.parse(c.dataset.annotations) || {};
|
||||||
|
$('#loading-case').show()
|
||||||
|
$("#open-viewer-local").addClass("flash-button");
|
||||||
|
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
|
||||||
|
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']);
|
||||||
|
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
|
||||||
|
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
|
||||||
|
|
||||||
|
bc.postMessage({"type": "open",
|
||||||
|
"case" : c.dataset.case, "images": c.dataset.images,
|
||||||
|
"viewerstate": viewerstate,
|
||||||
|
"annotations": annotations
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
$('.open-series-local').click(function() {
|
||||||
|
let c = $(this).closest(".case-item")[0];
|
||||||
|
let seriesNumber = $(this).data('series');
|
||||||
|
let seriesPk = $(this).data('target');
|
||||||
|
let caseDetails = JSON.parse(c.dataset.casejson);
|
||||||
|
let images = JSON.parse(c.dataset.images)[seriesNumber - 1] || [];
|
||||||
|
$('#loading-case').show();
|
||||||
|
$("#open-viewer-local").addClass("flash-button");
|
||||||
|
$('#current-case-title').html("<span class='title'>Case:</span> " + c.dataset.title);
|
||||||
|
$('#current-case-series').html("<span class='title'>Series:</span> " + seriesNumber);
|
||||||
|
$("#current-case-history").html("<span class='title'>History:</span> " + caseDetails['history']);
|
||||||
|
$("#current-case-discussion").html("<span class='title'>Discussion:</span> " + caseDetails['discussion']);
|
||||||
|
$("#current-case-report").html("<span class='title'>Report:</span> " + caseDetails['report']);
|
||||||
|
|
||||||
|
// Send only the images for this series
|
||||||
|
bc.postMessage({
|
||||||
|
"type": "open",
|
||||||
|
"case": c.dataset.case,
|
||||||
|
"series": seriesPk,
|
||||||
|
"images": JSON.stringify(images),
|
||||||
|
});
|
||||||
|
});
|
||||||
$('.open-case, .open-series').click(function() {
|
$('.open-case, .open-series').click(function() {
|
||||||
var url = $(this).data('target');
|
var url = $(this).data('target');
|
||||||
console.log('clicked', url)
|
console.log('clicked', url)
|
||||||
@@ -103,7 +274,7 @@
|
|||||||
|
|
||||||
let c = $(this).closest(".case-item");
|
let c = $(this).closest(".case-item");
|
||||||
|
|
||||||
case_details = c.data('case-json');
|
case_details = c.data('casejson');
|
||||||
console.log(case_details);
|
console.log(case_details);
|
||||||
|
|
||||||
$('#loading-case').show()
|
$('#loading-case').show()
|
||||||
@@ -137,6 +308,11 @@
|
|||||||
openSecondaryWindow("{% url 'atlas:collection_viewer' %}");
|
openSecondaryWindow("{% url 'atlas:collection_viewer' %}");
|
||||||
//bc.postMessage(url);
|
//bc.postMessage(url);
|
||||||
})
|
})
|
||||||
|
$('#open-viewer-local').click(function() {
|
||||||
|
$("#open-viewer-local").removeClass("flash-button");
|
||||||
|
openSecondaryWindow("{% url 'atlas:collection_viewer_local' %}");
|
||||||
|
//bc.postMessage(url);
|
||||||
|
})
|
||||||
$('#view-series').click(function() {
|
$('#view-series').click(function() {
|
||||||
$(".series-detail").attr("open", (_, attr) => !attr);
|
$(".series-detail").attr("open", (_, attr) => !attr);
|
||||||
//document.getElementsByClassName("series-detail").toggleAttribute("open");
|
//document.getElementsByClassName("series-detail").toggleAttribute("open");
|
||||||
@@ -146,7 +322,11 @@
|
|||||||
});
|
});
|
||||||
function openSecondaryWindow(url) {
|
function openSecondaryWindow(url) {
|
||||||
//return win2 = window.open(url,'secondary','width=300,height=100');
|
//return win2 = window.open(url,'secondary','width=300,height=100');
|
||||||
return win2 = window.open(url,'secondary');
|
return win2 = window.open(
|
||||||
|
url,
|
||||||
|
'secondary',
|
||||||
|
'toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=1200,height=800'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -183,5 +363,9 @@
|
|||||||
.title {
|
.title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-link-case, .edit-link-series, .edit-link-displayset {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock css %}
|
{% endblock css %}
|
||||||
@@ -19,18 +19,56 @@
|
|||||||
<h3>Collection</h3>
|
<h3>Collection</h3>
|
||||||
<p>A collection is a group of cases. It can be used to group cases. Cases can below to more than one group.</p>
|
<p>A collection is a group of cases. It can be used to group cases. Cases can below to more than one group.</p>
|
||||||
|
|
||||||
|
<p>A number of different collection types are available, including:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Review</li>
|
||||||
|
<li>Report</li>
|
||||||
|
<li>Question</li>
|
||||||
|
<li>Viva</li>
|
||||||
|
</ul>
|
||||||
|
<h5>Review</h5>
|
||||||
|
<p>A Review collection is used to group cases for the purpose of review or teaching sessions. It allows users to easily access a curated set of cases. No data is collected as part of the review, although it is possible for users to add a "Self Review" component.</p>
|
||||||
|
|
||||||
|
<h5>Report</h5>
|
||||||
|
<p>A Report collection is designed for cases that users are expected to complete a report for. This can be used for audit, teaching, or assessment purposes.</p>
|
||||||
|
|
||||||
|
<h5>Question</h5>
|
||||||
|
<p>A Question collection contains cases that are used for quiz or question-based learning. These cases may have associated questions and answers for self-assessment or examination.</p>
|
||||||
|
|
||||||
|
<h5>Viva</h5>
|
||||||
|
<p>A Viva collection is intended for oral examination practice. Cases in this collection can be used to simulate viva-style assessments. These are designed to be presented by the "examiner" and features a number of features to make this as easy as possible.</p>
|
||||||
|
|
||||||
<h2>Uploading/creating cases</h2>
|
<h2>Uploading/creating cases</h2>
|
||||||
<p>The easiest way to upload cases is to use the uploader tool. This can export, anonnyimize and upload cases from Insignia Insight, however it may need setting up manually.</p>
|
<p>If set up, the easiest way to upload cases is to use the uploader tool. This can export, anonnyimize and upload cases from Insignia Insight, however it may need setting up manually. If this is not possible you can upload files directly to the site.</p>
|
||||||
<p>Note: due to restrictions in Insight it is not possible to export dicom files from teaching folders. It is possible to export jpg/png files which can be uploaded and will function, but as they do not store the full range of dicom/pixel data some functionality will be limited (e.g. windowing/levelling will be restriction, automatic ordering will not be possible).</p>
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<strong>Note:</strong> Due to restrictions in Insight, it is not possible to export DICOM files from teaching folders. It is possible to export JPG/PNG files which can be uploaded and will function, but as they do not store the full range of DICOM/pixel data, some functionality will be limited (e.g. windowing/levelling will be restricted, automatic ordering will not be possible).
|
||||||
|
</div>
|
||||||
<h3>Setting up the Uploader tool</h3>
|
<h3>Setting up the Uploader tool</h3>
|
||||||
Requirements: Full Insight installation (not WebPacs)
|
Requirements: Full Insight installation (not WebPacs)
|
||||||
<h4>Step 1: Download the uploader tool</h4>
|
<h5>Step 1: Download the uploader tool</h4>
|
||||||
This is available <a href="https://www.penracourses.org.uk/uploader/anon_gui.exe">here</a>
|
This is available <a href="https://www.penracourses.org.uk/uploader/anon_gui.exe">here</a>
|
||||||
<h4>Step 2: Download insight integration xml file</h4>
|
<h5>Step 2: Download insight integration xml file</h4>
|
||||||
Available <a href="https://www.penracourses.org.uk/uploader/insight_rad_up.xml">here</a>
|
Available <a href="https://www.penracourses.org.uk/uploader/insight_rad_up.xml">here</a>
|
||||||
<h4>Step 3: Integrate the uploader with PACs</h4>
|
<h5>Step 3: Integrate the uploader with PACs</h4>
|
||||||
Whilst in PACs open the settings menu with the keyboard shortcut 'Ctrl+Shift+C'. In the 'General' tab, select 'External Tools' and click 'Add'. Press the Import button and select the xml file you downloaded in Step 2. Modify the file to run path to point the uploader exe downloaded in step 1 (clicking on the 3 dots will allow you to browse and select this file). The remaining fields should have been set via the xml file and should not require modification.
|
Whilst in PACs open the settings menu with the keyboard shortcut 'Ctrl+Shift+C'. In the 'General' tab, select 'External Tools' and click 'Add'. Press the Import button and select the xml file you downloaded in Step 2. Modify the file to run path to point the uploader exe downloaded in step 1 (clicking on the 3 dots will allow you to browse and select this file). The remaining fields should have been set via the xml file and should not require modification.
|
||||||
<h3>Uploading without the uploader tool</h3>
|
<h3>Uploading without the uploader tool</h3>
|
||||||
If you have <b>annonymised</b> dicom files already exported you can uploading them directly using this <a href='{% url "atlas:new_uploads" %}'>form</a>.
|
<p>If you have <b>annonymised</b> dicom files already exported you can uploading them directly on this page using this <a href='{% url "atlas:new_uploads" %}'>page</a></p>
|
||||||
|
<p>To do so you will have to first export the files from the PACs system.</p>
|
||||||
|
<p>Once uploaded the series will be available to be imported into cases
|
||||||
|
|
||||||
|
<h2>Cases</h2>
|
||||||
|
<h3>Ordering dicoms within a series</h3>
|
||||||
|
<p>Depending on how dicoms are exported images within a stack may appear in the wrong order. This is apparent when viewing the stacks/series as jumping of images.</p>
|
||||||
|
<p>In most cases this can be fixed by re-ordering based on series metadata (usually slice location).</p>
|
||||||
|
<p>It is possible to reorder all the stacks in a case at once, or to reorder individual stacks.</p>
|
||||||
|
<h4>Reorder all stacks in a case</h4>
|
||||||
|
<p>Go to the case page, click "Manage Series" then "Order dicoms by slice location". This will reorder all the stacks in the case based on slice location.</p>
|
||||||
|
<h4>Reorder individual stacks</h4>
|
||||||
|
<p>More ordering options (if the above does not work) are available on the series page.</p>
|
||||||
|
<p>These can be accessed by opening the "Series info" section and selecting the appropriate button.</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<a href="{{ prev_case.get_absolute_url }}">{{ case.title }}</a>
|
<a href="{{ prev_case.get_absolute_url }}">{{ case.title }}</a>
|
||||||
|
|
||||||
</li>
|
</li>
|
||||||
{% partial case-series %}
|
{% include "atlas/case_display_block.html#case-series" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -11,20 +11,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p>This form allows you to quickly upload dicom files to the server. Select the folder containing the dicom files below. If you wish to upload files directly from Insight you may rather use the <a href='{% url "atlas:help" %}'>Uploader tool.</a></p>
|
<p>This form allows you to quickly upload dicom files to the server. Select the folder containing the dicom files below. If you wish to upload files directly from Insight you may prefer to use the <a href='{% url "atlas:help" %}'>Uploader tool.</a></p>
|
||||||
<details>
|
<details class="help-text">
|
||||||
<summary>Help</summary>
|
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||||
<p>Only valid dicom files will be uploaded. </p>
|
<p>Only valid dicom files will be uploaded. </p>
|
||||||
<p>It is possible to upload multiple files by selecting the parent (containing) folder. Folders within will then be searched recursively for any valid dicom files.</p>
|
<p>It is possible to upload multiple files by selecting the parent (containing) folder. Folders within will then be searched recursively for any valid dicom files.</p>
|
||||||
<p>Files to be uploaded will be displayed below. </p>
|
<p>Files to be uploaded will be displayed below. </p>
|
||||||
<p>DICOM tags will be checked to see if valid accession or hospital numbers are present but no annonymisation will be performed.</p>
|
<p>DICOM tags will be checked to see if valid accession or hospital numbers are present but no annonymisation will be performed.</p>
|
||||||
<p>By default a basic OCR will also be performed to check for burned in details on images, the is resource intensive and can be disabled if you know the content you are uploading does not feature any patient details.</p>
|
<p>An attempt to detect duplicates will be made by calculating a hash of the pixel data in the DICOM files. If this check is not run and the files are uploaded they will be rejected.</p>
|
||||||
|
<p>If enabled a basic OCR will also be performed to check for burned in details on images, this is resource intensive and can be disabled if you know the content you are uploading does not feature any patient details.</p>
|
||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>Settings</summary>
|
<summary>Settings</summary>
|
||||||
<input type="checkbox" id="check-annonymisation-ocr"><label for="check-annonymisation-ocr">Check for annonymisation (OCR)</label>
|
<input type="checkbox" id="check-annonymisation-ocr"><label for="check-annonymisation-ocr">Check for annonymisation (OCR)</label>
|
||||||
<input type="checkbox" id="show-thumbnails"><label for="Show thumbnails">Show thumbnails</label>
|
<input type="checkbox" id="show-thumbnails"><label for="Show thumbnails">Show thumbnails</label>
|
||||||
<input type="checkbox" id="show-viewer"><label for="Show Viewer">Show viewer</label>
|
<input type="checkbox" id="show-viewer"><label for="Show Viewer">Show viewer</label>
|
||||||
|
<input type="checkbox" id="check-duplicates" checked><label for="check-duplicates">Check for duplicates before upload</label>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -40,8 +42,11 @@
|
|||||||
<h5>Uploaded</h5>
|
<h5>Uploaded</h5>
|
||||||
<ul id="uploaded-files"></ul>
|
<ul id="uploaded-files"></ul>
|
||||||
<h5>Duplicates</h5>
|
<h5>Duplicates</h5>
|
||||||
<ul id="duplicate-files"></ul>
|
|
||||||
<ul id="duplicate-series"></ul>
|
<ul id="duplicate-series"></ul>
|
||||||
|
<details>
|
||||||
|
<summary>Files</summary>
|
||||||
|
<ul id="duplicate-files"></ul>
|
||||||
|
</details>
|
||||||
<h5>Failed</h5>
|
<h5>Failed</h5>
|
||||||
<ul id="failed-files"></ul>
|
<ul id="failed-files"></ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,41 +60,175 @@
|
|||||||
<details><summary>Override annonymisation check</summary>
|
<details><summary>Override annonymisation check</summary>
|
||||||
<p>By clicking the button below you can override the annonymisation check and upload the files anyway. Only do this if you are sure this is a false positive.</p>
|
<p>By clicking the button below you can override the annonymisation check and upload the files anyway. Only do this if you are sure this is a false positive.</p>
|
||||||
<button id="override-anon" _="on click remove @disabled from #uploadButton then alert('Upload enabled')">Override annonymisation check</button>
|
<button id="override-anon" _="on click remove @disabled from #uploadButton then alert('Upload enabled')">Override annonymisation check</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4>Selected files:</h4>
|
|
||||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''></div>
|
|
||||||
<ol id="listing"></ol>
|
|
||||||
|
|
||||||
|
|
||||||
<div id="loading" class="fullscreen-overlay">
|
|
||||||
<div class="progress-block">
|
|
||||||
<div id="progress"></div>
|
|
||||||
<div class="sk-cube-grid full">
|
|
||||||
<div class="sk-cube sk-cube1"></div>
|
|
||||||
<div class="sk-cube sk-cube2"></div>
|
|
||||||
<div class="sk-cube sk-cube3"></div>
|
|
||||||
<div class="sk-cube sk-cube4"></div>
|
|
||||||
<div class="sk-cube sk-cube5"></div>
|
|
||||||
<div class="sk-cube sk-cube6"></div>
|
|
||||||
<div class="sk-cube sk-cube7"></div>
|
|
||||||
<div class="sk-cube sk-cube8"></div>
|
|
||||||
<div class="sk-cube sk-cube9"></div>
|
|
||||||
</div>
|
|
||||||
<div id="progress-text">Uploading...</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<h4>Selected files:</h4>
|
||||||
|
<div id="duplicate-progress"></div>
|
||||||
|
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''></div>
|
||||||
|
<ol id="listing"></ol>
|
||||||
|
|
||||||
|
|
||||||
|
<div id="loading" class="fullscreen-overlay">
|
||||||
|
<div class="progress-block">
|
||||||
|
<div id="progress"></div>
|
||||||
|
<div class="sk-cube-grid full">
|
||||||
|
<div class="sk-cube sk-cube1"></div>
|
||||||
|
<div class="sk-cube sk-cube2"></div>
|
||||||
|
<div class="sk-cube sk-cube3"></div>
|
||||||
|
<div class="sk-cube sk-cube4"></div>
|
||||||
|
<div class="sk-cube sk-cube5"></div>
|
||||||
|
<div class="sk-cube sk-cube6"></div>
|
||||||
|
<div class="sk-cube sk-cube7"></div>
|
||||||
|
<div class="sk-cube sk-cube8"></div>
|
||||||
|
<div class="sk-cube sk-cube9"></div>
|
||||||
|
</div>
|
||||||
|
<div id="progress-text">Uploading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block js %}
|
{% block js %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/hash-wasm@4"></script>
|
||||||
|
|
||||||
<script src="{% static 'js/upload_form_helpers.js' %}"></script>
|
<script src="{% static 'js/upload_form_helpers.js' %}"></script>
|
||||||
<script>
|
<script>
|
||||||
window.to_upload = [];
|
window.to_upload = [];
|
||||||
window.upload_count = 1;
|
window.upload_count = 1;
|
||||||
window.duplicate_series = new Set()
|
window.duplicate_series = new Set()
|
||||||
|
|
||||||
|
async function calculateBlake3Hash(file) {
|
||||||
|
// Read file as ArrayBuffer
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
// Parse as DICOM
|
||||||
|
let pixelDataElement;
|
||||||
|
try {
|
||||||
|
const byteArray = new Uint8Array(buffer);
|
||||||
|
const dataSet = dicomParser.parseDicom(byteArray);
|
||||||
|
pixelDataElement = dataSet.elements.x7fe00010; // Pixel Data tag
|
||||||
|
if (!pixelDataElement) {
|
||||||
|
throw new Error("No Pixel Data found in DICOM file.");
|
||||||
|
}
|
||||||
|
// Extract pixel data bytes
|
||||||
|
const pixelData = byteArray.subarray(pixelDataElement.dataOffset, pixelDataElement.dataOffset + pixelDataElement.length);
|
||||||
|
// Hash just the pixel data
|
||||||
|
return await hashwasm.blake3(pixelData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("DICOM parse/hash error:", err, file.name);
|
||||||
|
// Fallback: hash the whole file if pixel data is missing or parsing fails
|
||||||
|
return await hashwasm.blake3(new Uint8Array(buffer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function checkForDuplicates(files) {
|
||||||
|
const hashes = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const hash = await calculateBlake3Hash(file);
|
||||||
|
hashes.push(hash);
|
||||||
|
file._blake3_hash = hash; // Store for later use if needed
|
||||||
|
}
|
||||||
|
// Call the API
|
||||||
|
return fetch("{% url 'api-1:check_images_hashes' %}", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRFToken": "{{ csrf_token }}"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(hashes)
|
||||||
|
}).then(res => res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function processFilesWithDuplicateCheck() {
|
||||||
|
const file_set = document.getElementById("filepicker").files;
|
||||||
|
let hasDuplicates = false;
|
||||||
|
const $dupProgress = $("#duplicate-progress");
|
||||||
|
$dupProgress.empty();
|
||||||
|
|
||||||
|
if ($("#check-duplicates").is(":checked")) {
|
||||||
|
$dupProgress.html('<span style="color:#0d6efd;">Calculating hashes...</span>');
|
||||||
|
$("#progress-text").text("Checking for duplicates...");
|
||||||
|
|
||||||
|
// Calculate hashes and update progress
|
||||||
|
const hashes = [];
|
||||||
|
let n = 0;
|
||||||
|
for (const file of file_set) {
|
||||||
|
n += 1;
|
||||||
|
$dupProgress.html(`<span style="color:#0d6efd;">Hashing: ${file.webkitRelativePath} (${n} of ${file_set.length})</span>`);
|
||||||
|
const hash = await calculateBlake3Hash(file);
|
||||||
|
hashes.push(hash);
|
||||||
|
file._blake3_hash = hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dupProgress.html('<span style="color:#0d6efd;">Checking hashes with server...</span>');
|
||||||
|
const result = await fetch("{% url 'api-1:check_images_hashes' %}", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRFToken": "{{ csrf_token }}"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(hashes)
|
||||||
|
}).then(res => res.json());
|
||||||
|
|
||||||
|
// Build a map from hash to file for quick lookup
|
||||||
|
const hashToFile = {};
|
||||||
|
for (const file of file_set) {
|
||||||
|
if (file._blake3_hash) {
|
||||||
|
hashToFile[file._blake3_hash] = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let duplicateCount = 0;
|
||||||
|
// Map of unique series url to {url, id}
|
||||||
|
const duplicateSeriesMap = {};
|
||||||
|
for (const hash in result) {
|
||||||
|
if (result[hash].id) {
|
||||||
|
hasDuplicates = true;
|
||||||
|
duplicateCount += 1;
|
||||||
|
// Highlight the file in the listing
|
||||||
|
const file = hashToFile[hash];
|
||||||
|
if (file) {
|
||||||
|
const selector = `.dicom-file[data-path='${file.webkitRelativePath}']`;
|
||||||
|
const li = document.querySelector(selector);
|
||||||
|
if (li) {
|
||||||
|
li.classList.add("duplicate-file-highlight");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Collect unique series by url
|
||||||
|
if (result[hash].url) {
|
||||||
|
duplicateSeriesMap[result[hash].url] = {
|
||||||
|
url: result[hash].url,
|
||||||
|
id: result[hash].id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDuplicates) {
|
||||||
|
$("#uploadButton").prop("disabled", true);
|
||||||
|
// Show summary in red and list duplicate series as links if any
|
||||||
|
let html = `<div style="color:#dc3545;font-weight:bold;">Found ${duplicateCount} duplicate file(s). Please review before uploading.</div>`;
|
||||||
|
const seriesUrls = Object.values(duplicateSeriesMap);
|
||||||
|
if (seriesUrls.length > 0) {
|
||||||
|
html += `<div style="margin-top:8px;">Duplicate series:</div><ul>`;
|
||||||
|
seriesUrls.forEach(series => {
|
||||||
|
html += `<li><a href="${series.url}" target="_blank" style="color:#dc3545;text-decoration:underline;">Series ${series.id}</a></li>`;
|
||||||
|
});
|
||||||
|
html += `</ul>`;
|
||||||
|
}
|
||||||
|
$dupProgress.html(html);
|
||||||
|
toastr.error("Duplicates detected. Please review before uploading.");
|
||||||
|
} else {
|
||||||
|
$("#uploadButton").prop("disabled", false);
|
||||||
|
$dupProgress.html('<span style="color:#198754;">No duplicates detected.</span>');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$("#uploadButton").prop("disabled", false);
|
||||||
|
$dupProgress.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function* chunks(arr, n) {
|
function* chunks(arr, n) {
|
||||||
for (let i = 0; i < arr.length; i += n) {
|
for (let i = 0; i < arr.length; i += n) {
|
||||||
yield arr.slice(i, i + n);
|
yield arr.slice(i, i + n);
|
||||||
@@ -170,12 +309,32 @@
|
|||||||
if (current_chunk + 1 == total_chunks) {
|
if (current_chunk + 1 == total_chunks) {
|
||||||
$("#loading").hide();
|
$("#loading").hide();
|
||||||
|
|
||||||
window.duplicate_series.forEach((element) => {
|
// If there are duplicates, open the details and highlight the series
|
||||||
let item = document.createElement("a");
|
if (window.duplicate_series.size > 0) {
|
||||||
item.href = element;
|
$("#upload-results").attr("open", true);
|
||||||
item.textContent = element;
|
$("#duplicate-series").closest("details").attr("open", true);
|
||||||
$("#duplicate-series").append(item, "<br/>");
|
// Show a global alert for duplicate files
|
||||||
});
|
toastr.warning(
|
||||||
|
"Some duplicate files have been detected and not re-imported.",
|
||||||
|
"Duplicate Files Warning",
|
||||||
|
{
|
||||||
|
"closeButton": true,
|
||||||
|
"progressBar": true,
|
||||||
|
"positionClass": "toast-top-full-width",
|
||||||
|
"timeOut": 7000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
window.duplicate_series.forEach((element) => {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "alert alert-warning duplicate-series-highlight";
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = element;
|
||||||
|
link.textContent = `Series exists: ${element}`;
|
||||||
|
link.target = "_blank";
|
||||||
|
wrapper.appendChild(link);
|
||||||
|
$("#duplicate-series").append(wrapper);
|
||||||
|
});
|
||||||
|
};
|
||||||
//$("#duplicate-series").append([...window.duplicate_series].join("<br/>"));
|
//$("#duplicate-series").append([...window.duplicate_series].join("<br/>"));
|
||||||
alert("Uploading complete")
|
alert("Uploading complete")
|
||||||
}
|
}
|
||||||
@@ -220,7 +379,6 @@
|
|||||||
errors.push(`Patient ID starts with ${site_codes[i]} (${patient_id})`);
|
errors.push(`Patient ID starts with ${site_codes[i]} (${patient_id})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(errors)
|
|
||||||
|
|
||||||
if (not_anon) {
|
if (not_anon) {
|
||||||
toastr.warning(`File does not appear to be annonymised<br/>Accession: ${accession_number} <br/>Patient ID: ${patient_id}<br/>`, 'Anonymisation warning', {
|
toastr.warning(`File does not appear to be annonymised<br/>Accession: ${accession_number} <br/>Patient ID: ${patient_id}<br/>`, 'Anonymisation warning', {
|
||||||
@@ -244,11 +402,11 @@
|
|||||||
$(".anon-alert").show();
|
$(".anon-alert").show();
|
||||||
|
|
||||||
setTimeout(
|
setTimeout(
|
||||||
function(errors) {
|
function(errors) {
|
||||||
console.log(errors)
|
console.log(errors)
|
||||||
// We delay this a few seconds otherwise it does not work
|
// We delay this a few seconds otherwise it does not work
|
||||||
$(`.dicom-file[data-path='${file.webkitRelativePath}']`).addClass("image-ident-warning").append($(`<div>${errors.join("<br/>")}</div>`));
|
$(`.dicom-file[data-path='${file.webkitRelativePath}']`).addClass("image-ident-warning").append($(`<div>${errors.join("<br/>")}</div>`));
|
||||||
}, 2000, errors
|
}, 2000, errors
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,20 +611,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
document.getElementById("filepicker").addEventListener(
|
document.getElementById("filepicker").addEventListener("change", async () => {
|
||||||
"change",
|
await loadDicomViewerUploads();
|
||||||
(event) => {
|
await processFilesWithDuplicateCheck();
|
||||||
//let output = document.getElementById("listing");
|
});
|
||||||
//for (const file of event.target.files) {
|
|
||||||
// let item = document.createElement("li");
|
|
||||||
// item.textContent = file.webkitRelativePath;
|
|
||||||
// output.appendChild(item);
|
|
||||||
//}
|
|
||||||
|
|
||||||
loadDicomViewerUploads();
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
// Add event listener to the button element
|
// Add event listener to the button element
|
||||||
@@ -478,7 +626,17 @@
|
|||||||
<script type="text/hyperscript">
|
<script type="text/hyperscript">
|
||||||
|
|
||||||
init
|
init
|
||||||
add @disabled to #uploadButton
|
add @disabled to #uploadButton
|
||||||
end
|
end
|
||||||
</script>
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block css %}
|
||||||
|
<style>
|
||||||
|
.duplicate-file-highlight {
|
||||||
|
background: #fff3cd !important;
|
||||||
|
border-left: 5px solid #dc3545 !important;
|
||||||
|
color: #856404 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{% for collection in collections %}
|
||||||
|
<option value="{{ collection.pk }}">{{ collection.name }}</option>
|
||||||
|
{% empty %}
|
||||||
|
<option value="">No collections available</option>
|
||||||
|
{% endfor %}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<form
|
||||||
|
hx-post="{% if form.instance.pk %}{% url 'atlas:case_displaysets_edit' form.instance.pk %}{% else %}{% url 'atlas:case_displaysets_add' case.pk %}{% endif %}"
|
||||||
|
{% if form.instance.pk %}
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-target="closest li"
|
||||||
|
{% else %}
|
||||||
|
hx-target="#display-set-list"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
hx-on::after-request= "this.remove()"
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
>
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
|
{{ form.media }}
|
||||||
|
<input type="hidden" name="viewerstate" id="viewerstate-input">
|
||||||
|
<input type="hidden" name="annotations" id="annotations-input">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<li class="list-group-item d-flex align-items-start" id="displayset-{{ ds.pk }}">
|
||||||
|
<div class="me-3">
|
||||||
|
{% if ds.viewerstate and ds.annotations %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-lg"
|
||||||
|
style="min-width: 100px;"
|
||||||
|
onclick='importAnnotations_main_viewer({{ ds.annotations|safe }}); importViewerState_main_viewer({{ ds.viewerstate|safe }});'
|
||||||
|
>Load</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<b>{{ ds.name }}</b>
|
||||||
|
{% if ds.description %}
|
||||||
|
<span class="text-muted">({{ ds.description }})</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<strong>Findings:</strong>
|
||||||
|
{% if ds.findings.all %}
|
||||||
|
<ul>
|
||||||
|
{% for finding in ds.findings.all %}
|
||||||
|
<li>{{ finding }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Structures:</strong>
|
||||||
|
{% if ds.structures.all %}
|
||||||
|
<ul>
|
||||||
|
{% for structure in ds.structures.all %}
|
||||||
|
<li>{{ structure }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Conditions:</strong>
|
||||||
|
{% if ds.conditions.all %}
|
||||||
|
<ul>
|
||||||
|
{% for condition in ds.conditions.all %}
|
||||||
|
<li>{{ condition }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<details>
|
||||||
|
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
|
||||||
|
<div>
|
||||||
|
<strong>Viewer State:</strong>
|
||||||
|
<pre class="small">{{ ds.viewerstate|default:"{}" }}</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Annotations:</strong>
|
||||||
|
<pre class="small">{{ ds.annotations|default:"{}" }}</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button
|
||||||
|
id="displayset-{{ ds.pk }}-edit-btn"
|
||||||
|
hx-get="{% url 'atlas:case_displaysets_edit' ds.pk %}"
|
||||||
|
hx-target="#displayset-edit-form-{{ ds.pk }}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-after
|
||||||
|
class="btn btn-warning btn-sm"
|
||||||
|
onclick="
|
||||||
|
this.style.display='none';
|
||||||
|
var li = this.closest('li');
|
||||||
|
var ul = li.parentNode;
|
||||||
|
ul.insertBefore(li, ul.firstChild);
|
||||||
|
li.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||||
|
setTimeout(function() {
|
||||||
|
var form = document.querySelector('#displayset-edit-form-{{ ds.pk }} form');
|
||||||
|
if (form) {
|
||||||
|
var cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.type = 'button';
|
||||||
|
cancelBtn.className = 'btn btn-secondary btn-sm ms-2';
|
||||||
|
cancelBtn.textContent = 'Cancel';
|
||||||
|
cancelBtn.onclick = function() {
|
||||||
|
document.querySelector('#displayset-edit-form-{{ ds.pk }}').innerHTML = '';
|
||||||
|
document.querySelector('#displayset-{{ ds.pk }}-edit-btn').style.display = '';
|
||||||
|
};
|
||||||
|
form.appendChild(cancelBtn);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
"
|
||||||
|
>Edit</button>
|
||||||
|
<button
|
||||||
|
hx-delete="{% url 'atlas:case_displaysets_delete' ds.pk %}"
|
||||||
|
hx-target="closest li"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="btn btn-danger btn-sm"
|
||||||
|
onclick="return confirm('Are you sure you want to delete this display set?');"
|
||||||
|
>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="displayset-edit-form-{{ ds.pk }}"></div>
|
||||||
|
<div class="align-self-start ms-3">
|
||||||
|
<span class="text-muted small">ID: {{ ds.pk }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
<div class="floating-header">
|
<div class="floating-header">
|
||||||
<a href="{% url 'atlas:case_detail' pk=case.pk %}" title="View the Case">View</a>
|
<a href="{% url 'atlas:case_detail' pk=case.pk %}" title="View the Case">View</a>
|
||||||
<a href="{% url 'atlas:case_update' pk=case.pk %}" title="Edit the Case">Edit</a>
|
<a href="{% url 'atlas:case_update' pk=case.pk %}" title="Edit the Case">Edit</a>
|
||||||
|
<a href="{% url 'atlas:case_series_update' pk=case.pk %}" title="Edit the Cases Series">Series</a>
|
||||||
|
<a href="{% url 'atlas:case_displaysets' pk=case.pk %}" title="View and edit case display sets">Display Sets</a>
|
||||||
<a href="{% url 'atlas:case_clone' pk=case.pk %}"
|
<a href="{% url 'atlas:case_clone' pk=case.pk %}"
|
||||||
title="Clone the Case (duplicate everything but the images)">Clone</a>
|
title="Clone the Case (duplicate everything but the images)">Clone</a>
|
||||||
<a href="{% url 'atlas:case_delete' pk=case.pk %}" title="Delete the Case">Delete</a>
|
<a href="{% url 'atlas:case_delete' pk=case.pk %}" title="Delete the Case">Delete</a>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
{% extends 'atlas/base.html' %}
|
{% extends 'atlas/base.html' %}
|
||||||
|
|
||||||
{% load render_table from django_tables2 %}
|
{% load render_table from django_tables2 %}
|
||||||
{% block css %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
@@ -20,8 +18,57 @@
|
|||||||
</div>
|
</div>
|
||||||
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
|
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details class="mt-3">
|
||||||
|
<summary>
|
||||||
|
<strong>Actions</strong>
|
||||||
|
</summary>
|
||||||
|
<form id="bulk-delete-form" method="post" action="{% url 'atlas:series_bulk_delete' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-danger mb-2" onclick="return confirm('Delete selected series?');">
|
||||||
|
Delete Selected
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
{% render_table table %}
|
{% render_table table %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('bulk-delete-form').addEventListener('submit', function(e) {
|
||||||
|
// Remove any previously added hidden inputs
|
||||||
|
document.querySelectorAll('#bulk-delete-form input[name="series_ids"]').forEach(el => el.remove());
|
||||||
|
|
||||||
|
// Find all checked checkboxes
|
||||||
|
const checked = document.querySelectorAll('input[name="selection"]:checked');
|
||||||
|
if (checked.length === 0) {
|
||||||
|
alert("Please select at least one series to delete.");
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Add hidden inputs for each checked series
|
||||||
|
checked.forEach(cb => {
|
||||||
|
const hidden = document.createElement('input');
|
||||||
|
hidden.type = 'hidden';
|
||||||
|
hidden.name = 'series_ids';
|
||||||
|
hidden.value = cb.value;
|
||||||
|
this.appendChild(hidden);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<div id="exam-options"></div>
|
<div id="exam-options"></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block css %}
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
|
{% endblock css %}
|
||||||
|
|
||||||
@@ -38,14 +38,15 @@ Associated case:
|
|||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<div>Author: {{ series.get_author_display }}</div>
|
||||||
|
|
||||||
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
||||||
{% comment %} <div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ image_url_array_and_count.0 }}" data-annotations=''>
|
{% comment %} <div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ image_url_array_and_count.0 }}" data-annotations=''>
|
||||||
</div> {% endcomment %}
|
</div> {% endcomment %}
|
||||||
|
|
||||||
<div id="root" class="dicom-viewer-root" data-images="{{ image_url_array_and_count.0 }}"
|
<div id="root" class="dicom-viewer-root" data-images="{{ image_url_array_and_count.0 }}"
|
||||||
style="width: 1000px; height: 600px; box-sizing: border-box; background: #222;"
|
style="max-width: 1000px; height: 600px; box-sizing: border-box; background: #222;"
|
||||||
data-auto-cache-stack=true
|
data-auto-cache-stack=false
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
|
|
||||||
@@ -82,13 +83,9 @@ Associated case:
|
|||||||
<button id="reset-viewport-button">Reset viewport</button>
|
<button id="reset-viewport-button">Reset viewport</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<details open>
|
<details class="styled-detail open">
|
||||||
<summary>Findings</summary>
|
<summary>Findings</summary>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% for finding in series.findings.all %}
|
{% for finding in series.findings.all %}
|
||||||
<div class="finding-box">
|
<div class="finding-box">
|
||||||
<button id="finding-{{finding.pk}}-3d" class="view-finding-button-3d" data-annotationjson3d='{{finding.annotation_json_3d}}' data-viewerstatejson='{{finding.viewer_state_3d}}' data-findingid='{{finding.pk}}'>Click to view</button>
|
<button id="finding-{{finding.pk}}-3d" class="view-finding-button-3d" data-annotationjson3d='{{finding.annotation_json_3d}}' data-viewerstatejson='{{finding.viewer_state_3d}}' data-findingid='{{finding.pk}}'>Click to view</button>
|
||||||
@@ -121,11 +118,13 @@ Associated case:
|
|||||||
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="delete-finding-link">Delete</a>
|
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="delete-finding-link">Delete</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p>No findings available.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<details>
|
<details class="styled-detail">
|
||||||
<summary>Truncate series</summary>
|
<summary>Truncate series</summary>
|
||||||
<p>
|
<p>
|
||||||
This will limit the series to the selected bounds (the rest of the images will be deleted). This is useful when you have a large volume of which only a small area is relevant to the case. NOTE: once deleted the images cannot be recovered on the site (they would have to be reuploaded if required). Make sure your images are shown in the correct order above and use test the test truncate button first.
|
This will limit the series to the selected bounds (the rest of the images will be deleted). This is useful when you have a large volume of which only a small area is relevant to the case. NOTE: once deleted the images cannot be recovered on the site (they would have to be reuploaded if required). Make sure your images are shown in the correct order above and use test the test truncate button first.
|
||||||
@@ -133,7 +132,7 @@ Associated case:
|
|||||||
<div role="alert" class="alert alert-warning">
|
<div role="alert" class="alert alert-warning">
|
||||||
<strong>Warning:</strong> Please note: if you have reordered the series this may result in the incorrect images being deleted.
|
<strong>Warning:</strong> Please note: if you have reordered the series this may result in the incorrect images being deleted.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Start <input id="lower-truncation-bound-input" type="number" value="1"> <button id="set-lower-truncation-bound-button">Set</button><br/>
|
Start <input id="lower-truncation-bound-input" type="number" value="1"> <button id="set-lower-truncation-bound-button">Set</button><br/>
|
||||||
End <input id="upper-truncation-bound-input"type="number" value="{{image_url_array_and_count.1}}"> <button id="set-upper-truncation-bound-button">Set</button><br/>
|
End <input id="upper-truncation-bound-input"type="number" value="{{image_url_array_and_count.1}}"> <button id="set-upper-truncation-bound-button">Set</button><br/>
|
||||||
<button id="truncate-test-button">Test truncate</button>
|
<button id="truncate-test-button">Test truncate</button>
|
||||||
@@ -145,23 +144,29 @@ Associated case:
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
|
|
||||||
<div>Author: {{ series.get_author_display }}</div>
|
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<details>
|
<details class="styled-detail">
|
||||||
<summary>Series info</summary>
|
<summary>Series info</summary>
|
||||||
<div>
|
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||||
<a href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}"
|
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_anonymise_dicom' pk=series.pk %}'" title="Anonymise dicom images">
|
||||||
title="Anonymise dicom images">Anonymise dicoms</a><br />
|
Anonymise dicoms
|
||||||
<a href="{% url 'atlas:series_order_dicom' pk=series.pk %}" title="orders dicom by slice location">Order dicoms
|
</button>
|
||||||
by slice location</a>
|
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_dicom' pk=series.pk %}'" title="orders dicom by slice location">
|
||||||
<a href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}"
|
Order by slice location
|
||||||
title="orders dicom by instance number">Order dicoms by instance number</a>
|
</button>
|
||||||
<a href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}"
|
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_dicom_instance' pk=series.pk %}'" title="orders dicom by instance number">
|
||||||
title="orders dicom by instance number">Order dicoms by SeriesInstanceUID</a>
|
Order by instance number
|
||||||
<a href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}"
|
</button>
|
||||||
title="orders dicom by uploaded filename">Order by uploaded filename</a><br/>
|
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}'" title="orders dicom by SeriesInstanceUID">
|
||||||
<a href="{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}"
|
Order by SeriesInstanceUID
|
||||||
title="split series by dicom tag ImageType">Split the series by the ImageType dicom tag</a>
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_upload_filename' pk=series.pk %}'" title="orders dicom by uploaded filename">
|
||||||
|
Order by uploaded filename
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-warning btn-sm" onclick="window.location.href='{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}'" title="split series by dicom tag ImageType">
|
||||||
|
Split by ImageType tag
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
@@ -172,45 +177,44 @@ Associated case:
|
|||||||
>
|
>
|
||||||
{% for image in series.get_images %}
|
{% for image in series.get_images %}
|
||||||
[{{ image.id }}] <a href='{% url "atlas:series_image_dicom" image.pk %}'>{{image.image.url}}</a>, pos: {{image.position}}, {{image.upload_filename}}
|
[{{ image.id }}] <a href='{% url "atlas:series_image_dicom" image.pk %}'>{{image.image.url}}</a>, pos: {{image.position}}, {{image.upload_filename}}
|
||||||
|
|
||||||
|
|
||||||
{% if image.image %}
|
{% if image.image %}
|
||||||
[{{image.get_file_size|filesizeformat}}]
|
[{{image.get_file_size|filesizeformat}}]
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{{image.image_md5_hash}} ({{image.is_dicom}}) {{image.image_blake3_hash}}
|
{{image.image_md5_hash}} ({{image.is_dicom}}) {{image.image_blake3_hash}}
|
||||||
|
|
||||||
<input type="checkbox" name="image-id" value="{{image.id}}">
|
<input type="checkbox" name="image-id" value="{{image.id}}">
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
{% comment %} {{image.get_dicom_info|safe}}<br /> {% endcomment %}
|
|
||||||
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<button>Diff files</button>
|
<button>Diff files</button>
|
||||||
</form>
|
</form>
|
||||||
<span id="temp"></span>
|
<span id="temp"></span>
|
||||||
|
|
||||||
|
<span id="diff-controls">show <span id="diff-show-all" _="on click
|
||||||
|
log '1'
|
||||||
|
for row in .diff.rows
|
||||||
|
show row
|
||||||
|
end">all</span> <span id="diff-show-diff" _="
|
||||||
|
on click
|
||||||
|
log 'go'
|
||||||
|
for row in <.diff tr/>
|
||||||
|
log row.children[0].innerHTML
|
||||||
|
if row.children[0].innerHTML is empty
|
||||||
|
hide row
|
||||||
|
end
|
||||||
|
end
|
||||||
|
">diff</span></span>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<p>Total image size: {{series.get_total_image_size|filesizeformat}}</p>
|
<p>Total image size: {{series.get_total_image_size|filesizeformat}}</p>
|
||||||
<p><a href="{% url 'atlas:series_download' pk=series.pk %}">Download images</a></p>
|
<p>
|
||||||
|
<button type="button" class="btn btn-outline-success btn-sm" onclick="window.location.href='{% url 'atlas:series_download' pk=series.pk %}'">
|
||||||
|
Download images
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
</details>
|
</details>
|
||||||
<span id="diff-controls">show <span id="diff-show-all" _="on click
|
|
||||||
log '1'
|
|
||||||
for row in .diff.rows
|
|
||||||
show row
|
|
||||||
end">all</span> <span id="diff-show-diff" _="
|
|
||||||
on click
|
|
||||||
log 'go'
|
|
||||||
for row in <.diff tr/>
|
|
||||||
log row.children[0].innerHTML
|
|
||||||
if row.children[0].innerHTML is empty
|
|
||||||
hide row
|
|
||||||
end
|
|
||||||
end
|
|
||||||
">diff</span></span>
|
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<div id="clone-findings-modal"
|
<div id="clone-findings-modal"
|
||||||
class="modal modal-blur fade"
|
class="modal modal-blur fade"
|
||||||
style="display: none"
|
style="display: none"
|
||||||
@@ -386,4 +390,78 @@ Associated case:
|
|||||||
#finding-form #hidden-form:focus-within {
|
#finding-form #hidden-form:focus-within {
|
||||||
box-shadow: 0 0 24px #198754aa;
|
box-shadow: 0 0 24px #198754aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#diff-show-all {
|
||||||
|
color: red;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#diff-show-diff {
|
||||||
|
color: #0d6efd;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.styled-detail {
|
||||||
|
background: #23272b;
|
||||||
|
border-radius: 0.7em;
|
||||||
|
margin: 1.2em 0;
|
||||||
|
padding: 1.2em 1.5em 1em 1.5em;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.10);
|
||||||
|
border: 1px solid #343a40;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
details.styled-detail[open] {
|
||||||
|
box-shadow: 0 4px 24px rgba(13,110,253,0.10);
|
||||||
|
border-color: #0d6efd;
|
||||||
|
}
|
||||||
|
details.styled-detail > summary {
|
||||||
|
font-size: 1.15em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0d6efd;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: -1.2em -1.5em 1em -1.5em;
|
||||||
|
padding: 1.2em 1.5em 0.5em 1.5em;
|
||||||
|
border-bottom: 1px solid #343a40;
|
||||||
|
background: linear-gradient(90deg, #23272b 80%, #0d6efd22 100%);
|
||||||
|
border-radius: 0.7em 0.7em 0 0;
|
||||||
|
transition: background 0.2s, font-size 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.styled-detail:not([open]) {
|
||||||
|
padding-bottom: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.styled-detail:not([open]) > *:not(summary) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.styled-detail:not([open]) > summary {
|
||||||
|
padding: 0.7em 1.2em 0.3em 1.2em;
|
||||||
|
}
|
||||||
|
details.styled-detail > summary:hover {
|
||||||
|
background: linear-gradient(90deg, #23272b 60%, #0d6efd44 100%);
|
||||||
|
color: #198754;
|
||||||
|
}
|
||||||
|
details.styled-detail > div, details.styled-detail > p, details.styled-detail > form, details.styled-detail > ul {
|
||||||
|
margin-top: 0.7em;
|
||||||
|
}
|
||||||
|
.alert {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
/* Nested details */
|
||||||
|
details.styled-detail details {
|
||||||
|
background: #181a1b;
|
||||||
|
border: 1px solid #444;
|
||||||
|
margin: 0.7em 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
details.styled-detail details > summary {
|
||||||
|
color: #ffc107;
|
||||||
|
background: linear-gradient(90deg, #181a1b 80%, #ffc10722 100%);
|
||||||
|
border-bottom: 1px solid #444;
|
||||||
|
}
|
||||||
|
details.styled-detail details > summary:hover {
|
||||||
|
color: #0d6efd;
|
||||||
|
background: linear-gradient(90deg, #181a1b 60%, #0d6efd22 100%);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -39,8 +39,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<details><summary>Upload settings</summary>
|
<details><summary>Upload settings</summary>
|
||||||
<form>
|
<form>
|
||||||
<div>
|
<div>
|
||||||
<fieldset><legend>Select how series should be ordered when uploaded</legend>
|
<fieldset><legend>Select how series should be ordered when uploaded</legend>
|
||||||
<div class="helptext">
|
<div class="helptext">
|
||||||
This will affect how series are displayed when viewing with the built in site viewer. By default when exporting from Insight the order is not maintained so you should choose to order either by slice location or instance number.<br/>
|
This will affect how series are displayed when viewing with the built in site viewer. By default when exporting from Insight the order is not maintained so you should choose to order either by slice location or instance number.<br/>
|
||||||
If you are not sure, leave this as the default.
|
If you are not sure, leave this as the default.
|
||||||
@@ -57,8 +57,8 @@
|
|||||||
<input type="radio" id="order-series-none" value="order-series-none" name="order-series" >
|
<input type="radio" id="order-series-none" value="order-series-none" name="order-series" >
|
||||||
<label for="order-series-none">None</label><br/>
|
<label for="order-series-none">None</label><br/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -68,23 +68,16 @@
|
|||||||
hx-confirm="This will clear selected uploads, Continue?"
|
hx-confirm="This will clear selected uploads, Continue?"
|
||||||
title="Deleted selected uploads"
|
title="Deleted selected uploads"
|
||||||
>Delete Uploads</button>
|
>Delete Uploads</button>
|
||||||
{% if case %}
|
|
||||||
<button id="import-dicoms-case-button"
|
|
||||||
hx-post="{% url 'api-1:import_dicoms_case' case.id %}"
|
|
||||||
hx-target=".imported"
|
|
||||||
{% comment %} hx-indicator=".indicator" {% endcomment %}
|
|
||||||
hx-include=".selected,[name='order-series']"
|
|
||||||
>Import into case</button>
|
|
||||||
|
|
||||||
{% else %}
|
<button id="import-dicoms-sequential-button"
|
||||||
<button id="import-dicoms-button"
|
type="button"
|
||||||
hx-post="{% url 'api-1:import_dicoms' %}"
|
class="btn btn-primary"
|
||||||
hx-target=".imported"
|
>Import {% if case %}into case{% endif %} (one at a time)</button>
|
||||||
{% comment %} hx-indicator=".indicator" {% endcomment %}
|
|
||||||
hx-include=".selected,[name='order-series']"
|
<div id="import-status"></div>
|
||||||
>Import</button>
|
|
||||||
{% endif %}
|
|
||||||
<div class="indicator"><div class="loader"></div>Loading...</div>
|
<div class="indicator"><div class="loader"></div>Loading...</div>
|
||||||
|
<div id="import-status"></div>
|
||||||
|
|
||||||
<div class="imported">
|
<div class="imported">
|
||||||
|
|
||||||
@@ -96,66 +89,138 @@
|
|||||||
|
|
||||||
<p><a href="{% url 'atlas:series_view' %}?case=null&sort=-modified_date">View orphan series</a></p>
|
<p><a href="{% url 'atlas:series_view' %}?case=null&sort=-modified_date">View orphan series</a></p>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const importButton = document.getElementById('import-dicoms-sequential-button');
|
||||||
|
if (importButton) {
|
||||||
|
document.getElementById('import-dicoms-sequential-button').addEventListener('click', async function() {
|
||||||
|
const checkboxes = Array.from(document.querySelectorAll('input[name="selection"]:checked'));
|
||||||
|
const allCheckboxes = Array.from(document.querySelectorAll('input[name="selection"]'));
|
||||||
|
// Only include checkboxes that are not disabled (i.e., not already imported)
|
||||||
|
const selectableCheckboxes = allCheckboxes.filter(cb => !cb.disabled);
|
||||||
|
const toImport = checkboxes.length
|
||||||
|
? checkboxes.filter(cb => !cb.disabled)
|
||||||
|
: selectableCheckboxes;
|
||||||
|
if (toImport.length === 0) {
|
||||||
|
alert("Please select at least one series to import (not already imported).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const statusDiv = document.getElementById('import-status');
|
||||||
|
statusDiv.innerHTML = '';
|
||||||
|
const orderSeries = document.querySelector('input[name="order-series"]:checked').value;
|
||||||
|
const csrfToken = '{{ csrf_token }}';
|
||||||
|
{% if case %}
|
||||||
|
const importUrl = "{% url 'api-1:import_dicoms_case' case.id %}";
|
||||||
|
{% else %}
|
||||||
|
const importUrl = "{% url 'api-1:import_dicoms' %}";
|
||||||
|
{% endif %}
|
||||||
|
for (let i = 0; i < toImport.length; i++) {
|
||||||
|
const seriesId = toImport[i].value;
|
||||||
|
statusDiv.innerHTML += `<div id="import-status-${seriesId}">Importing series ${seriesId}...</div>`;
|
||||||
|
try {
|
||||||
|
const response = await fetch(importUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"X-CSRFToken": csrfToken,
|
||||||
|
},
|
||||||
|
body: `selection=${encodeURIComponent(seriesId)}&order-series=${encodeURIComponent(orderSeries)}`
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
document.getElementById(`import-status-${seriesId}`).innerHTML = `Series ${seriesId}: ${text}`;
|
||||||
|
// Mark as imported in the UI and make unselectable
|
||||||
|
const li = document.querySelector(`#series-list input[value="${seriesId}"]`)?.closest('li');
|
||||||
|
if (li && !li.classList.contains('imported')) {
|
||||||
|
li.classList.add('imported');
|
||||||
|
// Add badge if not already present
|
||||||
|
if (!li.querySelector('.badge.bg-success')) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'badge bg-success ms-2';
|
||||||
|
badge.textContent = 'Imported';
|
||||||
|
li.appendChild(badge);
|
||||||
|
}
|
||||||
|
// Disable checkbox and prevent further selection
|
||||||
|
const checkbox = li.querySelector('input[name="selection"]');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.disabled = true;
|
||||||
|
checkbox.checked = false;
|
||||||
|
}
|
||||||
|
// Remove click handler for selection
|
||||||
|
li.querySelector('span').onclick = null;
|
||||||
|
li.style.pointerEvents = "none";
|
||||||
|
li.style.opacity = "0.7";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById(`import-status-${seriesId}`).innerHTML = `Series ${seriesId}: <span style="color:red;">Failed</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statusDiv.innerHTML += "<div>All done.</div>";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block css %}
|
{% block css %}
|
||||||
<style>
|
<style>
|
||||||
.indicator {
|
.indicator {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.indicator.htmx-request {
|
.indicator.htmx-request {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loader {
|
.loader {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
.loader::before,
|
.loader::before,
|
||||||
.loader::after {
|
.loader::after {
|
||||||
content:"";
|
content:"";
|
||||||
grid-area: 1/1;
|
grid-area: 1/1;
|
||||||
--c:no-repeat radial-gradient(farthest-side,#25b09b 92%,#0000);
|
--c:no-repeat radial-gradient(farthest-side,#25b09b 92%,#0000);
|
||||||
background:
|
background:
|
||||||
var(--c) 50% 0,
|
var(--c) 50% 0,
|
||||||
var(--c) 50% 100%,
|
var(--c) 50% 100%,
|
||||||
var(--c) 100% 50%,
|
var(--c) 100% 50%,
|
||||||
var(--c) 0 50%;
|
var(--c) 0 50%;
|
||||||
background-size: 12px 12px;
|
background-size: 12px 12px;
|
||||||
animation: l12 1s infinite;
|
animation: l12 1s infinite;
|
||||||
}
|
}
|
||||||
.loader::before {
|
.loader::before {
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
filter: hue-rotate(45deg);
|
filter: hue-rotate(45deg);
|
||||||
background-size: 8px 8px;
|
background-size: 8px 8px;
|
||||||
animation-timing-function: linear
|
animation-timing-function: linear
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes l12 {
|
@keyframes l12 {
|
||||||
100%{transform: rotate(.5turn)}
|
100%{transform: rotate(.5turn)}
|
||||||
}
|
}
|
||||||
|
|
||||||
#series-list {
|
#series-list {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#series-list li {
|
#series-list li {
|
||||||
}
|
}
|
||||||
#series-list .selected {
|
#series-list .selected {
|
||||||
color: white;
|
color: white;
|
||||||
border: 1px solid #52057b;
|
border: 1px solid #52057b;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
margin: -1px;
|
margin: -1px;
|
||||||
|
|
||||||
|
}
|
||||||
|
.series-block-popup-link {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
#series-list li.imported {
|
||||||
|
border-color: #e6ffe6;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
}
|
|
||||||
.series-block-popup-link {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
{% endblock css %}
|
{% endblock css %}
|
||||||
|
|
||||||
@@ -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 len(child2.get_parents()) == 1
|
||||||
assert parent in child2.get_parents()
|
assert parent in child2.get_parents()
|
||||||
|
|
||||||
print(parent.get_descendents())
|
|
||||||
|
|
||||||
#@pytest.fixture
|
#@pytest.fixture
|
||||||
#def create_basic_user(db, django_user_model):
|
#def create_basic_user(db, django_user_model):
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ urlpatterns = [
|
|||||||
name="exam_update",
|
name="exam_update",
|
||||||
),
|
),
|
||||||
path("collection/viewer", TemplateView.as_view(template_name="atlas/viewer.html"), name="collection_viewer"),
|
path("collection/viewer", TemplateView.as_view(template_name="atlas/viewer.html"), name="collection_viewer"),
|
||||||
|
path("collection/viewer_local", TemplateView.as_view(template_name="atlas/viewer_local.html"), name="collection_viewer_local"),
|
||||||
path("collection/<int:pk>", views.collection_detail, name="collection_detail"),
|
path("collection/<int:pk>", views.collection_detail, name="collection_detail"),
|
||||||
path("collection/<int:pk>/viva", views.collection_viva, name="collection_viva"),
|
path("collection/<int:pk>/viva", views.collection_viva, name="collection_viva"),
|
||||||
path("collection/<int:pk>/viva/<int:case_number>", views.collection_viva_case, name="collection_viva_case"),
|
path("collection/<int:pk>/viva/<int:case_number>", views.collection_viva_case, name="collection_viva_case"),
|
||||||
@@ -80,6 +81,16 @@ urlpatterns = [
|
|||||||
name="collection_take_start",
|
name="collection_take_start",
|
||||||
),
|
),
|
||||||
path("collection/<int:pk>/authors", views.CaseCollectionAuthorUpdate.as_view(), name="collection_authors"),
|
path("collection/<int:pk>/authors", views.CaseCollectionAuthorUpdate.as_view(), name="collection_authors"),
|
||||||
|
path(
|
||||||
|
"collection/<int:pk>/toggle_results_published",
|
||||||
|
views.GenericExamViews.exam_toggle_results_published_htmx,
|
||||||
|
name="exam_toggle_results_published",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"collection/<int:pk>/toggle_active",
|
||||||
|
views.GenericExamViews.exam_toggle_active_htmx,
|
||||||
|
name="exam_toggle_active",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"collection/<int:exam_id>/cids",
|
"collection/<int:exam_id>/cids",
|
||||||
views.GenericExamViews.exam_cids,
|
views.GenericExamViews.exam_cids,
|
||||||
@@ -90,6 +101,28 @@ urlpatterns = [
|
|||||||
views.collection_history,
|
views.collection_history,
|
||||||
name="collection_history",
|
name="collection_history",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"collection/<int:pk>/cases/update",
|
||||||
|
views.CollectionCaseUpdate.as_view(),
|
||||||
|
name="collection_case_update",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"collection/add_cases",
|
||||||
|
views.add_cases_to_collection,
|
||||||
|
name="add_cases_to_collection",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"collection/remove_cases",
|
||||||
|
views.remove_cases_from_collection,
|
||||||
|
name="remove_cases_from_collection",
|
||||||
|
),
|
||||||
|
path("case/set_open_access", views.set_open_access, name="set_open_access"),
|
||||||
|
path("case/remove_open_access", views.remove_open_access, name="remove_open_access"),
|
||||||
|
path(
|
||||||
|
"collection/options",
|
||||||
|
views.collection_options,
|
||||||
|
name="collection_options",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"collection/<int:exam_id>/history/<int:user_pk>/user",
|
"collection/<int:exam_id>/history/<int:user_pk>/user",
|
||||||
views.collection_history_user,
|
views.collection_history_user,
|
||||||
@@ -218,6 +251,11 @@ urlpatterns = [
|
|||||||
views.collection_case_view,
|
views.collection_case_view,
|
||||||
name="collection_case_view",
|
name="collection_case_view",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"collection/<int:collection_id>/<int:case_id>/display_setup",
|
||||||
|
views.collection_case_displaysetup,
|
||||||
|
name="collection_case_displaysetup",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>",
|
"collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>",
|
||||||
views.collection_case_view_take,
|
views.collection_case_view_take,
|
||||||
@@ -244,6 +282,11 @@ urlpatterns = [
|
|||||||
name="collection_dicom_json",
|
name="collection_dicom_json",
|
||||||
),
|
),
|
||||||
path("case/<int:pk>/authors", views.CaseAuthorUpdate.as_view(), name="case_authors"),
|
path("case/<int:pk>/authors", views.CaseAuthorUpdate.as_view(), name="case_authors"),
|
||||||
|
path("case/<int:pk>/display_sets", views.case_displaysets, name="case_displaysets"),
|
||||||
|
path("case/<int:pk>/display_sets/add", views.case_displaysets_add, name="case_displaysets_add"),
|
||||||
|
path("<int:pk>/display_sets/edit", views.case_displaysets_edit, name="case_displaysets_edit"),
|
||||||
|
path("<int:pk>/display_sets/detail", views.case_displaysets_detail, name="case_displaysets_detail"),
|
||||||
|
path("<int:pk>/display_sets/delete", views.case_displaysets_delete, name="case_displaysets_delete"),
|
||||||
path(
|
path(
|
||||||
"case/<int:pk>/dicom_json",
|
"case/<int:pk>/dicom_json",
|
||||||
views.case_dicom_json,
|
views.case_dicom_json,
|
||||||
@@ -259,6 +302,12 @@ urlpatterns = [
|
|||||||
views.remove_case_from_collection,
|
views.remove_case_from_collection,
|
||||||
name="remove_case_from_collection",
|
name="remove_case_from_collection",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"case/<int:case_pk>/series_remove/",
|
||||||
|
views.remove_selected_series_from_case,
|
||||||
|
name="remove_selected_series_from_case",
|
||||||
|
),
|
||||||
|
path('case/<int:case_pk>/reorder_series/', views.reorder_series, name='reorder_series'),
|
||||||
path(
|
path(
|
||||||
"series/<int:pk>/dicom_json",
|
"series/<int:pk>/dicom_json",
|
||||||
views.series_dicom_json,
|
views.series_dicom_json,
|
||||||
@@ -266,6 +315,7 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path('series/<int:series_pk>/add-to-case/', views.add_series_to_case, name='add_series_to_case'),
|
path('series/<int:series_pk>/add-to-case/', views.add_series_to_case, name='add_series_to_case'),
|
||||||
path('series/<int:series_pk>/<int:case_pk>/remove-from-case/', views.remove_series_from_case, name='remove_series_from_case'),
|
path('series/<int:series_pk>/<int:case_pk>/remove-from-case/', views.remove_series_from_case, name='remove_series_from_case'),
|
||||||
|
path('series/bulk-delete/', views.series_bulk_delete, name='series_bulk_delete'),
|
||||||
path('series_finding/<int:finding_id>/details/', views.series_finding_details, name='series_finding_details'),
|
path('series_finding/<int:finding_id>/details/', views.series_finding_details, name='series_finding_details'),
|
||||||
path('series_finding/migration-status/', views.seriesfinding_migration_status, name='seriesfinding_migration_status'),
|
path('series_finding/migration-status/', views.seriesfinding_migration_status, name='seriesfinding_migration_status'),
|
||||||
path("uncategorised_dicoms/", views.uncategorised_dicoms, name="uncategorised_dicoms_view"),
|
path("uncategorised_dicoms/", views.uncategorised_dicoms, name="uncategorised_dicoms_view"),
|
||||||
@@ -412,6 +462,11 @@ urlpatterns = [
|
|||||||
views.AtlasUpdate.as_view(),
|
views.AtlasUpdate.as_view(),
|
||||||
name="case_update",
|
name="case_update",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"case/<int:pk>/update/series",
|
||||||
|
views.AtlasSeriesUpdate.as_view(),
|
||||||
|
name="case_series_update",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"series/<int:pk>/update",
|
"series/<int:pk>/update",
|
||||||
views.SeriesUpdate.as_view(),
|
views.SeriesUpdate.as_view(),
|
||||||
|
|||||||
+534
-83
@@ -4,10 +4,14 @@ from typing import Any
|
|||||||
from dal import autocomplete
|
from dal import autocomplete
|
||||||
from django.db.models.base import Model as Model
|
from django.db.models.base import Model as Model
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
|
from django.db.models import Prefetch
|
||||||
from django.shortcuts import render, get_object_or_404, redirect
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.db.models import Count
|
||||||
|
|
||||||
# from django.contrib.auth.models import User
|
# from django.contrib.auth.models import User
|
||||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||||
@@ -24,7 +28,7 @@ from django.views.generic import View
|
|||||||
from django.views.generic.detail import DetailView
|
from django.views.generic.detail import DetailView
|
||||||
import pydicom
|
import pydicom
|
||||||
from pydicom.uid import generate_uid
|
from pydicom.uid import generate_uid
|
||||||
from generic.mixins import SuperuserRequiredMixin
|
from generic.mixins import SuperuserRequiredMixin, UserConfigurablePaginationMixin
|
||||||
|
|
||||||
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
|
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
|
||||||
from django.views.generic import ListView
|
from django.views.generic import ListView
|
||||||
@@ -45,9 +49,12 @@ from .forms import (
|
|||||||
CaseCollectionAuthorForm,
|
CaseCollectionAuthorForm,
|
||||||
CaseCollectionCaseFormSet,
|
CaseCollectionCaseFormSet,
|
||||||
CaseCollectionForm,
|
CaseCollectionForm,
|
||||||
|
CaseCollectionUpdateCaseForm,
|
||||||
CaseDetailForm,
|
CaseDetailForm,
|
||||||
|
CaseDisplaySetForm,
|
||||||
CaseForm,
|
CaseForm,
|
||||||
CaseResourceFormSet,
|
CaseResourceFormSet,
|
||||||
|
CaseUpdateSeriesForm,
|
||||||
CidQuestionAnswerForm,
|
CidQuestionAnswerForm,
|
||||||
CidReportAnswerForm,
|
CidReportAnswerForm,
|
||||||
CidReportAnswerMarkForm,
|
CidReportAnswerMarkForm,
|
||||||
@@ -75,7 +82,9 @@ from .models import (
|
|||||||
Case,
|
Case,
|
||||||
CaseCollection,
|
CaseCollection,
|
||||||
CaseDetail,
|
CaseDetail,
|
||||||
|
CaseDisplaySet,
|
||||||
CasePrior,
|
CasePrior,
|
||||||
|
CaseResource,
|
||||||
CidReportAnswer,
|
CidReportAnswer,
|
||||||
Condition,
|
Condition,
|
||||||
Differential,
|
Differential,
|
||||||
@@ -87,6 +96,7 @@ from .models import (
|
|||||||
Series,
|
Series,
|
||||||
Examination,
|
Examination,
|
||||||
Finding,
|
Finding,
|
||||||
|
SeriesDetail,
|
||||||
Structure,
|
Structure,
|
||||||
Subspecialty,
|
Subspecialty,
|
||||||
SeriesFinding,
|
SeriesFinding,
|
||||||
@@ -193,18 +203,148 @@ class AtlasEditorRequiredMixin(object):
|
|||||||
return obj
|
return obj
|
||||||
raise PermissionDenied("You must be an atlas editor to do this.") # or Http404
|
raise PermissionDenied("You must be an atlas editor to do this.") # or Http404
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@user_has_case_view_access
|
||||||
|
def case_displaysets(request, pk):
|
||||||
|
case = get_object_or_404(Case, pk=pk)
|
||||||
|
|
||||||
|
can_edit = case.check_user_can_edit(request.user)
|
||||||
|
|
||||||
|
# Get displayset id from GET parameter
|
||||||
|
displayset_id = request.GET.get("displayset")
|
||||||
|
edit = request.GET.get("edit", False)
|
||||||
|
selected_displayset = None
|
||||||
|
if displayset_id:
|
||||||
|
try:
|
||||||
|
selected_displayset = case.display_sets.get(pk=displayset_id)
|
||||||
|
except CaseDisplaySet.DoesNotExist:
|
||||||
|
selected_displayset = None
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"atlas/case_displaysets.html",
|
||||||
|
{
|
||||||
|
"case": case,
|
||||||
|
"edit": edit,
|
||||||
|
"can_edit": can_edit,
|
||||||
|
"selected_displayset": selected_displayset,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@user_has_case_view_access
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def case_displaysets_add(request, pk):
|
||||||
|
case = get_object_or_404(Case, pk=pk)
|
||||||
|
form = CaseDisplaySetForm(request.POST or None)
|
||||||
|
if request.method == "POST":
|
||||||
|
if form.is_valid():
|
||||||
|
ds = form.save(commit=False)
|
||||||
|
ds.case = case
|
||||||
|
ds.author = request.user
|
||||||
|
ds.viewerstate = request.POST.get("viewerstate") or None
|
||||||
|
ds.annotations = request.POST.get("annotations") or None
|
||||||
|
ds.save()
|
||||||
|
form.save_m2m()
|
||||||
|
# HTMX: return the new display set row HTML fragment
|
||||||
|
html = render_to_string("atlas/partials/displayset_row.html", {"ds": ds, "can_edit": True})
|
||||||
|
return HttpResponse(html)
|
||||||
|
else:
|
||||||
|
# HTMX: return the form HTML fragment with errors
|
||||||
|
html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request)
|
||||||
|
return HttpResponse(html, status=400)
|
||||||
|
else:
|
||||||
|
# HTMX: return the blank form HTML fragment
|
||||||
|
html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request)
|
||||||
|
return HttpResponse(html)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def case_displaysets_edit(request, pk):
|
||||||
|
ds = get_object_or_404(CaseDisplaySet, pk=pk)
|
||||||
|
|
||||||
|
case = ds.case
|
||||||
|
if not case.check_user_can_edit(request.user):
|
||||||
|
return HttpResponse("You do not have permission to delete this display set.")
|
||||||
|
form = CaseDisplaySetForm(request.POST or None, instance=ds)
|
||||||
|
if request.method == "POST":
|
||||||
|
if form.is_valid():
|
||||||
|
ds = form.save(commit=False)
|
||||||
|
ds.viewerstate = request.POST.get("viewerstate") or ds.viewerstate
|
||||||
|
ds.annotations = request.POST.get("annotations") or ds.annotations
|
||||||
|
ds.save()
|
||||||
|
form.save_m2m()
|
||||||
|
# Return updated row HTML
|
||||||
|
html = render_to_string("atlas/partials/displayset_row.html", {"ds": ds, "can_edit": True}, request=request)
|
||||||
|
return HttpResponse(html)
|
||||||
|
else:
|
||||||
|
html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request)
|
||||||
|
return HttpResponse(html, status=400)
|
||||||
|
else:
|
||||||
|
html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request)
|
||||||
|
return HttpResponse(html)
|
||||||
|
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def case_displaysets_detail(request, pk):
|
||||||
|
displayset = get_object_or_404(CaseDisplaySet, pk=pk)
|
||||||
|
case = displayset.case
|
||||||
|
# Redirect to the case_displaysets page with ?displayset=<pk>
|
||||||
|
return redirect(f"{reverse('atlas:case_displaysets', args=[case.pk])}?displayset={pk}")
|
||||||
|
|
||||||
|
def case_displaysets_delete(request, pk):
|
||||||
|
try:
|
||||||
|
displayset = CaseDisplaySet.objects.get(pk=pk)
|
||||||
|
except CaseDisplaySet.DoesNotExist:
|
||||||
|
raise Http404("Display set not found.")
|
||||||
|
|
||||||
|
if not displayset.case.check_user_can_edit(request.user):
|
||||||
|
raise PermissionDenied("You do not have permission to delete this display set.")
|
||||||
|
displayset.delete()
|
||||||
|
return HttpResponse("<li class='list-group-item' >Display set deleted successfully.")
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@user_has_case_view_access
|
@user_has_case_view_access
|
||||||
def case_detail(request, pk):
|
def case_detail(request, pk):
|
||||||
case = get_object_or_404(Case, pk=pk)
|
# Prefetch all related objects needed for the template
|
||||||
|
case = (
|
||||||
# if request.user not in atlas.author.all():
|
Case.objects
|
||||||
# raise PermissionDenied
|
.select_related() # Add any FK fields if needed
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"seriesdetail_set",
|
||||||
|
queryset=SeriesDetail.objects.select_related("series").order_by("sort_order").prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"series",
|
||||||
|
queryset=Series.objects.select_related("modality", "examination", "plane", "contrast")
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"findings",
|
||||||
|
queryset=SeriesFinding.objects.prefetch_related(
|
||||||
|
"findings", "conditions", "structures"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Prefetch("images", queryset=SeriesImage.objects.filter(removed=False).order_by("position")),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Prefetch("subspecialty", queryset=Subspecialty.objects.all()),
|
||||||
|
Prefetch("condition", queryset=Condition.objects.all()),
|
||||||
|
Prefetch("presentation", queryset=Presentation.objects.all()),
|
||||||
|
Prefetch("pathological_process", queryset=PathologicalProcess.objects.all()),
|
||||||
|
Prefetch("caseresource_set", queryset=CaseResource.objects.all()),
|
||||||
|
Prefetch("casecollection_set", queryset=CaseCollection.objects.all()),
|
||||||
|
Prefetch("display_sets", queryset=CaseDisplaySet.objects.prefetch_related(
|
||||||
|
Prefetch("findings", queryset=Finding.objects.all()),
|
||||||
|
Prefetch("structures", queryset=Structure.objects.all()),
|
||||||
|
Prefetch("conditions", queryset=Condition.objects.all()),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.get(pk=pk)
|
||||||
|
)
|
||||||
|
case.ordered_series = [sd.series for sd in case.seriesdetail_set.all()]
|
||||||
can_edit = case.check_user_can_edit(request.user)
|
can_edit = case.check_user_can_edit(request.user)
|
||||||
|
|
||||||
# logging.debug(atlas.subspecialty.first().name.all())
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"atlas/case_detail.html",
|
"atlas/case_detail.html",
|
||||||
@@ -215,7 +355,6 @@ def case_detail(request, pk):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||||||
def series_thumbnail_fail(request, pk):
|
def series_thumbnail_fail(request, pk):
|
||||||
@@ -498,6 +637,91 @@ def user_collections(request):
|
|||||||
|
|
||||||
return render(request, "atlas/user_collections.html", {"collections": collections})
|
return render(request, "atlas/user_collections.html", {"collections": collections})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def collection_options(request):
|
||||||
|
if not request.htmx:
|
||||||
|
return Http404
|
||||||
|
|
||||||
|
collections = CaseCollection.objects.filter(author=request.user).order_by("name")
|
||||||
|
html = render_to_string("atlas/partials/collection_options.html", {"collections": collections}, request=request)
|
||||||
|
return HttpResponse(html)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def remove_cases_from_collection(request):
|
||||||
|
if not request.htmx:
|
||||||
|
return Http404
|
||||||
|
case_ids = request.POST.getlist('selection')
|
||||||
|
collection_id = request.POST.get('collection_id')
|
||||||
|
if not case_ids or not collection_id:
|
||||||
|
return HttpResponse("No cases selected or collection ID provided.")
|
||||||
|
collection = get_object_or_404(CaseCollection, pk=collection_id)
|
||||||
|
if request.user not in collection.author.all():
|
||||||
|
return HttpResponse("You do not have permission to remove cases from this collection.")
|
||||||
|
cases = Case.objects.filter(pk__in=case_ids)
|
||||||
|
for case_ in cases:
|
||||||
|
collection.cases.remove(case_)
|
||||||
|
return HttpResponse(f"Removed {len(cases)} cases from collection {collection.name} (refresh to see)")
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_cases_to_collection(request):
|
||||||
|
if not request.htmx:
|
||||||
|
return Http404
|
||||||
|
case_ids = request.POST.getlist('selection')
|
||||||
|
collection_id = request.POST.get('collection_id')
|
||||||
|
if not case_ids or not collection_id:
|
||||||
|
return HttpResponse("No cases selected or collection ID provided.")
|
||||||
|
collection = get_object_or_404(CaseCollection, pk=collection_id)
|
||||||
|
if request.user not in collection.author.all():
|
||||||
|
return HttpResponse("You do not have permission to add cases to this collection.")
|
||||||
|
cases = Case.objects.filter(pk__in=case_ids)
|
||||||
|
for case_ in cases:
|
||||||
|
collection.cases.add(case_)
|
||||||
|
return HttpResponse(f"Added {len(cases)} cases to collection {collection.name} (refresh to see)")
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def set_open_access(request):
|
||||||
|
if not request.htmx:
|
||||||
|
return Http404
|
||||||
|
|
||||||
|
case_ids = request.POST.getlist("selection")
|
||||||
|
if not case_ids:
|
||||||
|
return HttpResponse("No case ID(s) provided.")
|
||||||
|
|
||||||
|
cases = Case.objects.filter(pk__in=case_ids)
|
||||||
|
|
||||||
|
for case in cases:
|
||||||
|
# This will not let give accurate permission errors if the user does not have permission to edit any of the cases
|
||||||
|
# but it is simpler to just check the first case.
|
||||||
|
if not case.check_user_can_edit(request.user):
|
||||||
|
return HttpResponse(f"You do not have permission to set case {case.name} as open access.")
|
||||||
|
|
||||||
|
case.open_access = True
|
||||||
|
case.save()
|
||||||
|
|
||||||
|
return HttpResponse(f"Cases {', '.join([case.title for case in cases])} set to open access (refresh to see changes).")
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def remove_open_access(request):
|
||||||
|
if not request.htmx:
|
||||||
|
return Http404
|
||||||
|
|
||||||
|
case_ids = request.POST.getlist("selection")
|
||||||
|
if not case_ids:
|
||||||
|
return HttpResponse("No case ID(s) provided.")
|
||||||
|
|
||||||
|
cases = Case.objects.filter(pk__in=case_ids)
|
||||||
|
|
||||||
|
for case in cases:
|
||||||
|
# This will not let give accurate permission errors if the user does not have permission to edit any of the cases
|
||||||
|
# but it is simpler to just check the first case.
|
||||||
|
if not case.check_user_can_edit(request.user):
|
||||||
|
return HttpResponse(f"You do not have permission to remove open access from case {case.name}.")
|
||||||
|
|
||||||
|
case.open_access = False
|
||||||
|
case.save()
|
||||||
|
|
||||||
|
return HttpResponse(f"Cases {', '.join([case.title for case in cases])} removed from open access (refresh to see changes).")
|
||||||
|
|
||||||
|
|
||||||
def add_case_to_collection(request, collection_id):
|
def add_case_to_collection(request, collection_id):
|
||||||
if not request.htmx:
|
if not request.htmx:
|
||||||
@@ -507,9 +731,12 @@ def add_case_to_collection(request, collection_id):
|
|||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
case = get_object_or_404(Case, pk=request.POST["case"])
|
case = get_object_or_404(Case, pk=request.POST["case"])
|
||||||
|
if request.user not in collection.author.all():
|
||||||
|
return HttpResponse("You do not have permission to add cases to this collection.")
|
||||||
|
|
||||||
if case in collection.cases.all():
|
if case in collection.cases.all():
|
||||||
return HttpResponse("Case already in collection")
|
return HttpResponse("Case already in collection")
|
||||||
|
|
||||||
print(case)
|
print(case)
|
||||||
collection.add_case(case)
|
collection.add_case(case)
|
||||||
return HttpResponse(f"Case added to collection {collection.name} (refresh to see)")
|
return HttpResponse(f"Case added to collection {collection.name} (refresh to see)")
|
||||||
@@ -800,19 +1027,19 @@ class CaseCollectionUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateVi
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(CaseCollectionUpdate, self).get_context_data(**kwargs)
|
context = super(CaseCollectionUpdate, self).get_context_data(**kwargs)
|
||||||
if self.request.POST:
|
#if self.request.POST:
|
||||||
context["case_formset"] = CaseCollectionCaseFormSet(
|
# context["case_formset"] = CaseCollectionCaseFormSet(
|
||||||
self.request.POST,
|
# self.request.POST,
|
||||||
self.request.FILES,
|
# self.request.FILES,
|
||||||
instance=self.object,
|
# instance=self.object,
|
||||||
form_kwargs={"user": self.request.user, "collection": self.object},
|
# form_kwargs={"user": self.request.user, "collection": self.object},
|
||||||
)
|
# )
|
||||||
context["case_formset"].full_clean()
|
# context["case_formset"].full_clean()
|
||||||
else:
|
#else:
|
||||||
context["case_formset"] = CaseCollectionCaseFormSet(
|
# context["case_formset"] = CaseCollectionCaseFormSet(
|
||||||
instance=self.object,
|
# instance=self.object,
|
||||||
form_kwargs={"user": self.request.user, "collection": self.object},
|
# form_kwargs={"user": self.request.user, "collection": self.object},
|
||||||
)
|
# )
|
||||||
|
|
||||||
context["collection"] = context["casecollection"]
|
context["collection"] = context["casecollection"]
|
||||||
return context
|
return context
|
||||||
@@ -823,6 +1050,7 @@ class CaseCollectionUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateVi
|
|||||||
|
|
||||||
form.instance.author.add(self.request.user.id)
|
form.instance.author.add(self.request.user.id)
|
||||||
|
|
||||||
|
return super().form_valid(form)
|
||||||
context = self.get_context_data(form=form)
|
context = self.get_context_data(form=form)
|
||||||
case_formset = context["case_formset"]
|
case_formset = context["case_formset"]
|
||||||
# logger.debug(formset.is_valid())
|
# logger.debug(formset.is_valid())
|
||||||
@@ -956,11 +1184,11 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(AtlasCreateBase, self).get_context_data(**kwargs)
|
context = super(AtlasCreateBase, self).get_context_data(**kwargs)
|
||||||
if self.request.POST:
|
if self.request.POST:
|
||||||
context["series_formset"] = SeriesFormSet(
|
#context["series_formset"] = SeriesFormSet(
|
||||||
self.request.POST,
|
# self.request.POST,
|
||||||
self.request.FILES,
|
# self.request.FILES,
|
||||||
form_kwargs={"user": self.request.user},
|
# form_kwargs={"user": self.request.user},
|
||||||
)
|
#)
|
||||||
context["casedifferential_formset"] = CaseDifferentialFormSet(
|
context["casedifferential_formset"] = CaseDifferentialFormSet(
|
||||||
self.request.POST, self.request.FILES
|
self.request.POST, self.request.FILES
|
||||||
)
|
)
|
||||||
@@ -969,14 +1197,14 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
|
|||||||
self.request.FILES,
|
self.request.FILES,
|
||||||
form_kwargs={"user": self.request.user},
|
form_kwargs={"user": self.request.user},
|
||||||
)
|
)
|
||||||
context["series_formset"].full_clean()
|
#context["series_formset"].full_clean()
|
||||||
context["casedifferential_formset"].full_clean()
|
context["casedifferential_formset"].full_clean()
|
||||||
context["caseresource_formset"].full_clean()
|
context["caseresource_formset"].full_clean()
|
||||||
else:
|
else:
|
||||||
# context["series_formset"] = SeriesFormSet(instance=Case.objects.filter(author__id=self.request.user.id), queryset=Series.objects.filter(author__id=self.request.user.id))
|
# context["series_formset"] = SeriesFormSet(instance=Case.objects.filter(author__id=self.request.user.id), queryset=Series.objects.filter(author__id=self.request.user.id))
|
||||||
context["series_formset"] = SeriesFormSet(
|
#context["series_formset"] = SeriesFormSet(
|
||||||
form_kwargs={"user": self.request.user}
|
# form_kwargs={"user": self.request.user}
|
||||||
)
|
#)
|
||||||
context["casedifferential_formset"] = CaseDifferentialFormSet()
|
context["casedifferential_formset"] = CaseDifferentialFormSet()
|
||||||
context["caseresource_formset"] = CaseResourceFormSet(
|
context["caseresource_formset"] = CaseResourceFormSet(
|
||||||
form_kwargs={"user": self.request.user}
|
form_kwargs={"user": self.request.user}
|
||||||
@@ -996,17 +1224,17 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
|
|||||||
# CimarAPI().get_study_by_uuid(self.object.cimar_uuid)
|
# CimarAPI().get_study_by_uuid(self.object.cimar_uuid)
|
||||||
|
|
||||||
context = self.get_context_data(form=form)
|
context = self.get_context_data(form=form)
|
||||||
series_formset = context["series_formset"]
|
#series_formset = context["series_formset"]
|
||||||
casedifferential_formset = context["casedifferential_formset"]
|
casedifferential_formset = context["casedifferential_formset"]
|
||||||
caseresource_formset = context["caseresource_formset"]
|
caseresource_formset = context["caseresource_formset"]
|
||||||
if (
|
if (
|
||||||
series_formset.is_valid()
|
#series_formset.is_valid()
|
||||||
and casedifferential_formset.is_valid()
|
casedifferential_formset.is_valid()
|
||||||
and caseresource_formset.is_valid()
|
and caseresource_formset.is_valid()
|
||||||
):
|
):
|
||||||
response = super().form_valid(form)
|
response = super().form_valid(form)
|
||||||
series_formset.instance = self.object
|
#series_formset.instance = self.object
|
||||||
series_formset.save()
|
#series_formset.save()
|
||||||
casedifferential_formset.instance = self.object
|
casedifferential_formset.instance = self.object
|
||||||
casedifferential_formset.save()
|
casedifferential_formset.save()
|
||||||
caseresource_formset.instance = self.object
|
caseresource_formset.instance = self.object
|
||||||
@@ -1023,6 +1251,7 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
|
|||||||
|
|
||||||
|
|
||||||
# @login_required
|
# @login_required
|
||||||
|
# CaseCreate
|
||||||
class AtlasCreate(AtlasCreateBase, CreateView):
|
class AtlasCreate(AtlasCreateBase, CreateView):
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
if "pk" in self.kwargs:
|
if "pk" in self.kwargs:
|
||||||
@@ -1041,6 +1270,7 @@ class AtlasCreate(AtlasCreateBase, CreateView):
|
|||||||
# return self.initial
|
# return self.initial
|
||||||
|
|
||||||
|
|
||||||
|
# CaseUpdate
|
||||||
class AtlasUpdate(
|
class AtlasUpdate(
|
||||||
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
|
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
|
||||||
):
|
):
|
||||||
@@ -1052,6 +1282,25 @@ class AtlasUpdate(
|
|||||||
# #initial = {'date_of_death': '05/01/2018'}
|
# #initial = {'date_of_death': '05/01/2018'}
|
||||||
# exclude = [ 'created_date', 'published_date' ]
|
# exclude = [ 'created_date', 'published_date' ]
|
||||||
|
|
||||||
|
|
||||||
|
#def get_object(self, queryset=None):
|
||||||
|
# # Prefetch all related objects needed for the form and formsets
|
||||||
|
# qs = self.model.objects.select_related(
|
||||||
|
# # Add all FK fields here
|
||||||
|
# ).prefetch_related(
|
||||||
|
# "series__examination",
|
||||||
|
# "series__plane",
|
||||||
|
# "series__contrast",
|
||||||
|
# "series__images",
|
||||||
|
# "subspecialty",
|
||||||
|
# "condition",
|
||||||
|
# "presentation",
|
||||||
|
# "pathological_process",
|
||||||
|
# "differentialcase",
|
||||||
|
# "caseresource_set",
|
||||||
|
# )
|
||||||
|
# return qs.get(pk=self.kwargs["pk"])
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
kwargs = super(AtlasUpdate, self).get_form_kwargs()
|
kwargs = super(AtlasUpdate, self).get_form_kwargs()
|
||||||
kwargs.update({"user": self.request.user})
|
kwargs.update({"user": self.request.user})
|
||||||
@@ -1060,12 +1309,12 @@ class AtlasUpdate(
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(AtlasUpdate, self).get_context_data(**kwargs)
|
context = super(AtlasUpdate, self).get_context_data(**kwargs)
|
||||||
if self.request.POST:
|
if self.request.POST:
|
||||||
context["series_formset"] = SeriesFormSet(
|
#context["series_formset"] = SeriesFormSet(
|
||||||
self.request.POST,
|
# self.request.POST,
|
||||||
self.request.FILES,
|
# self.request.FILES,
|
||||||
instance=self.object,
|
# instance=self.object,
|
||||||
form_kwargs={"user": self.request.user},
|
# form_kwargs={"user": self.request.user},
|
||||||
)
|
#)
|
||||||
context["casedifferential_formset"] = CaseDifferentialFormSet(
|
context["casedifferential_formset"] = CaseDifferentialFormSet(
|
||||||
self.request.POST, self.request.FILES, instance=self.object
|
self.request.POST, self.request.FILES, instance=self.object
|
||||||
)
|
)
|
||||||
@@ -1075,13 +1324,13 @@ class AtlasUpdate(
|
|||||||
instance=self.object,
|
instance=self.object,
|
||||||
form_kwargs={"user": self.request.user},
|
form_kwargs={"user": self.request.user},
|
||||||
)
|
)
|
||||||
context["series_formset"].full_clean()
|
#context["series_formset"].full_clean()
|
||||||
context["casedifferential_formset"].full_clean()
|
context["casedifferential_formset"].full_clean()
|
||||||
context["caseresource_formset"].full_clean()
|
context["caseresource_formset"].full_clean()
|
||||||
else:
|
else:
|
||||||
context["series_formset"] = SeriesFormSet(
|
#context["series_formset"] = SeriesFormSet(
|
||||||
instance=self.object, form_kwargs={"user": self.request.user}
|
# instance=self.object, form_kwargs={"user": self.request.user}
|
||||||
)
|
#)
|
||||||
context["casedifferential_formset"] = CaseDifferentialFormSet(
|
context["casedifferential_formset"] = CaseDifferentialFormSet(
|
||||||
instance=self.object
|
instance=self.object
|
||||||
)
|
)
|
||||||
@@ -1098,18 +1347,18 @@ class AtlasUpdate(
|
|||||||
form.instance.author.add(self.request.user.id)
|
form.instance.author.add(self.request.user.id)
|
||||||
|
|
||||||
context = self.get_context_data(form=form)
|
context = self.get_context_data(form=form)
|
||||||
series_formset = context["series_formset"]
|
#series_formset = context["series_formset"]
|
||||||
casedifferential_formset = context["casedifferential_formset"]
|
casedifferential_formset = context["casedifferential_formset"]
|
||||||
caseresource_formset = context["caseresource_formset"]
|
caseresource_formset = context["caseresource_formset"]
|
||||||
# logger.debug(formset.is_valid())
|
# logger.debug(formset.is_valid())
|
||||||
if (
|
if (
|
||||||
series_formset.is_valid()
|
#series_formset.is_valid()
|
||||||
and casedifferential_formset.is_valid()
|
casedifferential_formset.is_valid()
|
||||||
and caseresource_formset.is_valid()
|
and caseresource_formset.is_valid()
|
||||||
):
|
):
|
||||||
response = super().form_valid(form)
|
response = super().form_valid(form)
|
||||||
series_formset.instance = self.object
|
#series_formset.instance = self.object
|
||||||
series_formset.save()
|
#series_formset.save()
|
||||||
casedifferential_formset.instance = self.object
|
casedifferential_formset.instance = self.object
|
||||||
casedifferential_formset.save()
|
casedifferential_formset.save()
|
||||||
caseresource_formset.instance = self.object
|
caseresource_formset.instance = self.object
|
||||||
@@ -1119,6 +1368,110 @@ class AtlasUpdate(
|
|||||||
else:
|
else:
|
||||||
return super().form_invalid(form)
|
return super().form_invalid(form)
|
||||||
|
|
||||||
|
class CollectionCaseUpdate(
|
||||||
|
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView):
|
||||||
|
model = CaseCollection
|
||||||
|
form_class = CaseCollectionUpdateCaseForm
|
||||||
|
template_name = "atlas/casecollection_update_case.html"
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs.update({"user": self.request.user, "collection": self.object})
|
||||||
|
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
if self.request.POST:
|
||||||
|
context["case_formset"] = CaseCollectionCaseFormSet(
|
||||||
|
self.request.POST,
|
||||||
|
self.request.FILES,
|
||||||
|
instance=self.object,
|
||||||
|
form_kwargs={"user": self.request.user, "collection": self.object},
|
||||||
|
)
|
||||||
|
context["case_formset"].full_clean()
|
||||||
|
else:
|
||||||
|
context["case_formset"] = CaseCollectionCaseFormSet(
|
||||||
|
instance=self.object,
|
||||||
|
form_kwargs={"user": self.request.user, "collection": self.object},
|
||||||
|
)
|
||||||
|
context["collection"] = context["casecollection"]
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
context = self.get_context_data(form=form)
|
||||||
|
case_formset = context["case_formset"]
|
||||||
|
if case_formset.is_valid():
|
||||||
|
case_formset.instance = self.object
|
||||||
|
case_formset.save()
|
||||||
|
return super().form_valid(form)
|
||||||
|
else:
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class AtlasSeriesUpdate(
|
||||||
|
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
|
||||||
|
):
|
||||||
|
model = Case
|
||||||
|
form_class = CaseUpdateSeriesForm
|
||||||
|
template_name = "atlas/case_update_series.html"
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs.update({"user": self.request.user})
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
is_atlas_editor = self.request.user.groups.filter(name="atlas_editor").exists() or self.request.user.is_superuser
|
||||||
|
form_kwargs = {"user": self.request.user, "is_atlas_editor": is_atlas_editor}
|
||||||
|
|
||||||
|
if not is_atlas_editor:
|
||||||
|
queryset = Series.objects.filter(author__id=self.request.user.id)
|
||||||
|
else:
|
||||||
|
queryset = Series.objects.all()
|
||||||
|
queryset = queryset.select_related("modality").prefetch_related(
|
||||||
|
"examination", "plane", "contrast",
|
||||||
|
Prefetch("images", queryset=SeriesImage.objects.filter(removed=False).order_by("position"))
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.request.POST:
|
||||||
|
context["series_formset"] = SeriesFormSet(
|
||||||
|
self.request.POST,
|
||||||
|
self.request.FILES,
|
||||||
|
queryset=queryset,
|
||||||
|
instance=self.object,
|
||||||
|
form_kwargs=form_kwargs,
|
||||||
|
)
|
||||||
|
context["series_formset"].full_clean()
|
||||||
|
else:
|
||||||
|
context["series_formset"] = SeriesFormSet(
|
||||||
|
queryset=queryset,
|
||||||
|
instance=self.object,
|
||||||
|
form_kwargs=form_kwargs,
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
context = self.get_context_data(form=form)
|
||||||
|
series_formset = context["series_formset"]
|
||||||
|
if series_formset.is_valid():
|
||||||
|
series_formset.instance = self.object
|
||||||
|
series_formset.save()
|
||||||
|
return super().form_valid(form)
|
||||||
|
else:
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
qs = Case.objects.select_related().prefetch_related(
|
||||||
|
"seriesdetail_set__series__modality",
|
||||||
|
"seriesdetail_set__series__examination",
|
||||||
|
"seriesdetail_set__series__plane",
|
||||||
|
"seriesdetail_set__series__contrast",
|
||||||
|
"seriesdetail_set__series__images",
|
||||||
|
)
|
||||||
|
return qs.get(pk=self.kwargs["pk"])
|
||||||
|
|
||||||
def add_collection_to_case_form(request, case_id):
|
def add_collection_to_case_form(request, case_id):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -1230,7 +1583,7 @@ def get_examination_id(request):
|
|||||||
return HttpResponse("/")
|
return HttpResponse("/")
|
||||||
|
|
||||||
|
|
||||||
class CaseView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
class CaseView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||||||
model = Case
|
model = Case
|
||||||
table_class = CaseTable
|
table_class = CaseTable
|
||||||
template_name = "atlas/case_view.html"
|
template_name = "atlas/case_view.html"
|
||||||
@@ -1238,7 +1591,7 @@ class CaseView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
|||||||
filterset_class = CaseFilter
|
filterset_class = CaseFilter
|
||||||
|
|
||||||
|
|
||||||
class SeriesView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
class SeriesView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||||||
model = Series
|
model = Series
|
||||||
table_class = SeriesTable
|
table_class = SeriesTable
|
||||||
template_name = "atlas/series_view.html"
|
template_name = "atlas/series_view.html"
|
||||||
@@ -1246,7 +1599,7 @@ class SeriesView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
|||||||
filterset_class = SeriesFilter
|
filterset_class = SeriesFilter
|
||||||
|
|
||||||
|
|
||||||
class ConditionView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
class ConditionView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||||||
model = Condition
|
model = Condition
|
||||||
table_class = ConditionTable
|
table_class = ConditionTable
|
||||||
template_name = "atlas/view.html"
|
template_name = "atlas/view.html"
|
||||||
@@ -1254,7 +1607,7 @@ class ConditionView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
|||||||
filterset_class = ConditionFilter
|
filterset_class = ConditionFilter
|
||||||
|
|
||||||
|
|
||||||
class QuestionSchemaView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
class QuestionSchemaView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||||||
model = QuestionSchema
|
model = QuestionSchema
|
||||||
table_class = QuestionSchemaTable
|
table_class = QuestionSchemaTable
|
||||||
template_name = "atlas/schema_table_view.html"
|
template_name = "atlas/schema_table_view.html"
|
||||||
@@ -1262,7 +1615,7 @@ class QuestionSchemaView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
|||||||
filterset_class = QuestionSchemaFilter
|
filterset_class = QuestionSchemaFilter
|
||||||
|
|
||||||
|
|
||||||
class SubspecialtyView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
class SubspecialtyView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||||||
model = Subspecialty
|
model = Subspecialty
|
||||||
table_class = SubspecialtyTable
|
table_class = SubspecialtyTable
|
||||||
template_name = "atlas/view.html"
|
template_name = "atlas/view.html"
|
||||||
@@ -1270,7 +1623,7 @@ class SubspecialtyView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
|||||||
filterset_class = SubspecialtyFilter
|
filterset_class = SubspecialtyFilter
|
||||||
|
|
||||||
|
|
||||||
class PresentationView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
class PresentationView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||||||
model = Presentation
|
model = Presentation
|
||||||
table_class = PresentationTable
|
table_class = PresentationTable
|
||||||
template_name = "atlas/view.html"
|
template_name = "atlas/view.html"
|
||||||
@@ -1278,7 +1631,7 @@ class PresentationView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
|||||||
filterset_class = PresentationFilter
|
filterset_class = PresentationFilter
|
||||||
|
|
||||||
|
|
||||||
class PathologicalProcessView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
class PathologicalProcessView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||||||
model = PathologicalProcess
|
model = PathologicalProcess
|
||||||
table_class = PathologicalProcessTable
|
table_class = PathologicalProcessTable
|
||||||
template_name = "atlas/view.html"
|
template_name = "atlas/view.html"
|
||||||
@@ -1286,7 +1639,7 @@ class PathologicalProcessView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
|||||||
filterset_class = PathologicalProcessFilter
|
filterset_class = PathologicalProcessFilter
|
||||||
|
|
||||||
|
|
||||||
class StructureView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
class StructureView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||||||
model = Structure
|
model = Structure
|
||||||
table_class = StructureTable
|
table_class = StructureTable
|
||||||
template_name = "atlas/view.html"
|
template_name = "atlas/view.html"
|
||||||
@@ -1294,7 +1647,7 @@ class StructureView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
|||||||
filterset_class = StructureFilter
|
filterset_class = StructureFilter
|
||||||
|
|
||||||
|
|
||||||
class FindingView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
class FindingView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||||||
model = Finding
|
model = Finding
|
||||||
table_class = FindingTable
|
table_class = FindingTable
|
||||||
template_name = "atlas/view.html"
|
template_name = "atlas/view.html"
|
||||||
@@ -1305,7 +1658,7 @@ class FindingView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
|||||||
@login_required
|
@login_required
|
||||||
def case_order_dicom(request, pk):
|
def case_order_dicom(request, pk):
|
||||||
if request.htmx:
|
if request.htmx:
|
||||||
case = get_object_or_404(Case, pk=pk)
|
case: Case = get_object_or_404(Case, pk=pk)
|
||||||
|
|
||||||
fail = []
|
fail = []
|
||||||
# TODO: don't try to order non stack series
|
# TODO: don't try to order non stack series
|
||||||
@@ -1314,9 +1667,9 @@ def case_order_dicom(request, pk):
|
|||||||
if request.user in series.get_author_objects():
|
if request.user in series.get_author_objects():
|
||||||
series.order_by_dicom()
|
series.order_by_dicom()
|
||||||
else:
|
else:
|
||||||
fail.append(series.pk)
|
fail.append(f"{series.pk}: invalid permission")
|
||||||
except:
|
except:
|
||||||
fail.append(series.pk)
|
fail.append(f"{series.pk}: error")
|
||||||
|
|
||||||
if fail:
|
if fail:
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
@@ -1349,12 +1702,12 @@ def image_diff(request):
|
|||||||
first: SeriesImage = get_object_or_404(SeriesImage, pk=image_ids[0])
|
first: SeriesImage = get_object_or_404(SeriesImage, pk=image_ids[0])
|
||||||
second: SeriesImage = get_object_or_404(SeriesImage, pk=image_ids[1])
|
second: SeriesImage = get_object_or_404(SeriesImage, pk=image_ids[1])
|
||||||
|
|
||||||
first_ds = pydicom.read_file(first.image.file)
|
first_ds = pydicom.dcmread(first.image.file)
|
||||||
# del first_ds.PixelData
|
# del first_ds.PixelData
|
||||||
# j1 = first_ds.to_json_dict()
|
# j1 = first_ds.to_json_dict()
|
||||||
f = print_dicom(first_ds, join=False)
|
f = print_dicom(first_ds, join=False)
|
||||||
|
|
||||||
second_ds = pydicom.read_file(second.image.file)
|
second_ds = pydicom.dcmread(second.image.file)
|
||||||
# del second_ds.PixelData
|
# del second_ds.PixelData
|
||||||
# j2 = second_ds.to_json_dict()
|
# j2 = second_ds.to_json_dict()
|
||||||
s = print_dicom(second_ds, join=False)
|
s = print_dicom(second_ds, join=False)
|
||||||
@@ -1371,7 +1724,7 @@ def image_diff(request):
|
|||||||
ds_set = []
|
ds_set = []
|
||||||
for pk in image_ids:
|
for pk in image_ids:
|
||||||
series_image = get_object_or_404(SeriesImage, pk=pk)
|
series_image = get_object_or_404(SeriesImage, pk=pk)
|
||||||
ds_set.append(pydicom.read_file(series_image.image.file))
|
ds_set.append(pydicom.dcmread(series_image.image.file))
|
||||||
|
|
||||||
dict_diff = pprint.pformat(compare_dicom_datasets(*ds_set))
|
dict_diff = pprint.pformat(compare_dicom_datasets(*ds_set))
|
||||||
return HttpResponse(f"<pre>{dict_diff}</pre>")
|
return HttpResponse(f"<pre>{dict_diff}</pre>")
|
||||||
@@ -1611,22 +1964,39 @@ class CollectionView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
|||||||
def collection_viva(request, pk):
|
def collection_viva(request, pk):
|
||||||
collection = get_object_or_404(CaseCollection, pk=pk)
|
collection = get_object_or_404(CaseCollection, pk=pk)
|
||||||
|
|
||||||
cases = (
|
#cases = (
|
||||||
collection.cases.all()
|
# collection.cases.all()
|
||||||
.order_by("casedetail__sort_order")
|
# .order_by("casedetail__sort_order")
|
||||||
|
# .prefetch_related(
|
||||||
|
# "series",
|
||||||
|
# "series__images",
|
||||||
|
# "series__contrast",
|
||||||
|
# "series__plane",
|
||||||
|
# "series__examination",
|
||||||
|
# )
|
||||||
|
#)
|
||||||
|
|
||||||
|
casedetails = (
|
||||||
|
CaseDetail.objects
|
||||||
|
.filter(collection=collection)
|
||||||
|
.select_related("case")
|
||||||
|
.order_by("sort_order")
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
"series",
|
# Prefetch series and their images directly on the case
|
||||||
"series__images",
|
"case__series",
|
||||||
"series__contrast",
|
"case__series__modality",
|
||||||
"series__plane",
|
"case__series__examination",
|
||||||
"series__examination",
|
"case__series__plane",
|
||||||
|
"case__series__contrast",
|
||||||
|
"case__series__images",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"atlas/collection_viva.html",
|
"atlas/collection_viva.html",
|
||||||
{"collection": collection, "cases": cases, "can_edit": True},
|
{"collection": collection, "casedetails": casedetails, "can_edit": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1662,14 +2032,18 @@ def collection_detail(request, pk):
|
|||||||
collection = get_object_or_404(CaseCollection, pk=pk)
|
collection = get_object_or_404(CaseCollection, pk=pk)
|
||||||
|
|
||||||
# cases = collection.cases.all().order_by("casedetail__sort_order")
|
# cases = collection.cases.all().order_by("casedetail__sort_order")
|
||||||
casedetails = CaseDetail.objects.filter(collection=collection).order_by(
|
casedetails = (
|
||||||
"sort_order"
|
CaseDetail.objects
|
||||||
|
.filter(collection=collection)
|
||||||
|
.select_related("case")
|
||||||
|
.annotate(series_count=Count("case__seriesdetail"))
|
||||||
|
.order_by("sort_order")
|
||||||
)
|
)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"atlas/collection_detail.html",
|
"atlas/collection_detail.html",
|
||||||
{"collection": collection, "casesdetails": casedetails, "can_edit": True},
|
{"collection": collection, "casesdetails": casedetails, "can_edit": True, "exam": collection},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1727,7 +2101,9 @@ def collection_history_user(request, exam_id: int, user_pk: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def collection_take_start(request, pk):
|
|
||||||
|
|
||||||
|
def collection_take_start(request, pk, cid=None, passcode=None):
|
||||||
"""The starting page for taking / reviewing a case collection
|
"""The starting page for taking / reviewing a case collection
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1737,13 +2113,18 @@ def collection_take_start(request, pk):
|
|||||||
Returns:
|
Returns:
|
||||||
_type_: _description_
|
_type_: _description_
|
||||||
"""
|
"""
|
||||||
|
print(f"Collection take start: {pk}, cid: {cid}, passcode: {passcode}")
|
||||||
collection = get_object_or_404(CaseCollection, pk=pk)
|
collection = get_object_or_404(CaseCollection, pk=pk)
|
||||||
|
print(f"Collection: {collection}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
collection.check_user_can_take(cid, passcode, request.user)
|
||||||
|
valid_user = True
|
||||||
|
except Http404:
|
||||||
|
valid_user = False
|
||||||
|
#valid_user = collection.check_logged_in_user(request)
|
||||||
|
|
||||||
valid_user = collection.check_logged_in_user(request)
|
|
||||||
|
|
||||||
# for now we only allow registered users....
|
|
||||||
if not valid_user:
|
|
||||||
raise PermissionDenied
|
|
||||||
|
|
||||||
cid_exam = None
|
cid_exam = None
|
||||||
if collection.collection_type == "REV" or valid_user:
|
if collection.collection_type == "REV" or valid_user:
|
||||||
@@ -3084,6 +3465,36 @@ def remove_case_from_collection(request, case_pk, collection_pk):
|
|||||||
return HttpResponse(f"Case removed from collection: {collection.name}")
|
return HttpResponse(f"Case removed from collection: {collection.name}")
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def remove_selected_series_from_case(request, case_pk):
|
||||||
|
ids = request.POST.getlist('series-ids')
|
||||||
|
case = Case.objects.get(pk=case_pk)
|
||||||
|
|
||||||
|
if not case.check_user_can_edit(request.user):
|
||||||
|
return HttpResponse("You do not have permission to edit this case.", status=403)
|
||||||
|
|
||||||
|
|
||||||
|
if not ids:
|
||||||
|
return HttpResponse("No series selected for removal.")
|
||||||
|
|
||||||
|
# Remove the association (for a through model)
|
||||||
|
SeriesDetail.objects.filter(case=case, series_id__in=ids).delete()
|
||||||
|
return HttpResponse(f"Series removed from case.")
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def reorder_series(request, case_pk):
|
||||||
|
case = get_object_or_404(Case, pk=case_pk)
|
||||||
|
if not case.check_user_can_edit(request.user):
|
||||||
|
return HttpResponse("You do not have permission to edit this case.", status=403)
|
||||||
|
# Get new order from POST
|
||||||
|
order = []
|
||||||
|
for key in sorted([k for k in request.POST if k.startswith("series_order_")]):
|
||||||
|
order.append(int(request.POST[key]))
|
||||||
|
# Update sort_order in SeriesDetail
|
||||||
|
for idx, series_id in enumerate(order):
|
||||||
|
SeriesDetail.objects.filter(case=case, series_id=series_id).update(sort_order=idx)
|
||||||
|
return redirect(request.META.get("HTTP_REFERER", "/"))
|
||||||
|
|
||||||
def linked_cases_overview(request, case_id):
|
def linked_cases_overview(request, case_id):
|
||||||
"""
|
"""
|
||||||
View to display an overview of linked cases via the previous_case field.
|
View to display an overview of linked cases via the previous_case field.
|
||||||
@@ -3181,4 +3592,44 @@ def seriesfinding_migration_status(request):
|
|||||||
})
|
})
|
||||||
return render(request, "atlas/seriesfinding_migration_status.html", {
|
return render(request, "atlas/seriesfinding_migration_status.html", {
|
||||||
"findings_status": findings_status
|
"findings_status": findings_status
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def series_bulk_delete(request):
|
||||||
|
if request.method == "POST":
|
||||||
|
ids = request.POST.getlist("series_ids")
|
||||||
|
# Only delete series the user has permission to delete
|
||||||
|
series_to_delete = Series.objects.filter(pk__in=ids)
|
||||||
|
deleted_ids = []
|
||||||
|
for series in series_to_delete:
|
||||||
|
if series.check_user_can_edit(request.user):
|
||||||
|
series.delete()
|
||||||
|
deleted_ids.append(series.pk)
|
||||||
|
# Optionally, you could add a message here about which were deleted
|
||||||
|
return redirect('atlas:series_view')
|
||||||
|
|
||||||
|
@user_is_collection_author_or_atlas_editor
|
||||||
|
@csrf_exempt # Only if you have CSRF issues; otherwise, keep CSRF protection
|
||||||
|
def collection_case_displaysetup(request, collection_id, case_id):
|
||||||
|
"""
|
||||||
|
View to set up the display options for a specific case in a collection.
|
||||||
|
This can include setting which series to display, annotations, etc.
|
||||||
|
"""
|
||||||
|
case_detail = get_object_or_404(CaseDetail, case__pk=case_id, collection__pk=collection_id)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
data = json.loads(request.body.decode("utf-8"))
|
||||||
|
if data.get("reset_viewerstate"):
|
||||||
|
case_detail.default_viewerstate = None
|
||||||
|
case_detail.save()
|
||||||
|
return HttpResponse("<span class='text-success'>Viewer state reset to default.</span>")
|
||||||
|
viewerstate = data.get("viewerstate")
|
||||||
|
if viewerstate is not None:
|
||||||
|
case_detail.default_viewerstate = viewerstate
|
||||||
|
case_detail.save()
|
||||||
|
return HttpResponse("<span class='text-success'>Viewer state saved.</span>")
|
||||||
|
else:
|
||||||
|
return HttpResponse("<span class='text-danger'>No viewer state provided.</span>", status=400)
|
||||||
|
|
||||||
|
return render(request, "atlas/collection_case_displaysetup.html", {
|
||||||
|
"case_detail": case_detail
|
||||||
|
})
|
||||||
|
|||||||
+12
-1
@@ -54,6 +54,10 @@ from rapids.models import Exam as RapidsExam
|
|||||||
from rapids.models import Rapid
|
from rapids.models import Rapid
|
||||||
from rapids.models import Answer as RapidAnswer
|
from rapids.models import Answer as RapidAnswer
|
||||||
|
|
||||||
|
from shorts.models import UserAnswer as ShortsUserAnswer
|
||||||
|
from shorts.models import Exam as ShortsExam
|
||||||
|
from shorts.models import Question as ShortsQuestion
|
||||||
|
|
||||||
from longs.models import UserAnswer as LongsUserAnswer
|
from longs.models import UserAnswer as LongsUserAnswer
|
||||||
from longs.models import Exam as LongsExam
|
from longs.models import Exam as LongsExam
|
||||||
from longs.models import Long
|
from longs.models import Long
|
||||||
@@ -70,6 +74,7 @@ from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div
|
|||||||
from crispy_forms.bootstrap import Accordion, AccordionGroup
|
from crispy_forms.bootstrap import Accordion, AccordionGroup
|
||||||
|
|
||||||
from dal import autocomplete
|
from dal import autocomplete
|
||||||
|
from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register
|
||||||
|
|
||||||
|
|
||||||
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
|
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
|
||||||
@@ -497,6 +502,7 @@ class UserGroupExamForm(ModelForm):
|
|||||||
GROUP_TYPES = [
|
GROUP_TYPES = [
|
||||||
"anatomy_user_user_groups",
|
"anatomy_user_user_groups",
|
||||||
"rapid_user_user_groups",
|
"rapid_user_user_groups",
|
||||||
|
"shorts_user_user_groups",
|
||||||
"longs_user_user_groups",
|
"longs_user_user_groups",
|
||||||
"physics_user_user_groups",
|
"physics_user_user_groups",
|
||||||
"sba_user_user_groups",
|
"sba_user_user_groups",
|
||||||
@@ -537,6 +543,11 @@ class UserGroupExamForm(ModelForm):
|
|||||||
queryset=RapidsExam.objects.filter(archive=False),
|
queryset=RapidsExam.objects.filter(archive=False),
|
||||||
widget=FilteredSelectMultiple(verbose_name="Rapid Exams", is_stacked=False),
|
widget=FilteredSelectMultiple(verbose_name="Rapid Exams", is_stacked=False),
|
||||||
)
|
)
|
||||||
|
self.fields["shorts_user_user_groups"] = ModelMultipleChoiceField(
|
||||||
|
required=False,
|
||||||
|
queryset=ShortsExam.objects.filter(archive=False),
|
||||||
|
widget=FilteredSelectMultiple(verbose_name="Shorts Exams", is_stacked=False),
|
||||||
|
)
|
||||||
self.fields["longs_user_user_groups"] = ModelMultipleChoiceField(
|
self.fields["longs_user_user_groups"] = ModelMultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=LongsExam.objects.filter(archive=False),
|
queryset=LongsExam.objects.filter(archive=False),
|
||||||
@@ -736,6 +747,7 @@ class SupervisorForm(ModelForm):
|
|||||||
class ExamCollectionForm(ModelForm):
|
class ExamCollectionForm(ModelForm):
|
||||||
GROUP_TYPES = (
|
GROUP_TYPES = (
|
||||||
("Rapid Exams", "rapids_exams", RapidsExam),
|
("Rapid Exams", "rapids_exams", RapidsExam),
|
||||||
|
("Shorts Exams", "shorts_exams", ShortsExam),
|
||||||
("Long Exams", "longs_exams", LongsExam),
|
("Long Exams", "longs_exams", LongsExam),
|
||||||
("SBA Exams", "sbas_exams", SbasExam),
|
("SBA Exams", "sbas_exams", SbasExam),
|
||||||
("Physic Exams", "physics_exams", PhysicsExam),
|
("Physic Exams", "physics_exams", PhysicsExam),
|
||||||
@@ -808,7 +820,6 @@ class ExamCollectionCloneForm(ModelForm):
|
|||||||
model = ExamCollection
|
model = ExamCollection
|
||||||
fields = ("name", "date")
|
fields = ("name", "date")
|
||||||
|
|
||||||
from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register
|
|
||||||
|
|
||||||
class UserAutocomplete(ModelAutocomplete):
|
class UserAutocomplete(ModelAutocomplete):
|
||||||
model = User
|
model = User
|
||||||
|
|||||||
@@ -62,3 +62,22 @@ class AuthorMixin():
|
|||||||
def remove_author(self, user: User):
|
def remove_author(self, user: User):
|
||||||
"""Remove an author from the object"""
|
"""Remove an author from the object"""
|
||||||
self.author.remove(user)
|
self.author.remove(user)
|
||||||
|
|
||||||
|
|
||||||
|
class UserConfigurablePaginationMixin:
|
||||||
|
default_per_page = 25
|
||||||
|
allowed_per_page = [25, 50, 100, 250, 500]
|
||||||
|
|
||||||
|
def get_table_pagination(self, table):
|
||||||
|
try:
|
||||||
|
per_page = int(self.request.GET.get("per_page", self.default_per_page))
|
||||||
|
if per_page not in self.allowed_per_page:
|
||||||
|
per_page = self.default_per_page
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
per_page = self.default_per_page
|
||||||
|
return {"per_page": per_page}
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["page_sizes"] = [25, 50, 100, 250, 500]
|
||||||
|
return context
|
||||||
+45
-50
@@ -58,6 +58,17 @@ import requests
|
|||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
|
||||||
|
USER_EXAM_TYPES = (
|
||||||
|
("Physics", "user_physics_exams"),
|
||||||
|
("Rapids", "user_rapid_exams"),
|
||||||
|
("Shorts", "user_shorts_exams"),
|
||||||
|
("SBAs", "user_sba_exams"),
|
||||||
|
("Anatomy", "user_anatomy_exams"),
|
||||||
|
("Longs", "user_longs_exams"),
|
||||||
|
# ("CaseCollection", "user_casecollection_exams"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_pretty_json(data):
|
def get_pretty_json(data):
|
||||||
"""Function to display pretty version of our data"""
|
"""Function to display pretty version of our data"""
|
||||||
if not data:
|
if not data:
|
||||||
@@ -279,7 +290,7 @@ class SeriesImageBase(models.Model):
|
|||||||
# The first is a md5 hash of the pixel array data
|
# The first is a md5 hash of the pixel array data
|
||||||
# this is used because I can't get the direct pixel data to match from cornerstone
|
# this is used because I can't get the direct pixel data to match from cornerstone
|
||||||
# (and struggle to get blake3 working in the browser)
|
# (and struggle to get blake3 working in the browser)
|
||||||
image_md5_hash = models.CharField(max_length=32, null=True, blank=True)
|
image_md5_hash = models.CharField(max_length=64, null=True, blank=True)
|
||||||
|
|
||||||
# The second is a blake3 hash of the direct pixel data
|
# The second is a blake3 hash of the direct pixel data
|
||||||
# This is much faster when using pydicom
|
# This is much faster when using pydicom
|
||||||
@@ -316,14 +327,14 @@ class SeriesImageBase(models.Model):
|
|||||||
|
|
||||||
def get_dicom_json(self):
|
def get_dicom_json(self):
|
||||||
try:
|
try:
|
||||||
json = pydicom.read_file(self.image).to_json()
|
json = pydicom.dcmread(self.image).to_json()
|
||||||
except pydicom.errors.InvalidDicomError:
|
except pydicom.errors.InvalidDicomError:
|
||||||
return {}
|
return {}
|
||||||
return json
|
return json
|
||||||
|
|
||||||
def get_dicom_info(self):
|
def get_dicom_info(self):
|
||||||
try:
|
try:
|
||||||
info = pretty_print_dicom(pydicom.read_file(self.image))
|
info = pretty_print_dicom(pydicom.dcmread(self.image))
|
||||||
except pydicom.errors.InvalidDicomError:
|
except pydicom.errors.InvalidDicomError:
|
||||||
info = "File is not a dicom."
|
info = "File is not a dicom."
|
||||||
return info
|
return info
|
||||||
@@ -496,7 +507,12 @@ class SeriesBase(models.Model):
|
|||||||
return format_html('", "'.join(images))
|
return format_html('", "'.join(images))
|
||||||
|
|
||||||
def get_thumbnail(self, recreate=False, fail_loudly=False):
|
def get_thumbnail(self, recreate=False, fail_loudly=False):
|
||||||
images = self.images.filter(removed=False)
|
# Use prefetched images if available
|
||||||
|
images = getattr(self, '_prefetched_objects_cache', {}).get('images')
|
||||||
|
if images is not None:
|
||||||
|
images = [img for img in images if not img.removed]
|
||||||
|
else:
|
||||||
|
images = self.images.filter(removed=False)
|
||||||
|
|
||||||
if len(images) < 1:
|
if len(images) < 1:
|
||||||
return "No images", 0
|
return "No images", 0
|
||||||
@@ -568,48 +584,35 @@ class SeriesBase(models.Model):
|
|||||||
n = n + 1
|
n = n + 1
|
||||||
|
|
||||||
def order_by_dicom(self, field="SliceLocation"):
|
def order_by_dicom(self, field="SliceLocation"):
|
||||||
print(f"Ordering by dicom field {field}")
|
"""
|
||||||
|
Orders images in the series by the given DICOM field (default: SliceLocation).
|
||||||
|
Only keeps minimal data in memory and uses bulk_update for efficiency.
|
||||||
|
"""
|
||||||
images = self.images.filter(removed=False)
|
images = self.images.filter(removed=False)
|
||||||
|
sortable = []
|
||||||
|
|
||||||
|
for img in images:
|
||||||
|
try:
|
||||||
|
ds = pydicom.dcmread(img.image.path, stop_before_pixels=True)
|
||||||
|
value = getattr(ds, field, None)
|
||||||
|
if value is not None:
|
||||||
|
sortable.append((value, img))
|
||||||
|
except Exception:
|
||||||
|
continue # Skip unreadable or invalid DICOMs
|
||||||
|
|
||||||
files = []
|
if not sortable:
|
||||||
|
|
||||||
for i in images:
|
|
||||||
files.append((i, pydicom.dcmread(i.image.path)))
|
|
||||||
|
|
||||||
print("images to order")
|
|
||||||
print(files)
|
|
||||||
|
|
||||||
# print("file count: {}".format(len(files)))
|
|
||||||
|
|
||||||
# skip files with no SliceLocation (eg scout views)
|
|
||||||
slices = []
|
|
||||||
map = {}
|
|
||||||
skipcount = 0
|
|
||||||
for i, f in files:
|
|
||||||
if hasattr(f, field):
|
|
||||||
slices.append(f)
|
|
||||||
# map[f.SliceLocation] = i
|
|
||||||
map[f[field].value] = i
|
|
||||||
else:
|
|
||||||
skipcount = skipcount + 1
|
|
||||||
|
|
||||||
print("skipped, no {}: {}".format(field, skipcount))
|
|
||||||
|
|
||||||
if not slices:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# ensure they are in the correct order
|
# Sort by the DICOM field value
|
||||||
slices = sorted(slices, key=lambda s: s[field].value)
|
sortable.sort(key=lambda x: x[0])
|
||||||
|
|
||||||
# print(slices)
|
# Assign new positions and collect for bulk update
|
||||||
n = 1
|
for idx, (value, img) in enumerate(sortable, start=1):
|
||||||
for f in slices:
|
img.position = idx
|
||||||
i = map[f[field].value]
|
|
||||||
i.position = n
|
# Bulk update all changed images
|
||||||
print(f"Setting position {n} for {i}")
|
SeriesImage = self.images.model
|
||||||
i.save()
|
SeriesImage.objects.bulk_update([img for _, img in sortable], ['position'])
|
||||||
n = n + 1
|
|
||||||
|
|
||||||
def get_total_image_size(self, refresh=False):
|
def get_total_image_size(self, refresh=False):
|
||||||
"""Returns the total size of all images in the series.
|
"""Returns the total size of all images in the series.
|
||||||
@@ -1614,6 +1617,7 @@ CID_GROUP_EXAMS = (
|
|||||||
("Physics", "physics_cid_user_groups"),
|
("Physics", "physics_cid_user_groups"),
|
||||||
("Anatomy", "anatomy_cid_user_groups"),
|
("Anatomy", "anatomy_cid_user_groups"),
|
||||||
("Rapids", "rapid_cid_user_groups"),
|
("Rapids", "rapid_cid_user_groups"),
|
||||||
|
("Shorts", "shorts_cid_user_groups"),
|
||||||
("Longs", "longs_cid_user_groups"),
|
("Longs", "longs_cid_user_groups"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1661,6 +1665,7 @@ USER_GROUP_EXAMS = (
|
|||||||
("Physics", "physics_user_user_groups"),
|
("Physics", "physics_user_user_groups"),
|
||||||
("Anatomy", "anatomy_user_user_groups"),
|
("Anatomy", "anatomy_user_user_groups"),
|
||||||
("Rapids", "rapid_user_user_groups"),
|
("Rapids", "rapid_user_user_groups"),
|
||||||
|
("Shorts", "shorts_user_user_groups"),
|
||||||
("Longs", "longs_user_user_groups"),
|
("Longs", "longs_user_user_groups"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1701,16 +1706,6 @@ class UserGrades(models.Model):
|
|||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
USER_EXAM_TYPES = (
|
|
||||||
("Physics", "user_physics_exams"),
|
|
||||||
("Rapids", "user_rapid_exams"),
|
|
||||||
("SBAs", "user_sba_exams"),
|
|
||||||
("Anatomy", "user_anatomy_exams"),
|
|
||||||
("Longs", "user_longs_exams"),
|
|
||||||
# ("CaseCollection", "user_casecollection_exams"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfile(models.Model):
|
class UserProfile(models.Model):
|
||||||
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||||
supervisor = models.ForeignKey(
|
supervisor = models.ForeignKey(
|
||||||
|
|||||||
@@ -104,6 +104,15 @@
|
|||||||
|
|
||||||
</select>
|
</select>
|
||||||
</span>
|
</span>
|
||||||
|
<span>
|
||||||
|
Shorts Exams<br />
|
||||||
|
<select id="shorts-exams" multiple="multiple">
|
||||||
|
{% for name, id in shorts_exams %}
|
||||||
|
<option value="{{id}}">{{name}}</option>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
<span>
|
<span>
|
||||||
CaseCollection Exams<br />
|
CaseCollection Exams<br />
|
||||||
<select id="casecollection-exams" multiple="multiple">
|
<select id="casecollection-exams" multiple="multiple">
|
||||||
@@ -213,6 +222,7 @@
|
|||||||
sba_exams: JSON.stringify($("#sbas-exams").val()),
|
sba_exams: JSON.stringify($("#sbas-exams").val()),
|
||||||
longs_exams: JSON.stringify($("#longs-exams").val()),
|
longs_exams: JSON.stringify($("#longs-exams").val()),
|
||||||
anatomy_exams: JSON.stringify($("#anatomy-exams").val()),
|
anatomy_exams: JSON.stringify($("#anatomy-exams").val()),
|
||||||
|
shorts_exams: JSON.stringify($("#shorts-exams").val()),
|
||||||
casecollection_exams: JSON.stringify($("#casecollection-exams").val()),
|
casecollection_exams: JSON.stringify($("#casecollection-exams").val()),
|
||||||
cid_groups: JSON.stringify($("#cid-groups").val()),
|
cid_groups: JSON.stringify($("#cid-groups").val()),
|
||||||
delete: false,
|
delete: false,
|
||||||
|
|||||||
@@ -96,22 +96,10 @@ Open access: {{ exam.open_access }}<br />
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<div class="parent-help" title="Click to enable / disable the exam">
|
{% include "generic/partials/exams/exam_status.html#exam-active" %}
|
||||||
Exam active: <input type="checkbox"
|
|
||||||
{% if not can_edit %}
|
|
||||||
disabled
|
|
||||||
{% endif %}
|
|
||||||
id="exam-active-switch" {% if exam.active %}checked{% endif %} data-posturl="{% url exam.get_app_name|add:':exam_toggle_active' pk=exam.pk %}"> <span class="help-text">[When checked the exam will be available to take in the test system]</span>
|
|
||||||
</div>
|
|
||||||
{% if exam.exam_mode %}
|
{% if exam.exam_mode %}
|
||||||
<div class="parent-help" title="Click to enable / disable the exam results">
|
|
||||||
Publish results: <input type="checkbox"
|
{% include "generic/partials/exams/exam_status.html#publish-results" %}
|
||||||
{% if not can_edit %}
|
|
||||||
disabled
|
|
||||||
{% endif %}
|
|
||||||
id="exam-publish-results-switch" data-posturl="{% url exam.get_app_name|add:':exam_toggle_results_published' pk=exam.pk %}"
|
|
||||||
{% if exam.publish_results %}checked{% endif %}> <span class="help-text">[When checked the exam results will be available to users on this site]</span>
|
|
||||||
</div>
|
|
||||||
{% if exam.get_app_name == "anatomy" or exam.get_app_name == "longs" %}
|
{% if exam.get_app_name == "anatomy" or exam.get_app_name == "longs" %}
|
||||||
<p><a href="{% url exam.get_app_name|add:':mark_overview' pk=exam.pk %}"><button>Mark exam</button></a></p>
|
<p><a href="{% url exam.get_app_name|add:':mark_overview' pk=exam.pk %}"><button>Mark exam</button></a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -58,6 +58,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if object.shorts_exams.all %}
|
||||||
|
<h2>Shorts Exams:</h2>
|
||||||
|
{% with exams=object.shorts_exams.all app_name="shorts" %}
|
||||||
|
{% include "exam_list.html#exam-list" %}
|
||||||
|
{% endwith %}
|
||||||
|
<a href="{% url 'shorts:exam_list_collection' object.pk %}">View exam list</a>
|
||||||
|
<div class="add-marker">
|
||||||
|
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'shorts' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if object.physics_exams.all %}
|
{% if object.physics_exams.all %}
|
||||||
<h2>Physics Exams:</h2>
|
<h2>Physics Exams:</h2>
|
||||||
{% with exams=object.physics_exams.all app_name="physics" %}
|
{% with exams=object.physics_exams.all app_name="physics" %}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{% load partials %}
|
||||||
|
|
||||||
|
{% partialdef publish-results %}
|
||||||
|
<div id="publish-results-status" >
|
||||||
|
Publish results: <span>{{exam.publish_results}}</span>
|
||||||
|
<button
|
||||||
|
hx-post="{% url exam.get_app_name|add:':exam_toggle_results_published' exam.pk %}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-target="#publish-results-status"
|
||||||
|
class="btn btn-sm btn-outline-primary reduce-opacity"
|
||||||
|
>
|
||||||
|
{% if exam.publish_results %}Unpublish Results{% else %}Publish Results{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endpartialdef publish-results %}
|
||||||
|
|
||||||
|
|
||||||
|
{% partialdef exam-active %}
|
||||||
|
<div id="exam-active-status">
|
||||||
|
Exam active: <span>{{exam.active}}</span>
|
||||||
|
<button
|
||||||
|
hx-post="{% url exam.get_app_name|add:':exam_toggle_active' exam.pk %}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-target="#exam-active-status"
|
||||||
|
class="btn btn-sm btn-outline-primary reduce-opacity"
|
||||||
|
>
|
||||||
|
{% if exam.active %}Deactivate Exam{% else %}Activate Exam{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endpartialdef exam-active %}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<form method="get" class="row align-items-center mb-3">
|
||||||
|
{# Keep existing filters/searches #}
|
||||||
|
{% for key, value in request.GET.items %}
|
||||||
|
{% if key != 'per_page' %}
|
||||||
|
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="col-auto">
|
||||||
|
<label for="per_page" class="form-label mb-0 ">Rows per page:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<select name="per_page" id="per_page" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
{% for size in page_sizes %}
|
||||||
|
<option value="{{ size }}" {% if request.GET.per_page|stringformat:"s" == size|stringformat:"s" %}selected{% endif %}>{{ size }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -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(
|
path(
|
||||||
"exam/<int:pk>/toggle_active",
|
"exam/<int:pk>/toggle_active",
|
||||||
generic_exam_view.exam_toggle_active,
|
generic_exam_view.exam_toggle_active_htmx,
|
||||||
name="exam_toggle_active",
|
name="exam_toggle_active",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"exam/<int:pk>/toggle_results_published",
|
"exam/<int:pk>/toggle_results_published",
|
||||||
generic_exam_view.exam_toggle_results_published,
|
generic_exam_view.exam_toggle_results_published_htmx,
|
||||||
name="exam_toggle_results_published",
|
name="exam_toggle_results_published",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
|
|||||||
+63
-5
@@ -146,6 +146,9 @@ import plotly.express as px
|
|||||||
from django.db.models import Prefetch
|
from django.db.models import Prefetch
|
||||||
|
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
class RedirectMixin:
|
class RedirectMixin:
|
||||||
def get_success_url(self) -> str:
|
def get_success_url(self) -> str:
|
||||||
if "redirect" in self.request.GET:
|
if "redirect" in self.request.GET:
|
||||||
@@ -692,6 +695,58 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
data = {"status": "error"}
|
data = {"status": "error"}
|
||||||
return JsonResponse(data, status=400)
|
return JsonResponse(data, status=400)
|
||||||
|
|
||||||
|
@method_decorator(login_required)
|
||||||
|
def exam_toggle_results_published_htmx(self, request, pk):
|
||||||
|
if request.method == "POST":
|
||||||
|
if not self.check_user_edit_access(request.user, exam_id=pk):
|
||||||
|
return HttpResponse(
|
||||||
|
format_html(
|
||||||
|
'<div class="alert alert-danger" role="alert">Error toggling results published.</div>'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
exam = get_object_or_404(self.Exam, pk=pk)
|
||||||
|
|
||||||
|
exam.publish_results = not exam.publish_results
|
||||||
|
exam.save()
|
||||||
|
return render(
|
||||||
|
request, "generic/partials/exams/exam_status.html#publish-results", context={
|
||||||
|
"exam": exam,
|
||||||
|
"collection": exam
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
HttpResponse(
|
||||||
|
format_html(
|
||||||
|
'<div class="alert alert-danger" role="alert">Error toggling results published.</div>'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@method_decorator(login_required)
|
||||||
|
def exam_toggle_active_htmx(self, request, pk):
|
||||||
|
if request.method == "POST":
|
||||||
|
if not self.check_user_edit_access(request.user, exam_id=pk):
|
||||||
|
return HttpResponse(
|
||||||
|
format_html(
|
||||||
|
'<div class="alert alert-danger" role="alert">Error toggling exam active.</div>'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
exam = get_object_or_404(self.Exam, pk=pk)
|
||||||
|
|
||||||
|
exam.active = not exam.active
|
||||||
|
exam.save()
|
||||||
|
return render(
|
||||||
|
request, "generic/partials/exams/exam_status.html#exam-active", context={
|
||||||
|
"exam": exam,
|
||||||
|
"collection": exam
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
HttpResponse(
|
||||||
|
format_html(
|
||||||
|
'<div class="alert alert-danger" role="alert">Error toggling exam active.</div>'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@method_decorator(login_required)
|
@method_decorator(login_required)
|
||||||
def exam_toggle_results_published(self, request, pk):
|
def exam_toggle_results_published(self, request, pk):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -796,6 +851,7 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
author__id=request.user.id, exam_mode=True
|
author__id=request.user.id, exam_mode=True
|
||||||
).order_by("name")
|
).order_by("name")
|
||||||
|
|
||||||
|
|
||||||
exam_list = exam_list | self.Exam.objects.filter(
|
exam_list = exam_list | self.Exam.objects.filter(
|
||||||
markers__id=request.user.id, exam_mode=True
|
markers__id=request.user.id, exam_mode=True
|
||||||
).order_by("name")
|
).order_by("name")
|
||||||
@@ -1396,6 +1452,7 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
|
|
||||||
app_exam_map = {}
|
app_exam_map = {}
|
||||||
app_exam_map["rapids"] = user_user.user_rapid_exams
|
app_exam_map["rapids"] = user_user.user_rapid_exams
|
||||||
|
app_exam_map["shorts"] = user_user.user_shorts_exams
|
||||||
app_exam_map["anatomy"] = user_user.user_anatomy_exams
|
app_exam_map["anatomy"] = user_user.user_anatomy_exams
|
||||||
app_exam_map["longs"] = user_user.user_longs_exams
|
app_exam_map["longs"] = user_user.user_longs_exams
|
||||||
app_exam_map["physics"] = user_user.user_physics_exams
|
app_exam_map["physics"] = user_user.user_physics_exams
|
||||||
@@ -1608,7 +1665,9 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
):
|
):
|
||||||
exams = self.Exam.objects.all()
|
exams = self.Exam.objects.all()
|
||||||
filter = self.ExtraExamFilter(request.GET, queryset=exams)
|
filter = self.ExtraExamFilter(request.GET, queryset=exams)
|
||||||
|
print("1")
|
||||||
else:
|
else:
|
||||||
|
print("2")
|
||||||
exams = self.Exam.objects.filter(
|
exams = self.Exam.objects.filter(
|
||||||
author__id=request.user.id
|
author__id=request.user.id
|
||||||
) | self.Exam.objects.filter(open_access=True)
|
) | self.Exam.objects.filter(open_access=True)
|
||||||
@@ -2237,7 +2296,7 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
ans = user_answer.get_answer()
|
ans = user_answer.get_answer()
|
||||||
answer_score = user_answer.get_answer_score()
|
answer_score = user_answer.get_answer_score()
|
||||||
|
|
||||||
if self.app_name == "longs":
|
if self.app_name in ("longs", "shorts"):
|
||||||
feedback = user_answer.candidate_feedback
|
feedback = user_answer.candidate_feedback
|
||||||
|
|
||||||
match self.app_name:
|
match self.app_name:
|
||||||
@@ -2273,10 +2332,11 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
if not exam.publish_results and not view_all_results:
|
if not exam.publish_results and not view_all_results:
|
||||||
correct_answer = "*****"
|
correct_answer = "*****"
|
||||||
answer_score = 0
|
answer_score = 0
|
||||||
|
logger.debug(f"{ans=} {answer_score=} {correct_answer=}")
|
||||||
answers.append(ans)
|
answers.append(ans)
|
||||||
answers_marks.append(answer_score)
|
answers_marks.append(answer_score)
|
||||||
|
|
||||||
if self.app_name == "longs":
|
if self.app_name in ("longs", "shorts"):
|
||||||
answers_and_marks.append(
|
answers_and_marks.append(
|
||||||
(ans, answer_score, correct_answer, feedback)
|
(ans, answer_score, correct_answer, feedback)
|
||||||
)
|
)
|
||||||
@@ -2289,7 +2349,7 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
case "physics":
|
case "physics":
|
||||||
total_score = sum(sum(i) for i in answers_marks)
|
total_score = sum(sum(i) for i in answers_marks)
|
||||||
case _:
|
case _:
|
||||||
if "unmarked" in answers_marks:
|
if "unmarked" in answers_marks or None in answers_marks:
|
||||||
answered = [i for i in answers_marks if type(i) == int]
|
answered = [i for i in answers_marks if type(i) == int]
|
||||||
total_score = sum(answered)
|
total_score = sum(answered)
|
||||||
unmarked_number = len(answers_marks) - len(answered)
|
unmarked_number = len(answers_marks) - len(answered)
|
||||||
@@ -2377,7 +2437,6 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
cached_scores = True
|
cached_scores = True
|
||||||
|
|
||||||
valid_cid_users = set(exam.valid_cid_users.all().values_list("cid", flat=True))
|
valid_cid_users = set(exam.valid_cid_users.all().values_list("cid", flat=True))
|
||||||
print(valid_cid_users)
|
|
||||||
valid_user_users = set(exam.valid_user_users.all().values_list("pk", flat=True))
|
valid_user_users = set(exam.valid_user_users.all().values_list("pk", flat=True))
|
||||||
|
|
||||||
if self.app_name in ("rapids"):
|
if self.app_name in ("rapids"):
|
||||||
@@ -2403,7 +2462,6 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
user_ids = set()
|
user_ids = set()
|
||||||
|
|
||||||
cids_user_id_map = {}
|
cids_user_id_map = {}
|
||||||
cids_name_map = {}
|
|
||||||
|
|
||||||
# Loop through all candidates
|
# Loop through all candidates
|
||||||
for cid_user_answer in cid_user_answers:
|
for cid_user_answer in cid_user_answers:
|
||||||
|
|||||||
+42
-11
@@ -2,6 +2,7 @@ import base64
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import hashlib
|
import hashlib
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
from pydicom.uid import ExplicitVRLittleEndian
|
||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ try:
|
|||||||
from .pydicom_PIL import get_PIL_image
|
from .pydicom_PIL import get_PIL_image
|
||||||
except:
|
except:
|
||||||
from pydicom_PIL import get_PIL_image
|
from pydicom_PIL import get_PIL_image
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
def image_as_base64(image_file):
|
def image_as_base64(image_file):
|
||||||
"""
|
"""
|
||||||
@@ -93,38 +95,29 @@ def get_dicom_order(path):
|
|||||||
files = []
|
files = []
|
||||||
|
|
||||||
for fname in glob.glob(path, recursive=False):
|
for fname in glob.glob(path, recursive=False):
|
||||||
print("loading: {}".format(fname))
|
|
||||||
files.append((fname, pydicom.dcmread(fname)))
|
files.append((fname, pydicom.dcmread(fname)))
|
||||||
|
|
||||||
print("file count: {}".format(len(files)))
|
|
||||||
|
|
||||||
# skip files with no SliceLocation (eg scout views)
|
# skip files with no SliceLocation (eg scout views)
|
||||||
slices = []
|
slices = []
|
||||||
map = {}
|
map = {}
|
||||||
skipcount = 0
|
skipcount = 0
|
||||||
for fname, f in files:
|
for fname, f in files:
|
||||||
print(dir(f))
|
|
||||||
print(fname)
|
|
||||||
if hasattr(f, 'SliceLocation'):
|
if hasattr(f, 'SliceLocation'):
|
||||||
slices.append(f)
|
slices.append(f)
|
||||||
map[f.SliceLocation] = fname
|
map[f.SliceLocation] = fname
|
||||||
else:
|
else:
|
||||||
skipcount = skipcount + 1
|
skipcount = skipcount + 1
|
||||||
|
|
||||||
print("skipped, no SliceLocation: {}".format(skipcount))
|
|
||||||
|
|
||||||
# ensure they are in the correct order
|
# ensure they are in the correct order
|
||||||
slices = sorted(slices, key=lambda s: s.SliceLocation)
|
slices = sorted(slices, key=lambda s: s.SliceLocation)
|
||||||
|
|
||||||
#print(slices)
|
return slices
|
||||||
for i in slices:
|
|
||||||
print(map[i.SliceLocation])
|
|
||||||
|
|
||||||
#get_dicom_order("/home/ross/Downloads/DICOM HEAD/STD4/SER1/*.dcm")
|
#get_dicom_order("/home/ross/Downloads/DICOM HEAD/STD4/SER1/*.dcm")
|
||||||
|
|
||||||
def pretty_print_dicom(dataset, indent=0):
|
def pretty_print_dicom(dataset, indent=0):
|
||||||
l = print_dicom(dataset, indent)
|
l = print_dicom(dataset, indent)
|
||||||
print(l)
|
|
||||||
return "".join(l)
|
return "".join(l)
|
||||||
|
|
||||||
|
|
||||||
@@ -183,7 +176,6 @@ def get_image_dicom_hash(img, dataset=None, hash_type="md5", direct_pixel_data=T
|
|||||||
hasher.update(dataset.PixelData)
|
hasher.update(dataset.PixelData)
|
||||||
except AttributeError as e:
|
except AttributeError as e:
|
||||||
print("Error getting pixel data hash from dataset")
|
print("Error getting pixel data hash from dataset")
|
||||||
print(dataset)
|
|
||||||
raise InvalidDicomError("Error getting pixel data hash from dataset")
|
raise InvalidDicomError("Error getting pixel data hash from dataset")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -294,3 +286,42 @@ def compare_dicom_datasets(*datasets: pydicom.Dataset):
|
|||||||
return differences
|
return differences
|
||||||
|
|
||||||
|
|
||||||
|
def combine_dicom_images_side_by_side(dicom_path1, dicom_path2):
|
||||||
|
"""
|
||||||
|
Combines two DICOM images side by side, using the metadata from the first image.
|
||||||
|
If the heights differ, pads the shorter image with zeros at the bottom.
|
||||||
|
Returns a new pydicom.Dataset with the combined image.
|
||||||
|
"""
|
||||||
|
ds1 = pydicom.dcmread(dicom_path1)
|
||||||
|
ds2 = pydicom.dcmread(dicom_path2)
|
||||||
|
|
||||||
|
arr1 = ds1.pixel_array
|
||||||
|
arr2 = ds2.pixel_array
|
||||||
|
|
||||||
|
h1, w1 = arr1.shape[:2]
|
||||||
|
h2, w2 = arr2.shape[:2]
|
||||||
|
|
||||||
|
# Pad arrays if heights differ
|
||||||
|
if h1 != h2:
|
||||||
|
max_h = max(h1, h2)
|
||||||
|
def pad(arr, target_h):
|
||||||
|
pad_height = target_h - arr.shape[0]
|
||||||
|
if pad_height > 0:
|
||||||
|
pad_shape = ((0, pad_height), (0, 0)) if arr.ndim == 2 else ((0, pad_height), (0, 0), (0, 0))
|
||||||
|
arr = np.pad(arr, pad_shape, mode='constant', constant_values=0)
|
||||||
|
return arr
|
||||||
|
arr1 = pad(arr1, max_h)
|
||||||
|
arr2 = pad(arr2, max_h)
|
||||||
|
|
||||||
|
combined = np.hstack((arr1, arr2))
|
||||||
|
|
||||||
|
ds1.PixelData = combined.tobytes()
|
||||||
|
ds1.Rows, ds1.Columns = combined.shape[:2]
|
||||||
|
|
||||||
|
if hasattr(ds1, 'PhotometricInterpretation'):
|
||||||
|
ds1.PhotometricInterpretation = ds1.PhotometricInterpretation
|
||||||
|
|
||||||
|
# Ensure output is uncompressed
|
||||||
|
ds1.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian
|
||||||
|
|
||||||
|
return ds1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
Usage:
|
Usage:
|
||||||
>>> import pydicom
|
>>> import pydicom
|
||||||
>>> from pydicom.contrib.pydicom_PIL import show_PIL
|
>>> from pydicom.contrib.pydicom_PIL import show_PIL
|
||||||
>>> ds = pydicom.read_file("filename")
|
>>> ds = pydicom.dcmread("filename")
|
||||||
>>> show_PIL(ds)
|
>>> show_PIL(ds)
|
||||||
Requires Numpy:
|
Requires Numpy:
|
||||||
http://numpy.scipy.org/
|
http://numpy.scipy.org/
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-09-02 20:18
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('longs', '0032_longseries_total_image_size'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='longseriesimage',
|
||||||
|
name='image_md5_hash',
|
||||||
|
field=models.CharField(blank=True, max_length=64, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -11,7 +11,6 @@ if __name__ == "__main__":
|
|||||||
if os.environ.get('RUN_MAIN'):
|
if os.environ.get('RUN_MAIN'):
|
||||||
if settings.DEBUG_CONTAINER:
|
if settings.DEBUG_CONTAINER:
|
||||||
import debugpy
|
import debugpy
|
||||||
print("DEBUG LISTEN")
|
|
||||||
debugpy.listen(("0.0.0.0", 3459))
|
debugpy.listen(("0.0.0.0", 3459))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1387,4 +1387,30 @@ span#user-id {
|
|||||||
width: auto;
|
width: auto;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.reduce-opacity {
|
||||||
|
opacity: 0.3;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.btn.reduce-opacity:hover, .btn.reduce-opacity:focus {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Highlight row on hover */
|
||||||
|
.row-selector tbody tr:hover td {
|
||||||
|
background-color: darkblue;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
/* Highlight selected row */
|
||||||
|
.row-selector tbody tr.selected td {
|
||||||
|
background-color: #b3d7ff !important;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-block {
|
||||||
|
min-width: 100px;
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
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