Compare commits

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

Some files were not shown because too many files have changed in this diff Show More