Compare commits
473
Commits
779559997e
...
dockerprod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02a9118faf | ||
|
|
63c6a648d7 | ||
|
|
517c30ba0e | ||
|
|
590e0b76ed | ||
|
|
e9a04e823c | ||
|
|
9ed789ba90 | ||
|
|
fd6e25d55e | ||
|
|
f074e5b086 | ||
|
|
2157b6f781 | ||
|
|
359682a0cd | ||
|
|
78577076c1 | ||
|
|
3635275573 | ||
|
|
0c412dfff6 | ||
|
|
3f00311136 | ||
|
|
e8c35efd83 | ||
|
|
0badca4c4f | ||
|
|
25f4e478b3 | ||
|
|
33cba3d005 | ||
|
|
efca426359 | ||
|
|
f713632fdd | ||
|
|
785638ee23 | ||
|
|
e8020c5d8f | ||
|
|
5ccf755e72 | ||
|
|
6308fbfcd7 | ||
|
|
8a871d6332 | ||
|
|
6eeb4cbb1d | ||
|
|
9d57d03cbb | ||
|
|
21de543313 | ||
|
|
a12a9e38f3 | ||
|
|
81dc5bf48f | ||
|
|
4abb57900d | ||
|
|
08c9138cdb | ||
|
|
fb833a2b90 | ||
|
|
2f1e2f6abe | ||
|
|
ffa717a9ef | ||
|
|
2dd9f4af32 | ||
|
|
feb5a85af2 | ||
|
|
292f81ad74 | ||
|
|
349c46c6db | ||
|
|
681a7560ba | ||
|
|
aa90ae5e09 | ||
|
|
f55bb1b18e | ||
|
|
5e78cc4603 | ||
|
|
a9cbb1303a | ||
|
|
24558f92ce | ||
|
|
0f8bae8a14 | ||
|
|
6e658aca93 | ||
|
|
81cd109449 | ||
|
|
d86f990be2 | ||
|
|
b6478c44dc | ||
|
|
12b7fa6629 | ||
|
|
7f4570417e | ||
|
|
0098fa5dfc | ||
|
|
740d013198 | ||
|
|
b47bb141e8 | ||
|
|
712ecec25d | ||
|
|
0193daf166 | ||
|
|
279d6344f1 | ||
|
|
8c8d484dce | ||
|
|
9760be8f27 | ||
|
|
5e0068d490 | ||
|
|
875744a08a | ||
|
|
c02a46fab1 | ||
|
|
6b73006122 | ||
|
|
7eb478b111 | ||
|
|
10c18bd514 | ||
|
|
1407fe83a2 | ||
|
|
e9716f3caf | ||
|
|
749450b03e | ||
|
|
7e72f731b3 | ||
|
|
e407326c25 | ||
|
|
0d8177f0b4 | ||
|
|
3312f3058a | ||
|
|
478dcb7217 | ||
|
|
a1aba7836a | ||
|
|
80b1ef3817 | ||
|
|
835d0382ba | ||
|
|
8096a36a11 | ||
|
|
97d65f0caa | ||
|
|
b73e89393b | ||
|
|
ec036a6266 | ||
|
|
14e6514b90 | ||
|
|
639e91fc1e | ||
|
|
74765e70a2 | ||
|
|
ca2b7feb5b | ||
|
|
453fbf91be | ||
|
|
a2e9d5e681 | ||
|
|
8da2dd27da | ||
|
|
970242e1fb | ||
|
|
f8be1918f6 | ||
|
|
5ac998454b | ||
|
|
d259d0e8dd | ||
|
|
5fb3c98941 | ||
|
|
a70354c18d | ||
|
|
345977e2ef | ||
|
|
726c7ea401 | ||
|
|
7fa08037f0 | ||
|
|
f786e88de9 | ||
|
|
9441541feb | ||
|
|
5cca1b079a | ||
|
|
2e551d411a | ||
|
|
d8055a509c | ||
|
|
208252a858 | ||
|
|
ea9dca5804 | ||
|
|
da04138861 | ||
|
|
ec0245fe7c | ||
|
|
c39d30c746 | ||
|
|
d781508364 | ||
|
|
06ea87be14 | ||
|
|
ce1b439681 | ||
|
|
c8375a28fa | ||
|
|
6be39ab69d | ||
|
|
e9d0e402be | ||
|
|
dc23dffaf9 | ||
|
|
87dde70fd3 | ||
|
|
c69eecc1ec | ||
|
|
6c149bb2ae | ||
|
|
1232c3af32 | ||
|
|
05a2b2357c | ||
|
|
d88a626a76 | ||
|
|
e4bab4a509 | ||
|
|
6e7135823a | ||
|
|
a158a6f928 | ||
|
|
b79d95cd70 | ||
|
|
796ac3fb84 | ||
|
|
14a54dfa8b | ||
|
|
ac6a8193a3 | ||
|
|
6752f03bf2 | ||
|
|
34ef5106f4 | ||
|
|
1997d013d8 | ||
|
|
9a9ce11ccb | ||
|
|
9ebcd24938 | ||
|
|
70430b2593 | ||
|
|
e989a18d31 | ||
|
|
e39539fb99 | ||
|
|
44ca49899a | ||
|
|
4246713ee8 | ||
|
|
5a4792ea43 | ||
|
|
94d5771a26 | ||
|
|
200a6e2a1c | ||
|
|
2ff857621a | ||
|
|
52c4f27e2b | ||
|
|
c1eff140f8 | ||
|
|
dce72f9abe | ||
|
|
738752c030 | ||
|
|
41fc7050ad | ||
|
|
6eec40c43b | ||
|
|
7782ac55ca | ||
|
|
9cf7ad6da7 | ||
|
|
922ca5053f | ||
|
|
10739f517f | ||
|
|
eebbe8b759 | ||
|
|
d176288607 | ||
|
|
bd71c669f0 | ||
|
|
f3a0a9fd8a | ||
|
|
66899b54bd | ||
|
|
24c35148b0 | ||
|
|
bece7f6ce8 | ||
|
|
bd81fdcf41 | ||
|
|
eaf8a38fe2 | ||
|
|
744f2c7600 | ||
|
|
e7c15a75a0 | ||
|
|
cf09449d16 | ||
|
|
0acc57302f | ||
|
|
ed88f794f8 | ||
|
|
0acc2da374 | ||
|
|
21a7383c4f | ||
|
|
8e038d958b | ||
|
|
ad7f8a68e1 | ||
|
|
0c5d8bebd6 | ||
|
|
477276c4c5 | ||
|
|
697191dae9 | ||
|
|
c2ccf02d3e | ||
|
|
1b6757bff2 | ||
|
|
02284be1a2 | ||
|
|
6d03e81e77 | ||
|
|
65a68b6605 | ||
|
|
2a07843a6b | ||
|
|
72c20c13e0 | ||
|
|
4d97e9e881 | ||
|
|
0be605b41d | ||
|
|
b467407e19 | ||
|
|
fdc60bf2e9 | ||
|
|
486a897e64 | ||
|
|
fee123741d | ||
|
|
367cebb787 | ||
|
|
b3b6ce7e6a | ||
|
|
1010f17e3d | ||
|
|
72b7940a8c | ||
|
|
cc7a334cb7 | ||
|
|
9c4eaceda0 | ||
|
|
2aeb1947af | ||
|
|
ecf67e3c2b | ||
|
|
d0609e4f8e | ||
|
|
f4878a58ac | ||
|
|
0d4d4f1dc0 | ||
|
|
3a40a5087d | ||
|
|
921cc53667 | ||
|
|
53ff56edc0 | ||
|
|
e078b5a98f | ||
|
|
3d6ca8be54 | ||
|
|
e37a7e0b74 | ||
|
|
390f6f098d | ||
|
|
3c31246636 | ||
|
|
dd55f999f7 | ||
|
|
a6f5ed4c88 | ||
|
|
d482b77430 | ||
|
|
3e9a96d99f | ||
|
|
092c4a1be6 | ||
|
|
ae720662f7 | ||
|
|
c89c14b89e | ||
|
|
e6a01cb1b5 | ||
|
|
fd8edd3cc3 | ||
|
|
2142749db6 | ||
|
|
1e1abb2e7f | ||
|
|
133a63cecc | ||
|
|
8ad47ffc91 | ||
|
|
bf240af678 | ||
|
|
0f571d8f5c | ||
|
|
321c7ef895 | ||
|
|
d26cac04a7 | ||
|
|
5613bf6b9f | ||
|
|
1a45f11fc2 | ||
|
|
4d830bb8e7 | ||
|
|
b424defd57 | ||
|
|
00a675b715 | ||
|
|
bd0859cce0 | ||
|
|
2bf74ea297 | ||
|
|
71e9b47c6b | ||
|
|
9f8b4ff36a | ||
|
|
9b8e6f1398 | ||
|
|
0ff60da006 | ||
|
|
53e18e296e | ||
|
|
8f2ca66bbb | ||
|
|
ebad9c6bd9 | ||
|
|
54afa6e6bd | ||
|
|
17541b8187 | ||
|
|
04ce90049a | ||
|
|
2d963259ca | ||
|
|
ccb27af697 | ||
|
|
dfdba4cce6 | ||
|
|
b12a45275c | ||
|
|
4f6e9fae9d | ||
|
|
22299b9298 | ||
|
|
06df2c4188 | ||
|
|
8c1c7b75e7 | ||
|
|
03a24615ab | ||
|
|
89a767034c | ||
|
|
d3669e8316 | ||
|
|
b92a969a3b | ||
|
|
c45d7a5464 | ||
|
|
b08154f804 | ||
|
|
6d02b35f86 | ||
|
|
3a5ceabff0 | ||
|
|
3461e45f93 | ||
|
|
eec04e3197 | ||
|
|
68dc7c05d8 | ||
|
|
ff02e3f02f | ||
|
|
3db74607ce | ||
|
|
37eae568f0 | ||
|
|
772e852763 | ||
|
|
d3a39d11a5 | ||
|
|
9cc4ea3cb0 | ||
|
|
d93483db26 | ||
|
|
aff8d205ed | ||
|
|
e58f070ae2 | ||
|
|
7a00efdf39 | ||
|
|
e173427d84 | ||
|
|
3c1bd40ce6 | ||
|
|
0679a0ec13 | ||
|
|
5d91b4a4ff | ||
|
|
83a60203f7 | ||
|
|
2d0098b2b6 | ||
|
|
d4aa3e73e2 | ||
|
|
8dbc39a317 | ||
|
|
66f9dadcc8 | ||
|
|
7d079e0466 | ||
|
|
3ea384542d | ||
|
|
7eeb989b9e | ||
|
|
206f0afd32 | ||
|
|
0e7307de18 | ||
|
|
09d9befceb | ||
|
|
62c26d199e | ||
|
|
cbf4c3497d | ||
|
|
a269cf1f52 | ||
|
|
57a3b02da3 | ||
|
|
8e8041cdc4 | ||
|
|
ad4b06d691 | ||
|
|
b1f0f9932f | ||
|
|
b32d265c2c | ||
|
|
0de7c345a2 | ||
|
|
77edb83688 | ||
|
|
f921a46b5f | ||
|
|
7116983e3d | ||
|
|
c0d71dfabc | ||
|
|
55371a9d0b | ||
|
|
6bc43135b9 | ||
|
|
b294cf310f | ||
|
|
3805c701d3 | ||
|
|
76d0db0e94 | ||
|
|
dca4ff249a | ||
|
|
f4cc3cc5b4 | ||
|
|
1dc02d1fc9 | ||
|
|
ab89ca22c9 | ||
|
|
ab29a7adf6 | ||
|
|
937edde023 | ||
|
|
299ac317e3 | ||
|
|
c45410b5e5 | ||
|
|
11cd044485 | ||
|
|
4a3055cab8 | ||
|
|
945a6e5e86 | ||
|
|
85229124de | ||
|
|
2676cb49a1 | ||
|
|
15791fa883 | ||
|
|
15c243172d | ||
|
|
293b5b6682 | ||
|
|
82ce6b0f93 | ||
|
|
02e844006d | ||
|
|
4cce444153 | ||
|
|
f462002162 | ||
|
|
b10110d50f | ||
|
|
640f1841e6 | ||
|
|
a491337a2e | ||
|
|
65e95b698d | ||
|
|
e503576e7a | ||
|
|
fba8d5ab05 | ||
|
|
be94ed8d0f | ||
|
|
668007b899 | ||
|
|
1c8f862303 | ||
|
|
1f2a25c3ad | ||
|
|
690cabe30c | ||
|
|
4dc07b1860 | ||
|
|
d9602eeae9 | ||
|
|
5d0eb56b37 | ||
|
|
64238f4052 | ||
|
|
ef26182b1a | ||
|
|
3f56af4881 | ||
|
|
473efbc8f4 | ||
|
|
6e8c86ab10 | ||
|
|
511b3c3777 | ||
|
|
a38c31ca87 | ||
|
|
74c5f4d8fd | ||
|
|
621e95cfd5 | ||
|
|
3dc38ef57a | ||
|
|
27e575f772 | ||
|
|
a404a335c0 | ||
|
|
8380ca1dd9 | ||
|
|
ec45464e58 | ||
|
|
702b7b35a7 | ||
|
|
4f72de6be9 | ||
|
|
9c68c4ff1c | ||
|
|
b26d31b216 | ||
|
|
c2d6cfa78e | ||
|
|
e687b55e6a | ||
|
|
d19e937bd4 | ||
|
|
fa68034f03 | ||
|
|
68f45c8561 | ||
|
|
be68fcd0b9 | ||
|
|
e819645278 | ||
|
|
9efec6b79b | ||
|
|
3fb5bc2ae8 | ||
|
|
6700c6a7d2 | ||
|
|
55621b2dcc | ||
|
|
b417266e56 | ||
|
|
6d589ef630 | ||
|
|
8addc900f5 | ||
|
|
a055e138c4 | ||
|
|
132f3c48c9 | ||
|
|
07f121fb69 | ||
|
|
c7fa3a096c | ||
|
|
196d472608 | ||
|
|
1a04064e6b | ||
|
|
a49f198d26 | ||
|
|
8663482a6b | ||
|
|
5e4fea38d7 | ||
|
|
45e50e9e93 | ||
|
|
8dac99b6d3 | ||
|
|
a4f4cb9e96 | ||
|
|
5e84ba3178 | ||
|
|
4050193cec | ||
|
|
c975b3c967 | ||
|
|
329394dcf0 | ||
|
|
6cb1303630 | ||
|
|
9b9cd8c813 | ||
|
|
593eafd427 | ||
|
|
7f68fc23c6 | ||
|
|
bad0d400a7 | ||
|
|
ae644f9c03 | ||
|
|
5b8896e2b2 | ||
|
|
66711f5c15 | ||
|
|
d342e7e047 | ||
|
|
bdb7aff5d1 | ||
|
|
ac9faeccbd | ||
|
|
1e7d765fc2 | ||
|
|
e3055e3998 | ||
|
|
f6f208b065 | ||
|
|
815d853599 | ||
|
|
9cffdb3636 | ||
|
|
a2a43e51f9 | ||
|
|
101326fea2 | ||
|
|
b157fe8b00 | ||
|
|
33b6d0d935 | ||
|
|
3daad1b83b | ||
|
|
512b222ccc | ||
|
|
5294c5f2f6 | ||
|
|
4f47354ca1 | ||
|
|
bc21dd6ebd | ||
|
|
5d09c34f6d | ||
|
|
7e7a1b8763 | ||
|
|
272c3381e1 | ||
|
|
4a3a089941 | ||
|
|
aac4146b7f | ||
|
|
42b64c11ff | ||
|
|
2a94e40a1e | ||
|
|
4d305e1fa2 | ||
|
|
452143f1ef | ||
|
|
264270ce00 | ||
|
|
980bb199cf | ||
|
|
1085b9e6a2 | ||
|
|
52886d176b | ||
|
|
4ad101d65e | ||
|
|
def291c853 | ||
|
|
9dd4fc0402 | ||
|
|
8f5ce830fe | ||
|
|
b680ae749f | ||
|
|
441d33a382 | ||
|
|
bddccd5768 | ||
|
|
88713aba5b | ||
|
|
c577da4779 | ||
|
|
16387fff77 | ||
|
|
d37376d998 | ||
|
|
80171da78d | ||
|
|
9be48140a4 | ||
|
|
01f67f3e32 | ||
|
|
06b7239daa | ||
|
|
93f0bb8927 | ||
|
|
4986c2faa9 | ||
|
|
2c30145d3a | ||
|
|
eeef34bffc | ||
|
|
a3712959ec | ||
|
|
ed4bd95955 | ||
|
|
32d8430f94 | ||
|
|
fa08f9cf76 | ||
|
|
1cf18c6f0d | ||
|
|
41d1fa605b | ||
|
|
a355dd223e | ||
|
|
6f99cf0d53 | ||
|
|
682e8160b5 | ||
|
|
569ef760fa | ||
|
|
bdfa16bbb4 | ||
|
|
974966e855 | ||
|
|
63f399d2c1 | ||
|
|
bb20f7d20d | ||
|
|
850b6ddc84 | ||
|
|
e051a4f15a | ||
|
|
735a29e71b | ||
|
|
4fc1de4300 | ||
|
|
5400959399 | ||
|
|
0839eb7e37 | ||
|
|
f087a6ef49 | ||
|
|
ac562ed1e1 | ||
|
|
5d6c5e8b1a | ||
|
|
73b12dd96b | ||
|
|
990907f8f5 | ||
|
|
ee42d5c1c2 | ||
|
|
85216b36b0 | ||
|
|
32b246b5ad | ||
|
|
3b19ddcb5e | ||
|
|
50f5813a75 | ||
|
|
298493a390 | ||
|
|
de8050a8ca | ||
|
|
0b12d95bdd | ||
|
|
b9f6b7c837 |
@@ -0,0 +1,22 @@
|
||||
# Django
|
||||
DEBUG=1
|
||||
SECRET_KEY=w(s0&(_eb058wvmg@44_repv8)r9@5p8fx*g_@c)1dm&d*ew^u
|
||||
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
|
||||
|
||||
# External DB (you mentioned production uses an external DB)
|
||||
DB_HOST=db-postgresql-lon1-05515-jan-22-backup-do-user-8165014-0.c.db.ondigitalocean.com
|
||||
DB_PORT=25060
|
||||
DB_NAME=testing
|
||||
DB_USER=django
|
||||
DB_PASSWORD=AVNS_NG_s4i7SMMobWLO
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Gunicorn tuning
|
||||
GUNICORN_WORKERS=3
|
||||
GUNICORN_LOGLEVEL=info
|
||||
|
||||
# Nginx host ports for local development (override prod defaults)
|
||||
NGINX_HTTP_PORT=8000
|
||||
NGINX_HTTPS_PORT=8443
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copy this to .env.prod on the server and fill in production values (do NOT commit secrets).
|
||||
|
||||
# Django
|
||||
SECRET_KEY=replace-me-with-a-secure-random-string
|
||||
DEBUG=0
|
||||
ALLOWED_HOSTS=example.com
|
||||
|
||||
# External DB (you mentioned production uses an external DB)
|
||||
DATABASE_HOST=your.db.host
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_NAME=rad
|
||||
DATABASE_USER=django
|
||||
DATABASE_PASSWORD=strong-db-password
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Gunicorn tuning
|
||||
GUNICORN_WORKERS=3
|
||||
GUNICORN_LOGLEVEL=info
|
||||
@@ -1,4 +1,5 @@
|
||||
media/
|
||||
mediafiles/
|
||||
|
||||
__pycache__
|
||||
.vscode/
|
||||
@@ -11,3 +12,7 @@ venv
|
||||
*.log
|
||||
|
||||
temp/
|
||||
|
||||
# Loki runtime data
|
||||
/docker/loki-data/
|
||||
/docker/loki-wal/
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# pull official base image
|
||||
FROM python:3.12.1-slim
|
||||
FROM python:3.14-slim
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
# set work directory
|
||||
|
||||
+15
-16
@@ -19,51 +19,50 @@ from django.contrib.auth import get_user_model
|
||||
router = Router()
|
||||
|
||||
class CidUserSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = CidUser
|
||||
|
||||
model_fields = ["cid", "name", "email"]
|
||||
fields = ["cid", "name", "email"]
|
||||
|
||||
class UserUserSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = get_user_model()
|
||||
model_fields = ["id", "email"]
|
||||
fields = ["id", "email"]
|
||||
|
||||
class CidUserExamSchema(ModelSchema):
|
||||
cid_user : CidUserSchema | None
|
||||
user_user : UserUserSchema | None
|
||||
class Config:
|
||||
class Meta:
|
||||
model = CidUserExam
|
||||
model_fields = ["id"]
|
||||
fields = ["id"]
|
||||
|
||||
class ExamStatusSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = ExamUserStatus
|
||||
model_fields = ["id", "datetime", "status", "extra"]
|
||||
fields = ["id", "datetime", "status", "extra"]
|
||||
|
||||
class ExamUserStatusSchema(ModelSchema):
|
||||
|
||||
#cid_user : CidUserSchema | None
|
||||
#user_user : UserUserSchema | None
|
||||
cid_user_exam : CidUserExamSchema | None
|
||||
class Config:
|
||||
class Meta:
|
||||
model = ExamUserStatus
|
||||
model_fields = ["id", "datetime", "status", "extra"]
|
||||
fields = ["id", "datetime", "status", "extra"]
|
||||
|
||||
class QuestionSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Question
|
||||
|
||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
||||
model_fields = "__all__"
|
||||
fields = "__all__"
|
||||
|
||||
#model_exclude = ["answers"]
|
||||
#exclude = ["answers"]
|
||||
|
||||
class ExamSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Exam
|
||||
|
||||
model_fields = ["id", "name", "active", "publish_results"]
|
||||
fields = ["id", "name", "active", "publish_results"]
|
||||
|
||||
@router.get('/')
|
||||
def list_questions(request):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Management package for anatomy app."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Management commands package for anatomy app."""
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import io
|
||||
from uuid import uuid4
|
||||
from typing import List, Tuple
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.core.files import File
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from anatomy.models import AnatomyQuestion, Exam, ExamQuestionDetail, QuestionType, Structure
|
||||
from generic.models import Modality as GenericModality # type: ignore
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Create anatomy questions and assign them to an exam."
|
||||
"Requires --exam-id and --image-path (path readable inside the container)."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--exam-id", type=int, required=False, help="ID of the anatomy.Exam to assign questions to (if omitted a new exam will be created)")
|
||||
parser.add_argument("--exam-name", type=str, default=None, help="Name to use when creating a new exam if --exam-id omitted")
|
||||
parser.add_argument("--count", type=int, default=1, help="Number of questions to create")
|
||||
parser.add_argument("--image-path", type=str, required=False, help="Path to image file to use for each created question")
|
||||
parser.add_argument("--generate-image", action="store_true", help="Generate a placeholder image automatically if --image-path is not provided or not found")
|
||||
parser.add_argument("--image-size", type=str, default="800x600", help="Generated image size WIDTHxHEIGHT, e.g. 800x600")
|
||||
parser.add_argument("--image-color", type=str, default="#777777", help="Background color for generated image (hex) e.g. #ffffff")
|
||||
parser.add_argument("--description", type=str, default="Generated question {i}", help="Description template; use {i} for index")
|
||||
parser.add_argument("--question-type-id", type=int, default=None, help="Optional QuestionType id to assign")
|
||||
parser.add_argument("--modality-id", type=int, default=None, help="Optional Modality id to assign (generic.Modality id)")
|
||||
parser.add_argument("--structure-id", type=int, default=None, help="Optional Structure id to assign")
|
||||
parser.add_argument("--author-id", type=int, action="append", help="Author user id; repeatable")
|
||||
parser.add_argument("--start-sort", type=int, default=1, help="Starting sort_order for the first question")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
exam_id = options.get("exam_id")
|
||||
count = options["count"]
|
||||
image_path = options.get("image_path")
|
||||
generate_image = options.get("generate_image")
|
||||
image_size_str = options.get("image_size") or "800x600"
|
||||
image_color = options.get("image_color") or "#777777"
|
||||
description_template = options["description"]
|
||||
qtype_id = options.get("question_type_id")
|
||||
modality_id = options.get("modality_id")
|
||||
structure_id = options.get("structure_id")
|
||||
author_ids: List[int] = options.get("author_id") or []
|
||||
sort = options.get("start_sort") or 1
|
||||
|
||||
exam = None
|
||||
if exam_id:
|
||||
try:
|
||||
exam = Exam.objects.get(pk=exam_id)
|
||||
except Exam.DoesNotExist:
|
||||
raise CommandError(f"Exam with id {exam_id} does not exist")
|
||||
else:
|
||||
# create new exam
|
||||
exam_name = options.get("exam_name") or f"Auto Anatomy Exam {uuid4().hex[:8]}"
|
||||
exam = Exam(name=exam_name, active=True, open_access=True)
|
||||
exam.save()
|
||||
self.stdout.write(self.style.SUCCESS(f"Created exam id={exam.id} name='{exam.name}'"))
|
||||
|
||||
# validate image path if provided
|
||||
if image_path and not os.path.exists(image_path):
|
||||
if not generate_image:
|
||||
raise CommandError(f"Image file not found at path: {image_path}")
|
||||
else:
|
||||
image_path = None
|
||||
|
||||
# Resolve optional foreign keys
|
||||
qtype = None
|
||||
if qtype_id:
|
||||
qtype = QuestionType.objects.filter(pk=qtype_id).first()
|
||||
if not qtype:
|
||||
raise CommandError(f"QuestionType id {qtype_id} not found")
|
||||
|
||||
modality = None
|
||||
if modality_id:
|
||||
# The project stores Modality in generic.models.Modality
|
||||
try:
|
||||
from generic.models import Modality as GModality
|
||||
|
||||
modality = GModality.objects.filter(pk=modality_id).first()
|
||||
if not modality:
|
||||
raise CommandError(f"Modality id {modality_id} not found in generic.Modality")
|
||||
except Exception as e:
|
||||
raise CommandError(str(e))
|
||||
|
||||
structure = None
|
||||
if structure_id:
|
||||
from atlas.models import Structure as AtlasStructure
|
||||
|
||||
structure = AtlasStructure.objects.filter(pk=structure_id).first()
|
||||
if not structure:
|
||||
raise CommandError(f"Structure id {structure_id} not found")
|
||||
|
||||
User = get_user_model()
|
||||
authors = []
|
||||
for uid in author_ids:
|
||||
u = User.objects.filter(pk=uid).first()
|
||||
if not u:
|
||||
raise CommandError(f"Author user id {uid} not found")
|
||||
authors.append(u)
|
||||
|
||||
# parse image size
|
||||
try:
|
||||
w, h = image_size_str.lower().split("x")
|
||||
image_size: Tuple[int, int] = (int(w), int(h))
|
||||
except Exception:
|
||||
raise CommandError("Invalid --image-size; expected WIDTHxHEIGHT, e.g. 800x600")
|
||||
|
||||
created = []
|
||||
|
||||
for i in range(1, count + 1):
|
||||
desc = description_template.format(i=i)
|
||||
|
||||
# determine image source: either provided path or generated image
|
||||
unique_name = f"{uuid4().hex}_image.jpg"
|
||||
|
||||
if image_path:
|
||||
# use provided file
|
||||
with open(image_path, "rb") as f:
|
||||
django_file = File(f, name=unique_name)
|
||||
img_file_for_save = django_file
|
||||
# create and save question
|
||||
q = AnatomyQuestion(
|
||||
question_type=qtype,
|
||||
description=desc,
|
||||
answer_help="",
|
||||
)
|
||||
|
||||
if modality:
|
||||
q.modality = modality
|
||||
|
||||
if structure:
|
||||
q.structure = structure
|
||||
|
||||
q.image.save(unique_name, img_file_for_save, save=False)
|
||||
q.save()
|
||||
|
||||
else:
|
||||
# generate an image in memory
|
||||
buf = io.BytesIO()
|
||||
img = Image.new("RGB", image_size, image_color)
|
||||
img.save(buf, format="JPEG")
|
||||
buf.seek(0)
|
||||
django_file = File(buf, name=unique_name)
|
||||
|
||||
q = AnatomyQuestion(
|
||||
question_type=qtype,
|
||||
description=desc,
|
||||
answer_help="",
|
||||
)
|
||||
|
||||
if modality:
|
||||
q.modality = modality
|
||||
|
||||
if structure:
|
||||
q.structure = structure
|
||||
|
||||
q.image.save(unique_name, django_file, save=False)
|
||||
q.save()
|
||||
|
||||
# authors
|
||||
if authors:
|
||||
q.author.set(authors)
|
||||
|
||||
# add to exam via the many-to-many through relationship
|
||||
try:
|
||||
exam.exam_questions.add(q, through_defaults={"sort_order": sort})
|
||||
except TypeError:
|
||||
ExamQuestionDetail.objects.create(exam=exam, anatomyquestion=q, sort_order=sort)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Created question id={q.id} and added to exam id={exam.id} with sort_order={sort}"))
|
||||
created.append(q)
|
||||
sort += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Finished: created {len(created)} question(s)"))
|
||||
+13
-8
@@ -132,14 +132,16 @@ class AnatomyQuestion(QuestionBase):
|
||||
return "anatomy"
|
||||
|
||||
def __str__(self):
|
||||
# Get first answer
|
||||
return "{}/{}: {} [{}, {}]".format(
|
||||
self.pk,
|
||||
self.question_type,
|
||||
self.get_primary_answer(),
|
||||
self.modality,
|
||||
self.structure,
|
||||
)
|
||||
# Nicely formatted representation
|
||||
pk = self.pk or ""
|
||||
qtype = str(self.question_type) if self.question_type else "N/A"
|
||||
primary = self.get_primary_answer() or "None"
|
||||
modality = str(self.modality) if self.modality else "Unknown"
|
||||
structure = str(self.structure) if self.structure else "Unknown"
|
||||
title = self.get_title() if hasattr(self, "get_title") else (self.description or "")
|
||||
if title and len(title) > 60:
|
||||
title = title[:57] + "..."
|
||||
return f"{title} (Type: {qtype}) — Answer: {primary} — Modality: {modality}; Structure: {structure}"
|
||||
|
||||
def get_link(self):
|
||||
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self)
|
||||
@@ -180,6 +182,9 @@ class AnatomyQuestion(QuestionBase):
|
||||
exams = ", ".join(e)
|
||||
return exams
|
||||
|
||||
def get_image_url(self):
|
||||
return "{}/{}".format(settings.REMOTE_URL, self.image.url)
|
||||
|
||||
def get_unmarked_user_answer_string(self, exam_pk=None):
|
||||
unmarked_answers = self.get_unmarked_user_answers(exam_pk)
|
||||
|
||||
|
||||
+317
-187
@@ -16,6 +16,8 @@ $(document).ready(function () {
|
||||
clearableTextAreaSetup();
|
||||
|
||||
|
||||
|
||||
|
||||
document.body.addEventListener("htmx:responseError", function(e) {
|
||||
let el = document.getElementById("htmx-error");
|
||||
var error;
|
||||
@@ -42,6 +44,10 @@ $(document).ready(function () {
|
||||
// $(element).append($("<span class='google-link' title='search for answer with google'><a href='https://www.google.com/search?q=" + $(element).text() + "' target='_blank'>G</a></span>"));
|
||||
//});
|
||||
|
||||
// Legacy per-element click handler: skip on the new mark2 page which
|
||||
// manages clicks via a delegated handler (it uses #answer-list). This
|
||||
// prevents duplicate/colliding handlers when mark2 is rendered.
|
||||
if (!document.getElementById('answer-list')) {
|
||||
$(".answer-list span.answer").each(function (index, element) {
|
||||
console.log(element);
|
||||
|
||||
@@ -56,10 +62,9 @@ $(document).ready(function () {
|
||||
|
||||
prepAnswerData();
|
||||
|
||||
|
||||
|
||||
});
|
||||
});
|
||||
}
|
||||
prepAnswerData();
|
||||
|
||||
if ($(".post-form").length > 0) {
|
||||
@@ -80,6 +85,14 @@ $(document).ready(function () {
|
||||
}
|
||||
|
||||
}
|
||||
let dicom_images_legacy = document.getElementsByClassName("dicom-image-legacy");
|
||||
if (dicom_images_legacy.length) {
|
||||
|
||||
for (let element of dicom_images_legacy) {
|
||||
setUpDicomLegacy(element)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
loadDicomViewer();
|
||||
@@ -176,37 +189,6 @@ $(document).ready(function () {
|
||||
});
|
||||
|
||||
|
||||
// Table row selection
|
||||
// Delegate click event to all table rows
|
||||
document.querySelectorAll('.row-selector tbody tr').forEach(function(row) {
|
||||
row.addEventListener('click', function(e) {
|
||||
// Ignore clicks on links or checkboxes
|
||||
if (e.target.tagName === 'A' || e.target.tagName === 'INPUT') return;
|
||||
|
||||
// Find the checkbox in this row
|
||||
const checkbox = row.querySelector('input[name="selection"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
row.classList.toggle('selected', checkbox.checked);
|
||||
}
|
||||
});
|
||||
|
||||
// Keep row visually in sync with checkbox state (e.g. after page reload)
|
||||
const checkbox = row.querySelector('input[name="selection"]');
|
||||
if (checkbox && checkbox.checked) {
|
||||
row.classList.add('selected');
|
||||
}
|
||||
// Also toggle row selection when checkbox is clicked directly
|
||||
if (checkbox) {
|
||||
checkbox.addEventListener('click', function(e) {
|
||||
row.classList.toggle('selected', checkbox.checked);
|
||||
// Prevent row click event from firing
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
window.loadDicomViewerEvent = new Event('loadDicomViewer');
|
||||
@@ -266,6 +248,7 @@ function loadDicomViewer(images_to_load, annotations_to_load) {
|
||||
console.log("loadDicomViewer", images_to_load);
|
||||
let single_dicom = document.getElementById("single-dicom-viewer");
|
||||
if (single_dicom) {
|
||||
console.log("Load single dicom viewer");
|
||||
$(single_dicom).empty()
|
||||
let images = single_dicom.dataset.images;
|
||||
|
||||
@@ -338,8 +321,8 @@ function prepAnswerData() {
|
||||
$("#id_marked_answers").val(JSON.stringify(window.marked_answers));
|
||||
}
|
||||
|
||||
function loadJsonToolStateOnCurrentImage(json) {
|
||||
let el = document.getElementById("dicom-image");
|
||||
function loadJsonToolStateOnCurrentImage(element, json) {
|
||||
let el = element;
|
||||
|
||||
const toolStateManager = cornerstoneTools.globalImageIdSpecificToolStateManager;
|
||||
|
||||
@@ -361,160 +344,195 @@ function loadJsonToolStateOnCurrentImage(json) {
|
||||
|
||||
}
|
||||
|
||||
//async function setUpDicom(element) {
|
||||
// console.log("setUpDicom (original)", element);
|
||||
//
|
||||
// $(element).bind('contextmenu', function (e) {
|
||||
// return false;
|
||||
// Called by HTMX after a fragment swap; initializes any dicom-image-legacy viewers
|
||||
window.initAnatomyFragment = function(fragmentRoot) {
|
||||
console.log("initAnatomyFragment", fragmentRoot);
|
||||
try {
|
||||
const root = fragmentRoot && fragmentRoot.nodeType ? fragmentRoot : (fragmentRoot && fragmentRoot.detail && fragmentRoot.detail.target) ? fragmentRoot.detail.target : document;
|
||||
const imgs = (root && root.querySelectorAll) ? root.querySelectorAll('.dicom-image-legacy') : [];
|
||||
imgs.forEach((el) => {
|
||||
try {
|
||||
// Avoid double initialization
|
||||
if (el.dataset && (el.dataset.dicomInitialised === '1' || el.dataset.dicomInitialised === 'true')) return;
|
||||
if (typeof window.setUpDicomLegacy === 'function') {
|
||||
window.setUpDicomLegacy(el);
|
||||
if (el.dataset) el.dataset.dicomInitialised = '1';
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('initAnatomyFragment: setUpDicomLegacy failed for element', el, err);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('initAnatomyFragment error', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Global fallback: listen for HTMX swaps and initialise any viewers inside
|
||||
document.addEventListener('htmx:afterSwap', function (e) {
|
||||
try {
|
||||
const root = e && e.target ? e.target : (e && e.detail && e.detail.target ? e.detail.target : document);
|
||||
if (window.initAnatomyFragment) window.initAnatomyFragment(root);
|
||||
} catch (err) {
|
||||
console.warn('htmx afterSwap listener error', err);
|
||||
}
|
||||
});
|
||||
|
||||
async function setUpDicomLegacy(element) {
|
||||
console.log("setUpDicom (original)", element);
|
||||
|
||||
$(element).bind('contextmenu', function (e) {
|
||||
return false;
|
||||
});
|
||||
|
||||
$(element).dblclick((evt) => {
|
||||
element.requestFullscreen();
|
||||
})
|
||||
|
||||
cornerstoneBase64ImageLoader.external.cornerstone = cornerstone;
|
||||
cornerstoneWebImageLoader.external.cornerstone = cornerstone;
|
||||
cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
|
||||
|
||||
cornerstoneTools.init();
|
||||
const PanTool = cornerstoneTools.PanTool;
|
||||
const ZoomTool = cornerstoneTools.ZoomTool;
|
||||
const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool;
|
||||
const WwwcTool = cornerstoneTools.WwwcTool;
|
||||
const WwwcRegionTool = cornerstoneTools.WwwcRegionTool;
|
||||
const RotateTool = cornerstoneTools.RotateTool;
|
||||
const StackScrollTool = cornerstoneTools.StackScrollTool;
|
||||
const MagnifyTool = cornerstoneTools.MagnifyTool;
|
||||
const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
|
||||
|
||||
|
||||
const images = element.dataset.url.split(",");
|
||||
|
||||
const annotation = element.dataset.annotations;
|
||||
|
||||
//console.log("Dicom - load imageId: ", imageIds);
|
||||
//for (let index = 0; index < imageIds.length; index++) {
|
||||
// if (imageIds[index].endsWith("dcm")) {
|
||||
// imageIds[index] = "wadouri:" + imageIds[index];
|
||||
// }
|
||||
|
||||
//}
|
||||
let imageIds = [];
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
let data_url = images[i];
|
||||
// check stack type
|
||||
if (data_url.startsWith("data:image")) {
|
||||
let imageId = "base64://" + data_url.split(",")[1];
|
||||
|
||||
imageIds.push(imageId);
|
||||
} else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) {
|
||||
|
||||
imageIds.push(data_url);
|
||||
|
||||
// Treat application/octet-stream as if they are dicoms
|
||||
} else if (data_url.startsWith("data:application/dicom") || data_url.startsWith("data:application/octet-stream")) {
|
||||
//stack = stack.split(";")[1];
|
||||
|
||||
let dfile = await urltoFile(data_url, "dicom", "application/dicom");
|
||||
|
||||
const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
|
||||
dfile
|
||||
);
|
||||
|
||||
loadAnnotation(imageId, annotation);
|
||||
|
||||
imageIds.push(imageId);
|
||||
//cornerstone.loadImage(imageId).then(function(image) {
|
||||
// tempFunction(image);
|
||||
//});
|
||||
//
|
||||
// $(element).dblclick((evt) => {
|
||||
// element.requestFullscreen();
|
||||
// })
|
||||
//
|
||||
// cornerstoneBase64ImageLoader.external.cornerstone = cornerstone;
|
||||
// cornerstoneWebImageLoader.external.cornerstone = cornerstone;
|
||||
// cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
|
||||
//
|
||||
// cornerstoneTools.init();
|
||||
// const PanTool = cornerstoneTools.PanTool;
|
||||
// const ZoomTool = cornerstoneTools.ZoomTool;
|
||||
// const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool;
|
||||
// const WwwcTool = cornerstoneTools.WwwcTool;
|
||||
// const WwwcRegionTool = cornerstoneTools.WwwcRegionTool;
|
||||
// const RotateTool = cornerstoneTools.RotateTool;
|
||||
// const StackScrollTool = cornerstoneTools.StackScrollTool;
|
||||
// const MagnifyTool = cornerstoneTools.MagnifyTool;
|
||||
// const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
|
||||
//
|
||||
//
|
||||
// const images = element.dataset.url.split(",");
|
||||
//
|
||||
// //console.log("Dicom - load imageId: ", imageIds);
|
||||
// //for (let index = 0; index < imageIds.length; index++) {
|
||||
// // if (imageIds[index].endsWith("dcm")) {
|
||||
// // imageIds[index] = "wadouri:" + imageIds[index];
|
||||
// // }
|
||||
//
|
||||
// //}
|
||||
// let imageIds = [];
|
||||
// for (let i = 0; i < images.length; i++) {
|
||||
// let data_url = images[i];
|
||||
// // check stack type
|
||||
// if (data_url.startsWith("data:image")) {
|
||||
// let imageId = "base64://" + data_url.split(",")[1];
|
||||
//
|
||||
// imageIds.push(imageId);
|
||||
// } else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) {
|
||||
//
|
||||
// imageIds.push(data_url);
|
||||
//
|
||||
// // Treat application/octet-stream as if they are dicoms
|
||||
// } else if (data_url.startsWith("data:application/dicom") || data_url.startsWith("data:application/octet-stream")) {
|
||||
// //stack = stack.split(";")[1];
|
||||
//
|
||||
// let dfile = await urltoFile(data_url, "dicom", "application/dicom");
|
||||
//
|
||||
// const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
|
||||
// dfile
|
||||
// );
|
||||
//
|
||||
// loadAnnotation(imageId, annotation);
|
||||
//
|
||||
// imageIds.push(imageId);
|
||||
// //cornerstone.loadImage(imageId).then(function(image) {
|
||||
// // tempFunction(image);
|
||||
// //});
|
||||
// } else {
|
||||
// let url;
|
||||
// // This doesn't seem to have any benefit
|
||||
// //if (data_url.startsWith("http")) {
|
||||
// // url = data_url;
|
||||
// //} else {
|
||||
// // url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
|
||||
// //}
|
||||
} else {
|
||||
let url;
|
||||
// This doesn't seem to have any benefit
|
||||
//if (data_url.startsWith("http")) {
|
||||
// url = data_url;
|
||||
//
|
||||
// if (url.endsWith("dcm")) {
|
||||
// url = "wadouri:" + url;
|
||||
// }
|
||||
//
|
||||
// // if there is no extension treat it as a dicom
|
||||
// if (/(?:\/|^)[^.\/]+$/.test(url)) {
|
||||
// url = "wadouri:" + url;
|
||||
// }
|
||||
//
|
||||
// imageIds.push(url);
|
||||
//
|
||||
//
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// cornerstone.enable(element);
|
||||
// cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
|
||||
// cornerstone.displayImage(element, image);
|
||||
//
|
||||
// cornerstoneTools.addToolForElement(element, PanTool);
|
||||
// cornerstoneTools.addToolForElement(element, ZoomTool);
|
||||
// cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool);
|
||||
// cornerstoneTools.addToolForElement(element, WwwcTool);
|
||||
// cornerstoneTools.addToolForElement(element, WwwcRegionTool);
|
||||
// cornerstoneTools.addToolForElement(element, RotateTool);
|
||||
// cornerstoneTools.addToolForElement(element, StackScrollTool);
|
||||
// cornerstoneTools.addToolForElement(element, MagnifyTool);
|
||||
//
|
||||
// cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
|
||||
// configuration: {
|
||||
// getTextCallback: () => {},
|
||||
// changeTextCallback: () => {},
|
||||
// allowEmptyLabel: true,
|
||||
// renderDashed: false,
|
||||
// drawHandles: false,
|
||||
// drawHandlesOnHover: true,
|
||||
// },
|
||||
// });
|
||||
//
|
||||
//
|
||||
//
|
||||
// // Enable our tools
|
||||
// // Avoid incorrect aspect ratio
|
||||
// cornerstoneTools.setToolActiveForElement(element, "Pan", {
|
||||
// mouseButtonMask: 1
|
||||
// });
|
||||
// cornerstoneTools.setToolActiveForElement(element, "Wwwc", {
|
||||
// mouseButtonMask: 2
|
||||
// });
|
||||
// cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", {
|
||||
// mouseButtonMask: 3
|
||||
// });
|
||||
// cornerstoneTools.setToolActiveForElement(element, "Zoom", {
|
||||
// mouseButtonMask: 4
|
||||
// });
|
||||
//
|
||||
// if (element.dataset.edit_annotation == "true") {
|
||||
// cornerstoneTools.setToolActiveForElement(element, "ArrowAnnotate", {
|
||||
// mouseButtonMask: 2
|
||||
// });
|
||||
//} else {
|
||||
// cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate");
|
||||
// url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
|
||||
//}
|
||||
//
|
||||
// if (element.dataset.annotations) {
|
||||
// loadJsonToolStateOnCurrentImage(element.dataset.annotations)
|
||||
// }
|
||||
//
|
||||
// cornerstone.resize(element);
|
||||
//
|
||||
//
|
||||
// }).catch((err, err2) => {
|
||||
// console.log(err);
|
||||
// });
|
||||
//
|
||||
//}
|
||||
//console.log("setUpDicom defined");
|
||||
//
|
||||
//// keep reference to the existing 2D setup
|
||||
//window.setUpDicomClassic = setUpDicom;
|
||||
url = data_url;
|
||||
|
||||
if (url.endsWith("dcm")) {
|
||||
url = "wadouri:" + url;
|
||||
}
|
||||
|
||||
// if there is no extension treat it as a dicom
|
||||
if (/(?:\/|^)[^.\/]+$/.test(url)) {
|
||||
url = "wadouri:" + url;
|
||||
}
|
||||
|
||||
imageIds.push(url);
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
cornerstone.enable(element);
|
||||
cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
|
||||
cornerstone.displayImage(element, image);
|
||||
|
||||
cornerstoneTools.addToolForElement(element, PanTool);
|
||||
cornerstoneTools.addToolForElement(element, ZoomTool);
|
||||
cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool);
|
||||
cornerstoneTools.addToolForElement(element, WwwcTool);
|
||||
cornerstoneTools.addToolForElement(element, WwwcRegionTool);
|
||||
cornerstoneTools.addToolForElement(element, RotateTool);
|
||||
cornerstoneTools.addToolForElement(element, StackScrollTool);
|
||||
cornerstoneTools.addToolForElement(element, MagnifyTool);
|
||||
|
||||
cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
|
||||
configuration: {
|
||||
getTextCallback: () => {},
|
||||
changeTextCallback: () => {},
|
||||
allowEmptyLabel: true,
|
||||
renderDashed: false,
|
||||
drawHandles: false,
|
||||
drawHandlesOnHover: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Enable our tools
|
||||
// Avoid incorrect aspect ratio
|
||||
cornerstoneTools.setToolActiveForElement(element, "Pan", {
|
||||
mouseButtonMask: 1
|
||||
});
|
||||
cornerstoneTools.setToolActiveForElement(element, "Wwwc", {
|
||||
mouseButtonMask: 2
|
||||
});
|
||||
cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", {
|
||||
mouseButtonMask: 3
|
||||
});
|
||||
cornerstoneTools.setToolActiveForElement(element, "Zoom", {
|
||||
mouseButtonMask: 4
|
||||
});
|
||||
|
||||
if (element.dataset.edit_annotation == "true") {
|
||||
cornerstoneTools.setToolActiveForElement(element, "ArrowAnnotate", {
|
||||
mouseButtonMask: 2
|
||||
});
|
||||
} else {
|
||||
cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate");
|
||||
}
|
||||
|
||||
if (element.dataset.annotations) {
|
||||
loadJsonToolStateOnCurrentImage(element, element.dataset.annotations)
|
||||
}
|
||||
|
||||
cornerstone.resize(element);
|
||||
|
||||
|
||||
}).catch((err, err2) => {
|
||||
console.log(err);
|
||||
});
|
||||
|
||||
}
|
||||
console.log("setUpDicom defined");
|
||||
|
||||
// keep reference to the existing 2D setup
|
||||
window.setUpDicomLegacy = setUpDicomLegacy;
|
||||
|
||||
async function setUpDicom3d(element) {
|
||||
console.log("setUpDicom3d (cornerstone3d) for", element);
|
||||
@@ -583,7 +601,22 @@ async function setUpDicom3d(element) {
|
||||
//viewportType: Enums.ViewportType.STACK, // Stack viewport for slice stacks
|
||||
};
|
||||
|
||||
try {
|
||||
renderingEngine.enableElement(viewportInput);
|
||||
} catch (err) {
|
||||
// Some bundlings / integrations (dv3d wrapper) may reject the provided
|
||||
// viewport type (e.g. "Viewport is not a valid type"). In that case
|
||||
// fall back to the 2D cornerstone loader which is more permissive.
|
||||
console.warn('renderingEngine.enableElement failed, falling back to 2D loader:', err);
|
||||
try {
|
||||
dicomViewer.loadCornerstone($(element), null, images, undefined, images.length > 5);
|
||||
element._cornerstone3d = { fallback: 'cornerstone2d', imageCount: images.length };
|
||||
return element._cornerstone3d;
|
||||
} catch (err2) {
|
||||
console.error('2D fallback after enableElement failure also failed:', err2);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const viewport = renderingEngine.getViewport(viewportId);
|
||||
|
||||
@@ -628,10 +661,50 @@ async function setUpDicom3d(element) {
|
||||
|
||||
|
||||
// Prepare imageIds: ensure remote URLs are usable by dicom-image-loader (wadouri:)
|
||||
// but avoid forcing wadouri: for standard web images (png/jpg/etc) because
|
||||
// the dicom parser will fail when given non-DICOM files. For such images
|
||||
// return the raw URL so the appropriate web-image loader can handle them.
|
||||
// If any image looks like a plain web image (png/jpg/etc) prefer the
|
||||
// existing 2D cornerstone loader which handles http(s) image schemes.
|
||||
// This avoids a situation where the 3D dicom loader has no handler for
|
||||
// the 'https' scheme and throws "No image loader found for scheme 'https'".
|
||||
const looksLikeWebImage = (u) => {
|
||||
if (!u) return false;
|
||||
try { u = u.trim(); } catch (e) {}
|
||||
if (u.startsWith('data:image')) return true;
|
||||
if (u.match(/\.(png|jpe?g|gif|bmp|webp)$/i)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
if (images.some(looksLikeWebImage)) {
|
||||
// Fallback: use the 2D cornerstone loader which has web-image handling
|
||||
// and is more tolerant of raw http(s) URLs. We reuse the project's
|
||||
// dicomViewer helper (it expects a jQuery element).
|
||||
console.warn('setUpDicom3d: contains web-image(s) — falling back to 2D loader');
|
||||
try {
|
||||
// Remove any partially-created dv3d DOM and ensure the element is a
|
||||
// clean container for the 2D viewer. This ensures the `.canvas-panel`
|
||||
// appended by `loadCornerstone` overlays exactly the element area.
|
||||
try { element.innerHTML = ""; } catch (e) { $(element).empty(); }
|
||||
element.style.position = element.style.position || 'relative';
|
||||
// annotations are not available in this scope in the 3d code path; pass
|
||||
// undefined for annotations and let the loader handle it.
|
||||
dicomViewer.loadCornerstone($(element), null, images, undefined, images.length > 5);
|
||||
// store a lightweight marker so callers can inspect what happened
|
||||
element._cornerstone3d = { fallback: 'cornerstone2d', imageCount: images.length };
|
||||
return element._cornerstone3d;
|
||||
} catch (err) {
|
||||
console.warn('Fallback 2D loader failed; will continue to try 3D loader', err);
|
||||
// fallthrough to attempt 3D loading below
|
||||
}
|
||||
}
|
||||
|
||||
const imageIds = images.map((url) => {
|
||||
url = url.trim();
|
||||
// if it's already a scheme we pass through; otherwise treat as wadouri
|
||||
if (/^(wadouri:|base64:|data:)/i.test(url)) return url;
|
||||
// if it's already a scheme we pass through
|
||||
if (/^(wadouri:|base64:|data:|http:|https:)/i.test(url)) return url;
|
||||
|
||||
// default: treat as wadouri (likely a DICOM file)
|
||||
return `wadouri:${url}`;
|
||||
});
|
||||
|
||||
@@ -648,7 +721,23 @@ async function setUpDicom3d(element) {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await viewport.setStack(imageIds, 0);
|
||||
} catch (err) {
|
||||
// If the 3D loader fails because it doesn't know how to load 'https'
|
||||
// URLs, fallback to the 2D cornerstone loader which handles web images.
|
||||
console.warn('viewport.setStack failed, attempting 2D fallback:', err);
|
||||
try {
|
||||
try { element.innerHTML = ""; } catch (e) { $(element).empty(); }
|
||||
element.style.position = element.style.position || 'relative';
|
||||
dicomViewer.loadCornerstone($(element), null, images, undefined, images.length > 5);
|
||||
element._cornerstone3d = { fallback: 'cornerstone2d', imageCount: images.length };
|
||||
return element._cornerstone3d;
|
||||
} catch (err2) {
|
||||
console.error('2D fallback also failed:', err2);
|
||||
throw err; // rethrow original error
|
||||
}
|
||||
}
|
||||
|
||||
viewport.render();
|
||||
|
||||
@@ -769,3 +858,44 @@ function initializeClock(id, endtime) {
|
||||
|
||||
window.initializeClock = initializeClock
|
||||
|
||||
|
||||
|
||||
// Unwrap single-root <p> elements from TinyMCE content before any form submit.
|
||||
// This prevents a single wrapping <p> from being sent to the server while
|
||||
// preserving other markup. We use a delegated submit listener so it works
|
||||
// for dynamically-added forms as well (e.g. admin inlines).
|
||||
document.addEventListener('submit', function(evt) {
|
||||
try {
|
||||
var form = evt.target;
|
||||
if (!form || !form.querySelector) return;
|
||||
// Find all tinyMCE textareas inside the form
|
||||
var areas = form.querySelectorAll('textarea.tinymce');
|
||||
areas.forEach(function(area) {
|
||||
var id = area.id;
|
||||
if (!id) return;
|
||||
var ed = (typeof tinyMCE !== 'undefined') ? tinyMCE.get(id) : null;
|
||||
if (!ed) return;
|
||||
var html = (ed.getContent && typeof ed.getContent === 'function') ? ed.getContent({format: 'html'}) : area.value;
|
||||
if (!html) return;
|
||||
// Use a temporary container to inspect top-level elements safely
|
||||
var tmp = document.createElement('div');
|
||||
tmp.innerHTML = html.trim();
|
||||
// If there is exactly one top-level element and it is a <p>, unwrap it
|
||||
if (tmp.childElementCount === 1 && tmp.firstElementChild && tmp.firstElementChild.tagName.toLowerCase() === 'p') {
|
||||
var inner = tmp.firstElementChild.innerHTML;
|
||||
// Update editor content and the underlying textarea value
|
||||
try {
|
||||
ed.setContent(inner);
|
||||
} catch (e) {
|
||||
// fallback: set textarea value directly
|
||||
area.value = inner;
|
||||
}
|
||||
// Ensure the textarea value matches (some setups sync on save, some don't)
|
||||
area.value = inner;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// be resilient; don't prevent form submission if something goes wrong here
|
||||
console.error('TinyMCE submit postprocess error', e);
|
||||
}
|
||||
}, true);
|
||||
@@ -237,9 +237,9 @@
|
||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||
{% csrf_token %}
|
||||
<a href="/anatomy/examination/create" id="add_examination" class="add-popup"
|
||||
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
|
||||
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
|
||||
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
|
||||
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
|
||||
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
|
||||
|
||||
{{ form|crispy }}
|
||||
<div id="drop-container" class="drop-target">Drop image here
|
||||
|
||||
@@ -28,32 +28,60 @@
|
||||
{% if object %}
|
||||
{% include 'anatomy/question_link_header.html' %}
|
||||
{% endif %}
|
||||
<h2>Question Answers</h2>
|
||||
<details class="help-text">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h2 class="mb-0">Question Answers</h2>
|
||||
<small class="text-muted">Manage answer keys for auto-marking</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<details class="help-text mb-3">
|
||||
<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>
|
||||
<p class="mb-0">This page can be used to add and modify answers associated with a question. Answers here are used for auto-marking submitted responses; avoid removing entries unless you intend to re-score submissions.</p>
|
||||
</details>
|
||||
|
||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||
{% csrf_token %}
|
||||
<h3>Answers:</h3>
|
||||
<div id="form_set">
|
||||
|
||||
<h3 class="h5 mt-2">Answers</h3>
|
||||
<div id="form_set" class="mt-3">
|
||||
{% for form in answer_formset %}
|
||||
<ul class="no-error answer-formset">
|
||||
<div class="answer-card mb-3 p-3 border rounded bg-body-secondary">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
<div class="answer-fields">
|
||||
{{ form.as_ul }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{{ answer_formset.management_form }}
|
||||
<input type="button" value="Add More Answers" id="add_more">
|
||||
<p>
|
||||
<input type="submit" class="submit-button btn btn-primary" value="Submit" name="submit">
|
||||
</p>
|
||||
|
||||
<div class="d-flex gap-2 align-items-center mt-3">
|
||||
<button type="button" id="add_more" class="btn btn-outline-secondary btn-sm">Add another answer</button>
|
||||
<input type="submit" class="submit-button btn btn-primary btn-sm ms-auto" value="Save answers" name="submit">
|
||||
</div>
|
||||
</form>
|
||||
<div id="empty_form" style="display:none">
|
||||
<ul class='no_error answer-formset'>
|
||||
|
||||
<div id="empty_form" class="d-none">
|
||||
<div class='answer-card p-3 mb-3 border rounded bg-body-secondary'>
|
||||
{{ answer_formset.empty_form.as_ul }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
/* Answer card layout */
|
||||
.answer-card label { font-weight: 600; }
|
||||
.answer-card li { list-style: none; margin-bottom: .5rem; }
|
||||
.answer-card input[type="text"], .answer-card textarea, .answer-card select { width: 100%; }
|
||||
.help-text summary { cursor: pointer; font-weight:600; }
|
||||
/* Make the Add button more prominent on small screens */
|
||||
@media (max-width: 576px) {
|
||||
.submit-button { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -23,7 +23,7 @@
|
||||
<pre>{{ans}}</pre>
|
||||
</span>
|
||||
{% if exam.publish_results or view_all_results%}
|
||||
<span class="answer-score">{{score}}</span>
|
||||
Marks: <span class="answer-score">{{score}}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="view-question-link" data-qn={{forloop.counter0}}>View</span>
|
||||
|
||||
@@ -2,14 +2,30 @@
|
||||
|
||||
{% block navigation %}
|
||||
{{block.super}}
|
||||
<br/>
|
||||
Exams: {{exam}}->
|
||||
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a> /
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
|
||||
<div class="container-fluid px-3">
|
||||
<span class="navbar-text me-3 text-white">
|
||||
<strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
|
||||
</span>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="examNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a></li>
|
||||
{% if exam.exam_mode %}
|
||||
<a href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a> /
|
||||
<a href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</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> /
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark2_overview' pk=exam.pk %}">Mark2</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a></li>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a></li>
|
||||
</ul>
|
||||
|
||||
{% include 'generic/partials/exams/dropdown_menu.html'%}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
<details>
|
||||
{% comment %} <details>
|
||||
<summary>Suggest incorrect answers:</summary>
|
||||
|
||||
{% for word in words_suggest_incorrect %}
|
||||
@@ -43,7 +43,7 @@
|
||||
<div class="border border-secondary">
|
||||
{% include "anatomy/question_detail.html#suggest-incorrect-form" %}
|
||||
</div>
|
||||
</details>
|
||||
</details> {% endcomment %}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
{% extends 'anatomy/exams.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h5 class="card-title mb-1">Marking question {{ question_details.current }} of {{ question_details.total }}</h5>
|
||||
<div class="mb-2">
|
||||
<a class="btn btn-sm btn-outline-secondary me-1" href="{% url 'anatomy:question_detail' question.id %}" title="View the Question">View</a>
|
||||
<a class="btn btn-sm btn-outline-secondary me-1" href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
|
||||
{% if request.user.is_superuser %}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}" title="Admin Edit">Admin Edit</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'anatomy:mark' exam.id question_number %}">Classic mark</a>
|
||||
</div>
|
||||
<h6 class="mb-1">{{ question.question_type }}</h6>
|
||||
{% if question.structure %}
|
||||
<div class="small text-muted">Structure: {{ question.structure }}</div>
|
||||
{% endif %}
|
||||
<div class="mt-2">Primary answer: <span id="primary-answer" title="The primary answer of the question">{{ question.get_primary_answer }}</span></div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
{% if question.answer_help %}
|
||||
<button class="btn btn-sm btn-outline-info" data-bs-toggle="collapse" data-bs-target="#marking-help">Answer Help</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if question.answer_help %}
|
||||
<div class="collapse mt-2" id="marking-help">
|
||||
<div class="card card-body">
|
||||
{{ question.answer_help|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3">
|
||||
<form method="POST" class="post-form">{% csrf_token %}
|
||||
<p class="small text-muted">Click each answer to toggle through marks awarded (as per colour). This UI writes marks immediately.</p>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div></div>
|
||||
<div>
|
||||
{% if question_details.current > 1 %}
|
||||
<button type="submit" name="previous" class="btn btn-outline-secondary btn-sm me-1">Previous</button>
|
||||
{% endif %}
|
||||
<button type="submit" name="save" class="btn btn-primary btn-sm me-1">Save</button>
|
||||
{% if question_details.current < question_details.total %}
|
||||
<button type="submit" name="next" class="btn btn-outline-primary btn-sm me-1">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<button class="btn btn-sm btn-outline-info me-1" hx-get="{% url 'anatomy:mark2_exam_marked' exam.id question_number %}" hx-target="#exam-marked-container" hx-swap="innerHTML">Show exam-submitted answers</button>
|
||||
<button class="btn btn-sm btn-outline-info" hx-get="{% url 'anatomy:mark2_question_marked' question.pk %}" hx-target="#question-marked-container" hx-swap="innerHTML">Show all stored answers for question</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h6>Answers</h6>
|
||||
<ul id="answer-list" class="list-group answer-list mb-3">
|
||||
{# Render currently-stored marked answers first, then unmarked submissions #}
|
||||
{% include 'anatomy/partials/mark2_marked_list_fragment.html' %}
|
||||
{% include 'anatomy/partials/mark2_unmarked_list_fragment.html' %}
|
||||
</ul>
|
||||
<div id="exam-marked-container" class="mt-2"></div>
|
||||
<div id="question-marked-container" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="small text-muted key">Key: <span class="key-item correct">2 Marks</span>, <span class="key-item half-correct">1 Mark</span>, <span class="key-item incorrect">0 Marks</span></div>
|
||||
</div>
|
||||
|
||||
<span class="visually-hidden">{{ form.as_p }}</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div id="single-dicom-viewer" class="marking-dicom" data-images="{{ question.get_image_url_array }}" data-annotations='{{ question.get_image_annotations }}'></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
/* Ensure answer states are visible and clickable in the new layout */
|
||||
.answer { cursor: pointer; }
|
||||
/* Use border to indicate mark state instead of background */
|
||||
.answer.correct { color: #155724; background-color: transparent; }
|
||||
.answer.half-correct { color: #856404; background-color: transparent; }
|
||||
.answer.incorrect { color: #721c24; background-color: transparent; }
|
||||
.answer.not-marked { color: inherit; background-color: transparent; }
|
||||
.answer-list .list-group-item pre { margin: 0; font-family: monospace; white-space: pre-wrap; }
|
||||
.mark-controls { display:flex; gap:0.25rem; }
|
||||
.mark-btn { padding: 0.15rem 0.4rem; font-size: 0.8rem; }
|
||||
/* Per-state border indicators on the list item */
|
||||
.marked-item { }
|
||||
.marked-correct { border-left: 4px solid #2a9d3f; }
|
||||
.marked-half-correct { border-left: 4px solid #ffb020; }
|
||||
.marked-incorrect { border-left: 4px solid #d3413a; }
|
||||
|
||||
/* Key items use a small left-border swatch to match the list markers */
|
||||
.key .key-item { display:inline-block; padding-left:8px; margin-left:6px; }
|
||||
.key .key-item.correct { border-left: 8px solid #2a9d3f; }
|
||||
.key .key-item.half-correct { border-left: 8px solid #ffb020; }
|
||||
.key .key-item.incorrect { border-left: 8px solid #d3413a; }
|
||||
/* Right-floating action icons for answer external links */
|
||||
.answer-actions { min-width: 6rem; }
|
||||
.answer-actions .action-icon { margin-left: 0.5rem; color: #6c757d; text-decoration: none; border: none; background: none; }
|
||||
.answer-actions .action-icon:hover { color: #000; text-decoration: none; }
|
||||
.answer-actions .copy-to-clipboard { cursor: pointer; }
|
||||
/* Active state for numeric mark buttons */
|
||||
.mark-btn.active { background-color: #0d6efd; color: #fff; border-color: #0d6efd; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script>
|
||||
(function(){
|
||||
// CSRF helper for fetch
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
const csrftoken = getCookie('csrftoken');
|
||||
|
||||
// Delegated click handler for answers — only updates local state.
|
||||
document.addEventListener('click', function(e){
|
||||
// Ignore clicks on action icons or mark controls
|
||||
if (e.target.closest('.answer-actions') || e.target.closest('.mark-controls') || e.target.closest('.copy-to-clipboard')) return;
|
||||
|
||||
// Prefer a clicked span.answer if present
|
||||
let spanEl = e.target.closest('span.answer');
|
||||
// Otherwise fall back to the list-item's span
|
||||
if (!spanEl) {
|
||||
const li = e.target.closest('#answer-list li');
|
||||
if (!li) return;
|
||||
spanEl = li.querySelector('span.answer');
|
||||
if (!spanEl) return;
|
||||
}
|
||||
|
||||
// Ensure span has an explicit state class
|
||||
if (!spanEl.classList.contains('correct') && !spanEl.classList.contains('half-correct') && !spanEl.classList.contains('incorrect') && !spanEl.classList.contains('not-marked')) {
|
||||
spanEl.classList.add('not-marked');
|
||||
}
|
||||
|
||||
// cycle states: not-marked -> correct -> half-correct -> incorrect -> not-marked
|
||||
const states = ['not-marked', 'correct', 'half-correct', 'incorrect'];
|
||||
let currentState = 'not-marked';
|
||||
for (let p of spanEl.classList) {
|
||||
if (states.includes(p)) { currentState = p; break; }
|
||||
}
|
||||
let idx = (states.indexOf(currentState) + 1) % states.length;
|
||||
const newState = states[idx];
|
||||
|
||||
// map to numeric for setMarkForSpan
|
||||
let numeric = null;
|
||||
if (newState === 'correct') numeric = 2;
|
||||
else if (newState === 'half-correct') numeric = 1;
|
||||
else if (newState === 'incorrect') numeric = 0;
|
||||
|
||||
// delegate to setMarkForSpan to keep behaviour consistent (updates classes, active buttons, hidden field)
|
||||
setMarkForSpan(spanEl, numeric);
|
||||
|
||||
}, false);
|
||||
|
||||
function updateMarkedAnswersField() {
|
||||
const marked = { 'correct': [], 'half-correct': [], 'incorrect': [] };
|
||||
document.querySelectorAll('#answer-list span.answer').forEach(function(el) {
|
||||
if (el.classList.contains('correct')) marked.correct.push((el.title || el.textContent).trim());
|
||||
else if (el.classList.contains('half-correct')) marked['half-correct'].push((el.title || el.textContent).trim());
|
||||
else if (el.classList.contains('incorrect')) marked.incorrect.push((el.title || el.textContent).trim());
|
||||
});
|
||||
|
||||
// Ensure the hidden input exists
|
||||
let hidden = document.getElementById('id_marked_answers');
|
||||
if (!hidden) {
|
||||
hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = 'marked_answers';
|
||||
hidden.id = 'id_marked_answers';
|
||||
const form = document.querySelector('form.post-form');
|
||||
if (form) form.appendChild(hidden);
|
||||
}
|
||||
hidden.value = JSON.stringify(marked);
|
||||
}
|
||||
|
||||
// Enhance list items with numeric mark controls (2,1,0,Clear)
|
||||
function attachNumericControls() {
|
||||
function makeButton(label, cls) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'btn btn-sm btn-outline-secondary me-1 mark-btn ' + cls;
|
||||
b.textContent = label;
|
||||
return b;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.answer-list li').forEach(function(li) {
|
||||
if (li.querySelector('.mark-controls')) return; // already attached
|
||||
const span = li.querySelector('span.answer');
|
||||
if (!span) return;
|
||||
// Ensure span has an explicit default state class so logic is consistent
|
||||
if (!span.classList.contains('correct') && !span.classList.contains('half-correct') && !span.classList.contains('incorrect') && !span.classList.contains('not-marked')) {
|
||||
span.classList.add('not-marked');
|
||||
}
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'mark-controls mt-2';
|
||||
const b2 = makeButton('2', 'mark-2');
|
||||
const b1 = makeButton('1', 'mark-1');
|
||||
const b0 = makeButton('0', 'mark-0');
|
||||
const bClear = makeButton('⨯', 'mark-clear');
|
||||
|
||||
controls.appendChild(b2);
|
||||
controls.appendChild(b1);
|
||||
controls.appendChild(b0);
|
||||
controls.appendChild(bClear);
|
||||
// append after the pre
|
||||
li.appendChild(controls);
|
||||
|
||||
// wire clicks
|
||||
b2.addEventListener('click', function(){ setMarkForSpan(span, 2); });
|
||||
b1.addEventListener('click', function(){ setMarkForSpan(span, 1); });
|
||||
b0.addEventListener('click', function(){ setMarkForSpan(span, 0); });
|
||||
bClear.addEventListener('click', function(){ setMarkForSpan(span, null); });
|
||||
|
||||
// set initial active button based on existing state
|
||||
(function(){
|
||||
let state = null;
|
||||
if (span.classList.contains('correct')) state = 2;
|
||||
else if (span.classList.contains('half-correct')) state = 1;
|
||||
else if (span.classList.contains('incorrect')) state = 0;
|
||||
// clear any pre-existing active flags then set the correct one
|
||||
[b2, b1, b0, bClear].forEach(b => b.classList.remove('active'));
|
||||
if (state === 2) b2.classList.add('active');
|
||||
else if (state === 1) b1.classList.add('active');
|
||||
else if (state === 0) b0.classList.add('active');
|
||||
else bClear.classList.add('active');
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
function setMarkForSpan(el, numeric) {
|
||||
// numeric: 2 -> correct, 1 -> half-correct, 0 -> incorrect, null -> not-marked
|
||||
const li = el.closest('li');
|
||||
let state = 'not-marked';
|
||||
if (numeric === 2) state = 'correct';
|
||||
else if (numeric === 1) state = 'half-correct';
|
||||
else if (numeric === 0) state = 'incorrect';
|
||||
el.className = 'answer ' + state;
|
||||
if (li) {
|
||||
// clear previous per-state classes
|
||||
li.classList.remove('marked-correct','marked-half-correct','marked-incorrect','marked-item');
|
||||
if (state === 'not-marked') {
|
||||
// nothing
|
||||
} else if (state === 'correct') {
|
||||
li.classList.add('marked-item','marked-correct');
|
||||
} else if (state === 'half-correct') {
|
||||
li.classList.add('marked-item','marked-half-correct');
|
||||
} else if (state === 'incorrect') {
|
||||
li.classList.add('marked-item','marked-incorrect');
|
||||
}
|
||||
}
|
||||
// update active state of numeric controls for this list item
|
||||
try {
|
||||
const li = el.closest('li');
|
||||
if (li) {
|
||||
const btn2 = li.querySelector('.mark-2');
|
||||
const btn1 = li.querySelector('.mark-1');
|
||||
const btn0 = li.querySelector('.mark-0');
|
||||
const btnClear = li.querySelector('.mark-clear');
|
||||
[btn2, btn1, btn0, btnClear].forEach(b => { if (b) { b.classList.remove('active'); } });
|
||||
if (numeric === 2 && btn2) btn2.classList.add('active');
|
||||
else if (numeric === 1 && btn1) btn1.classList.add('active');
|
||||
else if (numeric === 0 && btn0) btn0.classList.add('active');
|
||||
else if (numeric === null && btnClear) btnClear.classList.add('active');
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
updateMarkedAnswersField();
|
||||
}
|
||||
|
||||
// Run once on load to add controls. Also re-run after HTMX swaps so controls
|
||||
// appear when partials are replaced.
|
||||
function initMark2() {
|
||||
attachNumericControls();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initMark2);
|
||||
} else {
|
||||
initMark2();
|
||||
}
|
||||
|
||||
// If HTMX is used to update parts of the page, re-run attachNumericControls
|
||||
// after swaps so newly inserted list items get controls.
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
initMark2();
|
||||
});
|
||||
|
||||
// Ensure the hidden marked_answers field is updated before any form submit
|
||||
const postForm = document.querySelector('form.post-form');
|
||||
if (postForm) {
|
||||
postForm.addEventListener('submit', function(e){
|
||||
try { updateMarkedAnswersField(); } catch (err) { /* still submit */ }
|
||||
});
|
||||
}
|
||||
|
||||
// Copy-to-clipboard handler for dropdown items
|
||||
document.addEventListener('click', function(e){
|
||||
const btn = e.target.closest('.copy-to-clipboard');
|
||||
if (!btn) return;
|
||||
const text = btn.getAttribute('data-text');
|
||||
if (!text) return;
|
||||
try {
|
||||
navigator.clipboard.writeText(text);
|
||||
// simple visual feedback: change the button text briefly
|
||||
const prev = btn.textContent;
|
||||
btn.textContent = 'Copied';
|
||||
setTimeout(function(){ btn.textContent = prev; }, 1200);
|
||||
} catch (err) {
|
||||
console.warn('copy failed', err);
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,79 @@
|
||||
{% extends 'anatomy/exams.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-4 anatomy">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="h4 mb-1">Mark2 — Marking: {{ exam }}</h2>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="btn-group me-2" role="group" aria-label="listing actions">
|
||||
<button class="btn btn-sm btn-outline-secondary show-all-button">Show all</button>
|
||||
<button class="btn btn-sm btn-outline-secondary show-unmarked-button">Show unmarked</button>
|
||||
</div>
|
||||
<a href="{% url 'anatomy:mark2' exam_pk=exam.pk sk=0 %}" class="btn btn-sm btn-primary">Start mark2</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-2">
|
||||
<ul class="list-group list-group-flush" id="question-mark-list">
|
||||
{% for question, unmarked_count, unmarked_count2 in question_unmarked_map %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start" data-unmarked="{{ unmarked_count }}">
|
||||
<div class="me-3">
|
||||
<a href="{% url 'anatomy:mark2' exam_pk=exam.pk sk=forloop.counter0 %}" class="fw-semibold">Question {{ forloop.counter }}:</a>
|
||||
<div class="small text-truncate" style="max-width:60vw;">{{ question }}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="small text-muted">Unmarked</div>
|
||||
{% if unmarked_count > 0 %}
|
||||
<span class="badge bg-danger">{{ unmarked_count }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ unmarked_count }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="list-group-item small text-muted">No questions found.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
function $(sel, ctx){ return (ctx||document).querySelector(sel); }
|
||||
function $all(sel, ctx){ return Array.from((ctx||document).querySelectorAll(sel)); }
|
||||
|
||||
var showAllBtn = $('.show-all-button');
|
||||
var showUnmarkedBtn = $('.show-unmarked-button');
|
||||
var list = $('#question-mark-list');
|
||||
if (!list) return;
|
||||
|
||||
function showAll(){
|
||||
$all('#question-mark-list .list-group-item').forEach(function(li){ li.classList.remove('d-none'); });
|
||||
showAllBtn && showAllBtn.classList.add('active');
|
||||
showUnmarkedBtn && showUnmarkedBtn.classList.remove('active');
|
||||
}
|
||||
|
||||
function showUnmarked(){
|
||||
$all('#question-mark-list .list-group-item').forEach(function(li){
|
||||
var u = parseInt(li.getAttribute('data-unmarked') || '0', 10);
|
||||
if (u > 0) li.classList.remove('d-none'); else li.classList.add('d-none');
|
||||
});
|
||||
showUnmarkedBtn && showUnmarkedBtn.classList.add('active');
|
||||
showAllBtn && showAllBtn.classList.remove('active');
|
||||
}
|
||||
|
||||
if (showAllBtn) showAllBtn.addEventListener('click', function(e){ e.preventDefault(); showAll(); });
|
||||
if (showUnmarkedBtn) showUnmarkedBtn.addEventListener('click', function(e){ e.preventDefault(); showUnmarked(); });
|
||||
|
||||
// Initialize to show all
|
||||
showAll();
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
.show-all-button.active, .show-unmarked-button.active { box-shadow: inset 0 -2px 0 rgba(0,0,0,0.08); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,114 @@
|
||||
<div class="card mb-3" hx-on="htmx:afterSwap: initAnatomyFragment(this)">
|
||||
<div class="card-body">
|
||||
<p class="lead">{{ question.question_type }}</p>
|
||||
<div id="anatomy-dicom-image" class="dicom-image-legacy" data-url="{{ question.get_image_url }}"
|
||||
data-annotations='{{question.image_annotations}}' data-edit_annotation=true>
|
||||
</div>
|
||||
<p class="mt-2">
|
||||
Answer: {{ question.get_primary_answer }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
|
||||
</p>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Marked answers</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="small text-muted">{{ question.answers.all|length }}</div>
|
||||
<button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#marked-answers-{{ question.pk }}" aria-expanded="false" aria-controls="marked-answers-{{ question.pk }}">Show answers</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="collapse mt-2" id="marked-answers-{{ question.pk }}">
|
||||
<ul class="list-group">
|
||||
{% for answer in question.answers.all|dictsortreversed:"status" %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start{% if answer.status == '2' %} list-group-item-success{% elif answer.status == '1' %} list-group-item-warning{% elif answer.status == '0' %} list-group-item-secondary{% endif %}">
|
||||
<div>
|
||||
{% if answer.proposed %}
|
||||
<small class="text-muted me-2">(proposed)</small>
|
||||
{% endif %}
|
||||
<span>{{ answer.answer }}</span>
|
||||
</div>
|
||||
<div>
|
||||
{% if answer.status == '2' %}
|
||||
<span class="badge bg-success">Correct</span>
|
||||
{% elif answer.status == '1' %}
|
||||
<span class="badge bg-warning text-dark">Half mark</span>
|
||||
{% elif answer.status == '0' %}
|
||||
<span class="badge bg-secondary">Incorrect</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-muted">Unmarked</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Aggregated summary placeholder — auto-load via HTMX #}
|
||||
<div class="mt-3">
|
||||
<h6>Response summary</h6>
|
||||
<div id="anatomy-response-summary"
|
||||
hx-get="{% url 'anatomy:exam_review_question_summary' exam.pk q_index %}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#anatomy-summary-indicator-{{ question.pk }}">
|
||||
<div id="anatomy-summary-indicator-{{ question.pk }}" style="display:none">
|
||||
<span class="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading summary</span>
|
||||
</div>
|
||||
<style> #anatomy-summary-indicator-{{ question.pk }}.htmx-request{display:inline-block !important;} </style>
|
||||
<div class="small text-muted">Loading summary…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Responses: load on demand #}
|
||||
<div class="mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Responses in this exam</h6>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
hx-get="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}"
|
||||
hx-target="#anatomy-responses-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#anatomy-responses-indicator-{{ question.pk }}">Show responses</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2" id="anatomy-responses-container">
|
||||
<div id="anatomy-responses-indicator-{{ question.pk }}" style="display:none">
|
||||
<span class="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading responses</span>
|
||||
</div>
|
||||
<style> #anatomy-responses-indicator-{{ question.pk }}.htmx-request{display:inline-block !important;} </style>
|
||||
<div class="text-muted small">Responses are hidden — click “Show responses” to load.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-3">
|
||||
{% if prev_index is not None %}
|
||||
<a class="btn btn-outline-primary"
|
||||
hx-get="{% url app_name|add:':exam_review_question' exam.pk prev_index %}"
|
||||
hx-target="#review-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="true">← Previous</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
|
||||
{% if next_index is not None %}
|
||||
<a class="btn btn-primary"
|
||||
hx-get="{% url app_name|add:':exam_review_question' exam.pk next_index %}"
|
||||
hx-target="#review-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="true">Next →</a>
|
||||
{% else %}
|
||||
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
{# Responses partial for anatomy question within an exam. HTMX-loadable. #}
|
||||
<div id="anatomy-responses-container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div>
|
||||
<h6 class="mb-0">Responses in this exam</h6>
|
||||
<div class="small text-muted">List of candidate answers recorded for this question in the current exam.</div>
|
||||
</div>
|
||||
<div>
|
||||
{% if not reveal_respondents %}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary show-respondents-btn"
|
||||
hx-get="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}?reveal=1"
|
||||
hx-target="#anatomy-responses-container"
|
||||
hx-swap="outerHTML"
|
||||
data-url="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}?reveal=1"
|
||||
>Show respondents</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary show-respondents-btn"
|
||||
hx-get="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}"
|
||||
hx-target="#anatomy-responses-container"
|
||||
hx-swap="outerHTML"
|
||||
data-url="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}"
|
||||
>Hide respondents</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
{% for item in exam_user_answers %}
|
||||
{% with ua=item.ua %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
{% if reveal_respondents %}
|
||||
{% if item.display_name_raw %}
|
||||
<strong>{{ item.display_name_raw }}</strong>
|
||||
{% else %}
|
||||
<strong>{{ ua.get_candidate_masked }}</strong>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<strong>Respondent {{ forloop.counter }}</strong>
|
||||
{% endif %}
|
||||
<div>Answer: <strong>{{ ua.answer }}</strong></div>
|
||||
{% if ua.created_date %}
|
||||
<div class="small text-muted">Answered: {{ ua.created_date }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
{% if ua.score == '2' %}
|
||||
<span class="badge bg-success">Correct</span>
|
||||
{% elif ua.score == '1' %}
|
||||
<span class="badge bg-warning text-dark">Half mark</span>
|
||||
{% elif ua.score == '0' %}
|
||||
<span class="badge bg-secondary">Incorrect</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-muted">Unmarked</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endwith %}
|
||||
{% empty %}
|
||||
<li class="list-group-item">No responses recorded for this exam.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Fallback for environments where HTMX isn't active or the hx-* attributes don't trigger.
|
||||
// Attach a click handler to buttons with class .show-respondents-btn that will fetch
|
||||
// the fragment URL (from data-url) and replace the container content.
|
||||
(function(){
|
||||
try {
|
||||
var els = document.querySelectorAll('.show-respondents-btn');
|
||||
els.forEach(function(btn){
|
||||
btn.addEventListener('click', function(ev){
|
||||
// If HTMX is present it will handle the request; only run fallback when not handled.
|
||||
if (window.htmx) return;
|
||||
ev.preventDefault();
|
||||
var url = btn.getAttribute('data-url');
|
||||
if (!url) return;
|
||||
fetch(url, {credentials: 'same-origin'})
|
||||
.then(function(r){ if (!r.ok) throw new Error('Network response was not ok'); return r.text(); })
|
||||
.then(function(html){
|
||||
var container = document.querySelector('#anatomy-responses-container');
|
||||
if (container) container.outerHTML = html;
|
||||
}).catch(function(err){
|
||||
console.error('Failed to load responses fragment', err);
|
||||
});
|
||||
}, {passive: false});
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,103 @@
|
||||
{# Aggregated response summary partial for anatomy questions (HTMX-loadable) #}
|
||||
<div id="anatomy-response-summary-content">
|
||||
{% if exam_response_total %}
|
||||
<div class="small text-muted mb-2">Total responses: {{ exam_response_total }}</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-auto">Correct:</div>
|
||||
<div class="col">
|
||||
{% if exam_response_correct_count is not None %}
|
||||
<strong>{{ exam_response_correct_count }}</strong>
|
||||
{% if exam_response_correct_pct is not None %}
|
||||
<span class="text-muted">({{ exam_response_correct_pct }}%)</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-group mb-2">
|
||||
{# Correct (2) #}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||
<div>Correct</div>
|
||||
<div class="text-end"><strong>{{ exam_response_score_counts|get_item:'2'|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts|get_item:'2'|default:0 }}%</small></div>
|
||||
</div>
|
||||
<div class="progress mt-2" style="height:12px;">
|
||||
<div class="progress-bar bg-success" role="progressbar" style="width: {{ exam_response_score_pcts.2|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.2|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Half mark (1) #}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||
<div>Half mark</div>
|
||||
<div class="text-end"><strong>{{ exam_response_score_counts|get_item:'1'|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts|get_item:'1'|default:0 }}%</small></div>
|
||||
</div>
|
||||
<div class="progress mt-2" style="height:12px;">
|
||||
<div class="progress-bar bg-warning" role="progressbar" style="width: {{ exam_response_score_pcts.1|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.1|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Incorrect (0) #}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||
<div>Incorrect</div>
|
||||
<div class="text-end"><strong>{{ exam_response_score_counts|get_item:'0'|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts|get_item:'0'|default:0 }}%</small></div>
|
||||
</div>
|
||||
<div class="progress mt-2" style="height:12px;">
|
||||
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ exam_response_score_pcts.0|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.0|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Unmarked (empty) #}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||
<div>Unmarked</div>
|
||||
<div class="text-end"><strong>{{ exam_response_score_counts|get_item:'empty'|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts|get_item:'empty'|default:0 }}%</small></div>
|
||||
</div>
|
||||
<div class="progress mt-2" style="height:12px;">
|
||||
<div class="progress-bar bg-secondary" role="progressbar" style="width: {{ exam_response_score_pcts.empty|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.empty|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{# Collapsible Top submitted answers - closed by default. #}
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-1">Top submitted answers</h6>
|
||||
<button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#top-answers-{{ q_index }}" aria-expanded="false" aria-controls="top-answers-{{ q_index }}">
|
||||
Show answers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="collapse mt-2" id="top-answers-{{ q_index }}">
|
||||
{% if top_answers %}
|
||||
<ul class="list-group">
|
||||
{% for a in top_answers %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div class="flex-grow-1">{{ a.answer_compare }}</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if a.dominant_score == '2' %}
|
||||
<span class="badge bg-success">Correct</span>
|
||||
{% elif a.dominant_score == '1' %}
|
||||
<span class="badge bg-warning text-dark">Half</span>
|
||||
{% elif a.dominant_score == '0' %}
|
||||
<span class="badge bg-danger">Incorrect</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Unmarked</span>
|
||||
{% endif %}
|
||||
<div class="small text-muted badge bg-primary" title="Submission Count">{{ a.count }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="small text-muted">No submitted answers yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="small text-muted">No responses recorded for this question in the exam yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<div class="card mt-2">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Edit stored answer</h6>
|
||||
<form hx-post="{% url 'anatomy:mark2_edit_answer' %}" hx-target="closest .card" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="question_pk" value="{{ question.pk }}">
|
||||
<input type="hidden" name="original" value="{{ original }}">
|
||||
{% if exam_pk %}
|
||||
<input type="hidden" name="exam_pk" value="{{ exam_pk }}">
|
||||
{% endif %}
|
||||
{% if sk is not None %}
|
||||
<input type="hidden" name="sk" value="{{ sk }}">
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Original</label>
|
||||
<pre class="small bg-light p-2">{{ original }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label">New answer text</label>
|
||||
<textarea class="form-control" name="new_answer" rows="3">{% if existing %}{{ existing.answer }}{% else %}{{ original }}{% endif %}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
{% if exam_pk and sk is not None %}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" hx-get="{% url 'anatomy:mark2_exam_marked' exam_pk sk %}" hx-swap="outerHTML" hx-target="closest .card">Cancel</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" _="on click remove .card">Cancel</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,82 @@
|
||||
{% comment %}List of distinct answers submitted within this exam for the question, with counts and mark badges{% endcomment %}
|
||||
<div class="card mt-2">
|
||||
<div class="card-body">
|
||||
<style>
|
||||
/* Hide edit buttons and mark-action forms by default; show only when card has editing-enabled */
|
||||
.card .edit-btn { display: none !important; }
|
||||
.card.editing-enabled .edit-btn { display: inline-block !important; }
|
||||
.card .mark-actions { display: none !important; }
|
||||
.card.editing-enabled .mark-actions { display: inline-block !important; }
|
||||
</style>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="card-title mb-0">Answers submitted in this exam</h6>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Order">
|
||||
<button class="btn btn-sm btn-outline-primary{% if request.GET.order == 'count' or not request.GET.order %} active{% endif %}" hx-get="{{ request.path }}?order=count" hx-swap="outerHTML" hx-target="closest .card">By count</button>
|
||||
<button class="btn btn-sm btn-outline-primary{% if request.GET.order == 'mark' %} active{% endif %}" hx-get="{{ request.path }}?order=mark" hx-swap="outerHTML" hx-target="closest .card">By score</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="this.closest('.card').classList.toggle('editing-enabled'); this.textContent = this.closest('.card').classList.contains('editing-enabled') ? 'Disable editing' : 'Enable editing'">Enable editing</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if not items %}
|
||||
<div class="small text-muted">No answers submitted in this exam.</div>
|
||||
{% else %}
|
||||
<ul class="list-group">
|
||||
{% for it in items %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div class="flex-grow-1 me-3"><pre class="mb-0">{{ it.answer }}</pre></div>
|
||||
<div class="text-end d-flex align-items-center gap-2">
|
||||
{# Stored mark badge #}
|
||||
{% if it.mark == 'correct' %}
|
||||
<span class="badge bg-success me-1" title="Stored answer marked as correct (2)">2</span>
|
||||
{% elif it.mark == 'half-correct' %}
|
||||
<span class="badge bg-warning text-dark me-1" title="Stored answer marked as half-correct (1)">1</span>
|
||||
{% elif it.mark == 'incorrect' %}
|
||||
<span class="badge bg-danger me-1" title="Stored answer marked as incorrect (0)">0</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary me-1" title="No stored mark for this answer">-</span>
|
||||
{% endif %}
|
||||
|
||||
{# Submission count badge #}
|
||||
<span class="badge bg-light text-dark me-2" title="Submitted {{ it.count }} times">{{ it.count }}</span>
|
||||
|
||||
<form hx-post="{% url 'anatomy:mark2_toggle_answer' %}" hx-target="closest .card" hx-swap="outerHTML" class="d-inline mark-actions">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="question_pk" value="{{ question.pk }}">
|
||||
<input type="hidden" name="answer" value="{{ it.answer }}">
|
||||
<input type="hidden" name="exam_pk" value="{{ exam.pk }}">
|
||||
<input type="hidden" name="sk" value="{{ sk }}">
|
||||
<button class="btn btn-sm btn-outline-success" name="newmark" value="correct" title="Mark as correct (2)">2</button>
|
||||
<button class="btn btn-sm btn-outline-warning text-dark" name="newmark" value="half-correct" title="Mark as half-correct (1)">1</button>
|
||||
<button class="btn btn-sm btn-outline-danger" name="newmark" value="incorrect" title="Mark as incorrect (0)">0</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" name="newmark" value="clear" title="Clear stored mark">⨯</button>
|
||||
</form>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary edit-btn" hx-get="{% url 'anatomy:mark2_edit_answer' %}?question_pk={{ question.pk }}&original={{ it.answer|urlencode }}&exam_pk={{ exam.pk }}&sk={{ sk }}" hx-target="closest .card" hx-swap="outerHTML" title="Edit stored answer">Edit</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Keep Edit buttons hidden by default; reveal them when the card receives
|
||||
// the 'editing-enabled' class (toggled by the Enable editing button).
|
||||
(function(){
|
||||
document.querySelectorAll('.card').forEach(function(card){
|
||||
function update(){
|
||||
const enable = card.classList.contains('editing-enabled');
|
||||
card.querySelectorAll('.edit-btn').forEach(function(b){
|
||||
b.classList.toggle('d-none', !enable);
|
||||
});
|
||||
}
|
||||
// run once in case HTMX inserted the fragment
|
||||
update();
|
||||
// observe class changes on the card to re-run update
|
||||
const obs = new MutationObserver(update);
|
||||
obs.observe(card, { attributes: true, attributeFilter: ['class'] });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
{% comment %}Renders only the list items for already-marked answers (used as a partial){% endcomment %}
|
||||
{% for answer in correct_answers %}
|
||||
{% with answer_url=answer|urlencode:'' %}
|
||||
<li class="list-group-item d-flex flex-column">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<pre class="mb-0 me-3 flex-grow-1"><span class="answer correct" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
|
||||
<div class="answer-actions text-end">
|
||||
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
|
||||
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
|
||||
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% for answer in half_mark_answers %}
|
||||
{% with answer_url=answer|urlencode:'' %}
|
||||
<li class="list-group-item d-flex flex-column">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<pre class="mb-0 me-3 flex-grow-1"><span class="answer half-correct" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
|
||||
<div class="answer-actions text-end">
|
||||
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
|
||||
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
|
||||
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% for answer in incorrect_answers %}
|
||||
{% with answer_url=answer|urlencode:'' %}
|
||||
<li class="list-group-item d-flex flex-column">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<pre class="mb-0 me-3 flex-grow-1"><span class="answer incorrect" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
|
||||
<div class="answer-actions text-end">
|
||||
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
|
||||
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
|
||||
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% comment %}All stored Answer choices for this question grouped by status{% endcomment %}
|
||||
<div class="card mt-2">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">All stored answers for this question</h6>
|
||||
<style>
|
||||
/* Allow stored answers to wrap long lines and break words so they are visible
|
||||
in narrow columns. Also ensure the lists stack on small screens. */
|
||||
.card .list-group-item pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
|
||||
</style>
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-4">
|
||||
<h6>2 marks</h6>
|
||||
<ul class="list-group mb-2">
|
||||
{% for answer in correct_answers %}
|
||||
<li class="list-group-item"><pre class="mb-0">{{ answer.answer }}</pre></li>
|
||||
{% empty %}
|
||||
<li class="list-group-item small text-muted">None</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<h6>1 mark</h6>
|
||||
<ul class="list-group mb-2">
|
||||
{% for answer in half_mark_answers %}
|
||||
<li class="list-group-item"><pre class="mb-0">{{ answer.answer }}</pre></li>
|
||||
{% empty %}
|
||||
<li class="list-group-item small text-muted">None</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<h6>0 marks</h6>
|
||||
<ul class="list-group mb-2">
|
||||
{% for answer in incorrect_answers %}
|
||||
<li class="list-group-item"><pre class="mb-0">{{ answer.answer }}</pre></li>
|
||||
{% empty %}
|
||||
<li class="list-group-item small text-muted">None</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
{% comment %}Renders only the list items for unmarked answers (used as a partial){% endcomment %}
|
||||
{% for answer in user_answers %}
|
||||
{% with answer_url=answer|urlencode:'' %}
|
||||
{% if answer in answer_suggest_incorrect %}
|
||||
<li class="list-group-item d-flex flex-column">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<pre class="mb-0 me-3 flex-grow-1"><span class="answer incorrect suggest" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
|
||||
<div class="answer-actions text-end">
|
||||
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
|
||||
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
|
||||
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="list-group-item d-flex flex-column">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<pre class="mb-0 me-3 flex-grow-1"><span class="answer not-marked" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
|
||||
<div class="answer-actions text-end">
|
||||
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
|
||||
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
|
||||
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,16 @@
|
||||
<div id="incorrect-answers" class="small text-muted">
|
||||
<strong>Answer suggest incorrect:</strong>
|
||||
<div class="mt-2">
|
||||
{% if question.answer_suggest_incorrect %}
|
||||
{% for s in question.answer_suggest_incorrect %}
|
||||
<span class="badge bg-secondary me-1 mb-1">{{ s }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="small text-muted">No suggestions</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-1">
|
||||
<button id="answer-suggest-incorrect-button" class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="innerHTML">Edit suggestions</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
<div class="card mt-2">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Suggest incorrect answers</h6>
|
||||
<form hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Current suggestions</label>
|
||||
<div class="mb-2">
|
||||
{% for s in question.answer_suggest_incorrect %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary me-1 mb-1" hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-vals='{"remove": "{{ s|escapejs }}"}' hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-target="closest .card" hx-swap="outerHTML">{{ s }} <span class="text-danger ms-1">×</span></button>
|
||||
{% empty %}
|
||||
<div class="small text-muted">No suggestions</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<input id="new-suggest" name="suggestion" class="form-control form-control-sm" placeholder="Add suggestion" />
|
||||
<button type="button" class="btn btn-sm btn-primary" hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-include="#new-suggest" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-target="closest .card" hx-swap="outerHTML">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 justify-content-end">
|
||||
<button type="submit" name="close" value="1" class="btn btn-primary btn-sm">Close</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if candidates %}
|
||||
<hr/>
|
||||
<h6 class="mt-3">Suggested candidates</h6>
|
||||
<div class="small text-muted mb-2">These are frequent submitted answers that might be suitable to treat as incorrect suggestions.</div>
|
||||
<ul class="list-group">
|
||||
{% for c in candidates %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div class="flex-grow-1"><pre class="mb-0">{{ c.answer }}</pre></div>
|
||||
<div class="text-end">
|
||||
<span class="badge bg-light text-dark me-2">{{ c.count }}</span>
|
||||
<button class="btn btn-sm btn-outline-primary" hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-vals='{"suggestion": "{{ c.answer|escapejs }}", "context": "fragment"}' hx-target="closest .card" hx-swap="outerHTML">Add</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template class="incorrect-answers-original" hidden>
|
||||
<strong>Answer suggest incorrect:</strong> {{ question.answer_suggest_incorrect }}
|
||||
<div class="mt-1"><button id="answer-suggest-incorrect-button" class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="innerHTML">Edit suggestions</button></div>
|
||||
</template>
|
||||
@@ -3,117 +3,137 @@
|
||||
{% block content %}
|
||||
{% load static %}
|
||||
{% include 'anatomy/question_link_header.html' %}
|
||||
<div id="dicom-image" class="dicom-image" data-url="{{ remote_url }}{{ question.image.url}}"
|
||||
data-annotations='{{question.image_annotations}}' data-edit_annotation=true>
|
||||
<details class="help-text">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p>Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. To save the changes click the "Save Annotations" button.</p>
|
||||
|
||||
<p>There is no limit to the number of arrows that can be added to the image.</p>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div id="anatomy-dicom-image" class="dicom-image-legacy w-100" data-url="{{ question.get_image_url }}" data-annotations='{{question.image_annotations}}' data-edit_annotation=true style="min-height:300px">
|
||||
</div>
|
||||
<div class="mt-3 d-flex justify-content-between align-items-center">
|
||||
<details class="mb-0">
|
||||
<summary class="small text-muted"><i class="bi bi-info-circle"></i> Image help</summary>
|
||||
<div class="small text-muted mt-2">Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. Click <strong>Save Annotations</strong> to persist.</div>
|
||||
</details>
|
||||
<button id="save-annotations">Save Annotations</button>
|
||||
|
||||
<button id="save-annotations" class="btn btn-sm btn-primary">Save Annotations</button>
|
||||
</div>
|
||||
<div class="question">
|
||||
<div class="date">
|
||||
Created: {{ question.created_date|date:"d/m/Y" }}
|
||||
</div>
|
||||
<h2>Question type: {{question.question_type}}</h2>
|
||||
<h3>Primary answer: {{ question.get_primary_answer }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<details>
|
||||
<summary title="Click to view the question answers">
|
||||
Answers:
|
||||
</summary>
|
||||
<table>
|
||||
<tr><th>Answer</th><th>Score</th></tr>
|
||||
<h5 class="card-title mb-1">Question</h5>
|
||||
<div class="text-muted">Created: {{ question.created_date|date:"d/m/Y" }}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<h6 class="mb-0">Type: <span class="badge bg-secondary">{{ question.question_type }}</span></h6>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="mt-2">Primary answer</h6>
|
||||
<div class="mb-2"><strong>{{ question.get_primary_answer }}</strong></div>
|
||||
|
||||
<h6>Answers</h6>
|
||||
<details class="mb-3">
|
||||
<summary class="small">Show answers</summary>
|
||||
<table class="table table-sm mt-2">
|
||||
<thead>
|
||||
<tr><th>Answer</th><th>Score</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for answer in question.answers.all|dictsortreversed:"status" %}
|
||||
<tr>
|
||||
<td {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="anatomy"
|
||||
{% endif %}>
|
||||
<span >
|
||||
{{ answer }}
|
||||
</span>
|
||||
<td>
|
||||
<td>{{answer.status}}</td>
|
||||
{% if answer.proposed %}
|
||||
<td>
|
||||
<button class="btn btn-sm accept-button" data-aid={{answer.id}}
|
||||
hx-get="{% url 'anatomy:confirm_answer' answer.id %}"
|
||||
title="Click to accept the proposed answer">Accept</button>
|
||||
<button class="btn btn-sm delete-button" data-aid={{answer.id}}
|
||||
hx-get="{% url 'anatomy:delete_answer' answer.id %}"
|
||||
title="Click to delete the proposed answer"
|
||||
>Delete</button>
|
||||
</td>
|
||||
<td {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="anatomy"{% endif %}>
|
||||
{% if answer.status == "0" %}
|
||||
<div class="d-flex align-items-start">
|
||||
<button type="button" class="btn btn-sm btn-link text-muted p-0 toggle-words-btn me-2" title="Suggest words: show candidate words extracted from this stored answer (click to quick-add)" aria-expanded="false" aria-label="Suggest words"><i class="bi bi-lightbulb-fill"></i></button>
|
||||
<div class="flex-grow-1">
|
||||
<pre class="mb-0">{{ answer.answer }}</pre>
|
||||
<div class="mt-1">
|
||||
<div class="word-buttons d-none mt-2" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<pre class="mb-0">{{ answer.answer }}</pre>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</td>
|
||||
<td>{{ answer.get_status_display|default:answer.status }}</td>
|
||||
<td>
|
||||
{% if answer.proposed %}
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-success accept-button" data-aid={{answer.id}} hx-get="{% url 'anatomy:confirm_answer' answer.id %}" title="Accept">Accept</button>
|
||||
<button class="btn btn-outline-danger delete-button" data-aid={{answer.id}} hx-get="{% url 'anatomy:delete_answer' answer.id %}" title="Delete">Delete</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="small text-muted">No stored answers</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
</div>
|
||||
<div>
|
||||
Answer help: {{ question.answer_help|safe }}
|
||||
</div>
|
||||
<div>
|
||||
{% partialdef suggest-incorrect-form inline %}
|
||||
<span id="incorrect-answers">
|
||||
Answer suggest incorrect: {{ question.answer_suggest_incorrect }}
|
||||
<button id="answer-suggest-incorrect-button" class="btn btn-sm" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}"
|
||||
hx-target="#incorrect-answers"
|
||||
hx-swap="innerHTML">Edit suggestions</button>
|
||||
</span>
|
||||
{% endpartialdef %}
|
||||
</div>
|
||||
<div>
|
||||
Description: {{ question.description }}
|
||||
</div>
|
||||
<div>
|
||||
Exams: {% for exam in question.exams.all %}
|
||||
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">{{ exam }}</a>
|
||||
{% endfor %}
|
||||
<button class="btn btn-sm" hx-get="{% url 'anatomy:question_add_exam' question_id=question.pk %}"
|
||||
hx-target="#exam-list"
|
||||
hx-swap="innerHTML">Edit exam(s)</button>
|
||||
<span id="exam-list"></span>
|
||||
</div>
|
||||
<div>
|
||||
Modality: {{ question.modality }}
|
||||
</div>
|
||||
<div>
|
||||
Region: {{ question.region }}
|
||||
</div>
|
||||
<div>
|
||||
Structure: {{ question.structure }}
|
||||
</div>
|
||||
<div>
|
||||
Body Part: {{ question.body_part }}
|
||||
</div>
|
||||
<div>
|
||||
Author: {% for author in question.author.all %}
|
||||
{{ author }},
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
Open access: {{ question.open_access }}
|
||||
</div>
|
||||
{% include 'question_notes.html' %}
|
||||
<div>
|
||||
<details>
|
||||
<summary>
|
||||
<span id="annotation-json-toggle">Annotation JSON (+/-):</span> <span id="annotation-json-content">
|
||||
</summary>
|
||||
|
||||
{% if question.image_annotations %}
|
||||
<pre>{{ question.image_annotations }}</pre>
|
||||
{% else %}
|
||||
No saved annotations.
|
||||
{% if question.answer_help %}
|
||||
<div class="mb-3">
|
||||
<h6>Answer help</h6>
|
||||
<div class="small text-muted">{{ question.answer_help|safe }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<div class="mb-2">
|
||||
{% include 'anatomy/partials/suggest_incorrect_collapsed.html' %}
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<h6>Description</h6>
|
||||
<div class="small text-muted">{{ question.description }}</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="mb-1"><strong>Modality:</strong> {{ question.modality }}</div>
|
||||
<div class="mb-1"><strong>Region:</strong> {{ question.region }}</div>
|
||||
<div class="mb-1"><strong>Structure:</strong> {{ question.structure }}</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="mb-1"><strong>Body part:</strong> {{ question.body_part }}</div>
|
||||
<div class="mb-1"><strong>Open access:</strong> {{ question.open_access }}</div>
|
||||
<div class="mb-1"><strong>Author:</strong> {% for author in question.author.all %}{{ author }}{% if not forloop.last %}, {% endif %}{% endfor %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<h6>Exams</h6>
|
||||
<div class="small">
|
||||
{% for exam in question.exams.all %}
|
||||
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}" class="me-2">{{ exam }}</a>
|
||||
{% empty %}
|
||||
No exams
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-2"><button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-target="#exam-list" hx-swap="innerHTML">Edit exam(s)</button>
|
||||
<span id="exam-list"></span></div>
|
||||
</div>
|
||||
|
||||
{% include 'question_notes.html' %}
|
||||
|
||||
<div class="mt-3">
|
||||
<details>
|
||||
<summary class="small">Annotation JSON</summary>
|
||||
<div class="mt-2 small">{% if question.image_annotations %}<pre class="mb-0">{{ question.image_annotations }}</pre>{% else %}No saved annotations.{% endif %}</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function () {
|
||||
@@ -158,7 +178,7 @@
|
||||
|
||||
|
||||
function getJsonToolStateNoId() {
|
||||
const el = document.getElementById("dicom-image");
|
||||
const el = document.getElementById("anatomy-dicom-image");
|
||||
|
||||
const c = cornerstone.getEnabledElement(el);
|
||||
|
||||
@@ -174,6 +194,99 @@
|
||||
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// HTMX quick-add helpers for word buttons inside the answers table
|
||||
var SUGGEST_URL = "{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}";
|
||||
var CSRF_TOKEN = "{{ csrf_token }}";
|
||||
|
||||
function toggleWords(btn) {
|
||||
var container = btn.nextElementSibling; // the flex-grow-1 wrapper
|
||||
if (!container) return;
|
||||
var wordContainer = container.querySelector('.word-buttons');
|
||||
if (!wordContainer) return;
|
||||
// populate if empty
|
||||
if (!wordContainer.dataset.populated) {
|
||||
var pre = container.querySelector('pre');
|
||||
var text = pre ? pre.textContent || pre.innerText : '';
|
||||
buildWordButtons(wordContainer, text);
|
||||
wordContainer.dataset.populated = '1';
|
||||
}
|
||||
var expanded = !wordContainer.classList.contains('d-none');
|
||||
if (expanded) {
|
||||
wordContainer.classList.add('d-none');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
wordContainer.setAttribute('aria-hidden', 'true');
|
||||
} else {
|
||||
wordContainer.classList.remove('d-none');
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
wordContainer.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function buildWordButtons(container, text) {
|
||||
if (!text) return;
|
||||
var toks = (text.toLowerCase().match(/\w+/g) || []);
|
||||
var seen = {};
|
||||
toks.forEach(function(t){
|
||||
if (t.length < 3) return;
|
||||
if (seen[t]) return;
|
||||
seen[t]=true;
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'btn btn-sm btn-outline-primary me-1 mb-1';
|
||||
b.textContent = t;
|
||||
b.dataset.word = t;
|
||||
// do not attach inline handlers here; use delegated listener
|
||||
container.appendChild(b);
|
||||
});
|
||||
if (!Object.keys(seen).length) {
|
||||
var span = document.createElement('div');
|
||||
span.className = 'small text-muted';
|
||||
span.textContent = 'No suitable words';
|
||||
container.appendChild(span);
|
||||
}
|
||||
}
|
||||
|
||||
// Delegated click handler to keep markup unobtrusive and work for HTMX swaps
|
||||
document.addEventListener('click', function(e){
|
||||
var toggle = e.target.closest('.toggle-words-btn');
|
||||
if (toggle) {
|
||||
e.preventDefault();
|
||||
toggleWords(toggle);
|
||||
return;
|
||||
}
|
||||
var wordBtn = e.target.closest('.word-buttons button');
|
||||
if (wordBtn) {
|
||||
e.preventDefault();
|
||||
var word = wordBtn.dataset.word || wordBtn.textContent.trim();
|
||||
if (word) addQuickSuggestion(word);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
function addQuickSuggestion(word) {
|
||||
if (!word) return;
|
||||
if (typeof htmx === 'undefined') {
|
||||
// Fallback: simple POST via fetch
|
||||
fetch(SUGGEST_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': CSRF_TOKEN },
|
||||
body: new URLSearchParams({ suggestion: word, quick: '1' })
|
||||
}).then(function(r){ return r.text(); }).then(function(html){
|
||||
var wrapper = document.getElementById('incorrect-answers');
|
||||
if (wrapper) wrapper.innerHTML = html;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
htmx.ajax('POST', SUGGEST_URL, {
|
||||
values: { suggestion: word, quick: '1' },
|
||||
headers: { 'X-CSRFToken': CSRF_TOKEN },
|
||||
target: '#incorrect-answers',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
@@ -182,9 +295,9 @@
|
||||
content: "\F505";
|
||||
font-family: bootstrap-icons;
|
||||
}
|
||||
/* Ensure pre-wrapped content inside cards and tables so long answers don't overflow */
|
||||
.card pre, .table pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
.question div {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.card .mb-2, .card .mb-3 { margin-bottom: .75rem; }
|
||||
</style>
|
||||
{% endblock css %}
|
||||
@@ -1,28 +1,95 @@
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
|
||||
<div class="container-fluid px-3">
|
||||
<span class="navbar-text me-3 text-white">
|
||||
<strong>Question:</strong>
|
||||
{% if question.stem %}
|
||||
{{ question.stem|striptags|truncatechars:80 }}
|
||||
{% else %}
|
||||
#{{ question.pk }}
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<div class="floating-header">
|
||||
<a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
|
||||
<a href="{% url 'anatomy:question_clone' question.id %}" title="Clone the Question">Clone</a>
|
||||
<a href="{% url 'anatomy:question_delete' pk=question.pk %}" title="Delete the Question">Delete</a>
|
||||
<a href="{% url 'anatomy:question_answer_update' pk=question.pk %}" title="Update the question answers">Edit Answers</a>
|
||||
<a href="#"
|
||||
onclick="return window.create_popup_window('{% url 'feedback_create' question_type='anatomy' pk=question.pk %}')">
|
||||
Add Note</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#questionNav" aria-controls="questionNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="questionNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
{% if exam %}
|
||||
<li class="nav-item">
|
||||
{% if previous > -1 %}
|
||||
<a class="nav-link" href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li class="nav-item align-self-center">
|
||||
Viewing as part of exam: <a class="nav-link d-inline p-0" href="{% url 'anatomy:exam_overview' exam.id %}">{{ exam }}</a>
|
||||
<span class="navbar-text text-muted ms-2">[{{ pos }}/{{ exam_length }}]</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
{% if next %}
|
||||
<a class="nav-link" href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="questionActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="questionActions">
|
||||
<li>
|
||||
<a class="dropdown-item text-primary" href="{% url 'anatomy:question_detail' question.id %}">
|
||||
<i class="bi bi-eye me-2"></i> View
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item text-primary" href="{% url 'anatomy:anatomy_question_update' question.id %}">
|
||||
<i class="bi bi-pencil-square me-2"></i> Edit
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="dropdown-item text-white" href="{% url 'anatomy:question_answer_update' pk=question.pk %}">
|
||||
<i class="bi bi-list-check me-2"></i> Edit Answers
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="dropdown-item text-white" href="{% url 'anatomy:question_clone' question.id %}">
|
||||
<i class="bi bi-files me-2"></i> Clone
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="dropdown-item text-info" href="#" onclick="return window.create_popup_window('{% url 'feedback_create' question_type='anatomy' pk=question.pk %}')">
|
||||
<i class="bi bi-chat-left-text me-2"></i> Add Note
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
|
||||
<li>
|
||||
<a class="dropdown-item text-danger" href="{% url 'anatomy:question_delete' pk=question.pk %}">
|
||||
<i class="bi bi-trash me-2"></i> Delete
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% 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>
|
||||
<a href="{% url 'anatomy:question_user_answers' question.id %}" title="View user answers associated with this question">User answers</a>
|
||||
{% endif %}
|
||||
{% if exam %}
|
||||
<div>
|
||||
|
||||
{% if previous > -1 %}
|
||||
<a href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of exam: <a href="{% url 'anatomy:exam_overview' exam.id %}">{{exam}}</a> [{{pos}}/{{exam_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next question</a>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item text-warning" href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}">
|
||||
<i class="bi bi-tools me-2"></i> Admin Edit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item text-muted" href="{% url 'anatomy:question_user_answers' question.id %}">
|
||||
<i class="bi bi-people me-2"></i> User answers
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
@@ -16,11 +16,15 @@
|
||||
{% render_table table %}
|
||||
|
||||
<button id="button-select-add-exam"
|
||||
hx-get="{% url 'generic:generic_exam_htmx' %}?type=anatomy"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
data-exam_json_edit_url="{% url 'generic:generic_exam_json_edit' %}"
|
||||
data-exam_list_url="{% url 'api-1:anatomy_user_exams' %}"
|
||||
data-type="anatomy"
|
||||
data-csrf="{{ csrf_token}}">Add selected questions to
|
||||
exam</button>
|
||||
data-csrf="{{ csrf_token }}">
|
||||
Add selected questions to exam
|
||||
</button>
|
||||
<div id="exam-options"></div>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
@@ -38,6 +38,11 @@ urlpatterns = [
|
||||
views.question_suggest_incorrect_answers,
|
||||
name="question_suggest_incorrect_answers",
|
||||
),
|
||||
path(
|
||||
"question/<int:pk>/suggest_incorrect_collapsed",
|
||||
views.question_suggest_incorrect_collapsed,
|
||||
name="question_suggest_incorrect_collapsed",
|
||||
),
|
||||
|
||||
path("question/<int:pk>/answer/", views.answer_question, name="answer_question"),
|
||||
path(
|
||||
@@ -92,6 +97,23 @@ urlpatterns = [
|
||||
views.UserAnswerDelete.as_view(),
|
||||
name="user_answer_delete",
|
||||
),
|
||||
path(
|
||||
"exam/<int:pk>/review/<int:q_index>/responses",
|
||||
views.exam_review_question_responses,
|
||||
name="exam_review_question_responses",
|
||||
),
|
||||
path(
|
||||
"exam/<int:pk>/review/<int:q_index>/summary",
|
||||
views.exam_review_question_summary,
|
||||
name="exam_review_question_summary",
|
||||
),
|
||||
# New improved marking UI (mark2) and small toggle endpoint used by JS/HTMX
|
||||
path("exam/<int:exam_pk>/<int:sk>/mark2", views.mark2, name="mark2"),
|
||||
path("exam/<int:pk>/mark2", views.mark2_overview, name="mark2_overview"),
|
||||
path("exam/mark2/toggle", views.mark2_toggle_answer, name="mark2_toggle_answer"),
|
||||
path("exam/<int:exam_pk>/<int:sk>/mark2/exam_marked", views.mark2_exam_marked, name="mark2_exam_marked"),
|
||||
path("question/<int:pk>/mark2/question_marked", views.mark2_question_marked, name="mark2_question_marked"),
|
||||
path("exam/mark2/edit_answer", views.mark2_edit_answer, name="mark2_edit_answer"),
|
||||
]
|
||||
|
||||
urlpatterns.extend(generic_view_urls(views.GenericViews))
|
||||
|
||||
+808
-2
@@ -15,6 +15,7 @@ from django.views.generic.edit import CreateView, UpdateView, DeleteView
|
||||
from django.views.generic import ListView
|
||||
|
||||
from django.db.models.functions import Lower
|
||||
from django.db.models import Prefetch
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
@@ -80,6 +81,7 @@ from helpers.images import image_as_base64
|
||||
from django.template.defaulttags import register
|
||||
|
||||
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
|
||||
from django.db.models import Count
|
||||
|
||||
|
||||
class AuthorOrCheckerRequiredMixin(object):
|
||||
@@ -94,9 +96,43 @@ class AuthorOrCheckerRequiredMixin(object):
|
||||
|
||||
@register.filter
|
||||
def get_item(dictionary, key, default=None):
|
||||
"""Safe template filter to fetch an item by key/index from various containers.
|
||||
|
||||
Supports dict-like objects (uses .get), lists/tuples (indexing by integer
|
||||
keys), and other indexable objects. Returns ``default`` when the lookup
|
||||
fails or the input is None.
|
||||
"""
|
||||
if dictionary is None:
|
||||
return default
|
||||
return dictionary.get(key)
|
||||
|
||||
# If it's dict-like, prefer .get
|
||||
try:
|
||||
if hasattr(dictionary, "get") and callable(getattr(dictionary, "get")):
|
||||
# Allow .get default behaviour
|
||||
try:
|
||||
return dictionary.get(key, default)
|
||||
except Exception:
|
||||
# Fall through to other strategies
|
||||
pass
|
||||
|
||||
# If it's a list/tuple and key is integer-like, return by index
|
||||
if isinstance(dictionary, (list, tuple)):
|
||||
try:
|
||||
idx = int(key)
|
||||
except Exception:
|
||||
return default
|
||||
try:
|
||||
return dictionary[idx]
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
# Generic attempt: try __getitem__ with key (handles QueryDict, lists with int keys, etc.)
|
||||
try:
|
||||
return dictionary[key]
|
||||
except Exception:
|
||||
return default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def user_is_admin(user):
|
||||
@@ -412,6 +448,604 @@ def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def exam_review_question_responses(request, pk: int, q_index: int):
|
||||
"""Return the responses partial for a given anatomy exam question (HTMX-friendly)."""
|
||||
exam = get_object_or_404(Exam, pk=pk)
|
||||
|
||||
questions = exam.get_questions()
|
||||
try:
|
||||
question = questions[q_index]
|
||||
except Exception:
|
||||
raise Http404("Question not found in exam")
|
||||
|
||||
try:
|
||||
exam_user_answers_qs = question.cid_user_answers.filter(exam=exam).select_related("user")
|
||||
except Exception:
|
||||
exam_user_answers_qs = question.cid_user_answers.all()
|
||||
|
||||
# Allow an HTMX-driven reveal of respondent names. Default: anonymised.
|
||||
reveal = request.GET.get("reveal", "0")
|
||||
reveal_respondents = str(reveal).lower() in ("1", "true", "yes", "on")
|
||||
|
||||
# Build a small list that includes a sensible display name for each respondent.
|
||||
# If the answer is from a regular user use their full name/username. If it's a CID
|
||||
# candidate try to resolve the CidUser and show CID + name if available. If neither
|
||||
# is present fall back to empty/anonymous.
|
||||
|
||||
# Optimize CID lookups: collect distinct CID values and fetch all CidUser rows
|
||||
# in a single query to avoid an N+1 query pattern.
|
||||
cids_qs = exam_user_answers_qs.values_list("cid", flat=True).distinct()
|
||||
cids = [c for c in cids_qs if c is not None and str(c).strip() != ""]
|
||||
cid_map = {}
|
||||
if cids:
|
||||
for cu in CidUser.objects.filter(cid__in=cids):
|
||||
cid_map[cu.cid] = cu
|
||||
|
||||
exam_user_answers = []
|
||||
for ua in exam_user_answers_qs:
|
||||
# default display
|
||||
display = None
|
||||
is_cid = False
|
||||
try:
|
||||
if getattr(ua, "user", None):
|
||||
user = ua.user
|
||||
# prefer full name where available
|
||||
full = getattr(user, "get_full_name", None)
|
||||
if full:
|
||||
name = user.get_full_name() or user.username
|
||||
else:
|
||||
name = getattr(user, "username", str(user))
|
||||
display = name
|
||||
elif getattr(ua, "cid", None) is not None:
|
||||
cid_val = ua.cid
|
||||
cu = cid_map.get(cid_val)
|
||||
if cu is not None and cu.name:
|
||||
display = f"CID{cid_val} ({cu.name})"
|
||||
else:
|
||||
display = f"CID{cid_val}"
|
||||
is_cid = True
|
||||
except Exception:
|
||||
display = None
|
||||
|
||||
exam_user_answers.append({"ua": ua, "display_name_raw": display, "is_cid": is_cid})
|
||||
|
||||
context = {
|
||||
"exam": exam,
|
||||
"question": question,
|
||||
"q_index": q_index,
|
||||
"app_name": "anatomy",
|
||||
"exam_user_answers": exam_user_answers,
|
||||
"reveal_respondents": reveal_respondents,
|
||||
}
|
||||
|
||||
return render(request, "anatomy/partials/exam_review_question_responses_fragment.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def exam_review_question_summary(request, pk: int, q_index: int):
|
||||
"""Return aggregated response summary partial for anatomy question (HTMX-friendly)."""
|
||||
exam = get_object_or_404(Exam, pk=pk)
|
||||
|
||||
questions = exam.get_questions()
|
||||
try:
|
||||
question = questions[q_index]
|
||||
except Exception:
|
||||
raise Http404("Question not found in exam")
|
||||
|
||||
ua_qs = question.cid_user_answers.filter(exam=exam)
|
||||
total_responses = ua_qs.count()
|
||||
|
||||
# counts by score values ('2','1','0','')
|
||||
score_counts = {}
|
||||
for s, c in ua_qs.values_list("score").annotate(count=Count("id")) if False else []:
|
||||
pass
|
||||
|
||||
# simpler: compute via values and normalise unmarked -> 'empty'
|
||||
def _norm_score_key(v):
|
||||
# Treat None or empty-string scores as the 'empty' bucket
|
||||
if v is None:
|
||||
return "empty"
|
||||
if isinstance(v, str) and v.strip() == "":
|
||||
return "empty"
|
||||
return str(v)
|
||||
|
||||
score_counts = {_norm_score_key(row["score"]): row["count"] for row in ua_qs.values("score").annotate(count=Count("id"))}
|
||||
|
||||
# top submitted answers (by answer_compare) with dominant score per answer
|
||||
# First, get counts grouped by answer_compare and score
|
||||
grouped = ua_qs.values("answer_compare", "score").annotate(count=Count("id"))
|
||||
|
||||
# Aggregate totals and score breakdown per answer_compare
|
||||
answer_map = {}
|
||||
for row in grouped:
|
||||
ans = row["answer_compare"]
|
||||
sc = _norm_score_key(row["score"])
|
||||
cnt = row["count"]
|
||||
if ans not in answer_map:
|
||||
answer_map[ans] = {"total": 0, "score_counts": {}}
|
||||
answer_map[ans]["total"] += cnt
|
||||
answer_map[ans]["score_counts"][sc] = answer_map[ans]["score_counts"].get(sc, 0) + cnt
|
||||
|
||||
# Build list sorted by total count desc, and choose a dominant score per answer
|
||||
top_answers = []
|
||||
for ans, meta in answer_map.items():
|
||||
# pick dominant score by highest count; tie-break prefer higher numeric score
|
||||
ans_score_counts = meta["score_counts"]
|
||||
# sort by (count desc, score desc) where 'empty' maps to -1
|
||||
def score_key(item):
|
||||
s, c = item
|
||||
# Map 'empty' (or any non-numeric key) to a low value so numeric scores win ties
|
||||
if s == "empty":
|
||||
s_val = -1
|
||||
else:
|
||||
try:
|
||||
s_val = int(s)
|
||||
except Exception:
|
||||
s_val = -1
|
||||
return (c, s_val)
|
||||
|
||||
dominant = max(ans_score_counts.items(), key=score_key)[0]
|
||||
top_answers.append({"answer_compare": ans, "count": meta["total"], "dominant_score": dominant})
|
||||
|
||||
top_answers.sort(key=lambda x: x["count"], reverse=True)
|
||||
top_answers = top_answers[:10]
|
||||
|
||||
correct_count = None
|
||||
correct_pct = None
|
||||
# compute correct based on matching marked answers if available
|
||||
if total_responses:
|
||||
# count scores equal to '2' as correct
|
||||
correct_count = score_counts.get("2", 0)
|
||||
correct_pct = round(100.0 * correct_count / total_responses, 1) if total_responses else None
|
||||
|
||||
# compute percentage distribution for score buckets for the summary bar chart
|
||||
score_keys = ["2", "1", "0", "empty"]
|
||||
score_pcts = {}
|
||||
if total_responses:
|
||||
for k in score_keys:
|
||||
score_pcts[k] = round(100.0 * score_counts.get(k, 0) / total_responses, 1)
|
||||
else:
|
||||
for k in score_keys:
|
||||
score_pcts[k] = 0.0
|
||||
|
||||
context = {
|
||||
"exam": exam,
|
||||
"question": question,
|
||||
"q_index": q_index,
|
||||
"app_name": "anatomy",
|
||||
"exam_response_total": total_responses,
|
||||
"exam_response_score_counts": score_counts,
|
||||
"exam_response_score_pcts": score_pcts,
|
||||
"exam_response_correct_count": correct_count,
|
||||
"exam_response_correct_pct": correct_pct,
|
||||
"top_answers": top_answers,
|
||||
}
|
||||
|
||||
return render(request, "anatomy/partials/exam_review_question_summary_fragment.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def mark2(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
|
||||
"""Improved marking UI (mark2).
|
||||
|
||||
Same core behaviour as `mark` but renders a modern, HTMX-friendly template
|
||||
and exposes a small toggle endpoint for quick per-answer marking.
|
||||
"""
|
||||
exam = get_object_or_404(Exam, pk=exam_pk)
|
||||
|
||||
if not GenericExamViews.check_user_marker_access(request.user, exam_pk):
|
||||
raise PermissionDenied
|
||||
|
||||
if not exam.exam_mode:
|
||||
raise Http404("Packet not in exam mode")
|
||||
|
||||
mark_url = "anatomy:mark2"
|
||||
if review:
|
||||
mark_url = "anatomy:mark2"
|
||||
|
||||
questions = exam.get_questions().prefetch_related("answers")
|
||||
|
||||
n = sk
|
||||
|
||||
question_details = {
|
||||
"total": len(questions),
|
||||
"current": n + 1,
|
||||
}
|
||||
|
||||
try:
|
||||
question: AnatomyQuestion = questions[sk]
|
||||
except IndexError:
|
||||
raise Http404("Exam question does not exist")
|
||||
|
||||
# For consistency with the existing mark view we accept a standard POST form
|
||||
# but the preferred workflow is immediate HTMX/ajax toggles via mark2_toggle_answer.
|
||||
if request.method == "POST":
|
||||
form = MarkAnatomyQuestionForm(request.POST)
|
||||
if form.is_valid():
|
||||
if "skip" in request.POST:
|
||||
return redirect(mark_url, exam_pk=exam_pk, sk=n + 1)
|
||||
|
||||
cd = form.cleaned_data
|
||||
marked_answers = json.loads(cd.get("marked_answers"))
|
||||
|
||||
to_run = (
|
||||
("correct", Answer.MarkOptions.CORRECT),
|
||||
("half-correct", Answer.MarkOptions.HALF_MARK),
|
||||
("incorrect", Answer.MarkOptions.INCORRECT),
|
||||
)
|
||||
|
||||
for status_string, status in to_run:
|
||||
for ans in marked_answers[status_string]:
|
||||
ans = ans.strip()
|
||||
if ans == "":
|
||||
continue
|
||||
|
||||
a = Answer.objects.filter(answer__iexact=ans, question_id=question.pk).first()
|
||||
|
||||
if a is None:
|
||||
a = Answer()
|
||||
a.question_id = question.pk
|
||||
a.answer = ans
|
||||
|
||||
a.status = status
|
||||
a.save()
|
||||
|
||||
if "next" in request.POST:
|
||||
return redirect(mark_url, exam_pk=exam_pk, sk=n + 1)
|
||||
elif "previous" in request.POST:
|
||||
return redirect(mark_url, exam_pk=exam_pk, sk=n - 1)
|
||||
else:
|
||||
form = MarkAnatomyQuestionForm()
|
||||
|
||||
# Reuse existing collections for display
|
||||
correct_answers = []
|
||||
half_mark_answers = []
|
||||
incorrect_answers = []
|
||||
review_user_answers = []
|
||||
unmarked_answers_bool = False
|
||||
unmarked_user_answers = []
|
||||
|
||||
if review:
|
||||
unmarked_user_answers = question.get_user_answers(exam_pk=exam_pk, include_normal=False)
|
||||
elif unmarked_exam_answers_only:
|
||||
unmarked_user_answers = question.get_unmarked_user_answers(exam_pk=exam_pk)
|
||||
else:
|
||||
unmarked_user_answers = question.get_unmarked_user_answers()
|
||||
|
||||
if not unmarked_exam_answers_only:
|
||||
correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT)
|
||||
half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK)
|
||||
incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT)
|
||||
|
||||
answer_suggest_incorrect: list = []
|
||||
|
||||
if review:
|
||||
for ans in unmarked_user_answers:
|
||||
marked_ans = question.answers.filter(answer_compare__iexact=ans).first()
|
||||
mark = "unmarked"
|
||||
if marked_ans is not None:
|
||||
if marked_ans.status == Answer.MarkOptions.CORRECT:
|
||||
mark = "correct"
|
||||
elif marked_ans.status == Answer.MarkOptions.HALF_MARK:
|
||||
mark = "half-correct"
|
||||
elif marked_ans.status == Answer.MarkOptions.INCORRECT:
|
||||
mark = "incorrect"
|
||||
if mark == "unmarked":
|
||||
unmarked_answers_bool = True
|
||||
review_user_answers.append((ans, mark))
|
||||
else:
|
||||
for ans in unmarked_user_answers:
|
||||
for suggest_incorrect in question.answer_suggest_incorrect:
|
||||
if suggest_incorrect in ans:
|
||||
answer_suggest_incorrect.append(ans)
|
||||
break
|
||||
|
||||
words_present: set = set()
|
||||
words_present_in_correct: set = set()
|
||||
|
||||
for ans in correct_answers:
|
||||
answer_words = set(ans.get_constituent_words())
|
||||
words_present.update(answer_words)
|
||||
words_present_in_correct.update(answer_words)
|
||||
|
||||
for ans in half_mark_answers:
|
||||
answer_words = set(ans.get_constituent_words())
|
||||
words_present.update(answer_words)
|
||||
words_present_in_correct.update(answer_words)
|
||||
|
||||
for ans in incorrect_answers:
|
||||
answer_words = set(ans.get_constituent_words())
|
||||
words_present.update(answer_words)
|
||||
|
||||
words_suggest_incorrect = words_present - words_present_in_correct - set(question.answer_suggest_incorrect)
|
||||
|
||||
context = {
|
||||
"exam": exam,
|
||||
"form": form,
|
||||
"review": review,
|
||||
"question": question,
|
||||
"question_number": n,
|
||||
"question_details": question_details,
|
||||
"unmarked_answers_bool": unmarked_answers_bool,
|
||||
"user_answers": unmarked_user_answers,
|
||||
"review_user_answers": review_user_answers,
|
||||
"correct_answers": correct_answers,
|
||||
"half_mark_answers": half_mark_answers,
|
||||
"incorrect_answers": incorrect_answers,
|
||||
"unmarked_exam_answers_only": unmarked_exam_answers_only,
|
||||
"answer_suggest_incorrect": answer_suggest_incorrect,
|
||||
"words_suggest_incorrect": words_suggest_incorrect,
|
||||
}
|
||||
|
||||
return render(request, "anatomy/mark2.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def mark2_overview(request, pk):
|
||||
"""Overview page for mark2 workflow: lists questions and unmarked counts and links into mark2."""
|
||||
exam = get_object_or_404(Exam, pk=pk)
|
||||
|
||||
if not exam.exam_mode:
|
||||
raise Http404("Packet not in exam mode")
|
||||
|
||||
# Only allow authors or designated markers
|
||||
if request.user not in exam.author.all():
|
||||
if not GenericExamViews.check_user_marker_access(request.user, pk):
|
||||
raise PermissionDenied
|
||||
|
||||
# For anatomy prefer prefetch similar to generic.mark_overview
|
||||
questions = (
|
||||
exam.exam_questions.select_related(
|
||||
"modality",
|
||||
"structure",
|
||||
"body_part",
|
||||
"region",
|
||||
"examination",
|
||||
"question_type",
|
||||
)
|
||||
.all()
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"answers",
|
||||
queryset=Answer.objects.filter(proposed=False, status=Answer.MarkOptions.CORRECT),
|
||||
to_attr="prefetched_primary_answer",
|
||||
),
|
||||
)
|
||||
.order_by("examquestiondetail__sort_order")
|
||||
)
|
||||
|
||||
# Handle double marking which needs per-marker counts
|
||||
try:
|
||||
if exam.double_mark:
|
||||
question_unmarked_map = []
|
||||
for q in questions:
|
||||
question_unmarked_map.append(
|
||||
(
|
||||
q,
|
||||
int(q.get_unmarked_user_answer_count(exam_pk=exam.pk)),
|
||||
int(q.get_unmarked_user_answer_count(exam_pk=exam.pk, marker=request.user)),
|
||||
)
|
||||
)
|
||||
|
||||
return render(request, "anatomy/mark2_overview.html", {"exam": exam, "question_unmarked_map": question_unmarked_map})
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
question_unmarked_map = []
|
||||
for q in questions:
|
||||
count = int(q.get_unmarked_user_answer_count(exam_pk=exam.pk))
|
||||
question_unmarked_map.append((q, count, count))
|
||||
|
||||
return render(request, "anatomy/mark2_overview.html", {"exam": exam, "question_unmarked_map": question_unmarked_map})
|
||||
|
||||
|
||||
@login_required
|
||||
def mark2_toggle_answer(request):
|
||||
"""Endpoint to toggle/set a single answer mark quickly.
|
||||
|
||||
Expects POST with form fields: question_pk, answer (text), newmark ("correct"|"half-correct"|"incorrect").
|
||||
Returns JSON status.
|
||||
"""
|
||||
if request.method != "POST":
|
||||
return JsonResponse({"status": "error", "message": "POST required"}, status=400)
|
||||
|
||||
question_pk = request.POST.get("question_pk") or request.POST.get("question")
|
||||
answer_text = request.POST.get("answer")
|
||||
newmark = request.POST.get("newmark")
|
||||
|
||||
if not question_pk or not answer_text or not newmark:
|
||||
return JsonResponse({"status": "error", "message": "missing parameters"}, status=400)
|
||||
|
||||
try:
|
||||
question = AnatomyQuestion.objects.get(pk=question_pk)
|
||||
except AnatomyQuestion.DoesNotExist:
|
||||
return JsonResponse({"status": "error", "message": "question not found"}, status=404)
|
||||
|
||||
# permission checks: ensure user can mark the exam(s) this question belongs to
|
||||
# We can't easily infer exam_pk here, but ask GenericExamViews.check_user_marker_access
|
||||
# using any exam that contains this question is adequate if present.
|
||||
exams = list(question.exams.all())
|
||||
if exams:
|
||||
exam_pk = exams[0].pk
|
||||
if not GenericExamViews.check_user_marker_access(request.user, exam_pk):
|
||||
return JsonResponse({"status": "error", "message": "forbidden"}, status=403)
|
||||
|
||||
# Map mark strings to model constants
|
||||
mark_map = {
|
||||
"correct": Answer.MarkOptions.CORRECT,
|
||||
"half-correct": Answer.MarkOptions.HALF_MARK,
|
||||
"incorrect": Answer.MarkOptions.INCORRECT,
|
||||
}
|
||||
|
||||
# Support clearing a stored answer (delete) via 'clear'
|
||||
exam_pk = request.POST.get('exam_pk')
|
||||
sk = request.POST.get('sk')
|
||||
|
||||
if newmark == 'clear':
|
||||
a = Answer.objects.filter(answer_compare__iexact=answer_text, question_id=question.pk).first()
|
||||
if a is not None:
|
||||
# permission: ensure user can edit this answer
|
||||
if not a.can_edit(request.user):
|
||||
return JsonResponse({"status": "error", "message": "forbidden"}, status=403)
|
||||
a.delete()
|
||||
if request.htmx and exam_pk and sk is not None:
|
||||
try:
|
||||
return mark2_exam_marked(request, int(exam_pk), int(sk))
|
||||
except Exception:
|
||||
return JsonResponse({"status": "ok"})
|
||||
return JsonResponse({"status": "ok"})
|
||||
|
||||
if newmark not in mark_map:
|
||||
return JsonResponse({"status": "error", "message": "invalid mark"}, status=400)
|
||||
|
||||
status_val = mark_map[newmark]
|
||||
|
||||
# Find existing Answer by answer_compare or create
|
||||
a = Answer.objects.filter(answer_compare__iexact=answer_text, question_id=question.pk).first()
|
||||
if a is None:
|
||||
a = Answer()
|
||||
a.question_id = question.pk
|
||||
a.answer = answer_text
|
||||
|
||||
a.status = status_val
|
||||
a.save()
|
||||
|
||||
if request.htmx and exam_pk and sk is not None:
|
||||
try:
|
||||
return mark2_exam_marked(request, int(exam_pk), int(sk))
|
||||
except Exception:
|
||||
return JsonResponse({"status": "ok"})
|
||||
|
||||
return JsonResponse({"status": "ok"})
|
||||
|
||||
|
||||
@login_required
|
||||
def mark2_exam_marked(request, exam_pk, sk):
|
||||
"""Return a partial listing of answers submitted for this question within the given exam.
|
||||
|
||||
Shows distinct submitted answers with counts and any existing mark/status.
|
||||
"""
|
||||
exam = get_object_or_404(Exam, pk=exam_pk)
|
||||
questions = exam.get_questions()
|
||||
try:
|
||||
question = questions[sk]
|
||||
except Exception:
|
||||
raise Http404("Question not found in exam")
|
||||
|
||||
ua_qs = question.cid_user_answers.filter(exam=exam)
|
||||
|
||||
# Allow client to request ordering by 'count' (default) or by 'mark' (badge)
|
||||
order = request.GET.get('order', 'count')
|
||||
|
||||
# Group distinct submitted answers and count occurrences
|
||||
grouped = ua_qs.values('answer_compare').annotate(count=Count('id'))
|
||||
|
||||
items = []
|
||||
for row in grouped:
|
||||
ans = row['answer_compare']
|
||||
cnt = row['count']
|
||||
a = question.answers.filter(answer_compare__iexact=ans).first()
|
||||
mark = None
|
||||
if a is not None:
|
||||
if a.status == Answer.MarkOptions.CORRECT:
|
||||
mark = 'correct'
|
||||
elif a.status == Answer.MarkOptions.HALF_MARK:
|
||||
mark = 'half-correct'
|
||||
elif a.status == Answer.MarkOptions.INCORRECT:
|
||||
mark = 'incorrect'
|
||||
items.append({'answer': ans, 'count': cnt, 'mark': mark})
|
||||
|
||||
# Sort items according to requested ordering
|
||||
if order == 'mark':
|
||||
weight = {'correct': 3, 'half-correct': 2, 'incorrect': 1, None: 0}
|
||||
items.sort(key=lambda x: (weight.get(x.get('mark')), x.get('count', 0)), reverse=True)
|
||||
else:
|
||||
items.sort(key=lambda x: x.get('count', 0), reverse=True)
|
||||
|
||||
context = {'exam': exam, 'question': question, 'items': items, 'sk': sk}
|
||||
return render(request, 'anatomy/partials/mark2_exam_marked_fragment.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def mark2_edit_answer(request):
|
||||
"""HTMX-friendly endpoint to edit/create a stored Answer for a question.
|
||||
|
||||
GET: returns a small form fragment.
|
||||
POST: updates/creates the Answer and, if exam_pk/sk provided, returns the
|
||||
refreshed exam-marked fragment so the UI updates in-place.
|
||||
"""
|
||||
if request.method == 'GET' and request.htmx:
|
||||
question_pk = request.GET.get('question_pk')
|
||||
original = request.GET.get('original', '')
|
||||
exam_pk = request.GET.get('exam_pk')
|
||||
sk = request.GET.get('sk')
|
||||
question = get_object_or_404(AnatomyQuestion, pk=question_pk)
|
||||
# Try to find an existing Answer by compare
|
||||
existing = question.answers.filter(answer_compare__iexact=original).first()
|
||||
context = {
|
||||
'question': question,
|
||||
'original': original,
|
||||
'existing': existing,
|
||||
'exam_pk': exam_pk,
|
||||
'sk': sk,
|
||||
}
|
||||
return render(request, 'anatomy/partials/mark2_edit_answer_fragment.html', context)
|
||||
|
||||
if request.method == 'POST' and request.htmx:
|
||||
question_pk = request.POST.get('question_pk')
|
||||
original = request.POST.get('original', '')
|
||||
new_text = request.POST.get('new_answer', '').strip()
|
||||
exam_pk = request.POST.get('exam_pk')
|
||||
sk = request.POST.get('sk')
|
||||
|
||||
if not question_pk or not new_text:
|
||||
return HttpResponse('Missing parameters', status=400)
|
||||
|
||||
question = get_object_or_404(AnatomyQuestion, pk=question_pk)
|
||||
|
||||
a = question.answers.filter(answer_compare__iexact=original).first()
|
||||
if a is None:
|
||||
a = Answer()
|
||||
a.question = question
|
||||
a.answer = new_text
|
||||
else:
|
||||
a.answer = new_text
|
||||
|
||||
a.save()
|
||||
|
||||
# If we have exam context, refresh the exam-marked fragment for that question
|
||||
if exam_pk and sk is not None:
|
||||
# delegate to mark2_exam_marked to re-render the card
|
||||
try:
|
||||
return mark2_exam_marked(request, int(exam_pk), int(sk))
|
||||
except Exception:
|
||||
return HttpResponse('OK')
|
||||
|
||||
return HttpResponse('OK')
|
||||
|
||||
# Fallback: forbidden for non-HTMX or other methods
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
@login_required
|
||||
def mark2_question_marked(request, pk):
|
||||
"""Return a partial listing of all stored Answer choices for this question (grouped by status)."""
|
||||
question = get_object_or_404(AnatomyQuestion, pk=pk)
|
||||
correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT)
|
||||
half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK)
|
||||
incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT)
|
||||
|
||||
context = {
|
||||
'question': question,
|
||||
'correct_answers': correct_answers,
|
||||
'half_mark_answers': half_mark_answers,
|
||||
'incorrect_answers': incorrect_answers,
|
||||
}
|
||||
return render(request, 'anatomy/partials/mark2_question_marked_fragment.html', context)
|
||||
|
||||
|
||||
|
||||
#@login_required
|
||||
#def exam_scores_refresh(request, pk):
|
||||
# exam = get_object_or_404(Exam, pk=pk)
|
||||
@@ -758,23 +1392,195 @@ class AnatomyQuestionView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
||||
@user_is_author_or_anatomy_checker
|
||||
def question_suggest_incorrect_answers(request, pk):
|
||||
question = get_object_or_404(AnatomyQuestion, pk=pk)
|
||||
|
||||
# helper: build candidate suggestions (n-grams + prefixes)
|
||||
def build_candidates(question_obj):
|
||||
from django.db.models import Count
|
||||
import re
|
||||
|
||||
def tokenize(s: str):
|
||||
return re.findall(r"\w+", (s or "").lower())
|
||||
|
||||
candidates_qs = (
|
||||
question_obj.cid_user_answers.values("answer_compare")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)
|
||||
|
||||
existing = set([s for s in question_obj.answer_suggest_incorrect if s])
|
||||
marked_texts = [m.lower() for m in question_obj.get_marked_answers()]
|
||||
|
||||
ngram_map = {}
|
||||
ngram_weight = {}
|
||||
prefix_map = {}
|
||||
prefix_weight = {}
|
||||
|
||||
for row in candidates_qs:
|
||||
ans = (row.get("answer_compare") or "").strip()
|
||||
cnt = int(row.get("count") or 0)
|
||||
if not ans:
|
||||
continue
|
||||
tokens = tokenize(ans)
|
||||
max_n = min(4, len(tokens))
|
||||
for n in range(1, max_n + 1):
|
||||
for i in range(len(tokens) - n + 1):
|
||||
ngram = " ".join(tokens[i : i + n])
|
||||
ngram_map.setdefault(ngram, set()).add(ans)
|
||||
ngram_weight[ngram] = ngram_weight.get(ngram, 0) + cnt
|
||||
|
||||
for tok in tokens:
|
||||
tlen = len(tok)
|
||||
for plen in range(3, min(6, tlen) + 1):
|
||||
pref = tok[:plen]
|
||||
prefix_map.setdefault(pref, set()).add(ans)
|
||||
prefix_weight[pref] = prefix_weight.get(pref, 0) + cnt
|
||||
|
||||
raw_candidates = []
|
||||
|
||||
for ngram, ans_set in ngram_map.items():
|
||||
if ngram in existing:
|
||||
continue
|
||||
excluded = False
|
||||
for mt in marked_texts:
|
||||
if ngram in mt:
|
||||
excluded = True
|
||||
break
|
||||
if excluded:
|
||||
continue
|
||||
coverage = len(ans_set)
|
||||
if coverage < 1:
|
||||
continue
|
||||
raw_candidates.append(
|
||||
{
|
||||
"text": ngram,
|
||||
"coverage": coverage,
|
||||
"count": ngram_weight.get(ngram, 0),
|
||||
"words": len(ngram.split()),
|
||||
"type": "ngram",
|
||||
}
|
||||
)
|
||||
|
||||
for pref, ans_set in prefix_map.items():
|
||||
if pref in existing:
|
||||
continue
|
||||
excluded = False
|
||||
for mt in marked_texts:
|
||||
if pref in mt:
|
||||
excluded = True
|
||||
break
|
||||
if excluded:
|
||||
continue
|
||||
coverage = len(ans_set)
|
||||
if coverage < 1:
|
||||
continue
|
||||
raw_candidates.append(
|
||||
{
|
||||
"text": pref,
|
||||
"coverage": coverage,
|
||||
"count": prefix_weight.get(pref, 0),
|
||||
"words": 1,
|
||||
"type": "prefix",
|
||||
}
|
||||
)
|
||||
|
||||
raw_candidates.sort(key=lambda x: (x["words"], -x["coverage"], -x["count"]))
|
||||
|
||||
selected = []
|
||||
selected_texts = []
|
||||
for c in raw_candidates:
|
||||
t = c["text"]
|
||||
skip = False
|
||||
for s in selected_texts:
|
||||
if s in t:
|
||||
skip = True
|
||||
break
|
||||
if not skip:
|
||||
selected.append({"answer": t, "count": c["count"], "type": c.get("type", "ngram")})
|
||||
selected_texts.append(t)
|
||||
|
||||
return selected
|
||||
|
||||
# Support HTMX partial rendering with suggestion candidates and inline add
|
||||
if request.method == "POST":
|
||||
# Support remove action from the edit fragment
|
||||
if request.htmx and request.POST.get("remove"):
|
||||
to_remove = request.POST.get("remove").strip()
|
||||
if to_remove:
|
||||
existing = [s for s in question.answer_suggest_incorrect if s]
|
||||
if to_remove in existing:
|
||||
existing = [s for s in existing if s != to_remove]
|
||||
question.answer_suggest_incorrect = existing
|
||||
question.save()
|
||||
# Recompute candidates and render the edit fragment
|
||||
form = SuggestIncorrectAnswerForm(instance=question)
|
||||
candidates = build_candidates(question)
|
||||
context = {"form": form, "question": question, "candidates": candidates}
|
||||
return render(request, "anatomy/partials/suggest_incorrect_fragment.html", context)
|
||||
|
||||
# Support single-candidate add via HTMX (button posts 'suggestion').
|
||||
# Edits should only be performed via the edit fragment; when a
|
||||
# suggestion is posted we re-render the edit fragment so the
|
||||
# user remains in edit mode and sees the updated candidate list.
|
||||
if request.htmx and request.POST.get("suggestion"):
|
||||
suggestion = request.POST.get("suggestion").strip()
|
||||
if suggestion:
|
||||
existing = [s for s in question.answer_suggest_incorrect if s]
|
||||
if suggestion not in existing:
|
||||
existing.append(suggestion)
|
||||
question.answer_suggest_incorrect = existing
|
||||
question.save()
|
||||
# If this is a quick add (from the answers table) return the collapsed partial
|
||||
if request.POST.get("quick"):
|
||||
return render(request, "anatomy/partials/suggest_incorrect_collapsed.html", {"question": question})
|
||||
|
||||
# Recompute candidates and render the edit fragment
|
||||
form = SuggestIncorrectAnswerForm(instance=question)
|
||||
candidates = build_candidates(question)
|
||||
context = {"form": form, "question": question, "candidates": candidates}
|
||||
return render(request, "anatomy/partials/suggest_incorrect_fragment.html", context)
|
||||
|
||||
# Otherwise treat as the full form submission
|
||||
form = SuggestIncorrectAnswerForm(request.POST, instance=question)
|
||||
|
||||
if form.is_valid():
|
||||
obj = form.save()
|
||||
obj.save()
|
||||
|
||||
# If HTMX requested, return the fragment or collapsed partial
|
||||
if request.htmx:
|
||||
# If the close button was used, return the collapsed small partial
|
||||
if request.POST.get("close"):
|
||||
return render(request, "anatomy/partials/suggest_incorrect_collapsed.html", {"question": question})
|
||||
|
||||
# Otherwise re-render the edit fragment so the user stays in edit mode
|
||||
form = SuggestIncorrectAnswerForm(instance=question)
|
||||
candidates = build_candidates(question)
|
||||
context = {"form": form, "question": question, "candidates": candidates}
|
||||
return render(request, "anatomy/partials/suggest_incorrect_fragment.html", context)
|
||||
|
||||
return render(request, "anatomy/question_detail.html#suggest-incorrect-form", {"question": question})
|
||||
|
||||
return HttpResponse("Invalid")
|
||||
else:
|
||||
form = SuggestIncorrectAnswerForm(instance=question)
|
||||
candidates = build_candidates(question)
|
||||
context = {"form": form, "question": question, "candidates": candidates}
|
||||
|
||||
return render(request, "anatomy/suggest_incorrect.html", {"form": form, "question": question})
|
||||
if request.htmx:
|
||||
return render(request, "anatomy/partials/suggest_incorrect_fragment.html", context)
|
||||
|
||||
return render(request, "anatomy/suggest_incorrect.html", context)
|
||||
|
||||
return HttpResponse("Error", status=400)
|
||||
|
||||
|
||||
@user_is_author_or_anatomy_checker
|
||||
def question_suggest_incorrect_collapsed(request, pk):
|
||||
"""Return the small collapsed 'Edit suggestions' box as a partial."""
|
||||
question = get_object_or_404(AnatomyQuestion, pk=pk)
|
||||
context = {"question": question}
|
||||
return render(request, "anatomy/partials/suggest_incorrect_collapsed.html", context)
|
||||
|
||||
@user_is_author_or_anatomy_checker
|
||||
def question_save_annotation(request, pk):
|
||||
if request.method == "POST":
|
||||
|
||||
@@ -3,6 +3,7 @@ from .models import (
|
||||
Case,
|
||||
CaseDetail,
|
||||
CaseCollection,
|
||||
NormalCase,
|
||||
Differential,
|
||||
Series,
|
||||
SeriesImage,
|
||||
@@ -18,6 +19,8 @@ from .models import (
|
||||
SeriesDetail,
|
||||
Resource,
|
||||
CaseDisplaySet,
|
||||
UserReportAnswer,
|
||||
CidReportAnswer,
|
||||
)
|
||||
|
||||
from django.forms import ModelForm
|
||||
@@ -48,6 +51,8 @@ admin.site.register(UncategorisedDicom)
|
||||
admin.site.register(SeriesDetail)
|
||||
admin.site.register(Resource)
|
||||
admin.site.register(CaseDisplaySet)
|
||||
admin.site.register(UserReportAnswer)
|
||||
admin.site.register(CidReportAnswer)
|
||||
|
||||
|
||||
class DifferentialInline(admin.TabularInline):
|
||||
@@ -75,6 +80,22 @@ class CaseCollectionAdmin(VersionAdmin):
|
||||
|
||||
admin.site.register(CaseCollection, CaseCollectionAdmin)
|
||||
|
||||
|
||||
class NormalCaseAdmin(VersionAdmin):
|
||||
list_display = ("case", "display_age", "examination", "modality", "added_by", "added_date")
|
||||
list_filter = ("examination", "modality")
|
||||
search_fields = ("case__title", "case__pk")
|
||||
|
||||
def display_age(self, obj):
|
||||
try:
|
||||
return obj.display_age()
|
||||
except Exception:
|
||||
return ""
|
||||
display_age.short_description = "Age"
|
||||
|
||||
|
||||
admin.site.register(NormalCase, NormalCaseAdmin)
|
||||
|
||||
class AtlasAdminForm(ModelForm):
|
||||
class Meta:
|
||||
model = Case
|
||||
|
||||
+4
-4
@@ -32,9 +32,9 @@ router = Router()
|
||||
class SeriesSchema(ModelSchema):
|
||||
case_id: List[int] = []
|
||||
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Series
|
||||
model_fields = [
|
||||
fields = [
|
||||
"id",
|
||||
"modality",
|
||||
"examination",
|
||||
@@ -48,9 +48,9 @@ class SeriesSchema(ModelSchema):
|
||||
|
||||
|
||||
class CaseSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Case
|
||||
model_fields = ["id", "title"]
|
||||
fields = ["id", "title"]
|
||||
|
||||
|
||||
@router.post("/upload_dicom", auth=django_auth)
|
||||
|
||||
+130
-5
@@ -11,6 +11,9 @@ from .models import (
|
||||
Series,
|
||||
Structure,
|
||||
Subspecialty,
|
||||
NormalCase,
|
||||
Resource,
|
||||
Site,
|
||||
)
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
@@ -161,13 +164,16 @@ class SeriesFilter(django_filters.FilterSet):
|
||||
|
||||
|
||||
class ConditionFilter(django_filters.FilterSet):
|
||||
# Replace legacy `primary` filter with `is_canonical` and provide a
|
||||
# `synonym` filter which will filter by the canonical group of the
|
||||
# selected condition.
|
||||
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
||||
synonym = django_filters.ModelChoiceFilter(queryset=Condition.objects.all(), method="filter_synonym", label="Synonym group")
|
||||
|
||||
class Meta:
|
||||
model = Condition
|
||||
fields = {
|
||||
"name": ["icontains"],
|
||||
"primary": ["exact"],
|
||||
"synonym": ["exact"],
|
||||
#"parent": ["exact"],
|
||||
"subspecialty": ["exact"],
|
||||
"rcr_curriculum": ["exact"],
|
||||
}
|
||||
@@ -186,11 +192,32 @@ class ConditionFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_is_canonical(self, queryset, name, value):
|
||||
if value in (True, "True", "true", 1, "1"):
|
||||
return queryset.filter(canonical__isnull=True)
|
||||
if value in (False, "False", "false", 0, "0"):
|
||||
return queryset.filter(canonical__isnull=False)
|
||||
return queryset
|
||||
|
||||
def filter_synonym(self, queryset, name, value):
|
||||
# value is a Condition instance selected in the filter. Return all
|
||||
# Conditions that belong to the same canonical group as `value`.
|
||||
if not value:
|
||||
return queryset
|
||||
master = value.canonical if value.canonical else value
|
||||
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
||||
|
||||
|
||||
class FindingFilter(django_filters.FilterSet):
|
||||
# Replace legacy `primary`/`synonym` with canonical-based filters
|
||||
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
||||
synonym = django_filters.ModelChoiceFilter(queryset=Finding.objects.all(), method="filter_synonym", label="Synonym group")
|
||||
|
||||
class Meta:
|
||||
model = Finding
|
||||
fields = ("name", "primary", "synonym")
|
||||
fields = {
|
||||
"name": ["icontains"],
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -206,11 +233,29 @@ class FindingFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_is_canonical(self, queryset, name, value):
|
||||
if value in (True, "True", "true", 1, "1"):
|
||||
return queryset.filter(canonical__isnull=True)
|
||||
if value in (False, "False", "false", 0, "0"):
|
||||
return queryset.filter(canonical__isnull=False)
|
||||
return queryset
|
||||
|
||||
def filter_synonym(self, queryset, name, value):
|
||||
# value is a Finding instance selected in the filter. Return all
|
||||
# Findings that belong to the same canonical group as `value`.
|
||||
if not value:
|
||||
return queryset
|
||||
master = value.canonical if value.canonical else value
|
||||
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
||||
|
||||
|
||||
class StructureFilter(django_filters.FilterSet):
|
||||
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
||||
synonym = django_filters.ModelChoiceFilter(queryset=Structure.objects.all(), method="filter_synonym", label="Synonym group")
|
||||
|
||||
class Meta:
|
||||
model = Structure
|
||||
fields = {"name": ["icontains"], "primary": ["exact"], "synonym": ["exact"]}
|
||||
fields = {"name": ["icontains"]}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -226,6 +271,19 @@ class StructureFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_is_canonical(self, queryset, name, value):
|
||||
if value in (True, "True", "true", 1, "1"):
|
||||
return queryset.filter(canonical__isnull=True)
|
||||
if value in (False, "False", "false", 0, "0"):
|
||||
return queryset.filter(canonical__isnull=False)
|
||||
return queryset
|
||||
|
||||
def filter_synonym(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
master = value.canonical if value.canonical else value
|
||||
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
||||
|
||||
|
||||
class PresentationFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
@@ -286,6 +344,73 @@ class SubspecialtyFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
|
||||
class NormalCaseFilter(django_filters.FilterSet):
|
||||
"""FilterSet for the NormalCase model allowing filtering by
|
||||
examination, modality and nearest-year age ranges.
|
||||
"""
|
||||
examination = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
||||
modality = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
||||
# Accept min/max as years in the UI but filter against the canonical
|
||||
# `age_days` field by converting years -> days.
|
||||
min_age = django_filters.NumberFilter(label="Min age", method="filter_min_age")
|
||||
max_age = django_filters.NumberFilter(label="Max age", method="filter_max_age")
|
||||
|
||||
class Meta:
|
||||
model = NormalCase
|
||||
fields = ("examination", "modality", "age_days")
|
||||
|
||||
def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None):
|
||||
# Use the global Examination and Modality querysets from the Series model
|
||||
from generic.models import Examination, Modality
|
||||
|
||||
# Set up the actual querysets for the filters
|
||||
self.base_filters.get("examination").queryset = Examination.objects.all()
|
||||
self.base_filters.get("modality").queryset = Modality.objects.all()
|
||||
|
||||
# If not an atlas editor restrict to cases authored by the user or open access cases
|
||||
if request is not None and not request.user.groups.filter(name="atlas_editor").exists():
|
||||
from django.db.models import Q
|
||||
|
||||
if queryset is None:
|
||||
queryset = NormalCase.objects.select_related("case")
|
||||
queryset = queryset.filter((Q(case__author__id=request.user.id) | Q(case__open_access=True)))
|
||||
|
||||
super(NormalCaseFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
|
||||
|
||||
def filter_min_age(self, queryset, name, value):
|
||||
try:
|
||||
days = int(float(value) * 365.25)
|
||||
except Exception:
|
||||
return queryset
|
||||
return queryset.filter(age_days__gte=days)
|
||||
|
||||
def filter_max_age(self, queryset, name, value):
|
||||
try:
|
||||
days = int(float(value) * 365.25)
|
||||
except Exception:
|
||||
return queryset
|
||||
return queryset.filter(age_days__lte=days)
|
||||
|
||||
|
||||
class ResourceFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(field_name='name', lookup_expr='icontains')
|
||||
site = django_filters.ModelMultipleChoiceFilter(field_name='sites', queryset=lambda request: Site.objects.all() if True else Site.objects.none())
|
||||
subspecialty = django_filters.ModelMultipleChoiceFilter(field_name='subspecialty', queryset=Subspecialty.objects.all())
|
||||
author = django_filters.ModelMultipleChoiceFilter(queryset=get_authors, null_label='No author')
|
||||
has_file = django_filters.BooleanFilter(method='filter_has_file', label='Has file')
|
||||
|
||||
class Meta:
|
||||
model = Resource
|
||||
fields = ('name', 'site', 'subspecialty', 'author')
|
||||
|
||||
def filter_has_file(self, queryset, name, value):
|
||||
if value is True:
|
||||
return queryset.exclude(file__isnull=True).exclude(file__exact='')
|
||||
elif value is False:
|
||||
return queryset.filter(file__isnull=True) | queryset.filter(file__exact='')
|
||||
return queryset
|
||||
|
||||
class QuestionSchemaFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
model = QuestionSchema
|
||||
|
||||
+233
-62
@@ -15,6 +15,7 @@ from django.forms import (
|
||||
CheckboxSelectMultiple,
|
||||
SplitDateTimeWidget,
|
||||
)
|
||||
from django.utils.html import escape
|
||||
from django.forms import inlineformset_factory
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_jsonforms.forms import JSONSchemaField
|
||||
@@ -39,10 +40,13 @@ from atlas.models import (
|
||||
Condition,
|
||||
Structure,
|
||||
Subspecialty,
|
||||
Presentation,
|
||||
PathologicalProcess,
|
||||
UncategorisedDicom,
|
||||
UserReportAnswer,
|
||||
CaseDisplaySet,
|
||||
)
|
||||
from .models import NormalCase
|
||||
|
||||
from anatomy.models import Modality
|
||||
|
||||
@@ -52,7 +56,8 @@ from generic.models import Examination # , Sign
|
||||
|
||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||
from django.forms.widgets import RadioSelect, TextInput, Textarea, Select
|
||||
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML
|
||||
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML, Button
|
||||
from django.urls import reverse
|
||||
from crispy_forms.bootstrap import Accordion, AccordionGroup
|
||||
|
||||
from tinymce.widgets import TinyMCE
|
||||
@@ -67,7 +72,8 @@ from autocomplete import (
|
||||
register as autocomplete_register,
|
||||
)
|
||||
|
||||
import logging
|
||||
from loguru import logger
|
||||
|
||||
|
||||
from generic.forms import (
|
||||
ExamAuthorFormMixin,
|
||||
@@ -113,11 +119,10 @@ class ConditionForm(ModelForm):
|
||||
class Meta:
|
||||
model = Condition
|
||||
exclude = ["rcr_curriculum"]
|
||||
|
||||
widgets = {
|
||||
"synonym": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:condition-autocomplete"
|
||||
),
|
||||
# Use an AJAX/autocomplete single-select for canonical so users
|
||||
# can search for existing canonical Conditions by name.
|
||||
"canonical": autocomplete.ModelSelect2(url="atlas:condition-autocomplete"),
|
||||
"parent": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:condition-autocomplete"
|
||||
),
|
||||
@@ -126,6 +131,32 @@ class ConditionForm(ModelForm):
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Crispy helper to render a submit button consistently
|
||||
try:
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = True
|
||||
# Primary submit
|
||||
self.helper.add_input(Submit("submit", "Submit"))
|
||||
# Cancel as a button that redirects back to the condition list
|
||||
try:
|
||||
cancel_url = reverse("atlas:condition_view")
|
||||
except Exception:
|
||||
cancel_url = "#"
|
||||
self.helper.add_input(
|
||||
Button(
|
||||
"cancel",
|
||||
"Cancel",
|
||||
css_class="btn btn-secondary ms-2",
|
||||
onclick=f"window.location='{cancel_url}'",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# If crispy not available or something goes wrong, don't break form
|
||||
pass
|
||||
|
||||
|
||||
class CaseCollectionForm(ModelForm):
|
||||
class Meta:
|
||||
@@ -139,6 +170,25 @@ class CaseCollectionForm(ModelForm):
|
||||
ModelForm.__init__(self, *args, **kwargs)
|
||||
super(CaseCollectionForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Render collection_type as radio buttons and ensure it appears near the top
|
||||
if "collection_type" in self.fields:
|
||||
# Replace the model field with an explicit ChoiceField that uses RadioSelect
|
||||
original = self.fields["collection_type"]
|
||||
choices = getattr(original, "choices", ())
|
||||
initial = original.initial if hasattr(original, "initial") else None
|
||||
required = original.required
|
||||
label = original.label
|
||||
widget = RadioSelect(attrs={"class": "btn-check"})
|
||||
# assign custom template for rendering as button group
|
||||
#widget.template_name = "widgets/radioselect_buttons.html"
|
||||
self.fields["collection_type"] = forms.ChoiceField(
|
||||
choices=choices,
|
||||
widget=widget,
|
||||
initial=initial,
|
||||
required=required,
|
||||
label=label,
|
||||
)
|
||||
|
||||
# Identify show_ fields
|
||||
show_fields = [f for f in self.fields if f.startswith("show_")]
|
||||
show_pre_fields = [f for f in show_fields if f.endswith("_pre")]
|
||||
@@ -184,6 +234,7 @@ class CaseCollectionForm(ModelForm):
|
||||
Fieldset(
|
||||
"Collection Details",
|
||||
"name",
|
||||
Field("collection_type", css_class="mb-2"),
|
||||
Fieldset(
|
||||
"Dates",
|
||||
"restrict_to_dates",
|
||||
@@ -211,8 +262,9 @@ class CaseCollectionForm(ModelForm):
|
||||
"exam_mode",
|
||||
"self_review",
|
||||
"feedback_once_collection_complete",
|
||||
"collection_type",
|
||||
"viewer_mode",
|
||||
"question_time_limit",
|
||||
"prerequisites",
|
||||
),
|
||||
Fieldset("Valid User Groups", *user_fields) if user_fields else None,
|
||||
Div(
|
||||
@@ -232,33 +284,53 @@ class CaseCollectionForm(ModelForm):
|
||||
Submit("submit", "Save Collection", css_class="btn btn-primary"),
|
||||
)
|
||||
|
||||
self.fields["start_date"] = SplitDateTimeFieldDefaultTime(
|
||||
widget=SplitDateTimeWidget(
|
||||
date_attrs={"type": "date", "class": "datepicker"},
|
||||
time_attrs={"type": "time", "class": "timepicker"},
|
||||
date_format="%Y-%m-%d",
|
||||
time_format="%H:%M",
|
||||
|
||||
class NormalCaseForm(ModelForm):
|
||||
# Keep non-model input fields for age entry so users can provide a
|
||||
# value + unit (e.g. 6 months) which will be converted to canonical
|
||||
# days on save. The model now stores only `age_days`.
|
||||
age_value = forms.IntegerField(required=False, min_value=0)
|
||||
age_unit = forms.ChoiceField(required=False, choices=NormalCase.AGE_UNIT_CHOICES)
|
||||
|
||||
class Meta:
|
||||
model = NormalCase
|
||||
fields = ("examination", "modality", "notes")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper = FormHelper()
|
||||
# Do not render a <form> tag from crispy here because the template
|
||||
# already provides the surrounding form element (we inject this
|
||||
# fragment into a modal that contains the form). Rendering a second
|
||||
# <form> would nest forms and cause fields to be outside the
|
||||
# submitted form (HTMX would only send the outer form's fields).
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = False
|
||||
self.helper.layout = Layout(
|
||||
Div(
|
||||
Field("age_value", wrapper_class="col-md-3"),
|
||||
Field("age_unit", wrapper_class="col-md-3"),
|
||||
Field("examination", wrapper_class="col-md-3"),
|
||||
Field("modality", wrapper_class="col-md-3"),
|
||||
css_class="row",
|
||||
),
|
||||
required=False,
|
||||
help_text="The date the exam is due to start (time is optional)",
|
||||
)
|
||||
self.fields["end_date"] = SplitDateTimeFieldDefaultTimeEnd(
|
||||
widget=SplitDateTimeWidget(
|
||||
date_attrs={"type": "date", "class": "datepicker"},
|
||||
time_attrs={"type": "time", "class": "timepicker"},
|
||||
date_format="%Y-%m-%d",
|
||||
time_format="%H:%M",
|
||||
),
|
||||
required=False,
|
||||
help_text="The date the exam is due to start (time is optional)",
|
||||
Field("notes"),
|
||||
Submit("submit", "Save", css_class="btn btn-primary"),
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
# Get the unsaved Case instance
|
||||
instance = ModelForm.save(self, False)
|
||||
# Respect commit flag. Compute canonical `age_days` from the
|
||||
# provided age_value/age_unit and set it on the instance.
|
||||
instance = super().save(commit=False)
|
||||
age_value = self.cleaned_data.get("age_value")
|
||||
age_unit = self.cleaned_data.get("age_unit")
|
||||
if age_value is not None and age_unit:
|
||||
instance.age_days = NormalCase.to_days(age_value, age_unit)
|
||||
else:
|
||||
# If the user left fields blank, clear any previous value.
|
||||
instance.age_days = None
|
||||
|
||||
# Do we need to save all changes now?
|
||||
# if commit:
|
||||
if commit:
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
|
||||
@@ -269,9 +341,10 @@ class FindingForm(ModelForm):
|
||||
class Meta:
|
||||
model = Finding
|
||||
exclude = []
|
||||
|
||||
# Use the canonical FK in forms (if users want to mark this Finding
|
||||
# as an alias of another). The old `synonym` M2M has been removed.
|
||||
widgets = {
|
||||
"synonym": autocomplete.ModelSelect2Multiple(
|
||||
"canonical": autocomplete.ModelSelect2(
|
||||
url="atlas:finding-autocomplete"
|
||||
),
|
||||
}
|
||||
@@ -281,14 +354,31 @@ class StructureForm(ModelForm):
|
||||
class Meta:
|
||||
model = Structure
|
||||
exclude = []
|
||||
|
||||
widgets = {
|
||||
"synonym": autocomplete.ModelSelect2Multiple(
|
||||
"canonical": autocomplete.ModelSelect2(
|
||||
url="atlas:structure-autocomplete"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class SubspecialtyForm(ModelForm):
|
||||
class Meta:
|
||||
model = Subspecialty
|
||||
exclude = []
|
||||
|
||||
|
||||
class PresentationForm(ModelForm):
|
||||
class Meta:
|
||||
model = Presentation
|
||||
exclude = []
|
||||
|
||||
|
||||
class PathologicalProcessForm(ModelForm):
|
||||
class Meta:
|
||||
model = PathologicalProcess
|
||||
exclude = []
|
||||
|
||||
|
||||
class SeriesFindingForm(ModelForm):
|
||||
class Meta:
|
||||
model = SeriesFinding
|
||||
@@ -471,7 +561,7 @@ class CaseForm(ModelForm):
|
||||
self.collection = None
|
||||
if kwargs["initial"] is not None and "exams" in kwargs["initial"]:
|
||||
collections = kwargs["initial"].pop("exams")
|
||||
logging.debug(collections)
|
||||
logger.debug(collections)
|
||||
|
||||
self.collection = get_object_or_404(CaseCollection, pk=collections[0])
|
||||
|
||||
@@ -606,13 +696,13 @@ class CaseForm(ModelForm):
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
|
||||
logging.debug(f"{self.collection=}")
|
||||
logger.debug(f"{self.collection=}")
|
||||
|
||||
if self.collection is not None:
|
||||
logging.debug(f"{self.collection=}")
|
||||
logger.debug(f"{self.collection=}")
|
||||
case_no = self.collection.cases.count() + 1
|
||||
logging.debug(f"{case_no=}")
|
||||
logging.debug(f"{instance=}")
|
||||
logger.debug(f"{case_no=}")
|
||||
logger.debug(f"{instance=}")
|
||||
|
||||
# Create through model
|
||||
# cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
|
||||
@@ -640,7 +730,7 @@ CaseDifferentialFormSet = inlineformset_factory(
|
||||
class ResourceForm(ModelForm):
|
||||
class Meta:
|
||||
model = Resource
|
||||
exclude = ["author"]
|
||||
exclude = ["author", "created_date"]
|
||||
|
||||
widgets = {
|
||||
"description": Textarea(attrs={"cols": 80, "rows": 5}),
|
||||
@@ -787,7 +877,7 @@ class BaseReportAnswerForm(ModelForm):
|
||||
# "answer": "Write your answer in here."
|
||||
# }
|
||||
|
||||
def __init__(self, *args, case_detail, **kwargs):
|
||||
def __init__(self, *args, casedetail, **kwargs):
|
||||
super(BaseReportAnswerForm, self).__init__(*args, **kwargs)
|
||||
self.fields["answer"].required = False
|
||||
|
||||
@@ -803,12 +893,12 @@ class BaseQuestionAnswerForm(ModelForm):
|
||||
# "answer": "Write your answer in here."
|
||||
# }
|
||||
|
||||
def __init__(self, *args, case_detail, **kwargs):
|
||||
def __init__(self, *args, casedetail, **kwargs):
|
||||
super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs)
|
||||
# self.fields["json_answer"].schema = case_detail.question_schema
|
||||
if case_detail.question_schema is not None:
|
||||
# self.fields["json_answer"].schema = casedetail.question_schema
|
||||
if casedetail.question_schema is not None:
|
||||
self.fields["json_answer"] = JSONSchemaField(
|
||||
schema=case_detail.question_schema, options="schema/options.json"
|
||||
schema=casedetail.question_schema, options="schema/options.json"
|
||||
)
|
||||
self.fields["json_answer"].label = ""
|
||||
|
||||
@@ -969,6 +1059,37 @@ class ConditionAutocomplete(ModelAutocomplete):
|
||||
model = Condition
|
||||
search_attrs = ["name"]
|
||||
|
||||
@classmethod
|
||||
def get_items_from_keys(cls, keys, context_obj=None):
|
||||
"""Return items for given keys, ignoring empty strings.
|
||||
|
||||
The autocomplete frontend sometimes sends empty string keys which
|
||||
causes the ORM to raise ValueError when filtering numeric PK fields.
|
||||
Filter out falsy/blank keys before querying. If no valid keys remain
|
||||
return an empty queryset.
|
||||
"""
|
||||
# normalize and remove empty/blank values
|
||||
clean_keys = [k for k in keys if k is not None and str(k).strip() != ""]
|
||||
if not clean_keys:
|
||||
return cls.model.objects.none()
|
||||
|
||||
# try to convert numeric keys to ints where possible
|
||||
parsed_keys = []
|
||||
for k in clean_keys:
|
||||
try:
|
||||
parsed_keys.append(int(k))
|
||||
except Exception:
|
||||
parsed_keys.append(k)
|
||||
|
||||
qs = cls.model.objects.filter(pk__in=parsed_keys)
|
||||
|
||||
# Return a list of dicts matching the autocomplete core expectations
|
||||
# (items subscriptable by keys like 'key' / 'label').
|
||||
return [
|
||||
{"key": obj.pk, "label": getattr(obj, "name", str(obj)), "value": str(obj)}
|
||||
for obj in qs
|
||||
]
|
||||
|
||||
|
||||
class ConditionAutocompleteForm(Form):
|
||||
condition = ModelChoiceField(
|
||||
@@ -1010,7 +1131,7 @@ class SvelteJSONEditorWidgetOverride(SvelteJSONEditorWidget):
|
||||
template_name = "atlas/svelte_jsoneditor_widget_override.html"
|
||||
|
||||
|
||||
class CaseDetailForm(ModelForm):
|
||||
class CaseQuestionForm(ModelForm):
|
||||
class Meta:
|
||||
model = CaseDetail
|
||||
fields = ["question_schema", "question_answers"] # , "user"]
|
||||
@@ -1020,6 +1141,73 @@ class CaseDetailForm(ModelForm):
|
||||
"question_answers": SvelteJSONEditorWidgetOverride(),
|
||||
}
|
||||
|
||||
class CaseDetailForm(ModelForm):
|
||||
class Meta:
|
||||
model = CaseDetail
|
||||
fields = ["redact_history", "override_history"]
|
||||
|
||||
def __init__(self, *args, case_history: str = None, **kwargs):
|
||||
"""
|
||||
case_history: optional explicit history text to show (falls back to instance.case.history)
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Determine original history text (safe-escaped for HTML)
|
||||
if case_history is None:
|
||||
try:
|
||||
case_history = self.instance.case.history if self.instance and getattr(self.instance, "case", None) else ""
|
||||
except Exception:
|
||||
case_history = ""
|
||||
case_history_html = escape(case_history or "No history available.")
|
||||
|
||||
# Ensure override_history has a stable id we can reference from the inline script
|
||||
override_id = self.fields["override_history"].widget.attrs.get("id", "id_override_history")
|
||||
self.fields["override_history"].widget.attrs["id"] = override_id
|
||||
# Optionally make it a textarea style appearance if not already
|
||||
self.fields["override_history"].widget.attrs.setdefault("rows", 6)
|
||||
self.fields["override_history"].widget.attrs.setdefault("class", "form-control")
|
||||
|
||||
# Build crispy helper/layout embedding the original history and buttons tied to override_history
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
|
||||
# Inline HTML block with buttons and a script that copies/clears the override field.
|
||||
# The script references the explicit override_id above.
|
||||
history_block = f"""
|
||||
<details class="mb-3">
|
||||
<summary class="h6" style="cursor:pointer;"><i class="bi bi-info-circle"></i> Show original case history</summary>
|
||||
<div class="mt-2 p-2 bg-dark text-light border rounded">
|
||||
<label class="form-label fw-bold">Original history (read-only)</label>
|
||||
<div id="original-history" style="white-space: pre-wrap;">{case_history_html}</div>
|
||||
<div class="form-text text-secondary">Use the buttons below to copy this into the override field or clear the override.</div>
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-light" id="use-original-history">Copy original into override</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger ms-2" id="clear-override-history">Clear override</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {{
|
||||
var copyBtn = document.getElementById('use-original-history');
|
||||
var clearBtn = document.getElementById('clear-override-history');
|
||||
var original = '{case_history_html.replace("'", "\\'").replace("\\n", "\\\\n")}';
|
||||
var overrideField = document.getElementById('{override_id}');
|
||||
function setOverride(val) {{
|
||||
if(!overrideField) return; overrideField.value = val; overrideField.dispatchEvent(new Event('input',{{bubbles:true}})); }}
|
||||
if(copyBtn) copyBtn.addEventListener('click', function(e){{ setOverride(original); }});
|
||||
if(clearBtn) clearBtn.addEventListener('click', function(e){{ setOverride(''); }});
|
||||
}});
|
||||
</script>
|
||||
"""
|
||||
|
||||
# Compose layout: history block then the override field and redact checkbox
|
||||
self.helper.layout = Layout(
|
||||
HTML(history_block),
|
||||
Field("override_history"),
|
||||
Field("redact_history")
|
||||
)
|
||||
|
||||
|
||||
|
||||
class QuestionSchemaForm(ModelForm):
|
||||
class Meta:
|
||||
@@ -1042,23 +1230,6 @@ class PriorCaseForm(Form):
|
||||
|
||||
prior_visibility = CharField(max_length=2, required=True)
|
||||
|
||||
# def __init__(self, *args, **kwargs):
|
||||
# self.case_detail = kwargs.pop(
|
||||
# "case_detail"
|
||||
# ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
|
||||
|
||||
# super(PriorCaseForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# prior_cases = self.case_detail.case.get_all_prior_cases()
|
||||
|
||||
# if not prior_cases:
|
||||
# prior_cases = Case.objects.none()
|
||||
|
||||
# self.fields["case"] = ChoiceField(
|
||||
# required=False,
|
||||
# # widget=Select(verbose_name="Series", is_stacked=False),
|
||||
# )
|
||||
|
||||
|
||||
class CaseSeriesForm(forms.ModelForm):
|
||||
def __init__(self, *args, queryset, user=None, is_atlas_editor=False, **kwargs):
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.4 on 2025-09-15 09:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0074_alter_seriesimage_image_md5_hash'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casedetail',
|
||||
name='override_history',
|
||||
field=models.TextField(blank=True, help_text='This will override the case history for the purpose of the exam/collection.', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casedetail',
|
||||
name='redact_history',
|
||||
field=models.BooleanField(default=False, help_text='Set to true if the history should be redacted whilst taking the case.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-13 08:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0075_casedetail_override_history_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='question_time_limit',
|
||||
field=models.PositiveIntegerField(help_text='Time limit for answering questions in seconds.', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-13 08:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0076_casecollection_question_time_limit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='casecollection',
|
||||
name='question_time_limit',
|
||||
field=models.PositiveIntegerField(blank=True, help_text='Time limit for answering questions in seconds.', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-13 08:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0077_alter_casecollection_question_time_limit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cidreportanswer',
|
||||
name='started_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cidreportanswer',
|
||||
name='submitted_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userreportanswer',
|
||||
name='started_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userreportanswer',
|
||||
name='submitted_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-13 12:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0078_cidreportanswer_started_at_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='prerequisites',
|
||||
field=models.ManyToManyField(blank=True, help_text='Collections that must be completed before this collection can be taken', related_name='dependents', to='atlas.casecollection'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-13 21:43
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0079_casecollection_prerequisites'),
|
||||
('generic', '0029_add_unique_constraint_supervisor_email'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NormalCase',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('age_years', models.PositiveSmallIntegerField(blank=True, help_text='Age of the patient at scan in years (rounded to nearest year).', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(150)])),
|
||||
('added_date', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('notes', models.TextField(blank=True, null=True)),
|
||||
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='added_normals', to=settings.AUTH_USER_MODEL)),
|
||||
('case', models.OneToOneField(help_text='The Case that is marked as a normal example.', on_delete=django.db.models.deletion.CASCADE, related_name='normal_case', to='atlas.case')),
|
||||
('examination', models.ForeignKey(blank=True, help_text='Primary examination for this normal case (optional).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='normal_cases', to='generic.examination')),
|
||||
('modality', models.ForeignKey(blank=True, help_text='Primary modality for this normal case (optional).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='normal_cases', to='generic.modality')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-added_date'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-13 22:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0080_normalcase'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='normalcase',
|
||||
name='age_years',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='normalcase',
|
||||
name='age_days',
|
||||
field=models.PositiveIntegerField(blank=True, help_text='Patient age at scan in days (canonical).', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-14 20:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0081_remove_normalcase_age_years_normalcase_age_days'),
|
||||
('generic', '0029_add_unique_constraint_supervisor_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='sites',
|
||||
field=models.ManyToManyField(blank=True, help_text='The site(s) this resource is associated with (if any).', related_name='resources', to='generic.site'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='subspecialty',
|
||||
field=models.ManyToManyField(blank=True, help_text='Subspecialty or subspecialties this resource relates to.', to='atlas.subspecialty'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-14 21:02
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0082_resource_sites_resource_subspecialty'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='created_date',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='modified_date',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-15 22:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0083_resource_created_date_resource_modified_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='open_access',
|
||||
field=models.BooleanField(default=True, help_text='If true the resource is available to all users, otherwise only in the context of cases.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-17 10:28
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0084_resource_open_access'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='caseprior',
|
||||
old_name='case_detail',
|
||||
new_name='casedetail',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='caseprior',
|
||||
unique_together={('casedetail', 'prior_case')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Condition = apps.get_model("atlas", "Condition")
|
||||
# Build connected components using existing M2M 'synonym' links
|
||||
visited = set()
|
||||
|
||||
for cond in Condition.objects.all():
|
||||
if cond.pk in visited:
|
||||
continue
|
||||
# BFS/DFS to collect component
|
||||
stack = [cond]
|
||||
comp = []
|
||||
while stack:
|
||||
c = stack.pop()
|
||||
if c.pk in visited:
|
||||
continue
|
||||
visited.add(c.pk)
|
||||
comp.append(c)
|
||||
# access existing M2M relations
|
||||
for s in c.synonym.all():
|
||||
if s.pk not in visited:
|
||||
stack.append(s)
|
||||
|
||||
# choose canonical: prefer primary==True else lowest pk
|
||||
canon = None
|
||||
for c in comp:
|
||||
if getattr(c, "primary", False):
|
||||
canon = c
|
||||
break
|
||||
if not canon:
|
||||
canon = min(comp, key=lambda x: x.pk)
|
||||
|
||||
# set canonical for all non-canonical members
|
||||
for c in comp:
|
||||
if c.pk != canon.pk:
|
||||
c.canonical_id = canon.pk
|
||||
c.save()
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
Condition = apps.get_model("atlas", "Condition")
|
||||
for c in Condition.objects.all():
|
||||
if c.canonical_id:
|
||||
c.canonical_id = None
|
||||
c.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("atlas", "0085_rename_case_detail_caseprior_casedetail_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="condition",
|
||||
name="canonical",
|
||||
field=models.ForeignKey(
|
||||
related_name="aliases",
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
to="atlas.condition",
|
||||
blank=True,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(forwards, backwards),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-20 22:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0086_add_canonical'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='condition',
|
||||
name='canonical',
|
||||
field=models.ForeignKey(blank=True, help_text='If set, this Condition is an alias and points to the canonical Condition.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.condition'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-24 10:17
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0087_alter_condition_canonical'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='finding',
|
||||
name='primary',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='finding',
|
||||
name='synonym',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='structure',
|
||||
name='primary',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='structure',
|
||||
name='synonym',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='finding',
|
||||
name='canonical',
|
||||
field=models.ForeignKey(blank=True, help_text='If set, this Finding is an alias and points to the canonical Finding.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.finding'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='structure',
|
||||
name='canonical',
|
||||
field=models.ForeignKey(blank=True, help_text='If set, this Structure is an alias and points to the canonical Structure.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.structure'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-24 10:31
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0088_remove_finding_primary_remove_finding_synonym_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='condition',
|
||||
name='primary',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='condition',
|
||||
name='synonym',
|
||||
),
|
||||
]
|
||||
+594
-41
@@ -59,6 +59,7 @@ from generic.models import (
|
||||
SeriesBase,
|
||||
SeriesImageBase,
|
||||
Modality,
|
||||
Site,
|
||||
UserUserGroup,
|
||||
)
|
||||
|
||||
@@ -71,6 +72,7 @@ from django.utils import timezone
|
||||
import reversion
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
|
||||
@@ -108,36 +110,121 @@ class SynMixin(object):
|
||||
# abstract = True
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.primary:
|
||||
# Determine whether this record should be displayed as the
|
||||
# canonical/primary entry. Prefer the new canonical FK pattern
|
||||
# (`canonical is None` means this object is the master). Fallback
|
||||
# to legacy `primary` boolean when present.
|
||||
# If the model supports canonical FK, consider canonical==None as
|
||||
# the primary/master entry. For models without canonical (legacy
|
||||
# edge-cases), treat the instance as primary by default.
|
||||
if hasattr(self, "canonical"):
|
||||
try:
|
||||
if self.canonical is None or self.canonical == self:
|
||||
return self.name
|
||||
else:
|
||||
return f"{self.name} [syn]"
|
||||
except Exception:
|
||||
# defensive fallback
|
||||
return f"{self.name} [syn]"
|
||||
|
||||
def get_old_str(self) -> str:
|
||||
if self.primary:
|
||||
if self.synonym.count() == 0:
|
||||
# No canonical support: assume primary
|
||||
return self.name
|
||||
|
||||
def get_old_str(self) -> str:
|
||||
# Prefer using a model-specific get_synonyms (e.g. Condition.get_synonyms)
|
||||
try:
|
||||
syns = self.get_synonyms()
|
||||
except Exception:
|
||||
syns = None
|
||||
# If the model supports canonical, only canonical==None entries
|
||||
# should be rendered as the primary name. For models without
|
||||
# canonical, treat instance as primary.
|
||||
if hasattr(self, "canonical"):
|
||||
try:
|
||||
is_primary = self.canonical is None or self.canonical == self
|
||||
except Exception:
|
||||
is_primary = False
|
||||
else:
|
||||
synonyms = ",".join([i.name for i in self.synonym.all()])
|
||||
is_primary = True
|
||||
|
||||
if is_primary:
|
||||
if syns is None:
|
||||
synonyms_field = getattr(self, "synonym", None)
|
||||
if synonyms_field is None or (hasattr(synonyms_field, "count") and synonyms_field.count() == 0):
|
||||
return self.name
|
||||
synonyms = ",".join([i.name for i in synonyms_field.all()])
|
||||
return f"{self.name} ({synonyms})"
|
||||
else:
|
||||
if not syns:
|
||||
return self.name
|
||||
synonyms = ",".join([i.name for i in syns])
|
||||
return f"{self.name} ({synonyms})"
|
||||
|
||||
return f"{self.name} [syn]"
|
||||
|
||||
def get_synonym(self):
|
||||
if self.primary:
|
||||
# Return a readable synonym string. For models that implement
|
||||
# get_synonyms (Condition with canonical/aliases), use that.
|
||||
try:
|
||||
syns = self.get_synonyms()
|
||||
except Exception:
|
||||
syns = None
|
||||
# If canonical is present and this instance is the master, mark Primary
|
||||
if hasattr(self, "canonical"):
|
||||
try:
|
||||
if self.canonical is None or self.canonical == self:
|
||||
return "[Primary]"
|
||||
else:
|
||||
s = self.synonym.filter(primary=True).values_list("name", flat=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if syns is None:
|
||||
synonyms_field = getattr(self, "synonym", None)
|
||||
if synonyms_field is None:
|
||||
return ""
|
||||
# If the old M2M exists, prefer those marked primary if such a
|
||||
# attribute exists on the related objects.
|
||||
try:
|
||||
# Select all synonym names; legacy `primary` flag removed.
|
||||
s = synonyms_field.values_list("name", flat=True)
|
||||
except Exception:
|
||||
s = []
|
||||
return ", ".join(s)
|
||||
|
||||
# prefer primary names among synonyms if present
|
||||
primary_names = []
|
||||
for i in syns:
|
||||
# Prefer items that are canonical/master in the group
|
||||
if getattr(i, "canonical", None) is None:
|
||||
primary_names.append(i.name)
|
||||
if primary_names:
|
||||
return ", ".join(primary_names)
|
||||
return ", ".join([i.name for i in syns])
|
||||
|
||||
def get_synonym_link(self):
|
||||
if self.primary:
|
||||
try:
|
||||
syns = self.get_synonyms()
|
||||
except Exception:
|
||||
syns = None
|
||||
# Prefer canonical/master check
|
||||
if hasattr(self, "canonical"):
|
||||
try:
|
||||
if self.canonical is None or self.canonical == self:
|
||||
return "[Primary]"
|
||||
else:
|
||||
syns = self.synonym.filter(primary=True)
|
||||
return ", ".join(
|
||||
[f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns]
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if syns is None:
|
||||
# fall back to all M2M synonyms (not just primary) for models
|
||||
# that still use the old synonym field (e.g. Finding, Structure)
|
||||
synonyms_field = getattr(self, "synonym", None)
|
||||
if synonyms_field is None:
|
||||
return ""
|
||||
syns_qs = synonyms_field.all()
|
||||
items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns_qs]
|
||||
return ", ".join(items)
|
||||
|
||||
# render links for synonyms/canonical group
|
||||
items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns]
|
||||
return ", ".join(items)
|
||||
|
||||
def get_link(self):
|
||||
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
|
||||
@@ -145,21 +232,49 @@ class SynMixin(object):
|
||||
|
||||
class Finding(SynMixin, models.Model):
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
synonym = models.ManyToManyField("self", blank=True)
|
||||
|
||||
primary = models.BooleanField(default="True")
|
||||
# New canonical/alias field: if set, this Finding is an alias and points
|
||||
# to the canonical/master Finding. We remove the old M2M `synonym` and
|
||||
# `primary` boolean in favour of this single canonical FK.
|
||||
canonical = models.ForeignKey(
|
||||
"self",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="aliases",
|
||||
help_text="If set, this Finding is an alias and points to the canonical Finding.",
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("atlas:finding_detail", kwargs={"pk": self.pk})
|
||||
|
||||
@property
|
||||
def canonical_finding(self):
|
||||
return self.canonical if self.canonical else self
|
||||
|
||||
def get_synonyms(self):
|
||||
"""Return other Findings that are aliases/synonyms for this concept.
|
||||
|
||||
Behaviour mirrors Condition.get_synonyms: if this Finding is an alias
|
||||
(has canonical set) return the canonical and other aliases (excluding
|
||||
self). If this Finding is canonical, return all aliases (excluding
|
||||
self).
|
||||
"""
|
||||
master = self.canonical_finding
|
||||
qs = Finding.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
|
||||
return qs
|
||||
|
||||
|
||||
class Condition(SynMixin, models.Model):
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
|
||||
synonym = models.ManyToManyField(
|
||||
# New canonical/alias field (Option B): if set, this Condition is an alias
|
||||
# and points to the canonical/master Condition.
|
||||
canonical = models.ForeignKey(
|
||||
"self",
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Use if a direct synonym for the condition exists, e.g. 'Wegener granulomatosis' and 'Granulomatosis with Polyangitis'.",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="aliases",
|
||||
help_text="If set, this Condition is an alias and points to the canonical Condition.",
|
||||
)
|
||||
parent = models.ManyToManyField(
|
||||
"self",
|
||||
@@ -170,10 +285,10 @@ class Condition(SynMixin, models.Model):
|
||||
help_text="Use if the condition could be considered a subset of another, e.g. 'Alzheimer disease' and 'Dementia'.",
|
||||
)
|
||||
|
||||
primary = models.BooleanField(
|
||||
default="True",
|
||||
help_text="Sets if this should be the canonical item, all other synonyms will then redirect to this by default.",
|
||||
)
|
||||
# Legacy fields `synonym` (M2M) and `primary` (boolean) have been removed.
|
||||
# Canonical/aliases are represented by the `canonical` ForeignKey and
|
||||
# the reverse `aliases` related_name. Use `get_synonyms()` /
|
||||
# `canonical_condition` to access canonical groups.
|
||||
|
||||
subspecialty = models.ManyToManyField(
|
||||
"subspecialty",
|
||||
@@ -196,6 +311,23 @@ class Condition(SynMixin, models.Model):
|
||||
def get_absolute_url(self):
|
||||
return reverse("atlas:condition_detail", kwargs={"pk": self.pk})
|
||||
|
||||
@property
|
||||
def canonical_condition(self):
|
||||
"""Return the canonical/master condition for this Condition (self if none)."""
|
||||
return self.canonical if self.canonical else self
|
||||
|
||||
def get_synonyms(self):
|
||||
"""Return other Conditions that are synonyms/aliases for this concept.
|
||||
|
||||
Behaviour:
|
||||
- If this Condition is an alias (canonical set), return the canonical and
|
||||
any other aliases (excluding self).
|
||||
- If this Condition is canonical, return all aliases (and exclude self).
|
||||
"""
|
||||
master = self.canonical_condition
|
||||
qs = Condition.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
|
||||
return qs
|
||||
|
||||
def get_children(self):
|
||||
return self.child.all()
|
||||
|
||||
@@ -274,14 +406,28 @@ class Differential(models.Model):
|
||||
|
||||
class Structure(SynMixin, models.Model):
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
|
||||
synonym = models.ManyToManyField("self", blank=True)
|
||||
|
||||
primary = models.BooleanField(default="True")
|
||||
# Migrate to canonical/aliases model like Condition and Finding
|
||||
canonical = models.ForeignKey(
|
||||
"self",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="aliases",
|
||||
help_text="If set, this Structure is an alias and points to the canonical Structure.",
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("atlas:structure_detail", kwargs={"pk": self.pk})
|
||||
|
||||
@property
|
||||
def canonical_structure(self):
|
||||
return self.canonical if self.canonical else self
|
||||
|
||||
def get_synonyms(self):
|
||||
master = self.canonical_structure
|
||||
qs = Structure.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
|
||||
return qs
|
||||
|
||||
|
||||
@reversion.register
|
||||
class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
@@ -566,14 +712,18 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
|
||||
return False
|
||||
|
||||
def get_series_images_nested(self, as_json=True):
|
||||
def get_series_images_nested(self, as_json: bool =True, exclude_series_ids: None | list[int] = None):
|
||||
"""
|
||||
Returns a list of lists, where each inner list contains the image URLs
|
||||
for a single series in this case, in the order of the series.
|
||||
"""
|
||||
|
||||
series_qs = self.series.all()
|
||||
if exclude_series_ids is not None:
|
||||
series_qs = self.series.exclude(id__in=exclude_series_ids)
|
||||
images = [
|
||||
[image.image.url for image in series.images.all()]
|
||||
for series in self.series.all()
|
||||
for series in series_qs.order_by('seriesdetail__sort_order').prefetch_related('images')
|
||||
]
|
||||
|
||||
if as_json:
|
||||
@@ -581,6 +731,179 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
else:
|
||||
return images
|
||||
|
||||
def get_case_named_stacks(self):
|
||||
def build_stacks_for(case_obj, prefix=None):
|
||||
logger.debug(f"Building stacks for case {case_obj.pk} with prefix '{prefix}'")
|
||||
stacks = []
|
||||
for series in case_obj.get_ordered_series():
|
||||
images = [f"{REMOTE_URL}{img.image.url}" for img in series.get_images()]
|
||||
name = f"{prefix}: {series}" if prefix else str(series)
|
||||
stacks.append({"name": name, "imageIds": images})
|
||||
return stacks
|
||||
|
||||
results = []
|
||||
|
||||
logger.debug(f"Building stacks for case {self.pk}")
|
||||
|
||||
# main case entry
|
||||
results.append(
|
||||
{
|
||||
"caseId": f"",
|
||||
"studyId": f"Current Case",
|
||||
"stacks": build_stacks_for(self, prefix=None),
|
||||
}
|
||||
)
|
||||
|
||||
return json.dumps(results)
|
||||
|
||||
|
||||
class NormalCase(models.Model):
|
||||
"""Marks a Case as a 'normal' example used in the Atlas normals section.
|
||||
|
||||
This version stores a canonical `age_days` integer (patient age in days).
|
||||
We keep the examination/modality metadata and authored fields. The
|
||||
age fields previously stored (`age_years`, `age_value`, `age_unit`) have
|
||||
been removed in favour of the single canonical `age_days` value.
|
||||
"""
|
||||
case = models.OneToOneField(
|
||||
Case,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="normal_case",
|
||||
help_text="The Case that is marked as a normal example.",
|
||||
)
|
||||
|
||||
# Canonical internal representation: patient age in days
|
||||
age_days = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Patient age at scan in days (canonical).",
|
||||
)
|
||||
|
||||
# Helpful constants for converting/displaying ages. These are not
|
||||
# stored directly on the model but are used by forms/views/templates.
|
||||
AGE_UNIT_YEARS = "years"
|
||||
AGE_UNIT_MONTHS = "months"
|
||||
AGE_UNIT_WEEKS = "weeks"
|
||||
AGE_UNIT_DAYS = "days"
|
||||
AGE_UNIT_CHOICES = [
|
||||
(AGE_UNIT_YEARS, "years"),
|
||||
(AGE_UNIT_MONTHS, "months"),
|
||||
(AGE_UNIT_WEEKS, "weeks"),
|
||||
(AGE_UNIT_DAYS, "days"),
|
||||
]
|
||||
|
||||
examination = models.ForeignKey(
|
||||
Examination,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="normal_cases",
|
||||
help_text="Primary examination for this normal case (optional).",
|
||||
)
|
||||
|
||||
modality = models.ForeignKey(
|
||||
Modality,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="normal_cases",
|
||||
help_text="Primary modality for this normal case (optional).",
|
||||
)
|
||||
|
||||
added_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="added_normals",
|
||||
)
|
||||
|
||||
added_date = models.DateTimeField(default=timezone.now)
|
||||
|
||||
notes = models.TextField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-added_date"]
|
||||
|
||||
def __str__(self):
|
||||
return f"Normal: {self.case} ({self.display_age()})"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.case.get_absolute_url()
|
||||
|
||||
@staticmethod
|
||||
def to_days(value: int | float, unit: str) -> int | None:
|
||||
"""Convert a numeric value + unit into integer days (rounded).
|
||||
|
||||
Examples:
|
||||
to_days(2, 'years') -> ~730
|
||||
to_days(6, 'months') -> ~183
|
||||
"""
|
||||
try:
|
||||
if value is None or unit is None:
|
||||
return None
|
||||
v = float(value)
|
||||
if unit == NormalCase.AGE_UNIT_YEARS:
|
||||
return int(round(v * 365.25))
|
||||
if unit == NormalCase.AGE_UNIT_MONTHS:
|
||||
return int(round(v * 30.4375))
|
||||
if unit == NormalCase.AGE_UNIT_WEEKS:
|
||||
return int(round(v * 7))
|
||||
if unit == NormalCase.AGE_UNIT_DAYS:
|
||||
return int(round(v))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def days_to_value_unit(days: int | None) -> tuple[int | None, str | None]:
|
||||
"""Convert canonical days into a (value, unit) tuple for display.
|
||||
|
||||
Prefer years for ages >= 365 days, months for ages >= 30 days,
|
||||
weeks for >=7 days, else days.
|
||||
"""
|
||||
if days is None:
|
||||
return (None, None)
|
||||
try:
|
||||
d = int(days)
|
||||
if d >= 365:
|
||||
yrs = int(round(d / 365.25))
|
||||
return (yrs, NormalCase.AGE_UNIT_YEARS)
|
||||
if d >= 30:
|
||||
months = int(round(d / 30.4375))
|
||||
return (months, NormalCase.AGE_UNIT_MONTHS)
|
||||
if d >= 7:
|
||||
weeks = int(round(d / 7))
|
||||
return (weeks, NormalCase.AGE_UNIT_WEEKS)
|
||||
return (d, NormalCase.AGE_UNIT_DAYS)
|
||||
except Exception:
|
||||
return (None, None)
|
||||
|
||||
def display_age(self) -> str:
|
||||
"""Return a human friendly age string derived from age_days."""
|
||||
if self.age_days is None:
|
||||
return "age unknown"
|
||||
val, unit = NormalCase.days_to_value_unit(self.age_days)
|
||||
if val is None or unit is None:
|
||||
return "age unknown"
|
||||
# abbreviate years to 'y' for backwards compatibility where helpful
|
||||
if unit == NormalCase.AGE_UNIT_YEARS:
|
||||
return f"{val}y"
|
||||
return f"{val} {unit}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# If examination/modality not specified, try to infer from the first series
|
||||
try:
|
||||
if (self.examination is None or self.modality is None) and self.case.series.exists():
|
||||
first_series = self.case.series.first()
|
||||
if self.examination is None and getattr(first_series, 'examination', None) is not None:
|
||||
self.examination = first_series.examination
|
||||
if self.modality is None and getattr(first_series, 'modality', None) is not None:
|
||||
self.modality = first_series.modality
|
||||
except Exception:
|
||||
# Be defensive: if anything goes wrong whilst inferring, continue and save as-is
|
||||
pass
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def extract_image_dicom_json_from_ds(ds, url, image_index):
|
||||
to_keep = [
|
||||
@@ -914,6 +1237,21 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
||||
|
||||
feedback_once_collection_complete = models.BooleanField(default=True, help_text="If true feedback is only given once the collection is complete. If false feedback is given after each case.")
|
||||
|
||||
question_time_limit = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Time limit for answering questions in seconds."
|
||||
)
|
||||
|
||||
# Collections that must be completed before this collection can be taken
|
||||
prerequisites = models.ManyToManyField(
|
||||
"self",
|
||||
blank=True,
|
||||
symmetrical=False,
|
||||
related_name="dependents",
|
||||
help_text="Collections that must be completed before this collection can be taken",
|
||||
)
|
||||
|
||||
class COLLECTION_TYPE_CHOICES(models.TextChoices):
|
||||
REVIEW = (
|
||||
"REV",
|
||||
@@ -1071,6 +1409,49 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
||||
kwargs={"pk": self.pk, "case_number": cases.index(case)},
|
||||
)
|
||||
|
||||
def check_user_can_take(self, cid, passcode, user=None, active_only=True):
|
||||
"""
|
||||
Extend base check_user_can_take to also require completion of any
|
||||
prerequisite collections.
|
||||
"""
|
||||
# Perform the normal access checks first
|
||||
super().check_user_can_take(cid, passcode, user=user, active_only=active_only)
|
||||
|
||||
# If there are prerequisites, the user (or CID) must have completed them
|
||||
if not self.prerequisites.exists():
|
||||
return
|
||||
|
||||
for prereq in self.prerequisites.all():
|
||||
# Look up any existing exam record for this user/cid on the prerequisite
|
||||
ct = ContentType.objects.get_for_model(prereq)
|
||||
exam_record = None
|
||||
if cid is not None:
|
||||
# Find CidUser by cid
|
||||
try:
|
||||
cid_user = CidUser.objects.filter(cid=cid).first()
|
||||
except Exception:
|
||||
cid_user = None
|
||||
|
||||
if cid_user is None:
|
||||
exam_record = None
|
||||
else:
|
||||
exam_record = CidUserExam.objects.filter(
|
||||
content_type=ct, object_id=prereq.pk, cid_user=cid_user
|
||||
).first()
|
||||
else:
|
||||
# Check for a normal user_user exam record
|
||||
exam_record = CidUserExam.objects.filter(
|
||||
content_type=ct, object_id=prereq.pk, user_user=user
|
||||
).first()
|
||||
|
||||
if exam_record is None or not getattr(exam_record, "completed", False):
|
||||
# Not allowed to take this collection until prereq completed
|
||||
# Raise the PrerequisiteRequired exception including the prereq object
|
||||
raise PrerequisiteRequired(
|
||||
f"Collection not available until prerequisite '{prereq.name}' is completed.",
|
||||
prereq=prereq,
|
||||
)
|
||||
|
||||
def get_ohif_dicom_json(self, case_title_as_patient_name=True):
|
||||
studies = []
|
||||
for n, case in enumerate(self.cases.all()):
|
||||
@@ -1190,12 +1571,29 @@ class CaseDetail(models.Model):
|
||||
|
||||
sort_order = models.IntegerField(default=1000)
|
||||
|
||||
redact_history = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Set to true if the history should be redacted whilst taking the case."
|
||||
)
|
||||
|
||||
override_history = models.TextField(
|
||||
null=True, blank=True,
|
||||
help_text="This will override the case history for the purpose of the exam/collection."
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ("sort_order",)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.case} -> {self.collection}"
|
||||
|
||||
|
||||
def get_case_index(self):
|
||||
"""Returns the question index (number) in the collection (0 indexed)"""
|
||||
case_details = list(self.collection.casedetail_set.all().order_by("sort_order"))
|
||||
|
||||
return case_details.index(self)
|
||||
|
||||
def get_question_schema(self):
|
||||
return json.dumps(self.question_schema)
|
||||
|
||||
@@ -1210,11 +1608,95 @@ class CaseDetail(models.Model):
|
||||
except UserReportAnswer.DoesNotExist:
|
||||
return None
|
||||
|
||||
def get_cid_answers(self, cid):
|
||||
"""Returns the cid users answers as a json string"""
|
||||
try:
|
||||
return CidReportAnswer.objects.get(question=self, cid=cid)
|
||||
except CidReportAnswer.DoesNotExist:
|
||||
return None
|
||||
|
||||
def default_viewerstate_string(self):
|
||||
return json.dumps(self.default_viewerstate) if self.default_viewerstate else "{}"
|
||||
|
||||
def get_history_pre(self):
|
||||
if self.collection.show_history_pre:
|
||||
if self.redact_history:
|
||||
return "[Redacted]"
|
||||
if self.override_history and self.override_history != "":
|
||||
return self.override_history
|
||||
return self.case.history or "No history provided"
|
||||
return ""
|
||||
|
||||
def get_case_series_nested(self, include_priors=True):
|
||||
case_series_images = self.case.get_series_images_nested(as_json=False)
|
||||
|
||||
if include_priors:
|
||||
logger.debug(f"Checking for prior cases for case {self.case}")
|
||||
logger.debug(f"Found {self.case.prior_case.count()} prior cases for case {self.case}")
|
||||
|
||||
for prior in self.caseprior_set.all():
|
||||
logger.debug(f"Adding prior case {prior.prior_case} to case {self.case}")
|
||||
case_series_images.extend(prior.prior_case.get_series_images_nested(as_json=False))
|
||||
|
||||
return json.dumps(case_series_images)
|
||||
|
||||
def get_case_named_stacks(self, include_priors=True):
|
||||
def build_stacks_for(case_obj, prefix=None):
|
||||
stacks = []
|
||||
for series in case_obj.get_ordered_series():
|
||||
images = [f"{REMOTE_URL}{img.image.url}" for img in series.images.all()]
|
||||
name = f"{prefix}: {series}" if prefix else str(series)
|
||||
stacks.append({"name": name, "imageIds": images})
|
||||
return stacks
|
||||
|
||||
results = []
|
||||
|
||||
# main case entry
|
||||
results.append(
|
||||
{
|
||||
"caseId": f"CASE-{self.case.pk}",
|
||||
"studyId": f"Current Case",
|
||||
"stacks": build_stacks_for(self.case, prefix=None),
|
||||
}
|
||||
)
|
||||
|
||||
# include priors as separate entries
|
||||
if include_priors:
|
||||
for prior in self.caseprior_set.all():
|
||||
prior_case = prior.prior_case
|
||||
results.append(
|
||||
{
|
||||
"caseId": f"CASE-{prior_case.pk}",
|
||||
"studyId": f"Prior: {prior.relation_text}",
|
||||
"stacks": build_stacks_for(prior_case, prefix="Prior"),
|
||||
}
|
||||
)
|
||||
|
||||
return json.dumps(results)
|
||||
|
||||
def render_example_form(self, request=None):
|
||||
"""Build and return a rendered HTML snippet for the example answers form.
|
||||
|
||||
If `request` is provided it will be passed to the template renderer so
|
||||
csrf tokens and other context processors work correctly. The returned
|
||||
HTML contains the json-editor widget (the `json_answer` field) and a
|
||||
submit button for saving example answers.
|
||||
"""
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.safestring import mark_safe
|
||||
from .forms import JsonAnswerForm
|
||||
|
||||
post_data = {}
|
||||
# Ensure we pass the stored answers into the form so the widget is populated
|
||||
post_data["json_answer"] = json.dumps(self.question_answers) if self.question_answers is not None else json.dumps({})
|
||||
|
||||
form = JsonAnswerForm(post_data, question_schema=self.question_schema)
|
||||
|
||||
html = render_to_string("atlas/_rendered_example_form.html", {"form": form}, request=request)
|
||||
return mark_safe(html)
|
||||
|
||||
class CasePrior(models.Model):
|
||||
case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
|
||||
casedetail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
|
||||
prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case")
|
||||
|
||||
relation_text = models.CharField(max_length=255, blank=True, help_text="Text to describe the relationship between the cases")
|
||||
@@ -1233,10 +1715,10 @@ class CasePrior(models.Model):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("case_detail", "prior_case")
|
||||
unique_together = ("casedetail", "prior_case")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.case_detail.case} -> {self.prior_case}"
|
||||
return f"{self.casedetail.case} -> {self.prior_case}"
|
||||
|
||||
class BaseReportAnswer(models.Model):
|
||||
question = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
|
||||
@@ -1255,6 +1737,10 @@ class BaseReportAnswer(models.Model):
|
||||
)
|
||||
|
||||
completed = models.BooleanField(default=False)
|
||||
# Timestamp when the user first loaded the question (started answering)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
# Timestamp when the answer was submitted/saved
|
||||
submitted_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean()
|
||||
@@ -1276,18 +1762,25 @@ class BaseReportAnswer(models.Model):
|
||||
raise ValueError("No cid or user specified")
|
||||
|
||||
def get_correct_json_answers(self):
|
||||
logger.debug(f"Getting correct json answers for question {self.question.pk}")
|
||||
if self.question.question_schema is None or not "properties" in self.question.question_schema:
|
||||
return []
|
||||
answers = []
|
||||
for name, value in self.question.question_schema["properties"].items():
|
||||
logger.debug(f"Processing question property '{name}' with schema {value}")
|
||||
|
||||
try:
|
||||
user_answer = self.json_answer[name]
|
||||
except KeyError: # If the schema has changed?
|
||||
# Safely retrieve the user's answer and the stored correct answer.
|
||||
# json_answer or question_answers may be None (or not a dict) if not set,
|
||||
# so check before subscripting to avoid TypeError.
|
||||
if isinstance(self.json_answer, dict):
|
||||
user_answer = self.json_answer.get(name, "")
|
||||
else:
|
||||
user_answer = ""
|
||||
try:
|
||||
correct_answer = self.question.question_answers[name]
|
||||
except KeyError: # If the schema has changed?
|
||||
|
||||
qa = getattr(self.question, "question_answers", None)
|
||||
if isinstance(qa, dict):
|
||||
correct_answer = qa.get(name, "")
|
||||
else:
|
||||
correct_answer = ""
|
||||
|
||||
match value:
|
||||
@@ -1308,6 +1801,22 @@ class BaseReportAnswer(models.Model):
|
||||
|
||||
return answers
|
||||
|
||||
#def get_marked_answers_html(self):
|
||||
# """Returns an HTML representation of the users answers with correct answers highlighted"""
|
||||
# html = "<div class='json-answers'>"
|
||||
# for value, user_answer, correct_answer, answer_is_correct, automark in self.get_correct_json_answers():
|
||||
# if automark:
|
||||
# if answer_is_correct:
|
||||
# html += f"<div class='answer correct'>{user_answer}</div>"
|
||||
# else:
|
||||
# html += f"<div class='answer incorrect'>Your answer: {user_answer}<br/>Correct answer: {correct_answer}</div>"
|
||||
# else:
|
||||
# html += f"<div class='answer unmarked'>Your answer: {user_answer} (Not auto-marked)</div>"
|
||||
|
||||
# html += "</div>"
|
||||
|
||||
# return format_html(html)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -1518,6 +2027,27 @@ class Resource(models.Model, AuthorMixin):
|
||||
help_text="Author of the resource",
|
||||
related_name="resources",
|
||||
)
|
||||
created_date = models.DateTimeField(default=timezone.now)
|
||||
modified_date = models.DateTimeField(auto_now=True)
|
||||
# Link resources to one or more Sites (from generic.Site)
|
||||
sites = models.ManyToManyField(
|
||||
'generic.Site',
|
||||
blank=True,
|
||||
help_text='The site(s) this resource is associated with (if any).',
|
||||
related_name='resources',
|
||||
)
|
||||
|
||||
# Categorise resources by subspecialty
|
||||
subspecialty = models.ManyToManyField(
|
||||
Subspecialty,
|
||||
blank=True,
|
||||
help_text='Subspecialty or subspecialties this resource relates to.',
|
||||
)
|
||||
|
||||
open_access = models.BooleanField(
|
||||
default=True,
|
||||
help_text="If true the resource is available to all users, otherwise only in the context of cases."
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
@@ -1545,7 +2075,18 @@ class Resource(models.Model, AuthorMixin):
|
||||
else:
|
||||
html = self.name
|
||||
|
||||
return format_html("<details class='resource-block'><summary>{}</summary>{}</details>", self.name, mark_safe(html))
|
||||
meta = ''
|
||||
if self.sites.exists():
|
||||
meta += ' Sites: ' + ', '.join([s.short_code or s.full_name for s in self.sites.all()])
|
||||
if self.subspecialty.exists():
|
||||
meta += ' Subspecialty: ' + ', '.join([str(s) for s in self.subspecialty.all()])
|
||||
|
||||
return format_html(
|
||||
"<details class='resource-block'><summary>{}</summary><div>{}</div><div class='text-muted small'>{}</div></details>",
|
||||
self.name,
|
||||
mark_safe(html),
|
||||
meta,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1582,3 +2123,15 @@ class QuestionSchema(models.Model, AuthorMixin):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "{}".format(self.name)
|
||||
|
||||
|
||||
class PrerequisiteRequired(Exception):
|
||||
"""Raised when a user attempts to access a collection but has not completed a prerequisite.
|
||||
|
||||
The exception stores an optional `prereq` attribute pointing to the prerequisite
|
||||
CaseCollection instance so views can render a helpful page linking to it.
|
||||
"""
|
||||
|
||||
def __init__(self, message=None, *, prereq=None):
|
||||
super().__init__(message or "Prerequisite required")
|
||||
self.prereq = prereq
|
||||
+38
-44
@@ -1,7 +1,7 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import A
|
||||
|
||||
from generic.tables import SeriesImageColumn
|
||||
from generic.tables import SeriesImageColumn, SelectionTable
|
||||
|
||||
from .models import (
|
||||
Case,
|
||||
@@ -14,6 +14,7 @@ from .models import (
|
||||
Structure,
|
||||
Finding,
|
||||
Subspecialty,
|
||||
Resource,
|
||||
)
|
||||
|
||||
from django.utils.html import format_html
|
||||
@@ -25,7 +26,7 @@ from easy_thumbnails.exceptions import InvalidImageFormatError
|
||||
from django.db.models import Prefetch
|
||||
|
||||
|
||||
class CaseTable(tables.Table):
|
||||
class CaseTable(SelectionTable):
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
# Optimize prefetching for casecollection_set
|
||||
#case_collections_prefetch = Prefetch(
|
||||
@@ -174,9 +175,8 @@ class CaseTable(tables.Table):
|
||||
"atlas:case_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Case
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = (
|
||||
@@ -190,7 +190,6 @@ class CaseTable(tables.Table):
|
||||
)
|
||||
sequence = ("view", )
|
||||
order_by = "-created_date"
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
|
||||
@@ -205,7 +204,7 @@ class PopupLinkColumn(tables.Column):
|
||||
return obj.get_thumbnail()[0]
|
||||
|
||||
|
||||
class SeriesTable(tables.Table):
|
||||
class SeriesTable(SelectionTable):
|
||||
edit = tables.LinkColumn(
|
||||
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
@@ -219,13 +218,12 @@ class SeriesTable(tables.Table):
|
||||
delete = tables.LinkColumn(
|
||||
"atlas:series_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
case = tables.ManyToManyColumn(verbose_name="Cases")
|
||||
findings = tables.ManyToManyColumn(verbose_name="Findings")
|
||||
structures = tables.ManyToManyColumn(verbose_name="Structures")
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Series
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = (
|
||||
@@ -240,7 +238,6 @@ class SeriesTable(tables.Table):
|
||||
)
|
||||
sequence = ("view", "popup", "images", "case")
|
||||
order_by = "-created_date"
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -259,7 +256,7 @@ class SeriesTable(tables.Table):
|
||||
)
|
||||
|
||||
|
||||
class ConditionTable(tables.Table):
|
||||
class ConditionTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:condition_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="Condition",
|
||||
@@ -271,18 +268,16 @@ class ConditionTable(tables.Table):
|
||||
delete = tables.LinkColumn(
|
||||
"atlas:condition_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
# synonym = tables.ManyToManyColumn(verbose_name="Synonyms")
|
||||
synonym = tables.Column(empty_values=())
|
||||
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Condition
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
||||
fields = ("subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
||||
sequence = ("name",)
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -299,7 +294,7 @@ class ConditionTable(tables.Table):
|
||||
return f"{record.get_synonym_link()}"
|
||||
|
||||
|
||||
class FindingTable(tables.Table):
|
||||
class FindingTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:finding_detail", {"pk": tables.A("pk")}), verbose_name="Finding"
|
||||
)
|
||||
@@ -309,25 +304,23 @@ class FindingTable(tables.Table):
|
||||
delete = tables.LinkColumn(
|
||||
"atlas:finding_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
# synonym = tables.ManyToManyColumn(verbose_name="Synonyms")
|
||||
synonym = tables.Column(empty_values=())
|
||||
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Finding
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "primary")
|
||||
fields = ("name",)
|
||||
sequence = ("name",)
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
|
||||
|
||||
class StructureTable(tables.Table):
|
||||
class StructureTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:structure_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="Structure",
|
||||
@@ -338,25 +331,23 @@ class StructureTable(tables.Table):
|
||||
delete = tables.LinkColumn(
|
||||
"atlas:structure_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
# synonym = tables.ManyToManyColumn(verbose_name="Synonyms")
|
||||
synonym = tables.Column(empty_values=())
|
||||
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Structure
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "primary")
|
||||
fields = ("name",)
|
||||
sequence = ("name",)
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
|
||||
|
||||
class PresentationTable(tables.Table):
|
||||
class PresentationTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:presentation_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="Presentation",
|
||||
@@ -368,20 +359,19 @@ class PresentationTable(tables.Table):
|
||||
# delete = tables.LinkColumn(
|
||||
# "atlas:presentation_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
# )
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
subspecialty = tables.ManyToManyColumn(verbose_name="Subspecialty")
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Presentation
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name",)
|
||||
sequence = ("name",)
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
|
||||
class PathologicalProcessTable(tables.Table):
|
||||
|
||||
class PathologicalProcessTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:pathological_process_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="PathologicalProcess",
|
||||
@@ -393,18 +383,15 @@ class PathologicalProcessTable(tables.Table):
|
||||
# delete = tables.LinkColumn(
|
||||
# "atlas:pathological_process_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
# )
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = PathologicalProcess
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name",)
|
||||
sequence = ("name",)
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
|
||||
class SubspecialtyTable(tables.Table):
|
||||
class SubspecialtyTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:subspecialty_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="Subspecialty",
|
||||
@@ -416,21 +403,31 @@ class SubspecialtyTable(tables.Table):
|
||||
# delete = tables.LinkColumn(
|
||||
# "atlas:subspecialty_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
# )
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Subspecialty
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ()
|
||||
sequence = ("name",)
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return f"{record.get_synonym_link()}"
|
||||
|
||||
class CaseCollectionTable(tables.Table):
|
||||
|
||||
class ResourceTable(SelectionTable):
|
||||
name = tables.Column(linkify=("atlas:resource_detail", {"pk": tables.A("pk")}), verbose_name="Resource")
|
||||
|
||||
edit = tables.LinkColumn("atlas:resource_update", text="Edit", args=[A("pk")], orderable=False)
|
||||
delete = tables.LinkColumn("atlas:resource_delete", text="Delete", args=[A("pk")], orderable=False)
|
||||
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Resource
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "description")
|
||||
|
||||
class CaseCollectionTable(SelectionTable):
|
||||
edit = tables.LinkColumn(
|
||||
"atlas:exam_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
@@ -446,9 +443,8 @@ class CaseCollectionTable(tables.Table):
|
||||
|
||||
#differential = tables.ManyToManyColumn(verbose_name="Differential")
|
||||
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = CaseCollection
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = (
|
||||
@@ -459,7 +455,6 @@ class CaseCollectionTable(tables.Table):
|
||||
"author",
|
||||
)
|
||||
sequence = ("view", )#, "series")
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -471,7 +466,7 @@ class CaseCollectionTable(tables.Table):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
class QuestionSchemaTable(tables.Table):
|
||||
class QuestionSchemaTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:question_schema_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="QuestionSchema",
|
||||
@@ -484,11 +479,10 @@ class QuestionSchemaTable(tables.Table):
|
||||
"atlas:question_schema_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = QuestionSchema
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "description", "schema")
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
def render_schema(self, value, record):
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Search results for "{{ query }}"</h5>
|
||||
|
||||
{% for group_name, items in results.items %}
|
||||
{% if items.exists %}
|
||||
<h6 class="mt-3">{{ group_name|capfirst }}</h6>
|
||||
<ul class="list-unstyled">
|
||||
{% for item in items %}
|
||||
<li>
|
||||
{% if group_name == 'conditions' %}
|
||||
<a href="{% url 'atlas:condition_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% elif group_name == 'findings' %}
|
||||
<a href="{% url 'atlas:finding_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% elif group_name == 'structures' %}
|
||||
<a href="{% url 'atlas:structure_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% elif group_name == 'subspecialties' %}
|
||||
<a href="{% url 'atlas:subspecialty_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% elif group_name == 'presentations' %}
|
||||
<a href="{% url 'atlas:presentation_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% else %}
|
||||
{{ item.name }}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if not results.conditions.exists and not results.findings.exists and not results.structures.exists and not results.subspecialties.exists and not results.presentations.exists %}
|
||||
<p class="text-muted">No matches found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
{# Render a small example answers form for a given CaseDetail - expects a context variable `form` (JsonAnswerForm) #}
|
||||
<form method="POST" class="post-form">
|
||||
{% csrf_token %}
|
||||
{# Include any media the field needs (json-editor) #}
|
||||
|
||||
<div class="json-editor-wrapper">
|
||||
{{ form.as_p}}
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,14 +1,9 @@
|
||||
<span id="add-case-form">
|
||||
This form allows you to add an existing case to the collection. You may find it easier to do this from the individual case pages or the <a href="{% url 'atlas:case_view' %}">case overview view</a> which allows you to add multiple cases at once.
|
||||
<form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<label for="case">Select Case to add:</label>
|
||||
<select name="case" id="case">
|
||||
{% for case in cases %}
|
||||
<option value="{{ case.id }}">{{ case.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Add</button>
|
||||
<button _="on click remove #add-case-form">Cancel</button>
|
||||
</form>
|
||||
</span
|
||||
<div id="add-case-form" class="p-2 border rounded">
|
||||
<p class="mb-2">Add an existing case to this collection. You can also use the case overview view to add multiple cases at once: <a href="{% url 'atlas:case_view' %}">case overview</a>.</p>
|
||||
|
||||
{% include 'atlas/partials/case_search_widget.html' %}
|
||||
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-secondary" _="on click remove #add-case-form">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -15,68 +15,107 @@
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:collection_view' %}">Collections</a>
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:collection_view' %}"><i class="bi bi-collection me-1" aria-hidden="true"></i>Collections</a>
|
||||
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</a>
|
||||
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:user_collections' %}">Collections to view / take</a>
|
||||
<a class="dropdown-item" href="{% url 'atlas:user_collections' %}"><i class="bi bi-eye me-1" aria-hidden="true"></i>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>
|
||||
<a class="dropdown-item" href="{% url 'atlas:collection_view' %}"><i class="bi bi-gear me-1" aria-hidden="true"></i>Manage Collections</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:exam_create' %}">Create collection</a>
|
||||
<a class="dropdown-item" href="{% url 'atlas:exam_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>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" href="{% url 'atlas:case_view' %}"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>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>
|
||||
<a class="dropdown-item" href="{% url 'atlas:case_view' %}"><i class="bi bi-list-ul me-1" aria-hidden="true"></i>Manage Cases</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:case_create' %}">Create Case</a>
|
||||
<a class="dropdown-item" href="{% url 'atlas:case_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Case</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:normals_list' %}"><i class="bi bi-file-earmark-check me-1" aria-hidden="true"></i>Normals</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" href="{% url 'atlas:series_view' %}"><i class="bi bi-collection-play me-1" aria-hidden="true"></i>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>
|
||||
<a class="dropdown-item" href="{% url 'atlas:series_view' %}"><i class="bi bi-gear me-1" aria-hidden="true"></i>Manage Series</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:series_create' %}">Create Series</a>
|
||||
<a class="dropdown-item" href="{% url 'atlas:series_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Series</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</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:categories_list' %}"><i class="bi bi-list-ul me-1" aria-hidden="true"></i>All categories</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:condition_view' %}">Conditions</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:finding_view' %}">Findings</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:structure_view' %}">Structures</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_view' %}">Subspecialties</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:presentation_view' %}">Presentations</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_view' %}">Pathological process</a></li>
|
||||
{% if request.user.is_staff %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li class="dropdown-item-text small text-muted px-3">Create</li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:condition_create' %}">New condition</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:finding_create' %}">New finding</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:structure_create' %}">New structure</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_create' %}">New subspecialty</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:presentation_create' %}">New presentation</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_create' %}">New pathological process</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</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:resource_view' %}"><i class="bi bi-list-ul me-1" aria-hidden="true"></i>Manage Resources</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:resource_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Resource</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:categories_list' %}">Categories</a>
|
||||
<a class="nav-link" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads"><i class="bi bi-upload me-1" aria-hidden="true"></i>Uploads</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:resource_view' %}" title="Resources">Resources</a>
|
||||
<a class="nav-link" href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas"><i class="bi bi-journal-text me-1" aria-hidden="true"></i>Question Schemas</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>
|
||||
<a class="nav-link" href="{% url 'atlas:help' %}" title="View the atlas help pages"><i class="bi bi-question-circle me-1" aria-hidden="true"></i>Help</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -108,15 +108,50 @@
|
||||
|
||||
|
||||
{% 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>
|
||||
<div class="btn-group link-buttons" role="group">
|
||||
<button type="button" class="btn btn-primary" onclick="window.location.href='/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}'">View case in OHIF</button>
|
||||
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle dropdown</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Open in new tab</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="clearfix mb-2">
|
||||
<div class="float-start">
|
||||
<span class="id">ID: {{ case.id }}</span>
|
||||
<span class="mx-1">/</span>
|
||||
<span class="date">{{ case.created_date|date:"d/m/Y" }}</span>
|
||||
</div>
|
||||
|
||||
<div class="date">
|
||||
{{ case.created_date|date:"d/m/Y" }}
|
||||
<div class="float-end text-end">
|
||||
{# Subspecialty badges (preserve any anchor returned by get_link) #}
|
||||
{% if case.subspecialty.all %}
|
||||
{% for sub in case.subspecialty.all %}
|
||||
<span class="badge bg-secondary ms-1">{{ sub.get_link|safe }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted me-2">No subspecialty</span>
|
||||
{% endif %}
|
||||
|
||||
{# Pathological process badges #}
|
||||
{% if case.pathological_process.all %}
|
||||
{% for p in case.pathological_process.all %}
|
||||
<span class="badge bg-secondary ms-1">{{ p.get_link|safe }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# Diagnostic certainty as a badge #}
|
||||
Diagnostic certainty: <span class="badge bg-info text-dark ms-2">{{ case.get_diagnostic_certainty_display }}</span>
|
||||
{# Normal toggle HTMX block #}
|
||||
<div class="mt-1">
|
||||
{% include "atlas/partials/_normal_toggle.html" %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="id">
|
||||
ID: {{ case.id }}
|
||||
</div>
|
||||
|
||||
<details id="dicom-viewer-details">
|
||||
@@ -125,7 +160,7 @@
|
||||
<div id="main_viewer" class="dicom-viewer-root"
|
||||
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
|
||||
data-auto-cache-stack="false"
|
||||
data-images='{{case.get_series_images_nested}}'
|
||||
data-named-stacks='{{case.get_case_named_stacks}}'
|
||||
></div>
|
||||
</details>
|
||||
|
||||
@@ -140,11 +175,32 @@
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
<p class="pre-whitespace"><b>History:</b> {{ case.history |linebreaks}}</p>
|
||||
|
||||
<p class="pre-whitespace"><b>Discussion:</b> {{ case.discussion |linebreaks}}</p>
|
||||
|
||||
<p class="pre-whitespace"><b>Report:</b> {{ case.report |linebreaks}}</p>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-md-12">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">History</div>
|
||||
<div class="card-body">
|
||||
<div class="pre-whitespace">{{ case.history|linebreaks }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-12">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">Discussion</div>
|
||||
<div class="card-body">
|
||||
<div class="pre-whitespace">{{ case.discussion|linebreaks }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-12">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">Report</div>
|
||||
<div class="card-body">
|
||||
<div class="pre-whitespace">{{ case.report|linebreaks }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="pre-whitespace"><b>Resource</b><br/>{% for caseresource in case.caseresource_set.all %}
|
||||
<a href='{% url "atlas:resource_detail" caseresource.resource.pk %}'>{{caseresource.resource.name}}</a>
|
||||
{% if caseresource.pre_review %}
|
||||
@@ -157,7 +213,9 @@
|
||||
|
||||
<div class="pre-whitespace multi-image-block"><b>Series:</b>
|
||||
<form>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-start" id="case-series-row">
|
||||
{% partial case-series %}
|
||||
</div>
|
||||
</form>
|
||||
<span>
|
||||
<button type="button"
|
||||
@@ -227,7 +285,7 @@
|
||||
href="{% url 'atlas:case_scrap' pk=case.pk %}">(toggle)</a> {% endcomment %}
|
||||
|
||||
<div class="atlas-answer">
|
||||
<p class="pre-whitespace">
|
||||
<div class="pre-whitespace">
|
||||
Condition:
|
||||
<ul>
|
||||
{% for con in case.condition.all %}
|
||||
@@ -373,21 +431,9 @@
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
Subspecialty:
|
||||
<ul>
|
||||
{% for sub in case.subspecialty.all %}
|
||||
<li>{{sub.get_link}}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<b>Pathological Process:</b>
|
||||
<ul>
|
||||
{% for p in case.pathological_process.all %}
|
||||
<li>{{p.get_link}}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<b>Diagnostic certainty:</b> {{case.get_diagnostic_certainty_display}}<br />
|
||||
</div>
|
||||
|
||||
<b>Collections:</b>
|
||||
|
||||
{% if can_edit %}
|
||||
@@ -417,6 +463,13 @@
|
||||
</p>
|
||||
|
||||
|
||||
{% if casedetail %}
|
||||
{% include 'atlas/partials/collection_question_block.html' with can_edit=can_edit %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
|
||||
<p class="pre-whitespace"><b>Previous case:</b> {{ case.previous_case.get_link }}</p>
|
||||
<p class="pre-whitespace"><b>Next case:</b>
|
||||
@@ -721,6 +774,14 @@
|
||||
background-color: blue;
|
||||
transition: background-color 0.3s ease, border 0.3s ease;
|
||||
}
|
||||
/* Make series blocks act as flex items and wrap neatly on small screens */
|
||||
.series-block {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
min-width: 180px;
|
||||
max-width: 320px;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
#series-reorder-list .dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -733,4 +794,7 @@
|
||||
padding: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.link-buttons {
|
||||
float: right;
|
||||
}
|
||||
</style>
|
||||
@@ -10,18 +10,7 @@
|
||||
<h2>Cases</h2>
|
||||
Quick filters:<br/>
|
||||
Show <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>my cases only</a>, <a href='{% url "atlas:case_view" %}'>all cases</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>
|
||||
<form>
|
||||
<details id="actions-detail" class="mt-3">
|
||||
<summary>
|
||||
@@ -81,6 +70,7 @@
|
||||
</form>
|
||||
|
||||
{% include "generic/partials/page_size_form.html" %}
|
||||
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
|
||||
|
||||
<div id="exam-options"></div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,6 +3,40 @@
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
/* Turn the existing form-check radio markup into horizontal button-like radios */
|
||||
#div_id_collection_type .form-check { display: inline-block; margin-right: .5rem; }
|
||||
#div_id_collection_type .form-check-input {
|
||||
/* visually hide the native radio but keep it focusable */
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0,0,0,0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
#div_id_collection_type .form-check-label {
|
||||
display: inline-block;
|
||||
padding: .375rem .75rem;
|
||||
border: 1px solid #0d6efd;
|
||||
border-radius: .375rem;
|
||||
color: #0d6efd;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
#div_id_collection_type .form-check-input:focus + .form-check-label {
|
||||
box-shadow: 0 0 0 .25rem rgba(13,110,253,.25);
|
||||
}
|
||||
#div_id_collection_type .form-check-input:checked + .form-check-label {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
/* small spacing tweak when legend present */
|
||||
#div_id_collection_type fieldset { border: 0; padding: 0; margin: 0 0 1rem 0; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
||||
|
||||
@@ -12,6 +12,109 @@
|
||||
<li><a href="{% url 'atlas:pathological_process_view' %}">Pathological Process</li></a>
|
||||
</ul>
|
||||
|
||||
<div class="mt-4 mb-3">
|
||||
{# HTMX loading indicator styles: hide by default, show when HTMX adds the .htmx-request class #}
|
||||
<style>
|
||||
#category-search-loading { display: none; }
|
||||
#category-search-loading.htmx-request { display: inline-block; }
|
||||
</style>
|
||||
|
||||
<p>Alternatively you can use the search function below</p>
|
||||
|
||||
<form method="get" class="d-flex flex-column" action="" id="category-search-form">
|
||||
<div class="d-flex align-items-center">
|
||||
<input id="category-search-input" type="search" name="q" placeholder="Search all categories (conditions, findings, structures...)" value="{{ query|default:'' }}" class="form-control me-2"
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-trigger="keyup changed delay:500ms"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
|
||||
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
|
||||
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
|
||||
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary ms-2" type="submit">Search</button>
|
||||
</div>
|
||||
|
||||
{# Collapsed advanced filters block - defaults to collapsed #}
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-link p-0" type="button" data-bs-toggle="collapse" data-bs-target="#category-filters" aria-expanded="false" aria-controls="category-filters">
|
||||
Advanced filters ▾
|
||||
</button>
|
||||
|
||||
<div class="collapse mt-2" id="category-filters">
|
||||
<div class="card card-body">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-conditions" name="types" value="conditions" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-conditions">Conditions</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-findings" name="types" value="findings" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-findings">Findings</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-structures" name="types" value="structures" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-structures">Structures</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-subspecialties" name="types" value="subspecialties" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-subspecialties">Subspecialties</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-presentations" name="types" value="presentations" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-presentations">Presentations</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
|
||||
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
|
||||
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
|
||||
{% comment %} <button class="btn btn-primary ms-2" type="submit">Search</button> {% endcomment %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="category-search-results">
|
||||
{% if results is not None %}
|
||||
{% include 'atlas/_categories_search_results.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if request.user.is_staff %}
|
||||
<p>Manage <a href="{% url 'generic:examination_view' %}">Examinations</a>
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends 'atlas/exams.html' %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_details" %}
|
||||
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}">{{casedetail.case.title}}</a></h2>
|
||||
|
||||
<p>This page allows you to configure how the case is displayed as part of the collection.</p>
|
||||
|
||||
|
||||
|
||||
<form method="POST" class="post-form">
|
||||
{% csrf_token %}
|
||||
{% crispy form form.helper %}
|
||||
<button type="submit" value="answer" name="submit" class="btn btn-primary">Save Changes</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script>
|
||||
</script>
|
||||
<style></style>
|
||||
{% endblock %}
|
||||
@@ -3,13 +3,14 @@
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_displaysetup" %}
|
||||
<h2>Setup default display for case</h2>
|
||||
|
||||
<div id="root" class="dicom-viewer-root" data-images="{{case_detail.case.get_series_images_nested}}"
|
||||
<div id="root" class="dicom-viewer-root" data-images="{{casedetail.case.get_series_images_nested}}"
|
||||
style="box-sizing: border-box; background: #222; width: 100vw; height: 800px; max-width: 1600px;"
|
||||
data-auto-cache-stack="true"
|
||||
{% if case_detail.default_viewerstate %}
|
||||
data-viewerstate={{ case_detail.default_viewerstate }}
|
||||
{% if casedetail.default_viewerstate %}
|
||||
data-viewerstate={{ casedetail.default_viewerstate }}
|
||||
{% endif %}
|
||||
|
||||
></div>
|
||||
@@ -29,7 +30,7 @@ document.getElementById('save-viewerstate').addEventListener('click', function()
|
||||
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
|
||||
return;
|
||||
}
|
||||
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
|
||||
fetch({% if case_number %}"{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk case_number %}"{% else %}"{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}"{% endif %}, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -46,7 +47,7 @@ document.getElementById('save-viewerstate').addEventListener('click', function()
|
||||
});
|
||||
});
|
||||
document.getElementById('reset-viewerstate').addEventListener('click', function() {
|
||||
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
|
||||
fetch({% if case_number %}"{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk case_number %}"{% else %}"{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}"{% endif %}, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -4,106 +4,28 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div>
|
||||
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_priors" %}
|
||||
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id previous.id %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id next.id %}">Next question</a>
|
||||
{% endif %}
|
||||
<h2 class="mb-3">Managing Priors for Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}" class="text-decoration-none">{{casedetail.case.title}}</a></h2>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<p class="mb-1">This page allows you to add series from prior cases to a case in a collection. In order to be available here the cases must be linked on the system.</p>
|
||||
<p class="mb-0">It is necessary to give a description of how the cases are related that will be displayed to the user when viewing the case.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-3">Available priors</h3>
|
||||
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
|
||||
|
||||
<p>This page allows you to add series from prior cases to a case in a collection. In order to be available here the cases must be linked on the system.</p>
|
||||
<p>It is necessary to give a description of how the cases are related that will be displayed to the user when viewing the case.</p>
|
||||
|
||||
<h3>Available priors</h3>
|
||||
|
||||
<ul>
|
||||
<div class="row g-3">
|
||||
{% for case, added, relation, visibility in available_priors %}
|
||||
|
||||
<li class='
|
||||
{% if added %}
|
||||
added
|
||||
{% else %}
|
||||
not-added
|
||||
{% endif %}
|
||||
' data-case={{case.id}}>
|
||||
{{case.title}} / {{added}} / {{relation}} / <a href="{% url 'atlas:case_detail' case.id %}">View case</a>
|
||||
<br/>{{case.get_series_blocks|safe}}
|
||||
|
||||
<br/>
|
||||
|
||||
{% if not added %}
|
||||
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
|
||||
hx-target="#htmx-response-{{case.id}}"
|
||||
_="on htmx:afterRequest log 'test' then remove me">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="prior_case_id" value="{{case.id}}" />
|
||||
<input type="text" name="relation" value="{{relation}}" placeholder="relation to case" />
|
||||
<br />
|
||||
<label for="prior_visibility_{{case.id}}">Visibility:</label>
|
||||
<select name="prior_visibility" id="prior_visibility_{{case.id}}">
|
||||
<option value="AL"
|
||||
{% if visibility == 'AL' %}
|
||||
selected
|
||||
{% endif %}
|
||||
>Always</option>
|
||||
<option value="RE"
|
||||
{% if visibility == 'RE' %}
|
||||
selected
|
||||
{% endif %}
|
||||
|
||||
>Review</option>
|
||||
<option value="NO"
|
||||
{% if visibility == 'NO' %}
|
||||
selected
|
||||
{% endif %}
|
||||
>None</option>
|
||||
</select>
|
||||
<br />
|
||||
<button class="btn primary">Add to case</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
|
||||
hx-target="#htmx-response-{{case.id}}"
|
||||
_="on htmx:afterRequest log 'test' then remove me">
|
||||
Case available: {{relation}}
|
||||
<input type="hidden" name="remove" value="{{case.id}}" />
|
||||
<br />
|
||||
<label for="prior_visibility_{{case.id}}">Visibility:</label>
|
||||
<select name="prior_visibility" id="prior_visibility_{{case.id}}" disabled>
|
||||
<option value="AL"
|
||||
{% if visibility == 'AL' %}
|
||||
selected
|
||||
{% endif %}
|
||||
>Always</option>
|
||||
<option value="RE"
|
||||
{% if visibility == 'RE' %}
|
||||
selected
|
||||
{% endif %}
|
||||
|
||||
>Review</option>
|
||||
<option value="NO"
|
||||
{% if visibility == 'NO' %}
|
||||
selected
|
||||
{% endif %}
|
||||
>None</option>
|
||||
</select>
|
||||
<br />
|
||||
<button class="btn primary">Remove from case</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<span id="htmx-response-{{case.id}}"></span>
|
||||
|
||||
</li>
|
||||
|
||||
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility collection=collection %}
|
||||
{% empty %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">No available prior cases found for the case.</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
+149
-26
@@ -4,21 +4,11 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div>
|
||||
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_questions" %}
|
||||
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id previous.id %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id next.id %}">Next question</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}">{{casedetail.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 casedetail.question_schema is not None and not casedetail.question_answers %}
|
||||
|
||||
|
||||
<div class="alert alert-danger" role="alert">
|
||||
@@ -42,7 +32,7 @@
|
||||
|
||||
Import schema from:
|
||||
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
|
||||
hx-get="{% url 'atlas:collection_question_schemas' case_detail.collection.id %}"
|
||||
hx-get="{% url 'atlas:collection_question_schemas' casedetail.collection.id %}"
|
||||
hx-target=".offcanvas-body"
|
||||
>Collection questions</button>
|
||||
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
|
||||
@@ -54,7 +44,7 @@
|
||||
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">
|
||||
<input id="question_block_title_input" type="text" name="title" value="{{casedetail.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;">
|
||||
@@ -123,13 +113,74 @@
|
||||
</form> {% endcomment %}
|
||||
|
||||
|
||||
{% if case_detail.question_schema %}
|
||||
{% if casedetail.question_schema %}
|
||||
<h2>Question display</h2>
|
||||
This shows the form as it will be displayed (with the correct answers). If you wish to edit a correct answer you can change it below.
|
||||
|
||||
<form method="POST" class="post-form">
|
||||
{% csrf_token %}
|
||||
{{example_form}}
|
||||
{# Show example_form non-field errors first #}
|
||||
{% if example_form.non_field_errors %}
|
||||
<div class="alert alert-danger">
|
||||
<strong>Errors:</strong>
|
||||
<ul class="mb-0">
|
||||
{% for err in example_form.non_field_errors %}
|
||||
<li>{{ err }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Render example_form fields explicitly so we can improve error UX for json_answer #}
|
||||
{% for field in example_form %}
|
||||
{% if field.name == 'json_answer' %}
|
||||
<div class="mb-3">
|
||||
{% if field.errors %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>Answer required.</strong>
|
||||
<div>Some questions do not have a default answer, please select below.</div>
|
||||
<details style="margin-top:8px;">
|
||||
<summary class="small">Validation details</summary>
|
||||
<ul class="mb-0">
|
||||
{% for err in field.errors %}
|
||||
<li class="small text-muted">{{ err }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ field }} {# hidden/JS-bound field stays rendered #}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mb-3">
|
||||
{{ field.label_tag }} {{ field }}
|
||||
{% if field.errors %}
|
||||
{% for err in field.errors %}
|
||||
<div class="text-danger small">{{ err }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if field.help_text %}
|
||||
<small class="form-text text-muted">{{ field.help_text }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{# Debug: list all example_form.errors (field -> [errors]) in a collapsed block so nothing is missed #}
|
||||
{% if example_form.errors %}
|
||||
<details style="margin-top:8px;">
|
||||
<summary class="small">All example form errors (debug)</summary>
|
||||
<ul class="mb-0">
|
||||
{% for name, errs in example_form.errors.items %}
|
||||
<li><strong>{{ name }}</strong>
|
||||
<ul>
|
||||
{% for e in errs %}
|
||||
<li class="small text-muted">{{ e }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
<button type="submit" value="answer" name="submit">Save Correct Answers</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -172,6 +223,65 @@
|
||||
const resetQuestionOptionsButton = document.getElementById('reset-question-options');
|
||||
const questionsContainer = document.getElementById('questions-container');
|
||||
|
||||
// Helper: get options array from a question regardless of schema shape
|
||||
function getQuestionOptions(question) {
|
||||
if (!question) return [];
|
||||
if (Array.isArray(question.enum)) return question.enum.slice();
|
||||
if (question.items && Array.isArray(question.items.enum)) return question.items.enum.slice();
|
||||
return [];
|
||||
}
|
||||
|
||||
// Helper: infer a UI questionType from the schema if question.questionType is not present
|
||||
function inferQuestionType(question) {
|
||||
if (!question) return 'text';
|
||||
if (question.type === 'array') return 'multiselect';
|
||||
if (question.format === 'select2' && question.type === 'string' && Array.isArray(getQuestionOptions(question))) return 'dropdown-select';
|
||||
if (question.format === 'select' && question.type === 'string') return 'dropdown';
|
||||
if (question.format === 'radio' && question.type === 'string') return 'radio';
|
||||
if (question.format === 'textarea') return 'textarea';
|
||||
if (question.type === 'number') return 'number';
|
||||
if (question.type === 'range') return 'range';
|
||||
if (question.enum && Array.isArray(question.enum) && question.enum.length === 2) {
|
||||
const opts = question.enum.map(String).map(s => s.toLowerCase());
|
||||
if ((opts.includes('yes') && opts.includes('no'))) return 'yesno';
|
||||
if ((opts.includes('true') && opts.includes('false'))) return 'truefalse';
|
||||
}
|
||||
return 'text';
|
||||
}
|
||||
|
||||
// Helper: apply an options array to a question object according to the selected UI type
|
||||
function applyOptionsToQuestion(question, optionsArray, selectedType) {
|
||||
optionsArray = (optionsArray || []).filter(o => o !== '');
|
||||
if (selectedType === 'multiselect') {
|
||||
question.type = 'array';
|
||||
question.items = question.items || { type: 'string', enum: [] };
|
||||
question.items.enum = optionsArray.length ? optionsArray : (question.items.enum || []);
|
||||
question.uniqueItems = true;
|
||||
question.format = 'select2';
|
||||
delete question.enum;
|
||||
} else {
|
||||
// convert array->single if present
|
||||
if (question.type === 'array' && question.items && Array.isArray(question.items.enum)) {
|
||||
question.enum = question.items.enum.slice();
|
||||
delete question.items;
|
||||
delete question.uniqueItems;
|
||||
}
|
||||
if (optionsArray.length) {
|
||||
question.enum = optionsArray;
|
||||
}
|
||||
// ensure string type for single-select/other
|
||||
if (question.enum && Array.isArray(question.enum)) {
|
||||
question.type = 'string';
|
||||
}
|
||||
// set formats for some known selected types
|
||||
if (selectedType === 'dropdown-select') question.format = 'select2';
|
||||
else if (selectedType === 'dropdown') question.format = 'select';
|
||||
else if (selectedType === 'radio') question.format = 'radio';
|
||||
}
|
||||
// always store the UI mapping
|
||||
question.questionType = selectedType;
|
||||
}
|
||||
|
||||
question_editor.updateProps({
|
||||
onChange: (updatedContent, previousContent, context) => {
|
||||
// Call the original onChange handler if it exists
|
||||
@@ -268,6 +378,14 @@
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
|
||||
if (!currentContent) {
|
||||
currentContent = {
|
||||
title: questionBlockTitleInput.value || "Case Questions",
|
||||
type: "object",
|
||||
properties: {}
|
||||
};
|
||||
}
|
||||
|
||||
if (!currentContent.properties) {
|
||||
currentContent.properties = {};
|
||||
current_question_number = 1;
|
||||
@@ -402,6 +520,10 @@
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
if (!currentContent || !currentContent.properties) {
|
||||
console.warn('No properties found in the current content.');
|
||||
return;
|
||||
}
|
||||
const properties = currentContent.properties || {};
|
||||
|
||||
// Sort questions by propertyOrder
|
||||
@@ -564,8 +686,10 @@
|
||||
while (questionOptionsContainer.firstChild) {
|
||||
questionOptionsContainer.removeChild(questionOptionsContainer.firstChild);
|
||||
}
|
||||
if (question.enum) {
|
||||
question.enum.forEach((option, index) => {
|
||||
// Support both single-select (question.enum) and multiselect (question.items.enum)
|
||||
const enumList = getQuestionOptions(question) || [];
|
||||
if (enumList.length) {
|
||||
enumList.forEach((option, index) => {
|
||||
const optionInput = document.createElement('div');
|
||||
optionInput.className = 'option-input';
|
||||
optionInput.style.marginBottom = '5px';
|
||||
@@ -574,7 +698,6 @@
|
||||
<button type="button" class="btn btn-danger btn-sm remove-option-button" style="display: inline-block; width: 8%;">×</button>
|
||||
`;
|
||||
questionOptionsContainer.appendChild(optionInput);
|
||||
|
||||
// Add event listener to remove the option
|
||||
optionInput.querySelector('.remove-option-button').addEventListener('click', () => {
|
||||
questionOptionsContainer.removeChild(optionInput);
|
||||
@@ -584,6 +707,8 @@
|
||||
} else {
|
||||
optionsConfigurator.style.display = 'none';
|
||||
}
|
||||
// If questionType is not set, infer it from the schema
|
||||
questionTypeSelect.value = question.questionType || inferQuestionType(question);
|
||||
|
||||
// Update the "Add New Question" button to save changes
|
||||
addNewQuestionButton.textContent = 'Save Changes';
|
||||
@@ -618,15 +743,13 @@
|
||||
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
|
||||
}
|
||||
const newOptions = questionOptionsContainer ? Array.from(questionOptionsContainer.querySelectorAll('.option-field')).map((input) => input.value.trim()).filter((option) => option !== '') : [];
|
||||
const selectedType = questionTypeSelect ? questionTypeSelect.value : null;
|
||||
applyOptionsToQuestion(question, newOptions, selectedType);
|
||||
|
||||
// Update the editor with the modified question
|
||||
currentContent.properties[questionKey] = question;
|
||||
question_editor.update({ json: currentContent });
|
||||
try { question_editor.update({ json: currentContent }); } catch (e) { console.warn('Failed to update editor after saveEditedQuestion', e); }
|
||||
|
||||
toastr.success(`Question "${question.title}" updated successfully.`);
|
||||
renderQuestions(); // Re-render the questions list
|
||||
@@ -1,8 +1,12 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<span class="collection-name-blend">Collection: {{collection}}</span>
|
||||
<h2>Case {{case_number|add:1}}
|
||||
<div class="mb-2"><small class="text-muted">Collection: <strong>{{collection}}</strong></small></div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="h4 mb-0">Case {{case_number|add:1}}
|
||||
|
||||
{% if show_title %}
|
||||
: {{case.title}}
|
||||
@@ -10,11 +14,42 @@
|
||||
|
||||
|
||||
{% if question_completed %}
|
||||
<span class="stamp-white">REVIEW</span>
|
||||
<span class="badge bg-warning text-dark ms-2 small">REVIEW</span>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center ms-3">
|
||||
<div class="dropdown">
|
||||
<a class="btn btn-outline-secondary btn-sm dropdown-toggle" href="#" role="button" id="caseJump" data-bs-toggle="dropdown" aria-expanded="false"
|
||||
hx-get="{% url 'atlas:collection_case_jump_partial' pk=collection.pk %}?current={{case_number}}{% if cid %}&cid={{cid}}&passcode={{passcode}}{% endif %}"
|
||||
hx-target="#case-jump-menu"
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="click">
|
||||
Jump
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end" id="case-jump-menu">
|
||||
<div class="px-3 py-2 text-muted">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not question_completed and collection.question_time_limit is not None %}
|
||||
<div id="question-timer-block" class="mb-3" title="This question has a time limit of {{ collection.question_time_limit }} seconds. The timer will start when the page loads.">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div><strong>Time remaining:</strong> <span id="question-timer" aria-live="polite"> </span></div>
|
||||
<div style="flex:1; max-width:320px;">
|
||||
<div class="progress" style="height:10px;">
|
||||
<div id="question-timer-progress-inner" class="progress-bar" role="progressbar" style="width:100%; background:var(--timer-color, #28a745);"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="timer-htmx-target" style="display:none;"></div>
|
||||
<div id="autosubmit-toast-container" style="position:fixed; top:16px; right:16px; z-index:10500;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% comment %} <details>
|
||||
<summary class="opacity-50">Help <i class="bi bi-info-circle"></i></summary>
|
||||
@@ -23,12 +58,10 @@
|
||||
|
||||
{% if not question_completed %}
|
||||
{% if resources %}
|
||||
<h5>Resources</h4>
|
||||
<ul class="no-list-style">
|
||||
<h5 class="mt-3">Resources</h5>
|
||||
<ul class="list-group list-group-flush mb-3">
|
||||
{% for caseresource in resources %}
|
||||
<li>
|
||||
{{caseresource.resource.get_display}}
|
||||
</li>
|
||||
<li class="list-group-item py-1 small">{{caseresource.resource.get_display}}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
@@ -36,14 +69,21 @@
|
||||
|
||||
|
||||
{% if show_description and case.description %}
|
||||
<div>
|
||||
Description: {{case.description}}
|
||||
<div class="mb-3">
|
||||
<h6 class="mb-1">Description</h6>
|
||||
<p class="mb-0">{{case.description}}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if show_history and case.history %}
|
||||
<div>
|
||||
History: {{case.history|linebreaks}}
|
||||
|
||||
{% if show_history %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body p-2 d-flex justify-content-between align-items-start">
|
||||
<div class="me-3 small text-muted">History: {{casedetail.get_history_pre|linebreaks}}</div>
|
||||
{% if has_priors %}
|
||||
<div class="ms-2">
|
||||
<span title="This case has priors available for comparison" class="badge bg-secondary text-white rounded-pill small">Priors{% if prior_count %} ({{ prior_count }}){% endif %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -51,9 +91,9 @@
|
||||
<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>
|
||||
<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{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></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{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
@@ -63,15 +103,25 @@
|
||||
{% if collection.show_ohif_viewer_link %}
|
||||
<div>
|
||||
{% if question_completed %}
|
||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" 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{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if collection.show_built_in_viewer %}
|
||||
<div class="pre-whitespace multi-image-block">
|
||||
|
||||
<details id="dicom-viewer-details" open>
|
||||
<summary>Viewer</summary>
|
||||
<div id="main_viewer" class="dicom-viewer-root"
|
||||
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
|
||||
data-auto-cache-stack="false"
|
||||
data-named-stacks='{{casedetail.get_case_named_stacks}}'
|
||||
></div>
|
||||
</details>
|
||||
|
||||
{% comment %} <div class="pre-whitespace multi-image-block">
|
||||
<details open>
|
||||
<summary>
|
||||
Images
|
||||
@@ -101,33 +151,29 @@
|
||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
{% else %}
|
||||
{% endif %}
|
||||
|
||||
{% if show_discussion and case.discussion %}
|
||||
<p>
|
||||
<details>
|
||||
<summary>
|
||||
Discussion:
|
||||
<details class="card mb-3">
|
||||
<summary class="card-header p-2" style="cursor: pointer;">
|
||||
<strong>Discussion</strong>
|
||||
</summary>
|
||||
<div>
|
||||
<div class="card-body p-2 small">
|
||||
{{case.discussion|linebreaks}}
|
||||
</div>
|
||||
</details>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if show_report and case.report %}
|
||||
<p>
|
||||
<details>
|
||||
<summary>
|
||||
Report:
|
||||
<details class="card mb-3">
|
||||
<summary class="card-header p-2" style="cursor: pointer;">
|
||||
<strong>Report</strong>
|
||||
</summary>
|
||||
<div>
|
||||
<div class="card-body p-2 small">
|
||||
{{case.report|linebreaks}}
|
||||
</div>
|
||||
</details>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if question_completed %}
|
||||
@@ -148,35 +194,7 @@
|
||||
{% if collection.collection_type == "QUE" %}
|
||||
{% if question_completed %}
|
||||
<h3>Answers</h3>
|
||||
|
||||
<div class="answer-block">
|
||||
{% for value, user_answer, correct_answer, answer_is_correct, automark in answer.get_correct_json_answers %}
|
||||
<div class="{% if answer_is_correct %}
|
||||
correct
|
||||
{% else %}
|
||||
incorrect
|
||||
{% endif %}
|
||||
{% if automark %}
|
||||
automark
|
||||
{% endif %}
|
||||
|
||||
">
|
||||
<h4>{{value.title}}</h4>
|
||||
{{value.description}}
|
||||
<div
|
||||
>
|
||||
Answer : {{user_answer}}
|
||||
|
||||
{% if not answer_is_correct %}
|
||||
<br/>Correct answer: {{correct_answer}}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% include "atlas/partials/collection_question_answer_block.html" %}
|
||||
|
||||
{% if collection.self_review %}
|
||||
<div>
|
||||
@@ -222,9 +240,11 @@
|
||||
{{form.json.errors}}
|
||||
<div class="form-contents">
|
||||
<fieldset {% if question_completed %}disabled="disabled"{% endif %}>
|
||||
{{form}}
|
||||
{{form | crispy}}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{% if question_completed %}
|
||||
<div>
|
||||
{% if collection.self_review %}
|
||||
<p>
|
||||
@@ -244,27 +264,31 @@
|
||||
<br/>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
<div class="mb-2">
|
||||
{% if previous %}
|
||||
<button type="submit" name="previous" class="save btn btn-default">Previous</button>
|
||||
<button type="submit" name="previous" class="btn btn-outline-secondary btn-sm me-2">Previous</button>
|
||||
{% endif %}
|
||||
{% if next %}
|
||||
<button type="submit" name="next" class="save btn btn-default">Next</button>
|
||||
<button type="submit" name="next" class="btn btn-primary btn-sm me-2">Next</button>
|
||||
{% elif collection.collection_type == "REP" %}
|
||||
{% if not exam.publish_results %}
|
||||
<button type="submit" name="save" class="save btn btn-default">Save</button>
|
||||
<button type="submit" name="save" class="btn btn-primary btn-sm me-2">Save</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if collection.self_review and not collection.feedback_once_collection_complete and not question_completed %}
|
||||
<button type="submit" name="complete_case" class="save btn btn-default">Finish Case</button>
|
||||
<button type="submit" name="complete_case" class="btn btn-success btn-sm">Finish Case</button>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<br />
|
||||
<button type="submit" name="finish" class="save btn btn-default">Overview</button>
|
||||
<div class="mt-3">
|
||||
<button type="submit" name="finish" class="btn btn-outline-secondary btn-sm">Overview</button>
|
||||
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
|
||||
</div>
|
||||
</form>
|
||||
<style>
|
||||
label {
|
||||
@@ -343,107 +367,213 @@
|
||||
{{ form.media }}
|
||||
{% comment %} <script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %}
|
||||
<script type="text/javascript">
|
||||
window.images = {
|
||||
{% comment %} {% for series in series_list %}
|
||||
{{ forloop.counter0 }}: ["{{ series.get_image_url_array_not_json }}"],
|
||||
{% endfor %} {% endcomment %}
|
||||
{% for series, prior, relation in series_to_load %}
|
||||
{{ forloop.counter0 }}: ["{{ series.get_image_url_array_not_json }}"],
|
||||
{% endfor %}
|
||||
|
||||
// Question time limit countdown + auto-submit
|
||||
$(function () {
|
||||
|
||||
function lockQuestion() {
|
||||
// Only disable save buttons to prevent further saves
|
||||
$form = $('form.post-form');
|
||||
var $saveBtns = $form.find('button[name="save"], #id_answer');
|
||||
if ($saveBtns.length) {
|
||||
$saveBtns.prop('disabled', true);
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
setTimeout(() => {
|
||||
window.loadDicomViewer(window.images[0])
|
||||
}, 500);
|
||||
})
|
||||
// Visual feedback: set timer to Locked and progress to red
|
||||
$progressInner = $('#question-timer-progress-inner');
|
||||
$('#question-timer').text('Locked');
|
||||
$progressInner.css('background', '#dc3545');
|
||||
$progressInner.css('width', '0%');
|
||||
|
||||
{% comment %} $('document').ready(function() {
|
||||
|
||||
// Get value from either a json string or url pointing to a json file
|
||||
function process(value) {
|
||||
var isjson=true;
|
||||
var result;
|
||||
$("#question-timer-progress-outer").hide();
|
||||
}
|
||||
|
||||
try {
|
||||
result = JSON.parse(value);
|
||||
var timeLimit = {{ collection.question_time_limit|default:'null' }};
|
||||
var questionCompleted = {{ question_completed|yesno:"true,false" }};
|
||||
var answerStartedAtIso = "{{ answer_started_at_iso|default:'null' }}";
|
||||
console.debug('Timer init:', {timeLimit: timeLimit, questionCompleted: questionCompleted});
|
||||
|
||||
if (!timeLimit || questionCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
{% if answer %}
|
||||
if ({{answer.completed|yesno:"true,false"}}) {
|
||||
lockQuestion();
|
||||
console.debug('Timer: answer already completed, aborting timer init');
|
||||
return;
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
var $timer = $('#question-timer');
|
||||
var $form = $('form.post-form');
|
||||
|
||||
if ($timer.length === 0 || $form.length === 0) {
|
||||
console.debug('Timer: required elements not found, aborting timer init');
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute remaining based on the canonical start time (if provided)
|
||||
var remaining = parseInt(timeLimit, 10);
|
||||
if (answerStartedAtIso) {
|
||||
try {
|
||||
var started = new Date(answerStartedAtIso);
|
||||
var now = new Date();
|
||||
var elapsed = Math.floor((now - started) / 1000);
|
||||
remaining = Math.max(0, remaining - elapsed);
|
||||
console.debug('Timer: using started_at, elapsed seconds:', elapsed, 'remaining:', remaining);
|
||||
} catch (e) {
|
||||
isjson=false;
|
||||
console.debug('Timer: invalid started_at iso, falling back to full timeLimit', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (isjson) {
|
||||
return result;
|
||||
// If time has already elapsed when the page loads, lock the question and do not autosubmit.
|
||||
if (remaining <= 0) {
|
||||
console.debug('Timer: time already expired on load, locking without autosubmit');
|
||||
lockQuestion();
|
||||
$timer.text(formatTime(0));
|
||||
return;
|
||||
}
|
||||
|
||||
function formatTime(s) {
|
||||
var mins = Math.floor(s / 60);
|
||||
var secs = s % 60;
|
||||
return mins + ':' + (secs < 10 ? '0' + secs : secs);
|
||||
}
|
||||
|
||||
var $progressInner = $('#question-timer-progress-inner');
|
||||
$timer.text(formatTime(remaining));
|
||||
|
||||
function updateProgress() {
|
||||
var pct = Math.max(0, Math.min(100, Math.round((remaining / timeLimit) * 100)));
|
||||
var widthPct = pct;
|
||||
$progressInner.css('width', widthPct + '%');
|
||||
|
||||
// Smooth color transition: green (120) -> orange (30) -> red (0)
|
||||
// We use a two-stage interpolation so the midpoint (~50%) is orange.
|
||||
var hue = 0;
|
||||
if (pct > 50) {
|
||||
// interpolate from orange (30) to green (120)
|
||||
var t = (pct - 50) / 50.0; // 0..1
|
||||
hue = 30 + t * (120 - 30);
|
||||
} else {
|
||||
return $.getJSON(value)
|
||||
.then(function (response) {
|
||||
return response;
|
||||
// interpolate from red (0) to orange (30)
|
||||
var t = pct / 50.0; // 0..1
|
||||
hue = 0 + t * (30 - 0);
|
||||
}
|
||||
|
||||
var color = 'hsl(' + Math.round(hue) + ', 75%, 40%)';
|
||||
$progressInner.css('background', color);
|
||||
}
|
||||
|
||||
updateProgress();
|
||||
|
||||
var intervalId = setInterval(function () {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
clearInterval(intervalId);
|
||||
$timer.text('0:00');
|
||||
$progressInner.css('width', '0%');
|
||||
$progressInner.css('background', '#dc3545');
|
||||
//var $next = $form.find('button[name="next"]');
|
||||
//var $finish = $form.find('button[name="finish"]');
|
||||
|
||||
// Instead of navigating away, submit via fetch with timed_out flag
|
||||
// Use HTMX to POST the timed_out flag and then lock the UI
|
||||
|
||||
var onAfter = function (evt) {
|
||||
try {
|
||||
var resp = null;
|
||||
try {
|
||||
var xhr = evt && evt.detail && evt.detail.xhr;
|
||||
if (xhr && xhr.responseText) {
|
||||
resp = JSON.parse(xhr.responseText);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('No JSON response from timed_out request', e);
|
||||
}
|
||||
|
||||
lockQuestion();
|
||||
|
||||
var msg = 'Answer auto-submitted';
|
||||
if (resp && resp.submitted_at) {
|
||||
try {
|
||||
var dt = new Date(resp.submitted_at);
|
||||
msg += ' at ' + dt.toLocaleTimeString();
|
||||
} catch (e) {}
|
||||
}
|
||||
toastr.info(msg);
|
||||
} catch (e) {
|
||||
console.error('Error handling timed_out htmx response', e);
|
||||
} finally {
|
||||
htmx.off('htmx:afterRequest', onAfter);
|
||||
}
|
||||
};
|
||||
|
||||
htmx.on('htmx:afterRequest', onAfter);
|
||||
// Serialize the entire form so the server receives the user's answers
|
||||
// along with the timed_out flag. This ensures the form.save() path
|
||||
// can validate and persist the submitted answers on timeout.
|
||||
(function(){
|
||||
var formValues = {};
|
||||
try {
|
||||
$.each($form.serializeArray(), function(i, field) {
|
||||
if (formValues[field.name] !== undefined) {
|
||||
if (!Array.isArray(formValues[field.name])) {
|
||||
formValues[field.name] = [formValues[field.name]];
|
||||
}
|
||||
formValues[field.name].push(field.value);
|
||||
} else {
|
||||
formValues[field.name] = field.value;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.debug('Failed to serialize form with jQuery, falling back to manual collection', e);
|
||||
// Fallback: try to collect inputs manually
|
||||
var inputs = $form.find('input, textarea, select').not(':disabled');
|
||||
inputs.each(function () {
|
||||
var $el = $(this);
|
||||
var name = $el.attr('name');
|
||||
if (!name) return;
|
||||
var val = $el.val();
|
||||
if (formValues[name] !== undefined) {
|
||||
if (!Array.isArray(formValues[name])) {
|
||||
formValues[name] = [formValues[name]];
|
||||
}
|
||||
formValues[name].push(val);
|
||||
} else {
|
||||
formValues[name] = val;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$('.editor_holder').each(function() {
|
||||
// Get the DOM Element
|
||||
var element = $(this).get(0);
|
||||
console.log("el", element)
|
||||
// Ensure the timed_out flag is included
|
||||
formValues['timed_out'] = '1';
|
||||
|
||||
var options_text = $(this).attr('options')
|
||||
var schema_text = $(this).attr('schema')
|
||||
|
||||
var schema = process(schema_text);
|
||||
var options = process(options_text);
|
||||
|
||||
var name = $(this).attr('name');
|
||||
var hidden_identifier = 'input[name=' + name + ']';
|
||||
var initial = $(hidden_identifier).val();
|
||||
|
||||
// Check if editor is within form
|
||||
var form = $(this).closest('form')
|
||||
console.log("form", form)
|
||||
|
||||
//Wait for any ajax requests to complete
|
||||
$.when(schema, options).done(function(schemaresult, optionsresult) {
|
||||
optionsresult.form_name_root = name;
|
||||
|
||||
// Pass initial value though to editor
|
||||
if (initial) {
|
||||
optionsresult.startval = JSON.parse(initial);
|
||||
}
|
||||
|
||||
optionsresult.schema = schemaresult;
|
||||
// console.log(options);
|
||||
var editor = new JSONEditor(element, optionsresult);
|
||||
|
||||
console.log("editor", editor)
|
||||
if (form) {
|
||||
$(form).submit(function(e) {
|
||||
console.log("submitting")
|
||||
// Set the hidden field value to the editors value
|
||||
$(hidden_identifier).val(JSON.stringify(editor.getValue()));
|
||||
// Disable the editor so it's values wont be submitted
|
||||
//editor.disable();
|
||||
// Validate the editor's current value against the schema
|
||||
const errors = editor.validate();
|
||||
|
||||
if (errors.length) {
|
||||
// errors is an array of objects, each with a `path`, `property`, and `message` parameter
|
||||
// `property` is the schema keyword that triggered the validation error (e.g. "minLength")
|
||||
// `path` is a dot separated path into the JSON object (e.g. "root.path.to.field")
|
||||
console.log(errors);
|
||||
}
|
||||
else {
|
||||
console.log("valid");
|
||||
}
|
||||
console.log(editor.getValue());
|
||||
//e.preventDefault();
|
||||
})
|
||||
} else {
|
||||
console.log("No form found")
|
||||
}
|
||||
})
|
||||
htmx.ajax('POST', window.location.href, {
|
||||
values: formValues,
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRFToken': document.querySelector('input[name="csrfmiddlewaretoken"]').value,
|
||||
},
|
||||
target: "#timer-htmx-target",
|
||||
});
|
||||
})
|
||||
{% endcomment %}
|
||||
})();
|
||||
} else {
|
||||
$timer.text(formatTime(remaining));
|
||||
updateProgress();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
$form.on('submit', function () {
|
||||
clearInterval(intervalId);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error initializing timer:', e);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
{% endblock js %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% block content %}
|
||||
|
||||
<h2>Case: {{ case.title }}</h2>
|
||||
{{ case_detail.question_schema }}
|
||||
{{ casedetail.question_schema }}
|
||||
|
||||
|
||||
{{ case.question_answers }}
|
||||
|
||||
@@ -2,111 +2,156 @@
|
||||
|
||||
{% load partials %}
|
||||
|
||||
{% partialdef casedetails-management-links %}
|
||||
|
||||
<div class="btn-toolbar" role="toolbar" aria-label="Case management links">
|
||||
{% include 'atlas/partials/casedetails_management_links.html' %}
|
||||
</div>
|
||||
|
||||
{% endpartialdef %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{{collection.name}}</h2>
|
||||
|
||||
<div class="container">
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h2 class="card-title mb-2">{{ collection.name }}</h2>
|
||||
|
||||
{% include 'exam_notes.html' %}
|
||||
|
||||
<div>
|
||||
Exam mode: {{collection.exam_mode}} (open access: {{collection.exam_open_access}})<br />
|
||||
{% include "generic/partials/exams/exam_status.html#publish-results" %}
|
||||
{% include "generic/partials/exams/exam_status.html#exam-active" %}
|
||||
Collection Type: {{collection.get_collection_type_display}}<br />
|
||||
Self review: {{collection.self_review}}<br />
|
||||
Open access: {{collection.open_access}}<br />
|
||||
</div>
|
||||
|
||||
|
||||
{% if collection.collection_type == "VIV" %}
|
||||
<p><a href='{% url "atlas:collection_viva" collection.pk %}'><button class="btn btn-primary">Start Viva</button></a>
|
||||
{% else %}
|
||||
<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 %}
|
||||
|
||||
<h3>Cases</h3>
|
||||
<ol id="full-question-list" class="sortable">
|
||||
{% for casedetail in casesdetails %}
|
||||
<li data-question_pk={{casedetail.case.pk}}>
|
||||
|
||||
|
||||
<a href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a>
|
||||
:
|
||||
{% if casedetail.case.series.count == 0 %}
|
||||
<i class="bi bi-exclamation-circle text-warning" title="No series attached to this case"></i>
|
||||
{% endif %}
|
||||
{{casedetail.case.title}}
|
||||
|
||||
|
||||
(<a href="{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-display" title="Setup default display"></i></a>
|
||||
{% if casedetail.default_viewerstate %}
|
||||
<i class="bi bi-check text-success" title="This case has a default viewerstate defined"></i>
|
||||
{% endif %}
|
||||
)
|
||||
|
||||
{% if collection.collection_type == "QUE" %}
|
||||
(<a href='{% url "atlas:collection_case_details" casedetail.collection.pk casedetail.case.pk %}'>
|
||||
{% 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 %}
|
||||
</a>
|
||||
)
|
||||
{% endif %}
|
||||
|
||||
{% if casedetail.case.previous_case %}
|
||||
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>
|
||||
<i class="bi bi-link-45deg" title="Manage priors"></i></a>)
|
||||
{% endif %}
|
||||
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
|
||||
|
||||
|
||||
{% if can_edit %}
|
||||
<p><button id='button-edit-order' title='click and drag questions to change order' data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}">Edit case order / Remove cases</button></p>
|
||||
<p><button id='button-add-case'
|
||||
title='click to add a case to this collection'
|
||||
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list">
|
||||
Add case
|
||||
</button>
|
||||
<div id='case-list'></div>
|
||||
<p class="mb-1">
|
||||
<span class="badge bg-info text-dark me-2">{{ collection.exam_mode }}</span>
|
||||
<small class="text-muted">(open access: {{ collection.exam_open_access }})</small>
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
{% include "generic/partials/exams/exam_status.html#publish-results" %}
|
||||
{% include "generic/partials/exams/exam_status.html#exam-active" %}
|
||||
</p>
|
||||
|
||||
<p class="mb-1">
|
||||
<span class="badge bg-secondary me-2">{{ collection.get_collection_type_display }}</span>
|
||||
<span class="badge bg-light text-dark me-2">Self review: {{ collection.self_review }}</span>
|
||||
{% if collection.open_access %}
|
||||
<span class="badge bg-success">Open</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Restricted</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if collection.prerequisites.exists %}
|
||||
<div class="mt-3 mb-2">
|
||||
<strong>Prerequisites</strong>
|
||||
<ul class="mb-0">
|
||||
{% for prereq in collection.prerequisites.all %}
|
||||
<li><a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 d-flex flex-column align-items-md-end mt-3 mt-md-0">
|
||||
<div class="mb-2 w-100 text-md-end">
|
||||
{% if collection.collection_type == "VIV" %}
|
||||
<a href="{% url 'atlas:collection_viva' collection.pk %}" class="btn btn-primary mb-2">Start Viva</a>
|
||||
{% else %}
|
||||
<div class="d-inline-block">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{{ collection.get_take_url }}" id="take-url-link">Open</a>
|
||||
<button
|
||||
_="on click
|
||||
writeText(#take-url-link's href) on navigator.clipboard
|
||||
put 'copied url!' into me
|
||||
wait 1s
|
||||
put 'copy link' into me"
|
||||
class="btn btn-sm btn-outline-secondary ms-1">Copy link</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if collection.prerequisites.exists %}
|
||||
<div class="w-100 text-md-end">
|
||||
<button
|
||||
title="Sync users from all prerequisite collections into this collection"
|
||||
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
class="btn btn-sm btn-secondary"
|
||||
>Sync prerequisite users</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h3 class="mb-3">Cases</h3>
|
||||
|
||||
<ol id="full-question-list" class="sortable list-group list-group-numbered">
|
||||
{% for casedetail in casesdetails %}
|
||||
{% include 'atlas/partials/case_list_item.html' %}
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button id='button-edit-order'
|
||||
title='click and drag questions to change order'
|
||||
data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
Edit case order / Remove cases
|
||||
</button>
|
||||
|
||||
<button id='button-add-case'
|
||||
title='click to add a case to this collection'
|
||||
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list"
|
||||
class="btn btn-sm btn-secondary">
|
||||
Add case
|
||||
</button>
|
||||
|
||||
<div id='case-list'></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<details>
|
||||
<summary>Answer management</summary>
|
||||
|
||||
<div class="alert alert-danger">
|
||||
<p>Manage answers for this collection. </p>
|
||||
|
||||
Please note these are permanant and cannot be undone.
|
||||
<div class="alert alert-danger mt-3">
|
||||
<p class="mb-1">Manage answers for this collection.</p>
|
||||
<small class="text-muted">Please note these are permanent and cannot be undone.</small>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button title="This will clear all user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
|
||||
class="btn btn-danger btn-sm"
|
||||
>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>
|
||||
class="btn btn-warning btn-sm">
|
||||
Reset user answers</button>
|
||||
</div>
|
||||
|
||||
<div id="user-list" class="mt-3"></div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include 'exam_overview_js.html' %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
{% if request.user.is_authenticated %}
|
||||
Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a> /
|
||||
<a href="{% url 'atlas:collection_case_update' collection.pk %}">Cases</a> /
|
||||
<a href="{% url 'atlas:collection_history' collection.pk %}">History</a> /
|
||||
<a href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a> /
|
||||
<a href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a> /
|
||||
<a href="{% url 'atlas:exam_cids' collection.pk %}">Candidates</a> /
|
||||
<a href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a> /
|
||||
<div class="floating-header">
|
||||
<a href="{% url 'atlas:exam_update' collection.id %}" title="Edit the Collection">Edit</a>
|
||||
\ <a href="{% url 'atlas:exam_deleted' collection.id %}" title="Delete the Collection">Delete</a>
|
||||
\ <a href="{% url 'atlas:exam_clone' collection.id %}" title="Clone the Collection">Clone</a>
|
||||
\ <a href="{% url 'atlas:collection_authors' collection.pk %}" title="Edit Authors">Authors</a>
|
||||
{% if request.user.is_authenticated and collection %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
|
||||
<div class="container-fluid px-3">
|
||||
<span class="navbar-text me-3 text-white">
|
||||
<strong>Collection:</strong> {{ collection.name }}
|
||||
</span>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#collectionNav" aria-controls="collectionNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="collectionNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_case_update' collection.pk %}">Cases</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_history' collection.pk %}">History</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:exam_cids' collection.pk %}">Candidates</a></li>
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="collectionActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="collectionActions">
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:collection_case_update' collection.pk %}">Edit Cases</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:exam_update' collection.id %}">Edit</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:exam_clone' collection.id %}">Clone</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:collection_authors' collection.pk %}">Authors</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item text-danger" href="{% url 'atlas:exam_deleted' collection.id %}">Delete</a></li>
|
||||
{% if request.user.is_superuser %}
|
||||
\ <a href="{% url 'admin:atlas_casecollection_change' collection.id %}" title="Edit the Collection using the admin interface">Admin Edit</a>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
{% block content %}
|
||||
<h2>{{collection.name}}</h2>
|
||||
|
||||
|
||||
{% if userexams %}
|
||||
<h3>Users</h3>
|
||||
|
||||
<ul>
|
||||
@@ -23,6 +25,30 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if cidexams %}
|
||||
<h3>CID Users</h3>
|
||||
|
||||
<ul>
|
||||
{% for cidexam in cidexams %}
|
||||
<li id="cid-history-{{ cidexam.cid_user.pk }}">
|
||||
<b><a href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">{{cidexam.cid_user}}</a><b><br/>
|
||||
Completed: {{cidexam.completed}}<br/>
|
||||
Started: {{cidexam.start_time}}, Ended: {{cidexam.end_time}}<br/>
|
||||
|
||||
<button
|
||||
hx-post="{% url 'atlas:collection_reset_answers_ciduser' collection.pk cidexam.cid_user.cid %}"
|
||||
hx-target="#cid-history-{{ cidexam.cid_user.cid }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this CID user's collection history?"
|
||||
class="btn btn-danger btn-sm remove-button">
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,10 +10,22 @@
|
||||
<li class="case">
|
||||
|
||||
<h4>{{forloop.counter}} / Case: {{casedetail.case.title}}</h4>
|
||||
Question started: {{user_answer.started_at}} - Answer submitted: {{user_answer.submitted_at}}
|
||||
{% if request.user.is_superuser and user_answer %}
|
||||
(<a href="{% url 'admin:atlas_userreportanswer_change' user_answer.pk %}" target="_blank">Edit in admin</a>)
|
||||
{% endif %}
|
||||
<br/>
|
||||
<br/>
|
||||
{% if not user_answer %}
|
||||
<span class="case-not-answered">Case not answered.</span>
|
||||
{% else %}
|
||||
<div class="answer-block">
|
||||
User answer:
|
||||
<div class="user-answer">
|
||||
{% if user_answer.answer %}
|
||||
{{user_answer.answer}}
|
||||
{% else %}
|
||||
{{user_answer.json_answer}}
|
||||
{% for value, user_answer, correct_answer, answer_is_correct, automark in user_answer.get_correct_json_answers %}
|
||||
{% if not user_answer %}
|
||||
Not answered
|
||||
@@ -43,6 +55,8 @@
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="vstack gap-3">
|
||||
{% for case_detail, answers in case_map.items %}
|
||||
{% for casedetail, answers in case_map.items %}
|
||||
<div class="border border-dark">
|
||||
<h4>Case: {{case_detail.case.title}}</h4>
|
||||
<h4>Case: {{casedetail.case.title}}</h4>
|
||||
Answers
|
||||
<ul>
|
||||
{% for answer in answers %}
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
{% comment %} <script src="{% static 'js/jquery-3.7.1.min.js' %}"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %}
|
||||
<ol>
|
||||
{% for case_detail in case_details %}
|
||||
{% for casedetail in casedetails %}
|
||||
|
||||
<li id="case-detail-{{case_detail.pk}}"{% if case_detail.question_schema %} class="cases"{% endif %}>
|
||||
<h2>Case: {{ case_detail.case.title }}</h2>
|
||||
<li id="case-detail-{{casedetail.pk}}"{% if casedetail.question_schema %} class="cases"{% endif %}>
|
||||
<h2>Case: {{ casedetail.case.title }}</h2>
|
||||
|
||||
{% if case_detail.question_schema %}
|
||||
{% if casedetail.question_schema %}
|
||||
<button class="btn btn-sm btn-secondary use-layout">Use schema</button>
|
||||
<details><summary>Schema:</summary>
|
||||
<div class="schema" data-schema='{{ case_detail.get_question_schema }}'>{{ case_detail.question_schema }}</div>
|
||||
<div class="schema" data-schema='{{ casedetail.get_question_schema }}'>{{ casedetail.question_schema }}</div>
|
||||
</details>
|
||||
<div class="form"></div>
|
||||
{% else %}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
|
||||
{% for cid_user_exam in cid_users %}
|
||||
{% if cid_user_exam.user_user and cid_user_exam.user_user.id %}
|
||||
<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>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-secondary" disabled>Unknown user</button>
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
{% if request.user.is_authenticated and valid_user %}
|
||||
User: {{request.user}}<br/>
|
||||
|
||||
|
||||
{% if cid_exam %}
|
||||
Started: {{cid_exam.start_time}} <br/>
|
||||
|
||||
@@ -26,7 +27,7 @@
|
||||
<a href="{% url 'atlas:collection_take_overview_user' pk=collection.pk %}"><button>Overview</button></a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Enter your CID and passcode in the below boxes.<br />
|
||||
Enter your CID and passcode in the below boxes. (Registered user should login <a href="{% url 'login' %}?next={% url 'atlas:collection_take_start' pk=collection.pk %}">here</a>)<br />
|
||||
<p><input id="cid-box" type="text" value="Candidate ID"></p>
|
||||
<p><input id="passcode-box" type="text" value="Passcode"></p>
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex align-items-start justify-content-between mb-3">
|
||||
<h2 class="mb-0">Collections</h2>
|
||||
{% if request.user.is_staff %}
|
||||
<a class="btn btn-sm btn-primary" href="{% url 'atlas:exam_create' %}">Create collection</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p class="mb-3">Manage and browse case collections. Use filters to narrow results.</p>
|
||||
|
||||
{% render_table table %}
|
||||
|
||||
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -67,6 +67,10 @@
|
||||
{% endfor %}
|
||||
</details>
|
||||
</div>
|
||||
{# Hidden per-case question snippet to inject when case is loaded #}
|
||||
<div class="question-block-snippet" style="display:none;">
|
||||
{% include 'atlas/partials/collection_question_block.html' with casedetail=casedetail %}
|
||||
</div>
|
||||
{% if case.display_sets.all %}
|
||||
<details class="displayset-detail"><summary>Display Sets:</summary>
|
||||
{% for ds in case.display_sets.all %}
|
||||
@@ -116,10 +120,9 @@
|
||||
<div id="current-case-history"></div>
|
||||
<div id="current-case-discussion"></div>
|
||||
<div id="current-case-report"></div>
|
||||
<div id="current-case-questions" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<iframe id="viewer" style="width: 100%; height: 500px; border: none"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -209,34 +212,42 @@
|
||||
let c = this.closest(".case-item");
|
||||
console.log('clicked local', c)
|
||||
console.log('open local', c.dataset.case)
|
||||
case_details = JSON.parse(c.dataset.casejson);
|
||||
casedetails = JSON.parse(c.dataset.casejson);
|
||||
let viewerstate = JSON.parse(c.dataset.viewerstate) || {};
|
||||
$('#loading-case').show()
|
||||
$("#open-viewer-local").addClass("flash-button");
|
||||
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
|
||||
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']);
|
||||
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
|
||||
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
|
||||
$("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
|
||||
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
|
||||
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
|
||||
|
||||
bc.postMessage({"type": "open",
|
||||
"case" : c.dataset.case, "images": c.dataset.images,
|
||||
"viewerstate": viewerstate
|
||||
});
|
||||
|
||||
// Inject the per-case question snippet into the current case panel
|
||||
try {
|
||||
var snippet = $(c).find('.question-block-snippet').html();
|
||||
$('#current-case-questions').html(snippet || '');
|
||||
} catch (e) {
|
||||
console.warn('Unable to inject question snippet', e);
|
||||
}
|
||||
|
||||
});
|
||||
$('.open-displayset').click(function() {
|
||||
let c = this;
|
||||
console.log('clicked local', c)
|
||||
console.log('open local', c.dataset.case)
|
||||
case_details = JSON.parse(c.dataset.casejson);
|
||||
casedetails = JSON.parse(c.dataset.casejson);
|
||||
let viewerstate = JSON.parse(c.dataset.viewerstate) || {};
|
||||
let annotations = JSON.parse(c.dataset.annotations) || {};
|
||||
$('#loading-case').show()
|
||||
$("#open-viewer-local").addClass("flash-button");
|
||||
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
|
||||
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']);
|
||||
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
|
||||
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
|
||||
$("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
|
||||
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
|
||||
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
|
||||
|
||||
bc.postMessage({"type": "open",
|
||||
"case" : c.dataset.case, "images": c.dataset.images,
|
||||
@@ -244,6 +255,15 @@
|
||||
"annotations": annotations
|
||||
});
|
||||
|
||||
// Inject the per-case question snippet
|
||||
try {
|
||||
var caseItem = $(this).closest('.case-item');
|
||||
var snippet = caseItem.find('.question-block-snippet').html();
|
||||
$('#current-case-questions').html(snippet || '');
|
||||
} catch (e) {
|
||||
console.warn('Unable to inject question snippet for displayset', e);
|
||||
}
|
||||
|
||||
});
|
||||
$('.open-series-local').click(function() {
|
||||
let c = $(this).closest(".case-item")[0];
|
||||
@@ -266,6 +286,14 @@
|
||||
"series": seriesPk,
|
||||
"images": JSON.stringify(images),
|
||||
});
|
||||
|
||||
// Inject the per-case question snippet
|
||||
try {
|
||||
var snippet = $(c).find('.question-block-snippet').html();
|
||||
$('#current-case-questions').html(snippet || '');
|
||||
} catch (e) {
|
||||
console.warn('Unable to inject question snippet for series', e);
|
||||
}
|
||||
});
|
||||
$('.open-case, .open-series').click(function() {
|
||||
var url = $(this).data('target');
|
||||
@@ -274,15 +302,15 @@
|
||||
|
||||
let c = $(this).closest(".case-item");
|
||||
|
||||
case_details = c.data('casejson');
|
||||
console.log(case_details);
|
||||
casedetails = c.data('casejson');
|
||||
console.log(casedetails);
|
||||
|
||||
$('#loading-case').show()
|
||||
$("#open-viewer").addClass("flash-button");
|
||||
$('#current-case-title').html("<span class='title'>Case:</span> "+c.data('title'));
|
||||
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']);
|
||||
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
|
||||
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
|
||||
$("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
|
||||
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
|
||||
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
|
||||
|
||||
console.log($(this).data('type'))
|
||||
if ($(this).data('type') == 'case') {
|
||||
@@ -291,6 +319,14 @@
|
||||
$('#current-case-series').html("<span class='title'>Series:</span> " + $(this).data('series'));
|
||||
}
|
||||
bc.postMessage({"type": "open", "url" : url});
|
||||
// Inject question snippet for this case
|
||||
try {
|
||||
var caseItem = $(this).closest('.case-item');
|
||||
var snippet = caseItem.find('.question-block-snippet').html();
|
||||
$('#current-case-questions').html(snippet || '');
|
||||
} catch (e) {
|
||||
console.warn('Unable to inject question snippet', e);
|
||||
}
|
||||
//if (win2 == false || win2.closed) {
|
||||
// win2 = openSecondaryWindow(url);
|
||||
// console.log('opened', win2)
|
||||
@@ -368,4 +404,6 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script>
|
||||
<script src="/static/django_jsonforms/jsoneditor_init.js"></script>
|
||||
{% endblock css %}
|
||||
@@ -1,87 +1,155 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
|
||||
<div class="floating-header">
|
||||
<a href="{% url 'atlas:condition_update' pk=condition.pk %}" title="Edit the Condition">Edit</a>
|
||||
<a href="{% url 'atlas:condition_delete' pk=condition.pk %}" title="Delete the Condition">Delete</a>
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<h3 class="mb-0">{{ condition.name }}</h3>
|
||||
<div class="btn-group" role="group" aria-label="Condition actions">
|
||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'atlas:condition_update' pk=condition.pk %}">Edit</a>
|
||||
<a class="btn btn-sm btn-outline-danger" href="{% url 'atlas:condition_delete' pk=condition.pk %}">Delete</a>
|
||||
{% if request.user.is_superuser %}
|
||||
<a href="{% url 'admin:atlas_condition_change' condition.id %}"
|
||||
title="Edit the Condition using the admin interface">Admin Edit</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'admin:atlas_condition_change' condition.id %}">Admin</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h3>Name: {{condition.name}}</h3>
|
||||
Primary name: {{condition.primary}}<br />
|
||||
Subspecialty: {% for sub in condition.subspecialty.all %}
|
||||
<a href="{{sub.get_absolute_url}}">{{sub}}</a>
|
||||
{% endfor %}<br />
|
||||
Synonyms:
|
||||
{% for syn in condition.synonym.all %}
|
||||
<a href="{{syn.get_absolute_url}}">{{syn}}</a>
|
||||
{% endfor %}<br />
|
||||
Parent:
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<section class="mb-3">
|
||||
<h5 class="mb-2">Details</h5>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Primary name</dt>
|
||||
<dd class="col-sm-8">{% if condition.canonical %}No (alias of <a href="{{ condition.canonical.get_absolute_url }}">{{ condition.canonical.name }}</a>){% else %}Yes{% endif %}</dd>
|
||||
|
||||
<dt class="col-sm-4">Subspecialty</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for sub in condition.subspecialty.all %}
|
||||
<a class="badge bg-dark text-white me-1 text-decoration-none" href="{{ sub.get_absolute_url }}">{{ sub }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Synonyms</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div id="synonyms-container">
|
||||
{% include 'atlas/partials/_synonyms_list.html' %}
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Parent</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for parent in condition.parent.all %}
|
||||
<a href="{{parent.get_absolute_url}}">{{parent}}</a>
|
||||
{% endfor %}<br />
|
||||
Children:
|
||||
<a class="badge bg-primary text-white text-decoration-none me-1" href="{{ parent.get_absolute_url }}">{{ parent }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Children</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for child in condition.child.all %}
|
||||
<a href="{{child.get_absolute_url}}">{{child}}</a>
|
||||
{% endfor %}<br />
|
||||
RCR condition: {{condition.rcr_curriculum}}<br/>
|
||||
<a class="badge bg-info text-dark text-decoration-none me-1" href="{{ child.get_absolute_url }}">{{ child }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">RCR condition</dt>
|
||||
<dd class="col-sm-8">{% if condition.rcr_curriculum %}Yes{% else %}No{% endif %}</dd>
|
||||
|
||||
{% if not condition.rcr_curriculum %}
|
||||
|
||||
RCR condition map:
|
||||
<dt class="col-sm-4">RCR condition map</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for map in condition.rcr_curriculum_map.all %}
|
||||
<a href="{{map.get_absolute_url}}">{{map}}</a>
|
||||
{% endfor %}<br />
|
||||
<a class="badge bg-success text-white text-decoration-none me-1" href="{{ map.get_absolute_url }}">{{ map }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{% if condition.case_set.all %}
|
||||
<h4>Associated Cases</h4>
|
||||
<ul>
|
||||
|
||||
{% if condition.case_set.exists %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Associated Cases</h5>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for case in condition.case_set.all %}
|
||||
<li>{{case.get_link}}</li>
|
||||
<li class="mb-1">{{ case.get_link }}</li>
|
||||
{% endfor %}
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if condition.differential_set.all %}
|
||||
<h4>Associated Case Differentials</h4>
|
||||
<ul>
|
||||
|
||||
{% if condition.differential_set.exists %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Associated Case Differentials</h5>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for diff in condition.differential_set.all %}
|
||||
<li>{{diff.case.get_link}} [{{diff.text}}]</li>
|
||||
<li class="mb-1">{{ diff.case.get_link }} <small class="text-muted">[{{ diff.text }}]</small></li>
|
||||
{% endfor %}
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if condition.sbas_questions.exists %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Related SBA Questions</h5>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for q in condition.sbas_questions.all %}
|
||||
<li class="mb-1"><a href="{{ q.get_absolute_url }}">{% if q.title %}{{ q.title }}{% else %}{{ q.get_stem_stripped|truncatechars:140 }}{% endif %}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not condition.rcr_curriculum and can_merge %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Merge</h5>
|
||||
<p class="small text-muted">Merge this condition into another. This will transfer associated cases and differentials but will not transfer synonyms, parents or children.</p>
|
||||
|
||||
<details><summary>Merge</summary>
|
||||
Merge this condition into another. This will transfer all associated cases (and differential) but will not transfer synonymns / parents or children.
|
||||
<form method="POST">
|
||||
<p>
|
||||
<a class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" href="#mergeCollapse" role="button" aria-expanded="false" aria-controls="mergeCollapse">Show merge form</a>
|
||||
</p>
|
||||
|
||||
<div class="collapse" id="mergeCollapse">
|
||||
<div class="card card-body">
|
||||
<form method="POST" id="condition-merge-form">
|
||||
{% csrf_token %}
|
||||
<table>
|
||||
{{ form.as_table }}
|
||||
</table>
|
||||
<button hx-post='{% url "atlas:condition_merge" condition.pk %}'
|
||||
<div class="mb-3">
|
||||
{{ form.as_p }}
|
||||
</div>
|
||||
<button class="btn btn-danger"
|
||||
hx-post='{% url "atlas:condition_merge" condition.pk %}'
|
||||
hx-target="#merge-options" hx-swap="innerHTML"
|
||||
hx-confirm="This will merge {{condition.name}} into the selecetd condition, this cannot be undone. Continue?"
|
||||
>
|
||||
hx-confirm="This will merge {{condition.name}} into the selected condition, this cannot be undone. Continue?">
|
||||
Merge
|
||||
</button>
|
||||
</form>
|
||||
<div id="merge-options"></div>
|
||||
</details>
|
||||
<div id="merge-options" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<aside class="col-lg-4">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle mb-2 text-muted">Quick facts</h6>
|
||||
<p class="mb-0"><strong>Case count:</strong> {{ condition.case_set.count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
{% extends "atlas/base.html" %}
|
||||
<!-- {% load static from static %} -->
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block css %}{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
||||
{{ form.media }}
|
||||
|
||||
<script type="text/javascript">
|
||||
</script>
|
||||
|
||||
<!-- {{ form.media }} -->
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Add / Edit Condition</h2>
|
||||
<p>Use this form to create or edit a condition.</p>
|
||||
<p>Please check if it already <a href='{% url "atlas:condition_view" %}'>exists</a> before doing so!</p>
|
||||
|
||||
<form action="" method="post" enctype="multipart/form-data" id="condition-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<table>
|
||||
{{ form.as_table }}
|
||||
</table>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
{% crispy form %}
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
{% if collection %}
|
||||
{% include "atlas/collection_headers.html" %}
|
||||
{% endif %}
|
||||
|
||||
{% comment %} Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=exam.pk %}">Overview</a> /
|
||||
<a href="{% url 'atlas:collection_mark_overview' exam.pk %}">Mark</a> /
|
||||
<a href="{% url 'atlas:collection_scores_cid' exam.pk %}">Scores</a> /
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
</div>
|
||||
<div>
|
||||
<h3>Name: {{finding.name}}</h3>
|
||||
Primary name: {{finding.primary}}<br />
|
||||
Synonyms: {{finding.synonym.all|join:", "}}<br />
|
||||
Primary name: {% if finding.canonical %}No (alias of <a href="{{ finding.canonical.get_absolute_url }}">{{ finding.canonical.name }}</a>){% else %}Yes{% endif %}<br />
|
||||
Synonyms: {{ finding.get_synonym_link|safe }}<br />
|
||||
</div>
|
||||
{% if finding.seriesfinding_set.all %}
|
||||
<h4>Associated Cases</h4>
|
||||
@@ -27,6 +27,15 @@
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if finding.sbas_questions.all %}
|
||||
<h4>Related SBA Questions</h4>
|
||||
<ul>
|
||||
{% for q in finding.sbas_questions.all %}
|
||||
<li><a href="{{ q.get_absolute_url }}">{% if q.title %}{{ q.title }}{% else %}{{ q.get_stem_stripped|truncatechars:140 }}{% endif %}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
@@ -1,14 +1,76 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Atlas</h2>
|
||||
<div class="container my-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h2 class="mb-3">Atlas</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{% url 'atlas:user_collections' %}">Collections to view / take</a><br/>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Collections</h5>
|
||||
<p class="card-text">Quick access to collections you can view or take.</p>
|
||||
<a href="{% url 'atlas:user_collections' %}" class="btn btn-primary">Collections to view / take</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">My cases / collections</h5>
|
||||
<p>View my <a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">cases</a>.</p>
|
||||
<p>
|
||||
<a href="{% url 'atlas:user_uploads' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-cloud-arrow-up me-1"></i> Uploads awaiting import
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>My cases / collections</h3>
|
||||
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.<br/>
|
||||
|
||||
<a href="{% url 'atlas:user_uploads' %}"><i class="bi bi-cloud-arrow-up"></i> Uploads awaiting import</a>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle mb-2 text-muted">Quick links</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><a href="{% url 'atlas:user_collections' %}">Collections to view / take</a></li>
|
||||
<li><a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">My cases</a></li>
|
||||
<li><a href="{% url 'atlas:user_uploads' %}">Uploads awaiting import</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Search cases</h5>
|
||||
<div class="mb-2">
|
||||
<div class="input-group">
|
||||
<input class="form-control" id="case-search-input" name="q" type="search"
|
||||
placeholder="Search cases by title or description"
|
||||
hx-get="{% url 'atlas:case_search_partial' %}"
|
||||
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
|
||||
hx-target="#case-search-results"
|
||||
hx-indicator="#case-search-indicator" />
|
||||
<span class="input-group-text" id="case-search-indicator" aria-hidden="true">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
/* Hide indicator by default; HTMX will add the `htmx-request` class during requests */
|
||||
#case-search-indicator { display: none; }
|
||||
#case-search-indicator.htmx-request { display: inline-flex; align-items: center; }
|
||||
</style>
|
||||
<div id="case-search-results">
|
||||
{% include 'atlas/partials/_case_search_results.html' with cases=None %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends "atlas/base.html" %}
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h1>Normals</h1>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<form method="get" class="form-inline">
|
||||
{% for field in filter.form.visible_fields %}
|
||||
<div class="mb-2">
|
||||
{{ field.label_tag }}
|
||||
{{ field }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="mb-2">
|
||||
<button class="btn btn-primary" type="submit">Filter</button>
|
||||
<a class="btn btn-link" href="?">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if page_obj.object_list %}
|
||||
<div class="list-group">
|
||||
{% for normal in page_obj.object_list %}
|
||||
<a class="list-group-item list-group-item-action" href="{% url 'atlas:case_detail' normal.case.pk %}">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">{{ normal.case.title }}</h5>
|
||||
<small class="text-muted">{{ normal.added_date|date:"Y-m-d" }}</small>
|
||||
</div>
|
||||
<p class="mb-1">
|
||||
Age: {{ normal.display_age }}
|
||||
{% if normal.examination %} • Exam: {{ normal.examination }}{% endif %}
|
||||
{% if normal.modality %} • Modality: {{ normal.modality }}{% endif %}
|
||||
</p>
|
||||
{% if normal.notes %}
|
||||
<p class="mb-0 text-muted">{{ normal.notes }}</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="list-group-item">No normal cases found.</div>
|
||||
{% endif %}
|
||||
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination mt-3">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
|
||||
{% endif %}
|
||||
<li class="page-item disabled"><span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{# partial: list of cases for HTMX search results #}
|
||||
<div class="list-group">
|
||||
{% if cases %}
|
||||
{% for case in cases %}
|
||||
<a href="{% url 'atlas:case_detail' case.pk %}" class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">{{ case.title }}</h6>
|
||||
<small class="text-muted">{{ case.created_date|date:"Y-m-d" }}</small>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small>
|
||||
{% if case.condition %}<span class="badge bg-secondary me-1">{{ case.condition.name }}</span>{% endif %}
|
||||
{% if case.presentation %}<span class="badge bg-secondary me-1">{{ case.presentation.name }}</span>{% endif %}
|
||||
{% if case.subspecialty %}<span class="badge bg-secondary">{{ case.subspecialty.name }}</span>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-1">
|
||||
{# show up to three series modalities/examinations if present #}
|
||||
{% for s in case.series.all|slice:":3" %}
|
||||
<span class="badge bg-light text-dark me-1">
|
||||
{% if s.modality %}{{ s.modality.modality }}{% endif %}
|
||||
{% if s.examination %} / {{ s.examination.examination }}{% endif %}
|
||||
</span>
|
||||
{% empty %}
|
||||
<small class="text-muted">No series</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<p class="mb-1 text-truncate">{{ case.description }}</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="list-group-item">No cases found</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
{% for cd in casedetails %}
|
||||
{% with idx=forloop.counter0 %}
|
||||
{% if cid %}
|
||||
<a class="dropdown-item {% if idx == current %}active{% endif %}" href="{% url 'atlas:collection_case_view_take' pk=collection.pk case_number=idx cid=cid passcode=passcode %}">
|
||||
{% else %}
|
||||
<a class="dropdown-item {% if idx == current %}active{% endif %}" href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=idx %}">
|
||||
{% endif %}
|
||||
Case {{ forloop.counter }}: {{ cd.case.title|default:cd.case.pk|truncatechars:60 }}
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,37 @@
|
||||
<div id="normal-modal-{{ case.pk }}" class="modal modal-blur fade" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<form method="post" hx-post="{% url 'atlas:case_create_normal' case.pk %}" hx-target="#normal-toggle-block" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Mark case {{ case.pk }} as Normal</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% load crispy_forms_tags %}
|
||||
{{ form.media }}
|
||||
{% crispy form %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-link" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Normal</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Show the modal after HTMX injects it
|
||||
(function(){
|
||||
try {
|
||||
var el = document.getElementById('normal-modal-{{ case.pk }}');
|
||||
if(el){
|
||||
var m = new bootstrap.Modal(el);
|
||||
m.show();
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to show normal modal', e);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,11 @@
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1"><a href="{{ normal.case.get_absolute_url }}">{{ normal.case.title }}</a></h5>
|
||||
<small>{{ normal.added_date|date:"Y-m-d" }}</small>
|
||||
</div>
|
||||
<p class="mb-1">Age: {{ normal.display_age }}</p>
|
||||
<p class="mb-1">Examination: {{ normal.examination }} | Modality: {{ normal.modality }}</p>
|
||||
{% if normal.notes %}
|
||||
<p class="mb-0">Notes: {{ normal.notes }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
{% load static %}
|
||||
|
||||
<div id="normal-toggle-block">
|
||||
{% if case.normal_case %}
|
||||
<span class="badge bg-success">Normal</span>
|
||||
<small class="text-muted">{{ case.normal_case.display_age }}</small>
|
||||
{% if can_mark_normal %}
|
||||
<button class="btn btn-sm btn-outline-danger ms-2"
|
||||
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
|
||||
hx-target="#normal-toggle-block"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Unmark this case as normal?">
|
||||
Unmark normal
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if can_mark_normal %}
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
hx-get="{% url 'atlas:case_normal_form' case.pk %}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
Mark as normal
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-muted">Normal status only editable by atlas editors or case authors</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,80 @@
|
||||
<div id="prior-card-{{case.id}}" class="col-12 col-md-6 col-lg-4" data-case="{{case.id}}">
|
||||
<div class="card h-100 shadow-sm {% if added %}border-success{% else %}border-secondary{% endif %}">
|
||||
<div class="card-body d-flex flex-column">
|
||||
{% if error %}
|
||||
<div class="alert alert-danger py-1 mb-2 small">{{ error }}</div>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 class="card-title mb-0">{{case.title}}</h5>
|
||||
<div>
|
||||
{% if added %}
|
||||
<span class="badge bg-success">Added</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Available</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="card-text small text-muted mb-2">Relation: <span class="fw-medium">{{ relation }}</span></p>
|
||||
|
||||
<div class="mb-3 small">{{case.get_series_blocks|safe}}</div>
|
||||
|
||||
<div class="mt-auto">
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<a href="{% url 'atlas:case_detail' case.id %}" class="btn btn-sm btn-outline-primary">View case</a>
|
||||
</div>
|
||||
|
||||
{% if not added %}
|
||||
<form class="row g-2"
|
||||
{% if case_number %}
|
||||
hx-post="{% url 'atlas:collection_case_priors' collection.id case_number %}"
|
||||
{% else %}
|
||||
hx-post="{% url 'atlas:collection_case_priors_legacy' collection.id casedetail.case.pk %}"
|
||||
{% endif %}
|
||||
hx-target="#prior-card-{{case.id}}" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="prior_case_id" value="{{case.id}}" />
|
||||
<div class="col-12">
|
||||
<input class="form-control form-control-sm" type="text" name="relation" value="{{relation}}" placeholder="Relation to case" />
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<select class="form-select form-select-sm" 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>
|
||||
</div>
|
||||
<div class="col-4 d-grid">
|
||||
<button class="btn btn-sm btn-primary" type="submit">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<form
|
||||
{% if case_number %}
|
||||
hx-post="{% url 'atlas:collection_case_priors' collection.id case_number %}"
|
||||
{% else %}
|
||||
hx-post="{% url 'atlas:collection_case_priors_legacy' collection.id casedetail.case.pk %}"
|
||||
{% endif %}
|
||||
hx-target="#prior-card-{{case.id}}" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="remove" value="{{case.id}}" />
|
||||
<div class="mb-2 small text-muted">Current relation: <strong>{{relation}}</strong></div>
|
||||
<div class="d-flex gap-2">
|
||||
<select class="form-select form-select-sm w-50" 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>
|
||||
<button class="btn btn-sm btn-danger" type="submit">Remove</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent">
|
||||
<small class="text-muted">Case ID: {{ case.id }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,56 @@
|
||||
{% comment %}Partial that renders the synonyms list + add form. Used by condition_detail and HTMX responses.{% endcomment %}
|
||||
{% load static %}
|
||||
|
||||
{% with syns=condition.get_synonyms %}
|
||||
{% if syns %}
|
||||
{% for syn in syns %}
|
||||
<a class="badge bg-secondary text-decoration-none text-white me-1" href="{{ syn.get_absolute_url }}">{{ syn }}</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if request.user.is_authenticated and can_merge %}
|
||||
<div class="mt-2 d-flex gap-2">
|
||||
<!-- Button to add an existing condition as a synonym -->
|
||||
<a class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" href="#addSynonymCollapse" role="button" aria-expanded="false" aria-controls="addSynonymCollapse">Add synonym</a>
|
||||
|
||||
<!-- Button to create a new condition and add it as a synonym -->
|
||||
<a class="btn btn-sm btn-outline-success" data-bs-toggle="collapse" href="#createSynonymCollapse" role="button" aria-expanded="false" aria-controls="createSynonymCollapse">Create synonym</a>
|
||||
</div>
|
||||
|
||||
<!-- Collapse: add existing condition -->
|
||||
<div class="collapse mt-2" id="addSynonymCollapse">
|
||||
<div class="card card-body">
|
||||
<form method="POST"
|
||||
hx-post="{% url 'atlas:condition_detail' condition.pk %}"
|
||||
hx-target="#synonyms-container" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_synonym" />
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Select existing condition</label>
|
||||
{{ synonym_form.condition }}
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" type="submit">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapse: create new condition -->
|
||||
<div class="collapse mt-2" id="createSynonymCollapse">
|
||||
<div class="card card-body">
|
||||
<form method="POST"
|
||||
hx-post="{% url 'atlas:condition_detail' condition.pk %}"
|
||||
hx-target="#synonyms-container" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_synonym" />
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Create new synonym name</label>
|
||||
<input class="form-control" type="text" name="synonym_name" placeholder="e.g. Granulomatosis with Polyangitis" required />
|
||||
</div>
|
||||
<button class="btn btn-success btn-sm" type="submit">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
{% if previous %}
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url nav_link_view collection.id case_number|add:-1 %}">← Previous</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="h6 mb-0">Viewing as part of a collection</div>
|
||||
<div>
|
||||
<a href="{% url 'atlas:collection_detail' collection.id %}" class="fw-bold">{{collection.name}}</a>
|
||||
<small class="text-muted">[{{case_number|add:1}}/{{collection_length}}]</small>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{% if next %}
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url nav_link_view collection.id case_number|add:1 %}">Next →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-1">
|
||||
{% include "atlas/collection_detail.html#casedetails-management-links" %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<li class="list-group-item d-flex gap-3 align-items-start" data-question_pk="{{ casedetail.case.pk }}">
|
||||
|
||||
<div class="flex-fill">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<a class="h6 mb-0 text-decoration-none case-link"
|
||||
href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}"
|
||||
data-case-base-href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=0 %}">
|
||||
{{ casedetail.case.title }}
|
||||
</a>
|
||||
<div class="small text-muted"><span class="case-number">Case {{ forloop.counter }}</span> — <span class="text-truncate">{{ casedetail.case.description|default_if_none:""|truncatechars:80 }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 small text-muted">
|
||||
{% if casedetail.case.series.count == 0 %}
|
||||
<span class="text-warning">This case has no series / stacks attached</span>
|
||||
{% else %}
|
||||
{{ casedetail.case.series.count }} series
|
||||
{% endif %}
|
||||
{% if casedetail.question_schema %}
|
||||
• <span class="text-success">Questions defined</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center ms-3">
|
||||
{% include 'atlas/partials/casedetails_management_links.html' %}
|
||||
</div>
|
||||
|
||||
</li>
|
||||
@@ -0,0 +1,45 @@
|
||||
{# Search results #}
|
||||
{% if q %}
|
||||
{% if cases and cases.exists %}
|
||||
<div class="list-group mb-2">
|
||||
{% for case in cases %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}">
|
||||
<div class="flex-fill me-3">
|
||||
<div class="fw-semibold">{{ case.title }}</div>
|
||||
<div class="small text-muted">{% if case.description %}{{ case.description|truncatechars:100 }}{% endif %}</div>
|
||||
</div>
|
||||
|
||||
<form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="case" value="{{ case.pk }}">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted small mb-2">No cases found.</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Recent cases quick-add (rendered below search results) #}
|
||||
{% if recent_cases and recent_cases|length > 0 %}
|
||||
<div>
|
||||
<div class="small text-muted mb-1">Recently created</div>
|
||||
<div class="list-group">
|
||||
{% for case in recent_cases %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}">
|
||||
<div class="flex-fill me-3">
|
||||
<div class="fw-semibold">{{ case.title }}</div>
|
||||
<div class="small text-muted">{% if case.description %}{{ case.description|truncatechars:100 }}{% endif %}</div>
|
||||
</div>
|
||||
<form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="case" value="{{ case.pk }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,46 @@
|
||||
<div class="case-search-widget">
|
||||
<label for="case-search-input" class="form-label">Search cases</label>
|
||||
<input id="case-search-input" name="q" class="form-control" type="search"
|
||||
placeholder="Type to search cases..."
|
||||
hx-get="{% url 'atlas:case_search' %}"
|
||||
hx-include="#case-search-input"
|
||||
hx-vals='{"collection": "{{ collection.pk }}"}'
|
||||
hx-target="#case-search-results"
|
||||
hx-trigger="keyup changed delay:400ms"
|
||||
autocomplete="off">
|
||||
|
||||
<div id="case-search-results" class="mt-2">
|
||||
{# Render initial results (search + recent) on initial GET; HTMX searches will replace this div with partial results #}
|
||||
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=collection %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
// Robust fallback: ensure repeated searches always work by using htmx.ajax when available.
|
||||
try {
|
||||
var input = document.getElementById('case-search-input');
|
||||
if (!input) return;
|
||||
var timer = null;
|
||||
input.addEventListener('input', function(){
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(function(){
|
||||
try {
|
||||
var q = encodeURIComponent(input.value || '');
|
||||
var url = "{% url 'atlas:case_search' %}?q=" + q + "&collection={{ collection.pk }}";
|
||||
if (window.htmx && typeof window.htmx.ajax === 'function') {
|
||||
window.htmx.ajax('GET', url, { target: '#case-search-results', swap: 'innerHTML' });
|
||||
} else {
|
||||
// fallback to fetch
|
||||
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
|
||||
var el = document.getElementById('case-search-results');
|
||||
if (el) el.innerHTML = html;
|
||||
}).catch(function(e){ console.error('case search fetch error', e); });
|
||||
}
|
||||
} catch(e) { console.error(e); }
|
||||
}, 400);
|
||||
});
|
||||
} catch(e) { console.error('case search widget init error', e); }
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<div class="btn-group btn-group-sm me-2" role="group" aria-label="management-actions">
|
||||
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Setup default display">
|
||||
<i class="bi bi-display"></i>
|
||||
</a>
|
||||
|
||||
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_details_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Case details">
|
||||
<i class="bi bi-info-square"></i>
|
||||
</a>
|
||||
|
||||
{% if collection.collection_type == "QUE" %}
|
||||
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_questions_legacy" casedetail.collection.pk casedetail.case.pk %}' title="Manage questions">
|
||||
{% if casedetail.question_schema %}
|
||||
<i class="bi bi-question-square text-success"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-question-square text-danger"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if casedetail.case.previous_case %}
|
||||
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_priors_legacy" casedetail.collection.pk casedetail.case.pk %}' title="Manage priors">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="status-indicator">
|
||||
{% if casedetail.default_viewerstate %}
|
||||
<button type="button" class="btn btn-sm btn-outline-success" aria-disabled="true" title="This case has a default viewerstate defined">
|
||||
<i class="bi bi-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Remove button (HTMX) - only shown when the surrounding template has `can_edit` true #}
|
||||
{% if can_edit %}
|
||||
<form class="d-inline ms-2 m-0" hx-post="{% url 'atlas:remove_case_from_collection' casedetail.case.pk casedetail.collection.pk %}" hx-target="closest li" hx-swap="outerHTML" hx-confirm="Remove this case from the collection?">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm case-remove" title="Remove this case from the collection">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,27 @@
|
||||
<div class="answer-block">
|
||||
{% for value, user_answer, correct_answer, answer_is_correct, automark in answer.get_correct_json_answers %}
|
||||
<div class="{% if answer_is_correct %}
|
||||
correct
|
||||
{% else %}
|
||||
incorrect
|
||||
{% endif %}
|
||||
{% if automark %}
|
||||
automark
|
||||
{% endif %}
|
||||
|
||||
">
|
||||
<h4>{{value.title}}</h4>
|
||||
{{value.description}}
|
||||
<div
|
||||
>
|
||||
Answer : {{user_answer}}
|
||||
|
||||
{% if not answer_is_correct %}
|
||||
<br/>Correct answer: {{correct_answer}}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
{# Partial: render questions and answers as HTML. #}
|
||||
|
||||
{% if casedetail.question_schema %}
|
||||
<div class="collection-question-block">
|
||||
<h4>Questions</h4>
|
||||
<dl class="row">
|
||||
{% for name, prop in casedetail.question_schema.properties.items %}
|
||||
<dt class="col-sm-4">{{ prop.title|default:name }}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% if prop.description %}
|
||||
<div class="mb-1 text-muted small">{{ prop.description }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if prop.type == "string" and prop.enum %}
|
||||
<div class="mt-1 small text-muted">Options: {{ prop.enum|join:", " }}</div>
|
||||
{% else %}
|
||||
<div class="mt-1 small text-muted">Type: {{ prop.type }}</div>
|
||||
{% endif %}
|
||||
|
||||
{# Safely fetch the stored/example answer using the project's `get_item` filter. #}
|
||||
{% if casedetail.question_answers %}
|
||||
{% with correct=casedetail.question_answers|get_item:name %}
|
||||
<div class="mt-2">
|
||||
<strong>Example / Correct:</strong>
|
||||
{% if correct %}
|
||||
<span class="ms-2">{{ correct }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted ms-2">(no answer provided)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<div class="mt-2"><strong>Example / Correct:</strong> <span class="text-muted ms-2">(no answer provided)</span></div>
|
||||
{% endif %}
|
||||
|
||||
{# Optionally render and compare a user's answer passed as `user_answer` in the context #}
|
||||
{% if user_answer %}
|
||||
{% if user_answer|get_item:name %}
|
||||
{% with ua=user_answer|get_item:name %}
|
||||
{% with correct=casedetail.question_answers|get_item:name %}
|
||||
<div class="mt-1">
|
||||
<strong>Your answer:</strong>
|
||||
<span class="ms-2 {% if ua == correct %}text-success{% else %}text-danger{% endif %}">{{ ua }}</span>
|
||||
{% if ua == correct %}
|
||||
<span class="badge bg-success ms-2">Correct</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger ms-2">Incorrect</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<div class="mt-1"><strong>Your answer:</strong> <span class="text-muted ms-2">(no answer)</span></div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="collection-question-block text-muted">No questions defined for this case.</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,33 @@
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="mb-2"><span class="badge bg-secondary">Atlas</span></div>
|
||||
<h5 class="card-title">Question</h5>
|
||||
<p class="lead">{{ question }}</p>
|
||||
|
||||
<p>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
|
||||
</p>
|
||||
|
||||
<div class="d-flex justify-content-between mt-3">
|
||||
{% if prev_index is not None %}
|
||||
<a class="btn btn-outline-primary"
|
||||
hx-get="{% url app_name|add:':exam_review_question' exam.pk prev_index %}"
|
||||
hx-target="#review-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="true">← Previous</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
|
||||
{% if next_index is not None %}
|
||||
<a class="btn btn-primary"
|
||||
hx-get="{% url app_name|add:':exam_review_question' exam.pk next_index %}"
|
||||
hx-target="#review-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="true">Next →</a>
|
||||
{% else %}
|
||||
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
{# Partial: series cases list. Expects `series` and `can_edit` in context. Returns the <ul> that is inserted into #cases-list. #}
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for case in series.case.all %}
|
||||
<li id="case-link-{{ case.pk }}" class="list-group-item d-flex justify-content-between align-items-center case-item">
|
||||
<a href="{% url 'atlas:case_detail' pk=case.pk %}">{{case}}</a>
|
||||
{% if can_edit %}
|
||||
<button hx-post="{% url 'atlas:remove_series_from_case' series.pk case.pk %}"
|
||||
hx-target="#cases-list"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Are you sure you want to remove this series from the case?"
|
||||
class="btn btn-link btn-sm text-danger remove-button">Remove</button>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="list-group-item">This series is not associated with any cases.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -0,0 +1,31 @@
|
||||
{% load static %}
|
||||
|
||||
<div class="series-images-list">
|
||||
{% for image in images %}
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
{% if image.image %}
|
||||
<a href="{% url 'atlas:series_image_dicom' pk=image.pk %}" target="_blank" class="me-2">
|
||||
<img src="{{ image.image.url }}" alt="image-{{ image.pk }}" style="width:96px;height:auto;object-fit:cover;border-radius:.25rem;" class="img-thumbnail">
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="me-2 bg-secondary" style="width:96px;height:72px;border-radius:.25rem;"></div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<div><strong>{{ image.image.name|default:"(no file)" }}</strong></div>
|
||||
{% if image.image %}
|
||||
<div class="small text-muted">{{ image.image.size|filesizeformat }}</div>
|
||||
{% endif %}
|
||||
{% if image.image_blake3_hash %}
|
||||
<div class="small text-muted">hash: {{ image.image_blake3_hash|truncatechars:12 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="ms-auto text-end">
|
||||
<a href="{% url 'atlas:series_image_dicom' pk=image.pk %}" class="btn btn-sm btn-outline-secondary">View DICOM</a>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-muted">No images found for this series.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user