Compare commits
192
Commits
b1da429036
...
3cb84fb7c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cb84fb7c4 | ||
|
|
c2185f73a6 | ||
|
|
8beb6890a7 | ||
|
|
2d185aa95f | ||
|
|
6fe2bae8a8 | ||
|
|
bd1bb54f9f | ||
|
|
b73b43d155 | ||
|
|
e46f843284 | ||
|
|
cff5a46e4c | ||
|
|
a304c0b4dc | ||
|
|
88584f4c37 | ||
|
|
21f85c3cac | ||
|
|
cf48453e6f | ||
|
|
2ebb0b4473 | ||
|
|
74d300ab16 | ||
|
|
d8c83ae750 | ||
|
|
e34d84d5f8 | ||
|
|
62683a7876 | ||
|
|
9e08c69684 | ||
|
|
9adc0f430f | ||
|
|
a42f57ca05 | ||
|
|
aeaf0fcb2e | ||
|
|
ca4c8d31b9 | ||
|
|
07d766cb30 | ||
|
|
fe8c5b208b | ||
|
|
eebe98ddda | ||
|
|
1acc9808b5 | ||
|
|
0915ef555c | ||
|
|
44ebe1a517 | ||
|
|
10b83b2ad6 | ||
|
|
9fe8c843ff | ||
|
|
fbdc7c87b4 | ||
|
|
7075bba7a2 | ||
|
|
35e4b81e34 | ||
|
|
7ef68c035b | ||
|
|
19a9a27747 | ||
|
|
bcfcd8da82 | ||
|
|
df462efacd | ||
|
|
00fa68247d | ||
|
|
b81fbbfea6 | ||
|
|
8fc74abd6e | ||
|
|
cef06e4682 | ||
|
|
71fefe6c4f | ||
|
|
61767c62b0 | ||
|
|
83e2f03eb5 | ||
|
|
27f817f321 | ||
|
|
8636610fea | ||
|
|
503e4d88aa | ||
|
|
2696d192c9 | ||
|
|
64cebf50a0 | ||
|
|
db43bcf557 | ||
|
|
30cd998d84 | ||
|
|
9bf8034179 | ||
|
|
e46d682635 | ||
|
|
9026ee5390 | ||
|
|
bdde50effc | ||
|
|
f0b676fd23 | ||
|
|
966de4a139 | ||
|
|
d50fbaafd6 | ||
|
|
1698612d6a | ||
|
|
62f7663461 | ||
|
|
741b8683c9 | ||
|
|
4367b88057 | ||
|
|
f37b3312e5 | ||
|
|
b014722d2e | ||
|
|
89e9ded09a | ||
|
|
21d83052cf | ||
|
|
490d1956af | ||
|
|
fa613ae116 | ||
|
|
6c57e35f06 | ||
|
|
f1642c7f01 | ||
|
|
8c6361f9b2 | ||
|
|
1e38078c0a | ||
|
|
ab9b16e7f9 | ||
|
|
ca2a3e065f | ||
|
|
4be9e24737 | ||
|
|
3b6128aa84 | ||
|
|
dff4705d72 | ||
|
|
e8f862c7bd | ||
|
|
b1fc763938 | ||
|
|
b3cbd48470 | ||
|
|
bf519a2807 | ||
|
|
98756baa37 | ||
|
|
f13d1ba7e2 | ||
|
|
2fef934323 | ||
|
|
a62023a5d8 | ||
|
|
0cb38f5c31 | ||
|
|
78e4ed871e | ||
|
|
3ab40304a3 | ||
|
|
444ff0e983 | ||
|
|
f2b4e05ba5 | ||
|
|
99eb6037d7 | ||
|
|
9d0cf66441 | ||
|
|
8299efabf9 | ||
|
|
f4d2ab85f9 | ||
|
|
3b40417532 | ||
|
|
80cccba6f3 | ||
|
|
e2d63d6b16 | ||
|
|
a816af5c7e | ||
|
|
77473a35d0 | ||
|
|
f63f4c6fa5 | ||
|
|
b6aa63d494 | ||
|
|
85d69dfcbd | ||
|
|
1af4d464e4 | ||
|
|
6c5537f335 | ||
|
|
fc7cac3512 | ||
|
|
0631b7d714 | ||
|
|
87b7395917 | ||
|
|
805425b816 | ||
|
|
c31c7ba37b | ||
|
|
c5a2deb26f | ||
|
|
085a8bf4e3 | ||
|
|
18fa9b4a94 | ||
|
|
0bed834b47 | ||
|
|
0ea63a30a5 | ||
|
|
1963d0a3ef | ||
|
|
fdc7c6150b | ||
|
|
df632319f5 | ||
|
|
3402dde43c | ||
|
|
5c3ba02674 | ||
|
|
ba8fa60adf | ||
|
|
1f109de25b | ||
|
|
ca05c67b87 | ||
|
|
eea8580c59 | ||
|
|
d1a7ccaae1 | ||
|
|
9435463707 | ||
|
|
081f8cbcee | ||
|
|
7c9192986a | ||
|
|
4721122e4d | ||
|
|
4629b08e2f | ||
|
|
baa1ffe890 | ||
|
|
8793f346ba | ||
|
|
5ea910cd63 | ||
|
|
3a52855eb7 | ||
|
|
0bc2830ea2 | ||
|
|
4e7838c17f | ||
|
|
b8656b1e91 | ||
|
|
f5d429498a | ||
|
|
56c165b3b1 | ||
|
|
f11f07504c | ||
|
|
ec7c354945 | ||
|
|
bd1a4fb529 | ||
|
|
7c6af8df89 | ||
|
|
fb7074475a | ||
|
|
017710042b | ||
|
|
e9d82d5922 | ||
|
|
8639f9e6ed | ||
|
|
b3761ccc96 | ||
|
|
684156fafe | ||
|
|
af4f742d3e | ||
|
|
5bf0095038 | ||
|
|
8d0e666a96 | ||
|
|
6c8694822b | ||
|
|
8aade14772 | ||
|
|
0da9a1802a | ||
|
|
6bac0e4cc7 | ||
|
|
ee29eaf307 | ||
|
|
c3d7546d79 | ||
|
|
2f55efb6a5 | ||
|
|
bf09390160 | ||
|
|
862755179a | ||
|
|
29bea3d63a | ||
|
|
92fca51b79 | ||
|
|
95237b5a29 | ||
|
|
e18b2cd399 | ||
|
|
e7a639b57a | ||
|
|
8fa91f4323 | ||
|
|
78efa6ed68 | ||
|
|
8f5792b879 | ||
|
|
3c10d89478 | ||
|
|
4d32f1d826 | ||
|
|
c6d27a9425 | ||
|
|
a6e25a8f0c | ||
|
|
9cc1e9b5d1 | ||
|
|
58446be3b4 | ||
|
|
ccb0b8abcc | ||
|
|
b2a2289625 | ||
|
|
3f68a98abd | ||
|
|
b60de142e7 | ||
|
|
580753c0d1 | ||
|
|
bbd28c3e4b | ||
|
|
093d8cf097 | ||
|
|
d8b8733fd3 | ||
|
|
dd2702d495 | ||
|
|
dbdba072d0 | ||
|
|
268a8addda | ||
|
|
b9cdbb98ef | ||
|
|
c3f7913402 | ||
|
|
20d05772e6 | ||
|
|
6be11d3fdf | ||
|
|
e21321adc2 | ||
|
|
9ff395325f |
@@ -8,4 +8,6 @@ venv
|
||||
.venv/
|
||||
.env
|
||||
|
||||
*.log
|
||||
|
||||
temp/
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# pull official base image
|
||||
FROM python:3.11.3-slim
|
||||
FROM python:3.12.1-slim
|
||||
|
||||
# set work directory
|
||||
WORKDIR /usr/src/rad
|
||||
|
||||
+15
-7
@@ -3,7 +3,7 @@ from typing import List
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ninja import ModelSchema, Router
|
||||
|
||||
from generic.models import CidUser, ExamUserStatus
|
||||
from generic.models import CidUser, CidUserExam, ExamUserStatus
|
||||
from .models import AnatomyQuestion as Question, Exam
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
@@ -29,6 +29,13 @@ class UserUserSchema(ModelSchema):
|
||||
model = get_user_model()
|
||||
model_fields = ["id", "email"]
|
||||
|
||||
class CidUserExamSchema(ModelSchema):
|
||||
cid_user : CidUserSchema | None
|
||||
user_user : UserUserSchema | None
|
||||
class Config:
|
||||
model = CidUserExam
|
||||
model_fields = ["id"]
|
||||
|
||||
class ExamStatusSchema(ModelSchema):
|
||||
class Config:
|
||||
model = ExamUserStatus
|
||||
@@ -36,8 +43,9 @@ class ExamStatusSchema(ModelSchema):
|
||||
|
||||
class ExamUserStatusSchema(ModelSchema):
|
||||
|
||||
cid_user : CidUserSchema | None
|
||||
user_user : UserUserSchema | None
|
||||
#cid_user : CidUserSchema | None
|
||||
#user_user : UserUserSchema | None
|
||||
cid_user_exam : CidUserExamSchema | None
|
||||
class Config:
|
||||
model = ExamUserStatus
|
||||
model_fields = ["id", "datetime", "status", "extra"]
|
||||
@@ -90,8 +98,8 @@ def get_exam_user_status_all(request, exam_id: int, latest:bool=True):
|
||||
|
||||
results = exam.exam_user_status.all()
|
||||
if latest:
|
||||
cid_users = exam.exam_user_status.filter(cid_user__isnull=False).order_by("cid_user", "-datetime").distinct("cid_user")
|
||||
user_users = exam.exam_user_status.filter(user_user__isnull=False).order_by("user_user", "-datetime").distinct("user_user")
|
||||
cid_users = exam.exam_user_status.filter(cid_user_exam__cid_user__isnull=False).order_by("cid_user_exam", "-datetime")#.distinct("cid_user")
|
||||
user_users = exam.exam_user_status.filter(cid_user_exam__user_user__isnull=False).order_by("cid_user_exam", "-datetime")#.distinct("user_user")
|
||||
|
||||
results = list(cid_users)
|
||||
results.extend(list(user_users))
|
||||
@@ -107,7 +115,7 @@ def get_exam_user_status_cid_user(request, exam_id: int, cid: int):
|
||||
|
||||
print(exam_id, cid)
|
||||
|
||||
return exam.exam_user_status.filter(cid_user__cid=cid)
|
||||
return exam.exam_user_status.filter(cid_user_exam__cid_user__cid=cid)
|
||||
|
||||
@router.get('/exam/{exam_id}/user_status/{user_id}/user', response=List[ExamStatusSchema])
|
||||
def get_exam_user_status_user_user(request, exam_id: int, user_id: int):
|
||||
@@ -115,4 +123,4 @@ def get_exam_user_status_user_user(request, exam_id: int, user_id: int):
|
||||
if not exam.check_user_can_edit(request.user):
|
||||
raise HttpError(HTTPStatus.UNAUTHORIZED,"Exam author required")
|
||||
|
||||
return exam.exam_user_status.filter(user_user=user_id)
|
||||
return exam.exam_user_status.filter(cid_user_exam__user_user=user_id)
|
||||
+11
-1
@@ -10,7 +10,7 @@ from django.forms import inlineformset_factory
|
||||
from django.contrib.postgres.forms import SimpleArrayField, SplitArrayField , SplitArrayWidget
|
||||
|
||||
|
||||
from generic.forms import ExamAuthorFormMixin, ExamFormMixin
|
||||
from generic.forms import ExamAuthorFormMixin, ExamFormMixin, ExamGroupsFormMixin, ExamMarkerFormMixin
|
||||
|
||||
from .models import (
|
||||
Answer,
|
||||
@@ -232,3 +232,13 @@ class ExamForm(ExamFormMixin, ModelForm):
|
||||
class ExamAuthorForm(ExamAuthorFormMixin):
|
||||
class Meta(ExamAuthorFormMixin.Meta):
|
||||
model = Exam
|
||||
|
||||
class ExamMarkerForm(ExamMarkerFormMixin):
|
||||
class Meta(ExamMarkerFormMixin.Meta):
|
||||
model = Exam
|
||||
|
||||
|
||||
|
||||
class ExamGroupsForm(ExamGroupsFormMixin):
|
||||
class Meta(ExamGroupsFormMixin.Meta):
|
||||
model = Exam
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 4.1.4 on 2024-02-17 22:10
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0013_rename_exam_collection_exam_examcollection'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ExamQuestionDetail',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('exam', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='anatomy.exam')),
|
||||
('anatomyquestion', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='anatomy.anatomyquestion')),
|
||||
('sort_order', models.IntegerField(default=1000)),
|
||||
],
|
||||
options={
|
||||
'ordering': ('sort_order',),
|
||||
},
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"""INSERT INTO anatomy_examquestiondetail
|
||||
SELECT * FROM anatomy_exam_exam_questions;"""
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.1.4 on 2024-02-17 22:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0014_examquestiondetail'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='exam',
|
||||
name='exam_questions',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='exam_questions',
|
||||
field=models.ManyToManyField(related_name='exams', through='anatomy.ExamQuestionDetail', to='anatomy.anatomyquestion'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-02-27 14:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0015_alter_exam_exam_questions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='exam',
|
||||
name='authors_only',
|
||||
field=models.BooleanField(default=False, help_text='If true only exam/collection authors will be able to view.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.0.2 on 2024-05-13 10:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0016_alter_exam_authors_only'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='end_date',
|
||||
field=models.DateTimeField(help_text='Date (and time) the exam/collection ends', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='restrict_to_dates',
|
||||
field=models.BooleanField(default=False, help_text='If the exam/collection should only be taken between the start and end date times'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='start_date',
|
||||
field=models.DateTimeField(help_text='Date (and time) the exam/collection starts', null=True),
|
||||
),
|
||||
]
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.0.2 on 2024-05-18 07:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0017_exam_end_date_exam_restrict_to_dates_exam_start_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='exam',
|
||||
name='end_date',
|
||||
field=models.DateTimeField(blank=True, help_text='Date (and time) the exam/collection ends', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='exam',
|
||||
name='exam_results_emailed',
|
||||
field=models.DateTimeField(blank=True, default=None, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='exam',
|
||||
name='start_date',
|
||||
field=models.DateTimeField(blank=True, help_text='Date (and time) the exam/collection starts', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='exam',
|
||||
name='user_scores',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.0.2 on 2024-08-05 09:43
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0018_alter_exam_end_date_alter_exam_exam_results_emailed_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
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,20 @@
|
||||
# Generated by Django 5.0.2 on 2024-08-05 10:09
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0019_exam_markers'),
|
||||
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', null=True, related_name='anatomy_exam_markers', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
+26
-7
@@ -13,8 +13,6 @@ from django.urls import reverse
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from sortedm2m.fields import SortedManyToManyField
|
||||
|
||||
import string
|
||||
|
||||
from generic.models import CidUser, CidUserGroup, ExamCollection, ExamUserStatus, Examination, ExamBase, QuestionBase, QuestionNote, UserAnswerBase, UserUserGroup, Modality
|
||||
@@ -129,6 +127,9 @@ class AnatomyQuestion(QuestionBase):
|
||||
class Meta:
|
||||
permissions = ()
|
||||
|
||||
def get_app_name(self):
|
||||
return "anatomy"
|
||||
|
||||
def __str__(self):
|
||||
# Get first answer
|
||||
return "{}/{}: {} [{}, {}]".format(
|
||||
@@ -361,14 +362,20 @@ class Answer(models.Model):
|
||||
# if self.answer:
|
||||
# self.answer.strip()
|
||||
|
||||
class ExamQuestionDetail(models.Model):
|
||||
exam = models.ForeignKey("Exam", on_delete=models.CASCADE)
|
||||
anatomyquestion = models.ForeignKey(AnatomyQuestion, on_delete=models.CASCADE)
|
||||
|
||||
sort_order = models.IntegerField(default=1000)
|
||||
|
||||
class Meta:
|
||||
ordering = ("sort_order",)
|
||||
|
||||
@reversion.register
|
||||
class Exam(ExamBase):
|
||||
app_name = "anatomy"
|
||||
|
||||
exam_questions = SortedManyToManyField(
|
||||
AnatomyQuestion, related_name="exams", blank="true"
|
||||
)
|
||||
exam_questions = models.ManyToManyField("AnatomyQuestion", through=ExamQuestionDetail, related_name="exams")
|
||||
time_limit = models.IntegerField(
|
||||
help_text="Exam time limit (in seconds)", default=5400
|
||||
)
|
||||
@@ -380,6 +387,14 @@ class Exam(ExamBase):
|
||||
related_name="anatomy_exam_author",
|
||||
)
|
||||
|
||||
markers = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Authorised markers for the exam",
|
||||
related_name="anatomy_exam_markers",
|
||||
)
|
||||
|
||||
valid_cid_users = models.ManyToManyField(
|
||||
CidUser, blank=True, related_name="anatomy_exams"
|
||||
)
|
||||
@@ -402,12 +417,16 @@ class Exam(ExamBase):
|
||||
related_name="anatomy_user_user_groups"
|
||||
)
|
||||
|
||||
exam_user_status = GenericRelation(ExamUserStatus)
|
||||
exam_user_status = GenericRelation(ExamUserStatus, related_query_name="anatomy_exams")
|
||||
cid_user_exam = GenericRelation("generic.CidUserExam", related_query_name="anatomy_exams")
|
||||
|
||||
examcollection = models.ForeignKey(ExamCollection, blank=True, null=True, on_delete=models.SET_NULL, related_name="anatomy_exams")
|
||||
|
||||
def get_app_name(self):
|
||||
return "anatomy"
|
||||
|
||||
def get_exam_json(self, based=True):
|
||||
questions = self.exam_questions.all()
|
||||
questions = self.get_questions()
|
||||
|
||||
exam_questions = defaultdict(dict)
|
||||
|
||||
|
||||
@@ -12,6 +12,14 @@ window.control_pressed = false;
|
||||
|
||||
|
||||
$(document).ready(function () {
|
||||
document.body.addEventListener("htmx:responseError", function(e) {
|
||||
let error = e.detail.xhr.response;
|
||||
let el = document.getElementById("htmx-error");
|
||||
el.innerHTML = error;
|
||||
el.classList.remove("hidden");
|
||||
});
|
||||
|
||||
|
||||
$("table thead th input").click((e) => {
|
||||
let status = e.currentTarget.checked;
|
||||
$("table tbody input").prop("checked", status);
|
||||
@@ -159,7 +167,7 @@ $(document).ready(function () {
|
||||
|
||||
window.loadDicomViewerEvent = new Event('loadDicomViewer');
|
||||
window.addEventListener('loadDicomViewer', function (e) {
|
||||
//console.log("listen", e.detail)
|
||||
console.log("LoadDicomViewer event", e.detail)
|
||||
let images = [];
|
||||
//console.log($("#image_form_set img"));
|
||||
$(e.detail).each((n, el) => {
|
||||
@@ -171,6 +179,7 @@ window.addEventListener('loadDicomViewer', function (e) {
|
||||
|
||||
window.loadDicomViewerEvent = new Event('loadDicomViewerUrls');
|
||||
window.addEventListener('loadDicomViewerUrls', function (e) {
|
||||
console.log("LoadDicomViewer unls event", e.detail)
|
||||
|
||||
loadDicomViewer(e.detail.images, e.detail.annotations);
|
||||
}, false);
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
|
||||
|
||||
function addFile(f, feedback) {
|
||||
console.log("add file", f)
|
||||
let dT = new DataTransfer();
|
||||
dT.clearData();
|
||||
dT.items.add(f);
|
||||
@@ -107,7 +108,7 @@
|
||||
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("accesion") || l.includes("number") || l.search(
|
||||
if (l.includes("accession") || l.includes("number") || l.search(
|
||||
/(ref|rk9|rh8|ra9|rbz)\d+/g) > -1) {
|
||||
console.log("Match found ", input);
|
||||
$(input).addClass("image-ident-warning");
|
||||
@@ -162,9 +163,6 @@
|
||||
// Probably no need to await here anymore
|
||||
//await readFile(el.files[0], el);
|
||||
|
||||
|
||||
|
||||
|
||||
$(el).parent().parent().find(".temp-thumb").remove();
|
||||
|
||||
let url = URL.createObjectURL(el.files[0])
|
||||
@@ -363,7 +361,7 @@
|
||||
//}
|
||||
|
||||
image_set = $("#image_form_set .temp-thumb").get() //.map(function() { return $(this).attr("src")}).get();
|
||||
console.log(image_set);
|
||||
console.log("image set", image_set);
|
||||
//console.log()
|
||||
//if (n == image_set.length) {
|
||||
const event = new CustomEvent('loadDicomViewer', {
|
||||
@@ -377,3 +375,168 @@
|
||||
//}
|
||||
|
||||
}
|
||||
|
||||
function extractDicomDetails(byteArray) {
|
||||
// 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
|
||||
// and element in the following format: xggggeeee where gggg is the group number
|
||||
// and eeee is the element number with lowercase hex characters.
|
||||
|
||||
// To access the data for an element, we need to know its type and its tag.
|
||||
// We will get the sopInstanceUid from the file which is a string and
|
||||
// has the tag (0020,000D)
|
||||
var study_description = dataSet.string('x00081030');
|
||||
var accession_number = dataSet.string('x00080050').toLowerCase();
|
||||
var patient_id = dataSet.string('x00100020').toLowerCase();
|
||||
var modality = dataSet.string('x00080060');
|
||||
var projection = dataSet.string('x00185101');
|
||||
|
||||
try {
|
||||
var plane = dataSet.string('x00180022').toLowerCase();
|
||||
} catch (err) {
|
||||
var plane = "";
|
||||
}
|
||||
console.log("STUDY", study_description);
|
||||
console.log("acc", accession_number);
|
||||
console.log("pid", patient_id);
|
||||
console.log("projection", projection);
|
||||
console.log("plane", plane);
|
||||
|
||||
function select_plane(option) {
|
||||
let plane_select_object = document.getElementsByName("plane")[0]
|
||||
const $options = Array.from(plane_select_object.options);
|
||||
const optionToSelect = $options.find(item => item.text === option);
|
||||
optionToSelect.selected = true;
|
||||
|
||||
}
|
||||
|
||||
if (plane.includes("axial")) {
|
||||
select_plane("axial");
|
||||
} else if (plane.includes("sagittal")) {
|
||||
select_plane("sagittal");
|
||||
} else if (plane.includes("coronal")) {
|
||||
select_plane("coronal");
|
||||
} else if (plane.includes("ap")) {
|
||||
select_plane("AP");
|
||||
} else if (plane.includes("lat") | plane.includes("lateral")) {
|
||||
select_plane("lateral");
|
||||
} else if (plane.includes("pa")) {
|
||||
select_plane("PA");
|
||||
|
||||
}
|
||||
|
||||
|
||||
modality_map = {
|
||||
"DX": "Radiograph",
|
||||
"CR": "Radiograph",
|
||||
"CT": "CT",
|
||||
"MR": "MR",
|
||||
"RF": "Fluroscopy",
|
||||
"US": "Ultrasound",
|
||||
}
|
||||
|
||||
if (modality in modality_map) {
|
||||
let modality_select_object = document.getElementsByName("modality")[0]
|
||||
const $options = Array.from(modality_select_object.options);
|
||||
const optionToSelect = $options.find(item => item.text === modality_map[modality]);
|
||||
optionToSelect.selected = true;
|
||||
|
||||
|
||||
|
||||
} else {
|
||||
toastr.info(`Unable to automatically set modality: ${modality}`);
|
||||
}
|
||||
|
||||
// If the dicom study_description contains multiple values select the first
|
||||
study_description = study_description.split(",")[0].trim();
|
||||
|
||||
to_replace = ["lt", "rt", "contrast", "LT", "RT", "Contrast"];
|
||||
|
||||
for (let i = 0; i < to_replace.length; i++) {
|
||||
|
||||
study_description = study_description.replace(to_replace[i], "");
|
||||
}
|
||||
|
||||
let select_object = document.getElementsByName("examination")[0]
|
||||
|
||||
if (select_object.value == "") {
|
||||
$.get(`${select_object.dataset.autocompleteLightUrl}?q=${study_description}`, (data) => {
|
||||
console.log(data)
|
||||
if (data.results.length == 1) {
|
||||
|
||||
$(select_object).select2("trigger", "select", {
|
||||
data: data.results[0]
|
||||
})
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
not_anon = false;
|
||||
|
||||
site_codes = ["ref", "rk9", "ra9", "rh8", "rbz", "rba"]
|
||||
|
||||
for (let i = 0; i < site_codes.length; i++) {
|
||||
if (
|
||||
accession_number.startsWith(site_codes[i]) ||
|
||||
patient_id.startsWith(site_codes[i])) {
|
||||
not_anon = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (not_anon) {
|
||||
toastr.warning(`File does not appear to be annonymised<br/>Accession: ${accession_number} <br/>Patient ID: ${patient_id}<br/>`, 'Anonymisation warning', {
|
||||
"closeButton": false,
|
||||
"debug": false,
|
||||
"newestOnTop": false,
|
||||
"progressBar": false,
|
||||
"positionClass": "toast-top-full-width",
|
||||
"preventDuplicates": false,
|
||||
"onclick": null,
|
||||
"showDuration": "0",
|
||||
"hideDuration": "0",
|
||||
"timeOut": 0,
|
||||
"extendedTimeOut": 0,
|
||||
"showEasing": "swing",
|
||||
"hideEasing": "linear",
|
||||
"showMethod": "fadeIn",
|
||||
"hideMethod": "fadeOut",
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: fix
|
||||
//$.ajax({
|
||||
// url: `{% url 'generic:examination-autocomplete' %}/${encodeURI(study_description)}`,
|
||||
// //context: document.body,
|
||||
// success: function(data){
|
||||
// console.log(data)
|
||||
// }
|
||||
//});
|
||||
|
||||
//// try to match with a study description (if not already selected)
|
||||
//if ($(`#id_examination_to option`).length < 1) {
|
||||
// option = $(`#id_examination_from option[title*='${study_description}' i]`);
|
||||
|
||||
// if (option.length) {
|
||||
// option.appendTo($("#id_examination_to"));
|
||||
// toastr.success(`Examination set to ${option.attr('title')} from dicom data`);
|
||||
// } else {
|
||||
// toastr.warning(
|
||||
// `Unable to set examination ${study_description} from dicom data (it needs creating)`);
|
||||
|
||||
// }
|
||||
//}
|
||||
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{% extends "anatomy/exams.html" %}
|
||||
|
||||
{% block js %}
|
||||
{{ form.media }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Add Exam</h2>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<table>
|
||||
{{ form.as_table }}
|
||||
</table>
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,11 +1,14 @@
|
||||
{% extends 'anatomy/exams.html' %}
|
||||
|
||||
{% block navigation %}
|
||||
{{ block.super }}
|
||||
{% include 'generic/exam_overview_headers.html' %}
|
||||
{% endblock navigation %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% load thumbnail %}
|
||||
<div class="anatomy">
|
||||
{% include 'generic/exam_overview_headers.html' %}
|
||||
|
||||
<ol id="full-question-list" class="sortable">
|
||||
{% for question in questions %}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block navigation %}
|
||||
{{block.super}}
|
||||
<br/>
|
||||
Exams: {{exam.name}}->
|
||||
Exams: {{exam}}->
|
||||
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a> /
|
||||
{% if exam.exam_mode %}
|
||||
<a href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a> /
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="anatomy">
|
||||
<h2>Marking exam: {{ exam.name }}</h2>
|
||||
<h2>Marking exam: {{ exam }}</h2>
|
||||
You can start marking from a particular question by clicking on it below.
|
||||
|
||||
<div>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
<div>
|
||||
Exams: {% for exam in question.exams.all %}
|
||||
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">{{ exam.name }}</a>
|
||||
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">{{ exam }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
{% if previous > -1 %}
|
||||
<a href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of exam: <a href="{% url 'anatomy:exam_overview' exam.id %}">{{exam.name}}</a> [{{pos}}/{{exam_length}}]
|
||||
Viewing question as part of exam: <a href="{% url 'anatomy:exam_overview' exam.id %}">{{exam}}</a> [{{pos}}/{{exam_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next question</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -303,7 +303,7 @@ def test_exams(db, client):
|
||||
.find("li", attrs={"data-exam-id": exam.pk})
|
||||
)
|
||||
assert search_exam
|
||||
assert exam.name in str(search_exam)
|
||||
assert str(exam) in str(search_exam)
|
||||
assert len(search_exam.find("button", {"class": "start-button"})) > 0
|
||||
# assert "Active" in assigned_exams # check it is active
|
||||
|
||||
@@ -319,8 +319,8 @@ def test_exams(db, client):
|
||||
.find_all("li", attrs={"data-exam-id": exam.pk})
|
||||
)
|
||||
assert results_exam
|
||||
assert exam.name in str(results_exam)
|
||||
assert exam.name + " test" not in str(results_exam)
|
||||
assert str(exam) in str(results_exam)
|
||||
assert str(exam) + " test" not in str(results_exam)
|
||||
assert "Results Published" not in str(
|
||||
results_exam
|
||||
) # It should not be published yet
|
||||
|
||||
@@ -52,6 +52,7 @@ urlpatterns = [
|
||||
path(
|
||||
"exam/<int:exam_pk>/<int:sk>/mark/review", views.mark_review, name="mark_review"
|
||||
),
|
||||
path("exam/<int:pk>/markers", views.ExamMarkersUpdate.as_view(), name="exam_markers"),
|
||||
path("exam/<int:pk>/authors", views.ExamAuthorUpdate.as_view(), name="exam_authors"),
|
||||
path("exam/create", views.ExamCreate.as_view(), name="exam_create"),
|
||||
path("exam/<int:exam_id>/clone", views.ExamClone.as_view(), name="exam_clone"),
|
||||
|
||||
+56
-55
@@ -33,6 +33,8 @@ from .forms import (
|
||||
AnswerUpdateFormSet,
|
||||
BodyPartForm,
|
||||
ExamAuthorForm,
|
||||
ExamGroupsForm,
|
||||
ExamMarkerForm,
|
||||
ExaminationForm,
|
||||
MarkAnatomyQuestionForm,
|
||||
AnatomyQuestionForm,
|
||||
@@ -50,7 +52,7 @@ from .models import (
|
||||
# IncorrectAnswers,
|
||||
)
|
||||
from generic.models import CidUser, Examination
|
||||
from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamCreateBase, ExamDeleteBase, ExamUpdateBase, ExamViews, GenericViewBase
|
||||
from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamCreateBase, ExamDeleteBase, ExamGroupsUpdateBase, ExamUpdateBase, ExamViews, GenericViewBase, UpdateQuestionMixin
|
||||
from reversion.views import RevisionMixin
|
||||
|
||||
from .decorators import user_is_author_or_anatomy_checker
|
||||
@@ -71,7 +73,7 @@ from helpers.images import image_as_base64
|
||||
|
||||
from django.template.defaulttags import register
|
||||
|
||||
from generic.mixins import SuperuserRequiredMixin
|
||||
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
|
||||
|
||||
from rest_framework import viewsets, permissions
|
||||
|
||||
@@ -89,9 +91,9 @@ class AuthorOrCheckerRequiredMixin(object):
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_item(dictionary, key):
|
||||
def get_item(dictionary, key, default=None):
|
||||
if dictionary is None:
|
||||
return None
|
||||
return default
|
||||
return dictionary.get(key)
|
||||
|
||||
|
||||
@@ -239,6 +241,9 @@ def mark_review(request, exam_pk, sk):
|
||||
def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
|
||||
exam = get_object_or_404(Exam, pk=exam_pk)
|
||||
|
||||
if not GenericExamViews.check_user_marker_access(request.user, exam_pk):
|
||||
raise PermissionDenied
|
||||
|
||||
if not exam.exam_mode:
|
||||
raise Http404("Packet not in exam mode")
|
||||
|
||||
@@ -248,7 +253,7 @@ def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
|
||||
elif not unmarked_exam_answers_only:
|
||||
mark_url = "anatomy:mark_all"
|
||||
|
||||
questions = exam.exam_questions.all().prefetch_related("answers")
|
||||
questions = exam.get_questions().prefetch_related("answers")
|
||||
|
||||
n = sk
|
||||
|
||||
@@ -383,28 +388,28 @@ def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def exam_scores_refresh(request, pk):
|
||||
exam = get_object_or_404(Exam, pk=pk)
|
||||
|
||||
if not exam.exam_mode:
|
||||
raise Http404("Packet not in exam mode")
|
||||
|
||||
questions = exam.exam_questions.all()
|
||||
|
||||
cids = UserAnswer.objects.filter(question__in=questions, exam__id=pk)
|
||||
|
||||
# Force a score update
|
||||
for c in cids:
|
||||
c.get_answer_score(cached=False)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"anatomy/base.html",
|
||||
{
|
||||
"simple_content": "Answer scores updated",
|
||||
},
|
||||
)
|
||||
#@login_required
|
||||
#def exam_scores_refresh(request, pk):
|
||||
# exam = get_object_or_404(Exam, pk=pk)
|
||||
#
|
||||
# if not exam.exam_mode:
|
||||
# raise Http404("Packet not in exam mode")
|
||||
#
|
||||
# questions = exam.exam_questions.all() # Don't worry about order here
|
||||
#
|
||||
# cids = UserAnswer.objects.filter(question__in=questions, exam__id=pk)
|
||||
#
|
||||
# # Force a score update
|
||||
# for c in cids:
|
||||
# c.get_answer_score(cached=False)
|
||||
#
|
||||
# return render(
|
||||
# request,
|
||||
# "anatomy/base.html",
|
||||
# {
|
||||
# "simple_content": "Answer scores updated",
|
||||
# },
|
||||
# )
|
||||
|
||||
|
||||
def exam_scores_cid_user(request, pk, cid, passcode):
|
||||
@@ -413,10 +418,10 @@ def exam_scores_cid_user(request, pk, cid, passcode):
|
||||
if not exam.exam_mode:
|
||||
raise Http404("Packet not in exam mode")
|
||||
|
||||
if not exam.check_cid_user(cid, passcode, request):
|
||||
if not exam.check_cid_user(cid, passcode, request.user):
|
||||
raise Http404("Error accessing exam")
|
||||
|
||||
questions = exam.exam_questions.all()
|
||||
questions = exam.get_questions().prefetch_related("answers")
|
||||
|
||||
answers_and_marks = []
|
||||
answers_marks = []
|
||||
@@ -593,35 +598,11 @@ class AnatomyQuestionAnswerUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, U
|
||||
return super().form_invalid(form)
|
||||
|
||||
# class AnatomyQuestionUpdate(LoginRequiredMixin, AuthorOrCheckerRequiredMixin,
|
||||
class AnatomyQuestionUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateView):
|
||||
class AnatomyQuestionUpdate(UpdateQuestionMixin,AuthorOrCheckerRequiredMixin):
|
||||
model = AnatomyQuestion
|
||||
form_class = AnatomyQuestionForm
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super(AnatomyQuestionUpdate, self).get_form_kwargs()
|
||||
kwargs.update({"user": self.request.user})
|
||||
return kwargs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(AnatomyQuestionUpdate, self).get_context_data(**kwargs)
|
||||
#if self.request.POST:
|
||||
# context["answer_formset"] = AnswerUpdateFormSet(
|
||||
# self.request.POST, self.request.FILES, instance=self.object
|
||||
# )
|
||||
# context["answer_formset"].full_clean()
|
||||
#else:
|
||||
# context["answer_formset"] = AnswerUpdateFormSet(instance=self.object)
|
||||
context["question"] = context["object"]
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
|
||||
# save exam orders (there must be a better way to do this)
|
||||
#exam_orders = {}
|
||||
#for exam in self.object.exams.all():
|
||||
# exam_orders[exam] = list(exam.exam_questions.all())
|
||||
# print(exam_orders[exam])
|
||||
|
||||
self.object = form.save(commit=False)
|
||||
self.object.save()
|
||||
|
||||
@@ -809,6 +790,12 @@ class ExamUpdate(ExamUpdateBase, AuthorOrCheckerRequiredMixin):
|
||||
model = Exam
|
||||
form_class = ExamForm
|
||||
|
||||
#def get_context_data(self, **kwargs):
|
||||
# context = super().get_context_data(**kwargs)
|
||||
|
||||
# context["can_edit"] = self.object.check_user_can_edit(self.request.user)
|
||||
|
||||
# return context
|
||||
|
||||
class ExamDelete(AuthorOrCheckerRequiredMixin, ExamDeleteBase):
|
||||
model = Exam
|
||||
@@ -831,10 +818,24 @@ class ExamDelete(AuthorOrCheckerRequiredMixin, ExamDeleteBase):
|
||||
# return Exam.objects.filter(author__id=user.id)
|
||||
|
||||
|
||||
class ExamAuthorUpdate(RevisionMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView):
|
||||
class ExamAuthorUpdate(RevisionMixin, CheckCanEditMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView):
|
||||
model = Exam
|
||||
form_class = ExamAuthorForm
|
||||
|
||||
template_name = "author_form.html"
|
||||
|
||||
class ExamMarkersUpdate(
|
||||
RevisionMixin, CheckCanEditMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView
|
||||
):
|
||||
model = Exam
|
||||
form_class = ExamMarkerForm
|
||||
|
||||
template_name = "markers_form.html"
|
||||
|
||||
|
||||
class ExamGroupsUpdate(ExamGroupsUpdateBase):
|
||||
model = Exam
|
||||
form_class = ExamGroupsForm
|
||||
|
||||
|
||||
GenericViews = GenericViewBase("anatomy", AnatomyQuestion, UserAnswer, Exam)
|
||||
|
||||
@@ -15,6 +15,8 @@ from .models import (
|
||||
PathologicalProcess,
|
||||
Presentation,
|
||||
UncategorisedDicom,
|
||||
SeriesDetail,
|
||||
Resource
|
||||
)
|
||||
|
||||
from django.forms import ModelForm
|
||||
@@ -42,6 +44,8 @@ admin.site.register(PathologicalProcess)
|
||||
admin.site.register(Presentation)
|
||||
admin.site.register(CaseDetail)
|
||||
admin.site.register(UncategorisedDicom)
|
||||
admin.site.register(SeriesDetail)
|
||||
admin.site.register(Resource)
|
||||
|
||||
|
||||
class DifferentialInline(admin.TabularInline):
|
||||
|
||||
+46
-7
@@ -1,11 +1,15 @@
|
||||
from collections import defaultdict
|
||||
from typing import List, Tuple
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
from ninja import ModelSchema, Router, Schema, Field
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
# from .decorators import user_is_author_or_rapid_checker
|
||||
from django.core.exceptions import PermissionDenied
|
||||
import pydicom
|
||||
from pydicom.errors import InvalidDicomError
|
||||
|
||||
from generic.decorators import check_user_in_group
|
||||
from generic.constants import Group
|
||||
@@ -15,14 +19,17 @@ from ninja.security import django_auth
|
||||
from ninja import NinjaAPI, File
|
||||
from ninja.files import UploadedFile
|
||||
|
||||
from generic.models import Modality
|
||||
from generic.models import Examination, Modality
|
||||
|
||||
from .models import Case, DuplicateDicom, Series, SeriesImage, UncategorisedDicom
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
|
||||
class SeriesSchema(ModelSchema):
|
||||
case_id: List[int] = []
|
||||
|
||||
@@ -51,6 +58,7 @@ class CaseSchema(ModelSchema):
|
||||
def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
||||
uploaded = []
|
||||
duplicate = []
|
||||
failed = []
|
||||
for file in files:
|
||||
# data = file.read()
|
||||
try:
|
||||
@@ -60,11 +68,12 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
||||
|
||||
uploaded.append((file.name, ud.image_blake3_hash))
|
||||
except DuplicateDicom:
|
||||
print(ud.check_for_duplicates(ud.image_blake3_hash))
|
||||
duplicate.append((file.name, ud.image_blake3_hash))
|
||||
pass
|
||||
except InvalidDicomError:
|
||||
failed.append(file.name)
|
||||
|
||||
return {"uploaded": uploaded, "duplicates": duplicate}
|
||||
return {"uploaded": uploaded, "duplicates": duplicate, "failed": failed}
|
||||
|
||||
@router.post("/generate_image_hash", auth=django_auth)
|
||||
def generate_image_hash(request, id:int):
|
||||
@@ -101,7 +110,6 @@ def uncategorised_dicoms(request):
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def import_dicoms_helper(request, case_id: int | None = None):
|
||||
#dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||
if "selection" in request.POST:
|
||||
@@ -110,6 +118,12 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
else:
|
||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||
|
||||
|
||||
if "order-series" in request.POST:
|
||||
order = request.POST["order-series"]
|
||||
else:
|
||||
order = None
|
||||
|
||||
data = defaultdict(list)
|
||||
for d in dicoms:
|
||||
tags = d.basic_dicom_tags
|
||||
@@ -122,21 +136,41 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
tags = data[series_uid][0][1]
|
||||
|
||||
# Check if series with the id already exists (in which case we just add to htat)
|
||||
try:
|
||||
if Series.objects.filter(series_instance_uid=tags["SeriesInstanceUID"]).exists():
|
||||
series = Series.objects.get(series_instance_uid=tags["SeriesInstanceUID"])
|
||||
except Series.DoesNotExist:
|
||||
else:
|
||||
series = Series(
|
||||
modality=modality,
|
||||
description=tags["SeriesDescription"],
|
||||
series_instance_uid=tags["SeriesInstanceUID"],
|
||||
)
|
||||
|
||||
if tags["StudyDescription"]:
|
||||
examination, created = Examination.objects.get_or_create(examination=tags["StudyDescription"])
|
||||
if created:
|
||||
examination.modality = modality
|
||||
examination.save()
|
||||
series.examination = examination
|
||||
|
||||
|
||||
series.save()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
if case_id is not None:
|
||||
series.case.add(case_id)
|
||||
case_object = get_object_or_404(Case, pk=case_id)
|
||||
if not series.case.contains(case_object):
|
||||
series.case.add(case_object)
|
||||
|
||||
for dicom, dicom_tags in data[series_uid]:
|
||||
series_image = SeriesImage(
|
||||
@@ -244,8 +278,13 @@ def check_images_hashes(request, hashes: List[str]):
|
||||
data = {
|
||||
"id": series_image.pk,
|
||||
"url": series_image.series.get_absolute_url(),
|
||||
"type": "series"
|
||||
}
|
||||
except SeriesImage.DoesNotExist:
|
||||
try:
|
||||
uncategorised_dicom = UncategorisedDicom.objects.get(image_blake3_hash=hash)
|
||||
data = { "id":uncategorised_dicom.pk, "url": reverse("atlas:user_uploads"), "type": "uncategorised"}
|
||||
except UncategorisedDicom.DoesNotExist:
|
||||
data = { "id": False, "url": False}
|
||||
|
||||
hash_status[hash] = data
|
||||
|
||||
@@ -7,6 +7,7 @@ from .models import (
|
||||
Finding,
|
||||
PathologicalProcess,
|
||||
Presentation,
|
||||
QuestionSchema,
|
||||
Series,
|
||||
Structure,
|
||||
Subspecialty,
|
||||
@@ -282,3 +283,8 @@ class SubspecialtyFilter(django_filters.FilterSet):
|
||||
data=data, queryset=queryset, prefix=prefix, request=request
|
||||
)
|
||||
pass
|
||||
|
||||
class QuestionSchemaFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
model = QuestionSchema
|
||||
fields = ("name", "description")
|
||||
+192
-12
@@ -2,6 +2,7 @@ from django import forms
|
||||
from django.contrib.admin import widgets
|
||||
from django.forms import (
|
||||
BaseInlineFormSet,
|
||||
BooleanField,
|
||||
Form,
|
||||
HiddenInput,
|
||||
ModelForm,
|
||||
@@ -15,6 +16,9 @@ from django.forms import (
|
||||
)
|
||||
from django.forms import inlineformset_factory
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_jsonforms.forms import JSONSchemaField
|
||||
from django_svelte_jsoneditor.widgets import SvelteJSONEditorWidget
|
||||
|
||||
|
||||
from atlas.models import (
|
||||
Case,
|
||||
@@ -23,6 +27,8 @@ from atlas.models import (
|
||||
CidReportAnswer,
|
||||
Differential,
|
||||
Finding,
|
||||
QuestionSchema,
|
||||
Resource,
|
||||
SelfReview,
|
||||
Series,
|
||||
SeriesImage,
|
||||
@@ -51,6 +57,13 @@ from autocomplete import widgets as htmx_widgets
|
||||
|
||||
import logging
|
||||
|
||||
from generic.forms import ExamAuthorFormMixin
|
||||
|
||||
from crispy_forms.helper import FormHelper
|
||||
|
||||
from atlas.helpers import get_cases_available_to_user
|
||||
|
||||
|
||||
class ConditionForm(ModelForm):
|
||||
class Meta:
|
||||
model = Condition
|
||||
@@ -72,7 +85,7 @@ class ConditionForm(ModelForm):
|
||||
class CaseCollectionForm(ModelForm):
|
||||
class Meta:
|
||||
model = CaseCollection
|
||||
exclude = ["cases", "valid_cid_users"]
|
||||
exclude = ["cases", "valid_cid_users", "valid_user_users", "author"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop(
|
||||
@@ -90,6 +103,18 @@ class CaseCollectionForm(ModelForm):
|
||||
|
||||
super(CaseCollectionForm, self).__init__(*args, **kwargs)
|
||||
|
||||
def save(self, commit=True):
|
||||
# Get the unsaved Case instance
|
||||
instance = ModelForm.save(self, False)
|
||||
|
||||
|
||||
# Do we need to save all changes now?
|
||||
# if commit:
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class FindingForm(ModelForm):
|
||||
class Meta:
|
||||
@@ -127,6 +152,10 @@ class SeriesFindingForm(ModelForm):
|
||||
"structures": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:structure-autocomplete"
|
||||
),
|
||||
"conditions": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:condition-autocomplete"
|
||||
),
|
||||
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -242,6 +271,7 @@ 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
|
||||
@@ -277,12 +307,18 @@ class CaseForm(ModelForm):
|
||||
# initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()]
|
||||
|
||||
|
||||
|
||||
ModelForm.__init__(self, *args, **kwargs)
|
||||
|
||||
super(CaseForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# self.fields["series"].queryset =
|
||||
|
||||
if self.user.groups.filter(name="atlas_editor").exists():
|
||||
case_queryset = Case.objects.all().order_by("pk")
|
||||
else:
|
||||
case_queryset = get_cases_available_to_user(self.user)
|
||||
self.fields["previous_case"].queryset=case_queryset
|
||||
|
||||
def get_queryset(self, request):
|
||||
return (
|
||||
super()
|
||||
@@ -337,8 +373,6 @@ class CaseForm(ModelForm):
|
||||
instance = ModelForm.save(self, False)
|
||||
|
||||
|
||||
|
||||
|
||||
# Prepare a 'save_m2m' method for the form,
|
||||
old_save_m2m = self.save_m2m
|
||||
|
||||
@@ -384,6 +418,35 @@ CaseDifferentialFormSet = inlineformset_factory(
|
||||
},
|
||||
)
|
||||
|
||||
class ResourceForm(ModelForm):
|
||||
class Meta:
|
||||
model = Resource
|
||||
exclude = ["author"]
|
||||
|
||||
widgets = {
|
||||
"description": Textarea(attrs={"cols": 80, "rows": 5}),
|
||||
"url": TextInput(attrs={"cols": 80, "rows": 1}),
|
||||
}
|
||||
|
||||
|
||||
class CaseResourceForm(ModelForm):
|
||||
|
||||
def __init__(self, *args, user, **kwargs):
|
||||
super(CaseResourceForm, self).__init__(*args, **kwargs)
|
||||
|
||||
if not user.groups.filter(name="atlas_editor").exists():
|
||||
queryset = Resource.objects.filter(author__id=user.id)
|
||||
else:
|
||||
queryset = Resource.objects.all()
|
||||
|
||||
self.fields["resource"] = ModelChoiceField(
|
||||
required=False,
|
||||
queryset=queryset,
|
||||
# widget=Select(verbose_name="Series", is_stacked=False),
|
||||
)
|
||||
|
||||
self.fields["pre_review"] = BooleanField(required=False)
|
||||
|
||||
|
||||
class CaseSeriesForm(ModelForm):
|
||||
|
||||
@@ -405,13 +468,10 @@ class CaseSeriesForm(ModelForm):
|
||||
|
||||
|
||||
class CaseCollectionCaseForm(ModelForm):
|
||||
def __init__(self, *args, user, **kwargs):
|
||||
def __init__(self, *args, user, collection=None, **kwargs):
|
||||
super(CaseCollectionCaseForm, self).__init__(*args, **kwargs)
|
||||
|
||||
if not user.groups.filter(name="atlas_editor").exists():
|
||||
queryset = Case.objects.filter(author__id=user.id)
|
||||
else:
|
||||
queryset = Case.objects.all()
|
||||
queryset = get_cases_available_to_user(user, collection=collection)
|
||||
|
||||
self.fields["case"] = ModelChoiceField(
|
||||
required=False,
|
||||
@@ -434,12 +494,21 @@ CaseCollectionCaseFormSet = inlineformset_factory(
|
||||
CaseCollection,
|
||||
Case.casecollection_set.through,
|
||||
form=CaseCollectionCaseForm,
|
||||
exclude=[],
|
||||
exclude=["question_schema", "question_answers"],
|
||||
can_delete=True,
|
||||
extra=0,
|
||||
max_num=50,
|
||||
)
|
||||
|
||||
CaseResourceFormSet = inlineformset_factory(
|
||||
Case,
|
||||
Resource.case_set.through,
|
||||
form=CaseResourceForm,
|
||||
exclude=[],
|
||||
can_delete=True,
|
||||
extra=1,
|
||||
max_num=10,
|
||||
)
|
||||
|
||||
SeriesImageFormSet = inlineformset_factory(
|
||||
Series,
|
||||
@@ -450,6 +519,22 @@ SeriesImageFormSet = inlineformset_factory(
|
||||
max_num=2000,
|
||||
)
|
||||
|
||||
|
||||
class JsonAnswerForm(Form):
|
||||
json_answer = JSONSchemaField(
|
||||
schema = {},
|
||||
#options = 'schema/options.json'
|
||||
options = 'schema/options.json'
|
||||
)
|
||||
class Meta:
|
||||
fields = ["json_answer"]
|
||||
|
||||
def __init__(self, *args, question_schema=None, **kwargs):
|
||||
super(JsonAnswerForm, self).__init__(*args, **kwargs)
|
||||
if question_schema is not None:
|
||||
self.fields["json_answer"] = JSONSchemaField(schema=question_schema, options='schema/options.json')
|
||||
self.fields["json_answer"].label = False
|
||||
|
||||
class BaseReportAnswerForm(ModelForm):
|
||||
class Meta:
|
||||
fields = ["answer"]
|
||||
@@ -460,17 +545,38 @@ class BaseReportAnswerForm(ModelForm):
|
||||
# (False, 'No')])
|
||||
# "findings": TinyMCE(attrs={"cols": 80, "rows": 20}),
|
||||
# "mark_scheme": TinyMCE(attrs={"cols": 80, "rows": 30}),
|
||||
"answer": Textarea(attrs={"cols": 100, "rows": 5}),
|
||||
"answer": Textarea(attrs={"cols": 100, "rows": 5, "placeholder":"Report text"}),
|
||||
}
|
||||
|
||||
# help_texts = {
|
||||
# "answer": "Write your answer in here."
|
||||
# }
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, case_detail, **kwargs):
|
||||
super(BaseReportAnswerForm, self).__init__(*args, **kwargs)
|
||||
self.fields["answer"].required = False
|
||||
|
||||
class BaseQuestionAnswerForm(ModelForm):
|
||||
json_answer = JSONSchemaField(
|
||||
schema = {},
|
||||
options = 'schema/options.json'
|
||||
)
|
||||
class Meta:
|
||||
fields = ["json_answer"]
|
||||
labels = {"json_answer": ""}
|
||||
|
||||
# help_texts = {
|
||||
# "answer": "Write your answer in here."
|
||||
# }
|
||||
|
||||
def __init__(self, *args, case_detail, **kwargs):
|
||||
super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs)
|
||||
#self.fields["json_answer"].schema = case_detail.question_schema
|
||||
if case_detail.question_schema is not None:
|
||||
self.fields["json_answer"] = JSONSchemaField(schema=case_detail.question_schema, options="schema/options.json")
|
||||
self.fields["json_answer"].label = ""
|
||||
|
||||
|
||||
class CidReportAnswerForm(BaseReportAnswerForm):
|
||||
class Meta(BaseReportAnswerForm.Meta):
|
||||
model = CidReportAnswer
|
||||
@@ -479,6 +585,14 @@ class UserReportAnswerForm(BaseReportAnswerForm):
|
||||
class Meta(BaseReportAnswerForm.Meta):
|
||||
model = UserReportAnswer
|
||||
|
||||
class CidQuestionAnswerForm(BaseQuestionAnswerForm):
|
||||
class Meta(BaseQuestionAnswerForm.Meta):
|
||||
model = CidReportAnswer
|
||||
|
||||
class UserQuestionAnswerForm(BaseQuestionAnswerForm):
|
||||
class Meta(BaseQuestionAnswerForm.Meta):
|
||||
model = UserReportAnswer
|
||||
|
||||
class CidReportAnswerMarkForm(ModelForm):
|
||||
class Meta:
|
||||
model = CidReportAnswer
|
||||
@@ -622,3 +736,69 @@ class ConditionAutocompleteForm(Form):
|
||||
condition = ModelChoiceField(queryset=Condition.objects.all(), widget=htmx_widgets.Autocomplete(
|
||||
name="conditions", options=dict(model=Condition)
|
||||
))
|
||||
|
||||
|
||||
class CaseCollectionAuthorForm(ExamAuthorFormMixin):
|
||||
class Meta(ExamAuthorFormMixin.Meta):
|
||||
model = CaseCollection
|
||||
|
||||
class CaseAuthorForm(ExamAuthorFormMixin):
|
||||
class Meta(ExamAuthorFormMixin.Meta):
|
||||
model = Case
|
||||
class SeriesAuthorForm(ExamAuthorFormMixin):
|
||||
class Meta(ExamAuthorFormMixin.Meta):
|
||||
model = Series
|
||||
|
||||
|
||||
class AnswerJSONForm(Form):
|
||||
|
||||
json = JSONSchemaField(
|
||||
schema = 'schema/schema.json',
|
||||
options = 'schema/options.json'
|
||||
)
|
||||
|
||||
|
||||
class SvelteJSONEditorWidgetOverride(SvelteJSONEditorWidget):
|
||||
template_name = "atlas/svelte_jsoneditor_widget_override.html"
|
||||
|
||||
class CaseDetailForm(ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = CaseDetail
|
||||
fields = ["question_schema", "question_answers"]#, "user"]
|
||||
|
||||
widgets = {
|
||||
"question_schema": SvelteJSONEditorWidgetOverride(),
|
||||
"question_answers": SvelteJSONEditorWidgetOverride(),
|
||||
}
|
||||
|
||||
|
||||
class QuestionSchemaForm(ModelForm):
|
||||
class Meta:
|
||||
model = QuestionSchema
|
||||
fields = ["name", "description", "schema"]
|
||||
|
||||
widgets = {
|
||||
"schema": SvelteJSONEditorWidgetOverride(),
|
||||
}
|
||||
|
||||
class PriorCaseForm(Form):
|
||||
|
||||
relation = CharField(max_length=255, required=True)
|
||||
|
||||
#def __init__(self, *args, **kwargs):
|
||||
# self.case_detail = kwargs.pop(
|
||||
# "case_detail"
|
||||
# ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
|
||||
|
||||
# super(PriorCaseForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# prior_cases = self.case_detail.case.get_all_prior_cases()
|
||||
|
||||
# if not prior_cases:
|
||||
# prior_cases = Case.objects.none()
|
||||
|
||||
# self.fields["case"] = ChoiceField(
|
||||
# required=False,
|
||||
# # widget=Select(verbose_name="Series", is_stacked=False),
|
||||
# )
|
||||
@@ -0,0 +1,13 @@
|
||||
from atlas.models import Case
|
||||
|
||||
def get_cases_available_to_user(user, collection=None):
|
||||
"""Returns a queryset of cases available to the user"""
|
||||
queryset = Case.objects.filter(author=user) | Case.objects.filter(open_access=True)
|
||||
|
||||
if collection is not None:
|
||||
queryset = queryset | collection.cases.all()
|
||||
return queryset.order_by("pk").distinct()
|
||||
if user.is_superuser:
|
||||
return Case.objects.all()
|
||||
else:
|
||||
return Case.objects.filter(author=user) | Case.objects.filter(open_access=True)
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.4 on 2024-02-12 12:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0037_alter_seriesimage_image_blake3_hash'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='viewer_mode',
|
||||
field=models.CharField(choices=[('BUILT_IN', 'Built in viewer'), ('OHIF', 'OHIF Viewer'), ('BUILT_IN_WITH_OHIF', 'Built in viewer with OHIF Viewer option')], default='BUILT_IN', max_length=100),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.4 on 2024-02-17 21:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0038_casecollection_viewer_mode'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='casecollection',
|
||||
name='collection_type',
|
||||
field=models.CharField(choices=[('REV', 'Review'), ('REP', 'Report'), ('VIV', 'Viva')], default='REP', max_length=3),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.1.4 on 2024-02-17 21:23
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0039_alter_casecollection_collection_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SeriesDetail',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('sort_order', models.IntegerField(default=1000)),
|
||||
('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='atlas.case')),
|
||||
('series', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='atlas.series')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('sort_order',),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.1.4 on 2024-02-17 21:42
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0040_seriesdetail'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(
|
||||
"""INSERT INTO atlas_seriesdetail
|
||||
SELECT * FROM atlas_case_series;"""
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.1.4 on 2024-02-17 21:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0041_auto_20240217_2142'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='case',
|
||||
name='series',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='case',
|
||||
name='series',
|
||||
field=models.ManyToManyField(related_name='case', through='atlas.SeriesDetail', to='atlas.series'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.4 on 2024-02-17 22:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0042_alter_case_series'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='seriesdetail',
|
||||
name='feedback',
|
||||
field=models.BooleanField(default=False, help_text='Set to true if the series should only be shown for feedback purposes.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-02-24 21:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0043_seriesdetail_feedback'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='seriesfinding',
|
||||
name='conditions',
|
||||
field=models.ManyToManyField(blank=True, to='atlas.condition'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-02-27 14:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0044_seriesfinding_conditions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='authors_only',
|
||||
field=models.BooleanField(default=False, help_text='If true only exam/collection authors will be able to view.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.0.2 on 2024-04-04 19:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0045_casecollection_authors_only'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Resource',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('url', models.URLField(blank=True, null=True)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to='atlas/resources/')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CaseResource',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='atlas.case')),
|
||||
('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='atlas.resource')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='case',
|
||||
name='resource',
|
||||
field=models.ManyToManyField(through='atlas.CaseResource', to='atlas.resource'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.0.2 on 2024-04-04 22:07
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0046_resource_caseresource_case_resource'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='caseresource',
|
||||
name='pre_review',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='author',
|
||||
field=models.ManyToManyField(blank=True, help_text='Author of the resource', related_name='resources', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# Generated by Django 5.0.2 on 2024-04-08 12:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0047_caseresource_pre_review_resource_author'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cidreportanswer',
|
||||
name='completed',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userreportanswer',
|
||||
name='completed',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='case',
|
||||
name='condition',
|
||||
field=models.ManyToManyField(blank=True, help_text='The condition(s) the case demonstrates.', to='atlas.condition'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='case',
|
||||
name='diagnostic_certainty',
|
||||
field=models.IntegerField(choices=[(0, 'None'), (1, 'Possible'), (2, 'Likely'), (3, 'Almost Certain'), (4, 'Certain')], default=0, help_text='The diagnostic certainty of the case.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='case',
|
||||
name='differential',
|
||||
field=models.ManyToManyField(help_text='The differential diagnosis for the case.', related_name='casedifferential', through='atlas.Differential', to='atlas.condition'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='case',
|
||||
name='presentation',
|
||||
field=models.ManyToManyField(blank=True, help_text='The presentation(s) the case is associated with.', to='atlas.presentation'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='case',
|
||||
name='previous_case',
|
||||
field=models.OneToOneField(blank=True, help_text='If this case is related to another case on the system (e.g. follow up), link them here.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='next_case', to='atlas.case'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='case',
|
||||
name='subspecialty',
|
||||
field=models.ManyToManyField(blank=True, help_text='The subspecialties the case is associated with. Multiple subspecialties can be selected.', to='atlas.subspecialty'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='caseresource',
|
||||
name='pre_review',
|
||||
field=models.BooleanField(default=False, help_text='Defines if the resource should be shown pre review (i.e. for pre reading before the case is taken)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.0.2 on 2024-04-29 10:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0048_cidreportanswer_completed_userreportanswer_completed_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casedetail',
|
||||
name='questions',
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cidreportanswer',
|
||||
name='json_answer',
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userreportanswer',
|
||||
name='json_answer',
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-04-29 11:06
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0049_casedetail_questions_cidreportanswer_json_answer_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='casedetail',
|
||||
old_name='questions',
|
||||
new_name='question_schema',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.0.2 on 2024-05-13 10:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0050_rename_questions_casedetail_question_schema'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='end_date',
|
||||
field=models.DateTimeField(help_text='Date (and time) the exam/collection ends', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='restrict_to_dates',
|
||||
field=models.BooleanField(default=False, help_text='If the exam/collection should only be taken between the start and end date times'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='start_date',
|
||||
field=models.DateTimeField(help_text='Date (and time) the exam/collection starts', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.0.2 on 2024-05-18 07:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0051_casecollection_end_date_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='casecollection',
|
||||
name='end_date',
|
||||
field=models.DateTimeField(blank=True, help_text='Date (and time) the exam/collection ends', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='casecollection',
|
||||
name='start_date',
|
||||
field=models.DateTimeField(blank=True, help_text='Date (and time) the exam/collection starts', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-05-20 09:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0052_alter_casecollection_end_date_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casedetail',
|
||||
name='question_answers',
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.0.2 on 2024-06-10 22:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0053_casedetail_question_answers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='feedback_once_collection_complete',
|
||||
field=models.BooleanField(default=True, help_text='If true feedback is only given once the collection is complete. If false feedback is given after each case.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='casecollection',
|
||||
name='collection_type',
|
||||
field=models.CharField(choices=[('REV', 'Review'), ('REP', 'Report'), ('QUE', 'Question'), ('VIV', 'Viva')], default='REP', max_length=3),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.0.2 on 2024-06-24 09:27
|
||||
|
||||
import generic.mixins
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0054_casecollection_feedback_once_collection_complete_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='QuestionSchema',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('schema', models.JSONField()),
|
||||
],
|
||||
bases=(models.Model, generic.mixins.AuthorMixin),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.0.2 on 2024-07-15 11:28
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0055_questionschema'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CasePrior',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('relation_text', models.CharField(blank=True, help_text='Text to describe the relationship between the cases', max_length=255)),
|
||||
('case_detail', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='atlas.casedetail')),
|
||||
('prior_case', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='prior_case', to='atlas.case')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('case_detail', 'prior_case')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.0.2 on 2024-07-15 12:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0056_caseprior'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='caseprior',
|
||||
name='prior_case',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prior_case', to='atlas.case'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.0.2 on 2024-08-05 09:43
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0057_alter_caseprior_prior_case'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
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,20 @@
|
||||
# Generated by Django 5.0.2 on 2024-08-05 10:09
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0058_casecollection_markers'),
|
||||
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', null=True, related_name='casecollection_markers', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
+467
-25
@@ -1,10 +1,11 @@
|
||||
from functools import lru_cache
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
from typing import Tuple
|
||||
|
||||
from django.http import Http404, HttpRequest
|
||||
from generic.mixins import AuthorMixin
|
||||
from generic.mixins import AuthorMixin, QuestionMixin
|
||||
from rad.settings import REMOTE_URL
|
||||
from django.db.models.fields.files import ImageField
|
||||
from django.db.models.fields.related import ForeignKey
|
||||
@@ -19,7 +20,7 @@ from pydicom.errors import InvalidDicomError
|
||||
|
||||
from django.core.files.storage import FileSystemStorage
|
||||
from django.conf import settings
|
||||
from django.utils.html import format_html
|
||||
from django.utils.html import format_html, mark_safe
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -29,8 +30,6 @@ from django.utils.html import mark_safe
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from sortedm2m.fields import SortedManyToManyField
|
||||
|
||||
import string
|
||||
from collections import defaultdict
|
||||
from helpers.images import (
|
||||
@@ -40,12 +39,15 @@ from helpers.images import (
|
||||
pretty_print_dicom,
|
||||
)
|
||||
|
||||
import mimetypes
|
||||
|
||||
|
||||
from generic.models import (
|
||||
CidUser,
|
||||
CidUserExam,
|
||||
CidUserGroup,
|
||||
ExamOrCollectionGenericBase,
|
||||
ExamUserStatus,
|
||||
Examination,
|
||||
# Condition,
|
||||
ExamBase,
|
||||
@@ -70,6 +72,9 @@ from django.contrib.contenttypes.fields import GenericRelation
|
||||
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
|
||||
image_storage = FileSystemStorage(
|
||||
# Physical file location ROOT
|
||||
@@ -198,6 +203,9 @@ class Condition(SynMixin, models.Model):
|
||||
def get_parents(self):
|
||||
return self.parent.all()
|
||||
|
||||
def get_with_subspecialty(self):
|
||||
return f"{', '.join([str(i) for i in self.subspecialty.all()])} / {self.name} "
|
||||
|
||||
|
||||
class ConditionRelationship(models.Model):
|
||||
child = models.ForeignKey(Condition, on_delete=models.CASCADE)
|
||||
@@ -274,7 +282,7 @@ class Structure(SynMixin, models.Model):
|
||||
|
||||
|
||||
@reversion.register
|
||||
class Case(models.Model, AuthorMixin):
|
||||
class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
# class SubspecialtyChoices(models.TextChoices):
|
||||
# BREAST = "BR", _("Breast")
|
||||
# CARDIAC = "CA", _("Cardiac")
|
||||
@@ -312,20 +320,32 @@ class Case(models.Model, AuthorMixin):
|
||||
# findings = models.TextField(null=True, blank=True)
|
||||
|
||||
# subspecialty = models.CharField(max_length=2, choices=SubspecialtyChoices.choices)
|
||||
subspecialty = models.ManyToManyField(Subspecialty, blank=True)
|
||||
subspecialty = models.ManyToManyField(
|
||||
Subspecialty,
|
||||
blank=True,
|
||||
help_text="The subspecialties the case is associated with. Multiple subspecialties can be selected.",
|
||||
)
|
||||
|
||||
condition = models.ManyToManyField(Condition, blank=True)
|
||||
condition = models.ManyToManyField(
|
||||
Condition, blank=True, help_text="The condition(s) the case demonstrates."
|
||||
)
|
||||
|
||||
presentation = models.ManyToManyField(Presentation, blank=True)
|
||||
presentation = models.ManyToManyField(
|
||||
Presentation,
|
||||
blank=True,
|
||||
help_text="The presentation(s) the case is associated with.",
|
||||
)
|
||||
|
||||
pathological_process = models.ManyToManyField(PathologicalProcess, blank=True)
|
||||
|
||||
differential = models.ManyToManyField(
|
||||
Condition, through=Differential, related_name="casedifferential"
|
||||
Condition, through=Differential, related_name="casedifferential",
|
||||
help_text="The differential diagnosis for the case.",
|
||||
)
|
||||
|
||||
diagnostic_certainty = models.IntegerField(
|
||||
choices=CertaintyChoices.choices, default=CertaintyChoices.NONE
|
||||
choices=CertaintyChoices.choices, default=CertaintyChoices.NONE,
|
||||
help_text="The diagnostic certainty of the case.",
|
||||
)
|
||||
|
||||
verified = models.BooleanField(default=False)
|
||||
@@ -353,7 +373,9 @@ class Case(models.Model, AuthorMixin):
|
||||
help_text="If a case should be freely available to browse", default=True
|
||||
)
|
||||
|
||||
series = SortedManyToManyField("Series", related_name="case")
|
||||
series = models.ManyToManyField(
|
||||
"Series", through="SeriesDetail", related_name="case"
|
||||
)
|
||||
|
||||
notes = GenericRelation(QuestionNote)
|
||||
|
||||
@@ -363,8 +385,14 @@ class Case(models.Model, AuthorMixin):
|
||||
null=True,
|
||||
related_name="next_case",
|
||||
blank=True,
|
||||
help_text="If this case is related to another case on the system (e.g. follow up), link them here.",
|
||||
)
|
||||
|
||||
resource = models.ManyToManyField("Resource", through="CaseResource")
|
||||
|
||||
def get_app_name(self):
|
||||
return "atlas"
|
||||
|
||||
# question_file = models.FileField(upload_to=question_file_directory_path, blank=True, null=True)
|
||||
|
||||
def get_absolute_url(self):
|
||||
@@ -374,7 +402,14 @@ class Case(models.Model, AuthorMixin):
|
||||
# return f"{self.pk}: {self.title}"
|
||||
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), str(self))
|
||||
|
||||
# def get_base_template(self):
|
||||
|
||||
# def get_edit_template_links(self):
|
||||
|
||||
def __str__(self):
|
||||
if self.open_access:
|
||||
return f"{self.pk}: {self.title} [OA]"
|
||||
else:
|
||||
return f"{self.pk}: {self.title}"
|
||||
|
||||
def get_series_blocks(self):
|
||||
@@ -388,18 +423,22 @@ class Case(models.Model, AuthorMixin):
|
||||
examinations = [series.get_examination() for series in self.series.all()]
|
||||
return f"{self.pk}:{self.title} {'/'.join([str(c) for c in self.condition.all()])} [{', '.join(examinations)}]"
|
||||
|
||||
def get_case_dicom_json(self):
|
||||
def get_case_dicom_json(self, case_title_as_patient_name=True, priors=None):
|
||||
series_json = []
|
||||
for series in self.series.all():
|
||||
series_json.append(series.get_series_dicom_json())
|
||||
|
||||
return {
|
||||
patient_name = ""
|
||||
if case_title_as_patient_name:
|
||||
patient_name = self.title
|
||||
|
||||
studies_json = {
|
||||
"studies": [
|
||||
{
|
||||
"StudyInstanceUID": "1.3.6.1.4.1.14519.5.2.1.6279.6001.298806137288633453246975630178",
|
||||
"StudyDate": "20000101",
|
||||
#"StudyDate": "20000101",
|
||||
"StudyTime": "",
|
||||
"PatientName": "",
|
||||
"PatientName": patient_name,
|
||||
"PatientID": "LIDC-IDRI-0001",
|
||||
"AccessionNumber": "",
|
||||
"PatientAge": "",
|
||||
@@ -407,8 +446,54 @@ class Case(models.Model, AuthorMixin):
|
||||
"series": series_json,
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
pass
|
||||
|
||||
if priors is not None:
|
||||
for prior in priors:
|
||||
series_json = []
|
||||
for series in prior.prior_case.series.all():
|
||||
series_json.append(series.get_series_dicom_json())
|
||||
studies_json["studies"].append(
|
||||
{
|
||||
"StudyInstanceUID": "1.3.6.1.4.1.14519.5.2.1.6279.6001.298806137288633453246975630177",
|
||||
#"StudyDate": "20000101",
|
||||
"StudyTime": "",
|
||||
"PatientName": patient_name,
|
||||
"PatientID": "LIDC-IDRI-0001",
|
||||
"AccessionNumber": "",
|
||||
"PatientAge": "",
|
||||
"PatientSex": "",
|
||||
"series": series_json,
|
||||
"StudyDescription": f"Prior: {prior.relation_text}"
|
||||
}
|
||||
)
|
||||
|
||||
return studies_json
|
||||
|
||||
def get_viva_details(self):
|
||||
return {
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"history": self.history,
|
||||
"discussion": self.discussion,
|
||||
"report": self.report,
|
||||
}
|
||||
|
||||
def get_viva_details_json(self):
|
||||
return json.dumps(self.get_viva_details())
|
||||
|
||||
def get_all_prior_cases(self):
|
||||
prior_cases = []
|
||||
|
||||
while self.previous_case is not None:
|
||||
prior_cases.append(self.previous_case)
|
||||
self = self.previous_case
|
||||
|
||||
return prior_cases
|
||||
|
||||
def get_series(self):
|
||||
return self.series.all().prefetch_related("images", "examination", "plane")
|
||||
|
||||
|
||||
def extract_image_dicom_json_from_ds(ds, url, image_index):
|
||||
@@ -498,12 +583,13 @@ class SeriesFinding(models.Model):
|
||||
)
|
||||
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)
|
||||
|
||||
def __str__(self) -> str:
|
||||
findings = self.findings.all().values_list("name")
|
||||
return f"{self.series.id}/{findings}/{self.description}"
|
||||
return f"SeriesFinding: {self.series.id}/{findings}/{self.description}"
|
||||
|
||||
def annotation_as_string(self) -> str:
|
||||
return json.dumps(self.annotation_json)
|
||||
@@ -571,6 +657,7 @@ class Series(SeriesBase):
|
||||
"<a href='{}'>{}</a>", self.get_absolute_url(), self.get_full_str()
|
||||
)
|
||||
|
||||
# @lru_cache(maxsize=128)
|
||||
def get_series_dicom_json(self):
|
||||
instances = []
|
||||
|
||||
@@ -608,6 +695,46 @@ class Series(SeriesBase):
|
||||
|
||||
return series_json
|
||||
|
||||
def get_ohif_dicom_json(self):
|
||||
series_json = []
|
||||
series_json.append(self.get_series_dicom_json())
|
||||
|
||||
patient_name = ""
|
||||
|
||||
return {
|
||||
"studies": [
|
||||
{
|
||||
"StudyInstanceUID": "1.3.6.1.4.1.14519.5.2.1.6279.6001.298806137288633453246975630178",
|
||||
"StudyDate": "20000101",
|
||||
"StudyTime": "",
|
||||
"PatientName": patient_name,
|
||||
"PatientID": "LIDC-IDRI-0001",
|
||||
"AccessionNumber": "",
|
||||
"PatientAge": "",
|
||||
"PatientSex": "",
|
||||
"series": series_json,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def use_date_as_description(self):
|
||||
# get first image
|
||||
image = self.images.first()
|
||||
|
||||
with pydicom.dcmread(image.image) as ds:
|
||||
date = ds.get("StudyDate", "No date")
|
||||
|
||||
self.description = f"{date[:4]}-{date[4:6]}-{date[6:]}"
|
||||
|
||||
self.save()
|
||||
|
||||
def get_base_template(self):
|
||||
return "atlas/base.html"
|
||||
|
||||
def get_link_headers(self):
|
||||
return "atlas/series_headers.html"
|
||||
|
||||
|
||||
|
||||
class CaseCollection(ExamOrCollectionGenericBase):
|
||||
app_name = "atlas"
|
||||
@@ -653,6 +780,14 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
||||
related_name="casecollection_authored_cases",
|
||||
)
|
||||
|
||||
markers = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Authorised markers for the exam",
|
||||
related_name="casecollection_markers",
|
||||
)
|
||||
|
||||
valid_cid_users = models.ManyToManyField(
|
||||
CidUser, blank=True, related_name="casecollection_exams"
|
||||
)
|
||||
@@ -683,6 +818,11 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
||||
help_text="If true allows users self complete and review cases in a self directed way.",
|
||||
)
|
||||
|
||||
exam_user_status = GenericRelation(ExamUserStatus, related_query_name="atlas_exams")
|
||||
cid_user_exam = GenericRelation("generic.CidUserExam", related_query_name="atlas_exams")
|
||||
|
||||
feedback_once_collection_complete = models.BooleanField(default=True, help_text="If true feedback is only given once the collection is complete. If false feedback is given after each case.")
|
||||
|
||||
class COLLECTION_TYPE_CHOICES(models.TextChoices):
|
||||
REVIEW = (
|
||||
"REV",
|
||||
@@ -692,6 +832,14 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
||||
"REP",
|
||||
_("Report"),
|
||||
)
|
||||
QUESTION = (
|
||||
"QUE",
|
||||
_("Question"),
|
||||
)
|
||||
VIVA = (
|
||||
"VIV",
|
||||
_("Viva"),
|
||||
)
|
||||
|
||||
collection_type = models.CharField(
|
||||
max_length=3,
|
||||
@@ -699,6 +847,35 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
||||
default=COLLECTION_TYPE_CHOICES.REPORT,
|
||||
)
|
||||
|
||||
class VIEWER_MODE_CHOICES(models.TextChoices):
|
||||
BUILT_IN = (
|
||||
"BUILT_IN",
|
||||
_("Built in viewer"),
|
||||
)
|
||||
OHIF = (
|
||||
"OHIF",
|
||||
_("OHIF Viewer"),
|
||||
)
|
||||
BUILT_IN_WITH_OHIF = (
|
||||
"BUILT_IN_WITH_OHIF",
|
||||
_("Built in viewer with OHIF Viewer option"),
|
||||
)
|
||||
|
||||
viewer_mode = models.CharField(
|
||||
max_length=100,
|
||||
choices=VIEWER_MODE_CHOICES.choices,
|
||||
default=VIEWER_MODE_CHOICES.BUILT_IN,
|
||||
)
|
||||
|
||||
def get_app_name(self):
|
||||
return "atlas"
|
||||
|
||||
def get_base_template(self):
|
||||
return "atlas/base.html"
|
||||
|
||||
def get_link_headers(self):
|
||||
return f"{self.get_app_name()}/collection_headers.html"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("atlas:collection_detail", kwargs={"pk": self.pk})
|
||||
|
||||
@@ -708,6 +885,22 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
def show_built_in_viewer(self) -> bool:
|
||||
return self.viewer_mode in (
|
||||
self.VIEWER_MODE_CHOICES.BUILT_IN,
|
||||
self.VIEWER_MODE_CHOICES.BUILT_IN_WITH_OHIF,
|
||||
)
|
||||
|
||||
def show_ohif_viewer(self) -> bool:
|
||||
return self.viewer_mode in (
|
||||
self.VIEWER_MODE_CHOICES.OHIF,
|
||||
)
|
||||
|
||||
def show_ohif_viewer_link(self) -> bool:
|
||||
return self.viewer_mode in (
|
||||
self.VIEWER_MODE_CHOICES.BUILT_IN_WITH_OHIF,
|
||||
)
|
||||
|
||||
def review_only(self) -> bool:
|
||||
"""Returns True if a users cannot submit responses
|
||||
|
||||
@@ -719,14 +912,54 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
||||
|
||||
return False
|
||||
|
||||
def in_review_mode(self) -> bool:
|
||||
return self.review_only() or self.publish_results
|
||||
|
||||
# def can_answer(self) -> bool:
|
||||
# """Returns true if the user is able to answer questions in a collection
|
||||
# (independent of current collection state (e.g. published / not published))"""
|
||||
# if self.collection_type == self.COLLECTION_TYPE_CHOICES.REPORT:
|
||||
# return True
|
||||
|
||||
# return False
|
||||
|
||||
def get_cases(self):
|
||||
"""Returns the cases in the collection in order of the CaseDetail sort_order"""
|
||||
return self.cases.all().order_by("casedetail__sort_order")
|
||||
|
||||
def get_next_case(self, case):
|
||||
cases = list(self.get_cases())
|
||||
|
||||
try:
|
||||
return cases[cases.index(case) + 1]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
def get_previous_case(self, case):
|
||||
cases = list(self.get_cases())
|
||||
|
||||
new_index = cases.index(case) - 1
|
||||
|
||||
if new_index < 0:
|
||||
return None
|
||||
else:
|
||||
return cases[new_index]
|
||||
|
||||
|
||||
def get_index_of_case(self, case, case_count=False):
|
||||
cases = list(self.get_cases())
|
||||
|
||||
if case_count:
|
||||
return cases.index(case), len(cases)
|
||||
else:
|
||||
return cases.index(case)
|
||||
|
||||
def get_case_by_index(
|
||||
self, case_index: int, case_count=False
|
||||
) -> Case | Tuple[Case, int]:
|
||||
# Seems to be a bug when case_number is 0 or 1 the same (first) object gets returned
|
||||
# forces a list fixes (but is likely inefficient)
|
||||
cases = list(
|
||||
self.cases.all().order_by("casedetail__sort_order").prefetch_related()
|
||||
)
|
||||
cases = list(self.get_cases().prefetch_related())
|
||||
|
||||
try:
|
||||
case = cases[case_index]
|
||||
@@ -740,31 +973,118 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
||||
return case
|
||||
|
||||
def get_case_take_url(self, case):
|
||||
cases = list(
|
||||
self.cases.all().order_by("casedetail__sort_order").prefetch_related()
|
||||
)
|
||||
cases = list(self.get_cases().prefetch_related())
|
||||
|
||||
return reverse(
|
||||
"atlas:collection_case_view_take_user",
|
||||
kwargs={"pk": self.pk, "case_number": cases.index(case)},
|
||||
)
|
||||
|
||||
def get_ohif_dicom_json(self, case_title_as_patient_name=True):
|
||||
studies = []
|
||||
for n, case in enumerate(self.cases.all()):
|
||||
series_json = []
|
||||
for series in case.series.all():
|
||||
series_json.append(series.get_series_dicom_json())
|
||||
|
||||
patient_name = ""
|
||||
if case_title_as_patient_name:
|
||||
patient_name = case.title
|
||||
|
||||
studies.append(
|
||||
{
|
||||
"StudyInstanceUID": f"1.3.6.1.4.1.14519.5.2.1.6279.6001.{n}",
|
||||
"StudyDate": "20000101",
|
||||
"StudyTime": "",
|
||||
"PatientName": patient_name,
|
||||
"PatientID": f"ID{n}",
|
||||
"AccessionNumber": "",
|
||||
"PatientAge": "",
|
||||
"PatientSex": "",
|
||||
"series": series_json,
|
||||
}
|
||||
)
|
||||
|
||||
return {"studies": studies}
|
||||
|
||||
def add_case(self, case):
|
||||
"""Adds a case to the collection and makes sure order is maintained"""
|
||||
# We might be better off adding via the case detail model direct
|
||||
# CaseDetail.objects.create(case=case, collection=self)
|
||||
self.cases.add(case)
|
||||
self.order_cases()
|
||||
|
||||
def order_cases(self):
|
||||
"""Modifies the casedetail sort_order to sequentially order the cases"""
|
||||
for n, c in enumerate(self.casedetail_set.all().order_by("sort_order")):
|
||||
c.sort_order = n
|
||||
c.save()
|
||||
|
||||
|
||||
class SeriesDetail(models.Model):
|
||||
case = models.ForeignKey(Case, on_delete=models.CASCADE)
|
||||
series = models.ForeignKey(Series, on_delete=models.CASCADE)
|
||||
|
||||
sort_order = models.IntegerField(default=1000)
|
||||
|
||||
feedback = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Set to true if the series should only be shown for feedback purposes.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ("sort_order",)
|
||||
|
||||
|
||||
class CaseDetail(models.Model):
|
||||
"""A through table that stores the relationship between a case and a collection.
|
||||
|
||||
This is what user answers are linked to (as there be different questions for the same case in a different collection)
|
||||
|
||||
The collection questions and answers are also defined here.
|
||||
"""
|
||||
case = models.ForeignKey(Case, on_delete=models.CASCADE)
|
||||
collection = models.ForeignKey(CaseCollection, on_delete=models.CASCADE)
|
||||
|
||||
question_schema = models.JSONField(null=True, blank=True)
|
||||
question_answers = models.JSONField(null=True, blank=True)
|
||||
# TODO add feedback for questions
|
||||
#question_feedback = models.JSONField(null=True, blank=True)
|
||||
|
||||
sort_order = models.IntegerField(default=1000)
|
||||
|
||||
class Meta:
|
||||
ordering = ("sort_order",)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.case} -> {self.collection}"
|
||||
|
||||
def get_question_schema(self):
|
||||
return json.dumps(self.question_schema)
|
||||
|
||||
def get_question_answers(self):
|
||||
"""Returns the correct question answers as a json string"""
|
||||
return json.dumps(self.question_answers)
|
||||
|
||||
class CasePrior(models.Model):
|
||||
case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
|
||||
prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case")
|
||||
|
||||
relation_text = models.CharField(max_length=255, blank=True, help_text="Text to describe the relationship between the cases")
|
||||
|
||||
class Meta:
|
||||
unique_together = ("case_detail", "prior_case")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.case_detail.case} -> {self.prior_case}"
|
||||
|
||||
class BaseReportAnswer(models.Model):
|
||||
question = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
|
||||
|
||||
answer = models.TextField(blank=True)
|
||||
|
||||
json_answer = models.JSONField(null=True, blank=True)
|
||||
|
||||
feedback = models.TextField(blank=True)
|
||||
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
@@ -774,6 +1094,8 @@ class BaseReportAnswer(models.Model):
|
||||
blank=True, null=True, validators=[MaxValueValidator(10), MinValueValidator(0)]
|
||||
)
|
||||
|
||||
completed = models.BooleanField(default=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean()
|
||||
return super(BaseReportAnswer, self).save(*args, **kwargs)
|
||||
@@ -793,6 +1115,45 @@ class BaseReportAnswer(models.Model):
|
||||
else:
|
||||
raise ValueError("No cid or user specified")
|
||||
|
||||
def get_correct_json_answers(self):
|
||||
if self.question.question_schema is None or not "properties" in self.question.question_schema:
|
||||
return []
|
||||
answers = []
|
||||
print("1", self.json_answer)
|
||||
print("2", self.question.question_answers)
|
||||
for name, value in self.question.question_schema["properties"].items():
|
||||
print(name, value)
|
||||
|
||||
try:
|
||||
user_answer = self.json_answer[name]
|
||||
except KeyError: # If the schema has changed?
|
||||
user_answer = ""
|
||||
try:
|
||||
correct_answer = self.question.question_answers[name]
|
||||
except KeyError: # If the schema has changed?
|
||||
correct_answer = ""
|
||||
|
||||
print(correct_answer)
|
||||
|
||||
match value:
|
||||
case {"type": "string", "enum": _}:
|
||||
print("YES", value)
|
||||
automark = True
|
||||
answer_is_correct = user_answer == correct_answer
|
||||
case {"type": "string"}:
|
||||
if user_answer == correct_answer:
|
||||
automark = True
|
||||
answer_is_correct = True
|
||||
else:
|
||||
automark = False
|
||||
answer_is_correct = False
|
||||
case _:
|
||||
automark = False
|
||||
answer_is_correct = user_answer == correct_answer
|
||||
answers.append((value, user_answer, correct_answer, answer_is_correct, automark))
|
||||
|
||||
return answers
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -892,7 +1253,9 @@ class UncategorisedDicom(models.Model):
|
||||
def check_for_duplicates(self, image_hash):
|
||||
duplicate = None
|
||||
|
||||
if obj := UncategorisedDicom.objects.filter(image_blake3_hash=image_hash).first():
|
||||
if obj := UncategorisedDicom.objects.filter(
|
||||
image_blake3_hash=image_hash
|
||||
).first():
|
||||
# duplicate = obj
|
||||
return obj
|
||||
|
||||
@@ -912,7 +1275,9 @@ class UncategorisedDicom(models.Model):
|
||||
|
||||
# if self.check_for_duplicates():
|
||||
# return DuplicateDicom
|
||||
image_blake3_hash = get_image_dicom_hash(None, dataset=ds, hash_type="blake3", direct_pixel_data=True)
|
||||
image_blake3_hash = get_image_dicom_hash(
|
||||
None, dataset=ds, hash_type="blake3", direct_pixel_data=True
|
||||
)
|
||||
|
||||
self.image_blake3_hash = image_blake3_hash
|
||||
|
||||
@@ -964,3 +1329,80 @@ class UncategorisedDicom(models.Model):
|
||||
|
||||
class DuplicateDicom(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Resource(models.Model, AuthorMixin):
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
url = models.URLField(blank=True, null=True)
|
||||
file = models.FileField(upload_to="atlas/resources/", blank=True, null=True)
|
||||
author = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
help_text="Author of the resource",
|
||||
related_name="resources",
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("atlas:resource_detail", kwargs={"pk": self.pk})
|
||||
|
||||
def get_link(self):
|
||||
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
|
||||
|
||||
def get_display(self) -> str:
|
||||
if self.url:
|
||||
html = f"<a target='_blank' href='{self.url}'>{self.name}</a>"
|
||||
elif self.file:
|
||||
filetype, _ = mimetypes.guess_type(self.file.url)
|
||||
|
||||
match filetype.split("/"):
|
||||
case "image", _:
|
||||
html = f"<img src='{self.file.url}'>"
|
||||
case "video", _:
|
||||
html = f"<video controls><source src='{self.file.url}'></video>"
|
||||
|
||||
case _:
|
||||
html = f"<a target='_blank' href='{self.file.url}'>{self.name}</a>"
|
||||
else:
|
||||
html = self.name
|
||||
|
||||
return format_html("<details class='resource-block'><summary>{}</summary>{}</details>", self.name, mark_safe(html))
|
||||
|
||||
|
||||
|
||||
class CaseResource(models.Model):
|
||||
resource = models.ForeignKey(Resource, on_delete=models.CASCADE)
|
||||
case = models.ForeignKey(Case, on_delete=models.CASCADE)
|
||||
|
||||
pre_review = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Defines if the resource should be shown pre review (i.e. for pre reading before the case is taken)",
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.resource} - {self.case} (Pre: {self.pre_review})"
|
||||
|
||||
|
||||
class QuestionSchema(models.Model, AuthorMixin):
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
schema = models.JSONField()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("atlas:question_schema_detail", kwargs={"pk": self.pk})
|
||||
|
||||
def get_example_form(self):
|
||||
from .forms import JsonAnswerForm
|
||||
example_form = JsonAnswerForm(
|
||||
question_schema=self.schema
|
||||
)
|
||||
return example_form
|
||||
|
||||
def get_schema_as_json(self) -> str:
|
||||
return json.dumps(self.schema)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "{}".format(self.name)
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"disable_edit_json": true,
|
||||
"disable_collapse": true,
|
||||
"disable_properties": true
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"title": "Report",
|
||||
"description": "Report text",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"report"
|
||||
],
|
||||
"properties": {
|
||||
"report": {
|
||||
"type": "string",
|
||||
"title": "Report"
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -5,9 +5,11 @@ from generic.tables import SeriesImageColumn
|
||||
|
||||
from .models import (
|
||||
Case,
|
||||
CaseCollection,
|
||||
Condition,
|
||||
PathologicalProcess,
|
||||
Presentation,
|
||||
QuestionSchema,
|
||||
Series,
|
||||
Structure,
|
||||
Finding,
|
||||
@@ -318,7 +320,7 @@ class CaseCollectionTable(tables.Table):
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
class Meta:
|
||||
model = Case
|
||||
model = CaseCollection
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = (
|
||||
"name",
|
||||
@@ -337,3 +339,24 @@ class CaseCollectionTable(tables.Table):
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
class QuestionSchemaTable(tables.Table):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:question_schema_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="QuestionSchema",
|
||||
)
|
||||
|
||||
edit = tables.LinkColumn(
|
||||
"atlas:question_schema_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
delete = tables.LinkColumn(
|
||||
"atlas:question_schema_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = QuestionSchema
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "description", "schema")
|
||||
|
||||
def render_schema(self, value, record):
|
||||
return format_html("<details><summary>Schema</summary>{}</details>", value)
|
||||
@@ -0,0 +1,13 @@
|
||||
<span id="add-case-form">
|
||||
<form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<label for="case">Select Case to add:</label>
|
||||
<select name="case" id="case">
|
||||
{% for case in cases %}
|
||||
<option value="{{ case.id }}">{{ case.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Add</button>
|
||||
<button _="on click remove #add-case-form">Cancel</button>
|
||||
</form>
|
||||
</span
|
||||
@@ -11,8 +11,13 @@
|
||||
<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: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>
|
||||
{% endif %}
|
||||
{% comment %} </br>
|
||||
Questions by:
|
||||
|
||||
@@ -3,32 +3,7 @@
|
||||
{% block content %}
|
||||
|
||||
|
||||
|
||||
<div class="floating-header">
|
||||
<a href="{% url 'atlas:case_update' pk=case.pk %}" title="Edit the Case">Edit</a>
|
||||
<a href="{% url 'atlas:case_clone' pk=case.pk %}"
|
||||
title="Clone the Case (duplicate everything but the images)">Clone</a>
|
||||
<a href="{% url 'atlas:case_delete' pk=case.pk %}" title="Delete the Case">Delete</a>
|
||||
<a href="#"
|
||||
onclick="return window.create_popup_window('{% url 'feedback_create' question_type='atlas' pk=case.pk %}')"> Add
|
||||
Note</a>
|
||||
{% if request.user.is_superuser %}
|
||||
<a href="{% url 'admin:atlas_case_change' case.id %}" title="Edit the Case using the admin interface">Admin Edit</a>
|
||||
{% endif %}
|
||||
|
||||
{% if collection %}
|
||||
<div>
|
||||
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_view' collection.id case_number|add:-1 %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'atlas:collection_case_view' collection.id case_number|add:1 %}">Next question</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'atlas/question_link_header.html' %}
|
||||
|
||||
|
||||
{% include 'atlas/case_display_block.html' %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="atlas {% if case.scrapped %}atlas-scrapped{% endif %}">
|
||||
<div><a href="https://viewer.penracourses.org.uk/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">View case in OHIF</a></div>
|
||||
<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>
|
||||
|
||||
<div class="date">
|
||||
{{ case.created_date|date:"d/m/Y" }}
|
||||
@@ -25,8 +25,18 @@
|
||||
<p class="pre-whitespace"><b>Discussion:</b> {{ case.discussion }}</p>
|
||||
|
||||
<p class="pre-whitespace"><b>Report:</b> {{ case.report }}</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 %}
|
||||
<span title="This resource will be avalible to review when or taking a case">(pre)</span>
|
||||
{% else %}
|
||||
<span title="This resource will only be available when reviewing a completed case">(post)</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
|
||||
<div class="pre-whitespace multi-image-block"><b>Series:</b>
|
||||
<form>
|
||||
{% for series in case.series.all %}
|
||||
<span class="series-block">
|
||||
<span>
|
||||
@@ -35,6 +45,7 @@
|
||||
{{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>
|
||||
@@ -42,6 +53,7 @@
|
||||
</span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</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 />
|
||||
@@ -50,9 +62,31 @@
|
||||
<button hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
|
||||
title="order dicom by slice location"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML">
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="This will reorder all case series based upon slice location"
|
||||
>
|
||||
Order all series dicoms by slice location
|
||||
</button>
|
||||
<button hx-post="{% url 'atlas:combine_series' %}"
|
||||
title="merge series"
|
||||
hx-include="[name='series-ids']"
|
||||
hx-target="#series-merge-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="This will merge all selected series into one"
|
||||
>
|
||||
Merge selected series
|
||||
</button>
|
||||
<button hx-post="{% url 'atlas:use_dates_as_descriptions' case.pk %}"
|
||||
title="merge series"
|
||||
hx-include="[name='series-ids']"
|
||||
hx-target="#series-merge-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="This will use the dicom date as the series description"
|
||||
>
|
||||
Use dates as description
|
||||
</button>
|
||||
<br/>
|
||||
<span id="series-merge-results"></span>
|
||||
</details>
|
||||
|
||||
</span>
|
||||
|
||||
@@ -29,19 +29,25 @@
|
||||
|
||||
function add_series_input_form() {
|
||||
var form_idx = $('#id_Case_series-TOTAL_FORMS').val();
|
||||
$('#series_formset').append($('#empty_series_form').html().replace(/__prefix__/g, form_idx));
|
||||
$('#series_formset ol').append($('#empty_series_form').html().replace(/__prefix__/g, form_idx));
|
||||
$('#id_Case_series-TOTAL_FORMS').val(parseInt(form_idx) + 1);
|
||||
}
|
||||
|
||||
function add_differential_input_form() {
|
||||
var form_idx = $('#id_differentialcase-TOTAL_FORMS').val();
|
||||
$('#casedifferential_formset').append($('#empty_casedifferential_form').html().replace(/__prefix__/g, form_idx));
|
||||
$('#casedifferential_formset ol').append($('#empty_casedifferential_form').html().replace(/__prefix__/g, form_idx));
|
||||
$('#id_differentialcase-TOTAL_FORMS').val(parseInt(form_idx) + 1);
|
||||
}
|
||||
function add_resource_input_form() {
|
||||
var form_idx = $('#id_resourcecase-TOTAL_FORMS').val();
|
||||
$('#caseresource_formset ol').append($('#empty_caseresource_form').html().replace(/__prefix__/g, form_idx));
|
||||
$('#id_resourcecase-TOTAL_FORMS').val(parseInt(form_idx) + 1);
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
$('#add_more_series').click(() => { add_series_input_form() });
|
||||
$('#add_more_differential').click(() => { add_differential_input_form() });
|
||||
$('#add_more_resource').click(() => { add_resource_input_form() });
|
||||
})
|
||||
|
||||
</script>
|
||||
@@ -49,54 +55,87 @@
|
||||
<!-- {{ form.media }} -->
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% if object %}
|
||||
{% include "atlas/question_link_header.html" %}
|
||||
{% endif %}
|
||||
|
||||
<h2>Submit Case</h2>
|
||||
Use this form to create a atlas case. Existing associated image sets can be added using this form.
|
||||
<details>
|
||||
<summary>Instructions</summary>
|
||||
<p>Use this form to create a atlas case</p>
|
||||
<p>Existing associated image sets can be added using this form.</p>
|
||||
<p>The more information you provide, the more useful the case will be to others.</p>
|
||||
</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.exam.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 action="" method="post" enctype="multipart/form-data" id="atlas-form">
|
||||
|
||||
<form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<table>
|
||||
{{ form.as_table }}
|
||||
</table>
|
||||
{{ 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">
|
||||
<div id="series_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in series_formset %}
|
||||
<ul class="no-error series-formset">
|
||||
<li class="no-error series-formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form.as_ul }}
|
||||
</ul>
|
||||
{{ form }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{{ series_formset.management_form }}
|
||||
<h3>Differential:</h3>
|
||||
Add differential here.
|
||||
<input type="button" value="Add Another Differential" id="add_more_differential">
|
||||
<div id="casedifferential_formset">
|
||||
<div id="casedifferential_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in casedifferential_formset %}
|
||||
<ul class="no-error casedifferential_formset">
|
||||
<li class="no-error casedifferential_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form.as_ul }}
|
||||
</ul>
|
||||
{{ form }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{{ casedifferential_formset.management_form }}
|
||||
<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 %}
|
||||
</ol>
|
||||
</div>
|
||||
{{ caseresource_formset.management_form }}
|
||||
<br/>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
<div id="empty_series_form" style="display:none">
|
||||
<ul class='no_error series-formset'>
|
||||
{{ series_formset.empty_form.as_ul }}
|
||||
</ul>
|
||||
<li class='no_error series-formset'>
|
||||
{{ series_formset.empty_form }}
|
||||
</li>
|
||||
</div>
|
||||
<div id="empty_casedifferential_form" style="display:none">
|
||||
<ul class='no_error casedifferential_formset'>
|
||||
{{ casedifferential_formset.empty_form.as_ul }}
|
||||
</ul>
|
||||
<li class='no_error casedifferential_formset'>
|
||||
{{ casedifferential_formset.empty_form }}
|
||||
</li>
|
||||
</div>
|
||||
<div id="empty_caseresource_form" style="display:none">
|
||||
<li class='no_error caseresource_formset'>
|
||||
{{ caseresource_formset.empty_form }}
|
||||
</li>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% extends "atlas/base.html" %}
|
||||
{% extends "atlas/exams.html" %}
|
||||
<!-- {% load static from static %} -->
|
||||
|
||||
{% block css %}
|
||||
@@ -10,7 +10,7 @@
|
||||
<script type="text/javascript">
|
||||
function add_case_input_form() {
|
||||
var form_idx = $('#id_casedetail_set-TOTAL_FORMS').val();
|
||||
$('#case_formset').append($('#empty_case_form').html().replace(/__prefix__/g, form_idx));
|
||||
$('#case_formset ol').append($('#empty_case_form').html().replace(/__prefix__/g, form_idx));
|
||||
$('#id_casedetail_set-TOTAL_FORMS').val(parseInt(form_idx) + 1);
|
||||
}
|
||||
|
||||
@@ -23,34 +23,43 @@
|
||||
<!-- {{ form.media }} -->
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if collection %}
|
||||
<h2>Update Collection</h2>
|
||||
|
||||
{% else %}
|
||||
<h2>Create Collection</h2>
|
||||
Use this form to create a collection of cases
|
||||
<form action="" method="post" enctype="multipart/form-data" id="atlas-form">
|
||||
|
||||
{% endif %}
|
||||
|
||||
<form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<table>
|
||||
{{ form.as_table }}
|
||||
</table>
|
||||
{{ form }}
|
||||
<h3>Cases:</h3>
|
||||
Add cases here. These can only be added once created (they can also be added to cases on creation). Click and drag to change order.
|
||||
<input type="button" value="Add More Cases" id="add_more_case">
|
||||
<input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" />
|
||||
<div id="case_formset" class="sortable">
|
||||
<div id="case_formset" class="sortable list_formset">
|
||||
<ol>
|
||||
{% for form in case_formset %}
|
||||
<ul class="no-error case-formset">
|
||||
<li class="no-error case-formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form.as_ul }}
|
||||
</ul>
|
||||
{{ form }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
</div>
|
||||
{{ case_formset.management_form }}
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
<div id="empty_case_form" style="display:none">
|
||||
<ul class='no_error case-formset'>
|
||||
{{ case_formset.empty_form.as_ul }}
|
||||
</ul>
|
||||
<li class='no_error case-formset'>
|
||||
{{ case_formset.empty_form }}
|
||||
</li>
|
||||
</div>
|
||||
<script>
|
||||
$(document).ready(() => {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
{% extends 'atlas/exams.html' %}
|
||||
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div>
|
||||
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id previous.id %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<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>
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
|
||||
Import schema from:
|
||||
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
|
||||
hx-get="{% url 'atlas:collection_question_schemas' case_detail.collection.id %}"
|
||||
hx-target=".offcanvas-body"
|
||||
>Collection questions</button>
|
||||
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
|
||||
hx-get="{% url 'atlas:question_schema_schemas' %}"
|
||||
hx-target=".offcanvas-body"
|
||||
>Preset questions</button>
|
||||
|
||||
<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvasRight" aria-labelledby="offcanvasRightLabel"
|
||||
>
|
||||
<div class="offcanvas-header">
|
||||
<h5 id="offcanvasRightLabel">Question schemas</h5>
|
||||
<button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit" value="save" name="submit">Submit</button>
|
||||
</form>
|
||||
|
||||
{% comment %} <h2>Blank form</h2>
|
||||
<form>
|
||||
{{ blank_form}}
|
||||
</form> {% endcomment %}
|
||||
|
||||
|
||||
{% if case_detail.question_schema %}
|
||||
<h2>Question display</h2>
|
||||
This shows the form as it will be displayed (with the correct answers). If you wish to edit a correct answer you can change it below.
|
||||
|
||||
<form method="POST" class="post-form">
|
||||
{% csrf_token %}
|
||||
{{example_form}}
|
||||
<button type="submit" value="answer" name="submit">Save Correct Answers</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
{% comment %} {{ example_form.media }} {% endcomment %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script>
|
||||
<script src="/static/django_jsonforms/jsoneditor_init.js"></script>
|
||||
<script>
|
||||
document.addEventListener('htmx:afterRequest', function(evt) {
|
||||
// Put the JS code that you want to execute here
|
||||
console.log($('.use-layout').each((n, el) => {
|
||||
el.addEventListener('click', (e) => {
|
||||
console.log(e.target.parentElement.querySelector('.schema').dataset.schema);
|
||||
window.jsoneditor_id_question_schema.set(
|
||||
{
|
||||
json: JSON.parse(e.target.parentElement.querySelector('.schema').dataset.schema)
|
||||
}
|
||||
)
|
||||
});
|
||||
}))
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,101 @@
|
||||
{% extends 'atlas/exams.html' %}
|
||||
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div>
|
||||
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id previous.id %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<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>
|
||||
|
||||
<p>This page allows you to add series from prior cases to a case in a collection. In order to be available here the cases must be linked on the system.</p>
|
||||
<p>It is necessary to give a description of how the cases are related that will be displayed to the user when viewing the case.</p>
|
||||
|
||||
<h3>Available priors</h3>
|
||||
|
||||
<ul>
|
||||
{% for case, added, relation in available_priors %}
|
||||
|
||||
<li class='
|
||||
{% if added %}
|
||||
added
|
||||
{% else %}
|
||||
not-added
|
||||
{% endif %}
|
||||
' data-case={{case.id}}>
|
||||
{{case.title}} / {{added}} / {{relation}} / <a href="{% url 'atlas:case_detail' case.id %}">View case</a>
|
||||
<br/>{{case.get_series_blocks|safe}}
|
||||
|
||||
<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"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="prior_case_id" value="{{case.id}}" />
|
||||
<input type="text" name="relation" value="{{relation}}" placeholder="relation to case" />
|
||||
<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"
|
||||
>
|
||||
Case available: {{relation}}
|
||||
<input type="hidden" name="remove" value="{{case.id}}" />
|
||||
<button class="btn primary">Remove from case</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<span id="htmx-response-{{case.id}}"></span>
|
||||
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
{% comment %} <form method="POST">
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<button type="submit" value="save" name="submit">Submit</button>
|
||||
</form> {% endcomment %}
|
||||
|
||||
{% comment %} <h2>Blank form</h2>
|
||||
<form>
|
||||
{{ blank_form}}
|
||||
</form> {% endcomment %}
|
||||
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.added {
|
||||
border: 1px solid green;
|
||||
}
|
||||
|
||||
.not-added {
|
||||
border: 1px solid white;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
|
||||
{% endblock %}
|
||||
@@ -9,13 +9,32 @@
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if completed %}
|
||||
{% if question_completed %}
|
||||
<span class="stamp-white">REVIEW</span>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</h2>
|
||||
|
||||
{% comment %} <details>
|
||||
<summary class="opacity-50">Help <i class="bi bi-info-circle"></i></summary>
|
||||
</details> {% endcomment %}
|
||||
|
||||
|
||||
{% if not question_completed %}
|
||||
{% if resources %}
|
||||
<h5>Resources</h4>
|
||||
<ul class="no-list-style">
|
||||
{% for caseresource in resources %}
|
||||
<li>
|
||||
{{caseresource.resource.get_display}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if show_description and case.description %}
|
||||
<div>
|
||||
Description: {{case.description}}
|
||||
@@ -28,18 +47,40 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if collection.show_ohif_viewer %}
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if collection.show_ohif_viewer_link %}
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if collection.show_built_in_viewer %}
|
||||
<div class="pre-whitespace multi-image-block">
|
||||
<details open>
|
||||
<summary>
|
||||
Images
|
||||
</summary>
|
||||
{% for series in series_list %}
|
||||
<span class="series-block">
|
||||
{% for series, prior, relation in series_to_load %}
|
||||
<span class="series-block
|
||||
{% if prior %}
|
||||
prior
|
||||
{% endif %}
|
||||
">
|
||||
<a href="#" onclick='window.loadDicomViewer(window.images[{{forloop.counter0}}])'>
|
||||
<span>
|
||||
<span class="series-block-series-number">Series {{ forloop.counter }}:</span>
|
||||
{{series.get_block}}
|
||||
</span>
|
||||
{% if prior %}
|
||||
<br/><span class="relation">Prior: {{relation}}</span>
|
||||
{% endif %}
|
||||
|
||||
</a>
|
||||
</span>
|
||||
{% endfor %}
|
||||
@@ -51,6 +92,9 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% else %}
|
||||
{% endif %}
|
||||
|
||||
{% if show_discussion and case.discussion %}
|
||||
<p>
|
||||
<details>
|
||||
@@ -76,21 +120,59 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if collection.publish_results or cid_user_exam.completed %}
|
||||
<a href="{% url 'atlas:add_self_review' cid_user_exam.id case.id %}"><button>Add self review</button></a>
|
||||
{% if question_completed %}
|
||||
{% if resources %}
|
||||
<h5>Resources</h4>
|
||||
<ul class="no-list-style">
|
||||
{% for caseresource in resources %}
|
||||
<li>
|
||||
{{caseresource.resource.get_display}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endif %}
|
||||
<form method="POST" class="post-form">{% csrf_token %}
|
||||
{% if collection.collection_type == "REP" %}
|
||||
<div class="form-contents">
|
||||
<fieldset {% if completed %}disabled="disabled"{% endif %}>
|
||||
{{form}}
|
||||
</fieldset>
|
||||
</div>
|
||||
<p>
|
||||
{% if completed %}
|
||||
<form method="POST" class="post-form{% if question_completed %} completed{% endif %}">{% csrf_token %}
|
||||
{% if collection.collection_type == "QUE" %}
|
||||
{% if question_completed %}
|
||||
<h3>Answers</h3>
|
||||
|
||||
<div class="answer-block">
|
||||
{% for value, user_answer, correct_answer, answer_is_correct, automark in answer.get_correct_json_answers %}
|
||||
<div class="{% if answer_is_correct %}
|
||||
correct
|
||||
{% else %}
|
||||
incorrect
|
||||
{% endif %}
|
||||
{% if automark %}
|
||||
automark
|
||||
{% endif %}
|
||||
|
||||
">
|
||||
<h4>{{value.title}}</h4>
|
||||
{{value.description}}
|
||||
<div
|
||||
>
|
||||
Answer : {{user_answer}}
|
||||
|
||||
{% if not answer_is_correct %}
|
||||
<br/>Correct answer: {{correct_answer}}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
<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>
|
||||
</p>
|
||||
{% if self_review %}
|
||||
<h4>Self Feedback</h4>
|
||||
|
||||
@@ -104,9 +186,54 @@
|
||||
Answer feedback: {{answer.feedback|safe}}
|
||||
<br/>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<details><summary class="opacity-50">View questions</summary>
|
||||
{{form.json.errors}}
|
||||
<div class="form-contents">
|
||||
<fieldset {% if question_completed %}disabled="disabled"{% endif %}>
|
||||
{{form}}
|
||||
</fieldset>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% else %}
|
||||
|
||||
{{form.json.errors}}
|
||||
<div class="form-contents">
|
||||
<fieldset {% if question_completed %}disabled="disabled"{% endif %}>
|
||||
{{form}}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% elif collection.collection_type == "REP" %}
|
||||
{{form.json.errors}}
|
||||
<div class="form-contents">
|
||||
<fieldset {% if question_completed %}disabled="disabled"{% endif %}>
|
||||
{{form}}
|
||||
</fieldset>
|
||||
</div>
|
||||
<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>
|
||||
</p>
|
||||
{% if self_review %}
|
||||
<h4>Self Feedback</h4>
|
||||
|
||||
{% for review in self_review %}
|
||||
{{review.get_display_block}}
|
||||
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h4>Answer score: {{answer.score}}</h4>
|
||||
Answer feedback: {{answer.feedback|safe}}
|
||||
<br/>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
{% if previous %}
|
||||
@@ -120,6 +247,11 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if collection.self_review and not collection.feedback_once_collection_complete and not question_completed %}
|
||||
<button type="submit" name="complete_case" class="save btn btn-default">Finish Case</button>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<br />
|
||||
<button type="submit" name="finish" class="save btn btn-default">Overview</button>
|
||||
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
|
||||
@@ -142,18 +274,73 @@
|
||||
background-color: darkblue;
|
||||
}
|
||||
|
||||
.series-block.prior {
|
||||
border: darkgray solid 1px;
|
||||
}
|
||||
|
||||
fieldset:disabled textarea {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
form.completed {
|
||||
}
|
||||
|
||||
.correct {
|
||||
}
|
||||
|
||||
.correct h4::after {
|
||||
content: '✓';
|
||||
color: green;
|
||||
}
|
||||
|
||||
.incorrect h4::after {
|
||||
content: '✗';
|
||||
color: orange;
|
||||
}
|
||||
|
||||
.incorrect.automark h4::after {
|
||||
content: '✗';
|
||||
color: red;
|
||||
}
|
||||
|
||||
.answer-block>div {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.relation {
|
||||
opacity: 0.5;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.viewer {
|
||||
background-color: darkblue;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.viewer:hover {
|
||||
opacity: 1;
|
||||
background-color: darkblue;
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
{{ form.media }}
|
||||
{% comment %} <script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %}
|
||||
<script type="text/javascript">
|
||||
window.images = {
|
||||
{% for series in series_list %}
|
||||
{% comment %} {% for series in series_list %}
|
||||
{{ forloop.counter0 }}: ["{{ series.get_image_url_array_not_json }}"],
|
||||
{% endfor %} {% endcomment %}
|
||||
{% for series, prior, relation in series_to_load %}
|
||||
{{ forloop.counter0 }}: ["{{ series.get_image_url_array_not_json }}"],
|
||||
{% endfor %}
|
||||
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
@@ -161,6 +348,92 @@
|
||||
window.loadDicomViewer(window.images[0])
|
||||
}, 500);
|
||||
})
|
||||
|
||||
{% comment %} $('document').ready(function() {
|
||||
|
||||
// Get value from either a json string or url pointing to a json file
|
||||
function process(value) {
|
||||
var isjson=true;
|
||||
var result;
|
||||
|
||||
try {
|
||||
result = JSON.parse(value);
|
||||
} catch(e) {
|
||||
isjson=false;
|
||||
}
|
||||
|
||||
if (isjson) {
|
||||
return result;
|
||||
} else {
|
||||
return $.getJSON(value)
|
||||
.then(function (response) {
|
||||
return response;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$('.editor_holder').each(function() {
|
||||
// Get the DOM Element
|
||||
var element = $(this).get(0);
|
||||
console.log("el", element)
|
||||
|
||||
var options_text = $(this).attr('options')
|
||||
var schema_text = $(this).attr('schema')
|
||||
|
||||
var schema = process(schema_text);
|
||||
var options = process(options_text);
|
||||
|
||||
var name = $(this).attr('name');
|
||||
var hidden_identifier = 'input[name=' + name + ']';
|
||||
var initial = $(hidden_identifier).val();
|
||||
|
||||
// Check if editor is within form
|
||||
var form = $(this).closest('form')
|
||||
console.log("form", form)
|
||||
|
||||
//Wait for any ajax requests to complete
|
||||
$.when(schema, options).done(function(schemaresult, optionsresult) {
|
||||
optionsresult.form_name_root = name;
|
||||
|
||||
// Pass initial value though to editor
|
||||
if (initial) {
|
||||
optionsresult.startval = JSON.parse(initial);
|
||||
}
|
||||
|
||||
optionsresult.schema = schemaresult;
|
||||
// console.log(options);
|
||||
var editor = new JSONEditor(element, optionsresult);
|
||||
|
||||
console.log("editor", editor)
|
||||
if (form) {
|
||||
$(form).submit(function(e) {
|
||||
console.log("submitting")
|
||||
// Set the hidden field value to the editors value
|
||||
$(hidden_identifier).val(JSON.stringify(editor.getValue()));
|
||||
// Disable the editor so it's values wont be submitted
|
||||
//editor.disable();
|
||||
// Validate the editor's current value against the schema
|
||||
const errors = editor.validate();
|
||||
|
||||
if (errors.length) {
|
||||
// errors is an array of objects, each with a `path`, `property`, and `message` parameter
|
||||
// `property` is the schema keyword that triggered the validation error (e.g. "minLength")
|
||||
// `path` is a dot separated path into the JSON object (e.g. "root.path.to.field")
|
||||
console.log(errors);
|
||||
}
|
||||
else {
|
||||
console.log("valid");
|
||||
}
|
||||
console.log(editor.getValue());
|
||||
//e.preventDefault();
|
||||
})
|
||||
} else {
|
||||
console.log("No form found")
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
{% endcomment %}
|
||||
</script>
|
||||
|
||||
{% endblock js %}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2>Case: {{ case.title }}</h2>
|
||||
{{ case_detail.question_schema }}
|
||||
|
||||
|
||||
{{ case.question_answers }}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,28 +1,66 @@
|
||||
{% extends 'atlas/exams.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{{collection.name}}</h2>
|
||||
|
||||
{% include 'exam_notes.html' %}
|
||||
<div>
|
||||
Exam mode: {{collection.exam_mode}}<br />
|
||||
Publish results: {{collection.publish_results}}<br />
|
||||
Active: {{collection.active}}<br />
|
||||
Collection Type: {{collection.collection_type}}<br />
|
||||
Collection Type: {{collection.get_collection_type_display}}<br />
|
||||
Self review: {{collection.self_review}}<br />
|
||||
</div>
|
||||
<h3>Cases</h3>
|
||||
<ol id="full-question-list" class="sortable">
|
||||
{% for case in cases %}
|
||||
<li data-question_pk={{case.pk}}><a href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a>
|
||||
: {{case.title}}
|
||||
{% for casedetail in casesdetails %}
|
||||
<li data-question_pk={{casedetail.case.pk}}><a title="sort_order: {{casedetail.sort_order}}" href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a>
|
||||
: {{casedetail.case.title}}
|
||||
|
||||
{% if collection.collection_type == "QUE" %}
|
||||
(<a href='{% url "atlas:collection_case_details" casedetail.collection.pk casedetail.case.pk %}'>edit questions</a>)
|
||||
{% endif %}
|
||||
|
||||
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>manage priors</a>)
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
<p>This collection will be available to view/take <a href='{{collection.get_take_url}}'>here</a>
|
||||
|
||||
|
||||
{% 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)
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
|
||||
<p><button id='button-edit-order' title='click and drag questions to change order' data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}">Edit case order / Delete cases</button></p>
|
||||
<p><button id='button-add-case'
|
||||
title='click to add a case to this collection'
|
||||
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list">
|
||||
Add case
|
||||
</button>
|
||||
<div id='case-list'></div>
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary>Answer management</summary>
|
||||
|
||||
<div class="alert alert-danger">
|
||||
<p>Manage answers for this collection. </p>
|
||||
|
||||
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."
|
||||
>Reset all answers</button>
|
||||
|
||||
</details>
|
||||
{% endif %}
|
||||
{% include 'exam_overview_js.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
|
||||
{% if request.user.is_authenticated %}
|
||||
<br/>Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a> /
|
||||
<a href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a> /
|
||||
<a href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a> /
|
||||
<a href="{% url 'atlas:exam_cids' collection.pk %}">Candidates</a> /
|
||||
<a href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a>
|
||||
<a href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a> /
|
||||
<div class="floating-header">
|
||||
<a href="{% url 'atlas:exam_update' collection.id %}" title="Edit the Collection">Edit</a>
|
||||
\ <a href="{% url 'atlas:exam_deleted' collection.id %}" title="Delete the Collection">Delete</a>
|
||||
\ <a href="{% url 'atlas:exam_clone' collection.id %}" title="Clone the Collection">Clone</a>
|
||||
\ <a href="{% url 'atlas:collection_authors' collection.pk %}" title="Edit Authors">Authors</a>
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
\ <a href="{% url 'admin:atlas_casecollection_change' collection.id %}" title="Edit the Collection using the admin interface">Admin Edit</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<h2>{{collection.name}}</h2>
|
||||
|
||||
{% include 'exam_notes.html' %}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
{% load static %}
|
||||
{% comment %} <script src="{% static 'js/jquery-3.7.1.min.js' %}"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %}
|
||||
<ol>
|
||||
{% for case_detail in case_details %}
|
||||
|
||||
<li id="case-detail-{{case_detail.pk}}"{% if case_detail.question_schema %} class="cases"{% endif %}>
|
||||
<h2>Case: {{ case_detail.case.title }}</h2>
|
||||
|
||||
{% if case_detail.question_schema %}
|
||||
<button class="btn btn-sm btn-secondary use-layout">Use schema</button>
|
||||
<details><summary>Schema:</summary>
|
||||
<div class="schema" data-schema='{{ case_detail.get_question_schema }}'>{{ case_detail.question_schema }}</div>
|
||||
</details>
|
||||
<div class="form"></div>
|
||||
{% else %}
|
||||
<p>No question(s)</p>
|
||||
{% endif %}
|
||||
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
<script>
|
||||
|
||||
$(document).ready(function() {
|
||||
$(".cases").each(function(n, el){
|
||||
console.log(n, el);
|
||||
form_el = $(el).find(".form").get(0);
|
||||
schema = $(el).find(".schema").get(0).dataset.schema;
|
||||
console.log(schema)
|
||||
console.log(form_el)
|
||||
form = new JSONEditor(form_el, {
|
||||
schema: JSON.parse(schema),
|
||||
theme: 'bootstrap4',
|
||||
disable_edit_json: true,
|
||||
disable_collapse: true,
|
||||
disable_properties: true,
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Collection: {{exam.name}}</h2>
|
||||
<h2>Collection: {{exam}}</h2>
|
||||
|
||||
{% comment %} <ul>
|
||||
{% for case in collection.cases.all %}
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
|
||||
<h2>Collection: {{collection.name}}
|
||||
|
||||
{% if collection.publish_results or cid_user_exam.completed %}
|
||||
{% if collection.in_review_mode or cid_user_exam.completed %}
|
||||
<span class="stamp-white">REVIEW</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
|
||||
{% if not collection.review_only %}
|
||||
{{answer_count}} out of {{collection_length}} cases answered. Click to go to case.
|
||||
{% endif %}
|
||||
<div class="sba-finish-list">
|
||||
<ul>
|
||||
{% for question, answer, self_review in question_answer_tuples %}
|
||||
@@ -24,14 +27,29 @@
|
||||
{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}
|
||||
{% endif %}
|
||||
">
|
||||
Case: {{forloop.counter}}{% if self_review %} <span class="self-review-complete" title="You have reviewed this case."> <i class="bi-card-checklist"></i></span>{% endif %}
|
||||
Case: {{forloop.counter}}
|
||||
|
||||
{% if answer.completed or cid_user_exam.completed %}
|
||||
<span title="You have completed this case"><i class="bi bi-check-square-fill"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if self_review %} <span class="self-review-complete" title="You have reviewed this case."> <i class="bi-card-checklist"></i></span>{% endif %}
|
||||
|
||||
</a>
|
||||
<br/>
|
||||
{% if not collection.review_only %}
|
||||
{% if not answer %}
|
||||
No answer
|
||||
{% else %}
|
||||
<details><summary>Answered</summary>
|
||||
|
||||
{% if aswer.answer %}
|
||||
{{answer.answer}}
|
||||
{% else %}
|
||||
{{answer.json_answer}}
|
||||
{% endif %}
|
||||
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -40,14 +58,12 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
Start time: {{cid_user_exam.start_time}}
|
||||
|
||||
|
||||
{% if collection.self_review %}
|
||||
{% if not collection.review_only and not cid_user_exam.completed %}
|
||||
<p>Completed: <span id="completed-state">{{cid_user_exam.completed}}</span></p>
|
||||
|
||||
|
||||
{% if not cid_user_exam.completed %}
|
||||
<form hx-post=""
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-delete
|
||||
@@ -59,7 +75,6 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{% extends "atlas/exams.html" %}
|
||||
|
||||
{% block js %}
|
||||
{{ form.media }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2>Update Exam</h2>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<table>
|
||||
{{ form.as_table }}
|
||||
</table>
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,177 @@
|
||||
{% extends 'atlas/exams.html' %}
|
||||
|
||||
{% block content %}
|
||||
<details class="help-text">
|
||||
<summary title="Click to view instructions">Instructions</summary>
|
||||
<p>This set up is designed to allow you to project/share cases in a seperate window whilst controlling/tracking their display from this page.</p>
|
||||
<p>First you will need to open the linked viewer window by clicking the 'Open Viewer' button. This will open a new window/tab with the viewer in it. You will probably need to detach this from your current window.</p>
|
||||
<p>If you are sharing the window you can maximise the screen area by hiding the tab/bookmark bar and the address bar. In Microsoft Edge this can be done by installing as an app, in Chrome you can use an extension such as <a href='https://chromewebstore.google.com/detail/ncppfjladdkdaemaghochfikpmghbcpc'>Open-as-Popup</a>.</p>
|
||||
<p>Click on the 'Load' button to open the case in a the linked window. Loading subsequent cases/series will replace the first case in the window.</p>
|
||||
<p>It is also possible to open a single series by clicking on the 'Load' button next to the individual series.</p>
|
||||
<p>Once opened the case details will be show on the right hand side of the page.</p>
|
||||
</details>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<h3>Cases</h3>
|
||||
<button id="open-viewer" >Open Viewer</button>
|
||||
<button id="view-series" class="text-dark border-dark" >Toggle series</button>
|
||||
<ol id="" class="sortable">
|
||||
{% for case in cases %}
|
||||
|
||||
<li data-question_pk={{case.pk}}>
|
||||
{{case.casedetail.sort_order}}
|
||||
<div class="case-item" data-title="{{case.title}}" data-case={{case.pk}} data-case-json='{{case.get_viva_details_json}}'>Case {{forloop.counter}}: {{case.title}}
|
||||
{% comment %} <button class="show-case no-padding" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Show</button> {% endcomment %}
|
||||
{% comment %} <button class="send-message no-padding" data-title="{{case.title}}" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Open</button> {% endcomment %}
|
||||
{% comment %} <button class="open-case no-padding" data-target="{% url 'atlas:collection_viva_case' collection.pk forloop.counter0 %}" data-type="case">Load</button> {% endcomment %}
|
||||
<button class="open-case no-padding" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}" data-type="case">Load</button>
|
||||
<br />
|
||||
<div class="pre-whitespace multi-image-block">
|
||||
<details class="series-detail"><summary>Series:</summary>
|
||||
{% for series in case.series.all %}
|
||||
<span class="series-block">
|
||||
<span>
|
||||
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
|
||||
{{series.get_block}}
|
||||
<br />
|
||||
{% comment %} <button class="show-series no-padding text-secondary border-secondary" data-case={{case.pk}} data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:series_dicom_json' series.pk %}">
|
||||
show
|
||||
</button> {% endcomment %}
|
||||
{% comment %} <button class="open-series no-padding text-secondary border-secondary" data-type="series" data-series={{forloop.counter}} data-case={{case.pk}} data-target="{% url 'atlas:collection_viva_series' collection.pk series.pk %}"> {% endcomment %}
|
||||
<button class="open-series no-padding text-secondary border-secondary" data-type="series" data-series={{forloop.counter}} data-case={{case.pk}} data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:series_dicom_json' series.pk %}">
|
||||
Load
|
||||
</button>
|
||||
<br>
|
||||
</span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
|
||||
<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>
|
||||
|
||||
<iframe id="viewer" style="width: 100%; height: 500px; border: none"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block js %}
|
||||
<script type="text/javascript">
|
||||
var win2 = false;
|
||||
|
||||
$(document).ready(function() {
|
||||
const bc = new BroadcastChannel("viva_load");
|
||||
bc.onmessage = (event) => {
|
||||
console.log("message received");
|
||||
console.log(event);
|
||||
if (event.data.type == "open-complete") {
|
||||
$("#loading-case").hide();
|
||||
$("#open-viewer").removeClass("flash-button");
|
||||
//bc.postMessage({"type": "open-complete", "url" : event.data.url});
|
||||
}
|
||||
};
|
||||
console.log('ready')
|
||||
$('.show-collection, .show-case, .show-series').click(function() {
|
||||
var url = $(this).data('target');
|
||||
console.log('clicked', url)
|
||||
$('#case-title').text($(this).data('title'));
|
||||
$('#viewer').attr('src', url)
|
||||
});
|
||||
$('.open-case, .open-series').click(function() {
|
||||
var url = $(this).data('target');
|
||||
console.log('clicked', url)
|
||||
|
||||
|
||||
let c = $(this).closest(".case-item");
|
||||
|
||||
case_details = c.data('case-json');
|
||||
console.log(case_details);
|
||||
|
||||
$('#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']);
|
||||
|
||||
console.log($(this).data('type'))
|
||||
if ($(this).data('type') == 'case') {
|
||||
$('#current-case-series').text("Series: All");
|
||||
} else {
|
||||
$('#current-case-series').text("Series: " + $(this).data('series'));
|
||||
}
|
||||
bc.postMessage({"type": "open", "url" : url});
|
||||
//if (win2 == false || win2.closed) {
|
||||
// win2 = openSecondaryWindow(url);
|
||||
// console.log('opened', win2)
|
||||
//} else {
|
||||
// win2.location.href = url
|
||||
//}
|
||||
//win2 = openSecondaryWindow(url);
|
||||
})
|
||||
$('.send-message').click(function() {
|
||||
var url = $(this).data('target');
|
||||
bc.postMessage(url);
|
||||
})
|
||||
$('#open-viewer').click(function() {
|
||||
$("#open-viewer").removeClass("flash-button");
|
||||
openSecondaryWindow("{% url 'atlas:collection_viewer' %}");
|
||||
//bc.postMessage(url);
|
||||
})
|
||||
$('#view-series').click(function() {
|
||||
$(".series-detail").attr("open", (_, attr) => !attr);
|
||||
//document.getElementsByClassName("series-detail").toggleAttribute("open");
|
||||
//bc.postMessage(url);
|
||||
})
|
||||
|
||||
});
|
||||
function openSecondaryWindow(url) {
|
||||
//return win2 = window.open(url,'secondary','width=300,height=100');
|
||||
return win2 = window.open(url,'secondary');
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
{{ form.media }}
|
||||
{% 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;
|
||||
}
|
||||
</style>
|
||||
{% endblock css %}
|
||||
@@ -0,0 +1,38 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Collection Viva Case</title>
|
||||
<style type="text/css">
|
||||
body, html
|
||||
{
|
||||
margin: 0; padding: 0; height: 100%; overflow: hidden;
|
||||
}
|
||||
|
||||
#viewer
|
||||
{
|
||||
position:absolute; left: 0; right: 0; bottom: 0; top: 0px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2 id="case-title">{{current_case.title}}</h2>
|
||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px;" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' current_case.pk %}"></iframe>
|
||||
|
||||
<script type="text/javascript">
|
||||
var win2 = false;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
document.title = "Case: {{current_case.title}}";
|
||||
console.log('ready');
|
||||
let bc = new BroadcastChannel("viva_load");
|
||||
bc.onmessage = (event) => {
|
||||
console.log("message received");
|
||||
console.log(event);
|
||||
bc.postMessage({"type": "open-completed", "url" : url});
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Collection Viva Case Series</title>
|
||||
<style type="text/css">
|
||||
body, html
|
||||
{
|
||||
margin: 0; padding: 0; height: 100%; overflow: hidden;
|
||||
}
|
||||
|
||||
#viewer
|
||||
{
|
||||
position:absolute; left: 0; right: 0; bottom: 0; top: 0px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px;" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:series_dicom_json' current_series.pk %}"></iframe>
|
||||
|
||||
<script type="text/javascript">
|
||||
var win2 = false;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
console.log('ready');
|
||||
const bc = new BroadcastChannel("viva_load");
|
||||
bc.onmessage = (event) => {
|
||||
console.log(event);
|
||||
bc.postMessage({"type": "open-completed", "url" : url});
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,7 +3,11 @@
|
||||
{% block navigation %}
|
||||
{{block.super}}
|
||||
|
||||
|
||||
{% if collection %}
|
||||
{% include "atlas/collection_headers.html" %}
|
||||
{% endif %}
|
||||
|
||||
{% comment %} Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=exam.pk %}">Overview</a> /
|
||||
<a href="{% url 'atlas:collection_mark_overview' exam.pk %}">Mark</a> /
|
||||
<a href="{% url 'atlas:collection_scores_cid' exam.pk %}">Scores</a> /
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2>Anatomy of the Atlas</h2>
|
||||
|
||||
The atlas consists of a number of different constituent parts.
|
||||
|
||||
<h3>Series</h3>
|
||||
<p>A series is a stack of images. They should consist of a single modality, and be of the same body part and aquired at the same time. For example, a CT scan of the head, or an MRI of the knee. </p>
|
||||
|
||||
<p>Each series can be associated with an arbitary number of cases.</p>
|
||||
|
||||
<h3>Cases</h3>
|
||||
<p>A case is the basic unit of the atlas. It should consist of one or more series, and be associated with a single patient.</p>
|
||||
|
||||
<p>Additional information can be added to a case, such as history, description, report, diagnosis and differential.</p>
|
||||
|
||||
<h3>Collection</h3>
|
||||
<p>A collection is a group of cases. It can be used to group cases. Cases can below to more than one group.</p>
|
||||
|
||||
<h2>Uploading/creating cases</h2>
|
||||
<p>The easiest way to upload cases is to use the uploader tool. This can export, anonnyimize and upload cases from Insignia Insight, however it may need setting up manually.</p>
|
||||
<p>Note: due to restrictions in Insight it is not possible to export dicom files from teaching folders. It is possible to export jpg/png files which can be uploaded and will function, but as they do not store the full range of dicom/pixel data some functionality will be limited (e.g. windowing/levelling will be restriction, automatic ordering will not be possible).</p>
|
||||
<h3>Setting up the Uploader tool</h3>
|
||||
Requirements: Full Insight installation (not WebPacs)
|
||||
<h4>Step 1: Download the uploader tool</h4>
|
||||
This is available <a href="https://www.penracourses.org.uk/uploader/anon_gui.exe">here</a>
|
||||
<h4>Step 2: Download insight integration xml file</h4>
|
||||
Available <a href="https://www.penracourses.org.uk/uploader/insight_rad_up.xml">here</a>
|
||||
<h4>Step 3: Integrate the uploader with PACs</h4>
|
||||
Whilst in PACs open the settings menu with the keyboard shortcut 'Ctrl+Shift+C'. In the 'General' tab, select 'External Tools' and click 'Add'. Press the Import button and select the xml file you downloaded in Step 2. Modify the file to run path to point the uploader exe downloaded in step 1 (clicking on the 3 dots will allow you to browse and select this file). The remaining fields should have been set via the xml file and should not require modification.
|
||||
<h3>Uploading without the uploader tool</h3>
|
||||
If you have <b>annonymised</b> dicom files already exported you can uploading them directly using this <a href='{% url "atlas:new_uploads" %}'>form</a>.
|
||||
|
||||
{% endblock %}
|
||||
@@ -3,8 +3,12 @@
|
||||
{% block content %}
|
||||
<h2>Atlas</h2>
|
||||
|
||||
<a href="{% url 'atlas:user_collections' %}">Available collections</a><br/>
|
||||
<a href="{% url 'atlas:user_collections' %}">Collections to view / take</a><br/>
|
||||
|
||||
|
||||
<h3>My cases / collections</h3>
|
||||
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.<br/>
|
||||
<a href="{% url 'atlas:user_uploads' %}">My uploads</a>
|
||||
|
||||
<a href="{% url 'atlas:user_uploads' %}"><i class="bi bi-cloud-arrow-up"></i> Uploads awaiting import</a>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,461 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% load static%}
|
||||
|
||||
{% block content %}
|
||||
<h2>Upload dicoms</h2>
|
||||
User: {{user}}<br/>
|
||||
|
||||
<div role="alert" class="alert alert-warning">
|
||||
Please note this form will not annonymise files, ensure that you have done so prior to using it. If files that do not appear to be annonymised are selected you will not be able to upload them.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p>This form allows you to quickly upload dicom files to the server. Select the folder containing the dicom files below. If you wish to upload files directly from Insight you may rather use the <a href='{% url "atlas:help" %}'>Uploader tool.</a></p>
|
||||
<details>
|
||||
<summary>Help</summary>
|
||||
<p>Only valid dicom files will be uploaded. </p>
|
||||
<p>It is possible to upload multiple files by selecting the parent (containing) folder. Folders within will then be searched recursively for any valid dicom files.</p>
|
||||
<p>Files to be uploaded will be displayed below. </p>
|
||||
<p>DICOM tags will be checked to see if valid accession or hospital numbers are present but no annonymisation will be performed.</p>
|
||||
<p>By default a basic OCR will also be performed to check for burned in details on images, the is resource intensive and can be disabled if you know the content you are uploading does not feature any patient details.</p>
|
||||
</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="show-thumbnails"><label for="Show thumbnails">Show thumbnails</label>
|
||||
<input type="checkbox" id="show-viewer"><label for="Show Viewer">Show viewer</label>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<input type="file" name="dicom_files" id="filepicker" webkitdirectory multiple>
|
||||
<button type="button" id="uploadButton">Upload</button>
|
||||
|
||||
</form>
|
||||
|
||||
<details id="upload-results"><summary>Upload results</summary>
|
||||
<div id="upload-results-list">
|
||||
<h5>Uploaded</h5>
|
||||
<ul id="uploaded-files"></ul>
|
||||
<h5>Duplicates</h5>
|
||||
<ul id="duplicate-files"></ul>
|
||||
<h5>Failed</h5>
|
||||
<ul id="failed-files"></ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
|
||||
<h4>Selected files:</h4>
|
||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''></div>
|
||||
<ol id="listing"></ol>
|
||||
|
||||
|
||||
<div id="loading" class="fullscreen-overlay">
|
||||
<div class="progress-block">
|
||||
<div id="progress"></div>
|
||||
<div class="sk-cube-grid full">
|
||||
<div class="sk-cube sk-cube1"></div>
|
||||
<div class="sk-cube sk-cube2"></div>
|
||||
<div class="sk-cube sk-cube3"></div>
|
||||
<div class="sk-cube sk-cube4"></div>
|
||||
<div class="sk-cube sk-cube5"></div>
|
||||
<div class="sk-cube sk-cube6"></div>
|
||||
<div class="sk-cube sk-cube7"></div>
|
||||
<div class="sk-cube sk-cube8"></div>
|
||||
<div class="sk-cube sk-cube9"></div>
|
||||
</div>
|
||||
<div id="progress-text">Uploading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static 'js/upload_form_helpers.js' %}"></script>
|
||||
<script>
|
||||
window.to_upload = [];
|
||||
window.upload_count = 1;
|
||||
|
||||
function* chunks(arr, n) {
|
||||
for (let i = 0; i < arr.length; i += n) {
|
||||
yield arr.slice(i, i + n);
|
||||
}
|
||||
}
|
||||
|
||||
function onUploadButtonClicked(event) {
|
||||
event.preventDefault();
|
||||
const fileInput = document.getElementById("filepicker");
|
||||
const selectedFiles = fileInput.files;
|
||||
// Check if any files are selected
|
||||
if (selectedFiles.length === 0) {
|
||||
alert("Please select at least one file to upload.");
|
||||
return;
|
||||
}
|
||||
//alert(`${window.to_upload.length} files selected. Uploading...`);
|
||||
|
||||
window.upload_results = {
|
||||
"uploaded": [],
|
||||
"duplicates": [],
|
||||
"failed": []
|
||||
};
|
||||
|
||||
chunked_files = [...chunks(window.to_upload, 10)];
|
||||
|
||||
|
||||
$("#loading").show();
|
||||
$("#progress-text").text(`Uploading..`);
|
||||
for (let i = 0; i < chunked_files.length; i++) {
|
||||
uploadFiles(chunked_files[i], i, chunked_files.length);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
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 }}");
|
||||
xhr.onreadystatechange = function () {
|
||||
$("#progress-text").text(`Uploading ${window.upload_count} of ${total_chunks} chunks`);
|
||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||
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"]);
|
||||
|
||||
for (let i = 0; i < JSON.parse(xhr.response)["uploaded"].length; i++) {
|
||||
let item = document.createElement("li");
|
||||
item.textContent = JSON.parse(xhr.response)["uploaded"][i];
|
||||
$("#uploaded-files").append(item);
|
||||
}
|
||||
|
||||
for (let i = 0; i < JSON.parse(xhr.response)["duplicates"].length; i++) {
|
||||
let item = document.createElement("li");
|
||||
item.textContent = JSON.parse(xhr.response)["duplicates"][i];
|
||||
$("#duplicate-files").append(item);
|
||||
}
|
||||
|
||||
for (let i = 0; i < JSON.parse(xhr.response)["failed"].length; i++) {
|
||||
let item = document.createElement("li");
|
||||
item.textContent = JSON.parse(xhr.response)["failed"][i];
|
||||
$("#failed-files").append(item);
|
||||
}
|
||||
} else {
|
||||
// Handle error response from the server
|
||||
console.error('Failed to upload files.');
|
||||
alert("Error occurred during file upload. Please try again.");
|
||||
}
|
||||
if (current_chunk + 1 == total_chunks) {
|
||||
$("#loading").hide();
|
||||
alert("Uploading complete")
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
function checkAnnonymisation(byteArray) {
|
||||
// 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
|
||||
// and element in the following format: xggggeeee where gggg is the group number
|
||||
// and eeee is the element number with lowercase hex characters.
|
||||
|
||||
// To access the data for an element, we need to know its type and its tag.
|
||||
// We will get the sopInstanceUid from the file which is a string and
|
||||
// has the tag (0020,000D)
|
||||
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"]
|
||||
|
||||
for (let i = 0; i < site_codes.length; i++) {
|
||||
if (
|
||||
accession_number.startsWith(site_codes[i]) ||
|
||||
patient_id.startsWith(site_codes[i])
|
||||
)
|
||||
{
|
||||
not_anon = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (not_anon) {
|
||||
toastr.warning(`File does not appear to be annonymised<br/>Accession: ${accession_number} <br/>Patient ID: ${patient_id}<br/>`, 'Anonymisation warning', {
|
||||
"closeButton": false,
|
||||
"debug": false,
|
||||
"newestOnTop": false,
|
||||
"progressBar": false,
|
||||
"positionClass": "toast-top-full-width",
|
||||
"preventDuplicates": false,
|
||||
"onclick": null,
|
||||
"showDuration": "0",
|
||||
"hideDuration": "0",
|
||||
"timeOut": 0,
|
||||
"extendedTimeOut": 0,
|
||||
"showEasing": "swing",
|
||||
"hideEasing": "linear",
|
||||
"showMethod": "fadeIn",
|
||||
"hideMethod": "fadeOut",
|
||||
});
|
||||
$("uploadButton").prop("disabled", true);
|
||||
}
|
||||
|
||||
// TODO: fix
|
||||
//$.ajax({
|
||||
// url: `{% url 'generic:examination-autocomplete' %}/${encodeURI(study_description)}`,
|
||||
// //context: document.body,
|
||||
// success: function(data){
|
||||
// console.log(data)
|
||||
// }
|
||||
//});
|
||||
|
||||
//// try to match with a study description (if not already selected)
|
||||
//if ($(`#id_examination_to option`).length < 1) {
|
||||
// option = $(`#id_examination_from option[title*='${study_description}' i]`);
|
||||
|
||||
// if (option.length) {
|
||||
// option.appendTo($("#id_examination_to"));
|
||||
// toastr.success(`Examination set to ${option.attr('title')} from dicom data`);
|
||||
// } else {
|
||||
// toastr.warning(
|
||||
// `Unable to set examination ${study_description} from dicom data (it needs creating)`);
|
||||
|
||||
// }
|
||||
//}
|
||||
return true;
|
||||
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function loadDicomViewerTest(callback) {
|
||||
$("#single-dicom-viewer").hide();
|
||||
console.log("load")
|
||||
$("#single-dicom-viewer").empty()
|
||||
//images = []
|
||||
//Function that returns a promise to read the file
|
||||
const reader = (file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = () => resolve(fileReader);
|
||||
fileReader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
// extractDicomStudyDescription(byteArray);
|
||||
//}
|
||||
var image = new Image();
|
||||
image.height = 100;
|
||||
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);
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
const dicomReader = (file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = () => resolve(fileReader);
|
||||
fileReader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
const readDicomFile = (file) => {
|
||||
return dicomReader(file).then(reader => {
|
||||
var byteArray = new Uint8Array(reader.result);
|
||||
console.log("etrxact", reader)
|
||||
return checkAnnonymisation(byteArray);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
file_set = document.getElementById("filepicker").files;
|
||||
|
||||
|
||||
let output = document.getElementById("listing");
|
||||
images = [];
|
||||
window.to_upload = [];
|
||||
$("#loading").show();
|
||||
$("#progress-text").text(`Processing..`);
|
||||
n = 0;
|
||||
for (const file of file_set) {
|
||||
n += 1;
|
||||
$("#progress-text").text(`Processing ${file.name} (${n} of ${file_set.length})`);
|
||||
//await readFile(file);
|
||||
dicom = await readDicomFile(file);
|
||||
|
||||
if (dicom) {
|
||||
let item = document.createElement("li");
|
||||
item.className = "dicom-file";
|
||||
item.textContent = 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);
|
||||
}
|
||||
window.to_upload.push(file);
|
||||
|
||||
if (!$("#check-annonymisation-ocr").is(":checked") && !$("#show-thumbnails").is(":checked")) {
|
||||
|
||||
} else {
|
||||
const element = $(`<div class='temp-thumb' src=${imageId}></div>`).get(0)
|
||||
item.appendChild(element);
|
||||
cornerstone.enable(element);
|
||||
cornerstone.loadAndCacheImage(imageId).then(function (image) {
|
||||
cornerstone.displayImage(element, image);
|
||||
|
||||
if ($("#check-annonymisation-ocr").is(":checked")) {
|
||||
$(element).addClass("temp-thumb-large");
|
||||
cornerstone.resize(element)
|
||||
setTimeout(function () {
|
||||
ocr($(element).find("canvas").get(0).toDataURL(), element);
|
||||
$(element).removeClass("temp-thumb-large");
|
||||
cornerstone.resize(element)
|
||||
|
||||
|
||||
if (!$("#show-thumbnails").is(":checked")) {
|
||||
cornerstone.disable(element);
|
||||
element.remove();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(images)
|
||||
|
||||
|
||||
const event = new CustomEvent('loadDicomViewerUrls', {
|
||||
"detail": {"images": images, "annontations": undefined}
|
||||
});
|
||||
|
||||
$("#loading").hide()
|
||||
if ($("#show-viewer").is(":checked")) {
|
||||
window.dispatchEvent(event);
|
||||
$("#single-dicom-viewer").show();
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
function ocr(url, input) {
|
||||
console.log("OCR", input)
|
||||
Tesseract.recognize(
|
||||
url,
|
||||
'eng',
|
||||
//{ logger: m => console.log(m) }
|
||||
).then(({
|
||||
data: {
|
||||
text
|
||||
}
|
||||
}) => {
|
||||
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);
|
||||
$(input).addClass("image-ident-warning");
|
||||
uploading_el.addClass("image-ident-warning");
|
||||
$("uploadButton").prop("disabled", true);
|
||||
toastr.warning(`File does not appear to be annonymised<br/>Image data burned in<br/>${element}`, 'Anonymisation warning', {
|
||||
"closeButton": false,
|
||||
"debug": false,
|
||||
"newestOnTop": false,
|
||||
"progressBar": false,
|
||||
"positionClass": "toast-top-full-width",
|
||||
"preventDuplicates": false,
|
||||
"onclick": null,
|
||||
"showDuration": "0",
|
||||
"hideDuration": "0",
|
||||
"timeOut": 0,
|
||||
"extendedTimeOut": 0,
|
||||
"showEasing": "swing",
|
||||
"hideEasing": "linear",
|
||||
"showMethod": "fadeIn",
|
||||
"hideMethod": "fadeOut",
|
||||
});
|
||||
} else {
|
||||
$(input).addClass("image-ident-ok");
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
document.getElementById("filepicker").addEventListener(
|
||||
"change",
|
||||
(event) => {
|
||||
//let output = document.getElementById("listing");
|
||||
//for (const file of event.target.files) {
|
||||
// let item = document.createElement("li");
|
||||
// item.textContent = file.webkitRelativePath;
|
||||
// output.appendChild(item);
|
||||
//}
|
||||
|
||||
loadDicomViewerTest();
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
|
||||
// Add event listener to the button element
|
||||
const uploadButton = document.getElementById("uploadButton");
|
||||
uploadButton.addEventListener("click", onUploadButtonClicked);
|
||||
});
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
<div class="floating-header">
|
||||
<a href="{% url 'atlas:case_detail' pk=case.pk %}" title="View the Case">View</a>
|
||||
<a href="{% url 'atlas:case_update' pk=case.pk %}" title="Edit the Case">Edit</a>
|
||||
<a href="{% url 'atlas:case_clone' pk=case.pk %}"
|
||||
title="Clone the Case (duplicate everything but the images)">Clone</a>
|
||||
<a href="{% url 'atlas:case_delete' pk=case.pk %}" title="Delete the Case">Delete</a>
|
||||
<a href="#"
|
||||
onclick="return window.create_popup_window('{% url 'feedback_create' question_type='atlas' pk=case.pk %}')"> Add
|
||||
Note</a>
|
||||
<a href="{% url 'atlas:case_authors' case.pk %}" title="Edit Authors">Authors</a>
|
||||
{% if request.user.is_superuser %}
|
||||
<a href="{% url 'admin:atlas_case_change' case.id %}" title="Edit the Case using the admin interface">Admin Edit</a>
|
||||
{% endif %}
|
||||
|
||||
{% if collection %}
|
||||
<div>
|
||||
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_view' collection.id case_number|add:-1 %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'atlas:collection_case_view' collection.id case_number|add:1 %}">Next question</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
|
||||
<div>
|
||||
<h3>Name: {{question_schema.name}}</h3>
|
||||
<p>Description: {{question_schema.description}}</p>
|
||||
|
||||
<details><summary>JSON:</summary>{{question_schema.schema}}</details>
|
||||
|
||||
{{example_form}}
|
||||
|
||||
<a href='{% url "atlas:question_schema_update" pk=question_schema.pk %}'><button>Edit</button></a>
|
||||
<a href='{% url "atlas:question_schema_delete" pk=question_schema.pk %}'><button>Delete</button></a>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
{{example_form.media}}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
|
||||
{% load static %}
|
||||
{% comment %} <script src="{% static 'js/jquery-3.7.1.min.js' %}"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %}
|
||||
<ol>
|
||||
{% for question_schema in question_schemas %}
|
||||
|
||||
<li class="schemas">
|
||||
<button class="btn btn-sm btn-secondary use-layout">Use schema</button>
|
||||
<details><summary>{{question_schema.name}}</summary>
|
||||
<div class="schema" data-schema='{{question_schema.get_schema_as_json|safe}}'>{{question_schema.schema}}</div>
|
||||
</details>
|
||||
<div class="form"></div>
|
||||
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
<script>
|
||||
|
||||
$(document).ready(function() {
|
||||
$(".schemas").each(function(n, el){
|
||||
console.log(n, el);
|
||||
form_el = $(el).find(".form").get(0);
|
||||
schema = $(el).find(".schema").get(0).dataset.schema;
|
||||
console.log(schema)
|
||||
console.log(form_el)
|
||||
form = new JSONEditor(form_el, {
|
||||
schema: JSON.parse(schema),
|
||||
theme: 'bootstrap4',
|
||||
disable_edit_json: true,
|
||||
disable_collapse: true,
|
||||
disable_properties: true,
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{% extends "atlas/base.html" %}
|
||||
<!-- {% load static from static %} -->
|
||||
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
||||
{{form.media}}
|
||||
|
||||
<script type="text/javascript">
|
||||
</script>
|
||||
|
||||
<!-- {{ form.media }} -->
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Add/Edit Condition</h2>
|
||||
<p>Use this form to create or edit a question schema.</p>
|
||||
<form action="" method="post" enctype="multipart/form-data" id="condition-form">
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form.as_div }}
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
{% endblock %}
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
{% if resource %}
|
||||
{% include "atlas/resource_link_header.html" %}
|
||||
{% endif %}
|
||||
|
||||
{{resource}}
|
||||
|
||||
Name: {{resource.name}}<br>
|
||||
Description: {{resource.description}}<br>
|
||||
URL: {{resource.url}}<br>
|
||||
file: {{resource.file}}<br>
|
||||
author: {{resource.get_authors}}<br>
|
||||
|
||||
{{resource.case}}
|
||||
|
||||
<div>
|
||||
<a href="{% url 'atlas:resource_view' resource.pk %}" target="_blank">View Resource</a>
|
||||
</div>
|
||||
|
||||
{% if resource.case_set.all %}
|
||||
<h3>Cases</h3>
|
||||
This resource is used in the following cases:
|
||||
|
||||
<ul>
|
||||
{% for case in resource.case_set.all %}
|
||||
<li><a href="{% url 'atlas:case_detail' case.id %}">{{case.title}}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>This resource is not used in any cases.</p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endblock %}
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
{% extends "atlas/base.html" %}
|
||||
<!-- {% load static from static %} -->
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
||||
{{form.media}}
|
||||
|
||||
<script type="text/javascript">
|
||||
</script>
|
||||
|
||||
<!-- {{ form.media }} -->
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
|
||||
<h2>Edit Resource {{object.name}}</h2>
|
||||
<form action="" method="post" enctype="multipart/form-data" id="resource-form">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
<div class="floating-header">
|
||||
|
||||
|
||||
<a href="{% url 'atlas:resource_update' pk=resource.pk %}" title="Edit the resource">Edit</a>
|
||||
<a href="{% url 'atlas:resource_delete' pk=resource.pk %}" title="Delete the resource">Delete</a>
|
||||
{% if request.user.is_superuser %}
|
||||
<a href="{% url 'admin:atlas_resource_change' resource.id %}" title="Edit the resource using the admin interface">Admin Edit</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
{% block content %}
|
||||
<a href="{% url 'atlas:resource_create' %}">Create a new resource</a>
|
||||
|
||||
<h2>Resources</h2>
|
||||
|
||||
<ul id="resource-list">
|
||||
{% for resource in object_list %}
|
||||
<li class="">
|
||||
<a href="{% url 'atlas:resource_detail' resource.pk %}">{{resource}}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<style>
|
||||
td, th { padding-left: 10px }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -8,11 +8,11 @@
|
||||
Adding self feedback for {{user_exam.exam}}/{{case}}
|
||||
|
||||
</div>
|
||||
<form action="" method="post">
|
||||
<form class="highlight" action="" method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors }}
|
||||
|
||||
{{ form.as_p }}
|
||||
{{ form }}
|
||||
{% comment %} <div class="fieldWrapper">
|
||||
{{ form.content_type.errors }}
|
||||
{{ form.content_type }}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
|
||||
{% extends "atlas/base.html" %}
|
||||
|
||||
{% block navigation%}
|
||||
|
||||
{{ block.super }}
|
||||
{% include 'atlas/series_headers.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<a href="{% url 'atlas:series_update' pk=series.pk %}" title="Edit the Series">Edit</a>
|
||||
<a href="{% url 'atlas:series_delete' pk=series.pk %}" title="Delete the Series">Delete</a>
|
||||
{% if request.user.is_superuser %}
|
||||
<a href="{% url 'admin:atlas_series_change' series.id %}" title="Edit the Series using the admin interface">Admin Edit</a>
|
||||
{% endif %}
|
||||
|
||||
{% include 'atlas/series_viewer.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -247,170 +247,20 @@
|
||||
//
|
||||
// }
|
||||
|
||||
function extractDicomDetails(byteArray) {
|
||||
// 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
|
||||
// and element in the following format: xggggeeee where gggg is the group number
|
||||
// and eeee is the element number with lowercase hex characters.
|
||||
|
||||
// To access the data for an element, we need to know its type and its tag.
|
||||
// We will get the sopInstanceUid from the file which is a string and
|
||||
// has the tag (0020,000D)
|
||||
var study_description = dataSet.string('x00081030');
|
||||
var accession_number = dataSet.string('x00080050').toLowerCase();
|
||||
var patient_id = dataSet.string('x00100020').toLowerCase();
|
||||
var modality = dataSet.string('x00080060');
|
||||
var projection = dataSet.string('x00185101');
|
||||
var plane = dataSet.string('x00180022').toLowerCase();
|
||||
console.log("STUDY", study_description);
|
||||
console.log("acc", accession_number);
|
||||
console.log("pid", patient_id);
|
||||
console.log("projection", projection);
|
||||
console.log("plane", plane);
|
||||
|
||||
function select_plane(option) {
|
||||
let plane_select_object = document.getElementsByName("plane")[0]
|
||||
const $options = Array.from(plane_select_object.options);
|
||||
const optionToSelect = $options.find(item => item.text === option);
|
||||
optionToSelect.selected = true;
|
||||
|
||||
}
|
||||
|
||||
if (plane.includes("axial")) {
|
||||
select_plane("axial");
|
||||
} else if (plane.includes("sagittal")) {
|
||||
select_plane("sagittal");
|
||||
} else if (plane.includes("coronal")) {
|
||||
select_plane("coronal");
|
||||
} else if (plane.includes("ap")) {
|
||||
select_plane("AP");
|
||||
} else if (plane.includes("lat") | plane.includes("lateral")) {
|
||||
select_plane("lateral");
|
||||
} else if (plane.includes("pa")) {
|
||||
select_plane("PA");
|
||||
|
||||
}
|
||||
|
||||
|
||||
modality_map = {
|
||||
"DX": "Radiograph",
|
||||
"CR": "Radiograph",
|
||||
"CT": "CT",
|
||||
"MR": "MR",
|
||||
"RF": "Fluroscopy",
|
||||
"US": "Ultrasound",
|
||||
}
|
||||
|
||||
if (modality in modality_map) {
|
||||
let modality_select_object = document.getElementsByName("modality")[0]
|
||||
const $options = Array.from(modality_select_object.options);
|
||||
const optionToSelect = $options.find(item => item.text === modality_map[modality]);
|
||||
optionToSelect.selected = true;
|
||||
|
||||
|
||||
|
||||
} else {
|
||||
toastr.info(`Unable to automatically set modality: ${modality}`);
|
||||
}
|
||||
|
||||
// If the dicom study_description contains multiple values select the first
|
||||
study_description = study_description.split(",")[0].trim();
|
||||
|
||||
to_replace = ["lt", "rt", "contrast", "LT", "RT", "Contrast"];
|
||||
|
||||
for (let i = 0; i < to_replace.length; i++) {
|
||||
|
||||
study_description = study_description.replace(to_replace[i], "");
|
||||
}
|
||||
|
||||
let select_object = document.getElementsByName("examination")[0]
|
||||
|
||||
if (select_object.value == "") {
|
||||
$.get(`${select_object.dataset.autocompleteLightUrl}?q=${study_description}`, (data) => {
|
||||
console.log(data)
|
||||
if (data.results.length==1){
|
||||
|
||||
$(select_object).select2("trigger","select", {
|
||||
data: data.results[0]
|
||||
})
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
not_anon = false;
|
||||
|
||||
site_codes = ["ref", "rk9", "ra9", "rh8", "rbz", "rba"]
|
||||
|
||||
for (let i = 0; i < site_codes.length; i++) {
|
||||
if (
|
||||
accession_number.startsWith(site_codes[i])
|
||||
|| patient_id.startsWith(site_codes[i])) {
|
||||
not_anon = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (not_anon) {
|
||||
toastr.warning(`File does not appear to be annonymised<br/>Accession: ${accession_number} <br/>Patient ID: ${patient_id}<br/>`, 'Anonymisation warning', {
|
||||
"closeButton": false,
|
||||
"debug": false,
|
||||
"newestOnTop": false,
|
||||
"progressBar": false,
|
||||
"positionClass": "toast-top-full-width",
|
||||
"preventDuplicates": false,
|
||||
"onclick": null,
|
||||
"showDuration": "0",
|
||||
"hideDuration": "0",
|
||||
"timeOut": 0,
|
||||
"extendedTimeOut": 0,
|
||||
"showEasing": "swing",
|
||||
"hideEasing": "linear",
|
||||
"showMethod": "fadeIn",
|
||||
"hideMethod": "fadeOut",
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: fix
|
||||
//$.ajax({
|
||||
// url: `{% url 'generic:examination-autocomplete' %}/${encodeURI(study_description)}`,
|
||||
// //context: document.body,
|
||||
// success: function(data){
|
||||
// console.log(data)
|
||||
// }
|
||||
//});
|
||||
|
||||
//// try to match with a study description (if not already selected)
|
||||
//if ($(`#id_examination_to option`).length < 1) {
|
||||
// option = $(`#id_examination_from option[title*='${study_description}' i]`);
|
||||
|
||||
// if (option.length) {
|
||||
// option.appendTo($("#id_examination_to"));
|
||||
// toastr.success(`Examination set to ${option.attr('title')} from dicom data`);
|
||||
// } else {
|
||||
// toastr.warning(
|
||||
// `Unable to set examination ${study_description} from dicom data (it needs creating)`);
|
||||
|
||||
// }
|
||||
//}
|
||||
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{{ form.media }}
|
||||
{% endblock %}
|
||||
|
||||
{% block navigation%}
|
||||
{{ block.super }}
|
||||
{% if object %}
|
||||
{% include "atlas/series_headers.html" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
|
||||
<h2>Series Form</h2>
|
||||
This form will create a image set that can be associated with a atlas question. If the question has already been created
|
||||
it can be linked below.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<br/>
|
||||
<a href="{% url 'atlas:series_detail' pk=series.pk %}" title="View the Series">View</a>
|
||||
<a href="{% url 'atlas:series_update' pk=series.pk %}" title="Edit the Series">Edit</a>
|
||||
<a href="{% url 'atlas:series_delete' pk=series.pk %}" title="Delete the Series">Delete</a>
|
||||
<a href="{% url 'atlas:series_authors' pk=series.pk %}" title="Update the authors">Authors</a>
|
||||
{% if request.user.is_superuser %}
|
||||
<a href="{% url 'admin:atlas_series_change' series.id %}" title="Edit the Series using the admin interface">Admin Edit</a>
|
||||
{% endif %}
|
||||
@@ -4,5 +4,9 @@
|
||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images=["{{ image.image.url }}"] data-annotations=''>
|
||||
</div>
|
||||
{{image.get_dicom_info|safe}}
|
||||
<div>
|
||||
<a href="{{ image.image.url }}" download>Download DICOM Image</a>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<div>Description: {{ series_finding_form.description }}</div>
|
||||
<div>Findings: {{ series_finding_form.findings }}</div>
|
||||
<div>Structures: {{ series_finding_form.structures }}</div>
|
||||
<div>Conditions: {{ series_finding_form.conditions }}</div>
|
||||
</div>
|
||||
<input type="submit" value="Submit">
|
||||
<button id="cancel-add-finding-button">Cancel</button>
|
||||
@@ -48,6 +49,7 @@
|
||||
<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 />
|
||||
Condition(s): {% for s in finding.conditions.all %}{{s.get_link}}{% endfor %}<br />
|
||||
Description: {{finding.description}}<br />
|
||||
</span>
|
||||
<a href="{% url 'atlas:series_edit_finding' pk=series.pk finding_pk=finding.pk %}" class="edit-finding-link">Edit</a>
|
||||
@@ -252,7 +254,8 @@
|
||||
cornerstone.getEnabledElement(dicom_element).viewport = viewport
|
||||
cornerstoneTools.globalImageIdSpecificToolStateManager.restoreToolState(
|
||||
annotation);
|
||||
console.log(annotation)
|
||||
console.log("annotation", annotation)
|
||||
console.log("viewport", viewport)
|
||||
//cornerstone.getEnabledElement(dicom_element).toolStateManager.restoreToolState(annotationjson)
|
||||
cornerstone.resize(dicom_element);
|
||||
|
||||
@@ -285,6 +288,11 @@
|
||||
.map((i, el) => {
|
||||
return $(el).val()
|
||||
}).toArray()),
|
||||
conditions: JSON.stringify($('#finding-form select[name="conditions"]').find(
|
||||
":selected")
|
||||
.map((i, el) => {
|
||||
return $(el).val()
|
||||
}).toArray()),
|
||||
csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(),
|
||||
action: 'post'
|
||||
},
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{% load static %}
|
||||
|
||||
<div id="jsoneditor_{{ widget.attrs.id }}" class="svelte-jsoneditor-wrapper"></div>
|
||||
|
||||
|
||||
<textarea name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</textarea>
|
||||
|
||||
<script type="module">
|
||||
import { JSONEditor } from '{% static "django_svelte_jsoneditor/js/svelte_jsoneditor.js" %}'
|
||||
|
||||
const editor = new JSONEditor({
|
||||
target: document.getElementById('jsoneditor_{{ widget.attrs.id }}'),
|
||||
props: {
|
||||
content: {
|
||||
text: document.getElementById('{{ widget.attrs.id }}').value || undefined
|
||||
},
|
||||
onChange: (updatedContent, previousContent, { contentErrors, patchResult }) => {
|
||||
const value = updatedContent.json ? JSON.stringify(updatedContent.json) : updatedContent.text
|
||||
document.getElementById('{{ widget.attrs.id }}').value = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
editor.updateProps({{ widget.props|safe }})
|
||||
window.jsoneditor_{{ widget.attrs.id }} = editor
|
||||
</script>
|
||||
@@ -3,6 +3,8 @@
|
||||
{% block content %}
|
||||
<h2>Collections</h2>
|
||||
|
||||
The following collections are available for you to view / take.
|
||||
|
||||
|
||||
<ul>
|
||||
{% for collection in collections %}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
{% block content %}
|
||||
<h2>Uploaded dicoms</h2>
|
||||
User: {{user}}<br/>
|
||||
<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.
|
||||
@@ -34,6 +36,30 @@
|
||||
{% if case %}
|
||||
<p>Importing into case {{case}}</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/>
|
||||
If you are not sure, leave this as the default.
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="order-series-slice-location" value="order-series-slice-location" name="order-series" checked>
|
||||
<label for="order-series-slice-location">Order series by slice location</label><br/>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="order-series-instance-number" value="order-series-instance-number" name="order-series" >
|
||||
<label for="order-series-instance-number">Order series by instance number</label><br/>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="order-series-none" value="order-series-none" name="order-series" >
|
||||
<label for="order-series-none">None</label><br/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<button id="delete-uploads-button"
|
||||
hx-post="{% url 'api-1:clear_dicoms' %}"
|
||||
@@ -45,14 +71,14 @@
|
||||
<button id="import-dicoms-case-button"
|
||||
hx-post="{% url 'api-1:import_dicoms_case' case.id %}"
|
||||
hx-target=".imported"
|
||||
hx-include=".selected"
|
||||
hx-include=".selected,[name='order-series']"
|
||||
>Import into case</button>
|
||||
|
||||
{% else %}
|
||||
<button id="import-dicoms-button"
|
||||
hx-post="{% url 'api-1:import_dicoms' %}"
|
||||
hx-target=".imported"
|
||||
hx-include=".selected"
|
||||
hx-include=".selected,[name='order-series']"
|
||||
>Import</button>
|
||||
{% endif %}
|
||||
|
||||
@@ -61,6 +87,7 @@
|
||||
</div>
|
||||
{% else %}
|
||||
No uploads awaiting importing have been found.
|
||||
|
||||
{% endif %}
|
||||
|
||||
<p><a href="{% url 'atlas:series_view' %}?case=null&sort=-modified_date">View orphan series</a></p>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Viewer</title>
|
||||
<style type="text/css">
|
||||
body, html
|
||||
{
|
||||
margin: 0; padding: 0; height: 100%; overflow: hidden;
|
||||
}
|
||||
|
||||
#viewer
|
||||
{
|
||||
position:absolute; left: 0; right: 0; bottom: 0; top: 0px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>Awating load request</p>
|
||||
This page will display series when loaded from other pages.
|
||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px;" ></iframe>
|
||||
|
||||
<script type="text/javascript">
|
||||
var win2 = false;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
document.title = "Case: {{current_case.title}}";
|
||||
console.log('ready');
|
||||
const bc = new BroadcastChannel("viva_load");
|
||||
bc.onmessage = (event) => {
|
||||
console.log("message received");
|
||||
console.log(event);
|
||||
if (event.data.type == "open") {
|
||||
document.getElementById("viewer").src = event.data.url;
|
||||
//document.querySelectorAll("[data-cy='seriesList-btn']")[0].click()
|
||||
bc.postMessage({"type": "open-complete", "url" : event.data.url});
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,231 @@
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from generic.tests.conftest import create_modality, create_examination, create_plane, create_contrast, create_user
|
||||
|
||||
from atlas.models import (
|
||||
Case,
|
||||
Finding,
|
||||
Condition,
|
||||
ConditionRelationship,
|
||||
Presentation,
|
||||
Subspecialty,
|
||||
PathologicalProcess,
|
||||
Differential,
|
||||
Structure,
|
||||
Series,
|
||||
SeriesImage,
|
||||
SeriesFinding,
|
||||
CaseCollection,
|
||||
SeriesDetail,
|
||||
CaseDetail,
|
||||
BaseReportAnswer,
|
||||
CidReportAnswer,
|
||||
UserReportAnswer,
|
||||
SelfReview,
|
||||
UncategorisedDicom,
|
||||
DuplicateDicom,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_finding():
|
||||
return Finding.objects.create(name="Test Finding")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_condition():
|
||||
return Condition.objects.create(name="Test Condition")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_condition_relationship(create_condition):
|
||||
return ConditionRelationship.objects.create(
|
||||
child=create_condition, parent=create_condition
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_presentation():
|
||||
return Presentation.objects.create(name="Test Presentation")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_subspecialty():
|
||||
return Subspecialty.objects.create(name="Test Subspecialty")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_pathological_process():
|
||||
return PathologicalProcess.objects.create(name="Test Pathological Process")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_differential(create_condition, create_case):
|
||||
return Differential.objects.create(condition=create_condition, case=create_case)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_structure():
|
||||
return Structure.objects.create(name="Test Structure")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_case(
|
||||
create_subspecialty,
|
||||
create_condition,
|
||||
create_presentation,
|
||||
create_pathological_process,
|
||||
):
|
||||
subspecialty = create_subspecialty
|
||||
condition = create_condition
|
||||
presentation = create_presentation
|
||||
pathological_process = create_pathological_process
|
||||
user = get_user_model().objects.create(
|
||||
username="Test User", password="Test Password", email="testuser@example.com"
|
||||
)
|
||||
|
||||
case = Case.objects.create(
|
||||
title="Sample Case",
|
||||
description="Sample description",
|
||||
history="Sample history",
|
||||
discussion="Sample discussion",
|
||||
report="Sample report",
|
||||
diagnostic_certainty=Case.CertaintyChoices.LIKELY,
|
||||
verified=True,
|
||||
created_date=timezone.now(),
|
||||
published_date=timezone.now(),
|
||||
archive=False,
|
||||
open_access=True,
|
||||
)
|
||||
|
||||
case.subspecialty.add(subspecialty)
|
||||
case.condition.add(condition)
|
||||
case.presentation.add(presentation)
|
||||
case.pathological_process.add(pathological_process)
|
||||
case.author.add(user)
|
||||
|
||||
return case
|
||||
|
||||
@pytest.fixture
|
||||
def make_case(create_subspecialty, create_condition, create_presentation, create_pathological_process):
|
||||
def _make_case(
|
||||
title="Sample Case",
|
||||
description="Sample description",
|
||||
history="Sample history",
|
||||
discussion="Sample discussion",
|
||||
report="Sample report",
|
||||
diagnostic_certainty=Case.CertaintyChoices.LIKELY,
|
||||
verified=True,
|
||||
created_date=timezone.now(),
|
||||
published_date=timezone.now(),
|
||||
archive=False,
|
||||
open_access=True,
|
||||
):
|
||||
subspecialty = create_subspecialty
|
||||
condition = create_condition
|
||||
presentation = create_presentation
|
||||
pathological_process = create_pathological_process
|
||||
user, _ = get_user_model().objects.get_or_create(
|
||||
username="Test User 2", password="Test Password", email="test@emai.com")
|
||||
|
||||
case = Case.objects.create(
|
||||
title=title,
|
||||
description=description,
|
||||
history=history,
|
||||
discussion=discussion,
|
||||
report=report,
|
||||
diagnostic_certainty=diagnostic_certainty,
|
||||
verified=verified,
|
||||
created_date=created_date,
|
||||
published_date=published_date,
|
||||
archive=archive,
|
||||
open_access=open_access,
|
||||
)
|
||||
case.subspecialty.add(subspecialty)
|
||||
case.condition.add(condition)
|
||||
case.presentation.add(presentation)
|
||||
case.pathological_process.add(pathological_process)
|
||||
case.author.add(user)
|
||||
|
||||
return case
|
||||
return _make_case
|
||||
|
||||
@pytest.fixture
|
||||
def create_series(create_modality, create_examination, create_plane, create_contrast, create_user):
|
||||
author=create_user
|
||||
series_instance_uid="1234567890"
|
||||
modality = create_modality
|
||||
examination = create_examination
|
||||
plane = create_plane
|
||||
contrast = create_contrast
|
||||
|
||||
series = Series.objects.create(
|
||||
modality=modality,
|
||||
examination=examination,
|
||||
plane=plane,
|
||||
contrast=contrast,
|
||||
series_instance_uid=series_instance_uid
|
||||
)
|
||||
|
||||
series.author.add(author)
|
||||
|
||||
return series
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_series_image():
|
||||
return SeriesImage.objects.create()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_series_finding():
|
||||
return SeriesFinding.objects.create(description="Test Description")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_case_collection():
|
||||
return CaseCollection.objects.create(name="Test Collection")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_series_detail():
|
||||
return SeriesDetail.objects.create()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_case_detail():
|
||||
return CaseDetail.objects.create()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_base_report_answer():
|
||||
return BaseReportAnswer.objects.create()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_cid_report_answer():
|
||||
return CidReportAnswer.objects.create()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_user_report_answer():
|
||||
return UserReportAnswer.objects.create()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_self_review():
|
||||
return SelfReview.objects.create()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_uncategorised_dicom():
|
||||
return UncategorisedDicom.objects.create()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_duplicate_dicom():
|
||||
return DuplicateDicom()
|
||||
@@ -1,8 +1,13 @@
|
||||
|
||||
import json
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
import pytest
|
||||
from atlas.models import Finding, Condition
|
||||
from atlas.models import CaseCollection, Finding, Condition
|
||||
#from atlas.views import GenericExamViews
|
||||
from pytest_django.asserts import assertRedirects, assertContains, assertQuerySetEqual
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
#@pytest.mark.django_db
|
||||
def test_create_condition(db):
|
||||
@@ -73,3 +78,77 @@ def test_condition_tree_structure(db):
|
||||
# response = client.get(reverse('rapids:active_exams'))
|
||||
# print(response.content)
|
||||
# assert response.status_code == 200
|
||||
|
||||
def test_create_case(db, create_case):
|
||||
case = create_case
|
||||
assert case.title == "Sample Case"
|
||||
|
||||
def test_create_series(db, create_series):
|
||||
series = create_series
|
||||
assert series
|
||||
|
||||
|
||||
|
||||
def test_collection(db, create_case_collection, create_case, make_case, client, create_user):
|
||||
collection: CaseCollection = create_case_collection
|
||||
|
||||
# create some cases
|
||||
case1 = create_case
|
||||
case2 = make_case()
|
||||
case3 = make_case(title="Case 3")
|
||||
case4 = make_case(title="Case 4")
|
||||
|
||||
collection.cases.set([case1, case2, case3, case4])
|
||||
|
||||
case5 = make_case(title="Case 5")
|
||||
|
||||
assert collection.cases.all().count() == 4
|
||||
|
||||
collection.add_case(case5)
|
||||
|
||||
assert collection.cases.all().count() == 5
|
||||
|
||||
assert collection.get_case_by_index(0) == case1
|
||||
assert collection.get_case_by_index(1) == case2
|
||||
assert collection.get_case_by_index(2) == case3
|
||||
assert collection.get_case_by_index(3) == case4
|
||||
assert collection.get_case_by_index(4) == case5
|
||||
|
||||
assert collection.get_absolute_url() == reverse("atlas:collection_detail", kwargs={"pk": collection.pk})
|
||||
|
||||
assert collection.get_take_url() == reverse("atlas:collection_take_start", kwargs={"pk": collection.pk})
|
||||
|
||||
start_order = collection.get_cases()
|
||||
|
||||
new_order = [
|
||||
case5.pk,
|
||||
case1.pk,
|
||||
case2.pk,
|
||||
case3.pk,
|
||||
case4.pk
|
||||
]
|
||||
response: HttpResponse = client.post(reverse("atlas:exam_json_edit", kwargs={"pk":collection.pk} ), json=new_order, follow=True)
|
||||
# Check that sorting the exam works
|
||||
|
||||
# First attempt is not logged in
|
||||
assertRedirects(response, f"{reverse('login')}?next=%2Fatlas%2Fcollection%2F1%2Fjson_edit")
|
||||
|
||||
client.force_login(create_user)
|
||||
|
||||
# Random user cannot modify order
|
||||
response: HttpResponse = client.post(reverse("atlas:exam_json_edit", kwargs={"pk":collection.pk} ), json=new_order, follow=True)
|
||||
assert response.status_code == 403
|
||||
|
||||
assertQuerySetEqual(start_order , collection.get_cases())
|
||||
|
||||
# Once the user has been made an author, they can modify the order
|
||||
collection.add_author(create_user)
|
||||
response: HttpResponse = client.post(reverse("atlas:exam_json_edit", kwargs={"pk":collection.pk} ), data={"set_exam_order" : json.dumps(new_order)}, follow=True)
|
||||
assert response.status_code == 200
|
||||
|
||||
assert collection.get_cases().count() == 5
|
||||
assert collection.get_case_by_index(0) == case5
|
||||
assert collection.get_case_by_index(1) == case1
|
||||
|
||||
|
||||
|
||||
|
||||
+99
-3
@@ -1,5 +1,5 @@
|
||||
from django.urls import path, include
|
||||
from django.views.generic import RedirectView
|
||||
from django.views.generic import RedirectView, TemplateView
|
||||
|
||||
|
||||
from atlas.models import Condition, Finding, Presentation, Structure
|
||||
@@ -9,6 +9,7 @@ app_name = "atlas"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.index, name="index"),
|
||||
path("help/", TemplateView.as_view(template_name="atlas/help.html"), name="help"),
|
||||
path(
|
||||
"create/",
|
||||
RedirectView.as_view(pattern_name="atlas:case_create", permanent=False),
|
||||
@@ -32,11 +33,26 @@ urlpatterns = [
|
||||
path("author/<int:pk>/", views.author_detail, name="author_detail"),
|
||||
path("author/", views.author_list, name="author_list"),
|
||||
path("case/", views.CaseView.as_view(), name="case_view"),
|
||||
path("collection/<int:collection_id>/add_case", views.add_case_to_collection, name="add_case_to_collection"),
|
||||
path("collection/", views.CollectionView.as_view(), name="collection_view"),
|
||||
path("collection/user", views.user_collections, name="user_collections"),
|
||||
path("uploads", views.user_uploads, name="user_uploads"),
|
||||
path("uploads/<int:user_pk>/user", views.other_user_uploads, name="other_user_uploads"),
|
||||
path("uploads/all", views.all_uploads, name="all_uploads"),
|
||||
path("uploads/new", views.new_uploads, name="new_uploads"),
|
||||
path("uploads/case/<int:case_id>", views.user_uploads, name="user_uploads_case"),
|
||||
path("uploads/<int:user_pk>/user/case/<int:case_id>", views.other_user_uploads, name="other_user_uploads_case"),
|
||||
path("uploads/series_id/<str:series_instance_uid>", views.user_uploads_series, name="user_uploads_series"),
|
||||
path("resource/", views.ResourceView.as_view(), name="resource_view"),
|
||||
path("resource/<int:pk>/", views.resource_detail, name="resource_detail"),
|
||||
path("resource/<int:pk>/view", views.resource_view, name="resource_view"),
|
||||
path("resource/<int:pk>/update", views.ResourceUpdate.as_view(), name="resource_update"),
|
||||
path("resource/<int:pk>/delete", views.ResourceDelete.as_view(), name="resource_delete"),
|
||||
path(
|
||||
"resource/create",
|
||||
views.ResourceCreate.as_view(),
|
||||
name="resource_create",
|
||||
),
|
||||
path(
|
||||
"collection/create",
|
||||
views.CaseCollectionCreate.as_view(),
|
||||
@@ -53,17 +69,38 @@ urlpatterns = [
|
||||
views.CaseCollectionUpdate.as_view(),
|
||||
name="exam_update",
|
||||
),
|
||||
path("collection/viewer", TemplateView.as_view(template_name="atlas/viewer.html"), name="collection_viewer"),
|
||||
path("collection/<int:pk>", views.collection_detail, name="collection_detail"),
|
||||
path("collection/<int:pk>/viva", views.collection_viva, name="collection_viva"),
|
||||
path("collection/<int:pk>/viva/<int:case_number>", views.collection_viva_case, name="collection_viva_case"),
|
||||
path("collection/<int:pk>/viva/<int:series_id>/series", views.collection_viva_series, name="collection_viva_series"),
|
||||
path(
|
||||
"collection/<int:pk>/take",
|
||||
views.collection_take_start,
|
||||
name="collection_take_start",
|
||||
),
|
||||
path("collection/<int:pk>/authors", views.CaseCollectionAuthorUpdate.as_view(), name="collection_authors"),
|
||||
path(
|
||||
"collection/<int:exam_id>/cids",
|
||||
views.GenericExamViews.exam_cids,
|
||||
name="exam_cids",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/user_status",
|
||||
views.GenericExamViews.exam_user_status,
|
||||
name="exam_user_status",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/user_status/<int:cid>/cid",
|
||||
views.GenericExamViews.exam_user_status_cid,
|
||||
name="exam_user_status_cid",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/user_status/<int:user_id>/user",
|
||||
views.GenericExamViews.exam_user_status_user,
|
||||
name="exam_user_status_user",
|
||||
),
|
||||
#path("collection/<int:pk>/<int:sk>/test", views.test_form, name="test_form"),
|
||||
path(
|
||||
"exam/<int:exam_id>/cids/edit",
|
||||
views.GenericExamViews.exam_cids_edit,
|
||||
@@ -74,6 +111,36 @@ urlpatterns = [
|
||||
views.GenericExamViews.exam_users_edit,
|
||||
name="exam_users_edit",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/case/<int:case_id>/details",
|
||||
views.collection_case_details,
|
||||
name="collection_case_details",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/case/<int:case_id>/priors",
|
||||
views.collection_case_priors,
|
||||
name="collection_case_priors",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/case/<int:case_id>/dicom_json",
|
||||
views.collection_case_dicom_json,
|
||||
name="collection_case_dicom_json",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/question_schemas",
|
||||
views.collection_question_schemas,
|
||||
name="collection_question_schemas",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/reset_answers",
|
||||
views.collection_reset_answers,
|
||||
name="collection_reset_answers",
|
||||
),
|
||||
#path(
|
||||
# "question_schemas_preset",
|
||||
# views.question_schemas_preset,
|
||||
# name="question_schemas_preset",
|
||||
#),
|
||||
path(
|
||||
"collection/<int:exam_id>/cids/<int:cid>/delete_answers",
|
||||
views.delete_collection_cid_answers,
|
||||
@@ -134,18 +201,44 @@ urlpatterns = [
|
||||
views.collection_case_view_take_user,
|
||||
name="collection_case_view_take_user",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/<int:case_number>/take/answers",
|
||||
views.collection_case_view_take_user_answers,
|
||||
name="collection_case_view_take_user_answers",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/json_edit",
|
||||
views.GenericExamViews.exam_json_edit,
|
||||
name="exam_json_edit",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/dicom_json",
|
||||
views.collection_dicom_json,
|
||||
name="collection_dicom_json",
|
||||
),
|
||||
path("case/<int:pk>/authors", views.CaseAuthorUpdate.as_view(), name="case_authors"),
|
||||
path(
|
||||
"case/<int:pk>/dicom_json",
|
||||
views.case_dicom_json,
|
||||
name="case_dicom_json",
|
||||
),
|
||||
path(
|
||||
"case/<int:pk>/use_dates_as_descriptions",
|
||||
views.use_dates_as_descriptions,
|
||||
name="use_dates_as_descriptions",
|
||||
),
|
||||
path(
|
||||
"series/<int:pk>/dicom_json",
|
||||
views.series_dicom_json,
|
||||
name="series_dicom_json",
|
||||
),
|
||||
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"),
|
||||
path("question_schema/<int:pk>/update", views.QuestionSchemaUpdate.as_view(), name="question_schema_update"),
|
||||
path("question_schema/<int:pk>/delete", views.QuestionSchemaDelete.as_view(), name="question_schema_delete"),
|
||||
path("question_schema/create", views.QuestionSchemaCreate.as_view(), name="question_schema_create"),
|
||||
path("question_schema/schemas", views.question_schema_schemas, name="question_schema_schemas"),
|
||||
path("condition/", views.ConditionView.as_view(), name="condition_view"),
|
||||
path("categories/", views.categories_list, name="categories_list"),
|
||||
path("condition/<int:pk>", views.condition_detail, name="condition_detail"),
|
||||
@@ -191,7 +284,9 @@ urlpatterns = [
|
||||
name="structure_update",
|
||||
),
|
||||
path("structure/create", views.StructureCreate.as_view(), name="structure_create"),
|
||||
path("series/<int:pk>/thumbnail", views.series_thumbnail, name="series_thumbnail"),
|
||||
path("series/<int:pk>", views.series_detail, name="series_detail"),
|
||||
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
|
||||
path("series/", views.SeriesView.as_view(), name="series_view"),
|
||||
path("series_image/<int:pk>/dicom", views.series_image_dicom, name="series_image_dicom"),
|
||||
path(
|
||||
@@ -215,7 +310,7 @@ urlpatterns = [
|
||||
name="series_order_dicom",
|
||||
),
|
||||
path(
|
||||
"images/<diff",
|
||||
"images/diff",
|
||||
views.image_diff,
|
||||
name="image_diff",
|
||||
),
|
||||
@@ -330,4 +425,5 @@ urlpatterns = [
|
||||
views.pathological_process_detail,
|
||||
name="pathological_process_detail",
|
||||
),
|
||||
path("combine_series/", views.combine_series, name="combine_series"),
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user