Compare commits
232
Commits
3f5f39c41c
...
820d0bc030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
820d0bc030 | ||
|
|
0c07400cc3 | ||
|
|
b825342a24 | ||
|
|
4a86a95c91 | ||
|
|
4873732b39 | ||
|
|
6439ec9aa4 | ||
|
|
394ef2196e | ||
|
|
13e6bc37b8 | ||
|
|
7070da01b0 | ||
|
|
363396786f | ||
|
|
d66783b2b8 | ||
|
|
e922a93a25 | ||
|
|
90612209f2 | ||
|
|
3c8cae769d | ||
|
|
c750cb0fa9 | ||
|
|
3873d24f7b | ||
|
|
8e92de3420 | ||
|
|
503c8f854a | ||
|
|
ead5bfcace | ||
|
|
77ee83cb08 | ||
|
|
9f5fc09e23 | ||
|
|
7689bded66 | ||
|
|
79f196966e | ||
|
|
5adbf619fa | ||
|
|
6ce54372ff | ||
|
|
e2aaf64258 | ||
|
|
1599e48584 | ||
|
|
e119dceaa1 | ||
|
|
5f94616168 | ||
|
|
a43a6e0fed | ||
|
|
fe44fa23a3 | ||
|
|
168398d9bc | ||
|
|
6564b25250 | ||
|
|
5e578ca2b0 | ||
|
|
a13be87077 | ||
|
|
dc1bf46004 | ||
|
|
06e66ad52c | ||
|
|
405b17776f | ||
|
|
f584ea07a3 | ||
|
|
e3762c39cb | ||
|
|
eea61fc99c | ||
|
|
edf4a992de | ||
|
|
5d37cf4284 | ||
|
|
dd334c6d6d | ||
|
|
7fe7823deb | ||
|
|
d387e32777 | ||
|
|
afa1379ce4 | ||
|
|
bcc449eb34 | ||
|
|
e27d45f3c5 | ||
|
|
35c67cbc80 | ||
|
|
2865935a95 | ||
|
|
0b575bc64c | ||
|
|
580249a82d | ||
|
|
2c22616cc7 | ||
|
|
73cd6b7591 | ||
|
|
c25f285202 | ||
|
|
baeaf3d9c6 | ||
|
|
8f3e8ab510 | ||
|
|
fb931f5a15 | ||
|
|
2d9c83ff95 | ||
|
|
1a1c455c03 | ||
|
|
c5ba153865 | ||
|
|
b3076d6dda | ||
|
|
de655c0d6a | ||
|
|
598a7118b2 | ||
|
|
f6b78a90ce | ||
|
|
486bb94ea9 | ||
|
|
f43c54f4af | ||
|
|
c1f5147af7 | ||
|
|
6cbba4f6fe | ||
|
|
b924bb2390 | ||
|
|
a0960c9b7c | ||
|
|
249bc656c8 | ||
|
|
ec55d1507e | ||
|
|
b4cc8a281d | ||
|
|
0ca17c23b5 | ||
|
|
9cb83b62d2 | ||
|
|
9d4487b7f5 | ||
|
|
9bb4d8bc08 | ||
|
|
7b45483870 | ||
|
|
d891c7b9ee | ||
|
|
f0c5964a5a | ||
|
|
ac96745fa1 | ||
|
|
7fc12737c7 | ||
|
|
e6beb734ab | ||
|
|
3b258adf59 | ||
|
|
ef49f6a5eb | ||
|
|
97e76bb100 | ||
|
|
58301dd251 | ||
|
|
3c72a06ad2 | ||
|
|
70bb6dc15a | ||
|
|
3e32b33864 | ||
|
|
056a51331e | ||
|
|
cb47e488ed | ||
|
|
7493d55bc2 | ||
|
|
13713d870f | ||
|
|
4981402ca9 | ||
|
|
b6fcb9a401 | ||
|
|
fa3f87181c | ||
|
|
6ee0187dd3 | ||
|
|
ffc8a245e4 | ||
|
|
22812c5ec1 | ||
|
|
178ec034a3 | ||
|
|
1fdb4c9159 | ||
|
|
55507d215c | ||
|
|
8c43c40215 | ||
|
|
e0974ae300 | ||
|
|
7e928c6887 | ||
|
|
b5e6a294ff | ||
|
|
267f53906e | ||
|
|
7da968825c | ||
|
|
12144e06d1 | ||
|
|
1c5f3a3425 | ||
|
|
187f7d9a83 | ||
|
|
d237cd0a5e | ||
|
|
99862e2e60 | ||
|
|
6a58cdb14f | ||
|
|
4b6ffd4559 | ||
|
|
6f19d0a3ed | ||
|
|
46f5a99697 | ||
|
|
de2b84e099 | ||
|
|
c6485333bb | ||
|
|
720debc2a5 | ||
|
|
69664b7b50 | ||
|
|
92f9cabcc0 | ||
|
|
4476d0b55f | ||
|
|
6d20beebce | ||
|
|
8d62673f06 | ||
|
|
5eb5fe3f50 | ||
|
|
695e651ea5 | ||
|
|
91ad134115 | ||
|
|
40ae9e1b64 | ||
|
|
1358b15986 | ||
|
|
f4eb874544 | ||
|
|
a9687345c6 | ||
|
|
ba29c9c881 | ||
|
|
4bc0e3be93 | ||
|
|
d0d680edc5 | ||
|
|
8be4c4f923 | ||
|
|
00323cc6bd | ||
|
|
9c69792e87 | ||
|
|
fad940bcd4 | ||
|
|
57497cc97c | ||
|
|
2a1f15683e | ||
|
|
2aff0f6a32 | ||
|
|
ed933c8104 | ||
|
|
a65ceffbfb | ||
|
|
fa2d270dfb | ||
|
|
4e2e350e52 | ||
|
|
30168b7624 | ||
|
|
80358e2113 | ||
|
|
eab6fc9d7e | ||
|
|
d8b07449a4 | ||
|
|
100d23b068 | ||
|
|
a0a6d7f415 | ||
|
|
bf14d4b7ee | ||
|
|
7c5cfae5d5 | ||
|
|
a4c7aeb84d | ||
|
|
33c2054bb3 | ||
|
|
ec36597dbc | ||
|
|
7b1fee464b | ||
|
|
3a2b89a1fd | ||
|
|
155c036f0c | ||
|
|
cee4009eb8 | ||
|
|
dedce34d6e | ||
|
|
2faedbcee6 | ||
|
|
5397bf10ac | ||
|
|
162776c103 | ||
|
|
9f6740accb | ||
|
|
a192157d81 | ||
|
|
ac0b556dfa | ||
|
|
0776400941 | ||
|
|
a20199cb98 | ||
|
|
37bf203bf2 | ||
|
|
3cb8d53d3f | ||
|
|
6ba7be7abd | ||
|
|
eeb0483223 | ||
|
|
76c96a32fe | ||
|
|
2dc2346962 | ||
|
|
0bc6d63c7a | ||
|
|
68c9c33f4c | ||
|
|
ce56de1b95 | ||
|
|
fc6e215cc2 | ||
|
|
a2907fa285 | ||
|
|
375261dd84 | ||
|
|
21fa4739ee | ||
|
|
7981684005 | ||
|
|
0d7cac594b | ||
|
|
bfe5be807e | ||
|
|
eeaa345f3c | ||
|
|
ad54fce54b | ||
|
|
aa9c109b68 | ||
|
|
b8aa7691cd | ||
|
|
7b4b88b573 | ||
|
|
7151d036cf | ||
|
|
ad0bc7c6ee | ||
|
|
87c1ac4e96 | ||
|
|
6b064b2653 | ||
|
|
185942b716 | ||
|
|
38fc924222 | ||
|
|
4630bf127b | ||
|
|
46cf69a7b6 | ||
|
|
78f2067bc0 | ||
|
|
16e3e12b2a | ||
|
|
615d5f8f80 | ||
|
|
61293f90a6 | ||
|
|
b0deb17951 | ||
|
|
502e4d3f83 | ||
|
|
bb33533501 | ||
|
|
8b84654ee3 | ||
|
|
ffac3175e1 | ||
|
|
57d3ea909a | ||
|
|
2c1043c2dc | ||
|
|
e69d1a6b9a | ||
|
|
f4c82f45f5 | ||
|
|
436a04e8dd | ||
|
|
87192466ed | ||
|
|
4d0a105ab2 | ||
|
|
2d1ddbd5cf | ||
|
|
ba607f3c52 | ||
|
|
955ec6831a | ||
|
|
fc6b2027c3 | ||
|
|
6812935d6c | ||
|
|
2698e3f9d6 | ||
|
|
b7a0c60b16 | ||
|
|
850da0c2ac | ||
|
|
8fe64fc316 | ||
|
|
8964a5598f | ||
|
|
2e80a4ee0a | ||
|
|
28bfa34367 | ||
|
|
7b938291ce | ||
|
|
1e55d195e9 |
+9
-3
@@ -1,5 +1,6 @@
|
||||
# pull official base image
|
||||
FROM python:3.12.1-slim
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
# set work directory
|
||||
WORKDIR /usr/src/rad
|
||||
@@ -13,13 +14,18 @@ ENV PYTHONUNBUFFERED 1
|
||||
# python3 python3-dev gcc \
|
||||
# gfortran musl-dev \
|
||||
# libffi-dev openssl-dev
|
||||
RUN apt-get update && apt-get -y install postgresql postgresql-contrib git libcairo2-dev pkg-config gcc
|
||||
RUN apt-get update && apt-get -y install postgresql postgresql-contrib git libcairo2-dev pkg-config gcc redis-tools
|
||||
|
||||
|
||||
# install dependencies
|
||||
RUN pip install --upgrade pip
|
||||
#RUN pip install --upgrade pip
|
||||
COPY ./requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
RUN uv venv /opt/venv
|
||||
# Use the virtual environment automatically
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
# Place entry points in the environment at the front of the path
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
RUN uv pip install -r requirements.txt
|
||||
|
||||
# copy project
|
||||
COPY . .
|
||||
+6
-1
@@ -252,4 +252,9 @@ class ExamMarkerForm(ExamMarkerFormMixin):
|
||||
|
||||
class ExamGroupsForm(ExamGroupsFormMixin):
|
||||
class Meta(ExamGroupsFormMixin.Meta):
|
||||
model = Exam
|
||||
model = Exam
|
||||
|
||||
class SuggestIncorrectAnswerForm(ModelForm):
|
||||
class Meta:
|
||||
model = AnatomyQuestion
|
||||
fields = ["answer_suggest_incorrect"]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.0.2 on 2024-10-09 20:39
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0021_alter_anatomyquestion_answer_help_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='exam',
|
||||
name='markers',
|
||||
field=models.ManyToManyField(blank=True, help_text='Authorised markers for the exam', related_name='anatomy_exam_markers', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-10-14 11:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0022_alter_exam_markers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='exam_open_access',
|
||||
field=models.BooleanField(default=False, help_text='Set to true if you want any registered user to be able to take the exam.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-10-28 11:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0023_exam_exam_open_access'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='results_supervisor_visible',
|
||||
field=models.BooleanField(default=False, help_text='If true the results will be visible to supervisors without candidate approval.'),
|
||||
),
|
||||
]
|
||||
+10
-1
@@ -22,6 +22,7 @@ from collections import defaultdict
|
||||
from helpers.images import image_as_base64
|
||||
|
||||
import reversion
|
||||
import urllib.parse
|
||||
|
||||
image_storage = FileSystemStorage(
|
||||
# Physical file location ROOT
|
||||
@@ -335,6 +336,13 @@ class Answer(models.Model):
|
||||
This just mirrors the result from the attached question"""
|
||||
return self.question.can_edit(user)
|
||||
|
||||
|
||||
def get_answer_url_safe(self):
|
||||
return urllib.parse.quote(self.answer_compare)
|
||||
|
||||
def get_constituent_words(self):
|
||||
return self.answer_compare.split()
|
||||
|
||||
# def get_compare_string(self):
|
||||
# s = self.answer.lower().strip()
|
||||
# s = s.translate(str.maketrans('', '', string.punctuation))
|
||||
@@ -396,7 +404,6 @@ class Exam(ExamBase):
|
||||
markers = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Authorised markers for the exam",
|
||||
related_name="anatomy_exam_markers",
|
||||
)
|
||||
@@ -541,6 +548,8 @@ class UserAnswer(UserAnswerBase):
|
||||
# s = self.answer.lower().strip()
|
||||
# s = s.translate(str.maketrans('', '', string.punctuation))
|
||||
# return s
|
||||
def get_answer_url_safe(self):
|
||||
return urllib.parse.quote(self.answer_compare)
|
||||
|
||||
def get_answer_string(self):
|
||||
return self.answer
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# serializers.py
|
||||
from rest_framework import serializers, permissions
|
||||
|
||||
|
||||
from .models import Exam
|
||||
|
||||
class ExamSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = Exam
|
||||
fields = ('name', 'id', 'active', "publish_results")
|
||||
@@ -12,9 +12,22 @@ window.control_pressed = false;
|
||||
|
||||
|
||||
$(document).ready(function () {
|
||||
// Select all text input fields and textareas with the clear button
|
||||
clearableTextAreaSetup();
|
||||
|
||||
|
||||
document.body.addEventListener("htmx:responseError", function(e) {
|
||||
let error = e.detail.xhr.response;
|
||||
let el = document.getElementById("htmx-error");
|
||||
var error;
|
||||
if (e.detail.pathInfo.requestPath.includes("cimar") && e.detail.xhr.status == 403) {
|
||||
error = "Cimar permission error, please login and refresh the page.";
|
||||
//document.getElementById("cimar-login-form").focus();
|
||||
htmx.trigger("#cimar-login-needed", "cimar-login-needed");
|
||||
|
||||
|
||||
} else {
|
||||
error = e.detail.xhr.response;
|
||||
}
|
||||
el.innerHTML = error;
|
||||
el.classList.remove("hidden");
|
||||
});
|
||||
@@ -184,6 +197,40 @@ window.addEventListener('loadDicomViewerUrls', function (e) {
|
||||
loadDicomViewer(e.detail.images, e.detail.annotations);
|
||||
}, false);
|
||||
|
||||
function clearableTextAreaSetup() {
|
||||
const textFields = document.querySelectorAll(".position-relative input[type='text'], .position-relative textarea");
|
||||
|
||||
textFields.forEach((field) => {
|
||||
const wrapper = field.closest(".position-relative");
|
||||
const clearButton = wrapper.querySelector(".clear-input-button");
|
||||
|
||||
// Function to toggle the visibility of the clear button
|
||||
const toggleClearButton = () => {
|
||||
if (field.value.trim() !== "" && wrapper.matches(":hover")) {
|
||||
clearButton.style.display = "inline-block"; // Show the button
|
||||
} else {
|
||||
clearButton.style.display = "none"; // Hide the button
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check
|
||||
toggleClearButton();
|
||||
|
||||
// Add event listeners to monitor changes in the input field or textarea
|
||||
field.addEventListener("input", toggleClearButton);
|
||||
|
||||
// Add hover event listeners to the wrapper
|
||||
wrapper.addEventListener("mouseenter", toggleClearButton);
|
||||
wrapper.addEventListener("mouseleave", toggleClearButton);
|
||||
|
||||
// Add click event to clear the field or textarea
|
||||
clearButton.addEventListener("click", function () {
|
||||
field.value = ""; // Clear the field
|
||||
toggleClearButton();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadDicomViewer(images_to_load, annotations_to_load) {
|
||||
console.log("loadDicomViewer", images_to_load);
|
||||
let single_dicom = document.getElementById("single-dicom-viewer");
|
||||
@@ -201,7 +248,6 @@ function loadDicomViewer(images_to_load, annotations_to_load) {
|
||||
images = JSON.parse(images);
|
||||
}
|
||||
|
||||
console.log(images);
|
||||
|
||||
let annotations = single_dicom.dataset.annotations;
|
||||
if (annotations_to_load != undefined) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
let active_file_inputs = new Set();
|
||||
var active_file_inputs = new Set();
|
||||
|
||||
function add_input_form() {
|
||||
var form_idx = $('#id_images-TOTAL_FORMS').val();
|
||||
|
||||
@@ -47,6 +47,7 @@ class AnatomyQuestionTable(tables.Table):
|
||||
"author",
|
||||
)
|
||||
sequence = ("view", "image", "exams")
|
||||
order_by = "-created_date"
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(
|
||||
|
||||
@@ -231,6 +231,8 @@
|
||||
<p>This form can be used to create an Anatomy question.</p>
|
||||
|
||||
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p>
|
||||
|
||||
<p>Correct and incorrect answers can be defined once the question has been created.</p>
|
||||
</details>
|
||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -29,7 +29,10 @@
|
||||
{% include 'anatomy/question_link_header.html' %}
|
||||
{% endif %}
|
||||
<h2>Question Answers</h2>
|
||||
This page can be used to add and modify answers associated with a question. Answers in here will be used for automarking submitted answers so it is best not to remove. Updating an answer here will not automatically update a submitted answers (scores cache would need to be refreshed)
|
||||
<details class="help-text">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p>This page can be used to add and modify answers associated with a question. Answers in here will be used for automarking submitted answers so it is best not to remove. Updating an answer here will not automatically update a submitted answers (scores cache would need to be refreshed)</p>
|
||||
</details>
|
||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||
{% csrf_token %}
|
||||
<h3>Answers:</h3>
|
||||
@@ -45,7 +48,7 @@ This page can be used to add and modify answers associated with a question. Answ
|
||||
{{ answer_formset.management_form }}
|
||||
<input type="button" value="Add More Answers" id="add_more">
|
||||
<p>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
<input type="submit" class="submit-button btn btn-primary" value="Submit" name="submit">
|
||||
</p>
|
||||
</form>
|
||||
<div id="empty_form" style="display:none">
|
||||
|
||||
@@ -5,17 +5,40 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block navigation %}
|
||||
Anatomy:
|
||||
{% if request.user.is_authenticated %}
|
||||
<a href="{% url 'anatomy:index' %}">Packets</a> /
|
||||
<a href="{% url 'anatomy:exam_list' %}">Exams</a> /
|
||||
<a href="{% url 'anatomy:exam_create' %}" title="Create a new exam">Create Exam</a> /
|
||||
<a href="{% url 'anatomy:question_list' %}">Questions</a> /
|
||||
<a href="{% url 'anatomy:anatomy_question_create' %}" title="Create a new question">Create Question</a>
|
||||
{% if request.user.is_superuser %}
|
||||
/ <a href="{% url 'anatomy:user_answer_table_view' %}" title="User answers">Answers</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-dark submenu">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{% url 'anatomy:index' %}">Anatomy</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse">
|
||||
<ul class="navbar-nav">
|
||||
{% if request.user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'anatomy:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'anatomy:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{% url 'anatomy:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'anatomy:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'anatomy:user_answer_table_view' %}" title="User answers">Answers</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% if simple_content %}
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
<li data-question_pk={{question.pk}}>
|
||||
<span class="flex-col">
|
||||
<a href="{% url 'anatomy:exam_question_detail' pk=exam.pk sk=forloop.counter0 %}">
|
||||
<img src="{{ question.image|thumbnail_url:'exam-list' }}" alt="thumbail" />
|
||||
|
||||
{% if question.image %}
|
||||
<img src="{{ question.image|thumbnail_url:'exam-list' }}" alt="thumbail" />
|
||||
{% else %}
|
||||
No image added.
|
||||
{% endif %}
|
||||
|
||||
</a>
|
||||
</span>
|
||||
<span class="flex-col-4">
|
||||
@@ -37,22 +43,8 @@
|
||||
{{ author }},
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<p>
|
||||
<button id='button-edit-order' title='click and drag questions to change order' data-posturl="{% url 'anatomy:exam_json_edit' pk=exam.pk %}">Edit question order / Delete questions</button>
|
||||
</p>
|
||||
{% endif %}
|
||||
<div>
|
||||
JSON creation time: {{exam.json_creation_time}} ({{exam.json_creation_time|date:"c"}}),
|
||||
JSON creation id: {{exam.exam_json_id}}
|
||||
</div>
|
||||
<a href="{% url 'anatomy:exam_json' pk=exam.pk %}">JSON</a>
|
||||
<a href="{% url 'anatomy:exam_json_unbased' pk=exam.pk %}">JSON (unbased)</a>
|
||||
<a href="{% url 'anatomy:exam_json_recreate' pk=exam.pk %}">Refresh JSON cache</a>
|
||||
{% if request.user.is_superuser %}
|
||||
<button id='button-open-access' data-posturl="{% url 'anatomy:exam_json_edit' pk=exam.pk %}">Make questions open access</button>
|
||||
<button id='button-closed-access' data-posturl="{% url 'anatomy:exam_json_edit' pk=exam.pk %}">Make questions closed access</button>
|
||||
{% endif %}
|
||||
{% include 'generic/exam_footer.html' %}
|
||||
|
||||
|
||||
</div>
|
||||
{% include 'exam_overview_js.html' %}
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
<a href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a> /
|
||||
<a href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a> /
|
||||
{% endif %}
|
||||
<a href="{% url 'anatomy:anatomy_create_exam' pk=exam.pk %}">Add New Question</a>
|
||||
{% comment %} <a href="{% url 'anatomy:question_create_exam' pk=exam.pk %}" title="Add a new question directly into the exam.">Add New Question</a> {% endcomment %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
{% block content %}
|
||||
<div class="col-md-6">
|
||||
<h2>Marking question {{question_details.current}} of {{question_details.total}}</h2>
|
||||
<a href="{% url 'anatomy:question_detail' question.id %}" title="View the Question">View</a> <a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a> <a href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}" title="Edit the Question using the admin interface">Admin Edit</a>
|
||||
<a href="{% url 'anatomy:question_detail' question.id %}" title="View the Question">View</a>
|
||||
<a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
|
||||
{% if request.user.is_superuser %}<a href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}" title="Edit the Question using the admin interface">Admin Edit</a>{% endif %}
|
||||
<h3>{{ question.question_type }}</h3>
|
||||
{% if question.structure %}
|
||||
Structure: {{question.structure}}<br/>
|
||||
@@ -13,10 +15,36 @@
|
||||
{% if question.answer_help %}
|
||||
<details>
|
||||
<summary>Marking help:</summary>
|
||||
<div class="border border-secondary">
|
||||
{{question.answer_help|safe}}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
<details>
|
||||
<summary>Suggest incorrect answers:</summary>
|
||||
|
||||
{% for word in words_suggest_incorrect %}
|
||||
<span>{{word}}<span class="user-select-none" _="on click if #id_answer_suggest_incorrect == null then
|
||||
set btn to #answer-suggest-incorrect-button
|
||||
js(btn) btn.click() end
|
||||
repeat forever
|
||||
if #id_answer_suggest_incorrect != null then break end
|
||||
wait 0.1s
|
||||
end
|
||||
end
|
||||
if (#id_answer_suggest_incorrect's value == '')
|
||||
put (#id_answer_suggest_incorrect's value + my @data-word) into #id_answer_suggest_incorrect's value
|
||||
else
|
||||
put (#id_answer_suggest_incorrect's value + ',' + my @data-word) into #id_answer_suggest_incorrect's value
|
||||
end
|
||||
then remove closest parent <span/>" data-word='{{word}}'>(+)</span>,</span>
|
||||
{% endfor %}
|
||||
|
||||
<div class="border border-secondary">
|
||||
{% include "anatomy/question_detail.html#suggest-incorrect-form" %}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
@@ -36,22 +64,22 @@
|
||||
{% for answer in user_answers %}
|
||||
|
||||
{% if answer in answer_suggest_incorrect %}
|
||||
<li><pre><span class="answer incorrect suggest" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
|
||||
<li><pre><span class="answer incorrect suggest" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
|
||||
{% else %}
|
||||
<li><pre><span class="answer not-marked" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
|
||||
<li><pre><span class="answer not-marked" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
Marked:
|
||||
<ul id="marked-answer-list" class="answer-list">
|
||||
{% for answer in correct_answers %}
|
||||
<li><pre><span class="answer correct" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
|
||||
<li><pre><span class="answer correct" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
|
||||
{% endfor %}
|
||||
{% for answer in half_mark_answers %}
|
||||
<li><pre><span class="answer half-correct" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
|
||||
<li><pre><span class="answer half-correct" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
|
||||
{% endfor %}
|
||||
{% for answer in incorrect_answers %}
|
||||
<li><pre><span class="answer incorrect" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
|
||||
<li><pre><span class="answer incorrect" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="mark-buttons">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends 'anatomy/base.html' %}
|
||||
|
||||
{% load partials %}
|
||||
{% block content %}
|
||||
{% load static %}
|
||||
{% include 'anatomy/question_link_header.html' %}
|
||||
@@ -58,7 +58,14 @@
|
||||
Answer help: {{ question.answer_help|safe }}
|
||||
</div>
|
||||
<div>
|
||||
Answer suggest incorrect: {{ question.answer_suggest_incorrect }}
|
||||
{% partialdef suggest-incorrect-form inline %}
|
||||
<span id="incorrect-answers">
|
||||
Answer suggest incorrect: {{ question.answer_suggest_incorrect }}
|
||||
<button id="answer-suggest-incorrect-button" class="btn btn-sm" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}"
|
||||
hx-target="#incorrect-answers"
|
||||
hx-swap="innerHTML">Edit suggestions</button>
|
||||
</span>
|
||||
{% endpartialdef %}
|
||||
</div>
|
||||
<div>
|
||||
Description: {{ question.description }}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<form method="POST" class="post-form">{% csrf_token %}
|
||||
{{ form | crispy}}
|
||||
<button type="button" class="btn btn-sm" hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.id %}"
|
||||
hx-target="#incorrect-answers"
|
||||
hx-swap="innerHTML">Save</button>
|
||||
<button type="button" _="on click remove closest <form/>" >Cancel</button>
|
||||
</form>
|
||||
@@ -34,7 +34,7 @@ APP_NAME = "anatomy"
|
||||
|
||||
def create_exam(db):
|
||||
exam: Exam = AnatomyExamViews.Exam.objects.create(
|
||||
name="Cid Exam", exam_mode=True, active=True
|
||||
name="Cid Exam", exam_mode=True, active=True, open_access=False, exam_open_access=False
|
||||
)
|
||||
|
||||
group1: CidUserGroup = CidUserGroup.objects.get(name="Group1")
|
||||
@@ -194,6 +194,8 @@ def test_exams(db, client):
|
||||
exam.save()
|
||||
|
||||
res = client.get(exam_metadata["url"])
|
||||
print("PRE ERRER", exam_metadata["url"])
|
||||
print(res.content)
|
||||
assert res.status_code == 404
|
||||
|
||||
exam.active = True
|
||||
@@ -365,7 +367,7 @@ def test_exams(db, client):
|
||||
cid_exam_scores_soup = BeautifulSoup(cid_exam_scores_res.content, "html.parser")
|
||||
|
||||
# Check that we have an alert that exams are not published
|
||||
alert = cid_exam_scores_soup.find("div", {"class": "alert"})
|
||||
alert = cid_exam_scores_soup.find("div", {"class": "no-results"})
|
||||
assert "Results are not currently published." in str(alert)
|
||||
|
||||
answer_lis = cid_exam_scores_soup.find(
|
||||
|
||||
+9
-8
@@ -7,21 +7,16 @@ from django.views.decorators.cache import cache_page
|
||||
app_name = "anatomy"
|
||||
|
||||
urlpatterns = [
|
||||
# path('', views.question_list, name='question_list'),
|
||||
# path('', views.question_view, name='question_view'),
|
||||
path(
|
||||
"question/",
|
||||
views.AnatomyQuestionView.as_view(),
|
||||
name="question_list",
|
||||
name="question_view",
|
||||
),
|
||||
path(
|
||||
"question/create/",
|
||||
views.AnatomyQuestionCreate.as_view(),
|
||||
name="anatomy_question_create",
|
||||
),
|
||||
path(
|
||||
"create/exam/<int:pk>",
|
||||
views.AnatomyQuestionCreate.as_view(),
|
||||
name="anatomy_create_exam",
|
||||
name="question_create",
|
||||
),
|
||||
path(
|
||||
"question/<int:pk>/update",
|
||||
@@ -38,6 +33,12 @@ urlpatterns = [
|
||||
views.question_save_annotation,
|
||||
name="question_save_annotation",
|
||||
),
|
||||
path(
|
||||
"question/<int:pk>/suggest_incorrect_answers",
|
||||
views.question_suggest_incorrect_answers,
|
||||
name="question_suggest_incorrect_answers",
|
||||
),
|
||||
|
||||
path("question/<int:pk>/answer/", views.answer_question, name="answer_question"),
|
||||
path(
|
||||
"question/<int:pk>/clone", views.QuestionClone.as_view(), name="question_clone"
|
||||
|
||||
+67
-38
@@ -44,6 +44,7 @@ from .forms import (
|
||||
MarkAnatomyQuestionForm,
|
||||
AnatomyQuestionForm,
|
||||
ExamForm,
|
||||
SuggestIncorrectAnswerForm,
|
||||
)
|
||||
from .models import (
|
||||
AnatomyQuestion,
|
||||
@@ -80,10 +81,6 @@ from django.template.defaulttags import register
|
||||
|
||||
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
|
||||
|
||||
from rest_framework import viewsets, permissions
|
||||
|
||||
from .serializers import ExamSerializer
|
||||
|
||||
|
||||
class AuthorOrCheckerRequiredMixin(object):
|
||||
def get_object(self, *args, **kwargs):
|
||||
@@ -109,14 +106,14 @@ def user_is_admin(user):
|
||||
return False
|
||||
|
||||
|
||||
def question_list(request):
|
||||
def question_view(request):
|
||||
questions = AnatomyQuestion.objects.all()
|
||||
|
||||
if not request.user.groups.filter(name="anatomy_checker").exists():
|
||||
questions = questions.filter(open_access=False)
|
||||
# raise PermissionDenied()
|
||||
|
||||
return render(request, "anatomy/question_list.html", {"questions": questions})
|
||||
return render(request, "anatomy/question_view.html", {"questions": questions})
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -349,6 +346,8 @@ def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
|
||||
incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT)
|
||||
|
||||
answer_suggest_incorrect: list = []
|
||||
|
||||
|
||||
if review:
|
||||
for ans in unmarked_user_answers:
|
||||
# duplicated from user answer model....
|
||||
@@ -371,6 +370,25 @@ def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
|
||||
answer_suggest_incorrect.append(ans)
|
||||
break
|
||||
|
||||
words_present: set = set()
|
||||
words_present_in_correct: set = set()
|
||||
|
||||
for ans in correct_answers:
|
||||
answer_words = set(ans.get_constituent_words())
|
||||
words_present.update(answer_words)
|
||||
words_present_in_correct.update(answer_words)
|
||||
|
||||
for ans in half_mark_answers:
|
||||
answer_words = set(ans.get_constituent_words())
|
||||
words_present.update(answer_words)
|
||||
words_present_in_correct.update(answer_words)
|
||||
|
||||
for ans in incorrect_answers:
|
||||
answer_words = set(ans.get_constituent_words())
|
||||
words_present.update(answer_words)
|
||||
|
||||
words_suggest_incorrect = words_present - words_present_in_correct - set(question.answer_suggest_incorrect)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"anatomy/mark.html",
|
||||
@@ -389,6 +407,7 @@ def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
|
||||
"incorrect_answers": incorrect_answers,
|
||||
"unmarked_exam_answers_only": unmarked_exam_answers_only,
|
||||
"answer_suggest_incorrect": answer_suggest_incorrect,
|
||||
"words_suggest_incorrect": words_suggest_incorrect,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -514,27 +533,34 @@ class AnatomyQuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
|
||||
self.object = form.save(commit=False)
|
||||
self.object.save()
|
||||
|
||||
response = super().form_valid(form)
|
||||
form.instance.author.add(self.request.user.id)
|
||||
|
||||
context = self.get_context_data(form=form)
|
||||
formset = context["answer_formset"]
|
||||
#context = self.get_context_data(form=form)
|
||||
#formset = context["answer_formset"]
|
||||
|
||||
if formset.is_valid():
|
||||
response = super().form_valid(form)
|
||||
formset.instance = self.object
|
||||
formset.save()
|
||||
# If the normal submit button is pressed we save as normal
|
||||
if "submit" in self.request.POST:
|
||||
return response
|
||||
# else we redirect to the clone url
|
||||
else:
|
||||
return redirect("anatomy:question_clone", pk=self.object.pk)
|
||||
#if formset.is_valid():
|
||||
# response = super().form_valid(form)
|
||||
# formset.instance = self.object
|
||||
# formset.save()
|
||||
# # If the normal submit button is pressed we save as normal
|
||||
# if "submit" in self.request.POST:
|
||||
# return response
|
||||
# # else we redirect to the clone url
|
||||
# else:
|
||||
# return redirect("anatomy:question_clone", pk=self.object.pk)
|
||||
|
||||
#else:
|
||||
# return super().form_invalid(form)
|
||||
# If the normal submit button is pressed we save as normal
|
||||
if "submit" in self.request.POST:
|
||||
return response
|
||||
# else we redirect to the clone url
|
||||
else:
|
||||
return super().form_invalid(form)
|
||||
return redirect("anatomy:question_clone", pk=self.object.pk)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return redirect("anatomy:question_detail", pk=self.object.pk)
|
||||
return reverse("anatomy:question_detail", kwargs={"pk":self.object.pk})
|
||||
|
||||
|
||||
class AnatomyQuestionCreate(AnatomyQuestionCreateBase):
|
||||
@@ -745,6 +771,25 @@ class AnatomyQuestionView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
||||
|
||||
# return qs
|
||||
|
||||
@user_is_author_or_anatomy_checker
|
||||
def question_suggest_incorrect_answers(request, pk):
|
||||
question = get_object_or_404(AnatomyQuestion, pk=pk)
|
||||
if request.method == "POST":
|
||||
form = SuggestIncorrectAnswerForm(request.POST, instance=question)
|
||||
|
||||
if form.is_valid():
|
||||
obj = form.save()
|
||||
obj.save()
|
||||
|
||||
return render(request, "anatomy/question_detail.html#suggest-incorrect-form", {"question": question})
|
||||
|
||||
return HttpResponse("Invalid")
|
||||
else:
|
||||
form = SuggestIncorrectAnswerForm(instance=question)
|
||||
|
||||
return render(request, "anatomy/suggest_incorrect.html", {"form": form, "question": question})
|
||||
|
||||
return HttpResponse("Error", status=400)
|
||||
|
||||
@user_is_author_or_anatomy_checker
|
||||
def question_save_annotation(request, pk):
|
||||
@@ -787,7 +832,7 @@ class UserAnswerDelete(SuperuserRequiredMixin, DeleteView):
|
||||
|
||||
class QuestionDelete(AuthorOrCheckerRequiredMixin, DeleteView):
|
||||
model = AnatomyQuestion
|
||||
success_url = reverse_lazy("anatomy:question_list")
|
||||
success_url = reverse_lazy("anatomy:question_view")
|
||||
|
||||
|
||||
class ExamCreate(ExamCreateBase):
|
||||
@@ -811,22 +856,6 @@ class ExamDelete(AuthorOrCheckerRequiredMixin, ExamDeleteBase):
|
||||
|
||||
|
||||
|
||||
#class ExamViewSet(RevisionMixin, viewsets.ModelViewSet):
|
||||
# # queryset = Exam.objects.all().order_by('name')
|
||||
# serializer_class = ExamSerializer
|
||||
# permission_classes = [permissions.IsAuthenticated]
|
||||
#
|
||||
# def get_queryset(self):
|
||||
# """
|
||||
# This view should return a list exams available to a user.
|
||||
# """
|
||||
# user = self.request.user
|
||||
# if user.groups.filter(name="anatomy_checker").exists():
|
||||
# return Exam.objects.all()
|
||||
#
|
||||
# return Exam.objects.filter(author__id=user.id)
|
||||
|
||||
|
||||
class ExamAuthorUpdate(RevisionMixin, CheckCanEditMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView):
|
||||
model = Exam
|
||||
form_class = ExamAuthorForm
|
||||
@@ -908,7 +937,7 @@ def question_add_exam(request, question_id: int):
|
||||
else:
|
||||
# Return a list of exams that we can add to the question
|
||||
|
||||
exams = Exam.objects.filter(author=request.user) | Exam.objects.filter(open_access=True)
|
||||
exams = Exam.objects.filter(author=request.user)
|
||||
exams = exams.difference(question.exams.all())
|
||||
|
||||
if not exams:
|
||||
|
||||
@@ -87,6 +87,7 @@ class AtlasAdminForm(ModelForm):
|
||||
"subspecialty",
|
||||
"author",
|
||||
"series",
|
||||
"cimar_uuid"
|
||||
]
|
||||
|
||||
widgets = {
|
||||
|
||||
+26
-10
@@ -70,8 +70,10 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
||||
except DuplicateDicom as e:
|
||||
duplicate.append((file.name, ud.image_blake3_hash))
|
||||
|
||||
duplicate_series.add(e.duplicate.get_series().get_absolute_url())
|
||||
print(duplicate_series)
|
||||
|
||||
if type(e.duplicate) == SeriesImage:
|
||||
duplicate_series.add(e.duplicate.series.get_absolute_url())
|
||||
|
||||
pass
|
||||
except InvalidDicomError:
|
||||
failed.append(file.name)
|
||||
@@ -127,6 +129,9 @@ def uncategorised_dicoms(request):
|
||||
|
||||
|
||||
def import_dicoms_helper(request, case_id: int | None = None):
|
||||
# TODO: move this out of the api (would be better with HTMX)
|
||||
|
||||
|
||||
# dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||
if "selection" in request.POST:
|
||||
dicoms = UncategorisedDicom.objects.filter(
|
||||
@@ -149,7 +154,13 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
|
||||
series_list = []
|
||||
for series_uid in data:
|
||||
modality = get_object_or_404(Modality, short_code=tags["Modality"])
|
||||
# Hmm probably not a good idea to blinding create a modality...
|
||||
try:
|
||||
modality = Modality.objects.get(short_code=tags["Modality"])
|
||||
except Modality.DoesNotExist:
|
||||
modality = Modality.objects.create(short_code=tags["Modality"], modality=tags["Modality"])
|
||||
|
||||
|
||||
tags = data[series_uid][0][1]
|
||||
|
||||
# Check if series with the id already exists (in which case we just add to htat)
|
||||
@@ -175,13 +186,6 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
|
||||
series.save()
|
||||
|
||||
match order:
|
||||
case "order-series-instance-number":
|
||||
series.order_by_dicom("InstanceNumber")
|
||||
case "order-series-slice-location":
|
||||
series.order_by_dicom("SliceLocation")
|
||||
case _:
|
||||
pass
|
||||
|
||||
# We might only want to add the author during creation....
|
||||
series.author.add(request.user)
|
||||
@@ -202,6 +206,15 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
# dicom.image.delete()
|
||||
|
||||
dicom.delete()
|
||||
|
||||
match order:
|
||||
case "order-series-instance-number":
|
||||
series.order_by_dicom("InstanceNumber")
|
||||
case "order-series-slice-location":
|
||||
series.order_by_dicom("SliceLocation")
|
||||
case _:
|
||||
pass
|
||||
|
||||
series_list.append((series, series.get_link()))
|
||||
|
||||
return series_list
|
||||
@@ -264,6 +277,9 @@ def series_truncate(request, series_id: int, start: int, end: int):
|
||||
image.image = None
|
||||
image.save()
|
||||
images_removed.append(image.pk)
|
||||
|
||||
series.get_total_image_size(refresh=True)
|
||||
|
||||
return {"status": "success", "images_removed": images_removed}
|
||||
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ def get_series(request):
|
||||
else:
|
||||
queryset = Series.objects.all()
|
||||
|
||||
queryset = queryset.prefetch_related("examination", "plane")
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
|
||||
+108
-15
@@ -24,6 +24,7 @@ from atlas.models import (
|
||||
Case,
|
||||
CaseCollection,
|
||||
CaseDetail,
|
||||
CasePrior,
|
||||
CidReportAnswer,
|
||||
Differential,
|
||||
Finding,
|
||||
@@ -47,13 +48,15 @@ from generic.models import Examination#, Sign
|
||||
# from generic.models import Examination, Site, Condition, Sign
|
||||
|
||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||
from django.forms.widgets import RadioSelect, TextInput, Textarea
|
||||
from django.forms.widgets import RadioSelect, TextInput, Textarea, Select
|
||||
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML
|
||||
from crispy_forms.bootstrap import Accordion, AccordionGroup
|
||||
|
||||
from tinymce.widgets import TinyMCE
|
||||
|
||||
from dal import autocomplete
|
||||
|
||||
from autocomplete import widgets as htmx_widgets
|
||||
from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register
|
||||
|
||||
import logging
|
||||
|
||||
@@ -63,6 +66,35 @@ from crispy_forms.helper import FormHelper
|
||||
|
||||
from atlas.helpers import get_cases_available_to_user
|
||||
|
||||
from typing import Any
|
||||
|
||||
from helpers.cimar import CimarAPI, NotFoundError
|
||||
from rad.settings import CIMAR_USERNAME, CIMAR_PASSWORD
|
||||
|
||||
class ClearableInput(TextInput):
|
||||
template_name = "crispy_forms/widgets/clearable_input_single_line.html"
|
||||
|
||||
class ClearableTextarea(Textarea):
|
||||
template_name = "crispy_forms/widgets/clearable_input.html"
|
||||
|
||||
class CaseSelect(Select):
|
||||
template_name = "atlas/case_select_widget.html"
|
||||
|
||||
|
||||
def create_option(self, *args, **kwargs):
|
||||
option = super().create_option(*args, **kwargs)
|
||||
|
||||
print(option)
|
||||
|
||||
|
||||
return option
|
||||
|
||||
def get_context(self, name: str, value, attrs) -> dict[str, Any]:
|
||||
context = super().get_context(name, value, attrs)
|
||||
return context
|
||||
|
||||
|
||||
pass
|
||||
|
||||
class ConditionForm(ModelForm):
|
||||
class Meta:
|
||||
@@ -143,7 +175,7 @@ class StructureForm(ModelForm):
|
||||
class SeriesFindingForm(ModelForm):
|
||||
class Meta:
|
||||
model = SeriesFinding
|
||||
exclude = ["series", "annotation_json", "viewport_json", "current_image_id_index"]
|
||||
exclude = ["series", "annotation_json", "viewport_json", "current_image_id_index", "annotation_json_3d", "viewer_state_3d"]
|
||||
|
||||
widgets = {
|
||||
"findings": autocomplete.ModelSelect2Multiple(
|
||||
@@ -269,14 +301,16 @@ class SeriesForm(ModelForm):
|
||||
|
||||
class CaseForm(ModelForm):
|
||||
|
||||
# exams = ModelMultipleChoiceField(required=False, queryset=Exam.objects.all(),widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False))
|
||||
|
||||
|
||||
class Media:
|
||||
# Django also includes a few javascript files necessary
|
||||
# for the operation of this form element. You need to
|
||||
# include <script src="/admin/jsi18n"></script>
|
||||
# in the template.
|
||||
#history = forms.CharField(
|
||||
# widget=ClearableTextarea(attrs={"maxlength": 1000, "rows": 5, "cols": 80}),
|
||||
# required=False,
|
||||
#)
|
||||
|
||||
css = {
|
||||
"all": ["css/widgets.css"],
|
||||
}
|
||||
@@ -284,8 +318,6 @@ class CaseForm(ModelForm):
|
||||
js = ["jsi18n.js", "tesseract.min.js", "js/upload_form_helpers.js"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
logging.info("LOG")
|
||||
logging.debug(kwargs)
|
||||
self.user = kwargs.pop(
|
||||
"user"
|
||||
) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
|
||||
@@ -314,11 +346,41 @@ class CaseForm(ModelForm):
|
||||
# self.fields["series"].queryset =
|
||||
|
||||
if self.user.groups.filter(name="atlas_editor").exists():
|
||||
case_queryset = Case.objects.all().order_by("pk")
|
||||
case_queryset = Case.objects.all().order_by("pk").prefetch_related(
|
||||
)
|
||||
else:
|
||||
case_queryset = get_cases_available_to_user(self.user)
|
||||
self.fields["previous_case"].queryset=case_queryset
|
||||
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_id = "id-case-form"
|
||||
self.helper.form_class = "case-form"
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_action = "submit"
|
||||
self.helper.form_tag = False
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Fieldset(
|
||||
"Case details",
|
||||
"title",
|
||||
"subspecialty",
|
||||
"description",
|
||||
"history",
|
||||
"presentation",
|
||||
"discussion",
|
||||
"condition",
|
||||
"pathological_process",
|
||||
"report",
|
||||
"open_access",
|
||||
"previous_case",
|
||||
"diagnostic_certainty",
|
||||
"cimar_uuid",
|
||||
),
|
||||
|
||||
)
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
return (
|
||||
super()
|
||||
@@ -347,6 +409,7 @@ class CaseForm(ModelForm):
|
||||
"open_access",
|
||||
"previous_case",
|
||||
"diagnostic_certainty",
|
||||
"cimar_uuid"
|
||||
]
|
||||
# fields = ['question', 'findings', 'subspecialty', 'references']
|
||||
widgets = {
|
||||
@@ -355,9 +418,11 @@ class CaseForm(ModelForm):
|
||||
# (False, 'No')])
|
||||
# "findings": TinyMCE(attrs={"cols": 80, "rows": 20}),
|
||||
# "mark_scheme": TinyMCE(attrs={"cols": 80, "rows": 30}),
|
||||
"description": Textarea(attrs={"cols": 80, "rows": 5}),
|
||||
"history": Textarea(attrs={"cols": 80, "rows": 5}),
|
||||
"discussion": Textarea(attrs={"cols": 80, "rows": 5}),
|
||||
"title": ClearableInput(attrs={"rows": 1}),
|
||||
"description": ClearableTextarea(attrs={"cols": 80, "rows": 5}),
|
||||
"history": ClearableTextarea(attrs={"maxlength": 2000, "rows": 5}),
|
||||
"discussion": ClearableTextarea(attrs={"maxlength": 2000, "rows": 5}),
|
||||
"report": ClearableTextarea(attrs={"maxlength": 2000, "rows": 5}),
|
||||
"condition": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:condition-autocomplete"
|
||||
),
|
||||
@@ -365,8 +430,21 @@ class CaseForm(ModelForm):
|
||||
url="atlas:presentation-autocomplete"
|
||||
),
|
||||
"subspecialty": CheckboxSelectMultiple(),
|
||||
"pathological_process": CheckboxSelectMultiple()
|
||||
"pathological_process": CheckboxSelectMultiple(),
|
||||
"previous_case": CaseSelect(),
|
||||
}
|
||||
|
||||
def clean_cimar_uuid(self):
|
||||
cimar_uuid = self.cleaned_data["cimar_uuid"]
|
||||
if cimar_uuid:
|
||||
with CimarAPI(username=CIMAR_USERNAME, password=CIMAR_PASSWORD) as api:
|
||||
try:
|
||||
study = api.get_study_by_uuid(cimar_uuid)
|
||||
|
||||
except NotFoundError:
|
||||
raise ValidationError("Study not found in CIMAR")
|
||||
|
||||
return cimar_uuid
|
||||
|
||||
def save(self, commit=True):
|
||||
# Get the unsaved Case instance
|
||||
@@ -732,11 +810,17 @@ class UncategorisedDicomForm(ModelForm):
|
||||
# )
|
||||
# }
|
||||
|
||||
class ConditionAutocomplete(ModelAutocomplete):
|
||||
model = Condition
|
||||
search_attrs = ["name"]
|
||||
|
||||
class ConditionAutocompleteForm(Form):
|
||||
condition = ModelChoiceField(queryset=Condition.objects.all(), widget=htmx_widgets.Autocomplete(
|
||||
name="conditions", options=dict(model=Condition)
|
||||
condition = ModelChoiceField(queryset=Condition.objects.all(), widget=AutocompleteWidget(
|
||||
ac_class=ConditionAutocomplete#, options=dict(model=Condition)
|
||||
|
||||
))
|
||||
|
||||
autocomplete_register(ConditionAutocomplete)
|
||||
|
||||
class CaseCollectionAuthorForm(ExamAuthorFormMixin):
|
||||
class Meta(ExamAuthorFormMixin.Meta):
|
||||
@@ -785,10 +869,19 @@ class QuestionSchemaForm(ModelForm):
|
||||
"schema": SvelteJSONEditorWidgetOverride(),
|
||||
}
|
||||
|
||||
#class ModelPriorCaseForm(ModelForm):
|
||||
# class Meta:
|
||||
# model = CasePrior
|
||||
# fields = ["case_detail", "prior_case", "relation_text", "prior_visibility"]
|
||||
|
||||
class PriorCaseForm(Form):
|
||||
|
||||
relation = CharField(max_length=255, required=True)
|
||||
|
||||
prior_visibility = CharField(max_length=2, required=True)
|
||||
|
||||
|
||||
|
||||
#def __init__(self, *args, **kwargs):
|
||||
# self.case_detail = kwargs.pop(
|
||||
# "case_detail"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.0.2 on 2024-10-09 20:39
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0061_alter_seriesfinding_current_image_id_index'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='casecollection',
|
||||
name='markers',
|
||||
field=models.ManyToManyField(blank=True, help_text='Authorised markers for the exam', related_name='casecollection_markers', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-10-28 11:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0062_alter_casecollection_markers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='results_supervisor_visible',
|
||||
field=models.BooleanField(default=False, help_text='If true the results will be visible to supervisors without candidate approval.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-11-18 09:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0063_casecollection_results_supervisor_visible'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='exam_open_access',
|
||||
field=models.BooleanField(default=False, help_text='Set to true if you want any registered user to be able to take the exam.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-12-18 23:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0064_casecollection_exam_open_access'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='series',
|
||||
name='total_image_size',
|
||||
field=models.BigIntegerField(blank=True, help_text='Stores a (cached) total size of images within the series', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-12-18 23:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0065_series_total_image_size'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='case',
|
||||
name='total_images_series_size',
|
||||
field=models.BigIntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-10 13:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0066_case_total_images_series_size'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='case',
|
||||
name='cimar_uuid',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-04-07 11:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0067_case_cimar_uuid'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='seriesfinding',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, help_text='Findings on the question', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-04-28 15:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0068_alter_seriesfinding_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='caseprior',
|
||||
name='prior_visibility',
|
||||
field=models.CharField(choices=[('NO', 'None'), ('AL', 'Always'), ('RE', 'Review')], default='AL', help_text='Defines when the prior case is shown to the user', max_length=2),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.4 on 2025-06-09 10:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0069_caseprior_prior_visibility'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='seriesfinding',
|
||||
name='annotation_json_3d',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='seriesfinding',
|
||||
name='viewer_state_3d',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
]
|
||||
+100
-34
@@ -46,11 +46,13 @@ from generic.models import (
|
||||
CidUser,
|
||||
CidUserExam,
|
||||
CidUserGroup,
|
||||
CimarCase,
|
||||
ExamOrCollectionGenericBase,
|
||||
ExamUserStatus,
|
||||
Examination,
|
||||
# Condition,
|
||||
ExamBase,
|
||||
FindingBase,
|
||||
Plane,
|
||||
Contrast,
|
||||
QuestionNote,
|
||||
@@ -390,6 +392,10 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
|
||||
resource = models.ManyToManyField("Resource", through="CaseResource")
|
||||
|
||||
total_images_series_size = models.BigIntegerField(null=True, blank=True)
|
||||
|
||||
cimar_uuid = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
def get_app_name(self):
|
||||
return "atlas"
|
||||
|
||||
@@ -402,6 +408,20 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
# return f"{self.pk}: {self.title}"
|
||||
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), str(self))
|
||||
|
||||
def get_cimar_case(self, raise_404_if_not_found=False):
|
||||
if raise_404_if_not_found:
|
||||
return get_object_or_404(CimarCase, uuid=self.cimar_uuid)
|
||||
|
||||
try:
|
||||
return CimarCase.objects.get(uuid=self.cimar_uuid)
|
||||
except CimarCase.DoesNotExist:
|
||||
return None
|
||||
|
||||
def get_cimar_case_details(self):
|
||||
cimar_case = self.get_cimar_case(raise_404_if_not_found=True)
|
||||
return cimar_case.study_details
|
||||
|
||||
|
||||
# def get_base_template(self):
|
||||
|
||||
# def get_edit_template_links(self):
|
||||
@@ -417,7 +437,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
for s in self.series.all():
|
||||
html += s.get_block()
|
||||
|
||||
return html
|
||||
return mark_safe(html)
|
||||
|
||||
def get_long_str(self):
|
||||
examinations = [series.get_examination() for series in self.series.all()]
|
||||
@@ -425,6 +445,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
|
||||
def get_case_dicom_json(self, case_title_as_patient_name=True, priors=None):
|
||||
series_json = []
|
||||
series: Series
|
||||
for series in self.series.all():
|
||||
series_json.append(series.get_series_dicom_json())
|
||||
|
||||
@@ -495,6 +516,36 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
def get_series(self):
|
||||
return self.series.all().prefetch_related("images", "examination", "plane")
|
||||
|
||||
def get_series_modalities(self) -> set:
|
||||
"""Returns a list of the modalities of the series in the case"""
|
||||
series = self.series.all()
|
||||
modalities = [s.modality.short_code for s in series if s.modality is not None]
|
||||
|
||||
return set(modalities)
|
||||
|
||||
|
||||
def get_total_series_images_size(self, refresh=False):
|
||||
if self.total_images_series_size is not None and not refresh:
|
||||
return self.total_images_series_size
|
||||
|
||||
series = self.series.all()
|
||||
size = sum(s.get_total_image_size(refresh=refresh) for s in series)
|
||||
|
||||
return size
|
||||
|
||||
def check_user_can_edit(self, user):
|
||||
if user.is_superuser:
|
||||
return True
|
||||
|
||||
if self.author.filter(id=user.id).exists():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def extract_image_dicom_json_from_ds(ds, url, image_index):
|
||||
to_keep = [
|
||||
@@ -555,24 +606,6 @@ class SeriesImage(SeriesImageBase):
|
||||
help_text="Reference to the object that has replaced this one.",
|
||||
)
|
||||
|
||||
def get_file_size(self):
|
||||
try:
|
||||
return self.image.size
|
||||
|
||||
# We catch this for when the image does not exist
|
||||
except FileNotFoundError:
|
||||
return 0
|
||||
|
||||
def get_dicom_data(self):
|
||||
try:
|
||||
with pydicom.dcmread(self.image) as d:
|
||||
return d
|
||||
except InvalidDicomError:
|
||||
return {}
|
||||
|
||||
def get_series(self):
|
||||
return self.series
|
||||
|
||||
# def get_image_dicom_json(self, image_index):
|
||||
# try:
|
||||
# with pydicom.dcmread(self.image) as ds:
|
||||
@@ -585,29 +618,20 @@ class SeriesImage(SeriesImageBase):
|
||||
# }}
|
||||
|
||||
|
||||
class SeriesFinding(models.Model):
|
||||
class SeriesFinding(FindingBase):
|
||||
series = models.ForeignKey(
|
||||
"Series", related_name="findings", on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
description = models.TextField(
|
||||
null=True, blank=True, help_text="Findings on the series / stack"
|
||||
)
|
||||
findings = models.ManyToManyField(Finding, blank=True)
|
||||
structures = models.ManyToManyField(Structure, blank=True)
|
||||
conditions = models.ManyToManyField(Condition, blank=True)
|
||||
annotation_json = models.TextField(null=True, blank=True)
|
||||
viewport_json = models.TextField(null=True, blank=True)
|
||||
# This has been migrated to install the image id (not index)
|
||||
current_image_id_index = models.TextField(null=True, blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
findings = self.findings.all().values_list("name")
|
||||
if self.series is None:
|
||||
return f"SeriesFinding: {findings}/{self.description} (no series)"
|
||||
return f"SeriesFinding: {self.series.id}/{findings}/{self.description}"
|
||||
|
||||
def annotation_as_string(self) -> str:
|
||||
return json.dumps(self.annotation_json)
|
||||
|
||||
|
||||
@reversion.register
|
||||
class Series(SeriesBase):
|
||||
modality = models.ForeignKey(
|
||||
@@ -651,6 +675,11 @@ class Series(SeriesBase):
|
||||
series_instance_uid = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
# findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack")
|
||||
def __str__(self) -> str:
|
||||
if self.description:
|
||||
return self.description
|
||||
else:
|
||||
return f"{self.examination} ({self.plane})"
|
||||
|
||||
def get_full_str(self):
|
||||
if self.case:
|
||||
@@ -747,6 +776,21 @@ class Series(SeriesBase):
|
||||
def get_link_headers(self):
|
||||
return "atlas/series_headers.html"
|
||||
|
||||
def get_related_findings(self):
|
||||
"""Returns the related findings for the series, including findings from other series in the same case."""
|
||||
# Get findings directly related to this series
|
||||
findings = list(self.findings.all())
|
||||
|
||||
# Get findings from other series in the same case
|
||||
if hasattr(self, "case") and self.case.exists():
|
||||
for case in self.case.all():
|
||||
for other_series in case.series.exclude(pk=self.pk):
|
||||
findings.extend(other_series.findings.all())
|
||||
|
||||
# Remove duplicates (by pk)
|
||||
unique_findings = {f.pk: f for f in findings}.values()
|
||||
return list(unique_findings)
|
||||
|
||||
|
||||
|
||||
class CaseCollection(ExamOrCollectionGenericBase):
|
||||
@@ -796,7 +840,6 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
||||
markers = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Authorised markers for the exam",
|
||||
related_name="casecollection_markers",
|
||||
)
|
||||
@@ -1092,6 +1135,19 @@ class CasePrior(models.Model):
|
||||
|
||||
relation_text = models.CharField(max_length=255, blank=True, help_text="Text to describe the relationship between the cases")
|
||||
|
||||
class PriorVisibility(models.TextChoices):
|
||||
NONE = "NO", _("None")
|
||||
ALWAYS = "AL", _("Always")
|
||||
REVIEW = "RE", _("Review")
|
||||
|
||||
|
||||
prior_visibility = models.CharField(
|
||||
max_length=2,
|
||||
choices=PriorVisibility.choices,
|
||||
default=PriorVisibility.ALWAYS,
|
||||
help_text="Defines when the prior case is shown to the user",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("case_detail", "prior_case")
|
||||
|
||||
@@ -1302,7 +1358,10 @@ class UncategorisedDicom(models.Model):
|
||||
if self.image:
|
||||
ds = self.get_dicom_info()
|
||||
|
||||
self.series_instance_uid = ds["SeriesInstanceUID"].value
|
||||
try:
|
||||
self.series_instance_uid = ds["SeriesInstanceUID"].value
|
||||
except AttributeError:
|
||||
raise InvalidDicomError("Invalid dicom file")
|
||||
|
||||
# if self.check_for_duplicates():
|
||||
# return DuplicateDicom
|
||||
@@ -1327,9 +1386,11 @@ class UncategorisedDicom(models.Model):
|
||||
|
||||
def get_dicom_info(self):
|
||||
try:
|
||||
ds = pydicom.read_file(self.image)
|
||||
ds = pydicom.dcmread(self.image)
|
||||
return ds
|
||||
except pydicom.errors.InvalidDicomError:
|
||||
print("Error getting dicom dataset")
|
||||
print(self.image)
|
||||
return {"SeriesInstanceUID": "1234"}
|
||||
|
||||
def get_basic_dicom_tags(self, dataset=None):
|
||||
@@ -1361,6 +1422,11 @@ class UncategorisedDicom(models.Model):
|
||||
def get_series(self):
|
||||
return self.series
|
||||
|
||||
def get_dicom_url(self):
|
||||
return f"{REMOTE_URL}{self.image.url}"
|
||||
|
||||
|
||||
|
||||
|
||||
class DuplicateDicom(Exception):
|
||||
def __init__(self, duplicate):
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# serializers.py
|
||||
from rest_framework import serializers, permissions
|
||||
+126
-15
@@ -22,11 +22,64 @@ from easy_thumbnails.files import get_thumbnailer
|
||||
|
||||
from easy_thumbnails.exceptions import InvalidImageFormatError
|
||||
|
||||
|
||||
|
||||
from django.db.models import Prefetch
|
||||
|
||||
|
||||
class CaseTable(tables.Table):
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
# Optimize prefetching for casecollection_set
|
||||
#case_collections_prefetch = Prefetch(
|
||||
# "casecollection_set",
|
||||
# queryset=CaseCollection.objects.select_related("author"), # Adjust fields as needed
|
||||
#)
|
||||
|
||||
super().__init__(
|
||||
data.prefetch_related(
|
||||
"author", # Many-to-many or reverse relationship
|
||||
"series", # Many-to-many relationship
|
||||
"series__examination", # ForeignKey in the related model
|
||||
"series__plane", # ForeignKey in the related model
|
||||
"series__images", # Reverse relationship
|
||||
"previous_case", # Prefetch the previous case relationship
|
||||
"next_case", # Prefetch the next case relationship
|
||||
"casecollection_set", # Prefetch the case collection set
|
||||
#case_collections_prefetch, # Optimized prefetch for collections
|
||||
),
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def render_title(self, value, record):
|
||||
return value
|
||||
|
||||
def render_description(self, value):
|
||||
max_length = 50 # Set the maximum length for truncation
|
||||
if len(value) > max_length:
|
||||
truncated_value = f"{value[:max_length]}..." # Truncate and add ellipsis
|
||||
else:
|
||||
truncated_value = value
|
||||
# Add a tooltip with the full text
|
||||
return format_html(
|
||||
'<span title="{}">{}</span>',
|
||||
value, # Full text for the tooltip
|
||||
truncated_value, # Truncated text for display
|
||||
)
|
||||
|
||||
def render_series(self, value):
|
||||
max_length = 50 # Set the maximum length for truncation
|
||||
series_list = [str(series) for series in value.all()]
|
||||
full_text = ", ".join(series_list) # Full text for the tooltip
|
||||
if len(full_text) > max_length:
|
||||
truncated_text = f"{full_text[:max_length]}..." # Truncate and add ellipsis
|
||||
else:
|
||||
truncated_text = full_text
|
||||
# Add a tooltip with the full text
|
||||
return format_html(
|
||||
'<span title="{}">{}</span>',
|
||||
full_text, # Full text for the tooltip
|
||||
truncated_text, # Truncated text for display
|
||||
)
|
||||
|
||||
def get_view_cell(record):
|
||||
if record.open_access:
|
||||
return format_html(f"""<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">
|
||||
@@ -36,6 +89,71 @@ class CaseTable(tables.Table):
|
||||
else:
|
||||
return f'View'
|
||||
|
||||
def render_author(self, value):
|
||||
max_length = 15 # Set the maximum length for truncating individual author names
|
||||
authors = []
|
||||
for author in value.all():
|
||||
full_name = str(author) # Convert the author object to a string (e.g., full name)
|
||||
if len(full_name) > max_length:
|
||||
truncated_name = f"{full_name[:max_length]}..." # Truncate and add ellipsis
|
||||
else:
|
||||
truncated_name = full_name
|
||||
# Add a tooltip with the full name
|
||||
authors.append(
|
||||
format_html(
|
||||
'<span title="{}">{}</span>',
|
||||
full_name, # Full name for the tooltip
|
||||
truncated_name, # Truncated name for display
|
||||
)
|
||||
)
|
||||
# Join all authors with commas
|
||||
return format_html(", ".join(authors))
|
||||
|
||||
def render_associated_cases(self, record):
|
||||
links = []
|
||||
if record.previous_case:
|
||||
links.append(
|
||||
format_html(
|
||||
'<a href="{}" title="View previous case">Previous Case</a>',
|
||||
record.previous_case.get_absolute_url(),
|
||||
)
|
||||
)
|
||||
try:
|
||||
if record.next_case:
|
||||
links.append(
|
||||
format_html(
|
||||
'<a href="{}" title="View next case">Next Case</a>',
|
||||
record.next_case.get_absolute_url(),
|
||||
)
|
||||
)
|
||||
except Case.DoesNotExist:
|
||||
pass
|
||||
if links:
|
||||
return format_html(", ".join(links))
|
||||
return "No associated cases"
|
||||
|
||||
def render_collections(self, record):
|
||||
collections = record.casecollection_set.all()
|
||||
if collections:
|
||||
links = [
|
||||
format_html(
|
||||
'<a href="{}" title="View collection">{}</a>',
|
||||
collection.get_absolute_url(),
|
||||
collection.name,
|
||||
)
|
||||
for collection in collections
|
||||
]
|
||||
return format_html(", ".join(links))
|
||||
return "No collections"
|
||||
|
||||
associated_cases = tables.Column(
|
||||
verbose_name="Associated Cases", accessor="pk", orderable=False
|
||||
)
|
||||
|
||||
collections = tables.Column(
|
||||
verbose_name="Collections", accessor="pk", orderable=False
|
||||
)
|
||||
|
||||
edit = tables.LinkColumn(
|
||||
"atlas:case_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
@@ -49,8 +167,6 @@ class CaseTable(tables.Table):
|
||||
"atlas:case_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
|
||||
#differential = tables.ManyToManyColumn(verbose_name="Differential")
|
||||
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
class Meta:
|
||||
@@ -59,21 +175,15 @@ class CaseTable(tables.Table):
|
||||
fields = (
|
||||
"title",
|
||||
"description",
|
||||
#"history",
|
||||
#"condition",
|
||||
"created_date",
|
||||
"series",
|
||||
"author",
|
||||
"associated_cases",
|
||||
"collections",
|
||||
)
|
||||
sequence = ("view", )#, "series")
|
||||
sequence = ("view", )
|
||||
order_by = "-created_date"
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(
|
||||
data.prefetch_related(
|
||||
"author",#"condition", "condition__synonym"
|
||||
),
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
class PopupLinkColumn(tables.Column):
|
||||
def render(self, value):
|
||||
@@ -125,6 +235,7 @@ class SeriesTable(tables.Table):
|
||||
"modified_date",
|
||||
)
|
||||
sequence = ("view", "popup", "images", "case")
|
||||
order_by = "-created_date"
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from time import sleep
|
||||
from django.core.mail import send_mail
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from celery import shared_task
|
||||
from atlas.models import Case
|
||||
from generic.models import CimarCase
|
||||
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
|
||||
from helpers.cimar import CimarAPI, NotFoundError
|
||||
from pydicom.uid import generate_uid
|
||||
|
||||
@shared_task()
|
||||
def push_case_to_cimar_task(case_id):
|
||||
"""Sends an email when the feedback form has been submitted."""
|
||||
case = get_object_or_404(Case, pk=case_id)
|
||||
|
||||
api = CimarAPI()
|
||||
api.login(username=CIMAR_USERNAME, password=CIMAR_PASSWORD)
|
||||
|
||||
# We use the same study_uid for all images in a case
|
||||
study_uid = generate_uid()
|
||||
|
||||
for series in case.get_series():
|
||||
print(f"Upload series: {series}")
|
||||
for image in series.images.filter(removed=False):
|
||||
data = api.upload_dicom(image.image.path, study_uid=study_uid)
|
||||
|
||||
|
||||
retries = 5
|
||||
delay = 20
|
||||
for attempt in range(retries):
|
||||
print("attempt 1")
|
||||
try:
|
||||
cimar_uuid = api.get_study_by_study_uid(data["study_uid"])["uuid"]
|
||||
break
|
||||
except NotFoundError:
|
||||
if attempt < retries - 1:
|
||||
sleep(delay)
|
||||
delay *= 2
|
||||
else:
|
||||
cimar_uuid = api.get_study_by_study_uid(data["study_uid"])["uuid"]
|
||||
|
||||
|
||||
case.cimar_uuid = cimar_uuid
|
||||
|
||||
case.save()
|
||||
|
||||
cimar_case, created = CimarCase.objects.get_or_create(uuid=cimar_uuid)
|
||||
|
||||
cimar_case.refresh_study()
|
||||
|
||||
return 10
|
||||
@@ -5,19 +5,83 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block navigation %}
|
||||
Atlas:
|
||||
{% if request.user.is_authenticated %}
|
||||
<a href="{% url 'atlas:collection_view' %}">Collections</a> /
|
||||
<a href="{% url 'atlas:case_view' %}?sort=-created_date">Cases</a> /
|
||||
<a href="{% url 'atlas:series_view' %}">Series</a> /
|
||||
<a href="{% url 'atlas:categories_list' %}">Categories</a> /
|
||||
{% comment %} <a href="{% url 'atlas:resource_view' %}">Resources</a> / {% endcomment %}
|
||||
<a href="{% url 'atlas:case_create' %}" title="Create a new atlas case">Create Case</a> /
|
||||
<a href="{% url 'atlas:series_create' %}" title="Create a new image series">Create Series</a> /
|
||||
<a href="{% url 'atlas:resource_view' %}" title="Resources">Resources</a> /
|
||||
<a href="{% url 'atlas:user_uploads' %}" title="View unimported uploads">Uploads</a> /
|
||||
<a href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas">Question Schemas</a> /
|
||||
<a href="{% url 'atlas:help' %}" title="View the atlas help pages">Help</a>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark submenu">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{% url 'atlas:index' %}">Atlas</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:collection_view' %}">Collections</a>
|
||||
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</a>
|
||||
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:user_collections' %}">Collections to view / take</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:collection_view' %}">Manage Collections</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:exam_create' %}">Create collection</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:case_view' %}">Cases</a>
|
||||
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</a>
|
||||
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:case_view' %}">Manage Cases</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:case_create' %}">Create Case</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:series_view' %}">Series</a>
|
||||
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</a>
|
||||
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:series_view' %}">Manage Series</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:series_create' %}">Create Series</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:categories_list' %}">Categories</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:resource_view' %}" title="Resources">Resources</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads">Uploads</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas">Question Schemas</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:help' %}" title="View the atlas help pages">Help</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% comment %} </br>
|
||||
Questions by:
|
||||
|
||||
@@ -1,5 +1,110 @@
|
||||
{% load partials %}
|
||||
|
||||
{% partialdef case-series %}
|
||||
{% for series in case.series.all %}
|
||||
<span class="series-block" id="series-block-{{ series.pk }}">
|
||||
<span>
|
||||
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
|
||||
<a href="{% url 'atlas:series_detail' pk=series.pk %}">
|
||||
{{series.get_block}}
|
||||
</a>
|
||||
<br>
|
||||
<input type="checkbox" name="series-ids" value="{{series.pk}}">
|
||||
<span class="series-block-popup-link">
|
||||
<a href="#"
|
||||
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% endpartialdef %}
|
||||
|
||||
<div class="atlas {% if case.scrapped %}atlas-scrapped{% endif %}">
|
||||
<div><a href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">View case in OHIF</a> <a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">(new tab)</a>
|
||||
<h2>Case: {{ case.title }}</h2>
|
||||
{% if case.previous_case or case.next_case %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
This case is part of a <a href='{% url "atlas:linked_cases_overview" case.pk %}'>series.</a><br/>
|
||||
<div class="row">
|
||||
{% if case.previous_case %}
|
||||
<div class="col-sm-6 mb-3 mb-sm-0">
|
||||
<h5 class="card-title">Previous Case</h5>
|
||||
{{ case.previous_case.get_link }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if case.next_case %}
|
||||
<div class="col-sm-6">
|
||||
<h5 class="card-title">Next Case</h5>
|
||||
{{ case.next_case.get_link }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
|
||||
{% if case.cimar_uuid %}
|
||||
|
||||
<details><summary>Details</summary>
|
||||
Case uuid: {{case.cimar_uuid}}
|
||||
<br/>
|
||||
|
||||
{% if cimar_sid %}
|
||||
<a href="{{case.get_cimar_case.viewer_link}}?sid={{cimar_sid}}" target="_blank">External</a>
|
||||
{% else %}
|
||||
<a href="{{case.get_cimar_case.viewer_link}}" target="_blank">External</a>
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
<button
|
||||
hx-get='{% url "cimar_study_viewer_embed" case.cimar_uuid %}'
|
||||
hx-target="#modals-here"
|
||||
hx-trigger="click"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#modals-here"
|
||||
class="btn primary">Open Modal</button>
|
||||
|
||||
<div id="modals-here"
|
||||
class="modal modal-blur fade"
|
||||
style="display: none"
|
||||
aria-hidden="false"
|
||||
tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div class="modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href='{% url "cimar_study_viewer" case.cimar_uuid %}'>Viewer</a>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Study details</summary>
|
||||
{{ case.get_cimar_case.get_pretty_study_details }}
|
||||
</details>
|
||||
<details>
|
||||
<summary>Study schema</summary>
|
||||
{{ case.get_cimar_case.get_pretty_study_schema }}
|
||||
</details>
|
||||
<details>
|
||||
<summary>Study series</summary>
|
||||
<button
|
||||
id='load-series'
|
||||
hx-get='{% url "cimar_study_series_block" case.cimar_uuid %}'
|
||||
hx-trigger='load'
|
||||
hx-swap='outerHTML'>
|
||||
Load Series
|
||||
</button>
|
||||
</details>
|
||||
<a href='{% url "generic:cimar_case_refresh" case.cimar_uuid %}'>Refresh</a>
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
|
||||
{% else %}
|
||||
<a href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">View case in OHIF</a> <a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">(new tab)</a>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="date">
|
||||
{{ case.created_date|date:"d/m/Y" }}
|
||||
@@ -7,7 +112,6 @@
|
||||
<div class="id">
|
||||
ID: {{ case.id }}
|
||||
</div>
|
||||
<p class="pre-whitespace"><b>Title:</b> {{ case.title }}</p>
|
||||
|
||||
<p class="pre-whitespace"><b>Description:</b> {{ case.description }}</p>
|
||||
|
||||
@@ -20,11 +124,11 @@
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
<p class="pre-whitespace"><b>History:</b> {{ case.history }}</p>
|
||||
<p class="pre-whitespace"><b>History:</b> {{ case.history |linebreaks}}</p>
|
||||
|
||||
<p class="pre-whitespace"><b>Discussion:</b> {{ case.discussion }}</p>
|
||||
<p class="pre-whitespace"><b>Discussion:</b> {{ case.discussion |linebreaks}}</p>
|
||||
|
||||
<p class="pre-whitespace"><b>Report:</b> {{ case.report }}</p>
|
||||
<p class="pre-whitespace"><b>Report:</b> {{ case.report |linebreaks}}</p>
|
||||
<p class="pre-whitespace"><b>Resource</b><br/>{% for caseresource in case.caseresource_set.all %}
|
||||
<a href='{% url "atlas:resource_detail" caseresource.resource.pk %}'>{{caseresource.resource.name}}</a>
|
||||
{% if caseresource.pre_review %}
|
||||
@@ -37,27 +141,12 @@
|
||||
|
||||
<div class="pre-whitespace multi-image-block"><b>Series:</b>
|
||||
<form>
|
||||
{% for series in case.series.all %}
|
||||
<span class="series-block">
|
||||
<span>
|
||||
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
|
||||
<a href="{% url 'atlas:series_detail' pk=series.pk %}">
|
||||
{{series.get_block}}
|
||||
</a>
|
||||
<br>
|
||||
<input type="checkbox" name="series-ids" value="{{series.pk}}">
|
||||
<span class="series-block-popup-link">
|
||||
<a href="#"
|
||||
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% partial case-series %}
|
||||
</form>
|
||||
<span>
|
||||
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Add new series</a><br />
|
||||
<a href="{% url 'atlas:user_uploads_case' case_id=case.pk %}">Import new uploads</a><br />
|
||||
<details>
|
||||
<details class="series-actions" id="series-actions">
|
||||
<summary>+</summary>
|
||||
<button hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
|
||||
title="order dicom by slice location"
|
||||
@@ -103,14 +192,31 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div>
|
||||
<details>
|
||||
<details open>
|
||||
<summary><b>Findings</b></summary>
|
||||
|
||||
{% if can_edit %}
|
||||
<details class="help-text">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p>Findings are display sets that can be added to series. Once added they will appear here on linked cases.</p>
|
||||
<p>To add a finding to a series, select the series and then click the "Add finding" button.</p>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% if case.series.all %}
|
||||
{% for series in case.series.all %}
|
||||
{% for finding in series.findings.all %}
|
||||
<div class="finding-box">
|
||||
<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>
|
||||
<a href="{{series.get_absolute_url}}">Series {{series.pk}}</a>: {{series.get_examination_full}} |
|
||||
<a href="{{series.get_absolute_url}}?show_finding={{finding.pk}}"><button class="btn btn-primary btn-sm">View</button></a>
|
||||
<button class="btn btn-secondary btn-sm view-finding-modal" data-finding-id="{{ finding.pk }}" data-series-id="{{ series.pk }}">
|
||||
View in Modal
|
||||
</button>
|
||||
</span>
|
||||
{% if finding.findings.all %}
|
||||
<span>
|
||||
@@ -119,6 +225,13 @@
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if finding.conditions.all %}
|
||||
<span>
|
||||
Conditions: {% for c in finding.conditions.all %}
|
||||
{{c.get_link}},
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if finding.structures.all %}
|
||||
<span>
|
||||
Structure: {% for s in finding.structures.all %}
|
||||
@@ -165,19 +278,43 @@
|
||||
</ul>
|
||||
<b>Diagnostic certainty:</b> {{case.get_diagnostic_certainty_display}}<br />
|
||||
<b>Collections:</b>
|
||||
<button
|
||||
hx-get="{% url 'atlas:case_collection_form' case.pk %}"
|
||||
hx-target="#collection-form">
|
||||
Add collection</button>
|
||||
|
||||
{% if can_edit %}
|
||||
<button
|
||||
hx-get="{% url 'atlas:case_collection_form' case.pk %}"
|
||||
hx-target="#collection-form">
|
||||
Add to collection</button>
|
||||
{% endif %}
|
||||
<div id="collection-form"></div>
|
||||
<ul>
|
||||
{% for collection in case.casecollection_set.all %}
|
||||
<li><a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{collection.name}}</a></li>
|
||||
<li id="collection-{{ collection.pk }}">
|
||||
<a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{ collection.name }}</a>
|
||||
{% if can_edit %}
|
||||
<button
|
||||
class="btn btn-danger btn-sm remove-button"
|
||||
hx-post="{% url 'atlas:remove_case_from_collection' case.pk collection.pk %}"
|
||||
hx-target="#collection-{{ collection.pk }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to remove this case from the collection?">
|
||||
Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
<p class="pre-whitespace"><b>Previous case:</b> {{ case.previous_case.get_link }}</p>
|
||||
<p class="pre-whitespace"><b>Next case:</b> {{ case.next_case.get_link }}</p>
|
||||
<p class="pre-whitespace"><b>Next case:</b>
|
||||
{% if case.next_case %}
|
||||
{{ case.next_case.get_link }}
|
||||
{% else %}
|
||||
<a href="{% url 'atlas:case_clone_next' case.pk %}">Clone and add next case</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<div>
|
||||
{% include 'question_notes.html' %}
|
||||
|
||||
@@ -188,3 +325,146 @@
|
||||
|
||||
<p><b>Author(s):</b> {% for author in case.author.all %} <a
|
||||
href="{% url 'atlas:author_detail' pk=author.pk %}">{{author}}</a>, {% endfor %}</p>
|
||||
|
||||
|
||||
<p><b>Case size:</b> {{ case.get_total_series_images_size | filesizeformat }}</p>
|
||||
|
||||
<div class="modal fade" id="findingModal" tabindex="-1" aria-labelledby="findingModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="findingModalLabel">Finding Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="findingModalBody">
|
||||
<!-- Finding details will be loaded here dynamically -->
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// From series_viewer.html
|
||||
function loadAnnotationAndViewportOnElement(annotation, viewport, dicom_element, current_image_id_index) {
|
||||
cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
|
||||
cornerstone.getEnabledElement(dicom_element).viewport = viewport
|
||||
cornerstoneTools.globalImageIdSpecificToolStateManager.restoreToolState(
|
||||
annotation);
|
||||
console.log("annotation", annotation)
|
||||
console.log("viewport", viewport)
|
||||
|
||||
//cornerstone.getEnabledElement(dicom_element).toolStateManager.restoreToolState(annotationjson)
|
||||
cornerstone.resize(dicom_element);
|
||||
console.log("current_image_id_index", current_image_id_index)
|
||||
//current_image_id_index = parseInt(current_image_id_index);
|
||||
|
||||
console.log(current_image_id_index, Number.isInteger(current_image_id_index))
|
||||
if (Number.isInteger(current_image_id_index)) {
|
||||
console.log("loading stack index")
|
||||
dicomViewer.loadStackIndex(current_image_id_index, dicom_element);
|
||||
} else {
|
||||
try {
|
||||
console.log("current_image_id_index", current_image_id_index)
|
||||
dicomViewer.loadImageById(current_image_id_index, dicom_element);
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
console.log("loading image by id failed")
|
||||
console.log("loading next annotation image")
|
||||
dicomViewer.getNextAnnotationImage(dicom_element);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const detailsElement = document.getElementById("series-actions");
|
||||
const checkboxes = document.querySelectorAll("input[name='series-ids']");
|
||||
|
||||
function toggleCheckboxes() {
|
||||
const isOpen = detailsElement.hasAttribute("open");
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.style.display = isOpen ? "inline-block" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
// Initial state
|
||||
toggleCheckboxes();
|
||||
|
||||
// Listen for toggle events on the <details> element
|
||||
detailsElement.addEventListener("toggle", toggleCheckboxes);
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById("findingModal"));
|
||||
const modalBody = document.getElementById("findingModalBody");
|
||||
|
||||
document.querySelectorAll(".view-finding-modal").forEach(button => {
|
||||
button.addEventListener("click", function () {
|
||||
const findingId = this.getAttribute("data-finding-id");
|
||||
const seriesId = this.getAttribute("data-series-id");
|
||||
|
||||
// Show loading text
|
||||
modalBody.innerHTML = "<p>Loading...</p>";
|
||||
|
||||
// Fetch the finding details (replace with your actual endpoint)
|
||||
fetch(`/atlas/series_finding/${findingId}/details/`)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
modalBody.innerHTML = html; // Load the fetched HTML into the modal body
|
||||
load_3d = false;
|
||||
viewer_element_3d = document.getElementById('root');
|
||||
if (viewer_element_3d.dataset.viewerstate3d != undefined) {
|
||||
load_3d = true;
|
||||
viewerState = JSON.parse(viewer_element_3d.dataset.viewerstate3d || {});
|
||||
annotationjson3d = JSON.parse(viewer_element_3d.dataset.annotationjson3d || {});
|
||||
} else {
|
||||
viewer_element_3d = null;
|
||||
}
|
||||
// We have a slight delay to allow the viewer to load
|
||||
setTimeout(function () {
|
||||
// Then we load the annotation and viewport
|
||||
window.mountDicomViewers();
|
||||
if (load_3d) {
|
||||
console.log("loading 3d")
|
||||
|
||||
//setTimeout(function () {
|
||||
// console.log("loading 3d annotation")
|
||||
// importAnnotations_root(annotationjson3d)
|
||||
// importViewerState_root(viewerState);
|
||||
//}, 1000);
|
||||
} else {
|
||||
console.log("not loading 3d")
|
||||
}
|
||||
}, 200);
|
||||
|
||||
document.getElementById("reload-finding").addEventListener("click", function () {
|
||||
importAnnotations_root(annotationjson3d)
|
||||
importViewerState_root(viewerState);
|
||||
});
|
||||
|
||||
// Initialize the DICOM viewer with the images and annotations
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error loading finding details:", error);
|
||||
modalBody.innerHTML = "<p>Failed to load finding details.</p>";
|
||||
});
|
||||
|
||||
// Show the modal
|
||||
modal.show();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Highlight style for the series-block */
|
||||
.highlight-series {
|
||||
background-color: blue;
|
||||
transition: background-color 0.3s ease, border 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "atlas/base.html" %}
|
||||
<!-- {% load static from static %} -->
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
@@ -50,8 +51,70 @@
|
||||
$('#add_more_resource').click(() => { add_resource_input_form() });
|
||||
})
|
||||
|
||||
</script>
|
||||
{% comment %} document.addEventListener("DOMContentLoaded", function () {
|
||||
// Select all text input fields and textareas
|
||||
const textFields = document.querySelectorAll("input[type='text'], textarea");
|
||||
|
||||
textFields.forEach((field) => {
|
||||
// Create a clear button for each text field or textarea
|
||||
const clearButton = document.createElement("button");
|
||||
clearButton.type = "button";
|
||||
clearButton.className = "btn btn-danger btn-sm clear-input-button";
|
||||
clearButton.textContent = "×";
|
||||
|
||||
// Wrap the input field or textarea in a container with position-relative
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "position-relative";
|
||||
field.parentNode.insertBefore(wrapper, field);
|
||||
wrapper.appendChild(field);
|
||||
wrapper.appendChild(clearButton);
|
||||
|
||||
// Function to toggle the visibility of the clear button
|
||||
const toggleClearButton = () => {
|
||||
if (field.value.trim() !== "") {
|
||||
clearButton.classList.add("show"); // Add the "show" class
|
||||
} else {
|
||||
clearButton.classList.remove("show"); // Remove the "show" class
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check
|
||||
toggleClearButton();
|
||||
|
||||
// Add event listeners to monitor changes in the input field or textarea
|
||||
field.addEventListener("input", toggleClearButton);
|
||||
|
||||
// Add click event to clear the field or textarea
|
||||
clearButton.addEventListener("click", function () {
|
||||
field.value = ""; // Clear the field
|
||||
toggleClearButton(); // Update the button visibility
|
||||
});
|
||||
});
|
||||
});
|
||||
{% endcomment %}
|
||||
</script>
|
||||
{% comment %} <style>
|
||||
/* Ensure the parent container is positioned relatively */
|
||||
.position-relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Style the clear button */
|
||||
.clear-input-button {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: none; /* Hidden by default */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Show the clear button only when the input field or textarea is hovered and contains text */
|
||||
.position-relative:hover .clear-input-button.show {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
</style> {% endcomment %}
|
||||
<!-- {{ form.media }} -->
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
@@ -59,6 +122,8 @@
|
||||
{% include "atlas/question_link_header.html" %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
<h2>Submit Case</h2>
|
||||
<details>
|
||||
<summary>Instructions</summary>
|
||||
@@ -68,74 +133,77 @@
|
||||
</details>
|
||||
|
||||
{% if form.collection %}
|
||||
<div class="alert alert-info" role="alert">Creating a case in the collection: <a href="{% url 'atlas:collection_detail' pk=form.collection.pk %}">{{form.collection.name}}</a></div>
|
||||
<div class="alert alert-info" role="alert">Creating a case in the collection: <a href="{% url 'atlas:collection_detail' pk=form.collection.pk %}">{{form.collection.name}}</a></div>
|
||||
{% endif %}
|
||||
|
||||
<form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form">
|
||||
{% csrf_token %}
|
||||
{% crispy form form.helper %}
|
||||
|
||||
{% comment %} {% crispy series_formset %} {% endcomment %}
|
||||
|
||||
{% comment %} {{ form }} {% endcomment %}
|
||||
|
||||
{{ form }}
|
||||
<h3>Series:</h3>
|
||||
Add image sets here. These can only be added once created (they can also be added to cases on creation).
|
||||
<input type="button" value="Add More Series" id="add_more_series">
|
||||
<div id="series_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in series_formset %}
|
||||
<li class="no-error series-formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% for form in series_formset %}
|
||||
<li class="no-error series-formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form | crispy}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{{ series_formset.management_form }}
|
||||
{{ series_formset.management_form | crispy }}
|
||||
<h3>Differential:</h3>
|
||||
Add differential here.
|
||||
<input type="button" value="Add Another Differential" id="add_more_differential">
|
||||
<div id="casedifferential_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in casedifferential_formset %}
|
||||
<li class="no-error casedifferential_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% 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 }}
|
||||
{{ casedifferential_formset.management_form | crispy }}
|
||||
<h3>Resource:</h3>
|
||||
Add resource here. Resource can be anything that is useful for the case (videos, pdf, etc). This can either be avalaible before attempting the case (such a for a pre reading list) or after.
|
||||
<input type="button" value="Add Another Resource" id="add_more_resource">
|
||||
<div id="caseresource_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in caseresource_formset %}
|
||||
<li class="no-error caseresource_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% 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 }}
|
||||
{{ caseresource_formset.management_form | crispy}}
|
||||
<br/>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
<div id="empty_series_form" style="display:none">
|
||||
<li class='no_error series-formset'>
|
||||
{{ series_formset.empty_form }}
|
||||
{{ series_formset.empty_form | crispy}}
|
||||
</li>
|
||||
</div>
|
||||
<div id="empty_casedifferential_form" style="display:none">
|
||||
<li class='no_error casedifferential_formset'>
|
||||
{{ casedifferential_formset.empty_form }}
|
||||
{{ casedifferential_formset.empty_form | crispy}}
|
||||
</li>
|
||||
</div>
|
||||
<div id="empty_caseresource_form" style="display:none">
|
||||
<li class='no_error caseresource_formset'>
|
||||
{{ caseresource_formset.empty_form }}
|
||||
{{ caseresource_formset.empty_form | crispy}}
|
||||
</li>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<details class="case-details">
|
||||
<summary>Cases</summary>
|
||||
<input type="text" placeholder="Search Cases..."
|
||||
_="on keyup
|
||||
if the event's key is 'Escape'
|
||||
set my value to ''
|
||||
trigger keyup
|
||||
else
|
||||
log 'test' then
|
||||
show .case-option in #case-options when its textContent contains my value
|
||||
">
|
||||
<ul id="case-options">
|
||||
{% for group_name, group_choices, group_index in widget.optgroups reversed %}
|
||||
{% for widget in group_choices reversed %}
|
||||
|
||||
{% with widget.value.instance as case %}
|
||||
{% if not forloop.parentloop.first %}
|
||||
<li class="case-option">
|
||||
<span class="d-flex flex-row ">
|
||||
<span class="p-2">{{case.id}}</span>
|
||||
<span class="p-2">{{case.title}}</span>
|
||||
<span class="p-2">{{case.created_date}}</span>
|
||||
<button class="p-2 btn btn-sm border border-dark rounded text-primary"
|
||||
type="button"
|
||||
_="on click set (next <select/>)'s value to '{{case.pk}}' then remove @open from .case-details then go to top of (next <select/>)'s parent"
|
||||
>select</button>
|
||||
</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
|
||||
<optgroup label="{{ group_name }}">{% endif %}{% for widget in group_choices %}
|
||||
{% include widget.template_name %}{% endfor %}{% if group_name %}
|
||||
</optgroup>{% endif %}{% endfor %}
|
||||
</select>
|
||||
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% load render_table from django_tables2 %}
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="container-fluid">
|
||||
<h2>My Cases</h2>
|
||||
<details>
|
||||
<summary>
|
||||
<h4>Filter</h4>
|
||||
</summary>
|
||||
<div id="view-filter-options">
|
||||
<form action="" method="get">
|
||||
{{ filter.form }}
|
||||
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
|
||||
</details>
|
||||
{% render_table table %}
|
||||
</div>
|
||||
|
||||
<div id="exam-options"></div>
|
||||
{% endblock %}
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
<div>
|
||||
|
||||
{% if previous %}
|
||||
@@ -14,23 +14,31 @@
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id next.id %}">Next question</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
|
||||
|
||||
{% if case_detail.question_schema is not None and not case_detail.question_answers %}
|
||||
|
||||
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h4 class="alert-heading">Answers not defined!</h4>
|
||||
<p>A question schema has been defined but no question answers are found.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h4 class="alert-heading">Answers not defined!</h4>
|
||||
<p>A question schema has been defined but no question answers are found. Please set them below.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p>This form allows you to add questions to a case. A example of how the quesiton will be displayed is shown below.</p>
|
||||
<p>If answers are supplied the question will be automarked</p>
|
||||
<details class="help-text">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p>This form allows you to add questions to a case. A example of how the quesiton will be displayed is shown below.</p>
|
||||
<p>If answers are supplied the question will be automarked</p>
|
||||
|
||||
<p>The question system is built using https://github.com/json-editor/json-editor, this allows for highly flexible forms / question design at a cost of some complexity. To help with this a number of preset questions are avalible to choose from (these can then be edited if needed). It is also possible to copy questions from other cases in the collection. </p>
|
||||
<p>The question system is built using https://github.com/json-editor/json-editor, this allows for highly flexible forms / question design at a cost of some complexity. To help with this a number of preset questions are avalible to choose from (these can then be edited if needed). It is also possible to copy questions from other cases in the collection. </p>
|
||||
|
||||
<p>In most cases it is not necessary to edit the JSON schema directly as questions can be added / edited via the GUI.</p>
|
||||
|
||||
<p>To save the question schema you need to click the "Save Questions" button at the bottom of the page. The page will then refresh and you can see how questions will be displayed at the bottom of the page.</p>
|
||||
|
||||
</details>
|
||||
|
||||
Import schema from:
|
||||
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
|
||||
@@ -42,6 +50,56 @@
|
||||
hx-target=".offcanvas-body"
|
||||
>Preset questions</button>
|
||||
|
||||
<details><summary>
|
||||
Question Block Title
|
||||
</summary>
|
||||
<p>Title of the question block.</p>
|
||||
<input id="question_block_title_input" type="text" name="title" value="{{case_detail.question_schema.title}}" class="form-control" placeholder="Question title" aria-label="Question title">
|
||||
</details>
|
||||
<details><summary>Modify Questions</summary>
|
||||
<div id="questions-container" style="margin-top: 20px;">
|
||||
<!-- Questions will be dynamically rendered here -->
|
||||
</div>
|
||||
</details>
|
||||
<details open id="add-question-block"><summary>Add/Edit Question</summary>
|
||||
<div>
|
||||
<label for="question-type-select">Select Question Type:</label>
|
||||
<select id="question-type-select" class="form-select">
|
||||
<option value="radio">Multiple Choice (Radio)</option>
|
||||
<option value="dropdown">Multiple Choice (Dropdown)</option>
|
||||
<option value="dropdown-select">Multiple Choice (Dropdown - Select2)</option>
|
||||
<option value="text">Text Input</option>
|
||||
<option value="textarea">Text Area</option>
|
||||
<option value="multiselect">Multiselect</option>
|
||||
<option value="yesno">Yes/No</option>
|
||||
<option value="truefalse">True/False</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="range">Range</option>
|
||||
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="options-configurator" style="margin-top: 10px; ">
|
||||
<label>Enter Options:</label>
|
||||
<div id="question-options-container">
|
||||
<!-- Individual option input fields will be added here -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="add-option-button" style="margin-top: 10px;">Add Option</button>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<label for="question-title-input">Question Title:</label>
|
||||
<input id="question-title-input" type="text" class="form-control" placeholder="Enter question title">
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 10px;">
|
||||
<label for="question-description-input">Question Description:</label>
|
||||
<input id="question-description-input" type="text" class="form-control" placeholder="Enter question description">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-sm" id="add-new-question">Add New Question</button>
|
||||
<button class="btn btn-secondary btn-sm" id="reset-question-options">Reset</button>
|
||||
</details>
|
||||
|
||||
<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvasRight" aria-labelledby="offcanvasRightLabel"
|
||||
>
|
||||
<div class="offcanvas-header">
|
||||
@@ -56,7 +114,7 @@
|
||||
<form method="POST">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit" value="save" name="submit">Submit</button>
|
||||
<button type="submit" value="save" name="submit">Save Questions</button>
|
||||
</form>
|
||||
|
||||
{% comment %} <h2>Blank form</h2>
|
||||
@@ -97,6 +155,535 @@
|
||||
});
|
||||
}))
|
||||
});
|
||||
|
||||
|
||||
$(document).ready(function() {
|
||||
const question_editor = window.jsoneditor_id_question_schema;
|
||||
const questionBlockTitleInput = document.getElementById('question_block_title_input');
|
||||
// Wrap the existing onChange handler
|
||||
const originalOnChange = question_editor.$$.ctx[question_editor.$$.props.onChange];
|
||||
const addNewQuestionButton = document.getElementById('add-new-question');
|
||||
const optionsConfigurator = document.getElementById('options-configurator');
|
||||
const questionOptionsContainer = document.getElementById('question-options-container');
|
||||
const addOptionButton = document.getElementById('add-option-button');
|
||||
const questionTypeSelect = document.getElementById('question-type-select');
|
||||
const questionTitleInput = document.getElementById('question-title-input');
|
||||
const questionDescriptionInput = document.getElementById('question-description-input');
|
||||
const resetQuestionOptionsButton = document.getElementById('reset-question-options');
|
||||
const questionsContainer = document.getElementById('questions-container');
|
||||
|
||||
question_editor.updateProps({
|
||||
onChange: (updatedContent, previousContent, context) => {
|
||||
// Call the original onChange handler if it exists
|
||||
if (typeof originalOnChange === 'function') {
|
||||
originalOnChange(updatedContent, previousContent, context);
|
||||
}
|
||||
|
||||
const value = updatedContent.json ? updatedContent.json.title : '';
|
||||
if (value !== questionBlockTitleInput.value) {
|
||||
questionBlockTitleInput.value = value; // Update the input field
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sync changes from the input field to the editor
|
||||
// Yes this is a bit backwards but it works
|
||||
questionBlockTitleInput.addEventListener('input', () => {
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
}
|
||||
catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
if (currentContent) {
|
||||
currentContent.title = questionBlockTitleInput.value; // Update the title in the editor
|
||||
question_editor.update({json: currentContent}); // Apply the changes to the editor
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Show or hide the options configurator based on the selected question type
|
||||
questionTypeSelect.addEventListener('change', () => {
|
||||
const selectedType = questionTypeSelect.value;
|
||||
if (selectedType === 'radio' || selectedType === 'dropdown' || selectedType === 'multiselect') {
|
||||
optionsConfigurator.style.display = 'block';
|
||||
} else {
|
||||
optionsConfigurator.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Add a new option input field
|
||||
addOptionButton.addEventListener('click', () => {
|
||||
const optionIndex = questionOptionsContainer.children.length + 1;
|
||||
const optionInput = document.createElement('div');
|
||||
optionInput.className = 'option-input';
|
||||
optionInput.style.marginBottom = '5px';
|
||||
optionInput.innerHTML = `
|
||||
<input type="text" class="form-control option-field" placeholder="Option ${optionIndex}" style="display: inline-block; width: 90%;">
|
||||
<button type="button" class="btn btn-danger btn-sm remove-option-button" style="display: inline-block; width: 8%;">×</button>
|
||||
`;
|
||||
questionOptionsContainer.appendChild(optionInput);
|
||||
|
||||
// Add event listener to remove the option
|
||||
optionInput.querySelector('.remove-option-button').addEventListener('click', () => {
|
||||
questionOptionsContainer.removeChild(optionInput);
|
||||
});
|
||||
});
|
||||
|
||||
// Reset the question options
|
||||
resetQuestionOptionsButton.onclick = resetQuestion;
|
||||
|
||||
function resetQuestion() {
|
||||
|
||||
// Clear the title and description fields
|
||||
questionTitleInput.value = '';
|
||||
questionDescriptionInput.value = '';
|
||||
|
||||
// Clear all option input fields
|
||||
while (questionOptionsContainer.firstChild) {
|
||||
questionOptionsContainer.removeChild(questionOptionsContainer.firstChild);
|
||||
}
|
||||
|
||||
// Reset the question type to the default value
|
||||
questionTypeSelect.value = 'radio';
|
||||
|
||||
// Hide the options configurator if necessary
|
||||
optionsConfigurator.style.display = 'block';
|
||||
|
||||
// Reset the "Add New Question" button
|
||||
addNewQuestionButton.textContent = 'Add New Question';
|
||||
addNewQuestionButton.onclick = addNewQuestion;
|
||||
|
||||
toastr.success('Question options have been reset.');
|
||||
}
|
||||
// Add a new question
|
||||
addNewQuestionButton.onclick = addNewQuestion;
|
||||
|
||||
function addNewQuestion() {
|
||||
// Get the current content from the editor
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
|
||||
if (!currentContent.properties) {
|
||||
currentContent.properties = {};
|
||||
current_question_number = 1;
|
||||
} else {
|
||||
current_question_number = Object.keys(currentContent.properties).length + 1;
|
||||
}
|
||||
|
||||
// Get the selected question type
|
||||
const selectedType = questionTypeSelect.value;
|
||||
|
||||
// Collect options from individual input fields
|
||||
const options = Array.from(questionOptionsContainer.querySelectorAll('.option-field'))
|
||||
.map(input => input.value.trim())
|
||||
.filter(option => option !== ""); // Exclude empty options
|
||||
|
||||
// Get the title and description
|
||||
const title = questionTitleInput.value || `Question ${current_question_number}`;
|
||||
const description = questionDescriptionInput.value || "Enter the question description here.";
|
||||
|
||||
// Define the new question template based on the selected type
|
||||
let newQuestion;
|
||||
if (selectedType === 'radio') {
|
||||
newQuestion = {
|
||||
enum: options.length > 0 ? options : ["Option 1", "Option 2", "Option 3"], // Default options if none provided
|
||||
type: "string",
|
||||
title: title,
|
||||
format: "radio",
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'text') {
|
||||
newQuestion = {
|
||||
type: "string",
|
||||
title: title,
|
||||
format: "text",
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'dropdown') {
|
||||
newQuestion = {
|
||||
enum: options.length > 0 ? options : ["Option 1", "Option 2", "Option 3"], // Default options if none provided
|
||||
type: "string",
|
||||
title: title,
|
||||
format: "select",
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'dropdown-select') {
|
||||
newQuestion = {
|
||||
enum: options.length > 0 ? options : ["Option 1", "Option 2", "Option 3"], // Default options if none provided
|
||||
type: "string",
|
||||
title: title,
|
||||
format: "select2",
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'multiselect') {
|
||||
newQuestion = {
|
||||
type: "array",
|
||||
items: {
|
||||
enum: options.length > 0 ? options : ["Option 1", "Option 2", "Option 3"], // Default options if none provided
|
||||
type: "string",
|
||||
},
|
||||
title: title,
|
||||
format: "select2", // Multiselect format
|
||||
description: description,
|
||||
uniqueItems: true, // Ensure unique selections
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'yesno') {
|
||||
newQuestion = {
|
||||
enum: ["Yes", "No"],
|
||||
type: "string",
|
||||
title: title,
|
||||
format: "radio",
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'truefalse') {
|
||||
newQuestion = {
|
||||
enum: ["True", "False"],
|
||||
type: "string",
|
||||
title: title,
|
||||
format: "radio",
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'textarea') {
|
||||
newQuestion = {
|
||||
type: "string",
|
||||
title: title,
|
||||
format: "textarea", // Use textarea format
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'number') {
|
||||
newQuestion = {
|
||||
type: "number",
|
||||
title: title,
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'range') {
|
||||
newQuestion = {
|
||||
type: "range",
|
||||
title: title,
|
||||
description: description,
|
||||
minimum: 0, // Default minimum value
|
||||
maximum: 100, // Default maximum value
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
newQuestion.propertyOrder = current_question_number; // Set the property order
|
||||
// Add the new question to the properties
|
||||
currentContent.properties[`question_${current_question_number}`] = newQuestion;
|
||||
|
||||
// Update the editor with the new question
|
||||
question_editor.update({ json: currentContent });
|
||||
toastr.success(`New question added: ${title}`);
|
||||
renderQuestions(); // Re-render the questions list
|
||||
}
|
||||
|
||||
function renderQuestions() {
|
||||
questionsContainer.innerHTML = ''; // Clear the container
|
||||
|
||||
try {
|
||||
let currentContent;
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
const properties = currentContent.properties || {};
|
||||
|
||||
// Sort questions by propertyOrder
|
||||
const sortedKeys = Object.keys(properties).sort((a, b) => {
|
||||
const orderA = properties[a].propertyOrder !== undefined ? properties[a].propertyOrder : 1000;
|
||||
const orderB = properties[b].propertyOrder !== undefined ? properties[b].propertyOrder : 1000;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
sortedKeys.forEach((key, index) => {
|
||||
const question = properties[key];
|
||||
const questionElement = document.createElement('div');
|
||||
questionElement.className = 'question-item';
|
||||
questionElement.style.marginBottom = '10px';
|
||||
questionElement.setAttribute('draggable', 'true'); // Make the element draggable
|
||||
questionElement.setAttribute('data-question-key', key);
|
||||
questionElement.setAttribute('data-index', index);
|
||||
|
||||
questionElement.innerHTML = `
|
||||
<strong>${question.title || key}</strong>
|
||||
<button type="button" class="btn btn-warning btn-sm edit-question-button" data-question-key="${key}" style="margin-left: 10px;">Edit</button>
|
||||
<button type="button" class="btn btn-danger btn-sm delete-question-button" data-question-key="${key}" style="margin-left: 10px;">Delete</button>
|
||||
`;
|
||||
questionsContainer.appendChild(questionElement);
|
||||
|
||||
// Add event listener to delete the question
|
||||
questionElement.querySelector('.delete-question-button').addEventListener('click', (e) => {
|
||||
const questionKey = e.target.getAttribute('data-question-key');
|
||||
deleteQuestion(questionKey);
|
||||
});
|
||||
|
||||
// Add event listener to edit the question
|
||||
questionElement.querySelector('.edit-question-button').addEventListener('click', (e) => {
|
||||
const questionKey = e.target.getAttribute('data-question-key');
|
||||
editQuestion(questionKey);
|
||||
});
|
||||
|
||||
// Add drag-and-drop event listeners
|
||||
questionElement.addEventListener('dragstart', handleDragStart);
|
||||
questionElement.addEventListener('dragover', handleDragOver);
|
||||
questionElement.addEventListener('drop', handleDrop);
|
||||
questionElement.addEventListener('dragend', handleDragEnd);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error rendering questions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-and-drop event handlers
|
||||
let draggedElement = null;
|
||||
|
||||
function handleDragStart(e) {
|
||||
draggedElement = this;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/html', this.outerHTML);
|
||||
this.classList.add('dragging');
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
if (draggedElement !== this) {
|
||||
const draggedIndex = parseInt(draggedElement.getAttribute('data-index'));
|
||||
const targetIndex = parseInt(this.getAttribute('data-index'));
|
||||
|
||||
try {
|
||||
let currentContent;
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
const properties = currentContent.properties || {};
|
||||
const keys = Object.keys(properties);
|
||||
|
||||
// Sort keys by propertyOrder
|
||||
const sortedKeys = keys.sort((a, b) => {
|
||||
const orderA = properties[a].propertyOrder !== undefined ? properties[a].propertyOrder : Infinity;
|
||||
const orderB = properties[b].propertyOrder !== undefined ? properties[b].propertyOrder : Infinity;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
// Move the dragged question to the target position
|
||||
const draggedKey = sortedKeys.splice(draggedIndex, 1)[0]; // Remove dragged key
|
||||
sortedKeys.splice(targetIndex, 0, draggedKey); // Insert dragged key at target position
|
||||
|
||||
// Reassign propertyOrder to ensure uniqueness and correct order
|
||||
sortedKeys.forEach((key, index) => {
|
||||
properties[key].propertyOrder = index + 1;
|
||||
});
|
||||
|
||||
// Assign propertyOrder to any questions without it
|
||||
keys.forEach((key) => {
|
||||
if (properties[key].propertyOrder === undefined) {
|
||||
properties[key].propertyOrder = sortedKeys.length + 1;
|
||||
sortedKeys.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
currentContent.properties = properties;
|
||||
question_editor.update({ json: currentContent });
|
||||
toastr.success('Questions reordered successfully.');
|
||||
renderQuestions(); // Re-render the questions list
|
||||
} catch (error) {
|
||||
console.error('Error reordering questions:', error);
|
||||
toastr.error('Failed to reorder questions.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
this.classList.remove('dragging');
|
||||
}
|
||||
|
||||
// Function to delete a question
|
||||
function deleteQuestion(questionKey) {
|
||||
try {
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
if (currentContent.properties && currentContent.properties[questionKey]) {
|
||||
delete currentContent.properties[questionKey]; // Remove the question
|
||||
question_editor.update({ json: currentContent }); // Update the editor
|
||||
toastr.success(`Question "${questionKey}" has been deleted.`);
|
||||
renderQuestions(); // Re-render the questions list
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting question:', error);
|
||||
toastr.error('Failed to delete the question.');
|
||||
}
|
||||
}
|
||||
// Initial render of questions
|
||||
renderQuestions();
|
||||
|
||||
function editQuestion(questionKey) {
|
||||
try {
|
||||
let currentContent;
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
const properties = currentContent.properties || {};
|
||||
const question = properties[questionKey];
|
||||
|
||||
if (question) {
|
||||
// Populate the form fields with the question's data
|
||||
questionTitleInput.value = question.title || '';
|
||||
questionDescriptionInput.value = question.description || '';
|
||||
questionTypeSelect.value = question.questionType || 'text';
|
||||
|
||||
// Clear existing options and populate them if applicable
|
||||
while (questionOptionsContainer.firstChild) {
|
||||
questionOptionsContainer.removeChild(questionOptionsContainer.firstChild);
|
||||
}
|
||||
if (question.enum) {
|
||||
question.enum.forEach((option, index) => {
|
||||
const optionInput = document.createElement('div');
|
||||
optionInput.className = 'option-input';
|
||||
optionInput.style.marginBottom = '5px';
|
||||
optionInput.innerHTML = `
|
||||
<input type="text" class="form-control option-field" value="${option}" placeholder="Option ${index + 1}" style="display: inline-block; width: 90%;">
|
||||
<button type="button" class="btn btn-danger btn-sm remove-option-button" style="display: inline-block; width: 8%;">×</button>
|
||||
`;
|
||||
questionOptionsContainer.appendChild(optionInput);
|
||||
|
||||
// Add event listener to remove the option
|
||||
optionInput.querySelector('.remove-option-button').addEventListener('click', () => {
|
||||
questionOptionsContainer.removeChild(optionInput);
|
||||
});
|
||||
});
|
||||
optionsConfigurator.style.display = 'block';
|
||||
} else {
|
||||
optionsConfigurator.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update the "Add New Question" button to save changes
|
||||
addNewQuestionButton.textContent = 'Save Changes';
|
||||
addNewQuestionButton.onclick = () => {
|
||||
saveEditedQuestion(questionKey);
|
||||
};
|
||||
document.getElementById('add-question-block').open = true; // Open the question block
|
||||
document.getElementById('add-question-block').scrollIntoView({ behavior: 'smooth', block: "center" }); // Scroll to the question block
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error editing question:', error);
|
||||
toastr.error('Failed to edit the question.');
|
||||
}
|
||||
}
|
||||
|
||||
function saveEditedQuestion(questionKey) {
|
||||
try {
|
||||
let currentContent;
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
const properties = currentContent.properties || {};
|
||||
const question = properties[questionKey];
|
||||
|
||||
if (question) {
|
||||
// Update the question with the new values from the form
|
||||
question.title = questionTitleInput.value || `Question ${questionKey}`;
|
||||
question.description = questionDescriptionInput.value || '';
|
||||
question.format = questionTypeSelect.value;
|
||||
|
||||
// Update the options if applicable
|
||||
if (question.enum) {
|
||||
question.enum = Array.from(questionOptionsContainer.querySelectorAll('.option-field'))
|
||||
.map((input) => input.value.trim())
|
||||
.filter((option) => option !== ''); // Exclude empty options
|
||||
}
|
||||
|
||||
// Update the editor with the modified question
|
||||
currentContent.properties[questionKey] = question;
|
||||
question_editor.update({ json: currentContent });
|
||||
|
||||
toastr.success(`Question "${question.title}" updated successfully.`);
|
||||
renderQuestions(); // Re-render the questions list
|
||||
|
||||
// Reset the "Add New Question" button
|
||||
addNewQuestionButton.textContent = 'Add New Question';
|
||||
addNewQuestionButton.onclick = addNewQuestion;
|
||||
resetQuestion(); // Reset the question options
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving edited question:', error);
|
||||
toastr.error('Failed to save the question.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
<style>
|
||||
/* Style the <details> block */
|
||||
details {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
background-color:rgba(13, 8, 8, 0.86);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Style the <summary> element */
|
||||
details summary {
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Add hover effect to <summary> */
|
||||
details summary:hover {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
/* Add padding to the content inside <details> */
|
||||
details > *:not(summary) {
|
||||
margin-top: 10px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
/* Add a border to the expanded <details> */
|
||||
details[open] {
|
||||
border-color: #007bff;
|
||||
background-color: black;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
@@ -24,7 +24,7 @@
|
||||
<h3>Available priors</h3>
|
||||
|
||||
<ul>
|
||||
{% for case, added, relation in available_priors %}
|
||||
{% for case, added, relation, visibility in available_priors %}
|
||||
|
||||
<li class='
|
||||
{% if added %}
|
||||
@@ -38,24 +38,63 @@
|
||||
|
||||
<br/>
|
||||
|
||||
|
||||
{% if not added %}
|
||||
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
|
||||
hx-target="#htmx-response-{{case.id}}"
|
||||
_="on htmx:afterRequest log 'test' then remove me"
|
||||
>
|
||||
_="on htmx:afterRequest log 'test' then remove me">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="prior_case_id" value="{{case.id}}" />
|
||||
<input type="text" name="relation" value="{{relation}}" placeholder="relation to case" />
|
||||
<br />
|
||||
<label for="prior_visibility_{{case.id}}">Visibility:</label>
|
||||
<select name="prior_visibility" id="prior_visibility_{{case.id}}">
|
||||
<option value="AL"
|
||||
{% if visibility == 'AL' %}
|
||||
selected
|
||||
{% endif %}
|
||||
>Always</option>
|
||||
<option value="RE"
|
||||
{% if visibility == 'RE' %}
|
||||
selected
|
||||
{% endif %}
|
||||
|
||||
>Review</option>
|
||||
<option value="NO"
|
||||
{% if visibility == 'NO' %}
|
||||
selected
|
||||
{% endif %}
|
||||
>None</option>
|
||||
</select>
|
||||
<br />
|
||||
<button class="btn primary">Add to case</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
|
||||
hx-target="#htmx-response-{{case.id}}"
|
||||
_="on htmx:afterRequest log 'test' then remove me"
|
||||
>
|
||||
_="on htmx:afterRequest log 'test' then remove me">
|
||||
Case available: {{relation}}
|
||||
<input type="hidden" name="remove" value="{{case.id}}" />
|
||||
<br />
|
||||
<label for="prior_visibility_{{case.id}}">Visibility:</label>
|
||||
<select name="prior_visibility" id="prior_visibility_{{case.id}}" disabled>
|
||||
<option value="AL"
|
||||
{% if visibility == 'AL' %}
|
||||
selected
|
||||
{% endif %}
|
||||
>Always</option>
|
||||
<option value="RE"
|
||||
{% if visibility == 'RE' %}
|
||||
selected
|
||||
{% endif %}
|
||||
|
||||
>Review</option>
|
||||
<option value="NO"
|
||||
{% if visibility == 'NO' %}
|
||||
selected
|
||||
{% endif %}
|
||||
>None</option>
|
||||
</select>
|
||||
<br />
|
||||
<button class="btn primary">Remove from case</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -42,21 +42,31 @@
|
||||
{% endif %}
|
||||
{% if show_history and case.history %}
|
||||
<div>
|
||||
History: {{case.history}}
|
||||
History: {{case.history|linebreaks}}
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if collection.show_ohif_viewer %}
|
||||
<div>
|
||||
|
||||
{% if question_completed %}
|
||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}"></iframe>
|
||||
{% else %}
|
||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}"></iframe>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if collection.show_ohif_viewer_link %}
|
||||
<div>
|
||||
{% if question_completed %}
|
||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||
{% else %}
|
||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -102,7 +112,7 @@
|
||||
Discussion:
|
||||
</summary>
|
||||
<div>
|
||||
{{case.discussion}}
|
||||
{{case.discussion|linebreaks}}
|
||||
</div>
|
||||
</details>
|
||||
</p>
|
||||
@@ -114,7 +124,7 @@
|
||||
Report:
|
||||
</summary>
|
||||
<div>
|
||||
{{case.report}}
|
||||
{{case.report|linebreaks}}
|
||||
</div>
|
||||
</details>
|
||||
</p>
|
||||
@@ -218,7 +228,7 @@
|
||||
<div>
|
||||
{% if collection.self_review %}
|
||||
<p>
|
||||
<a href="{% url 'atlas:add_self_review' cid_user_exam.id case.id %}"><button>Add self review</button></a>
|
||||
<a target="_blank" title="Add self review, this will open in a new page / tab" href="{% url 'atlas:add_self_review' cid_user_exam.id case.id %}"><button type="button">Add self review</button></a>
|
||||
</p>
|
||||
{% if self_review %}
|
||||
<h4>Self Feedback</h4>
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
<h2>{{collection.name}}</h2>
|
||||
|
||||
{% include 'exam_notes.html' %}
|
||||
|
||||
<div>
|
||||
Exam mode: {{collection.exam_mode}}<br />
|
||||
Exam mode: {{collection.exam_mode}} (Exam open acess: {{collection.exam_open_access}})<br />
|
||||
Publish results: {{collection.publish_results}}<br />
|
||||
Active: {{collection.active}}<br />
|
||||
Collection Type: {{collection.get_collection_type_display}}<br />
|
||||
@@ -18,10 +19,20 @@
|
||||
: {{casedetail.case.title}}
|
||||
|
||||
{% 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 %}'>edit questions</a>
|
||||
|
||||
{% if casedetail.question_schema %}
|
||||
<i class="bi bi-question-square text-success" title="This case has questions defined."></i>
|
||||
{% else %}
|
||||
<i class="bi bi-question-square text-danger" title="This case has no questions defined."></i>
|
||||
{% endif %}
|
||||
)
|
||||
{% endif %}
|
||||
|
||||
{% if casedetail.case.previous_case %}
|
||||
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>manage priors</a>)
|
||||
{% endif %}
|
||||
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
@@ -32,7 +43,14 @@
|
||||
{% 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}}'>here</a> (when active)
|
||||
<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 %}
|
||||
@@ -49,17 +67,24 @@
|
||||
<summary>Answer management</summary>
|
||||
|
||||
<div class="alert alert-danger">
|
||||
<p>Manage answers for this collection. </p>
|
||||
<p>Manage answers for this collection. </p>
|
||||
|
||||
Please note these are permanant and cannot be undone.
|
||||
Please note these are permanant and cannot be undone.
|
||||
</div>
|
||||
|
||||
<button title="This will clear all user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
|
||||
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
|
||||
>Reset all answers</button>
|
||||
|
||||
|
||||
<button title="This will specific user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user_list' collection.pk %}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#user-list"
|
||||
>Reset user answers</button>
|
||||
<div id="user-list"></div>
|
||||
|
||||
</details>
|
||||
{% endif %}
|
||||
{% include 'exam_overview_js.html' %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% if request.user.is_authenticated %}
|
||||
<br/>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_history' collection.pk %}">History</a> /
|
||||
<a href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a> /
|
||||
<a href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a> /
|
||||
|
||||
@@ -7,11 +7,19 @@
|
||||
|
||||
<ul>
|
||||
{% for userexam in userexams %}
|
||||
<li>
|
||||
<li id="user-history-{{ userexam.user_user.pk }}">
|
||||
<b><a href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">{{userexam.get_user_name}}</a><b><br/>
|
||||
Completed: {{userexam.completed}}<br/>
|
||||
Started: {{userexam.start_time}}, Ended: {{userexam.end_time}}<br/>
|
||||
|
||||
<button
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk userexam.user_user.pk %}"
|
||||
hx-target="#user-history-{{ userexam.user_user.pk }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this user's collection history?"
|
||||
class="btn btn-danger btn-sm remove-button">
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<a href="{% url 'atlas:exam_create' %}">Create collection</a>
|
||||
<ul>
|
||||
{% for collection in collections %}
|
||||
<li><a href='{{collection.get_absolute_url}}'>{{collection.name}}</a></li>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
{% for cid_user_exam in cid_users %}
|
||||
<button class="btn btn-sm btn-primary" title="This will clear the user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk cid_user_exam.user_user.id %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to reset answers for the user? This action cannot be undone."
|
||||
>{{cid_user_exam.user_user}}</button>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
@@ -58,12 +58,14 @@
|
||||
|
||||
<div class="sticky-top">
|
||||
<h3>Current Case</h3>
|
||||
<div id="loading-case">Waiting for case to load in linked window.<br/> If you are seeing this message you probably need to open the viewer window and reload the case.</div>
|
||||
<div id="current-case-title">None</div>
|
||||
<div id="current-case-series"></div>
|
||||
<div id="current-case-history"></div>
|
||||
<div id="current-case-discussion"></div>
|
||||
<div id="current-case-report"></div>
|
||||
<div id="case-details">
|
||||
<div id="loading-case">Waiting for case to load in linked window.<br/> If you are seeing this message you probably need to open the viewer window and reload the case.</div>
|
||||
<div id="current-case-title">None</div>
|
||||
<div id="current-case-series"></div>
|
||||
<div id="current-case-history"></div>
|
||||
<div id="current-case-discussion"></div>
|
||||
<div id="current-case-report"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<iframe id="viewer" style="width: 100%; height: 500px; border: none"></iframe>
|
||||
@@ -106,16 +108,16 @@
|
||||
|
||||
$('#loading-case').show()
|
||||
$("#open-viewer").addClass("flash-button");
|
||||
$('#current-case-title').text("Case: "+c.data('title'));
|
||||
$("#current-case-history").text("History: "+case_details['history']);
|
||||
$("#current-case-discussion").text("Discussion: "+case_details['discussion']);
|
||||
$("#current-case-report").text("Report: "+case_details['report']);
|
||||
$('#current-case-title').html("<span class='title'>Case:</span> "+c.data('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']);
|
||||
|
||||
console.log($(this).data('type'))
|
||||
if ($(this).data('type') == 'case') {
|
||||
$('#current-case-series').text("Series: All");
|
||||
$('#current-case-series').html("<span class='title'>Series:</span> All");
|
||||
} else {
|
||||
$('#current-case-series').text("Series: " + $(this).data('series'));
|
||||
$('#current-case-series').html("<span class='title'>Series:</span> " + $(this).data('series'));
|
||||
}
|
||||
bc.postMessage({"type": "open", "url" : url});
|
||||
//if (win2 == false || win2.closed) {
|
||||
@@ -153,25 +155,33 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
#loading-case {
|
||||
display: none;
|
||||
color: red;
|
||||
}
|
||||
@keyframes glowing {
|
||||
0% {
|
||||
box-shadow: 0 0 20px #a600ff;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px #52057b;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 20px #a600ff;
|
||||
}
|
||||
}
|
||||
.flash-button {
|
||||
animation: glowing 1300ms infinite;
|
||||
}
|
||||
@keyframes glowing {
|
||||
0% {
|
||||
box-shadow: 0 0 20px #a600ff;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px #52057b;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 20px #a600ff;
|
||||
}
|
||||
}
|
||||
.flash-button {
|
||||
animation: glowing 1300ms infinite;
|
||||
}
|
||||
|
||||
#case-details {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
{% endblock css %}
|
||||
@@ -0,0 +1,39 @@
|
||||
<!-- filepath: /home/ross/rad/rad/atlas/templates/atlas/linked_cases_overview.html -->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load partials %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<h1>Linked Cases Overview</h1>
|
||||
<h2>Current Case: {{ current_case.title }}</h2>
|
||||
|
||||
<h3>Previous Cases</h3>
|
||||
{% if previous_cases %}
|
||||
<ul>
|
||||
{% for case in previous_cases %}
|
||||
<li>
|
||||
<a href="{{ prev_case.get_absolute_url }}">{{ case.title }}</a>
|
||||
|
||||
</li>
|
||||
{% partial case-series %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No previous cases.</p>
|
||||
{% endif %}
|
||||
|
||||
<h3>Next Cases</h3>
|
||||
{% if next_cases %}
|
||||
<ul>
|
||||
{% for case in next_cases %}
|
||||
<li>
|
||||
<a href="{{ next_case.get_absolute_url }}">{{ case.title }}</a>
|
||||
</li>
|
||||
{% include "atlas/case_display_block.html#case-series" %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No next cases.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -22,7 +22,7 @@
|
||||
</details>
|
||||
<details>
|
||||
<summary>Settings</summary>
|
||||
<input type="checkbox" id="check-annonymisation-ocr" checked><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-viewer"><label for="Show Viewer">Show viewer</label>
|
||||
</details>
|
||||
@@ -47,6 +47,15 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="alert alert-warning hide anon-alert" role="alert">
|
||||
<p>The system has detected files that do not appear to be annonymised. These have been highlighted below.</p>
|
||||
|
||||
<p>If you believe this is a false positive please contact ross.kruger@nhs.net with details about the file that has been flagged.</p>
|
||||
|
||||
<details><summary>Override annonymisation check</summary>
|
||||
<p>By clicking the button below you can override the annonymisation check and upload the files anyway. Only do this if you are sure this is a false positive.</p>
|
||||
<button id="override-anon" _="on click remove @disabled from #uploadButton then alert('Upload enabled')">Override annonymisation check</button>
|
||||
</div>
|
||||
|
||||
<h4>Selected files:</h4>
|
||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''></div>
|
||||
@@ -116,15 +125,12 @@
|
||||
}
|
||||
|
||||
function uploadFiles(files_to_upload, current_chunk, total_chunks){
|
||||
console.log("uploading", files_to_upload)
|
||||
|
||||
// Create a FormData object to store the form data
|
||||
const formData = new FormData();
|
||||
// Append each selected file to the FormData object
|
||||
for (let i = 0; i < files_to_upload.length; i++) {
|
||||
formData.append("files", files_to_upload[i]);
|
||||
}
|
||||
console.log("formdata", formData)
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "{% url 'api-1:upload_dicom' %}", true);
|
||||
xhr.setRequestHeader("X-CSRFToken", "{{ csrf_token }}");
|
||||
@@ -134,8 +140,6 @@
|
||||
window.upload_count += 1;
|
||||
if (xhr.status === 200) {
|
||||
// Handle successful response from the server
|
||||
console.log('Files uploaded successfully!');
|
||||
console.log(xhr.response);
|
||||
window.upload_results["uploaded"].push(...JSON.parse(xhr.response)["uploaded"]);
|
||||
window.upload_results["duplicates"].push(...JSON.parse(xhr.response)["duplicates"]);
|
||||
window.upload_results["failed"].push(...JSON.parse(xhr.response)["failed"]);
|
||||
@@ -180,14 +184,12 @@
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
function checkAnnonymisation(byteArray) {
|
||||
function checkAnnonymisation(byteArray, file) {
|
||||
// We need to setup a try/catch block because parseDicom will throw an exception
|
||||
// if you attempt to parse a non dicom part 10 file (or one that is corrupted)
|
||||
try {
|
||||
console.log("bytearray", byteArray)
|
||||
// parse byteArray into a DataSet object using the parseDicom library
|
||||
var dataSet = dicomParser.parseDicom(byteArray);
|
||||
console.log("ds", dataSet)
|
||||
|
||||
// dataSet contains the parsed elements. Each element is available via a property
|
||||
// in the dataSet.elements object. The property name is based on the elements group
|
||||
@@ -200,24 +202,25 @@
|
||||
var study_description = dataSet.string('x00081030');
|
||||
var accession_number = dataSet.string('x00080050').toLowerCase();
|
||||
var patient_id = dataSet.string('x00100020').toLowerCase();
|
||||
console.log("STUDY", study_description);
|
||||
console.log("acc", accession_number);
|
||||
console.log("pid", patient_id);
|
||||
|
||||
|
||||
not_anon = false;
|
||||
|
||||
site_codes = ["ref", "rk9", "ra9", "rh8", "rbz", "rba"]
|
||||
|
||||
errors = []
|
||||
for (let i = 0; i < site_codes.length; i++) {
|
||||
if (
|
||||
accession_number.startsWith(site_codes[i]) ||
|
||||
patient_id.startsWith(site_codes[i])
|
||||
)
|
||||
if ( accession_number.startsWith(site_codes[i]))
|
||||
{
|
||||
not_anon = true;
|
||||
errors.push(`Accession number starts with ${site_codes[i]} (${accession_number})`);
|
||||
}
|
||||
if ( patient_id.startsWith(site_codes[i]))
|
||||
{
|
||||
not_anon = true;
|
||||
errors.push(`Patient ID starts with ${site_codes[i]} (${patient_id})`);
|
||||
}
|
||||
}
|
||||
console.log(errors)
|
||||
|
||||
if (not_anon) {
|
||||
toastr.warning(`File does not appear to be annonymised<br/>Accession: ${accession_number} <br/>Patient ID: ${patient_id}<br/>`, 'Anonymisation warning', {
|
||||
@@ -237,7 +240,16 @@
|
||||
"showMethod": "fadeIn",
|
||||
"hideMethod": "fadeOut",
|
||||
});
|
||||
$("uploadButton").prop("disabled", true);
|
||||
$("#uploadButton").prop("disabled", true);
|
||||
$(".anon-alert").show();
|
||||
|
||||
setTimeout(
|
||||
function(errors) {
|
||||
console.log(errors)
|
||||
// We delay this a few seconds otherwise it does not work
|
||||
$(`.dicom-file[data-path='${file.webkitRelativePath}']`).addClass("image-ident-warning").append($(`<div>${errors.join("<br/>")}</div>`));
|
||||
}, 2000, errors
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: fix
|
||||
@@ -269,7 +281,8 @@
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function loadDicomViewerTest(callback) {
|
||||
async function loadDicomViewerUploads(callback) {
|
||||
$("#uploadButton").attr("disabled", false);
|
||||
$("#single-dicom-viewer").hide();
|
||||
console.log("load")
|
||||
$("#single-dicom-viewer").empty()
|
||||
@@ -285,7 +298,6 @@
|
||||
|
||||
const readFile = (file, el) => {
|
||||
reader(file).then(reader => {
|
||||
console.log("hello", file, el, image)
|
||||
//if ($("#id_examination").find(":selected")[0].dataset.select2Id == "2") {
|
||||
// var byteArray = new Uint8Array(reader.result);
|
||||
// console.log("etrxact", reader)
|
||||
@@ -296,7 +308,6 @@
|
||||
image.title = file.name;
|
||||
image.src = reader.result;
|
||||
image.className = "temp-thumb";
|
||||
console.log("read", el);
|
||||
$(el).append(image);
|
||||
//images.push(reader.result);
|
||||
loadViewer(images);
|
||||
@@ -315,8 +326,7 @@
|
||||
const readDicomFile = (file) => {
|
||||
return dicomReader(file).then(reader => {
|
||||
var byteArray = new Uint8Array(reader.result);
|
||||
console.log("etrxact", reader)
|
||||
return checkAnnonymisation(byteArray);
|
||||
return checkAnnonymisation(byteArray, file);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -341,12 +351,12 @@
|
||||
let item = document.createElement("li");
|
||||
item.className = "dicom-file";
|
||||
item.textContent = file.webkitRelativePath;
|
||||
item.dataset.path = file.webkitRelativePath;
|
||||
output.appendChild(item);
|
||||
//await readFile(file, item);
|
||||
|
||||
|
||||
let url = URL.createObjectURL(file)
|
||||
console.log(url)
|
||||
imageId = `wadouri:${url}`;
|
||||
if ($("#show-viewer").is(":checked")) {
|
||||
images.push(imageId);
|
||||
@@ -384,8 +394,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
console.log(images)
|
||||
|
||||
|
||||
const event = new CustomEvent('loadDicomViewerUrls', {
|
||||
"detail": {"images": images, "annontations": undefined}
|
||||
@@ -401,7 +409,6 @@
|
||||
|
||||
}
|
||||
function ocr(url, input) {
|
||||
console.log("OCR", input)
|
||||
Tesseract.recognize(
|
||||
url,
|
||||
'eng',
|
||||
@@ -414,7 +421,6 @@
|
||||
l = text.toLowerCase();
|
||||
uploading_el = $(`img[data-input-id='${input.id}'], div[data-input-id='${input.id}']`)
|
||||
uploading_el.removeClass("image-ident-loading");
|
||||
console.log("found text", l);
|
||||
if (l.includes("accession") || l.includes("number") || l.search(
|
||||
/(ref|rk9|rh8|ra9|rbz)\d+/g) > -1) {
|
||||
console.log("Match found ", input);
|
||||
@@ -457,7 +463,7 @@
|
||||
// output.appendChild(item);
|
||||
//}
|
||||
|
||||
loadDicomViewerTest();
|
||||
loadDicomViewerUploads();
|
||||
},
|
||||
false,
|
||||
);
|
||||
@@ -469,4 +475,10 @@
|
||||
});
|
||||
|
||||
</script>
|
||||
<script type="text/hyperscript">
|
||||
|
||||
init
|
||||
add @disabled to #uploadButton
|
||||
end
|
||||
</script>
|
||||
{% endblock %}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% load render_table from django_tables2 %}
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="container-fluid">
|
||||
<h2>Schemas</h2>
|
||||
Create a new schema <a href="{% url 'atlas:question_schema_create' %}">here</a>.
|
||||
<details>
|
||||
<summary>
|
||||
<h4>Filter</h4>
|
||||
</summary>
|
||||
<div id="view-filter-options">
|
||||
<form action="" method="get">
|
||||
{{ filter.form }}
|
||||
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
|
||||
</details>
|
||||
{% render_table table %}
|
||||
</div>
|
||||
|
||||
<div id="exam-options"></div>
|
||||
{% endblock %}
|
||||
@@ -8,6 +8,7 @@
|
||||
Adding self feedback for {{user_exam.exam}}/{{case}}
|
||||
|
||||
</div>
|
||||
This form allows you to record your own feedback for the question / case. This can take the form of a note or a ranking (1-5) of your findings and interpretation.
|
||||
<form class="highlight" action="" method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors }}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
|
||||
<div>
|
||||
<p><b>Description:</b> {{ finding.description }}</p>
|
||||
{% if finding.conditions.all %}
|
||||
<p><b>Conditions:</b></p>
|
||||
<ul>
|
||||
{% for condition in finding.conditions.all %}
|
||||
<li>{{ condition.get_link }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if finding.structures.all %}
|
||||
<p><b>Structures:</b></p>
|
||||
<ul>
|
||||
{% for structure in finding.structures.all %}
|
||||
<li>{{ structure.get_link }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<button class="btn btn-primary" id="reload-finding">Reload</button>
|
||||
|
||||
{% with image_url_array_and_count=finding.series.get_image_url_array_and_count %}
|
||||
<div id="root" class="dicom-viewer-root" data-images="{{ image_url_array_and_count.0 }}"
|
||||
style="height: 600px; box-sizing: border-box; background: #222;"
|
||||
data-annotationjson='{{ finding.annotation_json_3d }}' data-viewerstate='{{ finding.viewer_state_3d }}'
|
||||
></div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
@@ -0,0 +1,124 @@
|
||||
<div class="modal-dialog modal-dialog-centered related-finding-modal bg-dark">
|
||||
<div class="modal-content bg-dark text-light border-secondary">
|
||||
<div class="modal-content bg-dark text-light">
|
||||
<h5 class="modal-title">Related findings from case</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<details class="help-text">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p>This form is designed to quickly populate the same finding across different series.</p>
|
||||
<p>Cloning a finding from this form will populate the details with a blank viewport (you will need to add visual annotation and set the display as intended).</p>
|
||||
</details>
|
||||
<ul class="list-group list-group-flush bg-dark">
|
||||
{% for finding in findings %}
|
||||
<li class="list-group-item bg-dark text-light border-secondary">
|
||||
<div class="fw-bold mb-1 text-info">{{ finding.title }}</div>
|
||||
{% if finding.description %}
|
||||
Description:
|
||||
<div class="text-muted small mb-1" style="color:#b0b0b0 !important;">{{ finding.description|truncatechars:100 }}</div>
|
||||
{% endif %}
|
||||
{% if finding.findings.all %}
|
||||
<div class="mb-1">
|
||||
<span class="fw-semibold text-warning">Findings:</span>
|
||||
{% for subfinding in finding.findings.all %}
|
||||
{{ subfinding.get_link }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if finding.structures.all %}
|
||||
<div class="mb-1">
|
||||
<span class="fw-semibold text-warning">Structures:</span>
|
||||
{% for structure in finding.structures.all %}
|
||||
{{ structure.get_link }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if finding.conditions.all %}
|
||||
<div class="mb-1">
|
||||
<span class="fw-semibold text-warning">Conditions:</span>
|
||||
{% for condition in finding.conditions.all %}
|
||||
{{ condition.get_link }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mt-2">
|
||||
<button
|
||||
class="btn btn-sm btn-primary text-light use-finding-base-btn"
|
||||
type="button"
|
||||
data-finding-id="{{ finding.pk }}"
|
||||
data-finding-description="{{ finding.description|default_if_none:''|escapejs }}"
|
||||
{% if finding.conditions.all %}
|
||||
data-finding-conditions="{% for c in finding.conditions.all %}{{ c.pk }}{% if not forloop.last %},{% endif %}{% endfor %}"
|
||||
data-finding-conditions-names="{% for c in finding.conditions.all %}{{ c }}{% if not forloop.last %}|{% endif %}{% endfor %}"
|
||||
{% endif %}
|
||||
{% if finding.structures.all %}
|
||||
data-finding-structures="{% for s in finding.structures.all %}{{ s.pk }}{% if not forloop.last %},{% endif %}{% endfor %}"
|
||||
data-finding-structures-names="{% for s in finding.structures.all %}{{ s }}{% if not forloop.last %}|{% endif %}{% endfor %}"
|
||||
{% endif %}
|
||||
{% if finding.findings.all %}
|
||||
data-finding-findings="{% for f in finding.findings.all %}{{ f.pk }}{% if not forloop.last %},{% endif %}{% endfor %}"
|
||||
data-finding-findings-names="{% for f in finding.findings.all %}{{ f }}{% if not forloop.last %}|{% endif %}{% endfor %}"
|
||||
{% endif %}
|
||||
>Use as finding base</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('.use-finding-base-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
console.log('Button clicked:', btn);
|
||||
// Example: populate #hidden-form fields with finding data
|
||||
const form = window.parent.document.querySelector('#hidden-form');
|
||||
if (!form) return;
|
||||
|
||||
form.style.display = 'block'; // Show the form if hidden
|
||||
document.querySelector('#add-finding-button').style.display = 'none'; // Hide the button
|
||||
|
||||
|
||||
// Set values (adjust field names as needed)
|
||||
form.querySelector('[name="description"]').value = btn.getAttribute('data-finding-description');
|
||||
|
||||
|
||||
|
||||
function setSelect2Values(fieldName, dataAttr, namesAttr) {
|
||||
const values = btn.getAttribute(dataAttr);
|
||||
const names = btn.getAttribute(namesAttr);
|
||||
const select = form.querySelector(`[name="${fieldName}"]`);
|
||||
if (values && select) {
|
||||
const valueArr = values.split(',').map(v => v.trim()).filter(Boolean);
|
||||
const nameArr = names ? names.split('|') : [];
|
||||
valueArr.forEach((val, idx) => {
|
||||
if (val && !Array.from(select.options).some(opt => opt.value == val)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = val;
|
||||
opt.text = nameArr[idx] || val;
|
||||
opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
});
|
||||
$(select).val(valueArr).trigger('change');
|
||||
}
|
||||
}
|
||||
|
||||
// Usage inside your click handler:
|
||||
setSelect2Values('conditions', 'data-finding-conditions', 'data-finding-conditions-names');
|
||||
setSelect2Values('structures', 'data-finding-structures', 'data-finding-structures-names');
|
||||
setSelect2Values('findings', 'data-finding-findings', 'data-finding-findings-names');
|
||||
|
||||
|
||||
// Optionally, close the modal (if using Bootstrap modal JS)
|
||||
const modal = window.parent.document.querySelector('#clone-findings-modal');
|
||||
if (modal) {
|
||||
const modalInstance = bootstrap.Modal.getInstance(modal);
|
||||
if (modalInstance) modalInstance.hide();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% load render_table from django_tables2 %}
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="container-fluid">
|
||||
<h2>My Series</h2>
|
||||
<details>
|
||||
<summary>
|
||||
<h4>Filter</h4>
|
||||
</summary>
|
||||
<div id="view-filter-options">
|
||||
<form action="" method="get">
|
||||
{{ filter.form }}
|
||||
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
|
||||
</details>
|
||||
{% render_table table %}
|
||||
</div>
|
||||
|
||||
<div id="exam-options"></div>
|
||||
{% endblock %}
|
||||
@@ -3,30 +3,77 @@
|
||||
<div>{{ series.modality}}, {{ series.examination }}, {{ series.plane }}, {{ series.contrast }}</div>
|
||||
<div>Description: {{series.description}}</div>
|
||||
|
||||
{% if series.case %}
|
||||
Associated case:
|
||||
{% for case in series.case.all %}
|
||||
Associated case:
|
||||
{% for case in series.case.all %}
|
||||
<span id="case-link-{{ case.pk }}" class="case-item">
|
||||
<a href="{% url 'atlas:case_detail' pk=case.pk %}">{{case}}</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% if can_edit %}
|
||||
<button hx-post="{% url 'atlas:remove_series_from_case' series.pk case.pk %}"
|
||||
hx-target="#case-link-{{ case.pk }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to remove this series from the case?"
|
||||
class="btn btn-link btn-sm button-unset remove-button">
|
||||
Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% empty %}
|
||||
This series is not associated with any cases.
|
||||
{% endif %}
|
||||
{% if can_edit %}
|
||||
<details>
|
||||
<summary>Add Series to Case</summary>
|
||||
<form hx-post="{% url 'atlas:add_series_to_case' series.pk %}"
|
||||
hx-target="#add-series-to-case-result"
|
||||
hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<label for="case-select">Select Case:</label>
|
||||
<select name="case_id" id="case-select" required>
|
||||
{% for case in available_cases %}
|
||||
<option value="{{ case.pk }}">{{ case.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary">Add to Case</button>
|
||||
</form>
|
||||
<div id="add-series-to-case-result"></div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ image_url_array_and_count.0 }}" data-annotations=''>
|
||||
</div>
|
||||
{% comment %} <div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ image_url_array_and_count.0 }}" data-annotations=''>
|
||||
</div> {% endcomment %}
|
||||
|
||||
<div id="root" class="dicom-viewer-root" data-images="{{ image_url_array_and_count.0 }}"
|
||||
style="width: 1000px; height: 600px; box-sizing: border-box; background: #222;"
|
||||
data-auto-cache-stack=true
|
||||
></div>
|
||||
|
||||
|
||||
{% if can_edit %}
|
||||
{% if editing_finding < 1 %}
|
||||
<button id="add-finding-button">Add finding</button>
|
||||
<button id="clone-finding-button" title="Click to copy the details of a finding that is already associated with the series/case"
|
||||
hx-get="{% url 'atlas:series_finding_related' series.pk %}"
|
||||
hx-target="#clone-findings-modal"
|
||||
hx-trigger="click"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#clone-findings-modal"
|
||||
>Clone existing finding</button>
|
||||
{% endif %}
|
||||
<button id="reset-viewport-button">Reset viewport</button>
|
||||
<button id="reset-viewport-button" class="btn btn-secondary">Reset viewport</button>
|
||||
<div id="finding-form">
|
||||
<div class="hide" id="hidden-form">
|
||||
{% if editing_finding > 0 %}
|
||||
<h3>Editing Finding</h3>
|
||||
<p>Editing finding with ID: {{editing_finding}}</p>
|
||||
{% else %}
|
||||
<h3>Add Finding</h3>
|
||||
<p>Click the button below to add a new finding.</p>
|
||||
{% endif %}
|
||||
<form method="post" id="series_finding_form">
|
||||
{% csrf_token %}
|
||||
{{series_finding_form|crispy}}
|
||||
<input type="submit" value="Submit">
|
||||
<input type="submit" value="Submit" class="btn btn-lg btn-success fw-bold px-4 py-2" style="font-size: 1.2rem;">
|
||||
<button id="cancel-add-finding-button">Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -44,8 +91,8 @@
|
||||
|
||||
{% for finding in series.findings.all %}
|
||||
<div class="finding-box">
|
||||
<button class="view-finding-button" data-annotationjson={{finding.annotation_json}}
|
||||
data-viewportjson={{finding.viewport_json}} data-findingid={{finding.id}} data-currentimageid={{finding.current_image_id_index}}>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>
|
||||
|
||||
<span class="view-finding-details">
|
||||
Finding(s): {% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br />
|
||||
Structure(s): {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br />
|
||||
@@ -61,6 +108,11 @@
|
||||
<pre>{{finding.viewport_json}}</pre>
|
||||
<h4>Image ID</h4>
|
||||
<pre>{{finding.current_image_id_index}}</pre>
|
||||
|
||||
<h4>3D Annotation JSON</h4>
|
||||
<pre>{{finding.annotation_json_3d}}</pre>
|
||||
<h4>Viewer State</h4>
|
||||
<pre>{{finding.viewer_state_3d}}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -78,6 +130,10 @@
|
||||
<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.
|
||||
</p>
|
||||
<div role="alert" class="alert alert-warning">
|
||||
<strong>Warning:</strong> Please note: if you have reordered the series this may result in the incorrect images being deleted.
|
||||
</div>
|
||||
|
||||
Start <input id="lower-truncation-bound-input" type="number" value="1"> <button id="set-lower-truncation-bound-button">Set</button><br/>
|
||||
End <input id="upper-truncation-bound-input"type="number" value="{{image_url_array_and_count.1}}"> <button id="set-upper-truncation-bound-button">Set</button><br/>
|
||||
<button id="truncate-test-button">Test truncate</button>
|
||||
@@ -90,111 +146,100 @@
|
||||
|
||||
|
||||
<div>Author: {{ series.get_author_display }}</div>
|
||||
{% if can_edit %}
|
||||
<details>
|
||||
<summary>Series info</summary>
|
||||
<div>
|
||||
<a href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}"
|
||||
title="Anonymise dicom images">Anonymise dicoms</a><br />
|
||||
<a href="{% url 'atlas:series_order_dicom' pk=series.pk %}" title="orders dicom by slice location">Order dicoms
|
||||
by slice location</a>
|
||||
<a href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}"
|
||||
title="orders dicom by instance number">Order dicoms by instance number</a>
|
||||
<a href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}"
|
||||
title="orders dicom by instance number">Order dicoms by SeriesInstanceUID</a>
|
||||
<a href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}"
|
||||
title="orders dicom by uploaded filename">Order by uploaded filename</a><br/>
|
||||
<a href="{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}"
|
||||
title="split series by dicom tag ImageType">Split the series by the ImageType dicom tag</a>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<details>
|
||||
<summary>
|
||||
Image details
|
||||
</summary>
|
||||
<form hx-post="{% url 'atlas:image_diff' %}"
|
||||
hx-target="#temp"
|
||||
>
|
||||
{% 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}}
|
||||
<summary>Series info</summary>
|
||||
<div>
|
||||
<a href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}"
|
||||
title="Anonymise dicom images">Anonymise dicoms</a><br />
|
||||
<a href="{% url 'atlas:series_order_dicom' pk=series.pk %}" title="orders dicom by slice location">Order dicoms
|
||||
by slice location</a>
|
||||
<a href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}"
|
||||
title="orders dicom by instance number">Order dicoms by instance number</a>
|
||||
<a href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}"
|
||||
title="orders dicom by instance number">Order dicoms by SeriesInstanceUID</a>
|
||||
<a href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}"
|
||||
title="orders dicom by uploaded filename">Order by uploaded filename</a><br/>
|
||||
<a href="{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}"
|
||||
title="split series by dicom tag ImageType">Split the series by the ImageType dicom tag</a>
|
||||
</div>
|
||||
<details>
|
||||
<summary>
|
||||
Image details
|
||||
</summary>
|
||||
<form hx-post="{% url 'atlas:image_diff' %}"
|
||||
hx-target="#temp"
|
||||
>
|
||||
{% for image in series.get_images %}
|
||||
[{{ image.id }}] <a href='{% url "atlas:series_image_dicom" image.pk %}'>{{image.image.url}}</a>, pos: {{image.position}}, {{image.upload_filename}}
|
||||
|
||||
|
||||
{% if image.image %}
|
||||
[{{image.get_file_size|filesizeformat}}]
|
||||
{% endif %}
|
||||
{% if image.image %}
|
||||
[{{image.get_file_size|filesizeformat}}]
|
||||
{% 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 %}
|
||||
<button>Diff files</button>
|
||||
</form>
|
||||
<span id="temp"></span>
|
||||
</details>
|
||||
{% endfor %}
|
||||
<button>Diff files</button>
|
||||
</form>
|
||||
<span id="temp"></span>
|
||||
</details>
|
||||
|
||||
<p>Total image size: {{series.get_total_image_size|filesizeformat}}</p>
|
||||
<p><a href="{% url 'atlas:series_download' pk=series.pk %}">Download images</a></p>
|
||||
</details>
|
||||
<span id="diff-controls">show <span id="diff-show-all" _="on click
|
||||
log '1'
|
||||
for row in .diff.rows
|
||||
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>
|
||||
<p>Total image size: {{series.get_total_image_size|filesizeformat}}</p>
|
||||
<p><a href="{% url 'atlas:series_download' pk=series.pk %}">Download images</a></p>
|
||||
</details>
|
||||
<span id="diff-controls">show <span id="diff-show-all" _="on click
|
||||
log '1'
|
||||
for row in .diff.rows
|
||||
show row
|
||||
end">all</span> <span id="diff-show-diff" _="
|
||||
on click
|
||||
log 'go'
|
||||
for row in <.diff tr/>
|
||||
log row.children[0].innerHTML
|
||||
if row.children[0].innerHTML is empty
|
||||
hide row
|
||||
end
|
||||
end
|
||||
">diff</span></span>
|
||||
|
||||
{% endif %}
|
||||
|
||||
<div id="clone-findings-modal"
|
||||
class="modal modal-blur fade"
|
||||
style="display: none"
|
||||
aria-hidden="false"
|
||||
tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered related-finding-modal" style="max-width: 700px;">
|
||||
<div class="modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
const apiKey = "root"
|
||||
|
||||
$("#set-lower-truncation-bound-button").click(() => {
|
||||
dicom_element = $(".cornerstone-element").get(0);
|
||||
index = cornerstone.getEnabledElement(dicom_element).toolStateManager.toolState.stack.data[0].currentImageIdIndex
|
||||
$("#lower-truncation-bound-input").val(index+1);
|
||||
$("#lower-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
|
||||
});
|
||||
$("#set-upper-truncation-bound-button").click(() => {
|
||||
dicom_element = $(".cornerstone-element").get(0);
|
||||
index = cornerstone.getEnabledElement(dicom_element).toolStateManager.toolState.stack.data[0].currentImageIdIndex
|
||||
$("#upper-truncation-bound-input").val(index+1);
|
||||
$("#upper-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
|
||||
});
|
||||
|
||||
$("#truncate-test-button").click(() => {
|
||||
lower_bound = parseInt($("#lower-truncation-bound-input").val()) - 1;
|
||||
upper_bound = parseInt($("#upper-truncation-bound-input").val()) - 1;
|
||||
|
||||
$("#lower-truncation-bound-input").prop("disabled", true);
|
||||
$("#upper-truncation-bound-input").prop("disabled", true);
|
||||
$("#set-lower-truncation-bound-button").remove();
|
||||
$("#set-upper-truncation-bound-button").remove();
|
||||
$("#truncate-test-button").remove();
|
||||
if (lower_bound >= upper_bound) {
|
||||
alert("The lower bound must be less than the upper bound.");
|
||||
return;
|
||||
const lower = parseInt($("#lower-truncation-bound-input").val()) - 1;
|
||||
const upper = parseInt($("#upper-truncation-bound-input").val()) - 1;
|
||||
const ok = window[`truncateStack_${apiKey}`](lower, upper);
|
||||
if (ok) {
|
||||
$("#truncate-output").text("The truncated images are shown above.");
|
||||
}
|
||||
dicom_element = $(".cornerstone-element").get(0);
|
||||
stack = cornerstone.getEnabledElement(dicom_element).toolStateManager.toolState.stack.data[0]
|
||||
stack.imageIds = stack.imageIds.slice(lower_bound, upper_bound+1) // inclusive
|
||||
|
||||
stack.currentImageIdIndex = 0
|
||||
//let id = stack.imageIds[0];
|
||||
//cornerstone.loadImage(id).then((b) => {
|
||||
// cornerstone.displayImage(dicom_element, b);
|
||||
//});
|
||||
cornerstone.reset(dicom_element);
|
||||
|
||||
$("#truncate-output").text("The truncated images are shown above. If you wish to make changes refresh the page. To continue click the trucated series button.")
|
||||
|
||||
})
|
||||
});
|
||||
$("#truncate-button").click(() => {
|
||||
lower_bound = parseInt($("#lower-truncation-bound-input").val()) - 1;
|
||||
upper_bound = parseInt($("#upper-truncation-bound-input").val()) - 1;
|
||||
@@ -214,11 +259,12 @@
|
||||
|
||||
$("#add-finding-button").click(() => {
|
||||
$("#hidden-form").show()
|
||||
dicom_element = $(".cornerstone-element").get(0);
|
||||
//dicom_element = $(".cornerstone-element").get(0);
|
||||
//cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
|
||||
//cornerstone.reset(dicom_element);
|
||||
////cornerstone.resize(dicom_element, true);
|
||||
$("#add-finding-button").hide()
|
||||
$("#clone-finding-button").hide()
|
||||
//$("#finding-form").empty().append(
|
||||
// $("#hidden-form form").clone()
|
||||
//);
|
||||
@@ -227,84 +273,47 @@
|
||||
$("#cancel-add-finding-button").click((e) => {
|
||||
$("#hidden-form").hide()
|
||||
$("#add-finding-button").show()
|
||||
$("#clone-finding-button").show()
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
|
||||
$("#reset-viewport-button").click(() => {
|
||||
dicom_element = $(".cornerstone-element").get(0);
|
||||
//annotationjson = JSON.parse(e.currentTarget.dataset.annotationjson);
|
||||
cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
|
||||
cornerstone.reset(dicom_element);
|
||||
cornerstone.resize(dicom_element, true);
|
||||
resetViewer_root();
|
||||
|
||||
});
|
||||
|
||||
$(".view-finding-button").each((n, el) => {
|
||||
$(".view-finding-button-3d").each((n, el) => {
|
||||
$(el).click((e) => {
|
||||
dicom_element = $(".cornerstone-element").get(0);
|
||||
annotationjson = JSON.parse(e.currentTarget.dataset.annotationjson);
|
||||
viewport = JSON.parse(e.currentTarget.dataset.viewportjson);
|
||||
current_image_id_index = e.currentTarget.dataset.currentimageid;
|
||||
console.log(e.currentTarget, current_image_id_index)
|
||||
loadAnnotationAndViewportOnElement(annotationjson, viewport, dicom_element, current_image_id_index);
|
||||
|
||||
|
||||
annotationjson3d = JSON.parse(e.currentTarget.dataset.annotationjson3d);
|
||||
viewer_state_json = JSON.parse(e.currentTarget.dataset.viewerstatejson);
|
||||
console.log("Loading 3D annotation and viewer state", annotationjson3d, viewer_state_json);
|
||||
importAnnotations_root(annotationjson3d);
|
||||
importViewerState_root(viewer_state_json);
|
||||
$(el).addClass("active");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
$("#diff-show-all").click(() => {
|
||||
console.log("Helol");
|
||||
})
|
||||
$("#diff-show-diff").click(() => {
|
||||
console.log("diff");
|
||||
})
|
||||
|
||||
|
||||
{% if editing_finding > 0 %}
|
||||
$("#hidden-form").show()
|
||||
setTimeout(function() {
|
||||
$(".view-finding-button[data-findingid={{editing_finding}}]").trigger("click");
|
||||
$(".view-finding-button-3d[data-findingid={{editing_finding}}]").trigger("click");
|
||||
}, 1000)
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if request.GET.show_finding %}
|
||||
function show_finding() {
|
||||
console.log("show finding")
|
||||
$("#finding-{{ request.GET.show_finding }}").trigger("click");
|
||||
}
|
||||
setTimeout(show_finding, 500);
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
function loadAnnotationAndViewportOnElement(annotation, viewport, dicom_element, current_image_id_index) {
|
||||
cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
|
||||
cornerstone.getEnabledElement(dicom_element).viewport = viewport
|
||||
cornerstoneTools.globalImageIdSpecificToolStateManager.restoreToolState(
|
||||
annotation);
|
||||
console.log("annotation", annotation)
|
||||
console.log("viewport", viewport)
|
||||
|
||||
//cornerstone.getEnabledElement(dicom_element).toolStateManager.restoreToolState(annotationjson)
|
||||
cornerstone.resize(dicom_element);
|
||||
console.log("current_image_id_index", current_image_id_index)
|
||||
//current_image_id_index = parseInt(current_image_id_index);
|
||||
|
||||
console.log(current_image_id_index, Number.isInteger(current_image_id_index))
|
||||
if (Number.isInteger(current_image_id_index)) {
|
||||
console.log("loading stack index")
|
||||
dicomViewer.loadStackIndex(current_image_id_index, dicom_element);
|
||||
} else {
|
||||
try {
|
||||
console.log("current_image_id_index", current_image_id_index)
|
||||
dicomViewer.loadImageById(current_image_id_index, dicom_element);
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
console.log("loading image by id failed")
|
||||
console.log("loading next annotation image")
|
||||
dicomViewer.getNextAnnotationImage(dicom_element);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$(document).on('submit', '#series_finding_form', function (e) {
|
||||
dicom_element = $(".cornerstone-element").get(0);
|
||||
c = cornerstone.getEnabledElement(dicom_element);
|
||||
e.preventDefault();
|
||||
|
||||
$.ajax({
|
||||
@@ -314,14 +323,8 @@
|
||||
series_finding_id: {{ editing_finding }},
|
||||
description: $('#id_description').val(),
|
||||
series: {{ series.pk }},
|
||||
//annotation_json: JSON.stringify(c.toolStateManager.saveToolState()),
|
||||
// This is the stack number
|
||||
//current_image_id_index: c.toolStateManager.toolState.stack.data[0].currentImageIdIndex,
|
||||
// but we want to use the current image id (so if the stack is reordered it still attaches to the correct image)
|
||||
current_image_id_index: c.toolStateManager.toolState.stack.data[0].imageIds[c.toolStateManager.toolState.stack.data[0].currentImageIdIndex],
|
||||
annotation_json: JSON.stringify(cornerstoneTools.globalImageIdSpecificToolStateManager
|
||||
.saveToolState()),
|
||||
viewport_json: JSON.stringify(c.viewport),
|
||||
annotation_json_3d: window.exportAnnotations_root(),
|
||||
viewer_state_3d: window.exportViewerState_root(),
|
||||
findings: JSON.stringify($('#finding-form select[name="findings"]').find(":selected")
|
||||
.map((i, el) => {
|
||||
return $(el).val()
|
||||
@@ -351,3 +354,36 @@
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.case-item {
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.case-item:hover {
|
||||
background-color: darkgray;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.remove-button:hover {
|
||||
color: #dc3545; /* Optional: Make the remove button text red on hover */
|
||||
}
|
||||
|
||||
.active {
|
||||
font-weight: bold; /* Optional: Make the active button text bold */
|
||||
border-style: dashed;
|
||||
}
|
||||
#finding-form #hidden-form {
|
||||
background: #23272b;
|
||||
border: 2px solid #198754;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 0 16px #19875455;
|
||||
padding: 2rem;
|
||||
margin: 2rem 0;
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
#finding-form #hidden-form:focus-within {
|
||||
box-shadow: 0 0 24px #198754aa;
|
||||
}
|
||||
</style>
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
{% load static %}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
||||
{% comment %} <div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ image_url_array_and_count.0 }}" data-annotations=''>
|
||||
</div> {% endcomment %}
|
||||
|
||||
<div id="root" class="dicom-viewer-root" data-images="{{ image_url_array_and_count.0 }}"
|
||||
style="width: 1000px; height: 600px; box-sizing: border-box; background: #222;"
|
||||
></div>
|
||||
|
||||
{% endwith %}
|
||||
<script type="module" src="{% static 'dv3d/index-DCNn6AQH.js' %}" defer="defer" type="module"></script>
|
||||
|
||||
<input type="file" id="dicomInput" multiple />
|
||||
<button onclick="sendToViewer()">Send to Viewer</button>
|
||||
<button onclick="testLoadWadouriStack()">Test Load wadouri Stack</button>
|
||||
<script>
|
||||
function sendToViewer() {
|
||||
const files = document.getElementById('dicomInput').files;
|
||||
if (window.loadDicomStackFromFiles) {
|
||||
window.loadDicomStackFromFiles(files);
|
||||
} else {
|
||||
alert("Viewer API not ready");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function testLoadWadouriStack() {
|
||||
const urls = [
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_757_XQQyovo.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_766_8fWI0Qc.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_774_c8IpxUu.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_758_6WKI7ws.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_767_TduVOb8.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_775_7zWneQ0.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_759_BtKzgS5.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_768_RdvtM8K.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_776_LhVY3Ty.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_760_a8CiR4J.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_769_YHyJ1l6.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_777_8IFYJmL.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_761_vyvO1EN.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_770_vQwi4LW.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_778_BwGAMOR.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_762_DhSgQsm.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_771_xifitCl.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_779_LyA1zYn.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_763_aVjff3H.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_772_cDQZtxQ.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_780_qaZqsqg.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_764_dDoeldL.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_773_93Hvedg.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_781_bEx9Owg.dcm",
|
||||
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_765_qmd5mYJ.dcm"
|
||||
];
|
||||
const wadouriList = urls.map(url => "wadouri:" + url);
|
||||
if (window.loadDicomStackFromWadouriList) {
|
||||
window.loadDicomStackFromWadouriList(wadouriList);
|
||||
} else {
|
||||
alert("Viewer API not ready");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2>SeriesFinding Migration Status</h2>
|
||||
|
||||
<form method="get" class="mb-3">
|
||||
<label>
|
||||
<input type="checkbox" name="hide_no_series" value="1" {% if request.GET.hide_no_series %}checked{% endif %}>
|
||||
Hide findings with no series
|
||||
</label>
|
||||
<button type="submit" class="btn btn-sm btn-primary ms-2">Apply</button>
|
||||
</form>
|
||||
|
||||
<table class="table table-bordered table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Description</th>
|
||||
<th>Series</th>
|
||||
<th>annotation_json_3d</th>
|
||||
<th>viewer_state_3d</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in findings_status %}
|
||||
{% if not request.GET.hide_no_series or f.series %}
|
||||
<tr>
|
||||
<td>{{ f.id }}</td>
|
||||
<td>{{ f.str }}</td>
|
||||
<td>
|
||||
{% if f.series %}
|
||||
<a href="{% url 'atlas:series_detail' f.series.pk %}">{{ f.series }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if f.annotation_json_3d %}
|
||||
<span class="badge bg-success">Populated</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Empty</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if f.viewer_state_3d %}
|
||||
<span class="badge bg-success">Populated</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Empty</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock content %}
|
||||
@@ -5,7 +5,7 @@
|
||||
<h2>SeriesInstanceUID: {{series_id}}</h2>
|
||||
|
||||
|
||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images="[{% for dicom in dicoms %}"{{dicom.image.url}}"
|
||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images="[{% for dicom in dicoms %}"{{dicom.get_dicom_url}}"
|
||||
{% if not forloop.last %},{% endif %}{% endfor %}]" data-annotations=''>
|
||||
</div>
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
</summary>
|
||||
{% for dicom in dicoms %}
|
||||
{% if dicom.image %}
|
||||
[{{image.image.size|filesizeformat}}]
|
||||
[{{dicom.image.size|filesizeformat}}]
|
||||
{% endif %}
|
||||
|
||||
{{image.image_md5_hash}} ({{image.is_dicom}})
|
||||
{% comment %} {{dicom.image_md5_hash}} ({{dicom.is_dicom}}) {% endcomment %}
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<a href="{% url 'atlas:new_uploads' %}">Upload more dicoms</a><br/>
|
||||
{% if not case %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
Series imported from here will be orphaned (not associated with a case). It is usually better to create a case first and then import directly into the case.
|
||||
Series imported from here will be orphaned (not associated with a case). <br/>It is usually better to <a href="{% url 'atlas:case_create' %}">create a case</a> first and then import directly into the case.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -18,10 +18,11 @@
|
||||
{% for series, n, tags, date in series_list %}
|
||||
<li>
|
||||
<span _="
|
||||
on click toggle .selected on me
|
||||
on click toggle .selected on me.closest('li')
|
||||
then set i to the first <input/> in me
|
||||
then set i.checked to not i.checked" >
|
||||
{{date}}: {{series}} [{{n}} images] <br/>
|
||||
Uploaded: {{date}}<br/>
|
||||
Series: {{series}} [{{n}} images] <br/>
|
||||
{{tags}}
|
||||
<input class="hidden" type="checkbox" name="selection" value="{{ series }}">
|
||||
</span>
|
||||
@@ -34,14 +35,14 @@
|
||||
</ul>
|
||||
|
||||
{% if case %}
|
||||
<p>Importing into case {{case}}</p>
|
||||
<p>Importing into case: <a href='{% url "atlas:case_detail" case.pk %}'>{{case}}</a></p>
|
||||
{% endif %}
|
||||
<details><summary>Upload settings</summary>
|
||||
<form>
|
||||
<div>
|
||||
<fieldset><legend>Select how series should be ordered when uploaded</legend>
|
||||
<div class="helptext">
|
||||
This will affect how series are displayed when viewing with the built in site viewer. By default when exporting from Insight the order is not maintained so you should choose to order eithre 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.
|
||||
</div>
|
||||
<div>
|
||||
@@ -71,6 +72,7 @@
|
||||
<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>
|
||||
|
||||
@@ -78,9 +80,11 @@
|
||||
<button id="import-dicoms-button"
|
||||
hx-post="{% url 'api-1:import_dicoms' %}"
|
||||
hx-target=".imported"
|
||||
{% comment %} hx-indicator=".indicator" {% endcomment %}
|
||||
hx-include=".selected,[name='order-series']"
|
||||
>Import</button>
|
||||
{% endif %}
|
||||
<div class="indicator"><div class="loader"></div>Loading...</div>
|
||||
|
||||
<div class="imported">
|
||||
|
||||
@@ -93,3 +97,65 @@
|
||||
<p><a href="{% url 'atlas:series_view' %}?case=null&sort=-modified_date">View orphan series</a></p>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.indicator.htmx-request {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 50px;
|
||||
aspect-ratio: 1;
|
||||
display: grid;
|
||||
}
|
||||
.loader::before,
|
||||
.loader::after {
|
||||
content:"";
|
||||
grid-area: 1/1;
|
||||
--c:no-repeat radial-gradient(farthest-side,#25b09b 92%,#0000);
|
||||
background:
|
||||
var(--c) 50% 0,
|
||||
var(--c) 50% 100%,
|
||||
var(--c) 100% 50%,
|
||||
var(--c) 0 50%;
|
||||
background-size: 12px 12px;
|
||||
animation: l12 1s infinite;
|
||||
}
|
||||
.loader::before {
|
||||
margin: 4px;
|
||||
filter: hue-rotate(45deg);
|
||||
background-size: 8px 8px;
|
||||
animation-timing-function: linear
|
||||
}
|
||||
|
||||
@keyframes l12 {
|
||||
100%{transform: rotate(.5turn)}
|
||||
}
|
||||
|
||||
#series-list {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#series-list li {
|
||||
}
|
||||
#series-list .selected {
|
||||
color: white;
|
||||
border: 1px solid #52057b;
|
||||
user-select: none;
|
||||
margin: -1px;
|
||||
|
||||
}
|
||||
.series-block-popup-link {
|
||||
float: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock css %}
|
||||
|
||||
@@ -7,10 +7,6 @@
|
||||
{% block content %}
|
||||
|
||||
<div class="container-fluid">
|
||||
Create a new <a href='{{request.path}}create'>collection</a> / <a href="{% url 'atlas:user_collections' %}">Collections to view / take</a>
|
||||
|
||||
|
||||
|
||||
<h2>My Collections</h2>
|
||||
<details>
|
||||
<summary>
|
||||
|
||||
+35
-1
@@ -138,6 +138,11 @@ urlpatterns = [
|
||||
views.collection_case_dicom_json,
|
||||
name="collection_case_dicom_json",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/case/<int:case_id>/dicom_json/review",
|
||||
views.collection_case_dicom_json_review,
|
||||
name="collection_case_dicom_json_review",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/question_schemas",
|
||||
views.collection_question_schemas,
|
||||
@@ -148,6 +153,16 @@ urlpatterns = [
|
||||
views.collection_reset_answers,
|
||||
name="collection_reset_answers",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/reset_answers_user_list",
|
||||
views.collection_reset_answers_user_list,
|
||||
name="collection_reset_answers_user_list",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/<int:user_id>/reset_answers",
|
||||
views.collection_reset_answers_user,
|
||||
name="collection_reset_answers_user",
|
||||
),
|
||||
#path(
|
||||
# "question_schemas_preset",
|
||||
# views.question_schemas_preset,
|
||||
@@ -239,11 +254,20 @@ urlpatterns = [
|
||||
views.use_dates_as_descriptions,
|
||||
name="use_dates_as_descriptions",
|
||||
),
|
||||
path(
|
||||
"case/<int:case_pk>/collection/<int:collection_pk>/remove/",
|
||||
views.remove_case_from_collection,
|
||||
name="remove_case_from_collection",
|
||||
),
|
||||
path(
|
||||
"series/<int:pk>/dicom_json",
|
||||
views.series_dicom_json,
|
||||
name="series_dicom_json",
|
||||
),
|
||||
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_finding/<int:finding_id>/details/', views.series_finding_details, name='series_finding_details'),
|
||||
path('series_finding/migration-status/', views.seriesfinding_migration_status, name='seriesfinding_migration_status'),
|
||||
path("uncategorised_dicoms/", views.uncategorised_dicoms, name="uncategorised_dicoms_view"),
|
||||
path("question_schema/", views.QuestionSchemaView.as_view(), name="question_schema_overview"),
|
||||
path("question_schema/<int:pk>", views.question_schema_detail, name="question_schema_detail"),
|
||||
@@ -296,10 +320,13 @@ urlpatterns = [
|
||||
name="structure_update",
|
||||
),
|
||||
path("structure/create", views.StructureCreate.as_view(), name="structure_create"),
|
||||
path("series/<int:pk>/thumbnail/fail", views.series_thumbnail_fail, name="series_thumbnail_fail"),
|
||||
path("series/<int:pk>/thumbnail", views.series_thumbnail, name="series_thumbnail"),
|
||||
# TODO: case context series viewing (so that we can view series in the context of a case)
|
||||
path("series/<int:pk>", views.series_detail, name="series_detail"),
|
||||
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
|
||||
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
|
||||
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
|
||||
path("series/", views.SeriesView.as_view(), name="series_view"),
|
||||
path("series_image/<int:pk>/dicom", views.series_image_dicom, name="series_image_dicom"),
|
||||
path(
|
||||
@@ -362,10 +389,12 @@ urlpatterns = [
|
||||
name="case_collection_form",
|
||||
),
|
||||
path("case/<int:pk>/clone", views.AtlasClone.as_view(), name="case_clone"),
|
||||
path("case/<int:pk>/clone/next", views.AtlasCloneNext.as_view(), name="case_clone_next"),
|
||||
# path("verified/", views.verified, name="verified"),
|
||||
# path("all_questions/", views.all_questions, name="all_questions"),
|
||||
path("case/<int:pk>/scrap", views.atlas_scrap, name="case_scrap"),
|
||||
path("case/<int:pk>/delete", views.CaseDelete.as_view(), name="case_delete"),
|
||||
path("case/<int:case_id>/push_to_cimar", views.push_case_to_cimar, name="push_case_to_cimar"),
|
||||
path("case/create/", views.AtlasCreate.as_view(), name="case_create"),
|
||||
path(
|
||||
"case/collection/<int:pk>/create",
|
||||
@@ -421,7 +450,7 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
"structure-autocomplete",
|
||||
views.StructureAutocomplete.as_view(model=Structure, create_field="name"),
|
||||
views.StructureAutocomplete.as_view(model=Structure, create_field="name", validate_create=True),
|
||||
name="structure-autocomplete",
|
||||
),
|
||||
path("presentation/", views.PresentationView.as_view(), name="presentation_view"),
|
||||
@@ -439,4 +468,9 @@ urlpatterns = [
|
||||
name="pathological_process_detail",
|
||||
),
|
||||
path("combine_series/", views.combine_series, name="combine_series"),
|
||||
path(
|
||||
"case/<int:case_id>/linked/",
|
||||
views.linked_cases_overview,
|
||||
name="linked_cases_overview",
|
||||
),
|
||||
]
|
||||
|
||||
+303
-47
@@ -7,6 +7,7 @@ from django.db.models.query import QuerySet
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django import forms
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
# from django.contrib.auth.models import User
|
||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||
@@ -22,6 +23,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMix
|
||||
from django.views.generic import View
|
||||
from django.views.generic.detail import DetailView
|
||||
import pydicom
|
||||
from pydicom.uid import generate_uid
|
||||
from generic.mixins import SuperuserRequiredMixin
|
||||
|
||||
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
|
||||
@@ -33,8 +35,9 @@ from django.urls import reverse_lazy, reverse
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.http import HttpResponseRedirect, HttpResponse
|
||||
|
||||
from generic.models import CidUser, CidUserExam
|
||||
from generic.models import CidUser, CidUserExam, CimarCase
|
||||
from atlas.helpers import get_cases_available_to_user
|
||||
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
|
||||
|
||||
from .forms import (
|
||||
AddCollectionToCaseForm,
|
||||
@@ -53,6 +56,7 @@ from .forms import (
|
||||
ExamGroupsForm,
|
||||
FindingForm,
|
||||
JsonAnswerForm,
|
||||
#ModelPriorCaseForm,
|
||||
PriorCaseForm,
|
||||
QuestionSchemaForm,
|
||||
ResourceForm,
|
||||
@@ -115,10 +119,12 @@ from .filters import (
|
||||
SubspecialtyFilter,
|
||||
)
|
||||
|
||||
from .tasks import push_case_to_cimar_task
|
||||
|
||||
from django_tables2 import SingleTableView, SingleTableMixin
|
||||
from django_filters.views import FilterView
|
||||
|
||||
from autocomplete import HTMXAutoComplete
|
||||
# from autocomplete import HTMXAutoComplete
|
||||
|
||||
|
||||
from .decorators import (
|
||||
@@ -156,11 +162,11 @@ from reversion.views import RevisionMixin
|
||||
import reversion
|
||||
|
||||
|
||||
from rest_framework import viewsets
|
||||
|
||||
import os
|
||||
import difflib
|
||||
|
||||
from helpers.cimar import CimarAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -196,19 +202,48 @@ def case_detail(request, pk):
|
||||
# if request.user not in atlas.author.all():
|
||||
# raise PermissionDenied
|
||||
|
||||
can_edit = case.check_user_can_edit(request.user)
|
||||
|
||||
# logging.debug(atlas.subspecialty.first().name.all())
|
||||
return render(request, "atlas/case_detail.html", {"case": case})
|
||||
return render(
|
||||
request,
|
||||
"atlas/case_detail.html",
|
||||
{
|
||||
"case": case,
|
||||
"cimar_sid": request.user.userprofile.cimar_sid,
|
||||
"can_edit": can_edit,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||||
def series_thumbnail(request, pk, finding_pk=None):
|
||||
def series_thumbnail_fail(request, pk):
|
||||
return series_thumbnail(request, pk=pk, fail_loudly=True)
|
||||
|
||||
|
||||
@login_required
|
||||
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||||
def series_thumbnail(request, pk, fail_loudly=False):
|
||||
series = get_object_or_404(Series, pk=pk)
|
||||
|
||||
thumbnail, _ = series.get_thumbnail(recreate=True)
|
||||
thumbnail, _ = series.get_thumbnail(recreate=True, fail_loudly=fail_loudly)
|
||||
|
||||
return HttpResponse(thumbnail)
|
||||
|
||||
@login_required
|
||||
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||||
def series_viewer(request, pk, finding_pk=None):
|
||||
series = get_object_or_404(Series, pk=pk)
|
||||
context = {
|
||||
"series": series,
|
||||
}
|
||||
|
||||
return render(
|
||||
request,
|
||||
"atlas/series_viewer_test.html",
|
||||
context,
|
||||
)
|
||||
|
||||
@login_required
|
||||
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||||
@@ -225,16 +260,21 @@ def series_detail(request, pk, finding_pk=None):
|
||||
else:
|
||||
series_finding_form = SeriesFindingForm(series_id=series.id)
|
||||
|
||||
# logging.debug(atlas.subspecialty.first().name.all())
|
||||
return render(
|
||||
request,
|
||||
"atlas/series.html",
|
||||
{
|
||||
context = {
|
||||
"series": series,
|
||||
"series_finding_form": series_finding_form,
|
||||
"editing_finding": editing_finding,
|
||||
"can_edit": can_edit,
|
||||
},
|
||||
}
|
||||
|
||||
if can_edit:
|
||||
context["available_cases"] = Case.objects.filter(author__id=request.user.id) # Adjust the queryset as needed
|
||||
|
||||
# logging.debug(atlas.subspecialty.first().name.all())
|
||||
return render(
|
||||
request,
|
||||
"atlas/series.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@@ -472,7 +512,7 @@ def add_case_to_collection(request, collection_id):
|
||||
return HttpResponse("Case already in collection")
|
||||
print(case)
|
||||
collection.add_case(case)
|
||||
return HttpResponse("Case added to collection (refresh to see)")
|
||||
return HttpResponse(f"Case added to collection {collection.name} (refresh to see)")
|
||||
|
||||
# if request.method == "POST":
|
||||
|
||||
@@ -875,7 +915,7 @@ class QuestionSchemaCreate(LoginRequiredMixin, CreateView):
|
||||
form_class = QuestionSchemaForm
|
||||
|
||||
|
||||
class QuestionSchemaUpdate(LoginRequiredMixin, UpdateView):
|
||||
class QuestionSchemaUpdate(AuthorOrCheckerRequiredMixin, UpdateView):
|
||||
model = QuestionSchema
|
||||
form_class = QuestionSchemaForm
|
||||
|
||||
@@ -884,7 +924,6 @@ class QuestionSchemaDelete(AuthorOrCheckerRequiredMixin, DeleteView):
|
||||
model = QuestionSchema
|
||||
template_name = "confirm_delete.html"
|
||||
|
||||
|
||||
class FindingUpdate(RevisionMixin, LoginRequiredMixin, UpdateView):
|
||||
model = Finding
|
||||
form_class = FindingForm
|
||||
@@ -953,6 +992,9 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
|
||||
|
||||
form.instance.author.add(self.request.user.id)
|
||||
|
||||
# Check Cimar Case
|
||||
# CimarAPI().get_study_by_uuid(self.object.cimar_uuid)
|
||||
|
||||
context = self.get_context_data(form=form)
|
||||
series_formset = context["series_formset"]
|
||||
casedifferential_formset = context["casedifferential_formset"]
|
||||
@@ -1119,7 +1161,7 @@ def add_collection_to_case_form(request, case_id):
|
||||
|
||||
|
||||
class AtlasClone(AtlasCreateBase, AuthorOrCheckerRequiredMixin, CreateView):
|
||||
"""Clones a existing atlas"""
|
||||
"""Clones a existing atlas case"""
|
||||
|
||||
# fields = '__all__'
|
||||
# #fields = [ 'condition' ]
|
||||
@@ -1140,7 +1182,16 @@ class AtlasClone(AtlasCreateBase, AuthorOrCheckerRequiredMixin, CreateView):
|
||||
else:
|
||||
pass
|
||||
|
||||
initial_data = model_to_dict(old_object, exclude=["id"])
|
||||
initial_data = model_to_dict(old_object, exclude=["id", "previous_case", "history", "discussion", "report"])
|
||||
return initial_data
|
||||
|
||||
|
||||
class AtlasCloneNext(AtlasClone):
|
||||
"""Extends AtlasClone to set the previous_case field to the case being cloned"""
|
||||
|
||||
def get_initial(self):
|
||||
initial_data = super().get_initial()
|
||||
initial_data["previous_case"] = self.kwargs["pk"]
|
||||
return initial_data
|
||||
|
||||
|
||||
@@ -1182,7 +1233,7 @@ def get_examination_id(request):
|
||||
class CaseView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
||||
model = Case
|
||||
table_class = CaseTable
|
||||
template_name = "atlas/view.html"
|
||||
template_name = "atlas/case_view.html"
|
||||
|
||||
filterset_class = CaseFilter
|
||||
|
||||
@@ -1190,7 +1241,7 @@ class CaseView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
||||
class SeriesView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
||||
model = Series
|
||||
table_class = SeriesTable
|
||||
template_name = "atlas/view.html"
|
||||
template_name = "atlas/series_view.html"
|
||||
|
||||
filterset_class = SeriesFilter
|
||||
|
||||
@@ -1206,7 +1257,7 @@ class ConditionView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
||||
class QuestionSchemaView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
||||
model = QuestionSchema
|
||||
table_class = QuestionSchemaTable
|
||||
template_name = "atlas/view.html"
|
||||
template_name = "atlas/schema_table_view.html"
|
||||
|
||||
filterset_class = QuestionSchemaFilter
|
||||
|
||||
@@ -1399,9 +1450,11 @@ def create_series_findings(request):
|
||||
structure_ids = json.loads(request.POST.get("structures"))
|
||||
condition_ids = json.loads(request.POST.get("conditions"))
|
||||
description = request.POST.get("description")
|
||||
annotation_json = request.POST.get("annotation_json")
|
||||
viewport_json = request.POST.get("viewport_json")
|
||||
current_image_id_index = request.POST.get("current_image_id_index")
|
||||
#annotation_json = request.POST.get("annotation_json")
|
||||
#viewport_json = request.POST.get("viewport_json")
|
||||
#current_image_id_index = request.POST.get("current_image_id_index")
|
||||
annotation_json_3d = request.POST.get("annotation_json_3d")
|
||||
viewer_state_3d = request.POST.get("viewer_state_3d")
|
||||
|
||||
series = Series.objects.get(pk=series_id)
|
||||
findings = Finding.objects.filter(pk__in=findings_ids)
|
||||
@@ -1412,20 +1465,22 @@ def create_series_findings(request):
|
||||
sf = get_object_or_404(SeriesFinding, pk=series_finding_id)
|
||||
sf.series = series
|
||||
sf.description = description
|
||||
sf.annotation_json = annotation_json
|
||||
sf.viewport_json = viewport_json
|
||||
#sf.annotation_json = annotation_json
|
||||
#sf.viewport_json = viewport_json
|
||||
else:
|
||||
sf = SeriesFinding.objects.create(
|
||||
series=series,
|
||||
description=description,
|
||||
annotation_json=annotation_json,
|
||||
viewport_json=viewport_json,
|
||||
#annotation_json=annotation_json,
|
||||
#viewport_json=viewport_json,
|
||||
)
|
||||
|
||||
sf.findings.set(findings)
|
||||
sf.structures.set(structures)
|
||||
sf.conditions.set(conditions)
|
||||
sf.current_image_id_index = current_image_id_index
|
||||
#sf.current_image_id_index = current_image_id_index
|
||||
sf.annotation_json_3d = annotation_json_3d
|
||||
sf.viewer_state_3d = viewer_state_3d
|
||||
sf.save()
|
||||
return JsonResponse({"success": True})
|
||||
|
||||
@@ -1656,7 +1711,9 @@ def collection_history_user(request, exam_id: int, user_pk: int):
|
||||
print(user)
|
||||
print(casedetails)
|
||||
|
||||
user_answers = [(casedetail, casedetail.get_user_answers(user)) for casedetail in casedetails]
|
||||
user_answers = [
|
||||
(casedetail, casedetail.get_user_answers(user)) for casedetail in casedetails
|
||||
]
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -1682,7 +1739,11 @@ def collection_take_start(request, pk):
|
||||
"""
|
||||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||||
|
||||
valid_user = (collection.check_logged_in_user(request),)
|
||||
valid_user = collection.check_logged_in_user(request)
|
||||
|
||||
# for now we only allow registered users....
|
||||
if not valid_user:
|
||||
raise PermissionDenied
|
||||
|
||||
cid_exam = None
|
||||
if collection.collection_type == "REV" or valid_user:
|
||||
@@ -1727,6 +1788,7 @@ def collection_case_priors(request, exam_id, case_id):
|
||||
return HttpResponse(
|
||||
"You need to enter text to describe the relationship between the cases"
|
||||
)
|
||||
p.prior_visibility = request.POST["prior_visibility"]
|
||||
|
||||
p.save()
|
||||
|
||||
@@ -1741,7 +1803,7 @@ def collection_case_priors(request, exam_id, case_id):
|
||||
|
||||
added_relation_map = {}
|
||||
for prior in added_priors:
|
||||
added_relation_map[prior.prior_case] = prior.relation_text
|
||||
added_relation_map[prior.prior_case] = (prior.relation_text, prior.prior_visibility)
|
||||
|
||||
case = case_detail.case
|
||||
|
||||
@@ -1750,9 +1812,9 @@ def collection_case_priors(request, exam_id, case_id):
|
||||
available_priors = []
|
||||
for case in available_cases:
|
||||
if case in added_relation_map:
|
||||
available_priors.append((case, True, added_relation_map[case]))
|
||||
available_priors.append((case, True, added_relation_map[case][0], added_relation_map[case][1]))
|
||||
else:
|
||||
available_priors.append((case, False, ""))
|
||||
available_priors.append((case, False, "", "AL"))
|
||||
|
||||
form = PriorCaseForm()
|
||||
|
||||
@@ -1769,6 +1831,7 @@ def collection_case_priors(request, exam_id, case_id):
|
||||
{
|
||||
"case_detail": case_detail,
|
||||
"form": form,
|
||||
#"model_form": model_form,
|
||||
# "example_form": example_form,
|
||||
"collection": collection,
|
||||
"case": case_detail.case,
|
||||
@@ -2263,17 +2326,6 @@ def collection_case_view_take(
|
||||
|
||||
series_list = case.series.all().prefetch_related("images", "examination", "plane")
|
||||
|
||||
prior_cases = case_detail.caseprior_set.all()
|
||||
|
||||
series_to_load = []
|
||||
|
||||
for series in series_list:
|
||||
series_to_load.append((series, False, ""))
|
||||
|
||||
for prior in prior_cases:
|
||||
for series in prior.prior_case.get_series():
|
||||
series_to_load.append((series, True, prior.relation_text))
|
||||
|
||||
previous = case_number > 0
|
||||
next = case_number < (case_count - 1)
|
||||
|
||||
@@ -2309,6 +2361,25 @@ def collection_case_view_take(
|
||||
|
||||
resources = case.caseresource_set.filter(pre_review=True)
|
||||
|
||||
prior_cases = case_detail.caseprior_set.all()
|
||||
|
||||
series_to_load = []
|
||||
|
||||
for series in series_list:
|
||||
series_to_load.append((series, False, ""))
|
||||
|
||||
for prior in prior_cases:
|
||||
match prior.prior_visibility:
|
||||
case "AL":
|
||||
for series in prior.prior_case.get_series():
|
||||
series_to_load.append((series, True, prior.relation_text))
|
||||
case "RE":
|
||||
if question_completed:
|
||||
for series in prior.prior_case.get_series():
|
||||
series_to_load.append((series, True, prior.relation_text))
|
||||
case _:
|
||||
continue
|
||||
|
||||
return render(
|
||||
request,
|
||||
"atlas/collection_case_view_take.html",
|
||||
@@ -2377,6 +2448,7 @@ def collection_case_view(request, pk, case_number):
|
||||
"previous": previous,
|
||||
"next": next,
|
||||
"answer": answer,
|
||||
"cimar_sid": request.user.userprofile.cimar_sid,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2397,11 +2469,16 @@ def use_dates_as_descriptions(request, pk):
|
||||
|
||||
return HttpResponse("Done")
|
||||
|
||||
def collection_case_dicom_json_review(request, exam_id, case_id):
|
||||
return collection_case_dicom_json(request, exam_id, case_id, review=True)
|
||||
|
||||
def collection_case_dicom_json(request, exam_id, case_id):
|
||||
def collection_case_dicom_json(request, exam_id, case_id, review=False):
|
||||
case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id)
|
||||
|
||||
priors = case_detail.caseprior_set.all()
|
||||
if review:
|
||||
priors = case_detail.caseprior_set.exclude(prior_visibility="NO")
|
||||
else:
|
||||
priors = case_detail.caseprior_set.exclude(prior_visibility__in=["NO", "RE"])
|
||||
|
||||
return JsonResponse(case_detail.case.get_case_dicom_json(priors=priors))
|
||||
|
||||
@@ -2902,6 +2979,45 @@ def collection_question_schemas(request, exam_id: int):
|
||||
)
|
||||
|
||||
|
||||
def collection_reset_answers_user_list(request, exam_id: int):
|
||||
if request.htmx:
|
||||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||||
cid_users = collection.cid_users.all()
|
||||
|
||||
if cid_users:
|
||||
return render(
|
||||
request,
|
||||
"atlas/collection_reset_answers_user_list.html",
|
||||
{"collection": collection, "cid_users": cid_users},
|
||||
)
|
||||
else:
|
||||
return HttpResponse("No user answers.")
|
||||
|
||||
else:
|
||||
raise Http404("Invalid request")
|
||||
|
||||
|
||||
def collection_reset_answers_user(request, exam_id: int, user_id: int):
|
||||
if request.htmx:
|
||||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||||
|
||||
# Select all case details (answers are linked to these)
|
||||
case_details = collection.casedetail_set.all().prefetch_related()
|
||||
|
||||
# Delete all answers
|
||||
for case in case_details:
|
||||
user_answers = case.userreportanswer_set.filter(user_id=user_id)
|
||||
user_answers.delete()
|
||||
|
||||
collection.cid_users.filter(user_user_id=user_id).delete()
|
||||
collection.exam_user_status.filter(cid_user_exam__user_user_id=user_id).delete()
|
||||
|
||||
return HttpResponse(f"User [{user_id}] answers deleted")
|
||||
|
||||
else:
|
||||
raise Http404("Invalid request")
|
||||
|
||||
|
||||
def collection_reset_answers(request, exam_id: int):
|
||||
if request.htmx:
|
||||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||||
@@ -2926,3 +3042,143 @@ def collection_reset_answers(request, exam_id: int):
|
||||
|
||||
else:
|
||||
raise Http404("Invalid request")
|
||||
|
||||
|
||||
def push_case_to_cimar(request, case_id):
|
||||
push_case_to_cimar_task.delay(case_id=case_id)
|
||||
|
||||
return HttpResponse("Push started")
|
||||
case = get_object_or_404(Case, pk=case_id)
|
||||
|
||||
api = CimarAPI()
|
||||
api.login(username=CIMAR_USERNAME, password=CIMAR_PASSWORD)
|
||||
|
||||
# We use the same study_uid for all images in a case
|
||||
study_uid = generate_uid()
|
||||
|
||||
for series in case.get_series():
|
||||
for image in series.images.all():
|
||||
data = api.upload_dicom(image.image.path, study_uid=study_uid)
|
||||
|
||||
cimar_uuid = api.get_study_by_study_uid(data["study_uid"])["uuid"]
|
||||
|
||||
case.cimar_uuid = cimar_uuid
|
||||
|
||||
case.save()
|
||||
|
||||
cimar_case, created = CimarCase.objects.get_or_create(uuid=cimar_uuid)
|
||||
|
||||
cimar_case.refresh_study()
|
||||
|
||||
return HttpResponse("Success")
|
||||
|
||||
|
||||
@require_POST
|
||||
def remove_case_from_collection(request, case_pk, collection_pk):
|
||||
case = get_object_or_404(Case, pk=case_pk)
|
||||
collection = get_object_or_404(CaseCollection, pk=collection_pk)
|
||||
|
||||
# Remove the case from the collection
|
||||
collection.cases.remove(case)
|
||||
|
||||
return HttpResponse(f"Case removed from collection: {collection.name}")
|
||||
|
||||
|
||||
def linked_cases_overview(request, case_id):
|
||||
"""
|
||||
View to display an overview of linked cases via the previous_case field.
|
||||
"""
|
||||
# Get the current case
|
||||
case = get_object_or_404(Case, pk=case_id)
|
||||
|
||||
# Traverse backward to get all previous cases
|
||||
previous_cases = []
|
||||
try:
|
||||
current_case = case.previous_case
|
||||
while current_case:
|
||||
previous_cases.insert(0, current_case) # Insert at the beginning to maintain order
|
||||
current_case = current_case.previous_case
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
# Traverse forward to get all next cases
|
||||
next_cases = []
|
||||
try:
|
||||
current_case = case.next_case
|
||||
while current_case:
|
||||
next_cases.append(current_case)
|
||||
current_case = current_case.next_case
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
# Render the template with the linked cases
|
||||
return render(
|
||||
request,
|
||||
"atlas/linked_cases_overview.html",
|
||||
{
|
||||
"current_case": case,
|
||||
"previous_cases": previous_cases,
|
||||
"next_cases": next_cases,
|
||||
},
|
||||
)
|
||||
|
||||
@login_required
|
||||
def add_series_to_case(request, series_pk):
|
||||
if request.method == "POST":
|
||||
case_id = request.POST.get("case_id")
|
||||
case = get_object_or_404(Case, pk=case_id)
|
||||
if not case.check_user_can_edit(request.user):
|
||||
raise PermissionDenied("You do not have permission to edit this case.")
|
||||
|
||||
series = get_object_or_404(Series, pk=series_pk)
|
||||
|
||||
# Associate the series with the case
|
||||
series.case.add(case)
|
||||
|
||||
return HttpResponse(f"Series {series_pk} added to case {case.title} ({case_id}).")
|
||||
return HttpResponse("Invalid request.")
|
||||
|
||||
@login_required
|
||||
def remove_series_from_case(request, series_pk, case_pk):
|
||||
if request.method == "POST":
|
||||
case = get_object_or_404(Case, pk=case_pk)
|
||||
if not case.check_user_can_edit(request.user):
|
||||
raise PermissionDenied("You do not have permission to edit this case.")
|
||||
|
||||
series = get_object_or_404(Series, pk=series_pk)
|
||||
|
||||
# Associate the series with the case
|
||||
series.case.remove(case)
|
||||
|
||||
return HttpResponse(f"Removed")
|
||||
return HttpResponse("Invalid request.")
|
||||
|
||||
@login_required
|
||||
def series_finding_details(request, finding_id):
|
||||
finding = get_object_or_404(SeriesFinding, pk=finding_id)
|
||||
return render(request, 'atlas/series_finding_details.html', {'finding': finding})
|
||||
|
||||
@login_required
|
||||
def series_finding_related(request, series_id):
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
findings = series.get_related_findings()
|
||||
return render(request, 'atlas/series_finding_related.html', {'series': series, 'findings': findings})
|
||||
|
||||
def seriesfinding_migration_status(request):
|
||||
"""
|
||||
View to list all SeriesFinding objects and their migration status.
|
||||
Shows whether annotation_json_3d and viewer_state_3d are populated.
|
||||
"""
|
||||
findings = SeriesFinding.objects.all().select_related()
|
||||
findings_status = []
|
||||
for finding in findings:
|
||||
findings_status.append({
|
||||
"id": finding.id,
|
||||
"str": str(finding),
|
||||
"annotation_json_3d": bool(finding.annotation_json_3d),
|
||||
"viewer_state_3d": bool(finding.viewer_state_3d),
|
||||
"series": finding.series
|
||||
})
|
||||
return render(request, "atlas/seriesfinding_migration_status.html", {
|
||||
"findings_status": findings_status
|
||||
})
|
||||
@@ -1,6 +1,11 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7.4.2-alpine
|
||||
expose:
|
||||
- 6379
|
||||
volumes:
|
||||
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf
|
||||
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
|
||||
web:
|
||||
build: ./rad
|
||||
command: python manage.py runserver 0.0.0.0:8000
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.contrib import admin
|
||||
# Register your models here.
|
||||
from .models import (
|
||||
CidUserGroup,
|
||||
CimarCase,
|
||||
Examination,
|
||||
# Sign,
|
||||
Modality,
|
||||
@@ -55,6 +56,7 @@ admin.site.register(UserGrades)
|
||||
admin.site.register(Supervisor)
|
||||
admin.site.register(Modality)
|
||||
admin.site.register(ExamCollection)
|
||||
admin.site.register(CimarCase)
|
||||
|
||||
|
||||
class ExamListFilter(admin.SimpleListFilter):
|
||||
|
||||
+6
-6
@@ -43,14 +43,14 @@ class UserUserFilter(django_filters.FilterSet):
|
||||
model = User
|
||||
# fields = ("cid", "active", "internal_candidate", "group")
|
||||
fields = {
|
||||
"first_name": ["contains"],
|
||||
"last_name": ["contains"],
|
||||
"first_name": ["icontains"],
|
||||
"last_name": ["icontains"],
|
||||
#"active": ["exact"],
|
||||
"user_groups": ["exact"],
|
||||
"user_groups__name": ["contains"],
|
||||
"user_groups__name": ["icontains"],
|
||||
#"userprofile__grade": ["exact"],
|
||||
#"userprofile__supervisor": ["exact"],
|
||||
"email": ["contains"],
|
||||
"email": ["icontains"],
|
||||
"is_active": ["exact"],
|
||||
"groups": ["exact"],
|
||||
}
|
||||
@@ -93,8 +93,8 @@ class SupervisorFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
model = Supervisor
|
||||
fields = {
|
||||
"name": ["contains"],
|
||||
"email": ["contains"],
|
||||
"name": ["icontains"],
|
||||
"email": ["icontains"],
|
||||
"active": ["exact"],
|
||||
"site": ["exact"],
|
||||
}
|
||||
|
||||
+58
-5
@@ -66,7 +66,8 @@ import datetime
|
||||
from django.forms.utils import from_current_timezone, to_current_timezone
|
||||
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Submit
|
||||
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div
|
||||
from crispy_forms.bootstrap import Accordion, AccordionGroup
|
||||
|
||||
from dal import autocomplete
|
||||
|
||||
@@ -119,7 +120,35 @@ class ExamFormMixin:
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_action = "submit"
|
||||
|
||||
self.helper.add_input(Submit("submit", "Submit"))
|
||||
self.helper.layout = Layout(
|
||||
Fieldset(
|
||||
"Exam Details",
|
||||
"name",
|
||||
"time_limit",
|
||||
"open_access",
|
||||
"authors_only",
|
||||
"exam_mode",
|
||||
"exam_open_access",
|
||||
"include_history",
|
||||
"active",
|
||||
"publish_results",
|
||||
"archive",
|
||||
Accordion(
|
||||
AccordionGroup("Date restrictions",
|
||||
"restrict_to_dates",
|
||||
"start_date",
|
||||
"end_date",
|
||||
active=False,
|
||||
)
|
||||
),
|
||||
),
|
||||
Div(
|
||||
Submit("submit", "Submit"),
|
||||
css_class="form-group",
|
||||
),
|
||||
)
|
||||
|
||||
# self.helper.add_input(Submit("submit", "Submit"))
|
||||
|
||||
# if user.is_superuser or user.groups.filter(name="cid_user_manager").exists():
|
||||
# cid_user_group_queryset = CidUserGroup.objects.filter(archive=False)
|
||||
@@ -164,6 +193,7 @@ class ExamFormMixin:
|
||||
"open_access",
|
||||
"authors_only",
|
||||
"exam_mode",
|
||||
"exam_open_access",
|
||||
"include_history",
|
||||
"restrict_to_dates",
|
||||
"start_date",
|
||||
@@ -171,6 +201,7 @@ class ExamFormMixin:
|
||||
"active",
|
||||
"publish_results",
|
||||
"archive",
|
||||
"results_supervisor_visible"
|
||||
# "cid_user_groups",
|
||||
# "user_user_groups",
|
||||
# "author",
|
||||
@@ -233,7 +264,7 @@ class ExamGroupsFormMixin(ModelForm):
|
||||
cid_user_group_queryset = CidUserGroup.objects.filter(
|
||||
archive=False, open_access=True
|
||||
)
|
||||
user_user_group_queryset = UserUserGroup.objects.none(
|
||||
user_user_group_queryset = UserUserGroup.objects.filter(
|
||||
archive=False, open_access=True
|
||||
)
|
||||
# cid_user_group_queryset = CidUserGroup.objects.none()
|
||||
@@ -675,6 +706,7 @@ class UserUserForm(Form):
|
||||
last_name = CharField(max_length=255, required=True)
|
||||
grade = ModelChoiceField(UserGrades.objects.all(), required=False)
|
||||
|
||||
|
||||
class TraineeForm(Form):
|
||||
username = EmailField(
|
||||
required=True,
|
||||
@@ -684,10 +716,12 @@ class TraineeForm(Form):
|
||||
last_name = CharField(max_length=255, required=True)
|
||||
grade = ModelChoiceField(UserGrades.objects.all(), required=False)
|
||||
supervisor = ModelChoiceField(
|
||||
Supervisor.objects.all().order_by("name"), required=False, widget=autocomplete.ModelSelect2(url='generic:supervisor-autocomplete')
|
||||
Supervisor.objects.all().order_by("name"),
|
||||
required=False,
|
||||
widget=autocomplete.ModelSelect2(url="generic:supervisor-autocomplete"),
|
||||
) # Needs to be a user/object ref
|
||||
|
||||
#class Meta:
|
||||
# class Meta:
|
||||
# widgets = {
|
||||
# "supervisor" : autocomplete.ModelSelect2(url='generic:supervisor-autocomplete')
|
||||
# }
|
||||
@@ -737,6 +771,11 @@ class ExamCollectionForm(ModelForm):
|
||||
widget=FilteredSelectMultiple(verbose_name=name, is_stacked=False),
|
||||
)
|
||||
|
||||
self.fields["author"] = ModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
widget=FilteredSelectMultiple(verbose_name="Authors", is_stacked=False), required=False,
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
# Get the unsaved Long instance
|
||||
instance = ModelForm.save(self, False)
|
||||
@@ -768,3 +807,17 @@ class ExamCollectionCloneForm(ModelForm):
|
||||
class Meta:
|
||||
model = ExamCollection
|
||||
fields = ("name", "date")
|
||||
|
||||
from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register
|
||||
|
||||
class UserAutocomplete(ModelAutocomplete):
|
||||
model = User
|
||||
search_attrs = ["first_name", "last_name", "username"]
|
||||
|
||||
class UsersAutocompleteForm(Form):
|
||||
users = ModelChoiceField(queryset=User.objects.all(), widget=AutocompleteWidget(
|
||||
ac_class=UserAutocomplete, options={"multiselect": True}
|
||||
|
||||
))
|
||||
|
||||
autocomplete_register(UserAutocomplete)
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-10-28 13:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('generic', '0018_alter_supervisor_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='examuserstatus',
|
||||
name='share_with_supervisor',
|
||||
field=models.BooleanField(default=False, help_text='If true the exam status and results will be available to a users supervisor'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.0.2 on 2024-10-28 13:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('generic', '0019_examuserstatus_share_with_supervisor'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='examuserstatus',
|
||||
name='share_with_supervisor',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ciduserexam',
|
||||
name='share_with_supervisor',
|
||||
field=models.BooleanField(default=False, help_text='If true the exam status and results will be available to a users supervisor'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-10 12:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('generic', '0020_remove_examuserstatus_share_with_supervisor_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='cimar_sid',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-10 13:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('generic', '0021_userprofile_cimar_sid'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CimarCase',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.CharField(max_length=255, unique=True)),
|
||||
('viewer_link', models.URLField(blank=True)),
|
||||
('study_details', models.JSONField(blank=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-17 10:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('generic', '0022_cimarcase'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cimarcase',
|
||||
name='valid_uuid',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cimarcase',
|
||||
name='study_details',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-17 11:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('generic', '0023_cimarcase_valid_uuid_alter_cimarcase_study_details'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cimarcase',
|
||||
name='series_data',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cimarcase',
|
||||
name='study_schema',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.1.4 on 2025-03-10 09:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('generic', '0024_cimarcase_series_data_cimarcase_study_schema'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CimarSeriesThumbnail',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('series_id', models.CharField(max_length=255)),
|
||||
('image', models.ImageField(upload_to='cimar_series_thumbnails')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -51,6 +51,10 @@ class AuthorMixin():
|
||||
return "None"
|
||||
return authors
|
||||
|
||||
def add_authors(self, users : List[User]):
|
||||
"""Add an author to the object"""
|
||||
self.author.add(*users)
|
||||
|
||||
def add_author(self, user: User):
|
||||
"""Add an author to the object"""
|
||||
self.author.add(user)
|
||||
|
||||
+329
-9
@@ -7,6 +7,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db import models
|
||||
from django.forms import ValidationError
|
||||
from django.http import Http404, HttpRequest
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from smtplib import SMTPException
|
||||
@@ -29,7 +30,8 @@ from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from generic.mixins import AuthorMixin, QuestionMixin
|
||||
from helpers.images import get_image_hash, pretty_print_dicom
|
||||
from rad.settings import REMOTE_URL
|
||||
from helpers.cimar import CimarAPI, STORAGE_API_URL
|
||||
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
|
||||
from django.utils.html import format_html
|
||||
|
||||
from easy_thumbnails.files import get_thumbnailer
|
||||
@@ -37,11 +39,44 @@ from easy_thumbnails.exceptions import InvalidImageFormatError
|
||||
|
||||
|
||||
import pydicom
|
||||
import pydicom.errors
|
||||
import dicognito.anonymizer
|
||||
from django.contrib.auth.models import User
|
||||
from django.forms.models import model_to_dict
|
||||
from django.forms.utils import from_current_timezone, to_current_timezone
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from pygments import highlight
|
||||
from pygments.lexers import JsonLexer
|
||||
from pygments.formatters import HtmlFormatter
|
||||
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.template.loader import render_to_string
|
||||
import requests
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
|
||||
def get_pretty_json(data):
|
||||
"""Function to display pretty version of our data"""
|
||||
if not data:
|
||||
return "No data"
|
||||
|
||||
# Convert the data to sorted, indented JSON
|
||||
response = json.dumps(data, sort_keys=True, indent=2)
|
||||
|
||||
# Get the Pygments formatter
|
||||
formatter = HtmlFormatter(style='colorful')
|
||||
|
||||
# Highlight the data
|
||||
response = highlight(response, JsonLexer(), formatter)
|
||||
|
||||
# Get the stylesheet
|
||||
style = "<style>" + formatter.get_style_defs() + "</style><br>"
|
||||
|
||||
# Safe the output
|
||||
return mark_safe(style + response)
|
||||
|
||||
def findMiddle(input_list):
|
||||
"""Returns the middle element of a list"""
|
||||
@@ -196,6 +231,29 @@ class QuestionBase(models.Model, AuthorMixin, QuestionMixin):
|
||||
|
||||
return False
|
||||
|
||||
def get_thumbnail(self, recreate=False, fail_loudly=False):
|
||||
images = self.images.all()
|
||||
|
||||
if len(images) < 1:
|
||||
return "No images", 0
|
||||
|
||||
img = findMiddle(images).image
|
||||
try:
|
||||
thumbnailer = get_thumbnailer(img)
|
||||
if recreate:
|
||||
thumbnailer.delete_thumbnails()
|
||||
thumbnail = thumbnailer["exam-list"]
|
||||
|
||||
except InvalidImageFormatError as e:
|
||||
if fail_loudly:
|
||||
raise e
|
||||
return format_html(
|
||||
'<img title="{}" src="/static/not-found-image.jpg" />', img
|
||||
), len(images)
|
||||
return format_html('<span style="border: 1px solid;"><img src="/media/{}" /></span>', thumbnail), len(images)
|
||||
|
||||
|
||||
|
||||
|
||||
class SeriesImageBase(models.Model):
|
||||
"""
|
||||
@@ -238,6 +296,24 @@ class SeriesImageBase(models.Model):
|
||||
ordering = ["position"]
|
||||
abstract = True
|
||||
|
||||
def get_file_size(self):
|
||||
try:
|
||||
return self.image.size
|
||||
|
||||
# We catch this for when the image does not exist
|
||||
except FileNotFoundError:
|
||||
return 0
|
||||
|
||||
def get_dicom_data(self):
|
||||
try:
|
||||
with pydicom.dcmread(self.image) as d:
|
||||
return d
|
||||
except pydicom.errors.InvalidDicomError:
|
||||
return {}
|
||||
|
||||
def get_series(self):
|
||||
return self.series
|
||||
|
||||
def get_dicom_json(self):
|
||||
try:
|
||||
json = pydicom.read_file(self.image).to_json()
|
||||
@@ -336,6 +412,8 @@ class SeriesBase(models.Model):
|
||||
choices=SeriesModifiedChocies.choices,
|
||||
)
|
||||
|
||||
total_image_size = models.BigIntegerField(null=True, blank=True, help_text="Stores a (cached) total size of images within the series")
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -417,7 +495,7 @@ class SeriesBase(models.Model):
|
||||
else:
|
||||
return format_html('", "'.join(images))
|
||||
|
||||
def get_thumbnail(self, recreate=False):
|
||||
def get_thumbnail(self, recreate=False, fail_loudly=False):
|
||||
images = self.images.filter(removed=False)
|
||||
|
||||
if len(images) < 1:
|
||||
@@ -429,7 +507,9 @@ class SeriesBase(models.Model):
|
||||
if recreate:
|
||||
thumbnailer.delete_thumbnails()
|
||||
thumbnail = thumbnailer["exam-list"]
|
||||
except InvalidImageFormatError:
|
||||
except InvalidImageFormatError as e:
|
||||
if fail_loudly:
|
||||
raise e
|
||||
return format_html(
|
||||
'<img title="{}" src="/static/not-found-image.jpg" />', img
|
||||
), len(images)
|
||||
@@ -488,13 +568,18 @@ class SeriesBase(models.Model):
|
||||
n = n + 1
|
||||
|
||||
def order_by_dicom(self, field="SliceLocation"):
|
||||
print(f"Ordering by dicom field {field}")
|
||||
images = self.images.filter(removed=False)
|
||||
|
||||
|
||||
files = []
|
||||
|
||||
for i in images:
|
||||
files.append((i, pydicom.dcmread(i.image.path)))
|
||||
|
||||
print("images to order")
|
||||
print(files)
|
||||
|
||||
# print("file count: {}".format(len(files)))
|
||||
|
||||
# skip files with no SliceLocation (eg scout views)
|
||||
@@ -522,10 +607,18 @@ class SeriesBase(models.Model):
|
||||
for f in slices:
|
||||
i = map[f[field].value]
|
||||
i.position = n
|
||||
print(f"Setting position {n} for {i}")
|
||||
i.save()
|
||||
n = n + 1
|
||||
|
||||
def get_total_image_size(self):
|
||||
def get_total_image_size(self, refresh=False):
|
||||
"""Returns the total size of all images in the series.
|
||||
|
||||
If refresh is set to True the size will be recalculated and saved to the database
|
||||
(in the field 'total_image_size')."""
|
||||
if not refresh and self.total_image_size:
|
||||
return self.total_image_size
|
||||
|
||||
images = self.images.filter(removed=False)
|
||||
|
||||
size = 0
|
||||
@@ -533,6 +626,9 @@ class SeriesBase(models.Model):
|
||||
for i in images:
|
||||
size += i.get_file_size()
|
||||
|
||||
self.total_image_size = size
|
||||
self.save()
|
||||
|
||||
return size
|
||||
|
||||
def anonymise_images(self):
|
||||
@@ -602,6 +698,13 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
|
||||
default=False,
|
||||
)
|
||||
|
||||
results_supervisor_visible = models.BooleanField(
|
||||
help_text="If true the results will be visible to supervisors without candidate approval.",
|
||||
default=False,
|
||||
)
|
||||
|
||||
exam_open_access = models.BooleanField(default=False, help_text="Set to true if you want any registered user to be able to take the exam.")
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -696,7 +799,8 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
|
||||
user_id: int | None = None,
|
||||
allow_authors: bool = True,
|
||||
):
|
||||
print(f"Check cid user: {cid=}, {passcode=}, exam_id={self.pk}")
|
||||
"""Checks if a user (cid or otherwise) is allowed to access (and take) the exam"""
|
||||
print(f"Check cid user: {cid=}, {passcode=}, exam_id={self.pk}, {user=}, {user_id=}, {allow_authors=}")
|
||||
if user is not None and user.is_superuser:
|
||||
return True
|
||||
|
||||
@@ -705,15 +809,22 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
|
||||
if self.author.filter(pk=user_id).exists():
|
||||
return True
|
||||
|
||||
if not self.valid_cid_users.exists() and not self.valid_user_users.exists():
|
||||
return False
|
||||
|
||||
if cid is None and user_id is None:
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
if user is not None:
|
||||
user_id = user.id
|
||||
else:
|
||||
return False
|
||||
|
||||
if self.exam_open_access and user_id is not None:
|
||||
return True
|
||||
|
||||
if not self.valid_cid_users.exists() and not self.valid_user_users.exists():
|
||||
return False
|
||||
|
||||
|
||||
|
||||
# Start by checking if the logged in user can access
|
||||
if user_id is not None:
|
||||
if self.valid_user_users.filter(pk=user_id).exists():
|
||||
@@ -845,6 +956,12 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
|
||||
|
||||
return cloned_model
|
||||
|
||||
def add_markers(self, users):
|
||||
self.markers.add(*users)
|
||||
|
||||
def add_marker(self, user):
|
||||
self.markers.add(user)
|
||||
|
||||
|
||||
class ExamBase(ExamOrCollectionGenericBase):
|
||||
exam_mode = models.BooleanField(
|
||||
@@ -1456,6 +1573,8 @@ class CidUserExam(models.Model):
|
||||
# TODO switch to json field?
|
||||
results_emailed_status = models.CharField(max_length=255, blank=True)
|
||||
|
||||
share_with_supervisor = models.BooleanField(default=False, help_text="If true the exam status and results will be available to a users supervisor")
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.cid_user is None:
|
||||
try:
|
||||
@@ -1483,6 +1602,12 @@ class CidUserExam(models.Model):
|
||||
else:
|
||||
return "CID" + str(self.cid_user.cid)
|
||||
|
||||
def complete_exam(self):
|
||||
# TODO add examuserstatus?
|
||||
self.end_time = timezone.now()
|
||||
self.completed = True
|
||||
self.save()
|
||||
|
||||
|
||||
CID_GROUP_EXAMS = (
|
||||
("SBAs", "sba_cid_user_groups"),
|
||||
@@ -1620,6 +1745,8 @@ class UserProfile(models.Model):
|
||||
|
||||
username = property(getusername)
|
||||
|
||||
cimar_sid = models.CharField(max_length=100, blank=True)
|
||||
|
||||
def get_exams(self):
|
||||
available_exams = defaultdict(list)
|
||||
|
||||
@@ -1694,7 +1821,200 @@ class ExamCollection(models.Model, AuthorMixin):
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} [{self.date}]"
|
||||
if self.date is not None and self.date != "":
|
||||
return f"{self.name} [{self.date}]"
|
||||
else:
|
||||
return f"{self.name}"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("generic:examcollection_detail", kwargs={"pk": self.pk})
|
||||
|
||||
def get_exams(self):
|
||||
return {
|
||||
"anatomy": self.anatomy_exams.filter(archive=False),
|
||||
"longs": self.longs_exams.filter(archive=False),
|
||||
"physics": self.physics_exams.filter(archive=False),
|
||||
"rapids": self.rapids_exams.filter(archive=False),
|
||||
"sbas": self.sbas_exams.filter(archive=False),
|
||||
}
|
||||
|
||||
def add_author_to_exams(self, users, exam_types: None | list[str] =None):
|
||||
|
||||
for exam_type, exams in self.get_exams().items():
|
||||
if exam_types is None:
|
||||
pass
|
||||
elif exam_type not in exam_types:
|
||||
continue
|
||||
else:
|
||||
raise ValueError(f"Invalid exam type {exam_type}")
|
||||
|
||||
for exam in exams:
|
||||
exam.add_authors(users)
|
||||
#return True
|
||||
|
||||
def add_marker_to_exams(self, users, exam_types: None | list[str] =None):
|
||||
|
||||
for exam_type, exams in self.get_exams().items():
|
||||
if exam_types is None:
|
||||
pass
|
||||
elif exam_type not in exam_types:
|
||||
continue
|
||||
else:
|
||||
pass
|
||||
|
||||
for exam in exams:
|
||||
exam.add_markers(users)
|
||||
#return True
|
||||
|
||||
|
||||
#class Presentation(models.Model, AuthorMixin):
|
||||
#
|
||||
# name = models.CharField(max_length=255, help_text="Name of the presentation")
|
||||
#
|
||||
# date_created = models.DateField(help_text="Date the presentation was created", auto_now_add=True)
|
||||
#
|
||||
# date_updated = models.DateField(help_text="Date the presentation was last updated", auto_now=True)
|
||||
#
|
||||
# markdown = models.TextField(help_text="Markdown text for the presentation")
|
||||
#
|
||||
# output_html = models.TextField(help_text="HTML output of the presentation")
|
||||
#
|
||||
# author = models.ManyToManyField(
|
||||
# settings.AUTH_USER_MODEL,
|
||||
# blank=True,
|
||||
# help_text="Author/Manager(s) of the exam collection",
|
||||
# )
|
||||
|
||||
class CimarCase(models.Model):
|
||||
uuid = models.CharField(max_length=255, unique=True)
|
||||
|
||||
viewer_link = models.URLField(blank=True)
|
||||
|
||||
study_details = models.JSONField(blank=True, default=dict)
|
||||
|
||||
study_schema = models.JSONField(blank=True, default=dict)
|
||||
|
||||
series_data = models.JSONField(blank=True, default=dict)
|
||||
|
||||
valid_uuid = models.BooleanField(default=False)
|
||||
|
||||
def load_series_data(self, api=None):
|
||||
if api is None:
|
||||
api = CimarAPI()
|
||||
api.login(username=CIMAR_USERNAME, password=CIMAR_PASSWORD)
|
||||
|
||||
|
||||
|
||||
|
||||
def refresh_study(self):
|
||||
api = CimarAPI()
|
||||
api.login(username=CIMAR_USERNAME, password=CIMAR_PASSWORD)
|
||||
|
||||
self.study_details = api.get_study_by_uuid(self.uuid)
|
||||
self.study_schema = api.get_study_schema_by_uuid(self.uuid)
|
||||
|
||||
self.viewer_link = api.get_study_viewer_url_by_uuid(self.uuid)
|
||||
|
||||
series_data = []
|
||||
|
||||
for series in self.study_schema["series"]:
|
||||
# Cache the thumbnail
|
||||
#thumbnail_url = f"{self.get_series_image_thumbnail_link(series)}?{api.sid}"
|
||||
#response = requests.get(thumbnail_url)
|
||||
|
||||
#thumbnail = CimarSeriesThumbnail(series_id=series["id"])
|
||||
#thumbnail.image.save(f"{series['id']}.jpg", ContentFile(response.content))
|
||||
#thumbnail.save()
|
||||
|
||||
|
||||
series_data.append({
|
||||
"series_id" : series["id"],
|
||||
"series_description" : series["description"],
|
||||
"image_count": len(series["images"]),
|
||||
"thumbnail": self.get_series_image_thumbnail_link(series)
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
self.series_data = {}
|
||||
self.series_data["series"] = series_data
|
||||
|
||||
self.valid_uuid = True
|
||||
self.save()
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.uuid} ({self.valid_uuid})"
|
||||
|
||||
def get_user_viewer_link(self, user):
|
||||
return self.viewer_link + f"?sid={user.userprofile.cimar_sid}"
|
||||
|
||||
|
||||
def get_pretty_study_details(self):
|
||||
"""Function to display pretty version of our data"""
|
||||
return get_pretty_json(self.study_details)
|
||||
|
||||
def get_pretty_study_schema(self):
|
||||
"""Function to display pretty version of our data"""
|
||||
return get_pretty_json(self.study_schema)
|
||||
|
||||
def get_pretty_study_series(self):
|
||||
"""Function to display pretty version of our data"""
|
||||
return get_pretty_json(self.series_data)
|
||||
|
||||
#def get_series_details_block(self, cimar_sid=None):
|
||||
# if "series" in self.series_data:
|
||||
# return render_to_string("cimar_series.html", {"series_data": self.series_data["series"], cimar_sid: cimar_sid})
|
||||
#
|
||||
# return "No series data available"
|
||||
|
||||
# return render_to_string("cimar_series.html", {"series_data": self.series_data["series"]})
|
||||
|
||||
|
||||
|
||||
def get_series_image_thumbnail_link(self,series):
|
||||
|
||||
middle_point = len(series["images"]) // 2
|
||||
|
||||
image = series["images"][middle_point]
|
||||
|
||||
return STORAGE_API_URL + f"/study/{self.study_details["storage_namespace"]}/{self.study_details["study_uid"]}/image/{image["id"]}/version/{image["version"]}/frame/0/thumbnail"
|
||||
|
||||
|
||||
class CimarSeriesThumbnail(models.Model):
|
||||
series_id = models.CharField(max_length=255)
|
||||
image = models.ImageField(upload_to="cimar_series_thumbnails")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.series_id}"
|
||||
|
||||
|
||||
class FindingBase(models.Model):
|
||||
description = models.TextField(
|
||||
null=True, blank=True, help_text="Findings on the question"
|
||||
)
|
||||
annotation_json = models.TextField(null=True, blank=True)
|
||||
viewport_json = models.TextField(null=True, blank=True)
|
||||
# This has been migrated to install the image id (not index)
|
||||
current_image_id_index = models.TextField(null=True, blank=True)
|
||||
|
||||
annotation_json_3d = models.JSONField(default=dict)
|
||||
viewer_state_3d = models.JSONField(default=dict)
|
||||
|
||||
# TODO add tags
|
||||
# tags = models.ManyToManyField(Tag, blank=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def __str__(self):
|
||||
return self.description
|
||||
|
||||
def annotation_as_string(self) -> str:
|
||||
return json.dumps(self.annotation_json)
|
||||
|
||||
def get_pretty_annotation_json(self):
|
||||
return get_pretty_json(json.loads(self.annotation_json))
|
||||
|
||||
def get_pretty_viewport_json(self):
|
||||
return get_pretty_json(json.loads(self.viewport_json))
|
||||
@@ -315,6 +315,7 @@ class SupervisorTable(tables.Table):
|
||||
|
||||
sequence = ("name", "email")
|
||||
exclude = ("id",)
|
||||
attrs = {"id": "supervisor-table"}
|
||||
|
||||
def render_edit(self, value, record):
|
||||
url = f"{reverse("generic:supervisor_edit", args=[record.pk])}?redirect={self.request.path}"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="position-relative">
|
||||
{% include "django/forms/widgets/textarea.html" %}
|
||||
<button type="button" class="btn btn-danger btn-sm clear-input-button" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); display: none;">×</button>
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="position-relative">
|
||||
{% include "django/forms/widgets/input.html" %}
|
||||
<button type="button" class="btn btn-danger btn-sm clear-input-button" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); display: none;">×</button>
|
||||
</div>
|
||||
@@ -21,6 +21,7 @@
|
||||
<a href="{% url 'generic:manage_cid_exams' %}">Cids (exams)</a> /
|
||||
<a href="{% url 'generic:cid_group_view' %}">Cid Groups</a> /
|
||||
<a href="{% url 'generic:user_group_view' %}">User Groups</a> /
|
||||
<a href="{% url 'trainees' %}">Trainees</a> /
|
||||
<a href="{% url 'accounts_list' %}">Manage Users</a> /
|
||||
<a href="{% url 'generic:supervisor' %}">Manage Supervisors</a> /
|
||||
<a href="{% url 'generic:examcollection_list' %}">Collections</a>
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}
|
||||
Supervisor
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
{% block navigation %}
|
||||
Supervisor:
|
||||
{% if request.user.is_authenticated %}
|
||||
<a href='{% url "generic:supervisor_overview" supervisor.pk %}'>Overview</a> /
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -38,6 +38,24 @@
|
||||
They can be created (or added) <a href="{% url 'generic:manage_cids' %}">here</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="border border-secondary">
|
||||
<details>
|
||||
<summary >Add users by email</summary>
|
||||
<div >
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<textarea name="emails" placeholder="Email addresses (one per line)"></textarea>
|
||||
<button type="submit"
|
||||
hx-post="{% url 'generic:user_group_add_by_email_user' group.pk %}"
|
||||
hx-target="#add-users-by-email-results"
|
||||
>Add</button>
|
||||
</form>
|
||||
<div id="add-users-by-email-results"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<h3>Exams</h3>
|
||||
The group is currently associated with the following exams. This allows the users within the group to be easily added to the exam. It will not automatically give members of the group access to the exam.
|
||||
{% if group_type == "cid" %}
|
||||
@@ -52,30 +70,46 @@
|
||||
<div>
|
||||
{% for exam in value %}
|
||||
<div class="d-flex">
|
||||
<a href="{{exam.get_absolute_url}}" class="px-2">{{exam}}</a>
|
||||
<a href="{{exam.get_absolute_url}}" class="px-2">{{exam}}</a>
|
||||
{% if group_type == "cid" %}
|
||||
<a href="{{exam.get_cid_edit_url}}" class="edit-link" px-2>Edit candidates</a>
|
||||
{% comment %} <button>Add group candidates</button>
|
||||
<button>Remove group candidates</button> {% endcomment %}
|
||||
<form>
|
||||
<input type="hidden" name="exam_id" value="{{exam.pk}}">
|
||||
<input type="hidden" name="exam_type" value="{{exam.app_name}}">
|
||||
<button
|
||||
class="btn btn-sm px-2"
|
||||
hx-post="{% url 'generic:cid_group_add_candidates_to_exams' group.pk %}"
|
||||
title="Add all group candidates to this exam"
|
||||
name="action"
|
||||
value="add"
|
||||
>Add group candidates</button>
|
||||
<button
|
||||
hx-post="{% url 'generic:cid_group_add_candidates_to_exams' group.pk %}"
|
||||
title="Remove all group candidates to this exam"
|
||||
class="btn btn-sm px-2"
|
||||
name="action"
|
||||
value="remove"
|
||||
>Remove group candidates</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="{{exam.get_user_edit_url}}" class="edit-link px-2">Edit candidates</a>
|
||||
<form>
|
||||
<input type="hidden" name="exam_id" value="{{exam.pk}}">
|
||||
<input type="hidden" name="exam_type" value="{{exam.app_name}}">
|
||||
<button
|
||||
class="btn btn-sm px-2"
|
||||
hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}"
|
||||
title="Add all group candidates to this exam"
|
||||
name="action"
|
||||
value="add"
|
||||
>Add group candidates</button>
|
||||
<button
|
||||
hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}"
|
||||
title="Remove all group candidates to this exam"
|
||||
class="btn btn-sm px-2"
|
||||
name="action"
|
||||
value="remove"
|
||||
>Remove group candidates</button>
|
||||
<button
|
||||
class="btn btn-sm px-2"
|
||||
hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}"
|
||||
title="Add all group candidates to this exam"
|
||||
name="action"
|
||||
value="add"
|
||||
>Add group candidates</button>
|
||||
<button
|
||||
hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}"
|
||||
title="Remove all group candidates to this exam"
|
||||
class="btn btn-sm px-2"
|
||||
name="action"
|
||||
value="remove"
|
||||
>Remove group candidates</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -55,14 +55,17 @@
|
||||
<li class="user-user" data-user="{{user.username}}">
|
||||
|
||||
<div>
|
||||
<i class="bi bi-clock-history"
|
||||
title="View user exam history"
|
||||
hx-get="{% url exam.app_name|add:':exam_user_status_user' exam.id user.id %}"
|
||||
hx-target=".modal-content"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#history-modal"
|
||||
_="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML "
|
||||
></i>
|
||||
<i class="bi bi-clock-history"
|
||||
title="View user exam history"
|
||||
hx-get="{% url exam.app_name|add:':exam_user_status_user' exam.id user.id %}"
|
||||
hx-target=".modal-content"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#history-modal"
|
||||
_="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML "
|
||||
></i>
|
||||
{% if request.user.is_superuser %}
|
||||
<a href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
|
||||
{% endif %}
|
||||
{{user.username}}</div>
|
||||
<div>Email: {{user.email}}</div>
|
||||
</li>
|
||||
@@ -98,7 +101,7 @@
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
<br/>
|
||||
<br/>
|
||||
{% endif %}
|
||||
<span
|
||||
title="View all user exam history"
|
||||
|
||||
@@ -23,12 +23,22 @@
|
||||
</ol>
|
||||
|
||||
{% comment %} Hide the toggle button if there are no users (to add or remove) {% endcomment %}
|
||||
{% if current_cid_users or available_cid_users %}
|
||||
<p><button class="toggle-all-btn">Toggle all</button></p>
|
||||
<p>{% if current_cid_users or available_cid_users %}
|
||||
<button class="toggle-all-btn">Toggle all</button>
|
||||
{% endif %}
|
||||
<button class="add-all-btn">Add all</button>
|
||||
<button class="remove-all-btn">Remove all</button>
|
||||
|
||||
</p>
|
||||
|
||||
<script>
|
||||
$(document).ready(() => {
|
||||
$(".remove-all-btn").click((el) => {
|
||||
$(".current .toggle-btn").click();
|
||||
});
|
||||
$(".add-all-btn").click((el) => {
|
||||
$(":not(.current) .toggle-btn").click();
|
||||
});
|
||||
$(".toggle-all-btn").click((el) => {
|
||||
$(".toggle-btn").click();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{% if can_edit %}
|
||||
<p>
|
||||
<button id='button-edit-order' title='click and drag questions to change order' data-posturl="{% url exam.get_app_name|add:':exam_json_edit' pk=exam.pk %}">Edit question order / Delete questions</button>
|
||||
</p>
|
||||
<details><summary>Additional details</summary>
|
||||
<div>
|
||||
JSON creation time: {{exam.json_creation_time}} ({{exam.json_creation_time|date:"c"}}),
|
||||
JSON creation id: {{exam.exam_json_id}}
|
||||
</div>
|
||||
<a href="{% url exam.get_app_name|add:':exam_json' pk=exam.pk %}">JSON</a>
|
||||
<a href="{% url exam.get_app_name|add:':exam_json_unbased' pk=exam.pk %}">JSON (unbased)</a>
|
||||
<a href="{% url exam.get_app_name|add:':exam_json_recreate' pk=exam.pk %}">Refresh JSON cache</a>
|
||||
{% if request.user.is_superuser %}
|
||||
<button id='button-open-access' data-posturl="{% url exam.get_app_name|add:':exam_json_edit' pk=exam.pk %}">Make questions open access</button>
|
||||
<button id='button-closed-access' data-posturl="{% url exam.get_app_name|add:':exam_json_edit' pk=exam.pk %}">Make questions closed access</button>
|
||||
{% endif %}
|
||||
</details>
|
||||
{% endif %}
|
||||
@@ -3,6 +3,12 @@
|
||||
|
||||
{% include 'exam_notes.html' %}
|
||||
|
||||
|
||||
{% if exam.examcollection %}
|
||||
<div>This exam is part of the collection: <a href="{{ exam.examcollection.get_absolute_url }}">{{ exam.examcollection }}</a></div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if exam.recreate_json %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
Exam JSON may be out of date.
|
||||
@@ -13,19 +19,26 @@
|
||||
|
||||
|
||||
{% if exam.exam_mode %}
|
||||
{% if not exam.cid_user_groups.count and not exam.user_user_groups.count %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
This exam does not have any user / cid groups added.
|
||||
</div>
|
||||
{% if exam.exam_open_access %}
|
||||
{% else %}
|
||||
{% if not candidate_count.0 and not candidate_count.1 %}
|
||||
{% if not exam.cid_user_groups.count and not exam.user_user_groups.count %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
This exam does not have any candidates.
|
||||
This exam does not have any user / cid groups added.
|
||||
</div>
|
||||
{% if not candidate_count.0 and not candidate_count.1 %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
This exam does not have any candidates.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if exam.archive %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
This exam is archived. Results and submitted answers will not be available to candidates. They can still be viewed by supervisors.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
This exam has {{question_number}} questions.
|
||||
<span title="Time per question: {% widthratio exam.time_limit question_number 1 %} seconds">Time limit: {{exam.time_limit}} seconds [{{exam.get_time_limit}}]
|
||||
@@ -34,7 +47,12 @@ This exam has {{question_number}} questions.
|
||||
Exam mode: {{ exam.exam_mode }}<br />
|
||||
|
||||
{% if exam.exam_mode %}
|
||||
Cid candidates: <a href="{{exam.get_cid_edit_url}}">{{candidate_count.0}}</a>, User candidates: <a href="{{exam.get_user_edit_url}}">{{candidate_count.1}}</a><br />
|
||||
{% if exam.exam_open_access %}
|
||||
This exam is available to all registered users.<br />
|
||||
{% else %}
|
||||
Cid candidates: <a href="{{exam.get_cid_edit_url}}">{{candidate_count.0}}</a>, User candidates: <a href="{{exam.get_user_edit_url}}">{{candidate_count.1}}</a><br />
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
Markers:
|
||||
@@ -46,7 +64,7 @@ Markers:
|
||||
<br/>
|
||||
|
||||
|
||||
{% if can_edit %}
|
||||
{% if can_edit and exam.exam_mode %}
|
||||
<details class="answer-management">
|
||||
<summary>Answer management</summary>
|
||||
|
||||
@@ -94,7 +112,7 @@ Open access: {{ exam.open_access }}<br />
|
||||
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 == "rapids" 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>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -106,13 +124,15 @@ Open access: {{ exam.open_access }}<br />
|
||||
Author(s): {% for author in exam.author.all %}
|
||||
{{ author }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}<br/>
|
||||
<span title="If true the results from this exam will be visible to supervisors automatically. If false the trainee will have to manually approve access.">Supervisor visible: {{exam.results_supervisor_visible}}</span><br/>
|
||||
|
||||
<details>
|
||||
<summary>Extra</summary>
|
||||
<button title="This will clear all user answers and attempts"
|
||||
hx-post="{% url exam.get_app_name|add:':order_questions' exam.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
>Order questions</button>
|
||||
Occasionally the order of questions can get out of sync. If you are exeriancing issues with the order of questions you can reset the order here.
|
||||
<button title="This will clear all user answers and attempts"
|
||||
hx-post="{% url exam.get_app_name|add:':order_questions' exam.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
>Order questions</button>
|
||||
</details>
|
||||
|
||||
{% block css %}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<h3>Stats</h3>
|
||||
Candidates: {{cids|length}}<br />
|
||||
Questions: <span id="question-number">{{question_number}}</span><br />
|
||||
Available marks: {{max_score}}<br />
|
||||
<span title="Max score">Available marks: {{max_score}}</span><br />
|
||||
Mean: {{mean}}, Median {{median}}, Mode {{mode}}<br />
|
||||
Top score: {{exam.stats_max}}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
{% if user.userprofile.grade %}
|
||||
[{{user.userprofile.grade}}]
|
||||
{% endif %}
|
||||
{% if request.user.is_superuser %}
|
||||
<a href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
|
||||
{% endif %}
|
||||
<button class="toggle-btn" data-pk="{{user.pk}}" data-user="{{user.username}}">Toggle</button>
|
||||
</li>
|
||||
|
||||
@@ -24,6 +27,9 @@
|
||||
{% if user.userprofile.grade %}
|
||||
[{{user.userprofile.grade}}]
|
||||
{% endif %}
|
||||
{% if request.user.is_superuser %}
|
||||
<a href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
|
||||
{% endif %}
|
||||
<button class="toggle-btn" data-pk="{{user.pk}}" data-user="{{user.username}}">Toggle</button>
|
||||
</li>
|
||||
|
||||
@@ -31,12 +37,23 @@
|
||||
</ol>
|
||||
|
||||
{% comment %} Hide the toggle button if there are no users (to add or remove) {% endcomment %}
|
||||
<p>
|
||||
{% if current_user_users or available_user_users %}
|
||||
<p><button class="toggle-all-btn">Toggle all</button></p>
|
||||
<button class="toggle-all-btn">Toggle all</button>
|
||||
{% endif %}
|
||||
<button class="add-all-btn">Add all</button>
|
||||
<button class="remove-all-btn">Remove all</button>
|
||||
|
||||
</p>
|
||||
|
||||
<script>
|
||||
$(document).ready(() => {
|
||||
$(".remove-all-btn").click((el) => {
|
||||
$(".current .toggle-btn").click();
|
||||
});
|
||||
$(".add-all-btn").click((el) => {
|
||||
$("li:not(.current) .toggle-btn").click();
|
||||
});
|
||||
$(".toggle-all-btn").click((el) => {
|
||||
$(".toggle-btn").click();
|
||||
});
|
||||
|
||||
@@ -2,56 +2,91 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>{{ object.name }} [{{object.date}}] </h1>
|
||||
<h1>
|
||||
{{ object.name }}
|
||||
{% if object.date %}
|
||||
[{{object.date}}]
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
<a href="{% url 'generic:examcollection_edit' object.pk %}">Edit</a>
|
||||
<a href="{% url 'generic:examcollection_clone' object.pk %}">Clone</a>
|
||||
<a href="{% url 'generic:examcollection_delete' object.pk %}">Delete</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="{% url 'generic:examcollection_edit' object.pk %}">Edit</a>
|
||||
<a href="{% url 'generic:examcollection_clone' object.pk %}">Clone</a>
|
||||
<a href="{% url 'generic:examcollection_delete' object.pk %}">Delete</a>
|
||||
</p>
|
||||
|
||||
This collection contains the following exams
|
||||
<div>Authors: {{ object.get_authors }}</div>
|
||||
|
||||
<p>This collection contains the following exams<p>
|
||||
|
||||
|
||||
<h2>Anatomy Exams:</h2>
|
||||
<ul>
|
||||
{% for exam in object.anatomy_exams.all %}
|
||||
<li><a href="{% url 'anatomy:exam_overview' exam.pk %}">{{ exam }}</a> - <a href="{% url 'anatomy:exam_update' exam.pk %}">(Edit)</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h2>Longs Exams:</h2>
|
||||
<ul>
|
||||
{% for exam in object.longs_exams.all %}
|
||||
<li><a href="{% url 'longs:exam_overview' exam.pk %}">{{ exam }}</a> - <a href="{% url 'longs:exam_update' exam.pk %}">(Edit)</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h2>Rapids Exams:</h2>
|
||||
<ul>
|
||||
{% for exam in object.rapids_exams.all %}
|
||||
<li><a href="{% url 'rapids:exam_overview' exam.pk %}">{{ exam }}</a> - <a href="{% url 'rapids:exam_update' exam.pk %}">(Edit)</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h2>Physics Exams:</h2>
|
||||
<ul>
|
||||
{% for exam in object.physics_exams.all %}
|
||||
<li><a href="{% url 'physics:exam_overview' exam.pk %}">{{ exam }}</a> - <a href="{% url 'physics:exam_update' exam.pk %}">(Edit)</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h2>SBAs Exams:</h2>
|
||||
<ul>
|
||||
{% for exam in object.sbas_exams.all %}
|
||||
<li><a href="{% url 'sbas:exam_overview' exam.pk %}">{{ exam }}</a> - <a href="{% url 'sbas:exam_update' exam.pk %}">(Edit)</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
|
||||
{% if object.anatomy_exams.all %}
|
||||
<h2>Anatomy Exams:</h2>
|
||||
{% with exams=object.anatomy_exams.all app_name="anatomy" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
|
||||
<a href="{% url 'anatomy: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 'anatomy' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if object.longs_exams.all %}
|
||||
<h2>Longs Exams:</h2>
|
||||
{% with exams=object.longs_exams.all app_name="longs" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
<a href="{% url 'longs: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 'longs' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if object.rapids_exams.all %}
|
||||
<h2>Rapids Exams:</h2>
|
||||
{% with exams=object.rapids_exams.all app_name="rapids" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
<a href="{% url 'rapids: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 'rapids' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if object.physics_exams.all %}
|
||||
<h2>Physics Exams:</h2>
|
||||
{% with exams=object.physics_exams.all app_name="physics" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
<a href="{% url 'physics:exam_list_collection' object.pk %}">View exam list</a>
|
||||
{% endif %}
|
||||
|
||||
{% if object.sbas_exams.all %}
|
||||
<h2>SBAs Exams:</h2>
|
||||
{% with exams=object.sbas_exams.all app_name="sbas" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
<a href="{% url 'sbas:exam_list_collection' object.pk %}">View exam list</a>
|
||||
{% endif %}
|
||||
|
||||
<br/>
|
||||
<div id="add-user">
|
||||
<button hx-get="{% url 'generic:exam_collection_add_author' object.pk %}" hx-target="#add-user" hx-swap="innerHTML">Add author</button>
|
||||
</div>
|
||||
|
||||
{% include 'exam_overview_js.html' %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
{% endblock %}
|
||||
{% block css %}
|
||||
<style>
|
||||
.add-marker-button {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -20,7 +20,7 @@
|
||||
<h2>Edit Exam Collection {{object.name}}</h2>
|
||||
<form action="" method="post" enctype="multipart/form-data" id="examcollection-form">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
{{ form|crispy }}
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
{% endblock %}
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{{form.media}}
|
||||
<form action="" method="post" enctype="multipart/form-data" id="marker-form" class="inline-form">
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form|crispy }}
|
||||
{% comment %} <input type="submit" class="submit-button" value="Submit" name="submit"> {% endcomment %}
|
||||
|
||||
<button hx-post="{% url 'generic:exam_collection_add_marker' collection_id exam_type %}" hx-target=".add-marker" hx-swap="innerHTML" class="btn btn-primary">Add marker to exams</button>
|
||||
|
||||
</form>
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
{% endfor %}
|
||||
|
||||
<div><a href="{% url 'generic:supervisor_overview' pk=object.pk %}">Supervisor overview</a></div>
|
||||
|
||||
|
||||
<div>
|
||||
<a href="{% url 'generic:supervisor_edit' pk=object.pk %}"> Edit</a>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends 'generic/base_supervisor.html' %}
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2>Supervisor: {{supervisor.name}}</h2>
|
||||
<details class="help-text">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p>As a supervisor you have the ability to view results on the platform for your associated trainees.</p>
|
||||
<p>Some exams / collection results will be automatically available for you to review, others may require the trainee to allow access</p>
|
||||
<p>If your trainees are incorrect please contact ross.kruger@nhs.net</p>
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<h4>Trainees</h4>
|
||||
You have the following trainee(s):
|
||||
|
||||
<ul id="trainee-list">
|
||||
{% for trainee in trainees %}
|
||||
<li>
|
||||
<i class="bi bi-file-person"></i>
|
||||
<a href='{% url "generic:supervisor_trainee" supervisor.pk trainee.pk %}'>{{trainee.first_name}} {{trainee.last_name}}, {{trainee.email}}</a>
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
#trainee-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock css %}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
This form allows you to create an account if you are a supervisor. To do so please enter your email address.
|
||||
|
||||
<form>
|
||||
<input type="text" name="email" id="email" value="" required>
|
||||
<button
|
||||
hx-post="{% url 'generic:supervisor_request_account' %}"
|
||||
hx-target="#result"
|
||||
>Request Account</button>
|
||||
</form>
|
||||
<div id="result"></div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends 'generic/base_supervisor.html' %}
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2>Trainee: {{trainee.first_name}} {{trainee.last_name}}</h2>
|
||||
email: {{trainee.email}}
|
||||
|
||||
|
||||
<h3>Results</h3>
|
||||
<div id="exam-results">
|
||||
{% if all_exams %}
|
||||
The following exam results for the trainee are available.
|
||||
{% for exam_type, exams in all_exams %}
|
||||
{% if exams %}
|
||||
<h4>{{exam_type|title}}</h4>
|
||||
<ul class='{{exam_type|lower}}'>
|
||||
{% for exam in exams %}
|
||||
<li class="exam" data-exam-id="{{exam.pk}}">
|
||||
|
||||
<a href= "{% url exam_type|add:':exam_scores_user_supervisor' pk=exam.pk user_id=trainee.pk %}" title="Click to view results">
|
||||
|
||||
{{exam}}</a> {% if exam.active %}[Active]{% endif %} {% if exam.publish_results %}[Results Published]{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
No results are available for this trainee.
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user