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 # pull official base image
FROM python:3.12.1-slim FROM python:3.12.1-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# set work directory # set work directory
WORKDIR /usr/src/rad WORKDIR /usr/src/rad
@@ -13,13 +14,18 @@ ENV PYTHONUNBUFFERED 1
# python3 python3-dev gcc \ # python3 python3-dev gcc \
# gfortran musl-dev \ # gfortran musl-dev \
# libffi-dev openssl-dev # libffi-dev openssl-dev
RUN apt-get update && apt-get -y install postgresql postgresql-contrib git libcairo2-dev pkg-config gcc RUN apt-get update && apt-get -y install postgresql postgresql-contrib git libcairo2-dev pkg-config gcc redis-tools
# install dependencies # install dependencies
RUN pip install --upgrade pip #RUN pip install --upgrade pip
COPY ./requirements.txt . 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 project
COPY . . COPY . .
+6 -1
View File
@@ -252,4 +252,9 @@ class ExamMarkerForm(ExamMarkerFormMixin):
class ExamGroupsForm(ExamGroupsFormMixin): class ExamGroupsForm(ExamGroupsFormMixin):
class Meta(ExamGroupsFormMixin.Meta): 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 from helpers.images import image_as_base64
import reversion import reversion
import urllib.parse
image_storage = FileSystemStorage( image_storage = FileSystemStorage(
# Physical file location ROOT # Physical file location ROOT
@@ -335,6 +336,13 @@ class Answer(models.Model):
This just mirrors the result from the attached question""" This just mirrors the result from the attached question"""
return self.question.can_edit(user) 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): # def get_compare_string(self):
# s = self.answer.lower().strip() # s = self.answer.lower().strip()
# s = s.translate(str.maketrans('', '', string.punctuation)) # s = s.translate(str.maketrans('', '', string.punctuation))
@@ -396,7 +404,6 @@ class Exam(ExamBase):
markers = models.ManyToManyField( markers = models.ManyToManyField(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
blank=True, blank=True,
null=True,
help_text="Authorised markers for the exam", help_text="Authorised markers for the exam",
related_name="anatomy_exam_markers", related_name="anatomy_exam_markers",
) )
@@ -541,6 +548,8 @@ class UserAnswer(UserAnswerBase):
# s = self.answer.lower().strip() # s = self.answer.lower().strip()
# s = s.translate(str.maketrans('', '', string.punctuation)) # s = s.translate(str.maketrans('', '', string.punctuation))
# return s # return s
def get_answer_url_safe(self):
return urllib.parse.quote(self.answer_compare)
def get_answer_string(self): def get_answer_string(self):
return self.answer 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 () { $(document).ready(function () {
// Select all text input fields and textareas with the clear button
clearableTextAreaSetup();
document.body.addEventListener("htmx:responseError", function(e) { document.body.addEventListener("htmx:responseError", function(e) {
let error = e.detail.xhr.response;
let el = document.getElementById("htmx-error"); 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.innerHTML = error;
el.classList.remove("hidden"); el.classList.remove("hidden");
}); });
@@ -184,6 +197,40 @@ window.addEventListener('loadDicomViewerUrls', function (e) {
loadDicomViewer(e.detail.images, e.detail.annotations); loadDicomViewer(e.detail.images, e.detail.annotations);
}, false); }, 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) { function loadDicomViewer(images_to_load, annotations_to_load) {
console.log("loadDicomViewer", images_to_load); console.log("loadDicomViewer", images_to_load);
let single_dicom = document.getElementById("single-dicom-viewer"); let single_dicom = document.getElementById("single-dicom-viewer");
@@ -201,7 +248,6 @@ function loadDicomViewer(images_to_load, annotations_to_load) {
images = JSON.parse(images); images = JSON.parse(images);
} }
console.log(images);
let annotations = single_dicom.dataset.annotations; let annotations = single_dicom.dataset.annotations;
if (annotations_to_load != undefined) { 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() { function add_input_form() {
var form_idx = $('#id_images-TOTAL_FORMS').val(); var form_idx = $('#id_images-TOTAL_FORMS').val();
+1
View File
@@ -47,6 +47,7 @@ class AnatomyQuestionTable(tables.Table):
"author", "author",
) )
sequence = ("view", "image", "exams") sequence = ("view", "image", "exams")
order_by = "-created_date"
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
super().__init__( super().__init__(
@@ -231,6 +231,8 @@
<p>This form can be used to create an Anatomy question.</p> <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>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> </details>
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form"> <form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %} {% csrf_token %}
@@ -29,7 +29,10 @@
{% include 'anatomy/question_link_header.html' %} {% include 'anatomy/question_link_header.html' %}
{% endif %} {% endif %}
<h2>Question Answers</h2> <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"> <form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %} {% csrf_token %}
<h3>Answers:</h3> <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 }} {{ answer_formset.management_form }}
<input type="button" value="Add More Answers" id="add_more"> <input type="button" value="Add More Answers" id="add_more">
<p> <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> </p>
</form> </form>
<div id="empty_form" style="display:none"> <div id="empty_form" style="display:none">
+34 -11
View File
@@ -5,17 +5,40 @@
{% endblock %} {% endblock %}
{% block navigation %} {% block navigation %}
Anatomy:
{% if request.user.is_authenticated %} <nav class="navbar navbar-expand-lg navbar-dark submenu">
<a href="{% url 'anatomy:index' %}">Packets</a> / <div class="container-fluid">
<a href="{% url 'anatomy:exam_list' %}">Exams</a> / <a class="navbar-brand" href="{% url 'anatomy:index' %}">Anatomy</a>
<a href="{% url 'anatomy:exam_create' %}" title="Create a new exam">Create Exam</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">
<a href="{% url 'anatomy:question_list' %}">Questions</a> / <span class="navbar-toggler-icon"></span>
<a href="{% url 'anatomy:anatomy_question_create' %}" title="Create a new question">Create Question</a> </button>
{% if request.user.is_superuser %} <div class="collapse navbar-collapse">
/ <a href="{% url 'anatomy:user_answer_table_view' %}" title="User answers">Answers</a> <ul class="navbar-nav">
{% endif %} {% if request.user.is_authenticated %}
{% endif %} <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 %} {% endblock %}
{% block content %} {% block content %}
{% if simple_content %} {% if simple_content %}
+9 -17
View File
@@ -15,7 +15,13 @@
<li data-question_pk={{question.pk}}> <li data-question_pk={{question.pk}}>
<span class="flex-col"> <span class="flex-col">
<a href="{% url 'anatomy:exam_question_detail' pk=exam.pk sk=forloop.counter0 %}"> <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> </a>
</span> </span>
<span class="flex-col-4"> <span class="flex-col-4">
@@ -37,22 +43,8 @@
{{ author }}, {{ author }},
{% endfor %} {% endfor %}
</div> </div>
{% if can_edit %} {% include 'generic/exam_footer.html' %}
<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 %}
</div> </div>
{% include 'exam_overview_js.html' %} {% 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_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a> / <a href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a> /
{% endif %} {% 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 %} {% endblock %}
+35 -7
View File
@@ -3,7 +3,9 @@
{% block content %} {% block content %}
<div class="col-md-6"> <div class="col-md-6">
<h2>Marking question {{question_details.current}} of {{question_details.total}}</h2> <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> <h3>{{ question.question_type }}</h3>
{% if question.structure %} {% if question.structure %}
Structure: {{question.structure}}<br/> Structure: {{question.structure}}<br/>
@@ -13,10 +15,36 @@
{% if question.answer_help %} {% if question.answer_help %}
<details> <details>
<summary>Marking help:</summary> <summary>Marking help:</summary>
<div class="border border-secondary">
{{question.answer_help|safe}} {{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> </details>
{% endif %}
@@ -36,22 +64,22 @@
{% for answer in user_answers %} {% for answer in user_answers %}
{% if answer in answer_suggest_incorrect %} {% 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 %} {% 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 %} {% endif %}
{% endfor %} {% endfor %}
</ul> </ul>
Marked: Marked:
<ul id="marked-answer-list" class="answer-list"> <ul id="marked-answer-list" class="answer-list">
{% for answer in correct_answers %} {% 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 %} {% endfor %}
{% for answer in half_mark_answers %} {% 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 %} {% endfor %}
{% for answer in incorrect_answers %} {% 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 %} {% endfor %}
</ul> </ul>
<div class="mark-buttons"> <div class="mark-buttons">
@@ -1,5 +1,5 @@
{% extends 'anatomy/base.html' %} {% extends 'anatomy/base.html' %}
{% load partials %}
{% block content %} {% block content %}
{% load static %} {% load static %}
{% include 'anatomy/question_link_header.html' %} {% include 'anatomy/question_link_header.html' %}
@@ -58,7 +58,14 @@
Answer help: {{ question.answer_help|safe }} Answer help: {{ question.answer_help|safe }}
</div> </div>
<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>
<div> <div>
Description: {{ question.description }} 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): def create_exam(db):
exam: Exam = AnatomyExamViews.Exam.objects.create( 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") group1: CidUserGroup = CidUserGroup.objects.get(name="Group1")
@@ -194,6 +194,8 @@ def test_exams(db, client):
exam.save() exam.save()
res = client.get(exam_metadata["url"]) res = client.get(exam_metadata["url"])
print("PRE ERRER", exam_metadata["url"])
print(res.content)
assert res.status_code == 404 assert res.status_code == 404
exam.active = True exam.active = True
@@ -365,7 +367,7 @@ def test_exams(db, client):
cid_exam_scores_soup = BeautifulSoup(cid_exam_scores_res.content, "html.parser") cid_exam_scores_soup = BeautifulSoup(cid_exam_scores_res.content, "html.parser")
# Check that we have an alert that exams are not published # 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) assert "Results are not currently published." in str(alert)
answer_lis = cid_exam_scores_soup.find( 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" app_name = "anatomy"
urlpatterns = [ urlpatterns = [
# path('', views.question_list, name='question_list'), # path('', views.question_view, name='question_view'),
path( path(
"question/", "question/",
views.AnatomyQuestionView.as_view(), views.AnatomyQuestionView.as_view(),
name="question_list", name="question_view",
), ),
path( path(
"question/create/", "question/create/",
views.AnatomyQuestionCreate.as_view(), views.AnatomyQuestionCreate.as_view(),
name="anatomy_question_create", name="question_create",
),
path(
"create/exam/<int:pk>",
views.AnatomyQuestionCreate.as_view(),
name="anatomy_create_exam",
), ),
path( path(
"question/<int:pk>/update", "question/<int:pk>/update",
@@ -38,6 +33,12 @@ urlpatterns = [
views.question_save_annotation, views.question_save_annotation,
name="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>/answer/", views.answer_question, name="answer_question"),
path( path(
"question/<int:pk>/clone", views.QuestionClone.as_view(), name="question_clone" "question/<int:pk>/clone", views.QuestionClone.as_view(), name="question_clone"
+67 -38
View File
@@ -44,6 +44,7 @@ from .forms import (
MarkAnatomyQuestionForm, MarkAnatomyQuestionForm,
AnatomyQuestionForm, AnatomyQuestionForm,
ExamForm, ExamForm,
SuggestIncorrectAnswerForm,
) )
from .models import ( from .models import (
AnatomyQuestion, AnatomyQuestion,
@@ -80,10 +81,6 @@ from django.template.defaulttags import register
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
from rest_framework import viewsets, permissions
from .serializers import ExamSerializer
class AuthorOrCheckerRequiredMixin(object): class AuthorOrCheckerRequiredMixin(object):
def get_object(self, *args, **kwargs): def get_object(self, *args, **kwargs):
@@ -109,14 +106,14 @@ def user_is_admin(user):
return False return False
def question_list(request): def question_view(request):
questions = AnatomyQuestion.objects.all() questions = AnatomyQuestion.objects.all()
if not request.user.groups.filter(name="anatomy_checker").exists(): if not request.user.groups.filter(name="anatomy_checker").exists():
questions = questions.filter(open_access=False) questions = questions.filter(open_access=False)
# raise PermissionDenied() # raise PermissionDenied()
return render(request, "anatomy/question_list.html", {"questions": questions}) return render(request, "anatomy/question_view.html", {"questions": questions})
@login_required @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) incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT)
answer_suggest_incorrect: list = [] answer_suggest_incorrect: list = []
if review: if review:
for ans in unmarked_user_answers: for ans in unmarked_user_answers:
# duplicated from user answer model.... # 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) answer_suggest_incorrect.append(ans)
break 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( return render(
request, request,
"anatomy/mark.html", "anatomy/mark.html",
@@ -389,6 +407,7 @@ def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
"incorrect_answers": incorrect_answers, "incorrect_answers": incorrect_answers,
"unmarked_exam_answers_only": unmarked_exam_answers_only, "unmarked_exam_answers_only": unmarked_exam_answers_only,
"answer_suggest_incorrect": answer_suggest_incorrect, "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 = form.save(commit=False)
self.object.save() self.object.save()
response = super().form_valid(form)
form.instance.author.add(self.request.user.id) form.instance.author.add(self.request.user.id)
context = self.get_context_data(form=form) #context = self.get_context_data(form=form)
formset = context["answer_formset"] #formset = context["answer_formset"]
if formset.is_valid(): #if formset.is_valid():
response = super().form_valid(form) # response = super().form_valid(form)
formset.instance = self.object # formset.instance = self.object
formset.save() # formset.save()
# If the normal submit button is pressed we save as normal # # If the normal submit button is pressed we save as normal
if "submit" in self.request.POST: # if "submit" in self.request.POST:
return response # return response
# else we redirect to the clone url # # else we redirect to the clone url
else: # else:
return redirect("anatomy:question_clone", pk=self.object.pk) # 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: else:
return super().form_invalid(form) return redirect("anatomy:question_clone", pk=self.object.pk)
def get_success_url(self) -> str: 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): class AnatomyQuestionCreate(AnatomyQuestionCreateBase):
@@ -745,6 +771,25 @@ class AnatomyQuestionView(LoginRequiredMixin, SingleTableMixin, FilterView):
# return qs # 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 @user_is_author_or_anatomy_checker
def question_save_annotation(request, pk): def question_save_annotation(request, pk):
@@ -787,7 +832,7 @@ class UserAnswerDelete(SuperuserRequiredMixin, DeleteView):
class QuestionDelete(AuthorOrCheckerRequiredMixin, DeleteView): class QuestionDelete(AuthorOrCheckerRequiredMixin, DeleteView):
model = AnatomyQuestion model = AnatomyQuestion
success_url = reverse_lazy("anatomy:question_list") success_url = reverse_lazy("anatomy:question_view")
class ExamCreate(ExamCreateBase): 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): class ExamAuthorUpdate(RevisionMixin, CheckCanEditMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView):
model = Exam model = Exam
form_class = ExamAuthorForm form_class = ExamAuthorForm
@@ -908,7 +937,7 @@ def question_add_exam(request, question_id: int):
else: else:
# Return a list of exams that we can add to the question # 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()) exams = exams.difference(question.exams.all())
if not exams: if not exams:
+1
View File
@@ -87,6 +87,7 @@ class AtlasAdminForm(ModelForm):
"subspecialty", "subspecialty",
"author", "author",
"series", "series",
"cimar_uuid"
] ]
widgets = { widgets = {
+26 -10
View File
@@ -70,8 +70,10 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
except DuplicateDicom as e: except DuplicateDicom as e:
duplicate.append((file.name, ud.image_blake3_hash)) 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 pass
except InvalidDicomError: except InvalidDicomError:
failed.append(file.name) failed.append(file.name)
@@ -127,6 +129,9 @@ def uncategorised_dicoms(request):
def import_dicoms_helper(request, case_id: int | None = None): def import_dicoms_helper(request, case_id: int | None = None):
# TODO: move this out of the api (would be better with HTMX)
# dicoms = UncategorisedDicom.objects.filter(user=request.user) # dicoms = UncategorisedDicom.objects.filter(user=request.user)
if "selection" in request.POST: if "selection" in request.POST:
dicoms = UncategorisedDicom.objects.filter( dicoms = UncategorisedDicom.objects.filter(
@@ -149,7 +154,13 @@ def import_dicoms_helper(request, case_id: int | None = None):
series_list = [] series_list = []
for series_uid in data: 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] tags = data[series_uid][0][1]
# Check if series with the id already exists (in which case we just add to htat) # 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() 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.... # We might only want to add the author during creation....
series.author.add(request.user) series.author.add(request.user)
@@ -202,6 +206,15 @@ def import_dicoms_helper(request, case_id: int | None = None):
# dicom.image.delete() # dicom.image.delete()
dicom.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())) series_list.append((series, series.get_link()))
return series_list return series_list
@@ -264,6 +277,9 @@ def series_truncate(request, series_id: int, start: int, end: int):
image.image = None image.image = None
image.save() image.save()
images_removed.append(image.pk) images_removed.append(image.pk)
series.get_total_image_size(refresh=True)
return {"status": "success", "images_removed": images_removed} return {"status": "success", "images_removed": images_removed}
+2
View File
@@ -49,6 +49,8 @@ def get_series(request):
else: else:
queryset = Series.objects.all() queryset = Series.objects.all()
queryset = queryset.prefetch_related("examination", "plane")
return queryset return queryset
+108 -15
View File
@@ -24,6 +24,7 @@ from atlas.models import (
Case, Case,
CaseCollection, CaseCollection,
CaseDetail, CaseDetail,
CasePrior,
CidReportAnswer, CidReportAnswer,
Differential, Differential,
Finding, Finding,
@@ -47,13 +48,15 @@ from generic.models import Examination#, Sign
# from generic.models import Examination, Site, Condition, Sign # from generic.models import Examination, Site, Condition, Sign
from django.contrib.admin.widgets import FilteredSelectMultiple 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 tinymce.widgets import TinyMCE
from dal import autocomplete 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 import logging
@@ -63,6 +66,35 @@ from crispy_forms.helper import FormHelper
from atlas.helpers import get_cases_available_to_user 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 ConditionForm(ModelForm):
class Meta: class Meta:
@@ -143,7 +175,7 @@ class StructureForm(ModelForm):
class SeriesFindingForm(ModelForm): class SeriesFindingForm(ModelForm):
class Meta: class Meta:
model = SeriesFinding model = SeriesFinding
exclude = ["series", "annotation_json", "viewport_json", "current_image_id_index"] exclude = ["series", "annotation_json", "viewport_json", "current_image_id_index", "annotation_json_3d", "viewer_state_3d"]
widgets = { widgets = {
"findings": autocomplete.ModelSelect2Multiple( "findings": autocomplete.ModelSelect2Multiple(
@@ -269,14 +301,16 @@ class SeriesForm(ModelForm):
class CaseForm(ModelForm): class CaseForm(ModelForm):
# exams = ModelMultipleChoiceField(required=False, queryset=Exam.objects.all(),widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False))
class Media: class Media:
# Django also includes a few javascript files necessary # Django also includes a few javascript files necessary
# for the operation of this form element. You need to # for the operation of this form element. You need to
# include <script src="/admin/jsi18n"></script> # include <script src="/admin/jsi18n"></script>
# in the template. # in the template.
#history = forms.CharField(
# widget=ClearableTextarea(attrs={"maxlength": 1000, "rows": 5, "cols": 80}),
# required=False,
#)
css = { css = {
"all": ["css/widgets.css"], "all": ["css/widgets.css"],
} }
@@ -284,8 +318,6 @@ class CaseForm(ModelForm):
js = ["jsi18n.js", "tesseract.min.js", "js/upload_form_helpers.js"] js = ["jsi18n.js", "tesseract.min.js", "js/upload_form_helpers.js"]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
logging.info("LOG")
logging.debug(kwargs)
self.user = kwargs.pop( self.user = kwargs.pop(
"user" "user"
) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole ) # 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 = # self.fields["series"].queryset =
if self.user.groups.filter(name="atlas_editor").exists(): if self.user.groups.filter(name="atlas_editor").exists():
case_queryset = Case.objects.all().order_by("pk") case_queryset = Case.objects.all().order_by("pk").prefetch_related(
)
else: else:
case_queryset = get_cases_available_to_user(self.user) case_queryset = get_cases_available_to_user(self.user)
self.fields["previous_case"].queryset=case_queryset self.fields["previous_case"].queryset=case_queryset
self.helper = FormHelper()
self.helper.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): def get_queryset(self, request):
return ( return (
super() super()
@@ -347,6 +409,7 @@ class CaseForm(ModelForm):
"open_access", "open_access",
"previous_case", "previous_case",
"diagnostic_certainty", "diagnostic_certainty",
"cimar_uuid"
] ]
# fields = ['question', 'findings', 'subspecialty', 'references'] # fields = ['question', 'findings', 'subspecialty', 'references']
widgets = { widgets = {
@@ -355,9 +418,11 @@ class CaseForm(ModelForm):
# (False, 'No')]) # (False, 'No')])
# "findings": TinyMCE(attrs={"cols": 80, "rows": 20}), # "findings": TinyMCE(attrs={"cols": 80, "rows": 20}),
# "mark_scheme": TinyMCE(attrs={"cols": 80, "rows": 30}), # "mark_scheme": TinyMCE(attrs={"cols": 80, "rows": 30}),
"description": Textarea(attrs={"cols": 80, "rows": 5}), "title": ClearableInput(attrs={"rows": 1}),
"history": Textarea(attrs={"cols": 80, "rows": 5}), "description": ClearableTextarea(attrs={"cols": 80, "rows": 5}),
"discussion": Textarea(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( "condition": autocomplete.ModelSelect2Multiple(
url="atlas:condition-autocomplete" url="atlas:condition-autocomplete"
), ),
@@ -365,8 +430,21 @@ class CaseForm(ModelForm):
url="atlas:presentation-autocomplete" url="atlas:presentation-autocomplete"
), ),
"subspecialty": CheckboxSelectMultiple(), "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): def save(self, commit=True):
# Get the unsaved Case instance # Get the unsaved Case instance
@@ -732,11 +810,17 @@ class UncategorisedDicomForm(ModelForm):
# ) # )
# } # }
class ConditionAutocomplete(ModelAutocomplete):
model = Condition
search_attrs = ["name"]
class ConditionAutocompleteForm(Form): class ConditionAutocompleteForm(Form):
condition = ModelChoiceField(queryset=Condition.objects.all(), widget=htmx_widgets.Autocomplete( condition = ModelChoiceField(queryset=Condition.objects.all(), widget=AutocompleteWidget(
name="conditions", options=dict(model=Condition) ac_class=ConditionAutocomplete#, options=dict(model=Condition)
)) ))
autocomplete_register(ConditionAutocomplete)
class CaseCollectionAuthorForm(ExamAuthorFormMixin): class CaseCollectionAuthorForm(ExamAuthorFormMixin):
class Meta(ExamAuthorFormMixin.Meta): class Meta(ExamAuthorFormMixin.Meta):
@@ -785,10 +869,19 @@ class QuestionSchemaForm(ModelForm):
"schema": SvelteJSONEditorWidgetOverride(), "schema": SvelteJSONEditorWidgetOverride(),
} }
#class ModelPriorCaseForm(ModelForm):
# class Meta:
# model = CasePrior
# fields = ["case_detail", "prior_case", "relation_text", "prior_visibility"]
class PriorCaseForm(Form): class PriorCaseForm(Form):
relation = CharField(max_length=255, required=True) relation = CharField(max_length=255, required=True)
prior_visibility = CharField(max_length=2, required=True)
#def __init__(self, *args, **kwargs): #def __init__(self, *args, **kwargs):
# self.case_detail = kwargs.pop( # self.case_detail = kwargs.pop(
# "case_detail" # "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, CidUser,
CidUserExam, CidUserExam,
CidUserGroup, CidUserGroup,
CimarCase,
ExamOrCollectionGenericBase, ExamOrCollectionGenericBase,
ExamUserStatus, ExamUserStatus,
Examination, Examination,
# Condition, # Condition,
ExamBase, ExamBase,
FindingBase,
Plane, Plane,
Contrast, Contrast,
QuestionNote, QuestionNote,
@@ -390,6 +392,10 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
resource = models.ManyToManyField("Resource", through="CaseResource") 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): def get_app_name(self):
return "atlas" return "atlas"
@@ -402,6 +408,20 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
# return f"{self.pk}: {self.title}" # return f"{self.pk}: {self.title}"
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), str(self)) 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_base_template(self):
# def get_edit_template_links(self): # def get_edit_template_links(self):
@@ -417,7 +437,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
for s in self.series.all(): for s in self.series.all():
html += s.get_block() html += s.get_block()
return html return mark_safe(html)
def get_long_str(self): def get_long_str(self):
examinations = [series.get_examination() for series in self.series.all()] 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): def get_case_dicom_json(self, case_title_as_patient_name=True, priors=None):
series_json = [] series_json = []
series: Series
for series in self.series.all(): for series in self.series.all():
series_json.append(series.get_series_dicom_json()) series_json.append(series.get_series_dicom_json())
@@ -495,6 +516,36 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
def get_series(self): def get_series(self):
return self.series.all().prefetch_related("images", "examination", "plane") 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): def extract_image_dicom_json_from_ds(ds, url, image_index):
to_keep = [ to_keep = [
@@ -555,24 +606,6 @@ class SeriesImage(SeriesImageBase):
help_text="Reference to the object that has replaced this one.", 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): # def get_image_dicom_json(self, image_index):
# try: # try:
# with pydicom.dcmread(self.image) as ds: # 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 = models.ForeignKey(
"Series", related_name="findings", on_delete=models.SET_NULL, null=True "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) findings = models.ManyToManyField(Finding, blank=True)
structures = models.ManyToManyField(Structure, blank=True) structures = models.ManyToManyField(Structure, blank=True)
conditions = models.ManyToManyField(Condition, 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: def __str__(self) -> str:
findings = self.findings.all().values_list("name") 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}" return f"SeriesFinding: {self.series.id}/{findings}/{self.description}"
def annotation_as_string(self) -> str:
return json.dumps(self.annotation_json)
@reversion.register @reversion.register
class Series(SeriesBase): class Series(SeriesBase):
modality = models.ForeignKey( modality = models.ForeignKey(
@@ -651,6 +675,11 @@ class Series(SeriesBase):
series_instance_uid = models.CharField(max_length=255, blank=True, null=True) 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") # 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): def get_full_str(self):
if self.case: if self.case:
@@ -747,6 +776,21 @@ class Series(SeriesBase):
def get_link_headers(self): def get_link_headers(self):
return "atlas/series_headers.html" 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): class CaseCollection(ExamOrCollectionGenericBase):
@@ -796,7 +840,6 @@ class CaseCollection(ExamOrCollectionGenericBase):
markers = models.ManyToManyField( markers = models.ManyToManyField(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
blank=True, blank=True,
null=True,
help_text="Authorised markers for the exam", help_text="Authorised markers for the exam",
related_name="casecollection_markers", 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") 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: class Meta:
unique_together = ("case_detail", "prior_case") unique_together = ("case_detail", "prior_case")
@@ -1302,7 +1358,10 @@ class UncategorisedDicom(models.Model):
if self.image: if self.image:
ds = self.get_dicom_info() 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(): # if self.check_for_duplicates():
# return DuplicateDicom # return DuplicateDicom
@@ -1327,9 +1386,11 @@ class UncategorisedDicom(models.Model):
def get_dicom_info(self): def get_dicom_info(self):
try: try:
ds = pydicom.read_file(self.image) ds = pydicom.dcmread(self.image)
return ds return ds
except pydicom.errors.InvalidDicomError: except pydicom.errors.InvalidDicomError:
print("Error getting dicom dataset")
print(self.image)
return {"SeriesInstanceUID": "1234"} return {"SeriesInstanceUID": "1234"}
def get_basic_dicom_tags(self, dataset=None): def get_basic_dicom_tags(self, dataset=None):
@@ -1361,6 +1422,11 @@ class UncategorisedDicom(models.Model):
def get_series(self): def get_series(self):
return self.series return self.series
def get_dicom_url(self):
return f"{REMOTE_URL}{self.image.url}"
class DuplicateDicom(Exception): class DuplicateDicom(Exception):
def __init__(self, duplicate): 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 easy_thumbnails.exceptions import InvalidImageFormatError
from django.db.models import Prefetch
class CaseTable(tables.Table): 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): def get_view_cell(record):
if record.open_access: 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"> 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: else:
return f'View' 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( edit = tables.LinkColumn(
"atlas:case_update", text="Edit", args=[A("pk")], orderable=False "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 "atlas:case_delete", text="Delete", args=[A("pk")], orderable=False
) )
#differential = tables.ManyToManyColumn(verbose_name="Differential")
selection = tables.CheckBoxColumn(accessor="pk", orderable=False) selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
class Meta: class Meta:
@@ -59,21 +175,15 @@ class CaseTable(tables.Table):
fields = ( fields = (
"title", "title",
"description", "description",
#"history",
#"condition",
"created_date", "created_date",
"series",
"author", "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): class PopupLinkColumn(tables.Column):
def render(self, value): def render(self, value):
@@ -125,6 +235,7 @@ class SeriesTable(tables.Table):
"modified_date", "modified_date",
) )
sequence = ("view", "popup", "images", "case") sequence = ("view", "popup", "images", "case")
order_by = "-created_date"
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
super().__init__( 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 %} {% endblock %}
{% block navigation %} {% block navigation %}
Atlas:
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<a href="{% url 'atlas:collection_view' %}">Collections</a> / <nav class="navbar navbar-expand-lg navbar-dark submenu">
<a href="{% url 'atlas:case_view' %}?sort=-created_date">Cases</a> / <div class="container-fluid">
<a href="{% url 'atlas:series_view' %}">Series</a> / <a class="navbar-brand" href="{% url 'atlas:index' %}">Atlas</a>
<a href="{% url 'atlas:categories_list' %}">Categories</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">
{% comment %} <a href="{% url 'atlas:resource_view' %}">Resources</a> / {% endcomment %} <span class="navbar-toggler-icon"></span>
<a href="{% url 'atlas:case_create' %}" title="Create a new atlas case">Create Case</a> / </button>
<a href="{% url 'atlas:series_create' %}" title="Create a new image series">Create Series</a> / <div class="collapse navbar-collapse" id="navbarNav">
<a href="{% url 'atlas:resource_view' %}" title="Resources">Resources</a> / <ul class="navbar-nav">
<a href="{% url 'atlas:user_uploads' %}" title="View unimported uploads">Uploads</a> / <li class="nav-item dropdown">
<a href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas">Question Schemas</a> / <a class="nav-link d-inline" href="{% url 'atlas:collection_view' %}">Collections</a>
<a href="{% url 'atlas:help' %}" title="View the atlas help pages">Help</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 %} {% endif %}
{% comment %} </br> {% comment %} </br>
Questions by: 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 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"> <div class="date">
{{ case.created_date|date:"d/m/Y" }} {{ case.created_date|date:"d/m/Y" }}
@@ -7,7 +112,6 @@
<div class="id"> <div class="id">
ID: {{ case.id }} ID: {{ case.id }}
</div> </div>
<p class="pre-whitespace"><b>Title:</b> {{ case.title }}</p>
<p class="pre-whitespace"><b>Description:</b> {{ case.description }}</p> <p class="pre-whitespace"><b>Description:</b> {{ case.description }}</p>
@@ -20,11 +124,11 @@
</ul> </ul>
</p> </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 %} <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> <a href='{% url "atlas:resource_detail" caseresource.resource.pk %}'>{{caseresource.resource.name}}</a>
{% if caseresource.pre_review %} {% if caseresource.pre_review %}
@@ -37,27 +141,12 @@
<div class="pre-whitespace multi-image-block"><b>Series:</b> <div class="pre-whitespace multi-image-block"><b>Series:</b>
<form> <form>
{% for series in case.series.all %} {% partial case-series %}
<span class="series-block">
<span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
<a href="{% url 'atlas:series_detail' pk=series.pk %}">
{{series.get_block}}
</a>
<br>
<input type="checkbox" name="series-ids" value="{{series.pk}}">
<span class="series-block-popup-link">
<a href="#"
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
</span>
</span>
</span>
{% endfor %}
</form> </form>
<span> <span>
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Add new series</a><br /> <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 /> <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> <summary>+</summary>
<button hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}" <button hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
title="order dicom by slice location" title="order dicom by slice location"
@@ -103,14 +192,31 @@
{% endfor %} {% endfor %}
</ul> </ul>
<div> <div>
<details> <details open>
<summary><b>Findings</b></summary> <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 %} {% if case.series.all %}
{% for series in case.series.all %} {% for series in case.series.all %}
{% for finding in series.findings.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> <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> </span>
{% if finding.findings.all %} {% if finding.findings.all %}
<span> <span>
@@ -119,6 +225,13 @@
{% endfor %} {% endfor %}
</span> </span>
{% endif %} {% endif %}
{% if finding.conditions.all %}
<span>
Conditions: {% for c in finding.conditions.all %}
{{c.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.structures.all %} {% if finding.structures.all %}
<span> <span>
Structure: {% for s in finding.structures.all %} Structure: {% for s in finding.structures.all %}
@@ -165,19 +278,43 @@
</ul> </ul>
<b>Diagnostic certainty:</b> {{case.get_diagnostic_certainty_display}}<br /> <b>Diagnostic certainty:</b> {{case.get_diagnostic_certainty_display}}<br />
<b>Collections:</b> <b>Collections:</b>
<button
hx-get="{% url 'atlas:case_collection_form' case.pk %}" {% if can_edit %}
hx-target="#collection-form"> <button
Add collection</button> hx-get="{% url 'atlas:case_collection_form' case.pk %}"
hx-target="#collection-form">
Add to collection</button>
{% endif %}
<div id="collection-form"></div> <div id="collection-form"></div>
<ul> <ul>
{% for collection in case.casecollection_set.all %} {% 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 %} {% endfor %}
</ul> </ul>
</p> </p>
<p class="pre-whitespace"><b>Previous case:</b> {{ case.previous_case.get_link }}</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> <div>
{% include 'question_notes.html' %} {% include 'question_notes.html' %}
@@ -188,3 +325,146 @@
<p><b>Author(s):</b> {% for author in case.author.all %} <a <p><b>Author(s):</b> {% for author in case.author.all %} <a
href="{% url 'atlas:author_detail' pk=author.pk %}">{{author}}</a>, {% endfor %}</p> 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" %} {% extends "atlas/base.html" %}
<!-- {% load static from static %} --> <!-- {% load static from static %} -->
{% load crispy_forms_tags %}
{% block css %} {% block css %}
{% endblock %} {% endblock %}
{% block js %} {% block js %}
@@ -50,8 +51,70 @@
$('#add_more_resource').click(() => { add_resource_input_form() }); $('#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 }} --> <!-- {{ form.media }} -->
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -59,6 +122,8 @@
{% include "atlas/question_link_header.html" %} {% include "atlas/question_link_header.html" %}
{% endif %} {% endif %}
<h2>Submit Case</h2> <h2>Submit Case</h2>
<details> <details>
<summary>Instructions</summary> <summary>Instructions</summary>
@@ -68,74 +133,77 @@
</details> </details>
{% if form.collection %} {% 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 %} {% endif %}
<form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form"> <form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form">
{% csrf_token %} {% crispy form form.helper %}
{% comment %} {% crispy series_formset %} {% endcomment %}
{% comment %} {{ form }} {% endcomment %}
{{ form }}
<h3>Series:</h3> <h3>Series:</h3>
Add image sets here. These can only be added once created (they can also be added to cases on creation). 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"> <input type="button" value="Add More Series" id="add_more_series">
<div id="series_formset" class="list_formset"> <div id="series_formset" class="list_formset">
<ol> <ol>
{% for form in series_formset %} {% for form in series_formset %}
<li class="no-error series-formset"> <li class="no-error series-formset">
{{form.non_field_errors}} {{form.non_field_errors}}
{{form.errors}} {{form.errors}}
{{ form }} {{ form | crispy}}
</li> </li>
{% endfor %} {% endfor %}
</ol> </ol>
</div> </div>
{{ series_formset.management_form }} {{ series_formset.management_form | crispy }}
<h3>Differential:</h3> <h3>Differential:</h3>
Add differential here. Add differential here.
<input type="button" value="Add Another Differential" id="add_more_differential"> <input type="button" value="Add Another Differential" id="add_more_differential">
<div id="casedifferential_formset" class="list_formset"> <div id="casedifferential_formset" class="list_formset">
<ol> <ol>
{% for form in casedifferential_formset %} {% for form in casedifferential_formset %}
<li class="no-error casedifferential_formset"> <li class="no-error casedifferential_formset">
{{form.non_field_errors}} {{form.non_field_errors}}
{{form.errors}} {{form.errors}}
{{ form }} {{ form | crispy}}
</li> </li>
{% endfor %} {% endfor %}
</ol> </ol>
</div> </div>
{{ casedifferential_formset.management_form }} {{ casedifferential_formset.management_form | crispy }}
<h3>Resource:</h3> <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. 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"> <input type="button" value="Add Another Resource" id="add_more_resource">
<div id="caseresource_formset" class="list_formset"> <div id="caseresource_formset" class="list_formset">
<ol> <ol>
{% for form in caseresource_formset %} {% for form in caseresource_formset %}
<li class="no-error caseresource_formset"> <li class="no-error caseresource_formset">
{{form.non_field_errors}} {{form.non_field_errors}}
{{form.errors}} {{form.errors}}
{{ form }} {{ form | crispy}}
</li> </li>
{% endfor %} {% endfor %}
</ol> </ol>
</div> </div>
{{ caseresource_formset.management_form }} {{ caseresource_formset.management_form | crispy}}
<br/> <br/>
<input type="submit" class="submit-button" value="Submit" name="submit"> <input type="submit" class="submit-button" value="Submit" name="submit">
</form> </form>
<div id="empty_series_form" style="display:none"> <div id="empty_series_form" style="display:none">
<li class='no_error series-formset'> <li class='no_error series-formset'>
{{ series_formset.empty_form }} {{ series_formset.empty_form | crispy}}
</li> </li>
</div> </div>
<div id="empty_casedifferential_form" style="display:none"> <div id="empty_casedifferential_form" style="display:none">
<li class='no_error casedifferential_formset'> <li class='no_error casedifferential_formset'>
{{ casedifferential_formset.empty_form }} {{ casedifferential_formset.empty_form | crispy}}
</li> </li>
</div> </div>
<div id="empty_caseresource_form" style="display:none"> <div id="empty_caseresource_form" style="display:none">
<li class='no_error caseresource_formset'> <li class='no_error caseresource_formset'>
{{ caseresource_formset.empty_form }} {{ caseresource_formset.empty_form | crispy}}
</li> </li>
</div> </div>
{% endblock %} {% 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 %} {% block content %}
<div> <div>
{% if previous %} {% if previous %}
@@ -14,23 +14,31 @@
<a href="{% url 'atlas:collection_case_details' collection.id next.id %}">Next question</a> <a href="{% url 'atlas:collection_case_details' collection.id next.id %}">Next question</a>
{% endif %} {% endif %}
</div> </div>
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2> <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 %} {% if case_detail.question_schema is not None and not case_detail.question_answers %}
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
<h4 class="alert-heading">Answers not defined!</h4> <h4 class="alert-heading">Answers not defined!</h4>
<p>A question schema has been defined but no question answers are found.</p> <p>A question schema has been defined but no question answers are found. Please set them below.</p>
</div> </div>
{% endif %} {% 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> <details class="help-text">
<p>If answers are supplied the question will be automarked</p> <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: Import schema from:
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight" <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" hx-target=".offcanvas-body"
>Preset questions</button> >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 offcanvas-end" tabindex="-1" id="offcanvasRight" aria-labelledby="offcanvasRightLabel"
> >
<div class="offcanvas-header"> <div class="offcanvas-header">
@@ -56,7 +114,7 @@
<form method="POST"> <form method="POST">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit" value="save" name="submit">Submit</button> <button type="submit" value="save" name="submit">Save Questions</button>
</form> </form>
{% comment %} <h2>Blank form</h2> {% 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> </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 %} {% endblock %}
@@ -24,7 +24,7 @@
<h3>Available priors</h3> <h3>Available priors</h3>
<ul> <ul>
{% for case, added, relation in available_priors %} {% for case, added, relation, visibility in available_priors %}
<li class=' <li class='
{% if added %} {% if added %}
@@ -38,24 +38,63 @@
<br/> <br/>
{% if not added %} {% if not added %}
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}" <form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
hx-target="#htmx-response-{{case.id}}" hx-target="#htmx-response-{{case.id}}"
_="on htmx:afterRequest log 'test' then remove me" _="on htmx:afterRequest log 'test' then remove me">
>
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="prior_case_id" value="{{case.id}}" /> <input type="hidden" name="prior_case_id" value="{{case.id}}" />
<input type="text" name="relation" value="{{relation}}" placeholder="relation to case" /> <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> <button class="btn primary">Add to case</button>
</form> </form>
{% else %} {% else %}
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}" <form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
hx-target="#htmx-response-{{case.id}}" 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}} Case available: {{relation}}
<input type="hidden" name="remove" value="{{case.id}}" /> <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> <button class="btn primary">Remove from case</button>
</form> </form>
{% endif %} {% endif %}
@@ -42,21 +42,31 @@
{% endif %} {% endif %}
{% if show_history and case.history %} {% if show_history and case.history %}
<div> <div>
History: {{case.history}} History: {{case.history|linebreaks}}
</div> </div>
{% endif %} {% endif %}
{% if collection.show_ohif_viewer %} {% if collection.show_ohif_viewer %}
<div> <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> <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> </div>
{% endif %} {% endif %}
{% if collection.show_ohif_viewer_link %} {% if collection.show_ohif_viewer_link %}
<div> <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> <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> </div>
{% endif %} {% endif %}
@@ -102,7 +112,7 @@
Discussion: Discussion:
</summary> </summary>
<div> <div>
{{case.discussion}} {{case.discussion|linebreaks}}
</div> </div>
</details> </details>
</p> </p>
@@ -114,7 +124,7 @@
Report: Report:
</summary> </summary>
<div> <div>
{{case.report}} {{case.report|linebreaks}}
</div> </div>
</details> </details>
</p> </p>
@@ -218,7 +228,7 @@
<div> <div>
{% if collection.self_review %} {% if collection.self_review %}
<p> <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> </p>
{% if self_review %} {% if self_review %}
<h4>Self Feedback</h4> <h4>Self Feedback</h4>
+34 -9
View File
@@ -4,8 +4,9 @@
<h2>{{collection.name}}</h2> <h2>{{collection.name}}</h2>
{% include 'exam_notes.html' %} {% include 'exam_notes.html' %}
<div> <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 /> Publish results: {{collection.publish_results}}<br />
Active: {{collection.active}}<br /> Active: {{collection.active}}<br />
Collection Type: {{collection.get_collection_type_display}}<br /> Collection Type: {{collection.get_collection_type_display}}<br />
@@ -18,10 +19,20 @@
: {{casedetail.case.title}} : {{casedetail.case.title}}
{% if collection.collection_type == "QUE" %} {% if collection.collection_type == "QUE" %}
(<a href='{% url "atlas:collection_case_details" casedetail.collection.pk casedetail.case.pk %}'>edit questions</a>) (<a href='{% url "atlas:collection_case_details" casedetail.collection.pk casedetail.case.pk %}'>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 %} {% endif %}
{% if casedetail.case.previous_case %}
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>manage priors</a>) (<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>manage priors</a>)
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
@@ -32,7 +43,14 @@
{% if collection.collection_type == "VIV" %} {% if collection.collection_type == "VIV" %}
<p>View as a viva collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a> <p>View as a viva collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
{% else %} {% 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 %} {% endif %}
{% if can_edit %} {% if can_edit %}
@@ -49,17 +67,24 @@
<summary>Answer management</summary> <summary>Answer management</summary>
<div class="alert alert-danger"> <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> </div>
<button title="This will clear all user answers and attempts" <button title="This will clear all user answers and attempts"
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}" hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone." hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
>Reset all answers</button> >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> </details>
{% endif %} {% endif %}
{% include 'exam_overview_js.html' %} {% include 'exam_overview_js.html' %}
@@ -1,5 +1,5 @@
{% if request.user.is_authenticated %} {% 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_history' collection.pk %}">History</a> /
<a href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a> / <a href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a> /
<a href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a> / <a href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a> /
@@ -7,11 +7,19 @@
<ul> <ul>
{% for userexam in userexams %} {% 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/> <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/> Completed: {{userexam.completed}}<br/>
Started: {{userexam.start_time}}, Ended: {{userexam.end_time}}<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> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@@ -1,7 +1,6 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% block content %} {% block content %}
<a href="{% url 'atlas:exam_create' %}">Create collection</a>
<ul> <ul>
{% for collection in collections %} {% for collection in collections %}
<li><a href='{{collection.get_absolute_url}}'>{{collection.name}}</a></li> <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"> <div class="sticky-top">
<h3>Current Case</h3> <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="case-details">
<div id="current-case-title">None</div> <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-series"></div> <div id="current-case-title">None</div>
<div id="current-case-history"></div> <div id="current-case-series"></div>
<div id="current-case-discussion"></div> <div id="current-case-history"></div>
<div id="current-case-report"></div> <div id="current-case-discussion"></div>
<div id="current-case-report"></div>
</div>
</div> </div>
<iframe id="viewer" style="width: 100%; height: 500px; border: none"></iframe> <iframe id="viewer" style="width: 100%; height: 500px; border: none"></iframe>
@@ -106,16 +108,16 @@
$('#loading-case').show() $('#loading-case').show()
$("#open-viewer").addClass("flash-button"); $("#open-viewer").addClass("flash-button");
$('#current-case-title').text("Case: "+c.data('title')); $('#current-case-title').html("<span class='title'>Case:</span> "+c.data('title'));
$("#current-case-history").text("History: "+case_details['history']); $("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']);
$("#current-case-discussion").text("Discussion: "+case_details['discussion']); $("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
$("#current-case-report").text("Report: "+case_details['report']); $("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
console.log($(this).data('type')) console.log($(this).data('type'))
if ($(this).data('type') == 'case') { if ($(this).data('type') == 'case') {
$('#current-case-series').text("Series: All"); $('#current-case-series').html("<span class='title'>Series:</span> All");
} else { } 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}); bc.postMessage({"type": "open", "url" : url});
//if (win2 == false || win2.closed) { //if (win2 == false || win2.closed) {
@@ -153,25 +155,33 @@
{% endblock %} {% endblock %}
{% block css %} {% block css %}
<style type="text/css"> <style type="text/css">
#loading-case { #loading-case {
display: none; display: none;
color: red; color: red;
} }
@keyframes glowing { @keyframes glowing {
0% { 0% {
box-shadow: 0 0 20px #a600ff; box-shadow: 0 0 20px #a600ff;
} }
50% { 50% {
box-shadow: 0 0 40px #52057b; box-shadow: 0 0 40px #52057b;
} }
100% { 100% {
box-shadow: 0 0 20px #a600ff; box-shadow: 0 0 20px #a600ff;
} }
} }
.flash-button { .flash-button {
animation: glowing 1300ms infinite; animation: glowing 1300ms infinite;
} }
#case-details {
padding-bottom: 20px;
}
.title {
font-weight: bold;
}
</style> </style>
{% endblock css %} {% 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>
<details> <details>
<summary>Settings</summary> <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-thumbnails"><label for="Show thumbnails">Show thumbnails</label>
<input type="checkbox" id="show-viewer"><label for="Show Viewer">Show viewer</label> <input type="checkbox" id="show-viewer"><label for="Show Viewer">Show viewer</label>
</details> </details>
@@ -47,6 +47,15 @@
</div> </div>
</details> </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> <h4>Selected files:</h4>
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''></div> <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){ function uploadFiles(files_to_upload, current_chunk, total_chunks){
console.log("uploading", files_to_upload)
// Create a FormData object to store the form data // Create a FormData object to store the form data
const formData = new FormData(); const formData = new FormData();
// Append each selected file to the FormData object // Append each selected file to the FormData object
for (let i = 0; i < files_to_upload.length; i++) { for (let i = 0; i < files_to_upload.length; i++) {
formData.append("files", files_to_upload[i]); formData.append("files", files_to_upload[i]);
} }
console.log("formdata", formData)
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open("POST", "{% url 'api-1:upload_dicom' %}", true); xhr.open("POST", "{% url 'api-1:upload_dicom' %}", true);
xhr.setRequestHeader("X-CSRFToken", "{{ csrf_token }}"); xhr.setRequestHeader("X-CSRFToken", "{{ csrf_token }}");
@@ -134,8 +140,6 @@
window.upload_count += 1; window.upload_count += 1;
if (xhr.status === 200) { if (xhr.status === 200) {
// Handle successful response from the server // 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["uploaded"].push(...JSON.parse(xhr.response)["uploaded"]);
window.upload_results["duplicates"].push(...JSON.parse(xhr.response)["duplicates"]); window.upload_results["duplicates"].push(...JSON.parse(xhr.response)["duplicates"]);
window.upload_results["failed"].push(...JSON.parse(xhr.response)["failed"]); window.upload_results["failed"].push(...JSON.parse(xhr.response)["failed"]);
@@ -180,14 +184,12 @@
xhr.send(formData); xhr.send(formData);
} }
function checkAnnonymisation(byteArray) { function checkAnnonymisation(byteArray, file) {
// We need to setup a try/catch block because parseDicom will throw an exception // 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) // if you attempt to parse a non dicom part 10 file (or one that is corrupted)
try { try {
console.log("bytearray", byteArray)
// parse byteArray into a DataSet object using the parseDicom library // parse byteArray into a DataSet object using the parseDicom library
var dataSet = dicomParser.parseDicom(byteArray); var dataSet = dicomParser.parseDicom(byteArray);
console.log("ds", dataSet)
// dataSet contains the parsed elements. Each element is available via a property // 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 // 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 study_description = dataSet.string('x00081030');
var accession_number = dataSet.string('x00080050').toLowerCase(); var accession_number = dataSet.string('x00080050').toLowerCase();
var patient_id = dataSet.string('x00100020').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; not_anon = false;
site_codes = ["ref", "rk9", "ra9", "rh8", "rbz", "rba"] site_codes = ["ref", "rk9", "ra9", "rh8", "rbz", "rba"]
errors = []
for (let i = 0; i < site_codes.length; i++) { for (let i = 0; i < site_codes.length; i++) {
if ( if ( accession_number.startsWith(site_codes[i]))
accession_number.startsWith(site_codes[i]) ||
patient_id.startsWith(site_codes[i])
)
{ {
not_anon = true; 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) { if (not_anon) {
toastr.warning(`File does not appear to be annonymised<br/>Accession: ${accession_number} <br/>Patient ID: ${patient_id}<br/>`, 'Anonymisation warning', { toastr.warning(`File does not appear to be annonymised<br/>Accession: ${accession_number} <br/>Patient ID: ${patient_id}<br/>`, 'Anonymisation warning', {
@@ -237,7 +240,16 @@
"showMethod": "fadeIn", "showMethod": "fadeIn",
"hideMethod": "fadeOut", "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 // TODO: fix
@@ -269,7 +281,8 @@
return false; return false;
} }
} }
async function loadDicomViewerTest(callback) { async function loadDicomViewerUploads(callback) {
$("#uploadButton").attr("disabled", false);
$("#single-dicom-viewer").hide(); $("#single-dicom-viewer").hide();
console.log("load") console.log("load")
$("#single-dicom-viewer").empty() $("#single-dicom-viewer").empty()
@@ -285,7 +298,6 @@
const readFile = (file, el) => { const readFile = (file, el) => {
reader(file).then(reader => { reader(file).then(reader => {
console.log("hello", file, el, image)
//if ($("#id_examination").find(":selected")[0].dataset.select2Id == "2") { //if ($("#id_examination").find(":selected")[0].dataset.select2Id == "2") {
// var byteArray = new Uint8Array(reader.result); // var byteArray = new Uint8Array(reader.result);
// console.log("etrxact", reader) // console.log("etrxact", reader)
@@ -296,7 +308,6 @@
image.title = file.name; image.title = file.name;
image.src = reader.result; image.src = reader.result;
image.className = "temp-thumb"; image.className = "temp-thumb";
console.log("read", el);
$(el).append(image); $(el).append(image);
//images.push(reader.result); //images.push(reader.result);
loadViewer(images); loadViewer(images);
@@ -315,8 +326,7 @@
const readDicomFile = (file) => { const readDicomFile = (file) => {
return dicomReader(file).then(reader => { return dicomReader(file).then(reader => {
var byteArray = new Uint8Array(reader.result); var byteArray = new Uint8Array(reader.result);
console.log("etrxact", reader) return checkAnnonymisation(byteArray, file);
return checkAnnonymisation(byteArray);
}); });
} }
@@ -341,12 +351,12 @@
let item = document.createElement("li"); let item = document.createElement("li");
item.className = "dicom-file"; item.className = "dicom-file";
item.textContent = file.webkitRelativePath; item.textContent = file.webkitRelativePath;
item.dataset.path = file.webkitRelativePath;
output.appendChild(item); output.appendChild(item);
//await readFile(file, item); //await readFile(file, item);
let url = URL.createObjectURL(file) let url = URL.createObjectURL(file)
console.log(url)
imageId = `wadouri:${url}`; imageId = `wadouri:${url}`;
if ($("#show-viewer").is(":checked")) { if ($("#show-viewer").is(":checked")) {
images.push(imageId); images.push(imageId);
@@ -384,8 +394,6 @@
} }
} }
console.log(images)
const event = new CustomEvent('loadDicomViewerUrls', { const event = new CustomEvent('loadDicomViewerUrls', {
"detail": {"images": images, "annontations": undefined} "detail": {"images": images, "annontations": undefined}
@@ -401,7 +409,6 @@
} }
function ocr(url, input) { function ocr(url, input) {
console.log("OCR", input)
Tesseract.recognize( Tesseract.recognize(
url, url,
'eng', 'eng',
@@ -414,7 +421,6 @@
l = text.toLowerCase(); l = text.toLowerCase();
uploading_el = $(`img[data-input-id='${input.id}'], div[data-input-id='${input.id}']`) uploading_el = $(`img[data-input-id='${input.id}'], div[data-input-id='${input.id}']`)
uploading_el.removeClass("image-ident-loading"); uploading_el.removeClass("image-ident-loading");
console.log("found text", l);
if (l.includes("accession") || l.includes("number") || l.search( if (l.includes("accession") || l.includes("number") || l.search(
/(ref|rk9|rh8|ra9|rbz)\d+/g) > -1) { /(ref|rk9|rh8|ra9|rbz)\d+/g) > -1) {
console.log("Match found ", input); console.log("Match found ", input);
@@ -457,7 +463,7 @@
// output.appendChild(item); // output.appendChild(item);
//} //}
loadDicomViewerTest(); loadDicomViewerUploads();
}, },
false, false,
); );
@@ -469,4 +475,10 @@
}); });
</script> </script>
<script type="text/hyperscript">
init
add @disabled to #uploadButton
end
</script>
{% endblock %} {% 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}} Adding self feedback for {{user_exam.exam}}/{{case}}
</div> </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"> <form class="highlight" action="" method="post">
{% csrf_token %} {% csrf_token %}
{{ form.non_field_errors }} {{ 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>{{ series.modality}}, {{ series.examination }}, {{ series.plane }}, {{ series.contrast }}</div>
<div>Description: {{series.description}}</div> <div>Description: {{series.description}}</div>
{% if series.case %} Associated case:
Associated case: {% for case in series.case.all %}
{% 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> <a href="{% url 'atlas:case_detail' pk=case.pk %}">{{case}}</a>
{% endfor %} {% if can_edit %}
{% else %} <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. 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 %} {% 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=''> {% comment %} <div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ image_url_array_and_count.0 }}" data-annotations=''>
</div> </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 can_edit %}
{% if editing_finding < 1 %} {% if editing_finding < 1 %}
<button id="add-finding-button">Add finding</button> <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 %} {% 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 id="finding-form">
<div class="hide" id="hidden-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"> <form method="post" id="series_finding_form">
{% csrf_token %} {% csrf_token %}
{{series_finding_form|crispy}} {{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> <button id="cancel-add-finding-button">Cancel</button>
</form> </form>
</div> </div>
@@ -44,8 +91,8 @@
{% for finding in series.findings.all %} {% for finding in series.findings.all %}
<div class="finding-box"> <div class="finding-box">
<button class="view-finding-button" data-annotationjson={{finding.annotation_json}} <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>
data-viewportjson={{finding.viewport_json}} data-findingid={{finding.id}} data-currentimageid={{finding.current_image_id_index}}>Click to view</button>
<span class="view-finding-details"> <span class="view-finding-details">
Finding(s): {% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br /> 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 /> Structure(s): {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br />
@@ -61,6 +108,11 @@
<pre>{{finding.viewport_json}}</pre> <pre>{{finding.viewport_json}}</pre>
<h4>Image ID</h4> <h4>Image ID</h4>
<pre>{{finding.current_image_id_index}}</pre> <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> </div>
{% endif %} {% endif %}
</span> </span>
@@ -78,6 +130,10 @@
<p> <p>
This will limit the series to the selected bounds (the rest of the images will be deleted). This is useful when you have a large volume of which only a small area is relevant to the case. NOTE: once deleted the images cannot be recovered on the site (they would have to be reuploaded if required). Make sure your images are shown in the correct order above and use test the test truncate button first. This will limit the series to the selected bounds (the rest of the images will be deleted). This is useful when you have a large volume of which only a small area is relevant to the case. NOTE: once deleted the images cannot be recovered on the site (they would have to be reuploaded if required). Make sure your images are shown in the correct order above and use test the test truncate button first.
</p> </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/> Start <input id="lower-truncation-bound-input" type="number" value="1"> <button id="set-lower-truncation-bound-button">Set</button><br/>
End <input id="upper-truncation-bound-input"type="number" value="{{image_url_array_and_count.1}}"> <button id="set-upper-truncation-bound-button">Set</button><br/> End <input id="upper-truncation-bound-input"type="number" value="{{image_url_array_and_count.1}}"> <button id="set-upper-truncation-bound-button">Set</button><br/>
<button id="truncate-test-button">Test truncate</button> <button id="truncate-test-button">Test truncate</button>
@@ -90,111 +146,100 @@
<div>Author: {{ series.get_author_display }}</div> <div>Author: {{ series.get_author_display }}</div>
{% if can_edit %} {% 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>
<details> <details>
<summary> <summary>Series info</summary>
Image details <div>
</summary> <a href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}"
<form hx-post="{% url 'atlas:image_diff' %}" title="Anonymise dicom images">Anonymise dicoms</a><br />
hx-target="#temp" <a href="{% url 'atlas:series_order_dicom' pk=series.pk %}" title="orders dicom by slice location">Order dicoms
> by slice location</a>
{% for image in series.get_images %} <a href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}"
[{{ image.id }}] <a href='{% url "atlas:series_image_dicom" image.pk %}'>{{image.image.url}}</a>, pos: {{image.position}}, {{image.upload_filename}} 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 %} {% if image.image %}
[{{image.get_file_size|filesizeformat}}] [{{image.get_file_size|filesizeformat}}]
{% endif %} {% endif %}
{{image.image_md5_hash}} ({{image.is_dicom}}) {{image.image_blake3_hash}} {{image.image_md5_hash}} ({{image.is_dicom}}) {{image.image_blake3_hash}}
<input type="checkbox" name="image-id" value="{{image.id}}"> <input type="checkbox" name="image-id" value="{{image.id}}">
<br /> <br />
{% comment %} {{image.get_dicom_info|safe}}<br /> {% endcomment %} {% comment %} {{image.get_dicom_info|safe}}<br /> {% endcomment %}
{% endfor %} {% endfor %}
<button>Diff files</button> <button>Diff files</button>
</form> </form>
<span id="temp"></span> <span id="temp"></span>
</details> </details>
<p>Total image size: {{series.get_total_image_size|filesizeformat}}</p> <p>Total image size: {{series.get_total_image_size|filesizeformat}}</p>
<p><a href="{% url 'atlas:series_download' pk=series.pk %}">Download images</a></p> <p><a href="{% url 'atlas:series_download' pk=series.pk %}">Download images</a></p>
</details> </details>
<span id="diff-controls">show <span id="diff-show-all" _="on click <span id="diff-controls">show <span id="diff-show-all" _="on click
log '1' log '1'
for row in .diff.rows for row in .diff.rows
show row show row
end">all</span> <span id="diff-show-diff" _=" end">all</span> <span id="diff-show-diff" _="
on click on click
log 'go' log 'go'
for row in <.diff tr/> for row in <.diff tr/>
log row.children[0].innerHTML log row.children[0].innerHTML
if row.children[0].innerHTML is empty if row.children[0].innerHTML is empty
hide row hide row
end end
end end
">diff</span></span> ">diff</span></span>
{% endif %} {% 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> <script>
$(document).ready(function () { $(document).ready(function () {
const apiKey = "root"
$("#set-lower-truncation-bound-button").click(() => { $("#set-lower-truncation-bound-button").click(() => {
dicom_element = $(".cornerstone-element").get(0); $("#lower-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
index = cornerstone.getEnabledElement(dicom_element).toolStateManager.toolState.stack.data[0].currentImageIdIndex
$("#lower-truncation-bound-input").val(index+1);
}); });
$("#set-upper-truncation-bound-button").click(() => { $("#set-upper-truncation-bound-button").click(() => {
dicom_element = $(".cornerstone-element").get(0); $("#upper-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
index = cornerstone.getEnabledElement(dicom_element).toolStateManager.toolState.stack.data[0].currentImageIdIndex
$("#upper-truncation-bound-input").val(index+1);
}); });
$("#truncate-test-button").click(() => { $("#truncate-test-button").click(() => {
lower_bound = parseInt($("#lower-truncation-bound-input").val()) - 1; const lower = parseInt($("#lower-truncation-bound-input").val()) - 1;
upper_bound = parseInt($("#upper-truncation-bound-input").val()) - 1; const upper = parseInt($("#upper-truncation-bound-input").val()) - 1;
const ok = window[`truncateStack_${apiKey}`](lower, upper);
$("#lower-truncation-bound-input").prop("disabled", true); if (ok) {
$("#upper-truncation-bound-input").prop("disabled", true); $("#truncate-output").text("The truncated images are shown above.");
$("#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;
} }
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(() => { $("#truncate-button").click(() => {
lower_bound = parseInt($("#lower-truncation-bound-input").val()) - 1; lower_bound = parseInt($("#lower-truncation-bound-input").val()) - 1;
upper_bound = parseInt($("#upper-truncation-bound-input").val()) - 1; upper_bound = parseInt($("#upper-truncation-bound-input").val()) - 1;
@@ -214,11 +259,12 @@
$("#add-finding-button").click(() => { $("#add-finding-button").click(() => {
$("#hidden-form").show() $("#hidden-form").show()
dicom_element = $(".cornerstone-element").get(0); //dicom_element = $(".cornerstone-element").get(0);
//cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element); //cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
//cornerstone.reset(dicom_element); //cornerstone.reset(dicom_element);
////cornerstone.resize(dicom_element, true); ////cornerstone.resize(dicom_element, true);
$("#add-finding-button").hide() $("#add-finding-button").hide()
$("#clone-finding-button").hide()
//$("#finding-form").empty().append( //$("#finding-form").empty().append(
// $("#hidden-form form").clone() // $("#hidden-form form").clone()
//); //);
@@ -227,84 +273,47 @@
$("#cancel-add-finding-button").click((e) => { $("#cancel-add-finding-button").click((e) => {
$("#hidden-form").hide() $("#hidden-form").hide()
$("#add-finding-button").show() $("#add-finding-button").show()
$("#clone-finding-button").show()
e.preventDefault(); e.preventDefault();
}); });
$("#reset-viewport-button").click(() => { $("#reset-viewport-button").click(() => {
dicom_element = $(".cornerstone-element").get(0); resetViewer_root();
//annotationjson = JSON.parse(e.currentTarget.dataset.annotationjson);
cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
cornerstone.reset(dicom_element);
cornerstone.resize(dicom_element, true);
}); });
$(".view-finding-button-3d").each((n, el) => {
$(".view-finding-button").each((n, el) => {
$(el).click((e) => { $(el).click((e) => {
dicom_element = $(".cornerstone-element").get(0); dicom_element = $(".cornerstone-element").get(0);
annotationjson = JSON.parse(e.currentTarget.dataset.annotationjson); annotationjson3d = JSON.parse(e.currentTarget.dataset.annotationjson3d);
viewport = JSON.parse(e.currentTarget.dataset.viewportjson); viewer_state_json = JSON.parse(e.currentTarget.dataset.viewerstatejson);
current_image_id_index = e.currentTarget.dataset.currentimageid; console.log("Loading 3D annotation and viewer state", annotationjson3d, viewer_state_json);
console.log(e.currentTarget, current_image_id_index) importAnnotations_root(annotationjson3d);
loadAnnotationAndViewportOnElement(annotationjson, viewport, dicom_element, current_image_id_index); 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 %} {% if editing_finding > 0 %}
$("#hidden-form").show() $("#hidden-form").show()
setTimeout(function() { setTimeout(function() {
$(".view-finding-button[data-findingid={{editing_finding}}]").trigger("click"); $(".view-finding-button-3d[data-findingid={{editing_finding}}]").trigger("click");
}, 1000) }, 1000)
{% endif %} {% 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) { $(document).on('submit', '#series_finding_form', function (e) {
dicom_element = $(".cornerstone-element").get(0);
c = cornerstone.getEnabledElement(dicom_element);
e.preventDefault(); e.preventDefault();
$.ajax({ $.ajax({
@@ -314,14 +323,8 @@
series_finding_id: {{ editing_finding }}, series_finding_id: {{ editing_finding }},
description: $('#id_description').val(), description: $('#id_description').val(),
series: {{ series.pk }}, series: {{ series.pk }},
//annotation_json: JSON.stringify(c.toolStateManager.saveToolState()), annotation_json_3d: window.exportAnnotations_root(),
// This is the stack number viewer_state_3d: window.exportViewerState_root(),
//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),
findings: JSON.stringify($('#finding-form select[name="findings"]').find(":selected") findings: JSON.stringify($('#finding-form select[name="findings"]').find(":selected")
.map((i, el) => { .map((i, el) => {
return $(el).val() return $(el).val()
@@ -351,3 +354,36 @@
}); });
}); });
</script> </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> <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=''> {% if not forloop.last %},{% endif %}{% endfor %}]" data-annotations=''>
</div> </div>
@@ -17,10 +17,10 @@
</summary> </summary>
{% for dicom in dicoms %} {% for dicom in dicoms %}
{% if dicom.image %} {% if dicom.image %}
[{{image.image.size|filesizeformat}}] [{{dicom.image.size|filesizeformat}}]
{% endif %} {% endif %}
{{image.image_md5_hash}} ({{image.is_dicom}}) {% comment %} {{dicom.image_md5_hash}} ({{dicom.is_dicom}}) {% endcomment %}
<br /> <br />
+71 -5
View File
@@ -6,7 +6,7 @@
<a href="{% url 'atlas:new_uploads' %}">Upload more dicoms</a><br/> <a href="{% url 'atlas:new_uploads' %}">Upload more dicoms</a><br/>
{% if not case %} {% if not case %}
<div class="alert alert-info" role="alert"> <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> </div>
{% endif %} {% endif %}
@@ -18,10 +18,11 @@
{% for series, n, tags, date in series_list %} {% for series, n, tags, date in series_list %}
<li> <li>
<span _=" <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 to the first <input/> in me
then set i.checked to not i.checked" > then set i.checked to not i.checked" >
{{date}}: {{series}} [{{n}} images] <br/> Uploaded: {{date}}<br/>
Series: {{series}} [{{n}} images] <br/>
{{tags}} {{tags}}
<input class="hidden" type="checkbox" name="selection" value="{{ series }}"> <input class="hidden" type="checkbox" name="selection" value="{{ series }}">
</span> </span>
@@ -34,14 +35,14 @@
</ul> </ul>
{% if case %} {% if case %}
<p>Importing into case {{case}}</p> <p>Importing into case: <a href='{% url "atlas:case_detail" case.pk %}'>{{case}}</a></p>
{% endif %} {% endif %}
<details><summary>Upload settings</summary> <details><summary>Upload settings</summary>
<form> <form>
<div> <div>
<fieldset><legend>Select how series should be ordered when uploaded</legend> <fieldset><legend>Select how series should be ordered when uploaded</legend>
<div class="helptext"> <div class="helptext">
This will affect how series are displayed when viewing with the built in site viewer. By default when exporting from Insight the order is not maintained so you should choose to order 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. If you are not sure, leave this as the default.
</div> </div>
<div> <div>
@@ -71,6 +72,7 @@
<button id="import-dicoms-case-button" <button id="import-dicoms-case-button"
hx-post="{% url 'api-1:import_dicoms_case' case.id %}" hx-post="{% url 'api-1:import_dicoms_case' case.id %}"
hx-target=".imported" hx-target=".imported"
{% comment %} hx-indicator=".indicator" {% endcomment %}
hx-include=".selected,[name='order-series']" hx-include=".selected,[name='order-series']"
>Import into case</button> >Import into case</button>
@@ -78,9 +80,11 @@
<button id="import-dicoms-button" <button id="import-dicoms-button"
hx-post="{% url 'api-1:import_dicoms' %}" hx-post="{% url 'api-1:import_dicoms' %}"
hx-target=".imported" hx-target=".imported"
{% comment %} hx-indicator=".indicator" {% endcomment %}
hx-include=".selected,[name='order-series']" hx-include=".selected,[name='order-series']"
>Import</button> >Import</button>
{% endif %} {% endif %}
<div class="indicator"><div class="loader"></div>Loading...</div>
<div class="imported"> <div class="imported">
@@ -93,3 +97,65 @@
<p><a href="{% url 'atlas:series_view' %}?case=null&sort=-modified_date">View orphan series</a></p> <p><a href="{% url 'atlas:series_view' %}?case=null&sort=-modified_date">View orphan series</a></p>
{% endblock %} {% 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 %} {% block content %}
<div class="container-fluid"> <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> <h2>My Collections</h2>
<details> <details>
<summary> <summary>
+35 -1
View File
@@ -138,6 +138,11 @@ urlpatterns = [
views.collection_case_dicom_json, views.collection_case_dicom_json,
name="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( path(
"collection/<int:exam_id>/question_schemas", "collection/<int:exam_id>/question_schemas",
views.collection_question_schemas, views.collection_question_schemas,
@@ -148,6 +153,16 @@ urlpatterns = [
views.collection_reset_answers, views.collection_reset_answers,
name="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( #path(
# "question_schemas_preset", # "question_schemas_preset",
# views.question_schemas_preset, # views.question_schemas_preset,
@@ -239,11 +254,20 @@ urlpatterns = [
views.use_dates_as_descriptions, views.use_dates_as_descriptions,
name="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( path(
"series/<int:pk>/dicom_json", "series/<int:pk>/dicom_json",
views.series_dicom_json, views.series_dicom_json,
name="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("uncategorised_dicoms/", views.uncategorised_dicoms, name="uncategorised_dicoms_view"),
path("question_schema/", views.QuestionSchemaView.as_view(), name="question_schema_overview"), path("question_schema/", views.QuestionSchemaView.as_view(), name="question_schema_overview"),
path("question_schema/<int:pk>", views.question_schema_detail, name="question_schema_detail"), path("question_schema/<int:pk>", views.question_schema_detail, name="question_schema_detail"),
@@ -296,10 +320,13 @@ urlpatterns = [
name="structure_update", name="structure_update",
), ),
path("structure/create", views.StructureCreate.as_view(), name="structure_create"), 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"), 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) # 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>", 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: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/", views.SeriesView.as_view(), name="series_view"),
path("series_image/<int:pk>/dicom", views.series_image_dicom, name="series_image_dicom"), path("series_image/<int:pk>/dicom", views.series_image_dicom, name="series_image_dicom"),
path( path(
@@ -362,10 +389,12 @@ urlpatterns = [
name="case_collection_form", name="case_collection_form",
), ),
path("case/<int:pk>/clone", views.AtlasClone.as_view(), name="case_clone"), 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("verified/", views.verified, name="verified"),
# path("all_questions/", views.all_questions, name="all_questions"), # path("all_questions/", views.all_questions, name="all_questions"),
path("case/<int:pk>/scrap", views.atlas_scrap, name="case_scrap"), 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: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/create/", views.AtlasCreate.as_view(), name="case_create"),
path( path(
"case/collection/<int:pk>/create", "case/collection/<int:pk>/create",
@@ -421,7 +450,7 @@ urlpatterns = [
), ),
path( path(
"structure-autocomplete", "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", name="structure-autocomplete",
), ),
path("presentation/", views.PresentationView.as_view(), name="presentation_view"), path("presentation/", views.PresentationView.as_view(), name="presentation_view"),
@@ -439,4 +468,9 @@ urlpatterns = [
name="pathological_process_detail", name="pathological_process_detail",
), ),
path("combine_series/", views.combine_series, name="combine_series"), 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.shortcuts import render, get_object_or_404, redirect
from django import forms from django import forms
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_POST
# from django.contrib.auth.models import User # from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.decorators import login_required, user_passes_test
@@ -22,6 +23,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMix
from django.views.generic import View from django.views.generic import View
from django.views.generic.detail import DetailView from django.views.generic.detail import DetailView
import pydicom import pydicom
from pydicom.uid import generate_uid
from generic.mixins import SuperuserRequiredMixin from generic.mixins import SuperuserRequiredMixin
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView 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 Http404, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse 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 atlas.helpers import get_cases_available_to_user
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
from .forms import ( from .forms import (
AddCollectionToCaseForm, AddCollectionToCaseForm,
@@ -53,6 +56,7 @@ from .forms import (
ExamGroupsForm, ExamGroupsForm,
FindingForm, FindingForm,
JsonAnswerForm, JsonAnswerForm,
#ModelPriorCaseForm,
PriorCaseForm, PriorCaseForm,
QuestionSchemaForm, QuestionSchemaForm,
ResourceForm, ResourceForm,
@@ -115,10 +119,12 @@ from .filters import (
SubspecialtyFilter, SubspecialtyFilter,
) )
from .tasks import push_case_to_cimar_task
from django_tables2 import SingleTableView, SingleTableMixin from django_tables2 import SingleTableView, SingleTableMixin
from django_filters.views import FilterView from django_filters.views import FilterView
from autocomplete import HTMXAutoComplete # from autocomplete import HTMXAutoComplete
from .decorators import ( from .decorators import (
@@ -156,11 +162,11 @@ from reversion.views import RevisionMixin
import reversion import reversion
from rest_framework import viewsets
import os import os
import difflib import difflib
from helpers.cimar import CimarAPI
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -196,19 +202,48 @@ def case_detail(request, pk):
# if request.user not in atlas.author.all(): # if request.user not in atlas.author.all():
# raise PermissionDenied # raise PermissionDenied
can_edit = case.check_user_can_edit(request.user)
# logging.debug(atlas.subspecialty.first().name.all()) # 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 @login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access @user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_thumbnail(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) 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) 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 @login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access @user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
@@ -225,16 +260,21 @@ def series_detail(request, pk, finding_pk=None):
else: else:
series_finding_form = SeriesFindingForm(series_id=series.id) series_finding_form = SeriesFindingForm(series_id=series.id)
# logging.debug(atlas.subspecialty.first().name.all()) context = {
return render(
request,
"atlas/series.html",
{
"series": series, "series": series,
"series_finding_form": series_finding_form, "series_finding_form": series_finding_form,
"editing_finding": editing_finding, "editing_finding": editing_finding,
"can_edit": can_edit, "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") return HttpResponse("Case already in collection")
print(case) print(case)
collection.add_case(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": # if request.method == "POST":
@@ -875,7 +915,7 @@ class QuestionSchemaCreate(LoginRequiredMixin, CreateView):
form_class = QuestionSchemaForm form_class = QuestionSchemaForm
class QuestionSchemaUpdate(LoginRequiredMixin, UpdateView): class QuestionSchemaUpdate(AuthorOrCheckerRequiredMixin, UpdateView):
model = QuestionSchema model = QuestionSchema
form_class = QuestionSchemaForm form_class = QuestionSchemaForm
@@ -884,7 +924,6 @@ class QuestionSchemaDelete(AuthorOrCheckerRequiredMixin, DeleteView):
model = QuestionSchema model = QuestionSchema
template_name = "confirm_delete.html" template_name = "confirm_delete.html"
class FindingUpdate(RevisionMixin, LoginRequiredMixin, UpdateView): class FindingUpdate(RevisionMixin, LoginRequiredMixin, UpdateView):
model = Finding model = Finding
form_class = FindingForm form_class = FindingForm
@@ -953,6 +992,9 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
form.instance.author.add(self.request.user.id) 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) context = self.get_context_data(form=form)
series_formset = context["series_formset"] series_formset = context["series_formset"]
casedifferential_formset = context["casedifferential_formset"] casedifferential_formset = context["casedifferential_formset"]
@@ -1119,7 +1161,7 @@ def add_collection_to_case_form(request, case_id):
class AtlasClone(AtlasCreateBase, AuthorOrCheckerRequiredMixin, CreateView): class AtlasClone(AtlasCreateBase, AuthorOrCheckerRequiredMixin, CreateView):
"""Clones a existing atlas""" """Clones a existing atlas case"""
# fields = '__all__' # fields = '__all__'
# #fields = [ 'condition' ] # #fields = [ 'condition' ]
@@ -1140,7 +1182,16 @@ class AtlasClone(AtlasCreateBase, AuthorOrCheckerRequiredMixin, CreateView):
else: else:
pass 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 return initial_data
@@ -1182,7 +1233,7 @@ def get_examination_id(request):
class CaseView(LoginRequiredMixin, SingleTableMixin, FilterView): class CaseView(LoginRequiredMixin, SingleTableMixin, FilterView):
model = Case model = Case
table_class = CaseTable table_class = CaseTable
template_name = "atlas/view.html" template_name = "atlas/case_view.html"
filterset_class = CaseFilter filterset_class = CaseFilter
@@ -1190,7 +1241,7 @@ class CaseView(LoginRequiredMixin, SingleTableMixin, FilterView):
class SeriesView(LoginRequiredMixin, SingleTableMixin, FilterView): class SeriesView(LoginRequiredMixin, SingleTableMixin, FilterView):
model = Series model = Series
table_class = SeriesTable table_class = SeriesTable
template_name = "atlas/view.html" template_name = "atlas/series_view.html"
filterset_class = SeriesFilter filterset_class = SeriesFilter
@@ -1206,7 +1257,7 @@ class ConditionView(LoginRequiredMixin, SingleTableMixin, FilterView):
class QuestionSchemaView(LoginRequiredMixin, SingleTableMixin, FilterView): class QuestionSchemaView(LoginRequiredMixin, SingleTableMixin, FilterView):
model = QuestionSchema model = QuestionSchema
table_class = QuestionSchemaTable table_class = QuestionSchemaTable
template_name = "atlas/view.html" template_name = "atlas/schema_table_view.html"
filterset_class = QuestionSchemaFilter filterset_class = QuestionSchemaFilter
@@ -1399,9 +1450,11 @@ def create_series_findings(request):
structure_ids = json.loads(request.POST.get("structures")) structure_ids = json.loads(request.POST.get("structures"))
condition_ids = json.loads(request.POST.get("conditions")) condition_ids = json.loads(request.POST.get("conditions"))
description = request.POST.get("description") description = request.POST.get("description")
annotation_json = request.POST.get("annotation_json") #annotation_json = request.POST.get("annotation_json")
viewport_json = request.POST.get("viewport_json") #viewport_json = request.POST.get("viewport_json")
current_image_id_index = request.POST.get("current_image_id_index") #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) series = Series.objects.get(pk=series_id)
findings = Finding.objects.filter(pk__in=findings_ids) 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 = get_object_or_404(SeriesFinding, pk=series_finding_id)
sf.series = series sf.series = series
sf.description = description sf.description = description
sf.annotation_json = annotation_json #sf.annotation_json = annotation_json
sf.viewport_json = viewport_json #sf.viewport_json = viewport_json
else: else:
sf = SeriesFinding.objects.create( sf = SeriesFinding.objects.create(
series=series, series=series,
description=description, description=description,
annotation_json=annotation_json, #annotation_json=annotation_json,
viewport_json=viewport_json, #viewport_json=viewport_json,
) )
sf.findings.set(findings) sf.findings.set(findings)
sf.structures.set(structures) sf.structures.set(structures)
sf.conditions.set(conditions) 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() sf.save()
return JsonResponse({"success": True}) return JsonResponse({"success": True})
@@ -1656,7 +1711,9 @@ def collection_history_user(request, exam_id: int, user_pk: int):
print(user) print(user)
print(casedetails) 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( return render(
request, request,
@@ -1682,7 +1739,11 @@ def collection_take_start(request, pk):
""" """
collection = get_object_or_404(CaseCollection, pk=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 cid_exam = None
if collection.collection_type == "REV" or valid_user: if collection.collection_type == "REV" or valid_user:
@@ -1727,6 +1788,7 @@ def collection_case_priors(request, exam_id, case_id):
return HttpResponse( return HttpResponse(
"You need to enter text to describe the relationship between the cases" "You need to enter text to describe the relationship between the cases"
) )
p.prior_visibility = request.POST["prior_visibility"]
p.save() p.save()
@@ -1741,7 +1803,7 @@ def collection_case_priors(request, exam_id, case_id):
added_relation_map = {} added_relation_map = {}
for prior in added_priors: 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 case = case_detail.case
@@ -1750,9 +1812,9 @@ def collection_case_priors(request, exam_id, case_id):
available_priors = [] available_priors = []
for case in available_cases: for case in available_cases:
if case in added_relation_map: 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: else:
available_priors.append((case, False, "")) available_priors.append((case, False, "", "AL"))
form = PriorCaseForm() form = PriorCaseForm()
@@ -1769,6 +1831,7 @@ def collection_case_priors(request, exam_id, case_id):
{ {
"case_detail": case_detail, "case_detail": case_detail,
"form": form, "form": form,
#"model_form": model_form,
# "example_form": example_form, # "example_form": example_form,
"collection": collection, "collection": collection,
"case": case_detail.case, "case": case_detail.case,
@@ -2263,17 +2326,6 @@ def collection_case_view_take(
series_list = case.series.all().prefetch_related("images", "examination", "plane") 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 previous = case_number > 0
next = case_number < (case_count - 1) next = case_number < (case_count - 1)
@@ -2309,6 +2361,25 @@ def collection_case_view_take(
resources = case.caseresource_set.filter(pre_review=True) 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( return render(
request, request,
"atlas/collection_case_view_take.html", "atlas/collection_case_view_take.html",
@@ -2377,6 +2448,7 @@ def collection_case_view(request, pk, case_number):
"previous": previous, "previous": previous,
"next": next, "next": next,
"answer": answer, "answer": answer,
"cimar_sid": request.user.userprofile.cimar_sid,
}, },
) )
@@ -2397,11 +2469,16 @@ def use_dates_as_descriptions(request, pk):
return HttpResponse("Done") 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) 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)) 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): def collection_reset_answers(request, exam_id: int):
if request.htmx: if request.htmx:
collection = get_object_or_404(CaseCollection, pk=exam_id) collection = get_object_or_404(CaseCollection, pk=exam_id)
@@ -2926,3 +3042,143 @@ def collection_reset_answers(request, exam_id: int):
else: else:
raise Http404("Invalid request") 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: 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: web:
build: ./rad build: ./rad
command: python manage.py runserver 0.0.0.0:8000 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. # Register your models here.
from .models import ( from .models import (
CidUserGroup, CidUserGroup,
CimarCase,
Examination, Examination,
# Sign, # Sign,
Modality, Modality,
@@ -55,6 +56,7 @@ admin.site.register(UserGrades)
admin.site.register(Supervisor) admin.site.register(Supervisor)
admin.site.register(Modality) admin.site.register(Modality)
admin.site.register(ExamCollection) admin.site.register(ExamCollection)
admin.site.register(CimarCase)
class ExamListFilter(admin.SimpleListFilter): class ExamListFilter(admin.SimpleListFilter):
+6 -6
View File
@@ -43,14 +43,14 @@ class UserUserFilter(django_filters.FilterSet):
model = User model = User
# fields = ("cid", "active", "internal_candidate", "group") # fields = ("cid", "active", "internal_candidate", "group")
fields = { fields = {
"first_name": ["contains"], "first_name": ["icontains"],
"last_name": ["contains"], "last_name": ["icontains"],
#"active": ["exact"], #"active": ["exact"],
"user_groups": ["exact"], "user_groups": ["exact"],
"user_groups__name": ["contains"], "user_groups__name": ["icontains"],
#"userprofile__grade": ["exact"], #"userprofile__grade": ["exact"],
#"userprofile__supervisor": ["exact"], #"userprofile__supervisor": ["exact"],
"email": ["contains"], "email": ["icontains"],
"is_active": ["exact"], "is_active": ["exact"],
"groups": ["exact"], "groups": ["exact"],
} }
@@ -93,8 +93,8 @@ class SupervisorFilter(django_filters.FilterSet):
class Meta: class Meta:
model = Supervisor model = Supervisor
fields = { fields = {
"name": ["contains"], "name": ["icontains"],
"email": ["contains"], "email": ["icontains"],
"active": ["exact"], "active": ["exact"],
"site": ["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 django.forms.utils import from_current_timezone, to_current_timezone
from crispy_forms.helper import FormHelper 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 from dal import autocomplete
@@ -119,7 +120,35 @@ class ExamFormMixin:
self.helper.form_method = "post" self.helper.form_method = "post"
self.helper.form_action = "submit" 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(): # if user.is_superuser or user.groups.filter(name="cid_user_manager").exists():
# cid_user_group_queryset = CidUserGroup.objects.filter(archive=False) # cid_user_group_queryset = CidUserGroup.objects.filter(archive=False)
@@ -164,6 +193,7 @@ class ExamFormMixin:
"open_access", "open_access",
"authors_only", "authors_only",
"exam_mode", "exam_mode",
"exam_open_access",
"include_history", "include_history",
"restrict_to_dates", "restrict_to_dates",
"start_date", "start_date",
@@ -171,6 +201,7 @@ class ExamFormMixin:
"active", "active",
"publish_results", "publish_results",
"archive", "archive",
"results_supervisor_visible"
# "cid_user_groups", # "cid_user_groups",
# "user_user_groups", # "user_user_groups",
# "author", # "author",
@@ -233,7 +264,7 @@ class ExamGroupsFormMixin(ModelForm):
cid_user_group_queryset = CidUserGroup.objects.filter( cid_user_group_queryset = CidUserGroup.objects.filter(
archive=False, open_access=True archive=False, open_access=True
) )
user_user_group_queryset = UserUserGroup.objects.none( user_user_group_queryset = UserUserGroup.objects.filter(
archive=False, open_access=True archive=False, open_access=True
) )
# cid_user_group_queryset = CidUserGroup.objects.none() # cid_user_group_queryset = CidUserGroup.objects.none()
@@ -675,6 +706,7 @@ class UserUserForm(Form):
last_name = CharField(max_length=255, required=True) last_name = CharField(max_length=255, required=True)
grade = ModelChoiceField(UserGrades.objects.all(), required=False) grade = ModelChoiceField(UserGrades.objects.all(), required=False)
class TraineeForm(Form): class TraineeForm(Form):
username = EmailField( username = EmailField(
required=True, required=True,
@@ -684,10 +716,12 @@ class TraineeForm(Form):
last_name = CharField(max_length=255, required=True) last_name = CharField(max_length=255, required=True)
grade = ModelChoiceField(UserGrades.objects.all(), required=False) grade = ModelChoiceField(UserGrades.objects.all(), required=False)
supervisor = ModelChoiceField( 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 ) # Needs to be a user/object ref
#class Meta: # class Meta:
# widgets = { # widgets = {
# "supervisor" : autocomplete.ModelSelect2(url='generic:supervisor-autocomplete') # "supervisor" : autocomplete.ModelSelect2(url='generic:supervisor-autocomplete')
# } # }
@@ -737,6 +771,11 @@ class ExamCollectionForm(ModelForm):
widget=FilteredSelectMultiple(verbose_name=name, is_stacked=False), 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): def save(self, commit=True):
# Get the unsaved Long instance # Get the unsaved Long instance
instance = ModelForm.save(self, False) instance = ModelForm.save(self, False)
@@ -768,3 +807,17 @@ class ExamCollectionCloneForm(ModelForm):
class Meta: class Meta:
model = ExamCollection model = ExamCollection
fields = ("name", "date") fields = ("name", "date")
from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register
class UserAutocomplete(ModelAutocomplete):
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 "None"
return authors 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): def add_author(self, user: User):
"""Add an author to the object""" """Add an author to the object"""
self.author.add(user) 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.db import models
from django.forms import ValidationError from django.forms import ValidationError
from django.http import Http404, HttpRequest from django.http import Http404, HttpRequest
from django.shortcuts import render
from django.utils import timezone from django.utils import timezone
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from smtplib import SMTPException from smtplib import SMTPException
@@ -29,7 +30,8 @@ from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from generic.mixins import AuthorMixin, QuestionMixin from generic.mixins import AuthorMixin, QuestionMixin
from helpers.images import get_image_hash, pretty_print_dicom 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 django.utils.html import format_html
from easy_thumbnails.files import get_thumbnailer from easy_thumbnails.files import get_thumbnailer
@@ -37,11 +39,44 @@ from easy_thumbnails.exceptions import InvalidImageFormatError
import pydicom import pydicom
import pydicom.errors
import dicognito.anonymizer import dicognito.anonymizer
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.forms.models import model_to_dict from django.forms.models import model_to_dict
from django.forms.utils import from_current_timezone, to_current_timezone 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): def findMiddle(input_list):
"""Returns the middle element of a list""" """Returns the middle element of a list"""
@@ -196,6 +231,29 @@ class QuestionBase(models.Model, AuthorMixin, QuestionMixin):
return False 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): class SeriesImageBase(models.Model):
""" """
@@ -238,6 +296,24 @@ class SeriesImageBase(models.Model):
ordering = ["position"] ordering = ["position"]
abstract = True 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): def get_dicom_json(self):
try: try:
json = pydicom.read_file(self.image).to_json() json = pydicom.read_file(self.image).to_json()
@@ -336,6 +412,8 @@ class SeriesBase(models.Model):
choices=SeriesModifiedChocies.choices, 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: class Meta:
abstract = True abstract = True
@@ -417,7 +495,7 @@ class SeriesBase(models.Model):
else: else:
return format_html('", "'.join(images)) 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) images = self.images.filter(removed=False)
if len(images) < 1: if len(images) < 1:
@@ -429,7 +507,9 @@ class SeriesBase(models.Model):
if recreate: if recreate:
thumbnailer.delete_thumbnails() thumbnailer.delete_thumbnails()
thumbnail = thumbnailer["exam-list"] thumbnail = thumbnailer["exam-list"]
except InvalidImageFormatError: except InvalidImageFormatError as e:
if fail_loudly:
raise e
return format_html( return format_html(
'<img title="{}" src="/static/not-found-image.jpg" />', img '<img title="{}" src="/static/not-found-image.jpg" />', img
), len(images) ), len(images)
@@ -488,13 +568,18 @@ class SeriesBase(models.Model):
n = n + 1 n = n + 1
def order_by_dicom(self, field="SliceLocation"): def order_by_dicom(self, field="SliceLocation"):
print(f"Ordering by dicom field {field}")
images = self.images.filter(removed=False) images = self.images.filter(removed=False)
files = [] files = []
for i in images: for i in images:
files.append((i, pydicom.dcmread(i.image.path))) files.append((i, pydicom.dcmread(i.image.path)))
print("images to order")
print(files)
# print("file count: {}".format(len(files))) # print("file count: {}".format(len(files)))
# skip files with no SliceLocation (eg scout views) # skip files with no SliceLocation (eg scout views)
@@ -522,10 +607,18 @@ class SeriesBase(models.Model):
for f in slices: for f in slices:
i = map[f[field].value] i = map[f[field].value]
i.position = n i.position = n
print(f"Setting position {n} for {i}")
i.save() i.save()
n = n + 1 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) images = self.images.filter(removed=False)
size = 0 size = 0
@@ -533,6 +626,9 @@ class SeriesBase(models.Model):
for i in images: for i in images:
size += i.get_file_size() size += i.get_file_size()
self.total_image_size = size
self.save()
return size return size
def anonymise_images(self): def anonymise_images(self):
@@ -602,6 +698,13 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
default=False, 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: class Meta:
abstract = True abstract = True
@@ -696,7 +799,8 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
user_id: int | None = None, user_id: int | None = None,
allow_authors: bool = True, 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: if user is not None and user.is_superuser:
return True return True
@@ -705,15 +809,22 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
if self.author.filter(pk=user_id).exists(): if self.author.filter(pk=user_id).exists():
return True 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 cid is None and user_id is None:
if user.is_anonymous:
return False
if user is not None: if user is not None:
user_id = user.id user_id = user.id
else: else:
return False 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 # Start by checking if the logged in user can access
if user_id is not None: if user_id is not None:
if self.valid_user_users.filter(pk=user_id).exists(): if self.valid_user_users.filter(pk=user_id).exists():
@@ -845,6 +956,12 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
return cloned_model return cloned_model
def add_markers(self, users):
self.markers.add(*users)
def add_marker(self, user):
self.markers.add(user)
class ExamBase(ExamOrCollectionGenericBase): class ExamBase(ExamOrCollectionGenericBase):
exam_mode = models.BooleanField( exam_mode = models.BooleanField(
@@ -1456,6 +1573,8 @@ class CidUserExam(models.Model):
# TODO switch to json field? # TODO switch to json field?
results_emailed_status = models.CharField(max_length=255, blank=True) 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: def __str__(self) -> str:
if self.cid_user is None: if self.cid_user is None:
try: try:
@@ -1483,6 +1602,12 @@ class CidUserExam(models.Model):
else: else:
return "CID" + str(self.cid_user.cid) 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 = ( CID_GROUP_EXAMS = (
("SBAs", "sba_cid_user_groups"), ("SBAs", "sba_cid_user_groups"),
@@ -1620,6 +1745,8 @@ class UserProfile(models.Model):
username = property(getusername) username = property(getusername)
cimar_sid = models.CharField(max_length=100, blank=True)
def get_exams(self): def get_exams(self):
available_exams = defaultdict(list) available_exams = defaultdict(list)
@@ -1694,7 +1821,200 @@ class ExamCollection(models.Model, AuthorMixin):
) )
def __str__(self): 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): def get_absolute_url(self):
return reverse("generic:examcollection_detail", kwargs={"pk": self.pk}) 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") sequence = ("name", "email")
exclude = ("id",) exclude = ("id",)
attrs = {"id": "supervisor-table"}
def render_edit(self, value, record): def render_edit(self, value, record):
url = f"{reverse("generic:supervisor_edit", args=[record.pk])}?redirect={self.request.path}" 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:manage_cid_exams' %}">Cids (exams)</a> /
<a href="{% url 'generic:cid_group_view' %}">Cid Groups</a> / <a href="{% url 'generic:cid_group_view' %}">Cid Groups</a> /
<a href="{% url 'generic:user_group_view' %}">User 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 'accounts_list' %}">Manage Users</a> /
<a href="{% url 'generic:supervisor' %}">Manage Supervisors</a> / <a href="{% url 'generic:supervisor' %}">Manage Supervisors</a> /
<a href="{% url 'generic:examcollection_list' %}">Collections</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> They can be created (or added) <a href="{% url 'generic:manage_cids' %}">here</a>
{% endif %} {% endif %}
{% 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> <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. 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" %} {% if group_type == "cid" %}
@@ -52,30 +70,46 @@
<div> <div>
{% for exam in value %} {% for exam in value %}
<div class="d-flex"> <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" %} {% if group_type == "cid" %}
<a href="{{exam.get_cid_edit_url}}" class="edit-link" px-2>Edit candidates</a> <a href="{{exam.get_cid_edit_url}}" class="edit-link" px-2>Edit candidates</a>
{% comment %} <button>Add group candidates</button> <form>
<button>Remove group candidates</button> {% endcomment %} <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 %} {% else %}
<a href="{{exam.get_user_edit_url}}" class="edit-link px-2">Edit candidates</a> <a href="{{exam.get_user_edit_url}}" class="edit-link px-2">Edit candidates</a>
<form> <form>
<input type="hidden" name="exam_id" value="{{exam.pk}}"> <input type="hidden" name="exam_id" value="{{exam.pk}}">
<input type="hidden" name="exam_type" value="{{exam.app_name}}"> <input type="hidden" name="exam_type" value="{{exam.app_name}}">
<button <button
class="btn btn-sm px-2" class="btn btn-sm px-2"
hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}" hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}"
title="Add all group candidates to this exam" title="Add all group candidates to this exam"
name="action" name="action"
value="add" value="add"
>Add group candidates</button> >Add group candidates</button>
<button <button
hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}" hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}"
title="Remove all group candidates to this exam" title="Remove all group candidates to this exam"
class="btn btn-sm px-2" class="btn btn-sm px-2"
name="action" name="action"
value="remove" value="remove"
>Remove group candidates</button> >Remove group candidates</button>
</form> </form>
{% endif %} {% endif %}
+12 -9
View File
@@ -55,14 +55,17 @@
<li class="user-user" data-user="{{user.username}}"> <li class="user-user" data-user="{{user.username}}">
<div> <div>
<i class="bi bi-clock-history" <i class="bi bi-clock-history"
title="View user exam history" title="View user exam history"
hx-get="{% url exam.app_name|add:':exam_user_status_user' exam.id user.id %}" hx-get="{% url exam.app_name|add:':exam_user_status_user' exam.id user.id %}"
hx-target=".modal-content" hx-target=".modal-content"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#history-modal" data-bs-target="#history-modal"
_="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML " _="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML "
></i> ></i>
{% if request.user.is_superuser %}
<a href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
{% endif %}
{{user.username}}</div> {{user.username}}</div>
<div>Email: {{user.email}}</div> <div>Email: {{user.email}}</div>
</li> </li>
@@ -98,7 +101,7 @@
</ul> </ul>
</details> </details>
</div> </div>
<br/> <br/>
{% endif %} {% endif %}
<span <span
title="View all user exam history" title="View all user exam history"
+12 -2
View File
@@ -23,12 +23,22 @@
</ol> </ol>
{% comment %} Hide the toggle button if there are no users (to add or remove) {% endcomment %} {% comment %} Hide the toggle button if there are no users (to add or remove) {% endcomment %}
{% if current_cid_users or available_cid_users %} <p>{% if current_cid_users or available_cid_users %}
<p><button class="toggle-all-btn">Toggle all</button></p> <button class="toggle-all-btn">Toggle all</button>
{% endif %} {% endif %}
<button class="add-all-btn">Add all</button>
<button class="remove-all-btn">Remove all</button>
</p>
<script> <script>
$(document).ready(() => { $(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-all-btn").click((el) => {
$(".toggle-btn").click(); $(".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' %} {% 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 %} {% if exam.recreate_json %}
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
Exam JSON may be out of date. Exam JSON may be out of date.
@@ -13,19 +19,26 @@
{% if exam.exam_mode %} {% if exam.exam_mode %}
{% if not exam.cid_user_groups.count and not exam.user_user_groups.count %} {% if exam.exam_open_access %}
<div class="alert alert-danger" role="alert">
This exam does not have any user / cid groups added.
</div>
{% else %} {% 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"> <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> </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 %} {% 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. 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}}] <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 /> Exam mode: {{ exam.exam_mode }}<br />
{% if exam.exam_mode %} {% 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 %} {% endif %}
Markers: Markers:
@@ -46,7 +64,7 @@ Markers:
<br/> <br/>
{% if can_edit %} {% if can_edit and exam.exam_mode %}
<details class="answer-management"> <details class="answer-management">
<summary>Answer management</summary> <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 %}" 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> {% 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> </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> <p><a href="{% url exam.get_app_name|add:':mark_overview' pk=exam.pk %}"><button>Mark exam</button></a></p>
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -106,13 +124,15 @@ Open access: {{ exam.open_access }}<br />
Author(s): {% for author in exam.author.all %} Author(s): {% for author in exam.author.all %}
{{ author }}{% if not forloop.last %}, {% endif %} {{ author }}{% if not forloop.last %}, {% endif %}
{% endfor %}<br/> {% 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> <details>
<summary>Extra</summary> <summary>Extra</summary>
<button title="This will clear all user answers and attempts" 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.
hx-post="{% url exam.get_app_name|add:':order_questions' exam.pk %}" <button title="This will clear all user answers and attempts"
hx-swap="outerHTML" hx-post="{% url exam.get_app_name|add:':order_questions' exam.pk %}"
>Order questions</button> hx-swap="outerHTML"
>Order questions</button>
</details> </details>
{% block css %} {% block css %}
@@ -43,7 +43,7 @@
<h3>Stats</h3> <h3>Stats</h3>
Candidates: {{cids|length}}<br /> Candidates: {{cids|length}}<br />
Questions: <span id="question-number">{{question_number}}</span><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 /> Mean: {{mean}}, Median {{median}}, Mode {{mode}}<br />
Top score: {{exam.stats_max}} Top score: {{exam.stats_max}}
+18 -1
View File
@@ -13,6 +13,9 @@
{% if user.userprofile.grade %} {% if user.userprofile.grade %}
[{{user.userprofile.grade}}] [{{user.userprofile.grade}}]
{% endif %} {% 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> <button class="toggle-btn" data-pk="{{user.pk}}" data-user="{{user.username}}">Toggle</button>
</li> </li>
@@ -24,6 +27,9 @@
{% if user.userprofile.grade %} {% if user.userprofile.grade %}
[{{user.userprofile.grade}}] [{{user.userprofile.grade}}]
{% endif %} {% 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> <button class="toggle-btn" data-pk="{{user.pk}}" data-user="{{user.username}}">Toggle</button>
</li> </li>
@@ -31,12 +37,23 @@
</ol> </ol>
{% comment %} Hide the toggle button if there are no users (to add or remove) {% endcomment %} {% comment %} Hide the toggle button if there are no users (to add or remove) {% endcomment %}
<p>
{% if current_user_users or available_user_users %} {% 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 %} {% endif %}
<button class="add-all-btn">Add all</button>
<button class="remove-all-btn">Remove all</button>
</p>
<script> <script>
$(document).ready(() => { $(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-all-btn").click((el) => {
$(".toggle-btn").click(); $(".toggle-btn").click();
}); });
@@ -2,56 +2,91 @@
{% block content %} {% block content %}
<h1>{{ object.name }} [{{object.date}}] </h1> <h1>
{{ object.name }}
{% if object.date %}
[{{object.date}}]
{% endif %}
</h1>
<p> <p>
<a href="{% url 'generic:examcollection_edit' object.pk %}">Edit</a> <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_clone' object.pk %}">Clone</a>
<a href="{% url 'generic:examcollection_delete' object.pk %}">Delete</a> <a href="{% url 'generic:examcollection_delete' object.pk %}">Delete</a>
</p> </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 %} {% endblock %}
{% block js %} {% block css %}
<style>
{% endblock %} .add-marker-button {
opacity: 0.5;
}
</style>
{% endblock %}
@@ -20,7 +20,7 @@
<h2>Edit Exam Collection {{object.name}}</h2> <h2>Edit Exam Collection {{object.name}}</h2>
<form action="" method="post" enctype="multipart/form-data" id="examcollection-form"> <form action="" method="post" enctype="multipart/form-data" id="examcollection-form">
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} {{ form|crispy }}
<input type="submit" class="submit-button" value="Submit" name="submit"> <input type="submit" class="submit-button" value="Submit" name="submit">
</form> </form>
{% endblock %} {% 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 %} {% endfor %}
<div><a href="{% url 'generic:supervisor_overview' pk=object.pk %}">Supervisor overview</a></div>
<div> <div>
<a href="{% url 'generic:supervisor_edit' pk=object.pk %}"> Edit</a> <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