Compare commits

...
192 Commits
Author SHA1 Message Date
Ross 3cb84fb7c4 staging 2024-08-18 22:29:48 +01:00
Ross c2185f73a6 add a redirct view mixin 2024-08-12 11:58:28 +01:00
Ross 8beb6890a7 fix date wrangling 2024-08-12 11:09:36 +01:00
Ross 2d185aa95f a few more management changes 2024-08-12 10:56:20 +01:00
Ross 6fe2bae8a8 improve exam group management 2024-08-12 10:33:15 +01:00
Ross bd1bb54f9f fix a little question ordering bug 2024-08-12 09:11:27 +01:00
Ross b73b43d155 tidy up the marker changes 2024-08-05 13:20:29 +01:00
Ross e46f843284 add markers to exams (move away from group based to content based permissions) 2024-08-05 10:45:38 +01:00
Ross cff5a46e4c fix question ordering through mark systems 2024-08-05 10:00:59 +01:00
Ross a304c0b4dc some reset ansnwer improvements 2024-07-29 13:05:09 +01:00
Ross 88584f4c37 fix some relations 2024-07-29 12:58:16 +01:00
Ross 21f85c3cac . 2024-07-29 12:44:31 +01:00
Ross cf48453e6f allow resenting of exam answers 2024-07-29 12:43:01 +01:00
Ross 2ebb0b4473 try to fix longs scoring bug 2024-07-29 11:47:45 +01:00
Ross 74d300ab16 some more admin improvements 2024-07-29 11:38:52 +01:00
Ross d8c83ae750 remove now unecessary fileds from ExamUserStatus 2024-07-29 10:37:55 +01:00
Ross e34d84d5f8 improve some admin pages 2024-07-29 10:35:21 +01:00
Ross 62683a7876 speed up some admin pages 2024-07-29 10:00:39 +01:00
Ross 9e08c69684 hide some forms 2024-07-29 09:19:59 +01:00
Ross 9adc0f430f . 2024-07-22 17:40:49 +01:00
Ross a42f57ca05 . 2024-07-22 17:38:20 +01:00
Ross aeaf0fcb2e migarate exam_user_status 2024-07-22 17:25:12 +01:00
Ross ca4c8d31b9 pre refactor exam_user_status 2024-07-22 11:48:10 +01:00
Ross 07d766cb30 fix some collection issues 2024-07-22 11:35:37 +01:00
Ross fe8c5b208b shart showing question answers on averview 2024-07-22 09:23:22 +01:00
Ross eebe98ddda . 2024-07-22 09:11:27 +01:00
Ross 1acc9808b5 . 2024-07-22 09:07:08 +01:00
Ross 0915ef555c . 2024-07-17 22:30:00 +01:00
Ross 44ebe1a517 . 2024-07-17 22:29:28 +01:00
Ross 10b83b2ad6 . 2024-07-17 22:26:53 +01:00
Ross 9fe8c843ff try to add priors to ohif 2024-07-17 22:18:43 +01:00
Ross fbdc7c87b4 add some links 2024-07-17 22:05:59 +01:00
Ross 7075bba7a2 enable priors in atlas 2024-07-17 22:00:57 +01:00
Ross 35e4b81e34 a few fixes for exam marking 2024-07-15 11:24:02 +01:00
Ross 7ef68c035b order series didn't apply to cases 2024-07-15 11:19:12 +01:00
Ross 19a9a27747 tidy up some longs related stuff 2024-07-08 13:14:11 +01:00
Ross bcfcd8da82 tidy up exam list a little 2024-07-08 10:10:51 +01:00
Ross df462efacd Start using start dates for exams 2024-07-08 09:52:01 +01:00
Ross 00fa68247d further improvements to questions 2024-07-01 10:39:33 +01:00
Ross b81fbbfea6 . 2024-07-01 09:16:51 +01:00
Ross 8fc74abd6e add comments to rcr results 2024-07-01 09:15:58 +01:00
Ross cef06e4682 add question schema model and integrate most urls/views 2024-06-24 11:43:33 +01:00
Ross 71fefe6c4f speed up viva load page 2024-06-24 10:10:21 +01:00
Ross 61767c62b0 . 2024-06-24 09:53:10 +01:00
Ross 83e2f03eb5 . 2024-06-24 09:48:50 +01:00
Ross 27f817f321 . 2024-06-24 09:45:55 +01:00
Ross 8636610fea . 2024-06-24 09:44:30 +01:00
Ross 503e4d88aa fix viva display 2024-06-24 09:38:56 +01:00
Ross 2696d192c9 . 2024-06-24 09:18:27 +01:00
Ross 64cebf50a0 Numerous improvements to case questions 2024-06-24 08:59:15 +01:00
Ross db43bcf557 . 2024-06-22 09:51:44 +01:00
Ross 30cd998d84 switch to full name 2024-06-22 09:48:40 +01:00
Ross 9bf8034179 add assessed by to the results table 2024-06-20 13:59:22 +01:00
Ross e46d682635 improve case quesitons 2024-06-10 17:27:37 +01:00
Ross 9026ee5390 improve json editing 2024-06-10 09:59:43 +01:00
Ross bdde50effc typo 2024-06-10 09:59:33 +01:00
Ross f0b676fd23 add specialyt to rcr results 2024-06-02 09:01:30 +01:00
Ross 966de4a139 tidy up exam start and end dates a little 2024-05-20 10:06:04 +01:00
Ross d50fbaafd6 . 2024-05-18 18:11:12 +01:00
Ross 1698612d6a . 2024-05-18 17:42:38 +01:00
Ross 62f7663461 add rcr platform changes 2024-05-18 17:31:33 +01:00
Ross 741b8683c9 some more migrations 2024-05-18 17:31:23 +01:00
Ross 4367b88057 start adding exam dates 2024-05-18 08:39:45 +01:00
Ross f37b3312e5 improve exam list view 2024-05-13 11:10:25 +01:00
Ross b014722d2e show human readable time on exam overview page 2024-05-13 10:08:01 +01:00
Ross 89e9ded09a add some more detials to user management 2024-05-13 09:55:26 +01:00
Ross 21d83052cf numerous uploading fixes 2024-05-13 09:48:43 +01:00
Ross 490d1956af improve webpage atlas uploads 2024-04-29 10:42:41 +01:00
Ross fa613ae116 improve uploading 2024-04-22 15:17:31 +01:00
Ross 6c57e35f06 fix type 2024-04-15 14:05:04 +01:00
Ross f1642c7f01 many improvements 2024-04-15 14:02:01 +01:00
Ross 8c6361f9b2 add some image deps for losseless dicom 2024-04-15 10:28:45 +01:00
Ross 1e38078c0a update series to use middle image 2024-04-15 10:18:49 +01:00
Ross ab9b16e7f9 allow basic thumbnail recreation 2024-04-15 10:07:27 +01:00
Ross ca2a3e065f add option to order series 2024-04-13 16:58:55 +01:00
Ross 4be9e24737 . 2024-04-10 23:34:28 +01:00
Ross 3b6128aa84 . 2024-04-10 23:26:29 +01:00
Ross dff4705d72 fix updating a collection if you don't have access to all the cases 2024-04-10 23:24:41 +01:00
Ross e8f862c7bd . 2024-04-10 22:58:19 +01:00
Ross b1fc763938 . 2024-04-10 22:57:46 +01:00
Ross b3cbd48470 . 2024-04-10 22:46:16 +01:00
Ross bf519a2807 lots of further updates 2024-04-10 22:43:57 +01:00
Ross 98756baa37 i should break these commits up 2024-04-08 14:16:13 +01:00
Ross f13d1ba7e2 fix a number of issues and start adding resources 2024-04-04 23:15:22 +01:00
Ross 2fef934323 . 2024-04-04 20:20:11 +01:00
Ross a62023a5d8 add context to cloning forms 2024-03-11 11:48:37 +00:00
Ross 0cb38f5c31 start taking more advantage of class based views 2024-03-11 11:08:35 +00:00
Ross 78e4ed871e improved duplicate handling 2024-02-27 22:59:29 +00:00
Ross 3ab40304a3 Add an option to edit the author of cases/collections 2024-02-27 21:57:15 +00:00
Ross 444ff0e983 add exam authors to collection/exam base 2024-02-27 15:07:42 +00:00
Ross f2b4e05ba5 fix collection ordering 2024-02-27 15:06:08 +00:00
Ross 99eb6037d7 fix collection ordering 2024-02-27 08:41:15 +00:00
Ross 9d0cf66441 major update to test 2024-02-26 22:30:36 +00:00
Ross 8299efabf9 fix rcr tests 2024-02-26 21:24:48 +00:00
Ross f4d2ab85f9 . 2024-02-26 14:53:44 +00:00
Ross 3b40417532 . 2024-02-26 14:51:56 +00:00
Ross 80cccba6f3 get or create returns a tuple.. 2024-02-26 14:34:20 +00:00
Ross e2d63d6b16 auto add examination when uploading 2024-02-26 13:39:58 +00:00
Ross a816af5c7e . 2024-02-26 13:31:23 +00:00
Ross 77473a35d0 add some new functions to case series 2024-02-26 13:27:54 +00:00
Ross f63f4c6fa5 remove temp logging 2024-02-26 12:24:19 +00:00
Ross b6aa63d494 . 2024-02-26 12:17:55 +00:00
Ross 85d69dfcbd . 2024-02-26 12:16:02 +00:00
Ross 1af4d464e4 . 2024-02-26 12:14:03 +00:00
Ross 6c5537f335 . 2024-02-26 12:09:40 +00:00
Ross fc7cac3512 . 2024-02-26 12:08:24 +00:00
Ross 0631b7d714 . 2024-02-26 12:07:26 +00:00
Ross 87b7395917 . 2024-02-26 12:05:00 +00:00
Ross 805425b816 . 2024-02-26 12:02:59 +00:00
Ross c31c7ba37b . 2024-02-26 12:01:58 +00:00
Ross c5a2deb26f . 2024-02-26 12:01:40 +00:00
Ross 085a8bf4e3 . 2024-02-26 12:00:46 +00:00
Ross 18fa9b4a94 start logging 2024-02-26 11:59:40 +00:00
Ross 0bed834b47 . 2024-02-26 11:52:50 +00:00
Ross 0ea63a30a5 make box bigger 2024-02-26 11:50:59 +00:00
Ross 1963d0a3ef remove single fk 2024-02-26 11:47:42 +00:00
Ross fdc7c6150b migrate categories to a multiselect 2024-02-26 11:45:39 +00:00
Ross df632319f5 . 2024-02-26 10:58:43 +00:00
Ross 3402dde43c . 2024-02-26 10:55:22 +00:00
Ross 5c3ba02674 try to prevent atomic transation error 2024-02-26 10:47:11 +00:00
Ross ba8fa60adf allow admin viewing of other user uploads 2024-02-26 10:09:33 +00:00
Ross 1f109de25b see if atomic transactions fixes case uploads 2024-02-26 09:58:47 +00:00
Ross ca05c67b87 update seriesfinding to contain conditions 2024-02-24 22:39:45 +00:00
Ross eea8580c59 start long tests 2024-02-24 21:19:02 +00:00
Ross d1a7ccaae1 same basic rcr unit tests 2024-02-22 23:03:23 +00:00
Ross 9435463707 logout now needs to be a post request 2024-02-21 23:07:18 +00:00
Ross 081f8cbcee get ready for django 5 2024-02-18 22:21:04 +00:00
Ross 7c9192986a begin correct sorting of exams/collection 2024-02-18 21:56:19 +00:00
Ross 4721122e4d start testing rcr 2024-02-18 21:56:00 +00:00
Ross 4629b08e2f . 2024-02-17 23:54:04 +00:00
Ross baa1ffe890 . 2024-02-17 23:44:56 +00:00
Ross 8793f346ba clean up 2024-02-17 23:17:32 +00:00
Ross 5ea910cd63 migrate teh rest 2024-02-17 23:16:06 +00:00
Ross 3a52855eb7 migrate rapids 2024-02-17 23:01:43 +00:00
Ross 0bc2830ea2 update anatomy to remove sortedm2m 2024-02-17 22:50:57 +00:00
Ross 4e7838c17f . 2024-02-17 22:05:13 +00:00
Ross b8656b1e91 migrate atlas series from sortedm2m 2024-02-17 21:56:54 +00:00
Ross f5d429498a pre sm2m removal 2024-02-17 21:22:29 +00:00
Ross 56c165b3b1 . 2024-02-15 23:14:56 +00:00
Ross f11f07504c . 2024-02-15 22:23:30 +00:00
Ross ec7c354945 . 2024-02-15 22:21:36 +00:00
Ross bd1a4fb529 . 2024-02-15 22:18:55 +00:00
Ross 7c6af8df89 . 2024-02-15 22:17:28 +00:00
Ross fb7074475a . 2024-02-15 22:16:08 +00:00
Ross 017710042b . 2024-02-15 22:12:56 +00:00
Ross e9d82d5922 . 2024-02-15 22:09:40 +00:00
Ross 8639f9e6ed switch to broadcast channel for page maangement 2024-02-15 22:07:04 +00:00
Ross b3761ccc96 . 2024-02-14 22:40:12 +00:00
Ross 684156fafe . 2024-02-14 22:05:08 +00:00
Ross af4f742d3e . 2024-02-14 18:28:38 +00:00
Ross 5bf0095038 show case details in viva vew 2024-02-14 18:25:43 +00:00
Ross 8d0e666a96 add basic atlas help 2024-02-14 17:44:00 +00:00
Ross 6c8694822b . 2024-02-13 22:59:56 +00:00
Ross 8aade14772 . 2024-02-13 22:56:51 +00:00
Ross 0da9a1802a . 2024-02-13 22:55:36 +00:00
Ross 6bac0e4cc7 . 2024-02-13 22:53:25 +00:00
Ross ee29eaf307 . 2024-02-13 22:50:45 +00:00
Ross c3d7546d79 . 2024-02-13 22:48:25 +00:00
Ross 2f55efb6a5 . 2024-02-13 22:45:48 +00:00
Ross bf09390160 . 2024-02-13 22:42:36 +00:00
Ross 862755179a . 2024-02-13 22:33:42 +00:00
Ross 29bea3d63a . 2024-02-13 22:30:24 +00:00
Ross 92fca51b79 . 2024-02-13 22:27:03 +00:00
Ross 95237b5a29 . 2024-02-13 22:02:25 +00:00
Ross e18b2cd399 . 2024-02-13 21:59:30 +00:00
Ross e7a639b57a . 2024-02-13 21:57:29 +00:00
Ross 8fa91f4323 . 2024-02-13 21:55:49 +00:00
Ross 78efa6ed68 . 2024-02-13 15:14:49 +00:00
Ross 8f5792b879 . 2024-02-13 15:14:18 +00:00
Ross 3c10d89478 . 2024-02-13 15:12:57 +00:00
Ross 4d32f1d826 . 2024-02-13 15:12:26 +00:00
Ross c6d27a9425 . 2024-02-13 15:11:44 +00:00
Ross a6e25a8f0c . 2024-02-13 15:11:01 +00:00
Ross 9cc1e9b5d1 . 2024-02-13 15:07:07 +00:00
Ross 58446be3b4 . 2024-02-13 15:06:20 +00:00
Ross ccb0b8abcc . 2024-02-13 15:04:42 +00:00
Ross b2a2289625 . 2024-02-13 15:03:28 +00:00
Ross 3f68a98abd . 2024-02-13 14:55:17 +00:00
Ross b60de142e7 tidy up collection viva 2024-02-13 13:43:30 +00:00
Ross 580753c0d1 start viva collections 2024-02-13 09:50:12 +00:00
Ross bbd28c3e4b . 2024-02-12 22:00:01 +00:00
Ross 093d8cf097 . 2024-02-12 21:57:43 +00:00
Ross d8b8733fd3 . 2024-02-12 21:56:59 +00:00
Ross dd2702d495 . 2024-02-12 21:55:45 +00:00
Ross dbdba072d0 . 2024-02-12 21:43:23 +00:00
Ross 268a8addda . 2024-02-12 21:42:22 +00:00
Ross b9cdbb98ef numerous updates to case collections 2024-02-12 13:05:26 +00:00
Ross c3f7913402 improve dicom diff (still needs work) 2024-02-12 08:39:16 +00:00
Ross 20d05772e6 upgrade docker python version 2024-02-12 08:39:02 +00:00
Ross 6be11d3fdf fix new dicom diff 2024-02-07 14:42:13 +00:00
Ross e21321adc2 start new dicom diff 2024-02-07 14:25:50 +00:00
Ross 9ff395325f few small fixes 2024-02-07 14:04:42 +00:00
255 changed files with 8871 additions and 1917 deletions
+2
View File
@@ -8,4 +8,6 @@ venv
.venv/
.env
*.log
temp/
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
@@ -231,4 +231,14 @@ 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),
),
]
@@ -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),
),
]
+20
View File
@@ -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
View File
@@ -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)
+10 -1
View File
@@ -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);
+172 -9
View File
@@ -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])
@@ -196,9 +194,9 @@
checkHash(hash, el);
if ($("#drop-filenames").length > 0) {
$("#drop-filenames").append(
`<span data-input-id='${el.id}'><span>${n}: ${el.files[0].name}</span><img class='uploading${extra_class}' data-input-id='${el.id}' src=${url}></span>`
)
$("#drop-filenames").append(
`<span data-input-id='${el.id}'><span>${n}: ${el.files[0].name}</span><img class='uploading${extra_class}' data-input-id='${el.id}' src=${url}></span>`
)
}
} else {
imageId = `wadouri:${url}`;
@@ -223,7 +221,7 @@
console.log("image", image)
let bytesView = image.getPixelData()
let str = new TextDecoder("utf-8").decode(bytesView, );
let str = new TextDecoder("utf-8").decode(bytesView, );
console.log("bytes", bytesView)
console.log("str", str)
@@ -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', {
@@ -376,4 +374,169 @@
updateImagePositions();
//}
}
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);
}
}
-16
View File
@@ -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 %}
+5 -2
View File
@@ -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 %}
+1 -1
View File
@@ -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> /
+1 -1
View File
@@ -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 %}
+3 -3
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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)
View File
+4
View File
@@ -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):
+48 -9
View File
@@ -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,9 +278,14 @@ 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:
data = { "id": False, "url": False}
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
@@ -324,4 +363,4 @@ def series_order_by_tag(request, series_id: int, dicom_tag:str):
series.order_by_dicom(dicom_tag)
return [f"{series} ordered by {dicom_tag}"]
return [f"{series} ordered by {dicom_tag}"]
+6
View File
@@ -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
View File
@@ -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),
# )
+13
View File
@@ -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),
),
]
+26
View File
@@ -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),
),
]
@@ -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),
),
]
+24
View File
@@ -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),
),
]
+26
View File
@@ -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),
),
]
+469 -27
View File
@@ -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,8 +402,15 @@ 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):
return f"{self.pk}: {self.title}"
if self.open_access:
return f"{self.pk}: {self.title} [OA]"
else:
return f"{self.pk}: {self.title}"
def get_series_blocks(self):
html = ""
@@ -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,8 +1253,10 @@ class UncategorisedDicom(models.Model):
def check_for_duplicates(self, image_hash):
duplicate = None
if obj := UncategorisedDicom.objects.filter(image_blake3_hash=image_hash).first():
#duplicate = obj
if obj := UncategorisedDicom.objects.filter(
image_blake3_hash=image_hash
).first():
# duplicate = obj
return obj
print(image_hash)
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
{
"disable_edit_json": true,
"disable_collapse": true,
"disable_properties": true
}
+14
View File
@@ -0,0 +1,14 @@
{
"title": "Report",
"description": "Report text",
"type": "object",
"required": [
"report"
],
"properties": {
"report": {
"type": "string",
"title": "Report"
}
}
}
+25 -2
View File
@@ -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",
@@ -336,4 +338,25 @@ 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
+6 -1
View File
@@ -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:
+1 -26
View File
@@ -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' %}
+172 -138
View File
@@ -1,156 +1,190 @@
<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" }}
</div>
<div class="id">
ID: {{ case.id }}
</div>
<p class="pre-whitespace"><b>Title:</b> {{ case.title }}</p>
<div class="date">
{{ case.created_date|date:"d/m/Y" }}
</div>
<div class="id">
ID: {{ case.id }}
</div>
<p class="pre-whitespace"><b>Title:</b> {{ case.title }}</p>
<p class="pre-whitespace"><b>Description:</b> {{ case.description }}</p>
<p class="pre-whitespace"><b>Description:</b> {{ case.description }}</p>
<p>
<b>Presentation:</b>
<ul>
{% for presentation in case.presentation.all %}
<li>{{presentation.get_link}}</li>
{% endfor %}
</ul>
</p>
<p>
<b>Presentation:</b>
<ul>
{% for presentation in case.presentation.all %}
<li>{{presentation.get_link}}</li>
{% endfor %}
</ul>
</p>
<p class="pre-whitespace"><b>History:</b> {{ case.history }}</p>
<p class="pre-whitespace"><b>History:</b> {{ case.history }}</p>
<p class="pre-whitespace"><b>Discussion:</b> {{ case.discussion }}</p>
<p class="pre-whitespace"><b>Discussion:</b> {{ case.discussion }}</p>
<p class="pre-whitespace"><b>Report:</b> {{ case.report }}</p>
<div class="pre-whitespace multi-image-block"><b>Series:</b>
{% for series in case.series.all %}
<span class="series-block">
<span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
<a href="{% url 'atlas:series_detail' pk=series.pk %}">
{{series.get_block}}
</a>
<br>
<span class="series-block-popup-link">
<a href="#"
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
</span>
</span>
</span>
<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 %}
<span>
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Add new series</a><br />
<a href="{% url 'atlas:user_uploads_case' case_id=case.pk %}">Import new uploads</a><br />
<details>
<summary>+</summary>
<button hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
title="order dicom by slice location"
hx-target="this"
hx-swap="outerHTML">
Order all series dicoms by slice location
</button>
</details>
</p>
</span>
</div>
<div class="pre-whitespace multi-image-block"><b>Series:</b>
<form>
{% for series in case.series.all %}
<span class="series-block">
<span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
<a href="{% url 'atlas:series_detail' pk=series.pk %}">
{{series.get_block}}
</a>
<br>
<input type="checkbox" name="series-ids" value="{{series.pk}}">
<span class="series-block-popup-link">
<a href="#"
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
</span>
</span>
</span>
{% endfor %}
</form>
<span>
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Add new series</a><br />
<a href="{% url 'atlas:user_uploads_case' case_id=case.pk %}">Import new uploads</a><br />
<details>
<summary>+</summary>
<button hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
title="order dicom by slice location"
hx-target="this"
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>
</div>
{% comment %} <p><b>Scrapped:</b> {{ case.scrapped }} <a
href="{% url 'atlas:case_scrap' pk=case.pk %}">(toggle)</a> {% endcomment %}
<div class="atlas-answer">
<p class="pre-whitespace">
Condition:
<ul>
{% for con in case.condition.all %}
<li>{{con.get_link}}</li>
{% endfor %}
</ul>
<div>
<details>
<summary><b>Findings</b></summary>
{% if case.series.all %}
{% for series in case.series.all %}
{% for finding in series.findings.all %}
<div class="finding-box">
<span>
<a href="{{series.get_absolute_url}}">Series {{series.pk}}</a>: {{series.get_examination_full}} |&nbsp;
</span>
{% if finding.findings.all %}
<div class="atlas-answer">
<p class="pre-whitespace">
Condition:
<ul>
{% for con in case.condition.all %}
<li>{{con.get_link}}</li>
{% endfor %}
</ul>
<div>
<details>
<summary><b>Findings</b></summary>
{% if case.series.all %}
{% for series in case.series.all %}
{% for finding in series.findings.all %}
<div class="finding-box">
<span>
Findings: {% for f in finding.findings.all %}
{{f.get_link}},&nbsp;
{% endfor %}
<a href="{{series.get_absolute_url}}">Series {{series.pk}}</a>: {{series.get_examination_full}} |&nbsp;
</span>
{% endif %}
{% if finding.structures.all %}
<span>
Structure: {% for s in finding.structures.all %}
{{s.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.description %}
<span>
Description: {{finding.description}}
</span>
{% endif %}
</div>
{% if finding.findings.all %}
<span>
Findings: {% for f in finding.findings.all %}
{{f.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.structures.all %}
<span>
Structure: {% for s in finding.structures.all %}
{{s.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.description %}
<span>
Description: {{finding.description}}
</span>
{% endif %}
</div>
{% endfor %}
{% endfor %}
{% endfor %}
{% else %}
No series associated with case.
{% endif %}
</details>
</div>
<div>
<details open>
<summary><b>Differentials</b></summary>
<ul>
{% for diff in case.differentialcase.all %}
<li>{{diff.condition.get_link}} - {{diff.text}}</li>
{% endfor %}
</ul>
</details>
</div>
</p>
{% else %}
No series associated with case.
{% endif %}
</details>
</div>
<div>
<details open>
<summary><b>Differentials</b></summary>
<ul>
{% for diff in case.differentialcase.all %}
<li>{{diff.condition.get_link}} - {{diff.text}}</li>
{% endfor %}
</ul>
</details>
</div>
</p>
</div>
Subspecialty:
<ul>
{% for sub in case.subspecialty.all %}
<li>{{sub.get_link}}</li>
{% endfor %}
</ul>
<b>Pathological Process:</b>
<ul>
{% for p in case.pathological_process.all %}
<li>{{p.get_link}}</li>
{% endfor %}
</ul>
<b>Diagnostic certainty:</b> {{case.get_diagnostic_certainty_display}}<br />
<b>Collections:</b>
<button
hx-get="{% url 'atlas:case_collection_form' case.pk %}"
hx-target="#collection-form">
Add collection</button>
<div id="collection-form"></div>
<ul>
{% for collection in case.casecollection_set.all %}
<li><a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{collection.name}}</a></li>
{% endfor %}
</ul>
</p>
<p class="pre-whitespace"><b>Previous case:</b> {{ case.previous_case.get_link }}</p>
<p class="pre-whitespace"><b>Next case:</b> {{ case.next_case.get_link }}</p>
<div>
{% include 'question_notes.html' %}
</div>
Subspecialty:
<ul>
{% for sub in case.subspecialty.all %}
<li>{{sub.get_link}}</li>
{% endfor %}
</ul>
<b>Pathological Process:</b>
<ul>
{% for p in case.pathological_process.all %}
<li>{{p.get_link}}</li>
{% endfor %}
</ul>
<b>Diagnostic certainty:</b> {{case.get_diagnostic_certainty_display}}<br />
<b>Collections:</b>
<button
hx-get="{% url 'atlas:case_collection_form' case.pk %}"
hx-target="#collection-form">
Add collection</button>
<div id="collection-form"></div>
<ul>
{% for collection in case.casecollection_set.all %}
<li><a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{collection.name}}</a></li>
{% endfor %}
</ul>
</p>
<p class="pre-whitespace"><b>Previous case:</b> {{ case.previous_case.get_link }}</p>
<p class="pre-whitespace"><b>Next case:</b> {{ case.next_case.get_link }}</p>
<div>
{% include 'question_notes.html' %}
</div>
<p><b>Checked by:</b> {% for verified in case.verified.all %} <a
href="{% url 'atlas:verified_detail' pk=verified.pk %}">{{verified}}</a>, {% endfor %}</p>
<p><b>Checked by:</b> {% for verified in case.verified.all %} <a
href="{% url 'atlas:verified_detail' pk=verified.pk %}">{{verified}}</a>, {% endfor %}</p>
<p><b>Author(s):</b> {% for author in case.author.all %} <a
href="{% url 'atlas:author_detail' pk=author.pk %}">{{author}}</a>, {% endfor %}</p>
<p><b>Author(s):</b> {% for author in case.author.all %} <a
href="{% url 'atlas:author_detail' pk=author.pk %}">{{author}}</a>, {% endfor %}</p>
+61 -22
View File
@@ -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 %}
+22 -13
View File
@@ -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,29 +47,54 @@
</div>
{% endif %}
<div class="pre-whitespace multi-image-block">
<details open>
<summary>
Images
</summary>
{% for series in series_list %}
<span class="series-block">
<a href="#" onclick='window.loadDicomViewer(window.images[{{forloop.counter0}}])'>
<span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span>
{{series.get_block}}
</span>
</a>
</span>
{% endfor %}
</details>
</div>
{% 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 %}
<div>
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''>
{% 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, 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 %}
</details>
</div>
</div>
<div>
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''>
</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 %}
</p>
</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 %}
+44 -6
View File
@@ -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>
{{answer_count}} out of {{collection_length}} cases answered. Click to go to case.
{% 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 %}
{{answer.answer}}
<details><summary>Answered</summary>
{% if aswer.answer %}
{{answer.answer}}
{% else %}
{{answer.json_answer}}
{% endif %}
</details>
{% endif %}
{% endif %}
</span>
@@ -40,28 +58,25 @@
</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
hx-confirm="Finish session?"
hx-target="#completed-state"
_="on htmx:afterOnLoad remove me"
>
<button type="submit" name="finish" value="finish">Finish</button>
</form>
{% endif %}
<form hx-post=""
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete
hx-confirm="Finish session?"
hx-target="#completed-state"
_="on htmx:afterOnLoad remove me"
>
<button type="submit" name="finish" value="finish">Finish</button>
</form>
{% endif %}
{% endblock %}
{% block js %}
@@ -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 %}
+177
View File
@@ -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}} &nbsp;
{% comment %} <button class="show-case no-padding" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Show</button> {% endcomment %}
{% comment %} <button class="send-message no-padding" data-title="{{case.title}}" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Open</button> {% endcomment %}
{% comment %} <button class="open-case no-padding" data-target="{% url 'atlas:collection_viva_case' collection.pk forloop.counter0 %}" data-type="case">Load</button> {% endcomment %}
<button class="open-case no-padding" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}" data-type="case">Load</button>
<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>
+5 -1
View File
@@ -3,7 +3,11 @@
{% block navigation %}
{{block.super}}
{% include "atlas/collection_headers.html" %}
{% 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> /
+36
View File
@@ -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 %}
+6 -2
View File
@@ -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 %}
+461
View File
@@ -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
View File
@@ -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
View File
@@ -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 %}
+36
View File
@@ -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 %}
+26
View File
@@ -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>
+21
View File
@@ -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 %}
+2 -2
View File
@@ -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 }}
+6 -5
View File
@@ -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 %}
+12 -162
View File
@@ -6,8 +6,8 @@
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
<script type="text/javascript">
let csrf_token = "{{csrf_token}}";
let hash_url = false;
let csrf_token = "{{csrf_token}}";
let hash_url = false;
document.addEventListener('drop', function (e) { e.preventDefault(); }, false);
$(document).ready(function () {
@@ -247,177 +247,27 @@
//
// }
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.
<form action="" method="post" enctype="multipart/form-data" id="atlas-form">
{% csrf_token %}
<a href="/generic/examination/create" id="add_examination" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
<table>
{{ form.as_table }}
</table>
@@ -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 %}
+9 -1
View File
@@ -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 %}
+36 -9
View File
@@ -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.
@@ -16,13 +18,13 @@
{% for series, n, tags, date in series_list %}
<li>
<span _="
on click toggle .selected on me
then set i to the first <input/> in me
then set i.checked to not i.checked" >
{{date}}: {{series}} [{{n}} images] <br/>
{{tags}}
<input class="hidden" type="checkbox" name="selection" value="{{ series }}">
</span>
on click toggle .selected on me
then set i to the first <input/> in me
then set i.checked to not i.checked" >
{{date}}: {{series}} [{{n}} images] <br/>
{{tags}}
<input class="hidden" type="checkbox" name="selection" value="{{ series }}">
</span>
<span class="series-block-popup-link">
<a href="#"
onclick="return window.create_popup_window('/atlas/uploads/series_id/{{series}}', 'Series')">Popup</a>
@@ -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>
+43
View File
@@ -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>
-3
View File
@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.
+231
View File
@@ -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()
+81 -2
View File
@@ -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):
@@ -72,4 +77,78 @@ def test_condition_tree_structure(db):
# client.login(username="basicuser", password="password")
# response = client.get(reverse('rapids:active_exams'))
# print(response.content)
# assert response.status_code == 200
# 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
View File
@@ -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