Compare commits
769
Commits
3f5f39c41c
...
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 | ||
|
|
779559997e | ||
|
|
0db1e4c689 | ||
|
|
6b1db6b3c5 | ||
|
|
9ed8fd90b3 | ||
|
|
30523c9296 | ||
|
|
83e8c14f31 | ||
|
|
d7a410e8f8 | ||
|
|
7cc6b2959a | ||
|
|
7ff5d1d37e | ||
|
|
5270d5cd36 | ||
|
|
214b531757 | ||
|
|
963221633a | ||
|
|
149b95a7e4 | ||
|
|
8a92460cb2 | ||
|
|
a7e0438290 | ||
|
|
2fca5a098d | ||
|
|
6a8bded825 | ||
|
|
15c5b2e234 | ||
|
|
964bce8233 | ||
|
|
16a8bf3d3a | ||
|
|
9bda3f22db | ||
|
|
61c3fb8a82 | ||
|
|
f4ed53dedd | ||
|
|
125cbef6ef | ||
|
|
4afcfb6b26 | ||
|
|
2a351a8283 | ||
|
|
d61ecaac66 | ||
|
|
e71aa4c802 | ||
|
|
a8840290f5 | ||
|
|
99bc89fe20 | ||
|
|
754d46080d | ||
|
|
43d98d7adb | ||
|
|
bc5749a308 | ||
|
|
eb194aa1e6 | ||
|
|
63c342ff21 | ||
|
|
8e63dcc7c5 | ||
|
|
ddb5783f7f | ||
|
|
816b2adde2 | ||
|
|
b3304b6113 | ||
|
|
0af6b25349 | ||
|
|
c33e8c0240 | ||
|
|
a2fa94722a | ||
|
|
9200e9aae5 | ||
|
|
8806e48eb2 | ||
|
|
4460545442 | ||
|
|
ad4be1e9a6 | ||
|
|
6a919ffc53 | ||
|
|
8d80090af0 | ||
|
|
f5258d7e6f | ||
|
|
5187885c3b | ||
|
|
ec97fa0c71 | ||
|
|
8fc38557ac | ||
|
|
804cff33f4 | ||
|
|
0cdccbcdfb | ||
|
|
c8939cf385 | ||
|
|
d7499e9166 | ||
|
|
f3b6f8ef59 | ||
|
|
2ac8344b13 | ||
|
|
4df7e5aebd | ||
|
|
8229813eba | ||
|
|
153045a3e4 | ||
|
|
bc26209f07 | ||
|
|
afa3b0c0ef | ||
|
|
2f8601aa1a | ||
|
|
820d0bc030 | ||
|
|
0c07400cc3 | ||
|
|
b825342a24 | ||
|
|
4a86a95c91 | ||
|
|
4873732b39 | ||
|
|
6439ec9aa4 | ||
|
|
394ef2196e | ||
|
|
13e6bc37b8 | ||
|
|
7070da01b0 | ||
|
|
363396786f | ||
|
|
d66783b2b8 | ||
|
|
e922a93a25 | ||
|
|
90612209f2 | ||
|
|
3c8cae769d | ||
|
|
c750cb0fa9 | ||
|
|
3873d24f7b | ||
|
|
8e92de3420 | ||
|
|
503c8f854a | ||
|
|
ead5bfcace | ||
|
|
77ee83cb08 | ||
|
|
9f5fc09e23 | ||
|
|
7689bded66 | ||
|
|
79f196966e | ||
|
|
5adbf619fa | ||
|
|
6ce54372ff | ||
|
|
e2aaf64258 | ||
|
|
1599e48584 | ||
|
|
e119dceaa1 | ||
|
|
5f94616168 | ||
|
|
a43a6e0fed | ||
|
|
fe44fa23a3 | ||
|
|
168398d9bc | ||
|
|
6564b25250 | ||
|
|
5e578ca2b0 | ||
|
|
a13be87077 | ||
|
|
dc1bf46004 | ||
|
|
06e66ad52c | ||
|
|
405b17776f | ||
|
|
f584ea07a3 | ||
|
|
e3762c39cb | ||
|
|
eea61fc99c | ||
|
|
edf4a992de | ||
|
|
5d37cf4284 | ||
|
|
dd334c6d6d | ||
|
|
7fe7823deb | ||
|
|
d387e32777 | ||
|
|
afa1379ce4 | ||
|
|
bcc449eb34 | ||
|
|
e27d45f3c5 | ||
|
|
35c67cbc80 | ||
|
|
2865935a95 | ||
|
|
0b575bc64c | ||
|
|
580249a82d | ||
|
|
2c22616cc7 | ||
|
|
73cd6b7591 | ||
|
|
c25f285202 | ||
|
|
baeaf3d9c6 | ||
|
|
8f3e8ab510 | ||
|
|
fb931f5a15 | ||
|
|
2d9c83ff95 | ||
|
|
1a1c455c03 | ||
|
|
c5ba153865 | ||
|
|
b3076d6dda | ||
|
|
de655c0d6a | ||
|
|
598a7118b2 | ||
|
|
f6b78a90ce | ||
|
|
486bb94ea9 | ||
|
|
f43c54f4af | ||
|
|
c1f5147af7 | ||
|
|
6cbba4f6fe | ||
|
|
b924bb2390 | ||
|
|
a0960c9b7c | ||
|
|
249bc656c8 | ||
|
|
ec55d1507e | ||
|
|
b4cc8a281d | ||
|
|
0ca17c23b5 | ||
|
|
9cb83b62d2 | ||
|
|
9d4487b7f5 | ||
|
|
9bb4d8bc08 | ||
|
|
7b45483870 | ||
|
|
d891c7b9ee | ||
|
|
f0c5964a5a | ||
|
|
ac96745fa1 | ||
|
|
7fc12737c7 | ||
|
|
e6beb734ab | ||
|
|
3b258adf59 | ||
|
|
ef49f6a5eb | ||
|
|
97e76bb100 | ||
|
|
58301dd251 | ||
|
|
3c72a06ad2 | ||
|
|
70bb6dc15a | ||
|
|
3e32b33864 | ||
|
|
056a51331e | ||
|
|
cb47e488ed | ||
|
|
7493d55bc2 | ||
|
|
13713d870f | ||
|
|
4981402ca9 | ||
|
|
b6fcb9a401 | ||
|
|
fa3f87181c | ||
|
|
6ee0187dd3 | ||
|
|
ffc8a245e4 | ||
|
|
22812c5ec1 | ||
|
|
178ec034a3 | ||
|
|
1fdb4c9159 | ||
|
|
55507d215c | ||
|
|
8c43c40215 | ||
|
|
e0974ae300 | ||
|
|
7e928c6887 | ||
|
|
b5e6a294ff | ||
|
|
267f53906e | ||
|
|
7da968825c | ||
|
|
12144e06d1 | ||
|
|
1c5f3a3425 | ||
|
|
187f7d9a83 | ||
|
|
d237cd0a5e | ||
|
|
99862e2e60 | ||
|
|
6a58cdb14f | ||
|
|
4b6ffd4559 | ||
|
|
6f19d0a3ed | ||
|
|
46f5a99697 | ||
|
|
de2b84e099 | ||
|
|
c6485333bb | ||
|
|
720debc2a5 | ||
|
|
69664b7b50 | ||
|
|
92f9cabcc0 | ||
|
|
4476d0b55f | ||
|
|
6d20beebce | ||
|
|
8d62673f06 | ||
|
|
5eb5fe3f50 | ||
|
|
695e651ea5 | ||
|
|
91ad134115 | ||
|
|
40ae9e1b64 | ||
|
|
1358b15986 | ||
|
|
f4eb874544 | ||
|
|
a9687345c6 | ||
|
|
ba29c9c881 | ||
|
|
4bc0e3be93 | ||
|
|
d0d680edc5 | ||
|
|
8be4c4f923 | ||
|
|
00323cc6bd | ||
|
|
9c69792e87 | ||
|
|
fad940bcd4 | ||
|
|
57497cc97c | ||
|
|
2a1f15683e | ||
|
|
2aff0f6a32 | ||
|
|
ed933c8104 | ||
|
|
a65ceffbfb | ||
|
|
fa2d270dfb | ||
|
|
4e2e350e52 | ||
|
|
30168b7624 | ||
|
|
80358e2113 | ||
|
|
eab6fc9d7e | ||
|
|
d8b07449a4 | ||
|
|
100d23b068 | ||
|
|
a0a6d7f415 | ||
|
|
bf14d4b7ee | ||
|
|
7c5cfae5d5 | ||
|
|
a4c7aeb84d | ||
|
|
33c2054bb3 | ||
|
|
ec36597dbc | ||
|
|
7b1fee464b | ||
|
|
3a2b89a1fd | ||
|
|
155c036f0c | ||
|
|
cee4009eb8 | ||
|
|
dedce34d6e | ||
|
|
2faedbcee6 | ||
|
|
5397bf10ac | ||
|
|
162776c103 | ||
|
|
9f6740accb | ||
|
|
a192157d81 | ||
|
|
ac0b556dfa | ||
|
|
0776400941 | ||
|
|
a20199cb98 | ||
|
|
37bf203bf2 | ||
|
|
3cb8d53d3f | ||
|
|
6ba7be7abd | ||
|
|
eeb0483223 | ||
|
|
76c96a32fe | ||
|
|
2dc2346962 | ||
|
|
0bc6d63c7a | ||
|
|
68c9c33f4c | ||
|
|
ce56de1b95 | ||
|
|
fc6e215cc2 | ||
|
|
a2907fa285 | ||
|
|
375261dd84 | ||
|
|
21fa4739ee | ||
|
|
7981684005 | ||
|
|
0d7cac594b | ||
|
|
bfe5be807e | ||
|
|
eeaa345f3c | ||
|
|
ad54fce54b | ||
|
|
aa9c109b68 | ||
|
|
b8aa7691cd | ||
|
|
7b4b88b573 | ||
|
|
7151d036cf | ||
|
|
ad0bc7c6ee | ||
|
|
87c1ac4e96 | ||
|
|
6b064b2653 | ||
|
|
185942b716 | ||
|
|
38fc924222 | ||
|
|
4630bf127b | ||
|
|
46cf69a7b6 | ||
|
|
78f2067bc0 | ||
|
|
16e3e12b2a | ||
|
|
615d5f8f80 | ||
|
|
61293f90a6 | ||
|
|
b0deb17951 | ||
|
|
502e4d3f83 | ||
|
|
bb33533501 | ||
|
|
8b84654ee3 | ||
|
|
ffac3175e1 | ||
|
|
57d3ea909a | ||
|
|
2c1043c2dc | ||
|
|
e69d1a6b9a | ||
|
|
f4c82f45f5 | ||
|
|
436a04e8dd | ||
|
|
87192466ed | ||
|
|
4d0a105ab2 | ||
|
|
2d1ddbd5cf | ||
|
|
ba607f3c52 | ||
|
|
955ec6831a | ||
|
|
fc6b2027c3 | ||
|
|
6812935d6c | ||
|
|
2698e3f9d6 | ||
|
|
b7a0c60b16 | ||
|
|
850da0c2ac | ||
|
|
8fe64fc316 | ||
|
|
8964a5598f | ||
|
|
2e80a4ee0a | ||
|
|
28bfa34367 | ||
|
|
7b938291ce | ||
|
|
1e55d195e9 |
@@ -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
|
||||
+6
-1
@@ -1,4 +1,5 @@
|
||||
media/
|
||||
mediafiles/
|
||||
|
||||
__pycache__
|
||||
.vscode/
|
||||
@@ -10,4 +11,8 @@ venv
|
||||
|
||||
*.log
|
||||
|
||||
temp/
|
||||
temp/
|
||||
|
||||
# Loki runtime data
|
||||
/docker/loki-data/
|
||||
/docker/loki-wal/
|
||||
+10
-4
@@ -1,5 +1,6 @@
|
||||
# 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
|
||||
WORKDIR /usr/src/rad
|
||||
@@ -13,13 +14,18 @@ ENV PYTHONUNBUFFERED 1
|
||||
# python3 python3-dev gcc \
|
||||
# gfortran musl-dev \
|
||||
# libffi-dev openssl-dev
|
||||
RUN apt-get update && apt-get -y install postgresql postgresql-contrib git libcairo2-dev pkg-config gcc
|
||||
RUN apt-get update && apt-get -y install postgresql postgresql-contrib git libcairo2-dev pkg-config gcc redis-tools watchman
|
||||
|
||||
|
||||
# install dependencies
|
||||
RUN pip install --upgrade pip
|
||||
#RUN pip install --upgrade pip
|
||||
COPY ./requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
RUN uv venv /opt/venv
|
||||
# Use the virtual environment automatically
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
# Place entry points in the environment at the front of the path
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
RUN uv pip install -r requirements.txt
|
||||
|
||||
# copy project
|
||||
COPY . .
|
||||
@@ -0,0 +1,75 @@
|
||||
PenraCourses
|
||||
|
||||
# Access to the platform
|
||||
|
||||
URL: https://www.penracourses.org.uk/
|
||||
|
||||
Peninsula trainees are now automatically given access as numerous assessments / courses are run via the platform.
|
||||
|
||||
Other uses can have accounts created on request. Please contact ross.kruger@nhs.net
|
||||
|
||||
If available accounts will be set up with a users @nhs.net email
|
||||
|
||||
# Features
|
||||
|
||||
## Simulation of the RCR exam formats.
|
||||
This includes the ability to mimic the pratique/risr software used in the actual exam. This web based so can be accessed from anywhere.
|
||||
|
||||
Supported exam types
|
||||
- Physics
|
||||
- Anatomy
|
||||
- Rapids (old 2b format)
|
||||
- Shorts (new 2b format)
|
||||
- Longs
|
||||
|
||||
In each case exams/packets can be designed to either be "self marked" (user can take the exam and self review answers with no answer data submitted back to the platform) or "exam mode" where user answers are submitted back to the platform to be marked. The platform offers features to make marking as simple and quick as possible.
|
||||
|
||||
Once marked results can be published for the user to review.
|
||||
|
||||
Exams can be restricted to individual users or groups of users. Access can be restricted by date / time or manual activation.
|
||||
|
||||
Viva support is achieved via collections (see below).
|
||||
|
||||
Candidates for packets / exams can either be registered users of the platform or generated using a CID/Passcode (in which case no further user data need be stored on the platform, although an email address allows automatically messaging)
|
||||
|
||||
## Atlas
|
||||
The platform supports uploading cases into a virtual "Atlas".
|
||||
|
||||
These cases can be categorised and tagged to allow easy retrieval via searching / other methods.
|
||||
|
||||
Each case can contain multiple image stacks/series. Related cases can be linked to allow display as a time series.
|
||||
|
||||
Cases can be viewed via the built in dicom viewer.
|
||||
|
||||
Images can be annotated and categorised (via finding, condition or anatomical location). It is also possible to create more complex display sets (analogous to bookmarks in Insight)
|
||||
|
||||
Resources (videos, pdfs, etc) can be attached to cases.
|
||||
|
||||
|
||||
### Case Collections
|
||||
Case collections are sets of cases. These can take a number of different formats that alters how they are displayed to the end user.
|
||||
|
||||
Once collections have been completed a user can share their results / progress with nominated supervisors.
|
||||
|
||||
|
||||
- **Review**:
|
||||
|
||||
Cases are displayed sequentially to a user. No user input is stored. User are given predefined feedback (either after each case or at the end of the collection).
|
||||
|
||||
- **Report**:
|
||||
|
||||
Cases are displayed sequentially to the user. The user is able to enter a report for each case. Feedback can be given automatically (and a user self review) or the reports can be reviewed (and marked/assessed) by the collection author. Feedback can be given once the whole collection is complete or after each case.
|
||||
|
||||
- **Question**:
|
||||
|
||||
Cases are displayed sequentially to the user. Each case is linked to a predefined set of questions. Supported question types include
|
||||
- Text answers
|
||||
- True / False (or Yes / No)
|
||||
- Multiple choice (single or multiselect)
|
||||
- Numerical
|
||||
|
||||
Each case can have multiple questions associated, these may be the same or different for each case in the series.
|
||||
|
||||
- **Viva**:
|
||||
|
||||
Viva collections are designed to make the delivery of viva type sessions as easy as possible. This collection type allows the synchronisation of two windows. This allows one to be shared (via teams or other screen sharing software) whilst information pertinent to the case is displayed in the other window. Cases can be show as a case or series and it is possible to recreate predefined display sets.
|
||||
+15
-18
@@ -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):
|
||||
@@ -113,8 +112,6 @@ def get_exam_user_status_cid_user(request, exam_id: int, cid: int):
|
||||
if not exam.check_user_can_edit(request.user):
|
||||
raise HttpError(HTTPStatus.UNAUTHORIZED,"Exam author required")
|
||||
|
||||
print(exam_id, cid)
|
||||
|
||||
return exam.exam_user_status.filter(cid_user_exam__cid_user__cid=cid)
|
||||
|
||||
@router.get('/exam/{exam_id}/user_status/{user_id}/user', response=List[ExamStatusSchema])
|
||||
|
||||
+6
-1
@@ -252,4 +252,9 @@ class ExamMarkerForm(ExamMarkerFormMixin):
|
||||
|
||||
class ExamGroupsForm(ExamGroupsFormMixin):
|
||||
class Meta(ExamGroupsFormMixin.Meta):
|
||||
model = Exam
|
||||
model = Exam
|
||||
|
||||
class SuggestIncorrectAnswerForm(ModelForm):
|
||||
class Meta:
|
||||
model = AnatomyQuestion
|
||||
fields = ["answer_suggest_incorrect"]
|
||||
@@ -0,0 +1 @@
|
||||
"""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)"))
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.0.2 on 2024-10-09 20:39
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0021_alter_anatomyquestion_answer_help_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='exam',
|
||||
name='markers',
|
||||
field=models.ManyToManyField(blank=True, help_text='Authorised markers for the exam', related_name='anatomy_exam_markers', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-10-14 11:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0022_alter_exam_markers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='exam_open_access',
|
||||
field=models.BooleanField(default=False, help_text='Set to true if you want any registered user to be able to take the exam.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-10-28 11:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0023_exam_exam_open_access'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='results_supervisor_visible',
|
||||
field=models.BooleanField(default=False, help_text='If true the results will be visible to supervisors without candidate approval.'),
|
||||
),
|
||||
]
|
||||
+23
-10
@@ -22,6 +22,7 @@ from collections import defaultdict
|
||||
from helpers.images import image_as_base64
|
||||
|
||||
import reversion
|
||||
import urllib.parse
|
||||
|
||||
image_storage = FileSystemStorage(
|
||||
# Physical file location ROOT
|
||||
@@ -131,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)
|
||||
@@ -179,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)
|
||||
|
||||
@@ -335,6 +341,13 @@ class Answer(models.Model):
|
||||
This just mirrors the result from the attached question"""
|
||||
return self.question.can_edit(user)
|
||||
|
||||
|
||||
def get_answer_url_safe(self):
|
||||
return urllib.parse.quote(self.answer_compare)
|
||||
|
||||
def get_constituent_words(self):
|
||||
return self.answer_compare.split()
|
||||
|
||||
# def get_compare_string(self):
|
||||
# s = self.answer.lower().strip()
|
||||
# s = s.translate(str.maketrans('', '', string.punctuation))
|
||||
@@ -396,7 +409,6 @@ class Exam(ExamBase):
|
||||
markers = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Authorised markers for the exam",
|
||||
related_name="anatomy_exam_markers",
|
||||
)
|
||||
@@ -485,7 +497,6 @@ class Exam(ExamBase):
|
||||
return answer
|
||||
|
||||
def get_question_user_user_answer(self, question_index, user):
|
||||
print(user)
|
||||
question = self.get_question_by_index(question_index)
|
||||
answer = UserAnswer.objects.filter(question=question, user=user.pk, exam=self)
|
||||
|
||||
@@ -541,6 +552,8 @@ class UserAnswer(UserAnswerBase):
|
||||
# s = self.answer.lower().strip()
|
||||
# s = s.translate(str.maketrans('', '', string.punctuation))
|
||||
# return s
|
||||
def get_answer_url_safe(self):
|
||||
return urllib.parse.quote(self.answer_compare)
|
||||
|
||||
def get_answer_string(self):
|
||||
return self.answer
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# serializers.py
|
||||
from rest_framework import serializers, permissions
|
||||
|
||||
|
||||
from .models import Exam
|
||||
|
||||
class ExamSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = Exam
|
||||
fields = ('name', 'id', 'active', "publish_results")
|
||||
+394
-131
@@ -12,9 +12,24 @@ window.control_pressed = false;
|
||||
|
||||
|
||||
$(document).ready(function () {
|
||||
// Select all text input fields and textareas with the clear button
|
||||
clearableTextAreaSetup();
|
||||
|
||||
|
||||
|
||||
|
||||
document.body.addEventListener("htmx:responseError", function(e) {
|
||||
let error = e.detail.xhr.response;
|
||||
let el = document.getElementById("htmx-error");
|
||||
var error;
|
||||
if (e.detail.pathInfo.requestPath.includes("cimar") && e.detail.xhr.status == 403) {
|
||||
error = "Cimar permission error, please login and refresh the page.";
|
||||
//document.getElementById("cimar-login-form").focus();
|
||||
htmx.trigger("#cimar-login-needed", "cimar-login-needed");
|
||||
|
||||
|
||||
} else {
|
||||
error = e.detail.xhr.response;
|
||||
}
|
||||
el.innerHTML = error;
|
||||
el.classList.remove("hidden");
|
||||
});
|
||||
@@ -29,24 +44,27 @@ $(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>"));
|
||||
//});
|
||||
|
||||
$(".answer-list span.answer").each(function (index, element) {
|
||||
console.log(element);
|
||||
// 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);
|
||||
|
||||
$(element).click(function (e) {
|
||||
$(element).click(function (e) {
|
||||
|
||||
var classes = ['answer correct', 'answer half-correct', 'answer incorrect'];
|
||||
$(element).each(function () {
|
||||
let mark = classes[($.inArray(this.className, classes) + 1) % classes.length];
|
||||
this.className = mark
|
||||
this.dataset.newmark = mark.split(" ")[1]
|
||||
});
|
||||
|
||||
prepAnswerData();
|
||||
|
||||
var classes = ['answer correct', 'answer half-correct', 'answer incorrect'];
|
||||
$(element).each(function () {
|
||||
let mark = classes[($.inArray(this.className, classes) + 1) % classes.length];
|
||||
this.className = mark
|
||||
this.dataset.newmark = mark.split(" ")[1]
|
||||
});
|
||||
|
||||
prepAnswerData();
|
||||
|
||||
|
||||
|
||||
});
|
||||
});
|
||||
}
|
||||
prepAnswerData();
|
||||
|
||||
if ($(".post-form").length > 0) {
|
||||
@@ -63,7 +81,15 @@ $(document).ready(function () {
|
||||
if (dicom_images.length) {
|
||||
|
||||
for (let element of dicom_images) {
|
||||
setUpDicom(element)
|
||||
setUpDicom3d(element)
|
||||
}
|
||||
|
||||
}
|
||||
let dicom_images_legacy = document.getElementsByClassName("dicom-image-legacy");
|
||||
if (dicom_images_legacy.length) {
|
||||
|
||||
for (let element of dicom_images_legacy) {
|
||||
setUpDicomLegacy(element)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -184,10 +210,45 @@ window.addEventListener('loadDicomViewerUrls', function (e) {
|
||||
loadDicomViewer(e.detail.images, e.detail.annotations);
|
||||
}, false);
|
||||
|
||||
function clearableTextAreaSetup() {
|
||||
const textFields = document.querySelectorAll(".position-relative input[type='text'], .position-relative textarea");
|
||||
|
||||
textFields.forEach((field) => {
|
||||
const wrapper = field.closest(".position-relative");
|
||||
const clearButton = wrapper.querySelector(".clear-input-button");
|
||||
|
||||
// Function to toggle the visibility of the clear button
|
||||
const toggleClearButton = () => {
|
||||
if (field.value.trim() !== "" && wrapper.matches(":hover")) {
|
||||
clearButton.style.display = "inline-block"; // Show the button
|
||||
} else {
|
||||
clearButton.style.display = "none"; // Hide the button
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check
|
||||
toggleClearButton();
|
||||
|
||||
// Add event listeners to monitor changes in the input field or textarea
|
||||
field.addEventListener("input", toggleClearButton);
|
||||
|
||||
// Add hover event listeners to the wrapper
|
||||
wrapper.addEventListener("mouseenter", toggleClearButton);
|
||||
wrapper.addEventListener("mouseleave", toggleClearButton);
|
||||
|
||||
// Add click event to clear the field or textarea
|
||||
clearButton.addEventListener("click", function () {
|
||||
field.value = ""; // Clear the field
|
||||
toggleClearButton();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadDicomViewer(images_to_load, annotations_to_load) {
|
||||
console.log("loadDicomViewer", images_to_load);
|
||||
let single_dicom = document.getElementById("single-dicom-viewer");
|
||||
if (single_dicom) {
|
||||
console.log("Load single dicom viewer");
|
||||
$(single_dicom).empty()
|
||||
let images = single_dicom.dataset.images;
|
||||
|
||||
@@ -201,7 +262,6 @@ function loadDicomViewer(images_to_load, annotations_to_load) {
|
||||
images = JSON.parse(images);
|
||||
}
|
||||
|
||||
console.log(images);
|
||||
|
||||
let annotations = single_dicom.dataset.annotations;
|
||||
if (annotations_to_load != undefined) {
|
||||
@@ -261,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;
|
||||
|
||||
@@ -284,9 +344,41 @@ function loadJsonToolStateOnCurrentImage(json) {
|
||||
|
||||
}
|
||||
|
||||
async function setUpDicom(element) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Dicom element found ", $(element));
|
||||
// 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;
|
||||
@@ -314,6 +406,8 @@ async function setUpDicom(element) {
|
||||
|
||||
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")) {
|
||||
@@ -424,7 +518,7 @@ async function setUpDicom(element) {
|
||||
}
|
||||
|
||||
if (element.dataset.annotations) {
|
||||
loadJsonToolStateOnCurrentImage(element.dataset.annotations)
|
||||
loadJsonToolStateOnCurrentImage(element, element.dataset.annotations)
|
||||
}
|
||||
|
||||
cornerstone.resize(element);
|
||||
@@ -435,115 +529,242 @@ async function setUpDicom(element) {
|
||||
});
|
||||
|
||||
}
|
||||
console.log("setUpDicom defined");
|
||||
|
||||
// keep reference to the existing 2D setup
|
||||
window.setUpDicomLegacy = setUpDicomLegacy;
|
||||
|
||||
async function setUpDicom3d(element) {
|
||||
console.log("setUpDicom3d (cornerstone3d) for", element);
|
||||
|
||||
// parse images from dataset
|
||||
let raw = element.dataset.url || "";
|
||||
if (!raw) return;
|
||||
let images;
|
||||
try {
|
||||
images = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
images = raw.split(",").map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
if (!images || images.length === 0) return;
|
||||
|
||||
// ensure element has a viewport-like appearance
|
||||
element.style.width = element.style.width || "500px";
|
||||
element.style.height = element.style.height || "500px";
|
||||
element.style.position = element.style.position || "relative";
|
||||
element.innerHTML = ""; // clear
|
||||
|
||||
// Disable right-click context menu on the element
|
||||
element.addEventListener('contextmenu', function (e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
element.addEventListener('dblclick', function (e) {
|
||||
if (element.requestFullscreen) {
|
||||
element.requestFullscreen();
|
||||
} else if (element.webkitRequestFullscreen) {
|
||||
element.webkitRequestFullscreen();
|
||||
} else if (element.mozRequestFullScreen) {
|
||||
element.mozRequestFullScreen();
|
||||
} else if (element.msRequestFullscreen) {
|
||||
element.msRequestFullscreen();
|
||||
}
|
||||
});
|
||||
|
||||
// create a unique rendering engine / viewport ids per element
|
||||
const renderingEngineId = `rendingEngine`;
|
||||
const viewportId = `1`;
|
||||
|
||||
try {
|
||||
// dynamic import of cornerstone3D core + dicom loader
|
||||
const coreModule = window.cornerstone3d
|
||||
const dicomLoaderModule = window.cornerstoneDICOMImageLoader
|
||||
const toolsModule = window.cornerstone3dTools;
|
||||
|
||||
await coreModule.init();
|
||||
await dicomLoaderModule.init();
|
||||
|
||||
|
||||
await toolsModule.init();
|
||||
|
||||
const { RenderingEngine, Enums } = coreModule;
|
||||
const { chunkManager } = dicomLoaderModule; // may exist for some setups
|
||||
|
||||
// create engine and enable a Stack viewport
|
||||
const renderingEngine = new RenderingEngine(renderingEngineId);
|
||||
console.log(renderingEngine)
|
||||
|
||||
const viewportInput = {
|
||||
viewportId,
|
||||
type: Enums.ViewportType.STACK,
|
||||
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);
|
||||
|
||||
|
||||
// --- Register basic interaction tools (best-effort with @cornerstonejs/tools) ---
|
||||
// dynamic import so bundlers/CDNs provide the package
|
||||
// prefer module import, fall back to global if present
|
||||
|
||||
const { ToolGroupManager } = toolsModule;
|
||||
|
||||
toolsModule.addTool(toolsModule.PanTool)
|
||||
toolsModule.addTool(toolsModule.WindowLevelTool)
|
||||
//toolsModule.addTool(toolsModule.StackScrollTool)
|
||||
toolsModule.addTool(toolsModule.ZoomTool)
|
||||
|
||||
|
||||
|
||||
const toolGroupId = `toolgroup-${viewportId}`;
|
||||
console.log("ToolGroup id", toolGroupId);
|
||||
console.log("ToolGroupManager", ToolGroupManager);
|
||||
if (!ToolGroupManager.getToolGroup(toolGroupId)) {
|
||||
ToolGroupManager.createToolGroup(toolGroupId);
|
||||
}
|
||||
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
|
||||
|
||||
toolGroup.addTool(toolsModule.PanTool.toolName);
|
||||
toolGroup.addTool(toolsModule.WindowLevelTool.toolName);
|
||||
//toolGroup.addTool(toolsModule.StackScrollTool.toolName);
|
||||
toolGroup.addTool(toolsModule.ZoomTool.toolName);
|
||||
|
||||
// use actual tool names if available, else fall back to common strings
|
||||
//const stackName = (StackScrollTool && (StackScrollTool.toolName || StackScrollTool.name)) || 'StackScroll';
|
||||
|
||||
//toolGroup.setToolEnabled(toolsModule.PanTool.toolName, { bindings: [{ mouseButton: 1 }] });
|
||||
toolGroup.setToolActive(toolsModule.PanTool.toolName, { bindings: [{ mouseButton: 1 }] });
|
||||
toolGroup.setToolActive(toolsModule.WindowLevelTool.toolName, { bindings: [{ mouseButton: 2 }] });
|
||||
toolGroup.setToolActive(toolsModule.ZoomTool.toolName, { bindings: [{ mouseButton: 4 }] });
|
||||
toolGroup.setToolActive(toolsModule.ZoomTool.toolName, { bindings: [{ mouseButton: 524288 }] });
|
||||
toolGroup.addViewport(viewportId, renderingEngineId);
|
||||
|
||||
window.toolGroup = toolGroup; // expose for debugging
|
||||
|
||||
|
||||
// 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
|
||||
if (/^(wadouri:|base64:|data:|http:|https:)/i.test(url)) return url;
|
||||
|
||||
// default: treat as wadouri (likely a DICOM file)
|
||||
return `wadouri:${url}`;
|
||||
});
|
||||
|
||||
// If dicom-image-loader provides a metadata cache factory, pre-cache metadata
|
||||
if (typeof dicomLoaderModule.createImageIdsAndCacheMetaData === "function") {
|
||||
try {
|
||||
// attempt to call helper to cache meta-data for the first image (best-effort)
|
||||
await dicomLoaderModule.createImageIdsAndCacheMetaData({
|
||||
imageIds,
|
||||
});
|
||||
} catch (e) {
|
||||
// not fatal — continue
|
||||
console.warn("createImageIdsAndCacheMetaData failed (continuing):", e);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
|
||||
// store references for debugging/use elsewhere
|
||||
element._cornerstone3d = {
|
||||
renderingEngineId,
|
||||
viewportId,
|
||||
renderingEngine,
|
||||
viewport,
|
||||
imageIds,
|
||||
};
|
||||
|
||||
console.log("setUpDicom3d complete", element._cornerstone3d);
|
||||
return element._cornerstone3d;
|
||||
} catch (err) {
|
||||
console.error("setUpDicom3d failed:", err);
|
||||
// per request: no fallback here — rethrow so caller can handle if desired
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// expose globally like existing code expects
|
||||
window.setUpDicom = setUpDicom3d;
|
||||
|
||||
|
||||
|
||||
//function keyUpHandler(e) {
|
||||
// if (e.key == "Control") {
|
||||
// dicomViewer.registerPrimaryDicomInterface(e);
|
||||
// window.control_pressed = false;
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//function keyDownHandler(e) {
|
||||
// // Ignore our custom keybindings if we are currently in a field that
|
||||
// // accepts some kind of input
|
||||
// if ($("*:focus:not(disabled)").is("textarea, input")) {
|
||||
// // unless a modifier key is pressed (not shift)
|
||||
// if (e.altKey ? true : false || e.ctrlKey ? true : false) {} else {
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (e.key == "Control") {
|
||||
// if (window.control_pressed == false) {
|
||||
// window.control_pressed = true;
|
||||
// dicomViewer.registerAltDicomInterface(e);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// var charCode = typeof e.which == "number" ? e.which : e.keyCode;
|
||||
// console.log(e, charCode);
|
||||
//
|
||||
// function numberKeyPressed(e, x) {
|
||||
// if (e.altKey ? true : false) {
|
||||
// dicomViewer.selectThumb(x);
|
||||
// e.preventDefault();
|
||||
// } else {}
|
||||
// }
|
||||
//
|
||||
// switch (charCode) {
|
||||
// case 13: // Return
|
||||
// if (e.shiftKey ? true : false) {
|
||||
// //$(".next-button:last").click();
|
||||
// } else {
|
||||
// //$(".check-button:last").click();
|
||||
// }
|
||||
// break;
|
||||
// case 32: // Space
|
||||
// if (e.shiftKey ? true : false) {
|
||||
// //$(".previous-button:last").click();
|
||||
// } else {
|
||||
// //$(".next-button:last").click();
|
||||
// }
|
||||
// e.preventDefault(); // Needed to stop the default action (scroll)
|
||||
// break;
|
||||
// case 46: // .
|
||||
// //toggleFlagged();
|
||||
// break;
|
||||
//
|
||||
// // Numbers 1-9 select the corresponding answer (if it exists)
|
||||
// // TODO: fix for multi question questions
|
||||
// case 49: // 1
|
||||
// numberKeyPressed(e, 0);
|
||||
// break;
|
||||
// case 50: // 2
|
||||
// numberKeyPressed(e, 1);
|
||||
// break;
|
||||
// case 51: // 3
|
||||
// numberKeyPressed(e, 2);
|
||||
// break;
|
||||
// case 52: // 4
|
||||
// numberKeyPressed(e, 3);
|
||||
// break;
|
||||
// case 53: // 5
|
||||
// numberKeyPressed(e, 4);
|
||||
// break;
|
||||
// case 54: // 6
|
||||
// numberKeyPressed(e, 5);
|
||||
// break;
|
||||
// case 55: // 7
|
||||
// numberKeyPressed(e, 6);
|
||||
// break;
|
||||
// case 56: // 8
|
||||
// numberKeyPressed(e, 7);
|
||||
// break;
|
||||
// case 57: // 9
|
||||
// numberKeyPressed(e, 8);
|
||||
// break;
|
||||
// case 72: // H
|
||||
// previousQuestion();
|
||||
// break;
|
||||
// case 76: // L
|
||||
// //nextQuestion();
|
||||
// break;
|
||||
//
|
||||
// case 102: // f
|
||||
// //$("#filter-toggle").click();
|
||||
// break;
|
||||
// case 103: // g
|
||||
// //$("#options").slideDown("slow");
|
||||
// //$("#goto-question-input").focus();
|
||||
// //e.preventDefault();
|
||||
// break;
|
||||
//
|
||||
// // Vim like scrolling (incredibly important)
|
||||
// case 106: // j
|
||||
// //window.scrollBy(0, 25);
|
||||
// break;
|
||||
// case 107: // k
|
||||
// //window.scrollBy(0, -25);
|
||||
// break;
|
||||
// }
|
||||
//}
|
||||
|
||||
|
||||
function create_popup_window(url, title) {
|
||||
@@ -635,4 +856,46 @@ function initializeClock(id, endtime) {
|
||||
updateClock();
|
||||
}
|
||||
|
||||
window.initializeClock = initializeClock
|
||||
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);
|
||||
@@ -1,4 +1,4 @@
|
||||
let active_file_inputs = new Set();
|
||||
var active_file_inputs = new Set();
|
||||
|
||||
function add_input_form() {
|
||||
var form_idx = $('#id_images-TOTAL_FORMS').val();
|
||||
|
||||
@@ -47,6 +47,7 @@ class AnatomyQuestionTable(tables.Table):
|
||||
"author",
|
||||
)
|
||||
sequence = ("view", "image", "exams")
|
||||
order_by = "-created_date"
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load help_tags %}
|
||||
|
||||
{% block title %}
|
||||
Anatomy
|
||||
@@ -11,69 +12,69 @@
|
||||
{% block js %}
|
||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
||||
|
||||
<script type="text/javascript">
|
||||
function showEditPopup(url) {
|
||||
var win = window.open(url, "Edit",
|
||||
'height=500,width=800,resizable=yes,scrollbars=yes');
|
||||
return false;
|
||||
}
|
||||
function showAddPopup(triggeringLink) {
|
||||
var name = triggeringLink.id.replace(/^add_/, '');
|
||||
href = triggeringLink.href;
|
||||
var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
|
||||
win.focus();
|
||||
return false;
|
||||
}
|
||||
function closePopup(win, newID, newRepr, id) {
|
||||
console.log(id)
|
||||
$(id).append('<option value=' + newID + ` title=${newRepr} >` + newRepr + '</option>').removeAttr("selected").val(newID)
|
||||
win.close();
|
||||
}
|
||||
<script type="text/javascript">
|
||||
function showEditPopup(url) {
|
||||
var win = window.open(url, "Edit",
|
||||
'height=500,width=800,resizable=yes,scrollbars=yes');
|
||||
return false;
|
||||
}
|
||||
function showAddPopup(triggeringLink) {
|
||||
var name = triggeringLink.id.replace(/^add_/, '');
|
||||
href = triggeringLink.href;
|
||||
var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
|
||||
win.focus();
|
||||
return false;
|
||||
}
|
||||
function closePopup(win, newID, newRepr, id) {
|
||||
console.log(id)
|
||||
$(id).append('<option value=' + newID + ` title=${newRepr} >` + newRepr + '</option>').removeAttr("selected").val(newID)
|
||||
win.close();
|
||||
}
|
||||
|
||||
document.addEventListener('drop', function (e) { e.preventDefault(); }, false);
|
||||
document.addEventListener('drop', function (e) { e.preventDefault(); }, false);
|
||||
|
||||
|
||||
$(document).ready(function () {
|
||||
$("#add_examination").appendTo($("label[for='id_examination']"));
|
||||
$("#add_body_part").appendTo($("label[for='id_body_part']"));
|
||||
$(document).ready(function () {
|
||||
$("#add_examination").appendTo($("label[for='id_examination']"));
|
||||
$("#add_body_part").appendTo($("label[for='id_body_part']"));
|
||||
|
||||
|
||||
dropContainer = document.getElementById("drop-container");
|
||||
dropContainer = document.getElementById("drop-container");
|
||||
|
||||
dropContainer.ondragover = function (evt) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
};
|
||||
dropContainer.ondragover = function (evt) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
};
|
||||
|
||||
dropContainer.ondragenter = function (evt) {
|
||||
$(evt.target).addClass("drop-target-active")
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
};
|
||||
dropContainer.ondragenter = function (evt) {
|
||||
$(evt.target).addClass("drop-target-active")
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
};
|
||||
|
||||
dropContainer.ondragleave = function (evt) {
|
||||
$(evt.target).removeClass("drop-target-active")
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
};
|
||||
dropContainer.ondragleave = function (evt) {
|
||||
$(evt.target).removeClass("drop-target-active")
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
};
|
||||
|
||||
dropContainer.ondrop = function (evt) {
|
||||
console.log("drop event, ", evt);
|
||||
$(evt.target).removeClass("drop-target-active");
|
||||
dropContainer.ondrop = function (evt) {
|
||||
console.log("drop event, ", evt);
|
||||
$(evt.target).removeClass("drop-target-active");
|
||||
|
||||
// Get all input elements
|
||||
input = document.getElementById("id_image");
|
||||
input = document.getElementById("id_image");
|
||||
//fileInput = document.getElementById("id_images-0-image");
|
||||
|
||||
f = evt.dataTransfer.files[0]
|
||||
f = evt.dataTransfer.files[0]
|
||||
|
||||
let dT = new DataTransfer();
|
||||
dT.clearData();
|
||||
dT.items.add(f);
|
||||
let dT = new DataTransfer();
|
||||
dT.clearData();
|
||||
dT.items.add(f);
|
||||
|
||||
input.files = dT.files;
|
||||
input.files = dT.files;
|
||||
|
||||
ocr(input);
|
||||
ocr(input);
|
||||
|
||||
// Make sure we have enough input targets
|
||||
//input_diff = (evt.dataTransfer.files.length - inputs.length)
|
||||
@@ -119,50 +120,50 @@
|
||||
// dT.items.add(evt.dataTransfer.files[3]);
|
||||
// fileInput.files = dT.files;
|
||||
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
updateFileList();
|
||||
};
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
updateFileList();
|
||||
};
|
||||
|
||||
|
||||
$('#add_more').click(() => { add_input_form() });
|
||||
$('#add_more').click(() => { add_input_form() });
|
||||
|
||||
function updateFileList() {
|
||||
$("#drop-filenames").empty()
|
||||
$("#id_image").each((n, el) => {
|
||||
console.log(el);
|
||||
if (el.files.length > 0) {
|
||||
extra_class = " image-ident-loading";
|
||||
if ($(el).hasClass("image-ident-warning")) {
|
||||
extra_class = " image-ident-warning"
|
||||
} else if ($(el).hasClass("image-ident-ok")) {
|
||||
extra_class = " image-ident-ok"
|
||||
function updateFileList() {
|
||||
$("#drop-filenames").empty()
|
||||
$("#id_image").each((n, el) => {
|
||||
console.log(el);
|
||||
if (el.files.length > 0) {
|
||||
extra_class = " image-ident-loading";
|
||||
if ($(el).hasClass("image-ident-warning")) {
|
||||
extra_class = " image-ident-warning"
|
||||
} else if ($(el).hasClass("image-ident-ok")) {
|
||||
extra_class = " image-ident-ok"
|
||||
}
|
||||
$("#drop-filenames").append(
|
||||
`<span>${n}: ${el.files[0].name}</span><img class='uploading${extra_class}' data-input-id='${el.id}' src=${URL.createObjectURL(el.files[0])}>`
|
||||
)
|
||||
}
|
||||
$("#drop-filenames").append(
|
||||
`<span>${n}: ${el.files[0].name}</span><img class='uploading${extra_class}' data-input-id='${el.id}' src=${URL.createObjectURL(el.files[0])}>`
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
$("input[type=file]").on('change', function () {
|
||||
$(this).removeClass("image-ident-warning");
|
||||
$(this).removeClass("image-ident-ok");
|
||||
updateFileList();
|
||||
console.log("input change1");
|
||||
console.log("input change", this);
|
||||
ocr(this);
|
||||
});
|
||||
$("input[type=file]").on('change', function () {
|
||||
$(this).removeClass("image-ident-warning");
|
||||
$(this).removeClass("image-ident-ok");
|
||||
updateFileList();
|
||||
console.log("input change1");
|
||||
console.log("input change", this);
|
||||
ocr(this);
|
||||
});
|
||||
|
||||
//document.getElementById("id_question_type").selectedIndex = 1;
|
||||
answer_1_status = document.getElementById("id_answers-0-status").selectedIndex;
|
||||
if (answer_1_status == 0) {
|
||||
document.getElementById("id_answers-0-status").selectedIndex = 3;
|
||||
}
|
||||
answer_1_status = document.getElementById("id_answers-0-status").selectedIndex;
|
||||
if (answer_1_status == 0) {
|
||||
document.getElementById("id_answers-0-status").selectedIndex = 3;
|
||||
}
|
||||
|
||||
});
|
||||
function ocr(input) {
|
||||
});
|
||||
function ocr(input) {
|
||||
// Tesseract.recognize(f, "eng",{ logger: m => console.log(m) }
|
||||
// ).then(({ data: { text } }) => {
|
||||
// console.log(text);
|
||||
@@ -172,77 +173,78 @@
|
||||
// }
|
||||
// })
|
||||
|
||||
const worker = Tesseract.createWorker({
|
||||
workerPath: '{% static "worker.min.js" %}',
|
||||
langPath: '{% static "" %}',
|
||||
const worker = Tesseract.createWorker({
|
||||
workerPath: '{% static "worker.min.js" %}',
|
||||
langPath: '{% static "" %}',
|
||||
//langPath: '{{ STATIC_URL }}',
|
||||
corePath: '{% static "tesseract-core.wasm.js" %}',
|
||||
});
|
||||
corePath: '{% static "tesseract-core.wasm.js" %}',
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(input.files[0]);
|
||||
let img = new Image;
|
||||
img.src = url;
|
||||
const url = URL.createObjectURL(input.files[0]);
|
||||
let img = new Image;
|
||||
img.src = url;
|
||||
|
||||
img.onload = function () {
|
||||
img.onload = function () {
|
||||
|
||||
|
||||
width = img.width;
|
||||
width = img.width;
|
||||
|
||||
// const rectangle = { left: 0, top: 0, width: width, height: 10 }
|
||||
|
||||
|
||||
(async () => {
|
||||
await worker.load();
|
||||
await worker.loadLanguage('eng');
|
||||
await worker.initialize('eng');
|
||||
(async () => {
|
||||
await worker.load();
|
||||
await worker.loadLanguage('eng');
|
||||
await worker.initialize('eng');
|
||||
// const { data: { text } } = await worker.recognize(input.files[0], { rectangle });
|
||||
const { data: { text } } = await worker.recognize(input.files[0]);
|
||||
const { data: { text } } = await worker.recognize(input.files[0]);
|
||||
//console.log(text);
|
||||
l = text.toLowerCase();
|
||||
$(`img[data-input-id='${input.id}'`).removeClass("image-ident-loading");
|
||||
console.log(l);
|
||||
if (l.includes("accesion") || l.includes("number") || l.search(/(ref|rk9|rh8|ra9|rbz)\d+/g) > -1) {
|
||||
console.log("SHIT", input);
|
||||
$(input).addClass("image-ident-warning");
|
||||
$(`img[data-input-id='${input.id}'`).addClass("image-ident-warning");
|
||||
} else {
|
||||
$(input).addClass("image-ident-ok");
|
||||
l = text.toLowerCase();
|
||||
$(`img[data-input-id='${input.id}'`).removeClass("image-ident-loading");
|
||||
console.log(l);
|
||||
if (l.includes("accesion") || l.includes("number") || l.search(/(ref|rk9|rh8|ra9|rbz)\d+/g) > -1) {
|
||||
console.log("SHIT", input);
|
||||
$(input).addClass("image-ident-warning");
|
||||
$(`img[data-input-id='${input.id}'`).addClass("image-ident-warning");
|
||||
} else {
|
||||
$(input).addClass("image-ident-ok");
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
await worker.terminate();
|
||||
})();
|
||||
await worker.terminate();
|
||||
})();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
||||
{{ form.media }}
|
||||
{{ form.media }}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if object %}
|
||||
{% include 'anatomy/question_link_header.html' %}
|
||||
{% endif %}
|
||||
|
||||
<h2>{{object|yesno:"Update,Create"}} Question</h2>
|
||||
<details class="help-text">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p>This form can be used to create an Anatomy question.</p>
|
||||
{% if object %}
|
||||
{% include 'anatomy/question_link_header.html' %}
|
||||
{% endif %}
|
||||
|
||||
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p>
|
||||
</details>
|
||||
<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>
|
||||
<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>
|
||||
<h2>{{object|yesno:"Update,Create"}} Question</h2>
|
||||
{% help "Help" %}
|
||||
<p>This form can be used to create an Anatomy question.</p>
|
||||
|
||||
{{ form|crispy }}
|
||||
<div id="drop-container" class="drop-target">Drop image here
|
||||
<div id="drop-filenames"></div>
|
||||
</div>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p>
|
||||
|
||||
<p>Correct and incorrect answers can be defined once the question has been created.</p>
|
||||
{% endhelp %}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{{ form|crispy }}
|
||||
<div id="drop-container" class="drop-target">Drop image here
|
||||
<div id="drop-filenames"></div>
|
||||
</div>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -28,29 +28,60 @@
|
||||
{% if object %}
|
||||
{% include 'anatomy/question_link_header.html' %}
|
||||
{% endif %}
|
||||
<h2>Question Answers</h2>
|
||||
This page can be used to add and modify answers associated with a question. Answers in here will be used for automarking submitted answers so it is best not to remove. Updating an answer here will not automatically update a submitted answers (scores cache would need to be refreshed)
|
||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||
{% csrf_token %}
|
||||
<h3>Answers:</h3>
|
||||
<div id="form_set">
|
||||
{% for form in answer_formset %}
|
||||
<ul class="no-error answer-formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form.as_ul }}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
<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 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 class="h5 mt-2">Answers</h3>
|
||||
<div id="form_set" class="mt-3">
|
||||
{% for form in 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 }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{{ answer_formset.management_form }}
|
||||
|
||||
<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" class="d-none">
|
||||
<div class='answer-card p-3 mb-3 border rounded bg-body-secondary'>
|
||||
{{ answer_formset.empty_form.as_ul }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ answer_formset.management_form }}
|
||||
<input type="button" value="Add More Answers" id="add_more">
|
||||
<p>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</p>
|
||||
</form>
|
||||
<div id="empty_form" style="display:none">
|
||||
<ul class='no_error answer-formset'>
|
||||
{{ answer_formset.empty_form.as_ul }}
|
||||
</ul>
|
||||
</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 %}
|
||||
@@ -5,17 +5,40 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block navigation %}
|
||||
Anatomy:
|
||||
{% if request.user.is_authenticated %}
|
||||
<a href="{% url 'anatomy:index' %}">Packets</a> /
|
||||
<a href="{% url 'anatomy:exam_list' %}">Exams</a> /
|
||||
<a href="{% url 'anatomy:exam_create' %}" title="Create a new exam">Create Exam</a> /
|
||||
<a href="{% url 'anatomy:question_list' %}">Questions</a> /
|
||||
<a href="{% url 'anatomy:anatomy_question_create' %}" title="Create a new question">Create Question</a>
|
||||
{% if request.user.is_superuser %}
|
||||
/ <a href="{% url 'anatomy:user_answer_table_view' %}" title="User answers">Answers</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-dark submenu">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{% url 'anatomy:index' %}">Anatomy</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse">
|
||||
<ul class="navbar-nav">
|
||||
{% if request.user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'anatomy:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'anatomy:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{% url 'anatomy:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'anatomy:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'anatomy:user_answer_table_view' %}" title="User answers">Answers</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% if simple_content %}
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
<li data-question_pk={{question.pk}}>
|
||||
<span class="flex-col">
|
||||
<a href="{% url 'anatomy:exam_question_detail' pk=exam.pk sk=forloop.counter0 %}">
|
||||
<img src="{{ question.image|thumbnail_url:'exam-list' }}" alt="thumbail" />
|
||||
|
||||
{% if question.image %}
|
||||
<img src="{{ question.image|thumbnail_url:'exam-list' }}" alt="thumbail" />
|
||||
{% else %}
|
||||
No image added.
|
||||
{% endif %}
|
||||
|
||||
</a>
|
||||
</span>
|
||||
<span class="flex-col-4">
|
||||
@@ -37,22 +43,8 @@
|
||||
{{ author }},
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<p>
|
||||
<button id='button-edit-order' title='click and drag questions to change order' data-posturl="{% url 'anatomy:exam_json_edit' pk=exam.pk %}">Edit question order / Delete questions</button>
|
||||
</p>
|
||||
{% endif %}
|
||||
<div>
|
||||
JSON creation time: {{exam.json_creation_time}} ({{exam.json_creation_time|date:"c"}}),
|
||||
JSON creation id: {{exam.exam_json_id}}
|
||||
</div>
|
||||
<a href="{% url 'anatomy:exam_json' pk=exam.pk %}">JSON</a>
|
||||
<a href="{% url 'anatomy:exam_json_unbased' pk=exam.pk %}">JSON (unbased)</a>
|
||||
<a href="{% url 'anatomy:exam_json_recreate' pk=exam.pk %}">Refresh JSON cache</a>
|
||||
{% if request.user.is_superuser %}
|
||||
<button id='button-open-access' data-posturl="{% url 'anatomy:exam_json_edit' pk=exam.pk %}">Make questions open access</button>
|
||||
<button id='button-closed-access' data-posturl="{% url 'anatomy:exam_json_edit' pk=exam.pk %}">Make questions closed access</button>
|
||||
{% endif %}
|
||||
{% include 'generic/exam_footer.html' %}
|
||||
|
||||
|
||||
</div>
|
||||
{% include 'exam_overview_js.html' %}
|
||||
|
||||
@@ -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> /
|
||||
{% 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> /
|
||||
{% endif %}
|
||||
<a href="{% url 'anatomy:anatomy_create_exam' pk=exam.pk %}">Add New Question</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 %}
|
||||
<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 %}
|
||||
<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 %}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
{% block content %}
|
||||
<div class="col-md-6">
|
||||
<h2>Marking question {{question_details.current}} of {{question_details.total}}</h2>
|
||||
<a href="{% url 'anatomy:question_detail' question.id %}" title="View the Question">View</a> <a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a> <a href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}" title="Edit the Question using the admin interface">Admin Edit</a>
|
||||
<a href="{% url 'anatomy:question_detail' question.id %}" title="View the Question">View</a>
|
||||
<a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
|
||||
{% if request.user.is_superuser %}<a href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}" title="Edit the Question using the admin interface">Admin Edit</a>{% endif %}
|
||||
<h3>{{ question.question_type }}</h3>
|
||||
{% if question.structure %}
|
||||
Structure: {{question.structure}}<br/>
|
||||
@@ -13,10 +15,36 @@
|
||||
{% if question.answer_help %}
|
||||
<details>
|
||||
<summary>Marking help:</summary>
|
||||
<div class="border border-secondary">
|
||||
{{question.answer_help|safe}}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% endif %}
|
||||
{% comment %} <details>
|
||||
<summary>Suggest incorrect answers:</summary>
|
||||
|
||||
{% for word in words_suggest_incorrect %}
|
||||
<span>{{word}}<span class="user-select-none" _="on click if #id_answer_suggest_incorrect == null then
|
||||
set btn to #answer-suggest-incorrect-button
|
||||
js(btn) btn.click() end
|
||||
repeat forever
|
||||
if #id_answer_suggest_incorrect != null then break end
|
||||
wait 0.1s
|
||||
end
|
||||
end
|
||||
if (#id_answer_suggest_incorrect's value == '')
|
||||
put (#id_answer_suggest_incorrect's value + my @data-word) into #id_answer_suggest_incorrect's value
|
||||
else
|
||||
put (#id_answer_suggest_incorrect's value + ',' + my @data-word) into #id_answer_suggest_incorrect's value
|
||||
end
|
||||
then remove closest parent <span/>" data-word='{{word}}'>(+)</span>,</span>
|
||||
{% endfor %}
|
||||
|
||||
<div class="border border-secondary">
|
||||
{% include "anatomy/question_detail.html#suggest-incorrect-form" %}
|
||||
</div>
|
||||
</details> {% endcomment %}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -36,22 +64,22 @@
|
||||
{% for answer in user_answers %}
|
||||
|
||||
{% if answer in answer_suggest_incorrect %}
|
||||
<li><pre><span class="answer incorrect suggest" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
|
||||
<li><pre><span class="answer incorrect suggest" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
|
||||
{% else %}
|
||||
<li><pre><span class="answer not-marked" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
|
||||
<li><pre><span class="answer not-marked" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
Marked:
|
||||
<ul id="marked-answer-list" class="answer-list">
|
||||
{% for answer in correct_answers %}
|
||||
<li><pre><span class="answer correct" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
|
||||
<li><pre><span class="answer correct" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
|
||||
{% endfor %}
|
||||
{% for answer in half_mark_answers %}
|
||||
<li><pre><span class="answer half-correct" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
|
||||
<li><pre><span class="answer half-correct" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
|
||||
{% endfor %}
|
||||
{% for answer in incorrect_answers %}
|
||||
<li><pre><span class="answer incorrect" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
|
||||
<li><pre><span class="answer incorrect" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="mark-buttons">
|
||||
|
||||
@@ -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>
|
||||
@@ -1,111 +1,138 @@
|
||||
{% extends 'anatomy/base.html' %}
|
||||
|
||||
{% load partials %}
|
||||
{% 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>
|
||||
</details>
|
||||
<button id="save-annotations">Save Annotations</button>
|
||||
<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>
|
||||
|
||||
</div>
|
||||
<div class="question">
|
||||
<div class="date">
|
||||
Created: {{ question.created_date|date:"d/m/Y" }}
|
||||
<button id="save-annotations" class="btn btn-sm btn-primary">Save Annotations</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Question type: {{question.question_type}}</h2>
|
||||
<h3>Primary answer: {{ question.get_primary_answer }}</h3>
|
||||
<div>
|
||||
<details>
|
||||
<summary title="Click to view the question answers">
|
||||
Answers:
|
||||
</summary>
|
||||
<table>
|
||||
<tr><th>Answer</th><th>Score</th></tr>
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
<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 %}>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
<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>
|
||||
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</details>
|
||||
</div>
|
||||
<div>
|
||||
Answer help: {{ question.answer_help|safe }}
|
||||
</div>
|
||||
<div>
|
||||
Answer suggest incorrect: {{ question.answer_suggest_incorrect }}
|
||||
</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>
|
||||
</details>
|
||||
|
||||
<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">
|
||||
@@ -151,7 +178,7 @@
|
||||
|
||||
|
||||
function getJsonToolStateNoId() {
|
||||
const el = document.getElementById("dicom-image");
|
||||
const el = document.getElementById("anatomy-dicom-image");
|
||||
|
||||
const c = cornerstone.getEnabledElement(el);
|
||||
|
||||
@@ -167,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 %}
|
||||
@@ -175,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>
|
||||
|
||||
{% 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>
|
||||
<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 %}
|
||||
Viewing question as part of exam: <a href="{% url 'anatomy:exam_overview' exam.id %}">{{exam}}</a> [{{pos}}/{{exam_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next question</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</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 %}
|
||||
<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>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,8 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<form method="POST" class="post-form">{% csrf_token %}
|
||||
{{ form | crispy}}
|
||||
<button type="button" class="btn btn-sm" hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.id %}"
|
||||
hx-target="#incorrect-answers"
|
||||
hx-swap="innerHTML">Save</button>
|
||||
<button type="button" _="on click remove closest <form/>" >Cancel</button>
|
||||
</form>
|
||||
@@ -6,27 +6,31 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="view-filter-options">
|
||||
<h3>Filter Anatomy Questions</h3>
|
||||
<form action="" method="get">
|
||||
{{ filter.form }}
|
||||
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
{% render_table table %}
|
||||
<div id="view-filter-options">
|
||||
<h3>Filter Anatomy Questions</h3>
|
||||
<form action="" method="get">
|
||||
{{ filter.form }}
|
||||
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
{% render_table table %}
|
||||
|
||||
<button id="button-select-add-exam"
|
||||
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>
|
||||
<div id="exam-options"></div>
|
||||
<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>
|
||||
<div id="exam-options"></div>
|
||||
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function () {
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function () {
|
||||
|
||||
|
||||
});
|
||||
</script>
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -34,7 +34,7 @@ APP_NAME = "anatomy"
|
||||
|
||||
def create_exam(db):
|
||||
exam: Exam = AnatomyExamViews.Exam.objects.create(
|
||||
name="Cid Exam", exam_mode=True, active=True
|
||||
name="Cid Exam", exam_mode=True, active=True, open_access=False, exam_open_access=False
|
||||
)
|
||||
|
||||
group1: CidUserGroup = CidUserGroup.objects.get(name="Group1")
|
||||
@@ -365,7 +365,7 @@ def test_exams(db, client):
|
||||
cid_exam_scores_soup = BeautifulSoup(cid_exam_scores_res.content, "html.parser")
|
||||
|
||||
# Check that we have an alert that exams are not published
|
||||
alert = cid_exam_scores_soup.find("div", {"class": "alert"})
|
||||
alert = cid_exam_scores_soup.find("div", {"class": "no-results"})
|
||||
assert "Results are not currently published." in str(alert)
|
||||
|
||||
answer_lis = cid_exam_scores_soup.find(
|
||||
|
||||
+31
-8
@@ -7,21 +7,16 @@ from django.views.decorators.cache import cache_page
|
||||
app_name = "anatomy"
|
||||
|
||||
urlpatterns = [
|
||||
# path('', views.question_list, name='question_list'),
|
||||
# path('', views.question_view, name='question_view'),
|
||||
path(
|
||||
"question/",
|
||||
views.AnatomyQuestionView.as_view(),
|
||||
name="question_list",
|
||||
name="question_view",
|
||||
),
|
||||
path(
|
||||
"question/create/",
|
||||
views.AnatomyQuestionCreate.as_view(),
|
||||
name="anatomy_question_create",
|
||||
),
|
||||
path(
|
||||
"create/exam/<int:pk>",
|
||||
views.AnatomyQuestionCreate.as_view(),
|
||||
name="anatomy_create_exam",
|
||||
name="question_create",
|
||||
),
|
||||
path(
|
||||
"question/<int:pk>/update",
|
||||
@@ -38,6 +33,17 @@ urlpatterns = [
|
||||
views.question_save_annotation,
|
||||
name="question_save_annotation",
|
||||
),
|
||||
path(
|
||||
"question/<int:pk>/suggest_incorrect_answers",
|
||||
views.question_suggest_incorrect_answers,
|
||||
name="question_suggest_incorrect_answers",
|
||||
),
|
||||
path(
|
||||
"question/<int:pk>/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(
|
||||
"question/<int:pk>/clone", views.QuestionClone.as_view(), name="question_clone"
|
||||
@@ -91,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))
|
||||
|
||||
+874
-56
File diff suppressed because it is too large
Load Diff
+25
-1
@@ -3,6 +3,7 @@ from .models import (
|
||||
Case,
|
||||
CaseDetail,
|
||||
CaseCollection,
|
||||
NormalCase,
|
||||
Differential,
|
||||
Series,
|
||||
SeriesImage,
|
||||
@@ -16,7 +17,10 @@ from .models import (
|
||||
Presentation,
|
||||
UncategorisedDicom,
|
||||
SeriesDetail,
|
||||
Resource
|
||||
Resource,
|
||||
CaseDisplaySet,
|
||||
UserReportAnswer,
|
||||
CidReportAnswer,
|
||||
)
|
||||
|
||||
from django.forms import ModelForm
|
||||
@@ -46,6 +50,9 @@ admin.site.register(CaseDetail)
|
||||
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):
|
||||
@@ -73,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
|
||||
@@ -87,6 +110,7 @@ class AtlasAdminForm(ModelForm):
|
||||
"subspecialty",
|
||||
"author",
|
||||
"series",
|
||||
"cimar_uuid"
|
||||
]
|
||||
|
||||
widgets = {
|
||||
|
||||
+95
-93
@@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
from ninja import ModelSchema, Router, Schema, Field
|
||||
|
||||
from django.db import transaction
|
||||
from django.db import transaction, IntegrityError
|
||||
|
||||
# from .decorators import user_is_author_or_rapid_checker
|
||||
from django.core.exceptions import PermissionDenied
|
||||
@@ -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)
|
||||
@@ -70,8 +70,10 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
||||
except DuplicateDicom as e:
|
||||
duplicate.append((file.name, ud.image_blake3_hash))
|
||||
|
||||
duplicate_series.add(e.duplicate.get_series().get_absolute_url())
|
||||
print(duplicate_series)
|
||||
|
||||
if type(e.duplicate) == SeriesImage:
|
||||
duplicate_series.add(e.duplicate.series.get_absolute_url())
|
||||
|
||||
pass
|
||||
except InvalidDicomError:
|
||||
failed.append(file.name)
|
||||
@@ -107,7 +109,6 @@ def clear_dicoms(request):
|
||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||
|
||||
dicoms.delete()
|
||||
print(dicoms)
|
||||
|
||||
return True
|
||||
|
||||
@@ -127,12 +128,10 @@ def uncategorised_dicoms(request):
|
||||
|
||||
|
||||
def import_dicoms_helper(request, case_id: int | None = None):
|
||||
# dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||
if "selection" in request.POST:
|
||||
dicoms = UncategorisedDicom.objects.filter(
|
||||
series_instance_uid__in=request.POST.getlist("selection")
|
||||
)
|
||||
|
||||
else:
|
||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||
|
||||
@@ -141,70 +140,75 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
else:
|
||||
order = None
|
||||
|
||||
data = defaultdict(list)
|
||||
for d in dicoms:
|
||||
# Group dicoms by SeriesInstanceUID, but don't keep all tags in memory
|
||||
series_map = {}
|
||||
for d in dicoms.iterator(): # Use iterator() to avoid loading all at once
|
||||
tags = d.basic_dicom_tags
|
||||
series_uid = tags["SeriesInstanceUID"]
|
||||
|
||||
data[tags["SeriesInstanceUID"]].append((d, tags))
|
||||
if series_uid not in series_map:
|
||||
# Get or create Series and related objects as needed
|
||||
try:
|
||||
modality = Modality.objects.get(short_code=tags["Modality"])
|
||||
except Modality.DoesNotExist:
|
||||
modality = Modality.objects.create(short_code=tags["Modality"], modality=tags["Modality"])
|
||||
|
||||
series_list = []
|
||||
for series_uid in data:
|
||||
modality = get_object_or_404(Modality, short_code=tags["Modality"])
|
||||
tags = data[series_uid][0][1]
|
||||
series_tags = tags # Save the first tag for this series
|
||||
try:
|
||||
with transaction.atomic():
|
||||
series, created = Series.objects.get_or_create(
|
||||
series_instance_uid=series_uid,
|
||||
defaults={
|
||||
"modality": modality,
|
||||
"description": tags.get("SeriesDescription", ""),
|
||||
}
|
||||
)
|
||||
if created and tags.get("StudyDescription"):
|
||||
examination, created_exam = Examination.objects.get_or_create(
|
||||
examination=tags["StudyDescription"]
|
||||
)
|
||||
if created_exam:
|
||||
examination.modality = modality
|
||||
examination.save()
|
||||
series.examination = examination
|
||||
series.save()
|
||||
except IntegrityError:
|
||||
series = Series.objects.get(series_instance_uid=series_uid)
|
||||
|
||||
# Check if series with the id already exists (in which case we just add to htat)
|
||||
if Series.objects.filter(
|
||||
series_instance_uid=tags["SeriesInstanceUID"]
|
||||
).exists():
|
||||
series = Series.objects.get(series_instance_uid=tags["SeriesInstanceUID"])
|
||||
else:
|
||||
series = Series(
|
||||
modality=modality,
|
||||
description=tags["SeriesDescription"],
|
||||
series_instance_uid=tags["SeriesInstanceUID"],
|
||||
)
|
||||
|
||||
if tags["StudyDescription"]:
|
||||
examination, created = Examination.objects.get_or_create(
|
||||
examination=tags["StudyDescription"]
|
||||
)
|
||||
if created:
|
||||
examination.modality = modality
|
||||
examination.save()
|
||||
series.examination = examination
|
||||
|
||||
series.save()
|
||||
|
||||
match order:
|
||||
case "order-series-instance-number":
|
||||
series.order_by_dicom("InstanceNumber")
|
||||
case "order-series-slice-location":
|
||||
series.order_by_dicom("SliceLocation")
|
||||
case _:
|
||||
# Add author and case if needed
|
||||
series.author.add(request.user)
|
||||
if case_id is not None:
|
||||
case_object = get_object_or_404(Case, pk=case_id)
|
||||
try:
|
||||
series.case.add(case_object)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
# We might only want to add the author during creation....
|
||||
series.author.add(request.user)
|
||||
series_map[series_uid] = series
|
||||
else:
|
||||
series = series_map[series_uid]
|
||||
|
||||
if case_id is not None:
|
||||
case_object = get_object_or_404(Case, pk=case_id)
|
||||
if not series.case.contains(case_object):
|
||||
series.case.add(case_object)
|
||||
# Create and save SeriesImage immediately
|
||||
series_image = SeriesImage(
|
||||
image=d.image,
|
||||
series=series,
|
||||
is_dicom=True,
|
||||
image_blake3_hash=d.image_blake3_hash,
|
||||
)
|
||||
series_image.save()
|
||||
d.delete() # Remove UncategorisedDicom immediately
|
||||
|
||||
for dicom, dicom_tags in data[series_uid]:
|
||||
series_image = SeriesImage(
|
||||
image=dicom.image,
|
||||
series=series,
|
||||
is_dicom=True,
|
||||
image_blake3_hash=dicom.image_blake3_hash,
|
||||
)
|
||||
series_image.save()
|
||||
# dicom.image.delete()
|
||||
# Optionally, re-order series if needed
|
||||
for series in series_map.values():
|
||||
match order:
|
||||
case "order-series-instance-number":
|
||||
series.order_by_dicom("InstanceNumber")
|
||||
case "order-series-slice-location":
|
||||
series.order_by_dicom("SliceLocation")
|
||||
case _:
|
||||
pass
|
||||
|
||||
dicom.delete()
|
||||
series_list.append((series, series.get_link()))
|
||||
|
||||
return series_list
|
||||
return [(series, series.get_link()) for series in series_map.values()]
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -225,7 +229,6 @@ def import_dicoms_case(request, case_id: int):
|
||||
|
||||
@router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema])
|
||||
def orphan_series(request):
|
||||
# print(request.user.series.filter(case=None))
|
||||
return request.user.series.filter(case=None)
|
||||
|
||||
|
||||
@@ -248,7 +251,6 @@ def series_remove_duplicate_images(request, series_id: int):
|
||||
|
||||
@router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth)
|
||||
def series_truncate(request, series_id: int, start: int, end: int):
|
||||
print(start, end)
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
|
||||
if not series.check_user_can_edit(request.user):
|
||||
@@ -259,11 +261,13 @@ def series_truncate(request, series_id: int, start: int, end: int):
|
||||
if n >= start and n <= end:
|
||||
continue
|
||||
else:
|
||||
print(n, image)
|
||||
image.removed = True
|
||||
image.image = None
|
||||
image.save()
|
||||
images_removed.append(image.pk)
|
||||
|
||||
series.get_total_image_size(refresh=True)
|
||||
|
||||
return {"status": "success", "images_removed": images_removed}
|
||||
|
||||
|
||||
@@ -275,7 +279,6 @@ def get_cases_user(request):
|
||||
@router.get("/check_image_hash/{hash}", auth=django_auth)
|
||||
def check_image_hash(request, hash: str):
|
||||
try:
|
||||
print(hash)
|
||||
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
|
||||
data = {
|
||||
"status": "success",
|
||||
@@ -354,39 +357,38 @@ def series_split_by_tag(request, series_id: int, dicom_tag: str):
|
||||
|
||||
image_map = defaultdict(list)
|
||||
|
||||
# Split teh associated images
|
||||
for image in series.images.all():
|
||||
print(image)
|
||||
# Use iterator to avoid loading all images into memory
|
||||
for image in series.images.iterator():
|
||||
ds = image.get_dicom_data()
|
||||
|
||||
# print("2a", ds[dicom_tag])
|
||||
|
||||
if dicom_tag in ds:
|
||||
val = ds[dicom_tag].value
|
||||
|
||||
if type(val) == pydicom.multival.MultiValue:
|
||||
image_map[",".join((val))].append(image)
|
||||
if isinstance(val, pydicom.multival.MultiValue):
|
||||
key = ",".join(map(str, val))
|
||||
else:
|
||||
image_map[val].append(image)
|
||||
key = str(val)
|
||||
image_map[key].append(image)
|
||||
else:
|
||||
return [f"{dicom_tag} not found {image}"]
|
||||
|
||||
case = list(series.case.all())
|
||||
authors = list(series.author.all())
|
||||
new_series = []
|
||||
case = series.case.all()
|
||||
authors = series.author.all()
|
||||
|
||||
for n, option in enumerate(image_map):
|
||||
if n == 0:
|
||||
series.images.set(image_map[option])
|
||||
|
||||
else:
|
||||
series.pk = None
|
||||
series.save()
|
||||
series.case.set(case)
|
||||
series.author.set(authors)
|
||||
series.images.set(image_map[option])
|
||||
|
||||
new_series.append(series.pk)
|
||||
# Use a transaction for all DB changes
|
||||
with transaction.atomic():
|
||||
for n, (option, images) in enumerate(image_map.items()):
|
||||
if n == 0:
|
||||
# Update the original series
|
||||
series.images.set(images)
|
||||
new_series.append(series.pk)
|
||||
else:
|
||||
# Clone the series
|
||||
series.pk = None
|
||||
series.save()
|
||||
series.case.set(case)
|
||||
series.author.set(authors)
|
||||
series.images.set(images)
|
||||
new_series.append(series.pk)
|
||||
|
||||
return new_series
|
||||
|
||||
|
||||
@@ -80,6 +80,8 @@ def user_is_collection_author_or_atlas_editor(function):
|
||||
def wrap(request, *args, **kwargs):
|
||||
if "exam_id" in kwargs:
|
||||
atlas = CaseCollection.objects.get(pk=kwargs["exam_id"])
|
||||
elif "collection_id" in kwargs:
|
||||
atlas = CaseCollection.objects.get(pk=kwargs["collection_id"])
|
||||
else:
|
||||
atlas = CaseCollection.objects.get(pk=kwargs["pk"])
|
||||
if (
|
||||
|
||||
+132
-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
|
||||
@@ -49,6 +52,8 @@ def get_series(request):
|
||||
else:
|
||||
queryset = Series.objects.all()
|
||||
|
||||
queryset = queryset.prefetch_related("examination", "plane")
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@@ -159,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"],
|
||||
}
|
||||
@@ -184,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,
|
||||
@@ -204,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,
|
||||
@@ -224,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:
|
||||
@@ -284,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
|
||||
|
||||
+663
-140
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.0.2 on 2024-10-09 20:39
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0061_alter_seriesfinding_current_image_id_index'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='casecollection',
|
||||
name='markers',
|
||||
field=models.ManyToManyField(blank=True, help_text='Authorised markers for the exam', related_name='casecollection_markers', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-10-28 11:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0062_alter_casecollection_markers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='results_supervisor_visible',
|
||||
field=models.BooleanField(default=False, help_text='If true the results will be visible to supervisors without candidate approval.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-11-18 09:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0063_casecollection_results_supervisor_visible'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='exam_open_access',
|
||||
field=models.BooleanField(default=False, help_text='Set to true if you want any registered user to be able to take the exam.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-12-18 23:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0064_casecollection_exam_open_access'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='series',
|
||||
name='total_image_size',
|
||||
field=models.BigIntegerField(blank=True, help_text='Stores a (cached) total size of images within the series', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-12-18 23:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0065_series_total_image_size'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='case',
|
||||
name='total_images_series_size',
|
||||
field=models.BigIntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-10 13:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0066_case_total_images_series_size'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='case',
|
||||
name='cimar_uuid',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-04-07 11:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0067_case_cimar_uuid'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='seriesfinding',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, help_text='Findings on the question', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-04-28 15:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0068_alter_seriesfinding_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='caseprior',
|
||||
name='prior_visibility',
|
||||
field=models.CharField(choices=[('NO', 'None'), ('AL', 'Always'), ('RE', 'Review')], default='AL', help_text='Defines when the prior case is shown to the user', max_length=2),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.4 on 2025-06-09 10:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0069_caseprior_prior_visibility'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='seriesfinding',
|
||||
name='annotation_json_3d',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='seriesfinding',
|
||||
name='viewer_state_3d',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-06-16 12:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0070_seriesfinding_annotation_json_3d_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casedetail',
|
||||
name='default_viewerstate',
|
||||
field=models.JSONField(blank=True, help_text='Default viewer state for the case', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.1.4 on 2025-06-30 12:03
|
||||
|
||||
import django.db.models.deletion
|
||||
import generic.mixins
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0071_casedetail_default_viewerstate'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CaseDisplaySet',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Name of the display set', max_length=255)),
|
||||
('description', models.TextField(blank=True, help_text='Description of the display set')),
|
||||
('viewerstate', models.JSONField(blank=True, help_text='Viewer state for the display set', null=True)),
|
||||
('annotations', models.JSONField(blank=True, help_text='Annotations for the display set', null=True)),
|
||||
('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='atlas.case')),
|
||||
],
|
||||
bases=(models.Model, generic.mixins.AuthorMixin),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.1.4 on 2025-07-14 09:41
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0072_casedisplayset'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casedisplayset',
|
||||
name='conditions',
|
||||
field=models.ManyToManyField(blank=True, to='atlas.condition'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casedisplayset',
|
||||
name='findings',
|
||||
field=models.ManyToManyField(blank=True, to='atlas.finding'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casedisplayset',
|
||||
name='structures',
|
||||
field=models.ManyToManyField(blank=True, to='atlas.structure'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='casedisplayset',
|
||||
name='case',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='display_sets', to='atlas.case'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-09-02 20:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0073_casedisplayset_conditions_casedisplayset_findings_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='seriesimage',
|
||||
name='image_md5_hash',
|
||||
field=models.CharField(blank=True, max_length=64, null=True),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
+783
-90
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
# serializers.py
|
||||
from rest_framework import serializers, permissions
|
||||
+178
-51
@@ -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
|
||||
@@ -22,11 +23,64 @@ from easy_thumbnails.files import get_thumbnailer
|
||||
|
||||
from easy_thumbnails.exceptions import InvalidImageFormatError
|
||||
|
||||
from django.db.models import Prefetch
|
||||
|
||||
|
||||
class CaseTable(SelectionTable):
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
# Optimize prefetching for casecollection_set
|
||||
#case_collections_prefetch = Prefetch(
|
||||
# "casecollection_set",
|
||||
# queryset=CaseCollection.objects.select_related("author"), # Adjust fields as needed
|
||||
#)
|
||||
|
||||
super().__init__(
|
||||
data.prefetch_related(
|
||||
"author", # Many-to-many or reverse relationship
|
||||
"series", # Many-to-many relationship
|
||||
"series__examination", # ForeignKey in the related model
|
||||
"series__plane", # ForeignKey in the related model
|
||||
"series__images", # Reverse relationship
|
||||
"previous_case", # Prefetch the previous case relationship
|
||||
"next_case", # Prefetch the next case relationship
|
||||
"casecollection_set", # Prefetch the case collection set
|
||||
#case_collections_prefetch, # Optimized prefetch for collections
|
||||
),
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def render_title(self, value, record):
|
||||
return value
|
||||
|
||||
def render_description(self, value):
|
||||
max_length = 50 # Set the maximum length for truncation
|
||||
if len(value) > max_length:
|
||||
truncated_value = f"{value[:max_length]}..." # Truncate and add ellipsis
|
||||
else:
|
||||
truncated_value = value
|
||||
# Add a tooltip with the full text
|
||||
return format_html(
|
||||
'<span title="{}">{}</span>',
|
||||
value, # Full text for the tooltip
|
||||
truncated_value, # Truncated text for display
|
||||
)
|
||||
|
||||
def render_series(self, value):
|
||||
max_length = 50 # Set the maximum length for truncation
|
||||
series_list = [str(series) for series in value.all()]
|
||||
full_text = ", ".join(series_list) # Full text for the tooltip
|
||||
if len(full_text) > max_length:
|
||||
truncated_text = f"{full_text[:max_length]}..." # Truncate and add ellipsis
|
||||
else:
|
||||
truncated_text = full_text
|
||||
# Add a tooltip with the full text
|
||||
return format_html(
|
||||
'<span title="{}">{}</span>',
|
||||
full_text, # Full text for the tooltip
|
||||
truncated_text, # Truncated text for display
|
||||
)
|
||||
|
||||
class CaseTable(tables.Table):
|
||||
def get_view_cell(record):
|
||||
if record.open_access:
|
||||
return format_html(f"""<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">
|
||||
@@ -36,6 +90,78 @@ class CaseTable(tables.Table):
|
||||
else:
|
||||
return f'View'
|
||||
|
||||
def render_author(self, value):
|
||||
max_length = 15 # Set the maximum length for truncating individual author names
|
||||
authors = []
|
||||
for author in value.all():
|
||||
full_name = str(author) # Convert the author object to a string (e.g., full name)
|
||||
if len(full_name) > max_length:
|
||||
truncated_name = f"{full_name[:max_length]}..." # Truncate and add ellipsis
|
||||
else:
|
||||
truncated_name = full_name
|
||||
# Add a tooltip with the full name
|
||||
authors.append(
|
||||
format_html(
|
||||
'<span title="{}">{}</span>',
|
||||
full_name, # Full name for the tooltip
|
||||
truncated_name, # Truncated name for display
|
||||
)
|
||||
)
|
||||
# Join all authors with commas
|
||||
return format_html(", ".join(authors))
|
||||
|
||||
def render_associated_cases(self, record):
|
||||
links = []
|
||||
if record.previous_case:
|
||||
links.append(
|
||||
format_html(
|
||||
'<a href="{}" title="View previous case">Previous Case</a>',
|
||||
record.previous_case.get_absolute_url(),
|
||||
)
|
||||
)
|
||||
try:
|
||||
if record.next_case:
|
||||
links.append(
|
||||
format_html(
|
||||
'<a href="{}" title="View next case">Next Case</a>',
|
||||
record.next_case.get_absolute_url(),
|
||||
)
|
||||
)
|
||||
except Case.DoesNotExist:
|
||||
pass
|
||||
if links:
|
||||
return format_html(", ".join(links))
|
||||
return "No associated cases"
|
||||
|
||||
def render_collections(self, record):
|
||||
collections = [
|
||||
collection
|
||||
for collection in record.casecollection_set.all()
|
||||
if (
|
||||
hasattr(self, 'request')
|
||||
and self.request.user in collection.author.all()
|
||||
) or collection.open_access
|
||||
]
|
||||
if collections:
|
||||
links = [
|
||||
format_html(
|
||||
'<a href="{}" title="View collection">{}</a>',
|
||||
collection.get_absolute_url(),
|
||||
collection.name,
|
||||
)
|
||||
for collection in collections
|
||||
]
|
||||
return format_html(", ".join(links))
|
||||
return "No collections"
|
||||
|
||||
associated_cases = tables.Column(
|
||||
verbose_name="Associated Cases", accessor="pk", orderable=False
|
||||
)
|
||||
|
||||
collections = tables.Column(
|
||||
verbose_name="Collections", accessor="pk", orderable=False
|
||||
)
|
||||
|
||||
edit = tables.LinkColumn(
|
||||
"atlas:case_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
@@ -49,31 +175,23 @@ class CaseTable(tables.Table):
|
||||
"atlas:case_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
|
||||
#differential = tables.ManyToManyColumn(verbose_name="Differential")
|
||||
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Case
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = (
|
||||
"title",
|
||||
"description",
|
||||
#"history",
|
||||
#"condition",
|
||||
"created_date",
|
||||
"series",
|
||||
"author",
|
||||
"associated_cases",
|
||||
"collections",
|
||||
)
|
||||
sequence = ("view", )#, "series")
|
||||
sequence = ("view", )
|
||||
order_by = "-created_date"
|
||||
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(
|
||||
data.prefetch_related(
|
||||
"author",#"condition", "condition__synonym"
|
||||
),
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
class PopupLinkColumn(tables.Column):
|
||||
def render(self, value):
|
||||
@@ -86,12 +204,7 @@ class PopupLinkColumn(tables.Column):
|
||||
return obj.get_thumbnail()[0]
|
||||
|
||||
|
||||
def create_link(test):
|
||||
print(test)
|
||||
return "Hello"
|
||||
|
||||
|
||||
class SeriesTable(tables.Table):
|
||||
class SeriesTable(SelectionTable):
|
||||
edit = tables.LinkColumn(
|
||||
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
@@ -105,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 = (
|
||||
@@ -125,6 +237,8 @@ class SeriesTable(tables.Table):
|
||||
"modified_date",
|
||||
)
|
||||
sequence = ("view", "popup", "images", "case")
|
||||
order_by = "-created_date"
|
||||
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(
|
||||
@@ -142,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",
|
||||
@@ -154,18 +268,18 @@ 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",)
|
||||
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(
|
||||
data.prefetch_related(
|
||||
@@ -180,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"
|
||||
)
|
||||
@@ -190,23 +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",)
|
||||
|
||||
|
||||
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",
|
||||
@@ -217,23 +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",)
|
||||
|
||||
|
||||
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",
|
||||
@@ -245,18 +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",)
|
||||
|
||||
|
||||
|
||||
class PathologicalProcessTable(tables.Table):
|
||||
|
||||
class PathologicalProcessTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:pathological_process_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="PathologicalProcess",
|
||||
@@ -268,16 +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",)
|
||||
|
||||
|
||||
class SubspecialtyTable(tables.Table):
|
||||
class SubspecialtyTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:subspecialty_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="Subspecialty",
|
||||
@@ -289,19 +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",)
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
@@ -317,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 = (
|
||||
@@ -331,6 +456,7 @@ class CaseCollectionTable(tables.Table):
|
||||
)
|
||||
sequence = ("view", )#, "series")
|
||||
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(
|
||||
data.prefetch_related(
|
||||
@@ -340,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",
|
||||
@@ -353,10 +479,11 @@ 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")
|
||||
|
||||
|
||||
def render_schema(self, value, record):
|
||||
return format_html("<details><summary>Schema</summary>{}</details>", value)
|
||||
@@ -0,0 +1,52 @@
|
||||
from time import sleep
|
||||
from django.core.mail import send_mail
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from celery import shared_task
|
||||
from atlas.models import Case
|
||||
from generic.models import CimarCase
|
||||
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
|
||||
from helpers.cimar import CimarAPI, NotFoundError
|
||||
from pydicom.uid import generate_uid
|
||||
|
||||
@shared_task()
|
||||
def push_case_to_cimar_task(case_id):
|
||||
"""Sends an email when the feedback form has been submitted."""
|
||||
case = get_object_or_404(Case, pk=case_id)
|
||||
|
||||
api = CimarAPI()
|
||||
api.login(username=CIMAR_USERNAME, password=CIMAR_PASSWORD)
|
||||
|
||||
# We use the same study_uid for all images in a case
|
||||
study_uid = generate_uid()
|
||||
|
||||
for series in case.get_series():
|
||||
print(f"Upload series: {series}")
|
||||
for image in series.images.filter(removed=False):
|
||||
data = api.upload_dicom(image.image.path, study_uid=study_uid)
|
||||
|
||||
|
||||
retries = 5
|
||||
delay = 20
|
||||
for attempt in range(retries):
|
||||
print("attempt 1")
|
||||
try:
|
||||
cimar_uuid = api.get_study_by_study_uid(data["study_uid"])["uuid"]
|
||||
break
|
||||
except NotFoundError:
|
||||
if attempt < retries - 1:
|
||||
sleep(delay)
|
||||
delay *= 2
|
||||
else:
|
||||
cimar_uuid = api.get_study_by_study_uid(data["study_uid"])["uuid"]
|
||||
|
||||
|
||||
case.cimar_uuid = cimar_uuid
|
||||
|
||||
case.save()
|
||||
|
||||
cimar_case, created = CimarCase.objects.get_or_create(uuid=cimar_uuid)
|
||||
|
||||
cimar_case.refresh_study()
|
||||
|
||||
return 10
|
||||
@@ -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,13 +1,9 @@
|
||||
<span id="add-case-form">
|
||||
<form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<label for="case">Select Case to add:</label>
|
||||
<select name="case" id="case">
|
||||
{% for case in cases %}
|
||||
<option value="{{ case.id }}">{{ case.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Add</button>
|
||||
<button _="on click remove #add-case-form">Cancel</button>
|
||||
</form>
|
||||
</span
|
||||
<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>
|
||||
+115
-12
@@ -5,19 +5,122 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block navigation %}
|
||||
Atlas:
|
||||
{% if request.user.is_authenticated %}
|
||||
<a href="{% url 'atlas:collection_view' %}">Collections</a> /
|
||||
<a href="{% url 'atlas:case_view' %}?sort=-created_date">Cases</a> /
|
||||
<a href="{% url 'atlas:series_view' %}">Series</a> /
|
||||
<a href="{% url 'atlas:categories_list' %}">Categories</a> /
|
||||
{% comment %} <a href="{% url 'atlas:resource_view' %}">Resources</a> / {% endcomment %}
|
||||
<a href="{% url 'atlas:case_create' %}" title="Create a new atlas case">Create Case</a> /
|
||||
<a href="{% url 'atlas:series_create' %}" title="Create a new image series">Create Series</a> /
|
||||
<a href="{% url 'atlas:resource_view' %}" title="Resources">Resources</a> /
|
||||
<a href="{% url 'atlas:user_uploads' %}" title="View unimported uploads">Uploads</a> /
|
||||
<a href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas">Question Schemas</a> /
|
||||
<a href="{% url 'atlas:help' %}" title="View the atlas help pages">Help</a>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark submenu">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{% url 'atlas:index' %}">Atlas</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:collection_view' %}"><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' %}"><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' %}"><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' %}"><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' %}"><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' %}"><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' %}"><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' %}"><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' %}"><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' %}"><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: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: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:help' %}" title="View the atlas help pages"><i class="bi bi-question-circle me-1" aria-hidden="true"></i>Help</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% comment %} </br>
|
||||
Questions by:
|
||||
|
||||
@@ -1,13 +1,168 @@
|
||||
<div class="atlas {% if case.scrapped %}atlas-scrapped{% endif %}">
|
||||
<div><a href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">View case in OHIF</a> <a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">(new tab)</a>
|
||||
{% load partials %}
|
||||
|
||||
<div class="date">
|
||||
{{ case.created_date|date:"d/m/Y" }}
|
||||
{% partialdef case-series %}
|
||||
{% for series in case.get_ordered_series %}
|
||||
<span class="series-block" id="series-block-{{ series.pk }}">
|
||||
<span>
|
||||
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
|
||||
<a href="{% url 'atlas:series_detail' pk=series.pk %}">
|
||||
{{series.get_block}}
|
||||
</a>
|
||||
<br>
|
||||
<button type="button"
|
||||
class="btn btn-outline-primary btn-sm select-series-btn"
|
||||
style="display: none;"
|
||||
data-series-id="{{series.pk}}">
|
||||
Select
|
||||
</button>
|
||||
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
|
||||
<span class="series-block-popup-link">
|
||||
<a href="#"
|
||||
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% endpartialdef %}
|
||||
|
||||
<div class="atlas {% if case.scrapped %}atlas-scrapped{% endif %}">
|
||||
<h2>Case: {{ case.title }}</h2>
|
||||
{% if case.previous_case or case.next_case %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
This case is part of a <a href='{% url "atlas:linked_cases_overview" case.pk %}'>series.</a><br/>
|
||||
<div class="row">
|
||||
{% if case.previous_case %}
|
||||
<div class="col-sm-6 mb-3 mb-sm-0">
|
||||
<h5 class="card-title">Previous Case</h5>
|
||||
{{ case.previous_case.get_link }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if case.next_case %}
|
||||
<div class="col-sm-6">
|
||||
<h5 class="card-title">Next Case</h5>
|
||||
{{ case.next_case.get_link }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="id">
|
||||
ID: {{ case.id }}
|
||||
{% endif %}
|
||||
<div>
|
||||
|
||||
{% if case.cimar_uuid %}
|
||||
|
||||
<details><summary>Details</summary>
|
||||
Case uuid: {{case.cimar_uuid}}
|
||||
<br/>
|
||||
|
||||
{% if cimar_sid %}
|
||||
<a href="{{case.get_cimar_case.viewer_link}}?sid={{cimar_sid}}" target="_blank">External</a>
|
||||
{% else %}
|
||||
<a href="{{case.get_cimar_case.viewer_link}}" target="_blank">External</a>
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
<button
|
||||
hx-get='{% url "cimar_study_viewer_embed" case.cimar_uuid %}'
|
||||
hx-target="#modals-here"
|
||||
hx-trigger="click"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#modals-here"
|
||||
class="btn primary">Open Modal</button>
|
||||
|
||||
<div id="modals-here"
|
||||
class="modal modal-blur fade"
|
||||
style="display: none"
|
||||
aria-hidden="false"
|
||||
tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div class="modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href='{% url "cimar_study_viewer" case.cimar_uuid %}'>Viewer</a>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Study details</summary>
|
||||
{{ case.get_cimar_case.get_pretty_study_details }}
|
||||
</details>
|
||||
<details>
|
||||
<summary>Study schema</summary>
|
||||
{{ case.get_cimar_case.get_pretty_study_schema }}
|
||||
</details>
|
||||
<details>
|
||||
<summary>Study series</summary>
|
||||
<button
|
||||
id='load-series'
|
||||
hx-get='{% url "cimar_study_series_block" case.cimar_uuid %}'
|
||||
hx-trigger='load'
|
||||
hx-swap='outerHTML'>
|
||||
Load Series
|
||||
</button>
|
||||
</details>
|
||||
<a href='{% url "generic:cimar_case_refresh" case.cimar_uuid %}'>Refresh</a>
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
|
||||
{% else %}
|
||||
<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>
|
||||
<p class="pre-whitespace"><b>Title:</b> {{ case.title }}</p>
|
||||
|
||||
<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>
|
||||
|
||||
<details id="dicom-viewer-details">
|
||||
<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='{{case.get_case_named_stacks}}'
|
||||
></div>
|
||||
</details>
|
||||
|
||||
<p class="pre-whitespace"><b>Description:</b> {{ case.description }}</p>
|
||||
|
||||
@@ -20,11 +175,32 @@
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
<p class="pre-whitespace"><b>History:</b> {{ case.history }}</p>
|
||||
|
||||
<p class="pre-whitespace"><b>Discussion:</b> {{ case.discussion }}</p>
|
||||
|
||||
<p class="pre-whitespace"><b>Report:</b> {{ case.report }}</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 %}
|
||||
@@ -37,40 +213,35 @@
|
||||
|
||||
<div class="pre-whitespace multi-image-block"><b>Series:</b>
|
||||
<form>
|
||||
{% for series in case.series.all %}
|
||||
<span class="series-block">
|
||||
<span>
|
||||
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
|
||||
<a href="{% url 'atlas:series_detail' pk=series.pk %}">
|
||||
{{series.get_block}}
|
||||
</a>
|
||||
<br>
|
||||
<input type="checkbox" name="series-ids" value="{{series.pk}}">
|
||||
<span class="series-block-popup-link">
|
||||
<a href="#"
|
||||
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
<div class="d-flex flex-wrap gap-2 align-items-start" id="case-series-row">
|
||||
{% partial case-series %}
|
||||
</div>
|
||||
</form>
|
||||
<span>
|
||||
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Add new series</a><br />
|
||||
<a href="{% url 'atlas:user_uploads_case' case_id=case.pk %}">Import new uploads</a><br />
|
||||
<details>
|
||||
<summary>+</summary>
|
||||
<button type="button"
|
||||
class="btn btn-info"
|
||||
onclick="window.location.href='{% url 'atlas:user_uploads_case' case_id=case.pk %}'">
|
||||
Import new uploads
|
||||
</button><br />
|
||||
<details class="series-actions" id="series-actions">
|
||||
<summary>
|
||||
<button type="button" class="btn btn-primary" title="Click to manage series" onclick="this.closest('details').open = !this.closest('details').open;">
|
||||
Manage Series
|
||||
</button>
|
||||
</summary>
|
||||
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Create and add new series</a><br />
|
||||
<button hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
|
||||
title="order dicom by slice location"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#series-action-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="This will reorder all case series based upon slice location"
|
||||
>
|
||||
Order all series dicoms by slice location
|
||||
Order dicoms by slice location
|
||||
</button>
|
||||
<button hx-post="{% url 'atlas:combine_series' %}"
|
||||
title="merge series"
|
||||
hx-include="[name='series-ids']"
|
||||
hx-target="#series-merge-results"
|
||||
hx-target="#series-action-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="This will merge all selected series into one"
|
||||
>
|
||||
@@ -79,14 +250,33 @@
|
||||
<button hx-post="{% url 'atlas:use_dates_as_descriptions' case.pk %}"
|
||||
title="merge series"
|
||||
hx-include="[name='series-ids']"
|
||||
hx-target="#series-merge-results"
|
||||
hx-target="#series-action-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="This will use the dicom date as the series description"
|
||||
>
|
||||
Use dates as description
|
||||
</button>
|
||||
<button hx-post="{% url 'atlas:remove_selected_series_from_case' case.pk %}"
|
||||
title="remove selected series from this case"
|
||||
hx-include="[name='series-ids']:checked"
|
||||
hx-target="#series-action-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Are you sure you want to remove the selected series from this case? They will not be deleted from the database."
|
||||
class="btn btn-warning mt-2"
|
||||
type="button"
|
||||
>
|
||||
Remove selected series from case
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-secondary mt-2"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#reorderSeriesModal">
|
||||
Reorder series
|
||||
</button>
|
||||
<br/>
|
||||
<span id="series-merge-results"></span>
|
||||
<div class="alert alert-info mt-2" id="series-action-alert" style="display:none;">
|
||||
<span id="series-action-results" ></span>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</span>
|
||||
@@ -95,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 %}
|
||||
@@ -103,23 +293,46 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div>
|
||||
<details>
|
||||
<details open>
|
||||
<summary><b>Findings</b></summary>
|
||||
{% if case.series.all %}
|
||||
{% for series in case.series.all %}
|
||||
|
||||
{% if can_edit %}
|
||||
<details class="help-text">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p>Findings are display sets that can be added to series. Once added they will appear here on linked cases.</p>
|
||||
<p>To add a finding to a series, select the series and then click the "Add finding" button.</p>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<!-- Series Findings -->
|
||||
<h5>Series Findings</h5>
|
||||
{% if case.ordered_series %}
|
||||
{% for series in case.ordered_series %}
|
||||
{% for finding in series.findings.all %}
|
||||
<div class="finding-box">
|
||||
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}">
|
||||
<span>
|
||||
<a href="{{series.get_absolute_url}}">Series {{series.pk}}</a>: {{series.get_examination_full}} |
|
||||
<a href="{{series.get_absolute_url}}?show_finding={{finding.pk}}">
|
||||
<button class="btn btn-primary btn-sm">View</button>
|
||||
</a>
|
||||
<button class="btn btn-secondary btn-sm view-finding-modal" data-finding-id="{{ finding.pk }}" data-series-id="{{ series.pk }}">
|
||||
View in Modal
|
||||
</button>
|
||||
</span>
|
||||
{% if finding.findings.all %}
|
||||
{% if finding.findings %}
|
||||
<span>
|
||||
Findings: {% for f in finding.findings.all %}
|
||||
{{f.get_link}},
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if finding.structures.all %}
|
||||
{% if finding.conditions %}
|
||||
<span>
|
||||
Conditions: {% for c in finding.conditions.all %}
|
||||
{{c.get_link}},
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if finding.structures %}
|
||||
<span>
|
||||
Structure: {% for s in finding.structures.all %}
|
||||
{{s.get_link}},
|
||||
@@ -137,6 +350,75 @@
|
||||
{% else %}
|
||||
No series associated with case.
|
||||
{% endif %}
|
||||
|
||||
<!-- Case Display Sets -->
|
||||
<h5 class="mt-3">Case Display Sets</h5>
|
||||
{% if case.display_sets %}
|
||||
<ul class="list-group">
|
||||
{% for ds in case.display_sets.all %}
|
||||
<li class="list-group-item">
|
||||
<b>Name: {{ ds.name }}</b>
|
||||
{% if ds.description %}
|
||||
<span class="text-muted">({{ ds.description }})</span>
|
||||
{% endif %}
|
||||
<span>
|
||||
<a href="{% url 'atlas:case_displaysets_detail' ds.pk %}">View Display Set</a>
|
||||
</span>
|
||||
<div>
|
||||
<strong>Findings:</strong>
|
||||
{% if ds.findings.all %}
|
||||
<ul>
|
||||
{% for finding in ds.findings.all %}
|
||||
<li>{{ finding }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Structures:</strong>
|
||||
{% if ds.structures.all %}
|
||||
<ul>
|
||||
{% for structure in ds.structures.all %}
|
||||
<li>{{ structure }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Conditions:</strong>
|
||||
{% if ds.conditions.all %}
|
||||
<ul>
|
||||
{% for condition in ds.conditions.all %}
|
||||
<li>{{ condition }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<details>
|
||||
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
|
||||
<div>
|
||||
<strong>Viewer State:</strong>
|
||||
<pre class="small">{{ ds.viewerstate|default:"{}" }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Annotations:</strong>
|
||||
<pre class="small">{{ ds.annotations|default:"{}" }}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-muted">No display sets for this case.</span>
|
||||
{% endif %}
|
||||
</details>
|
||||
</div>
|
||||
<div>
|
||||
@@ -149,35 +431,54 @@
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
Subspecialty:
|
||||
<ul>
|
||||
{% for sub in case.subspecialty.all %}
|
||||
<li>{{sub.get_link}}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<b>Pathological Process:</b>
|
||||
<ul>
|
||||
{% for p in case.pathological_process.all %}
|
||||
<li>{{p.get_link}}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<b>Diagnostic certainty:</b> {{case.get_diagnostic_certainty_display}}<br />
|
||||
|
||||
<b>Collections:</b>
|
||||
<button
|
||||
hx-get="{% url 'atlas:case_collection_form' case.pk %}"
|
||||
hx-target="#collection-form">
|
||||
Add collection</button>
|
||||
|
||||
{% if can_edit %}
|
||||
<button
|
||||
hx-get="{% url 'atlas:case_collection_form' case.pk %}"
|
||||
hx-target="#collection-form">
|
||||
Add to collection</button>
|
||||
{% endif %}
|
||||
<div id="collection-form"></div>
|
||||
<ul>
|
||||
{% for collection in case.casecollection_set.all %}
|
||||
<li><a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{collection.name}}</a></li>
|
||||
<li id="collection-{{ collection.pk }}">
|
||||
<a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{ collection.name }}</a>
|
||||
{% if can_edit %}
|
||||
<button
|
||||
class="btn btn-danger btn-sm remove-button"
|
||||
hx-post="{% url 'atlas:remove_case_from_collection' case.pk collection.pk %}"
|
||||
hx-target="#collection-{{ collection.pk }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to remove this case from the collection?">
|
||||
Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
|
||||
{% 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> {{ case.next_case.get_link }}</p>
|
||||
<p class="pre-whitespace"><b>Next case:</b>
|
||||
{% if case.next_case %}
|
||||
{{ case.next_case.get_link }}
|
||||
{% else %}
|
||||
<a href="{% url 'atlas:case_clone_next' case.pk %}">Clone and add next case</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<div>
|
||||
{% include 'question_notes.html' %}
|
||||
|
||||
@@ -188,3 +489,312 @@
|
||||
|
||||
<p><b>Author(s):</b> {% for author in case.author.all %} <a
|
||||
href="{% url 'atlas:author_detail' pk=author.pk %}">{{author}}</a>, {% endfor %}</p>
|
||||
|
||||
|
||||
<p><b>Case size:</b> {{ case.get_total_series_images_size | filesizeformat }}</p>
|
||||
|
||||
<div class="modal fade" id="findingModal" tabindex="-1" aria-labelledby="findingModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="findingModalLabel">Finding Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="findingModalBody">
|
||||
<!-- Finding details will be loaded here dynamically -->
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reorder Series Modal -->
|
||||
<div class="modal fade" id="reorderSeriesModal" tabindex="-1" aria-labelledby="reorderSeriesModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" action="{% url 'atlas:reorder_series' case.pk %}" id="reorder-series-form">
|
||||
{% csrf_token %}
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="reorderSeriesModalLabel">Reorder Series</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul id="series-reorder-list" class="list-group">
|
||||
{% for series in case.get_ordered_series %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center" data-series-id="{{ series.pk }}">
|
||||
<span>
|
||||
<span class="series-block-series-number">Series {{ forloop.counter }}:</span>
|
||||
{{ series.get_block|default:series }}
|
||||
</span>
|
||||
<span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary move-up">↑</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary move-down">↓</button>
|
||||
</span>
|
||||
<input type="hidden" name="series_order" value="{{ series.pk }}">
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">Save order</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// From series_viewer.html
|
||||
function loadAnnotationAndViewportOnElement(annotation, viewport, dicom_element, current_image_id_index) {
|
||||
cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
|
||||
cornerstone.getEnabledElement(dicom_element).viewport = viewport
|
||||
cornerstoneTools.globalImageIdSpecificToolStateManager.restoreToolState(
|
||||
annotation);
|
||||
console.log("annotation", annotation)
|
||||
console.log("viewport", viewport)
|
||||
|
||||
//cornerstone.getEnabledElement(dicom_element).toolStateManager.restoreToolState(annotationjson)
|
||||
cornerstone.resize(dicom_element);
|
||||
console.log("current_image_id_index", current_image_id_index)
|
||||
//current_image_id_index = parseInt(current_image_id_index);
|
||||
|
||||
console.log(current_image_id_index, Number.isInteger(current_image_id_index))
|
||||
if (Number.isInteger(current_image_id_index)) {
|
||||
console.log("loading stack index")
|
||||
dicomViewer.loadStackIndex(current_image_id_index, dicom_element);
|
||||
} else {
|
||||
try {
|
||||
console.log("current_image_id_index", current_image_id_index)
|
||||
dicomViewer.loadImageById(current_image_id_index, dicom_element);
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
console.log("loading image by id failed")
|
||||
console.log("loading next annotation image")
|
||||
dicomViewer.getNextAnnotationImage(dicom_element);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
|
||||
const dicomDetails = document.getElementById("dicom-viewer-details");
|
||||
let dicomViewerLoaded = false;
|
||||
dicomDetails.addEventListener("toggle", function() {
|
||||
if (!dicomViewerLoaded && dicomDetails.open) {
|
||||
window.mountDicomViewers();
|
||||
dicomViewerLoaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Move up/down logic
|
||||
document.querySelectorAll("#series-reorder-list .move-up, #series-reorder-list .move-down").forEach(function(btn) {
|
||||
btn.addEventListener("click", function(e) {
|
||||
e.preventDefault();
|
||||
const li = btn.closest("li");
|
||||
if (btn.classList.contains("move-up") && li.previousElementSibling) {
|
||||
li.parentNode.insertBefore(li, li.previousElementSibling);
|
||||
}
|
||||
if (btn.classList.contains("move-down") && li.nextElementSibling) {
|
||||
li.parentNode.insertBefore(li.nextElementSibling, li);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// On submit, update the hidden inputs to match the new order
|
||||
document.getElementById("reorder-series-form").addEventListener("submit", function(e) {
|
||||
const lis = document.querynelectorAll("#series-reorder-list li");
|
||||
lis.forEach((li, idx) => {
|
||||
li.querySelector("input[name='series_order']").setAttribute("name", "series_order_" + idx);
|
||||
});
|
||||
});
|
||||
|
||||
// Drag and drop reordering for series list
|
||||
const reorderList = document.getElementById("series-reorder-list");
|
||||
let draggedItem = null;
|
||||
|
||||
if (reorderList) {
|
||||
reorderList.querySelectorAll("li").forEach(function(li) {
|
||||
li.setAttribute("draggable", "true");
|
||||
|
||||
li.addEventListener("dragstart", function(e) {
|
||||
draggedItem = li;
|
||||
li.classList.add("dragging");
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
|
||||
li.addEventListener("dragend", function() {
|
||||
draggedItem = null;
|
||||
li.classList.remove("dragging");
|
||||
});
|
||||
|
||||
li.addEventListener("dragover", function(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
// Visual feedback
|
||||
li.classList.add("drag-over");
|
||||
});
|
||||
|
||||
li.addEventListener("dragleave", function() {
|
||||
li.classList.remove("drag-over");
|
||||
});
|
||||
|
||||
li.addEventListener("drop", function(e) {
|
||||
e.preventDefault();
|
||||
li.classList.remove("drag-over");
|
||||
if (draggedItem && draggedItem !== li) {
|
||||
// Insert before or after depending on mouse position
|
||||
const bounding = li.getBoundingClientRect();
|
||||
const offset = e.clientY - bounding.top;
|
||||
if (offset < bounding.height / 2) {
|
||||
li.parentNode.insertBefore(draggedItem, li);
|
||||
} else {
|
||||
li.parentNode.insertBefore(draggedItem, li.nextSibling);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const detailsElement = document.getElementById("series-actions");
|
||||
const buttons = document.querySelectorAll(".select-series-btn");
|
||||
|
||||
function toggleCheckboxes() {
|
||||
const isOpen = detailsElement.hasAttribute("open");
|
||||
buttons.forEach(button => {
|
||||
button.style.display = isOpen ? "inline-block" : "none";
|
||||
});
|
||||
}
|
||||
document.querySelectorAll(".select-series-btn").forEach(function(btn) {
|
||||
btn.addEventListener("click", function() {
|
||||
const parent = btn.closest(".series-block");
|
||||
const checkbox = parent.querySelector("input[type='checkbox'][name='series-ids']");
|
||||
checkbox.checked = !checkbox.checked;
|
||||
btn.classList.toggle("btn-primary", checkbox.checked);
|
||||
btn.classList.toggle("btn-outline-primary", !checkbox.checked);
|
||||
btn.textContent = checkbox.checked ? "Selected" : "Select";
|
||||
// Highlight the series-block when selected
|
||||
if (checkbox.checked) {
|
||||
parent.classList.add("highlight-series");
|
||||
} else {
|
||||
parent.classList.remove("highlight-series");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initial state
|
||||
toggleCheckboxes();
|
||||
|
||||
|
||||
// Show the alert when a result is received
|
||||
const resultsSpan = document.getElementById("series-action-results");
|
||||
const alertDiv = document.getElementById("series-action-alert");
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
if (resultsSpan.textContent.trim() !== "") {
|
||||
alertDiv.style.display = "block";
|
||||
} else {
|
||||
alertDiv.style.display = "none";
|
||||
}
|
||||
});
|
||||
observer.observe(resultsSpan, { childList: true, subtree: true });
|
||||
|
||||
// Listen for toggle events on the <details> element
|
||||
detailsElement.addEventListener("toggle", toggleCheckboxes);
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById("findingModal"));
|
||||
const modalBody = document.getElementById("findingModalBody");
|
||||
|
||||
document.querySelectorAll(".view-finding-modal").forEach(button => {
|
||||
button.addEventListener("click", function () {
|
||||
const findingId = this.getAttribute("data-finding-id");
|
||||
const seriesId = this.getAttribute("data-series-id");
|
||||
|
||||
// Show loading text
|
||||
modalBody.innerHTML = "<p>Loading...</p>";
|
||||
|
||||
// Fetch the finding details (replace with your actual endpoint)
|
||||
fetch(`/atlas/series_finding/${findingId}/details/`)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
modalBody.innerHTML = html; // Load the fetched HTML into the modal body
|
||||
load_3d = false;
|
||||
viewer_element_3d = document.getElementById('root');
|
||||
if (viewer_element_3d.dataset.viewerstate3d != undefined) {
|
||||
load_3d = true;
|
||||
viewerState = JSON.parse(viewer_element_3d.dataset.viewerstate3d || {});
|
||||
annotationjson3d = JSON.parse(viewer_element_3d.dataset.annotationjson3d || {});
|
||||
} else {
|
||||
viewer_element_3d = null;
|
||||
}
|
||||
// We have a slight delay to allow the viewer to load
|
||||
setTimeout(function () {
|
||||
// Then we load the annotation and viewport
|
||||
window.mountDicomViewers();
|
||||
if (load_3d) {
|
||||
console.log("loading 3d")
|
||||
|
||||
//setTimeout(function () {
|
||||
// console.log("loading 3d annotation")
|
||||
// importAnnotations_root(annotationjson3d)
|
||||
// importViewerState_root(viewerState);
|
||||
//}, 1000);
|
||||
} else {
|
||||
console.log("not loading 3d")
|
||||
}
|
||||
}, 200);
|
||||
|
||||
document.getElementById("reload-finding").addEventListener("click", function () {
|
||||
window.mountDicomViewers();
|
||||
});
|
||||
|
||||
// Initialize the DICOM viewer with the images and annotations
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error loading finding details:", error);
|
||||
modalBody.innerHTML = "<p>Failed to load finding details.</p>";
|
||||
});
|
||||
|
||||
// Show the modal
|
||||
modal.show();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Highlight style for the series-block */
|
||||
.highlight-series {
|
||||
background-color: blue;
|
||||
transition: background-color 0.3s ease, border 0.3s ease;
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
#series-reorder-list .drag-over {
|
||||
border-top: 2px solid #0d6efd;
|
||||
}
|
||||
.series-actions[open] {
|
||||
border: 2px solid #0d6efd;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.link-buttons {
|
||||
float: right;
|
||||
}
|
||||
</style>
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
{% include 'atlas/question_link_header.html' %}
|
||||
|
||||
<h2>Display Sets: {{ case.title }}</h2>
|
||||
|
||||
<details class="help-text">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p>This page shows the display sets associated with the case.</p>
|
||||
<p>If you have access you can add and edit display sets.</p>
|
||||
</details>
|
||||
|
||||
|
||||
<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}}'
|
||||
></div>
|
||||
|
||||
<div class="mb-3">
|
||||
<button hx-get="{% url 'atlas:case_displaysets_add' case.pk %}" hx-target="#displayset-add-form" hx-swap="innerHTML">Add Display Set</button>
|
||||
<div id="displayset-add-form"></div>
|
||||
</div>
|
||||
|
||||
<ul class="list-group mb-4" id="display-set-list">
|
||||
{% for ds in case.display_sets.all %}
|
||||
{% include 'atlas/partials/displayset_row.html' %}
|
||||
{% empty %}
|
||||
<li class="list-group-item text-muted">No display sets for this case.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
window.mountDicomViewers();
|
||||
|
||||
|
||||
{% if selected_displayset %}
|
||||
setTimeout(function() {
|
||||
importAnnotations_main_viewer({{ selected_displayset.annotations|safe }});
|
||||
importViewerState_main_viewer({{ selected_displayset.viewerstate|safe }});
|
||||
// Highlight the selected displayset row
|
||||
var selectedRow = document.getElementById("displayset-{{ selected_displayset.pk }}");
|
||||
if (selectedRow) {
|
||||
selectedRow.style.border = "2px solid #ffc107";
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
{% if edit %}
|
||||
var editButton = document.getElementById("displayset-{{ selected_displayset.pk }}-edit-btn");
|
||||
if (editButton) {
|
||||
editButton.click();
|
||||
}
|
||||
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
});
|
||||
document.addEventListener("htmx:beforeRequest", function(evt) {
|
||||
console.log("htmx:beforeRequest", evt.detail);
|
||||
// Only for the display set add form
|
||||
var form = evt.target;
|
||||
console.log("Form:", form);
|
||||
console.log("Form matches:", form.matches("form[hx-post*='{% url 'atlas:case_displaysets_add' case.pk %}']"));
|
||||
console.log("Form matches:", form.matches("form[hx-post*='display_sets/edit']"));
|
||||
if (form && (form.matches("form[hx-post*='{% url 'atlas:case_displaysets_add' case.pk %}']") ||form.matches("form[hx-post*='display_sets/edit']" ))) {
|
||||
// Set hidden fields from JS functions
|
||||
var viewerState = window.exportViewerState_main_viewer ? window.exportViewerState_main_viewer() : "{}";
|
||||
var annotations = window.exportAnnotations_main_viewer ? window.exportAnnotations_main_viewer() : "{}";
|
||||
evt.detail.requestConfig.parameters["annotations"] = annotations;
|
||||
evt.detail.requestConfig.parameters["viewerstate"] = viewerState;
|
||||
console.log(evt)
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "atlas/base.html" %}
|
||||
<!-- {% load static from static %} -->
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
@@ -50,8 +51,70 @@
|
||||
$('#add_more_resource').click(() => { add_resource_input_form() });
|
||||
})
|
||||
|
||||
</script>
|
||||
{% comment %} document.addEventListener("DOMContentLoaded", function () {
|
||||
// Select all text input fields and textareas
|
||||
const textFields = document.querySelectorAll("input[type='text'], textarea");
|
||||
|
||||
textFields.forEach((field) => {
|
||||
// Create a clear button for each text field or textarea
|
||||
const clearButton = document.createElement("button");
|
||||
clearButton.type = "button";
|
||||
clearButton.className = "btn btn-danger btn-sm clear-input-button";
|
||||
clearButton.textContent = "×";
|
||||
|
||||
// Wrap the input field or textarea in a container with position-relative
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "position-relative";
|
||||
field.parentNode.insertBefore(wrapper, field);
|
||||
wrapper.appendChild(field);
|
||||
wrapper.appendChild(clearButton);
|
||||
|
||||
// Function to toggle the visibility of the clear button
|
||||
const toggleClearButton = () => {
|
||||
if (field.value.trim() !== "") {
|
||||
clearButton.classList.add("show"); // Add the "show" class
|
||||
} else {
|
||||
clearButton.classList.remove("show"); // Remove the "show" class
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check
|
||||
toggleClearButton();
|
||||
|
||||
// Add event listeners to monitor changes in the input field or textarea
|
||||
field.addEventListener("input", toggleClearButton);
|
||||
|
||||
// Add click event to clear the field or textarea
|
||||
clearButton.addEventListener("click", function () {
|
||||
field.value = ""; // Clear the field
|
||||
toggleClearButton(); // Update the button visibility
|
||||
});
|
||||
});
|
||||
});
|
||||
{% endcomment %}
|
||||
</script>
|
||||
{% comment %} <style>
|
||||
/* Ensure the parent container is positioned relatively */
|
||||
.position-relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Style the clear button */
|
||||
.clear-input-button {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: none; /* Hidden by default */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Show the clear button only when the input field or textarea is hovered and contains text */
|
||||
.position-relative:hover .clear-input-button.show {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
</style> {% endcomment %}
|
||||
<!-- {{ form.media }} -->
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
@@ -59,6 +122,8 @@
|
||||
{% include "atlas/question_link_header.html" %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
<h2>Submit Case</h2>
|
||||
<details>
|
||||
<summary>Instructions</summary>
|
||||
@@ -68,74 +133,87 @@
|
||||
</details>
|
||||
|
||||
{% if form.collection %}
|
||||
<div class="alert alert-info" role="alert">Creating a case in the collection: <a href="{% url 'atlas:collection_detail' pk=form.collection.pk %}">{{form.collection.name}}</a></div>
|
||||
<div class="alert alert-info" role="alert">Creating a case in the collection: <a href="{% url 'atlas:collection_detail' pk=form.collection.pk %}">{{form.collection.name}}</a></div>
|
||||
{% endif %}
|
||||
|
||||
<form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form">
|
||||
{% csrf_token %}
|
||||
{% crispy form form.helper %}
|
||||
|
||||
{{ form }}
|
||||
<h3>Series:</h3>
|
||||
Add image sets here. These can only be added once created (they can also be added to cases on creation).
|
||||
<input type="button" value="Add More Series" id="add_more_series">
|
||||
<div id="series_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in series_formset %}
|
||||
<li class="no-error series-formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% comment %} {% crispy series_formset %} {% endcomment %}
|
||||
|
||||
{% comment %} {{ form }} {% endcomment %}
|
||||
|
||||
|
||||
|
||||
<!-- Differential Collapsible Panel -->
|
||||
<div class="card my-3 bg-dark text-white">
|
||||
<div class="card-header">
|
||||
<a class="collapsed text-white" data-bs-toggle="collapse" href="#differentialPanel" role="button" aria-expanded="false" aria-controls="differentialPanel">
|
||||
<h3 class="d-inline">Differential</h3>
|
||||
<span class="ms-2">(click to expand/collapse)</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="collapse" id="differentialPanel">
|
||||
<input type="button" value="Add Another Differential" id="add_more_differential" class="btn btn-sm btn-primary float-end">
|
||||
<div class="card-body">
|
||||
<div id="casedifferential_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in casedifferential_formset %}
|
||||
<li class="no-error casedifferential_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form | crispy}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{{ casedifferential_formset.management_form | crispy }}
|
||||
</div>
|
||||
{{ series_formset.management_form }}
|
||||
<h3>Differential:</h3>
|
||||
Add differential here.
|
||||
<input type="button" value="Add Another Differential" id="add_more_differential">
|
||||
<div id="casedifferential_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in casedifferential_formset %}
|
||||
<li class="no-error casedifferential_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resource Collapsible Panel -->
|
||||
<div class="card my-3 bg-dark text-white">
|
||||
<div class="card-header">
|
||||
<a class="collapsed text-white" data-bs-toggle="collapse" href="#resourcePanel" role="button" aria-expanded="false" aria-controls="resourcePanel">
|
||||
<h3 class="d-inline">Resource</h3>
|
||||
<span class="ms-2">(click to expand/collapse)</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="collapse" id="resourcePanel">
|
||||
<input type="button" value="Add Another Resource" id="add_more_resource" class="btn btn-sm btn-primary float-end">
|
||||
<div class="card-body">
|
||||
<div id="caseresource_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in caseresource_formset %}
|
||||
<li class="no-error caseresource_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form | crispy}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{{ caseresource_formset.management_form | crispy}}
|
||||
</div>
|
||||
{{ casedifferential_formset.management_form }}
|
||||
<h3>Resource:</h3>
|
||||
Add resource here. Resource can be anything that is useful for the case (videos, pdf, etc). This can either be avalaible before attempting the case (such a for a pre reading list) or after.
|
||||
<input type="button" value="Add Another Resource" id="add_more_resource">
|
||||
<div id="caseresource_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in caseresource_formset %}
|
||||
<li class="no-error caseresource_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{{ caseresource_formset.management_form }}
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
<div id="empty_series_form" style="display:none">
|
||||
<li class='no_error series-formset'>
|
||||
{{ series_formset.empty_form }}
|
||||
{{ series_formset.empty_form | crispy}}
|
||||
</li>
|
||||
</div>
|
||||
<div id="empty_casedifferential_form" style="display:none">
|
||||
<li class='no_error casedifferential_formset'>
|
||||
{{ casedifferential_formset.empty_form }}
|
||||
{{ casedifferential_formset.empty_form | crispy}}
|
||||
</li>
|
||||
</div>
|
||||
<div id="empty_caseresource_form" style="display:none">
|
||||
<li class='no_error caseresource_formset'>
|
||||
{{ caseresource_formset.empty_form }}
|
||||
{{ caseresource_formset.empty_form | crispy}}
|
||||
</li>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<details class="case-details">
|
||||
<summary>Cases</summary>
|
||||
<input type="text" placeholder="Search Cases..."
|
||||
_="on keyup
|
||||
if the event's key is 'Escape'
|
||||
set my value to ''
|
||||
trigger keyup
|
||||
else
|
||||
log 'test' then
|
||||
show .case-option in #case-options when its textContent contains my value
|
||||
">
|
||||
<ul id="case-options">
|
||||
{% for group_name, group_choices, group_index in widget.optgroups reversed %}
|
||||
{% for widget in group_choices reversed %}
|
||||
|
||||
{% with widget.value.instance as case %}
|
||||
{% if not forloop.parentloop.first %}
|
||||
<li class="case-option">
|
||||
<span class="d-flex flex-row ">
|
||||
<span class="p-2">{{case.id}}</span>
|
||||
<span class="p-2">{{case.title}}</span>
|
||||
<span class="p-2">{{case.created_date}}</span>
|
||||
<button class="p-2 btn btn-sm border border-dark rounded text-primary"
|
||||
type="button"
|
||||
_="on click set (next <select/>)'s value to '{{case.pk}}' then remove @open from .case-details then go to top of (next <select/>)'s parent"
|
||||
>select</button>
|
||||
</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
|
||||
<optgroup label="{{ group_name }}">{% endif %}{% for widget in group_choices %}
|
||||
{% include widget.template_name %}{% endfor %}{% if group_name %}
|
||||
</optgroup>{% endif %}{% endfor %}
|
||||
</select>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
{% extends "atlas/base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load static %}
|
||||
{% block content %}
|
||||
{% include "atlas/question_link_header.html" %}
|
||||
<h2>Update Series for Case: {{ object.title }}</h2>
|
||||
<form method="post" enctype="multipart/form-data" data-bs-theme="dark">
|
||||
{% csrf_token %}
|
||||
{% crispy form form.helper %}
|
||||
{{ series_formset.management_form }}
|
||||
<div id="series_formset">
|
||||
{% for form in series_formset %}
|
||||
<div class="card mb-3 series-form">
|
||||
<div class="card-body">
|
||||
{{ form.non_field_errors }}
|
||||
{{ form.errors }}
|
||||
{{ form|crispy }}
|
||||
{% if form.can_delete %}
|
||||
<div class="form-check mt-2">
|
||||
{{ form.DELETE }} <label for="{{ form.DELETE.id_for_label }}">Delete</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<!-- Empty form template for JS -->
|
||||
<div id="empty-form" style="display:none;">
|
||||
<div class="card mb-3 series-form">
|
||||
<div class="card-body">
|
||||
{{ series_formset.empty_form|crispy }}
|
||||
{% if series_formset.empty_form.can_delete %}
|
||||
<div class="form-check mt-2">
|
||||
{{ series_formset.empty_form.DELETE }} <label for="{{ series_formset.empty_form.DELETE.id_for_label }}">Delete</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="add-series" class="btn btn-secondary mt-2">Add Series</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const addBtn = document.getElementById("add-series");
|
||||
const formsetDiv = document.getElementById("series_formset");
|
||||
const emptyFormDiv = document.getElementById("empty-form");
|
||||
let totalForms = document.getElementById("id_seriesdetail_set-TOTAL_FORMS");
|
||||
|
||||
addBtn.addEventListener("click", function() {
|
||||
let formIdx = parseInt(totalForms.value);
|
||||
let newFormHtml = emptyFormDiv.innerHTML.replace(/__prefix__/g, formIdx);
|
||||
let tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = newFormHtml;
|
||||
// Clear the id field value for the new form
|
||||
let idInput = tempDiv.querySelector('input[name$="-id"]');
|
||||
if (idInput) {
|
||||
idInput.value = "";
|
||||
}
|
||||
formsetDiv.appendChild(tempDiv.firstElementChild);
|
||||
totalForms.value = formIdx + 1;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% load render_table from django_tables2 %}
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="container-fluid">
|
||||
<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>
|
||||
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
|
||||
<form>
|
||||
<details id="actions-detail" class="mt-3">
|
||||
<summary>
|
||||
<strong>Actions</strong>
|
||||
</summary>
|
||||
<div class="row mb-2">
|
||||
<div class="col-auto">
|
||||
<select id="collection-select" name="collection_id" class="form-select form-select-sm">
|
||||
<!-- Dynamically load collection options via HTMX -->
|
||||
<option value="">Loading collections...</option>
|
||||
<option hx-trigger="every 1s[document.getElementById('actions-detail').open] once" hx-get="{% url 'atlas:collection_options' %}" hx-target="#collection-select" hx-swap="innerHTML"></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button id="add-to-collection-btn"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
hx-post="{% url 'atlas:add_cases_to_collection' %}"
|
||||
hx-include="[name='selection']:checked, #collection-select"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Add selected cases to collection
|
||||
</button>
|
||||
<button id="remove-from-collection-btn"
|
||||
class="btn btn-sm btn-outline-danger ms-2"
|
||||
hx-post="{% url 'atlas:remove_cases_from_collection' %}"
|
||||
hx-include="[name='selection']:checked, #collection-select"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Remove selected cases from collection
|
||||
</button>
|
||||
<br/>
|
||||
<button id="set-open-access-btn"
|
||||
class="btn btn-sm btn-outline-success ms-2"
|
||||
hx-post="{% url 'atlas:set_open_access' %}"
|
||||
hx-include="[name='selection']:checked"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Set Open Access
|
||||
</button>
|
||||
<button id="remove-open-access-btn"
|
||||
class="btn btn-sm btn-outline-warning ms-2"
|
||||
hx-post="{% url 'atlas:remove_open_access' %}"
|
||||
hx-include="[name='selection']:checked"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Remove Open Access
|
||||
</button>
|
||||
<span id="action-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% render_table table %}
|
||||
</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 %}
|
||||
@@ -1,7 +1,42 @@
|
||||
{% extends "atlas/exams.html" %}
|
||||
<!-- {% load static from static %} -->
|
||||
{% 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>-->
|
||||
@@ -36,8 +71,8 @@
|
||||
<form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form">
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form }}
|
||||
<h3>Cases:</h3>
|
||||
{% crispy form form.helper %}
|
||||
{% comment %} <h3>Cases:</h3>
|
||||
Add cases here. These can only be added once created (they can also be added to cases on creation). Click and drag to change order.
|
||||
<input type="button" value="Add More Cases" id="add_more_case">
|
||||
<input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" />
|
||||
@@ -54,13 +89,14 @@
|
||||
|
||||
</div>
|
||||
{{ case_formset.management_form }}
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
<div id="empty_case_form" style="display:none">
|
||||
<li class='no_error case-formset'>
|
||||
{{ case_formset.empty_form }}
|
||||
</li>
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
{% comment %} <input type="submit" class="submit-button" value="Submit" name="submit"> {% endcomment %}
|
||||
</form>
|
||||
<script>
|
||||
$(document).ready(() => {
|
||||
sortable('.sortable');
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "atlas/exams.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load static %}
|
||||
{% block content %}
|
||||
<h2>Update Cases for Case: {{ object.title }}</h2>
|
||||
This form allows you to update the cases associated with this collection. You can add, remove, or reorder cases as needed. This functionality is also available on the collection overview page (which is likely easier to use).
|
||||
<form method="post" enctype="multipart/form-data" data-bs-theme="dark">
|
||||
{% csrf_token %}
|
||||
{% crispy form form.helper %}
|
||||
{{ case_formset.management_form }}
|
||||
<div id="case_formset">
|
||||
{% for form in case_formset %}
|
||||
<div class="card mb-3 case-form">
|
||||
<div class="card-body">
|
||||
{{ form.non_field_errors }}
|
||||
{{ form.errors }}
|
||||
{{ form|crispy }}
|
||||
{% if form.can_delete %}
|
||||
<div class="form-check mt-2">
|
||||
{{ form.DELETE }} <label for="{{ form.DELETE.id_for_label }}">Delete</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<!-- Empty form template for JS -->
|
||||
<div id="empty-form" style="display:none;">
|
||||
<div class="card mb-3 case-form">
|
||||
<div class="card-body">
|
||||
{{ case_formset.empty_form|crispy }}
|
||||
{% if case_formset.empty_form.can_delete %}
|
||||
<div class="form-check mt-2">
|
||||
{{ case_formset.empty_form.DELETE }} <label for="{{ case_formset.empty_form.DELETE.id_for_label }}">Delete</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="add-case" class="btn btn-secondary mt-2">Add Case</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
{% comment %} <input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" /> {% endcomment %}
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const addBtn = document.getElementById("add-case");
|
||||
const formsetDiv = document.getElementById("case_formset");
|
||||
const emptyFormDiv = document.getElementById("empty-form");
|
||||
let totalForms = document.getElementById("id_casedetail_set-TOTAL_FORMS");
|
||||
|
||||
addBtn.addEventListener("click", function() {
|
||||
let formIdx = parseInt(totalForms.value);
|
||||
let newFormHtml = emptyFormDiv.innerHTML.replace(/__prefix__/g, formIdx);
|
||||
let tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = newFormHtml;
|
||||
// Clear the id field value for the new form
|
||||
let idInput = tempDiv.querySelector('input[name$="-id"]');
|
||||
if (idInput) {
|
||||
idInput.value = "";
|
||||
}
|
||||
formsetDiv.appendChild(tempDiv.firstElementChild);
|
||||
totalForms.value = formIdx + 1;
|
||||
});
|
||||
|
||||
document.getElementById("case-order-button").addEventListener("click", function(e) {
|
||||
const cases = document.querySelectorAll("#case_formset ul");
|
||||
cases.forEach((el, n) => {
|
||||
const inputEl = el.querySelector("input[name$='sort_order']");
|
||||
if (inputEl) {
|
||||
inputEl.value = n + 1;
|
||||
}
|
||||
});
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
{% extends 'atlas/exams.html' %}
|
||||
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div>
|
||||
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id previous.id %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id next.id %}">Next question</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
|
||||
|
||||
{% if case_detail.question_schema is not None and not case_detail.question_answers %}
|
||||
|
||||
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h4 class="alert-heading">Answers not defined!</h4>
|
||||
<p>A question schema has been defined but no question answers are found.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p>This form allows you to add questions to a case. A example of how the quesiton will be displayed is shown below.</p>
|
||||
<p>If answers are supplied the question will be automarked</p>
|
||||
|
||||
<p>The question system is built using https://github.com/json-editor/json-editor, this allows for highly flexible forms / question design at a cost of some complexity. To help with this a number of preset questions are avalible to choose from (these can then be edited if needed). It is also possible to copy questions from other cases in the collection. </p>
|
||||
|
||||
Import schema from:
|
||||
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
|
||||
hx-get="{% url 'atlas:collection_question_schemas' case_detail.collection.id %}"
|
||||
hx-target=".offcanvas-body"
|
||||
>Collection questions</button>
|
||||
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
|
||||
hx-get="{% url 'atlas:question_schema_schemas' %}"
|
||||
hx-target=".offcanvas-body"
|
||||
>Preset questions</button>
|
||||
|
||||
<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvasRight" aria-labelledby="offcanvasRightLabel"
|
||||
>
|
||||
<div class="offcanvas-header">
|
||||
<h5 id="offcanvasRightLabel">Question schemas</h5>
|
||||
<button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit" value="save" name="submit">Submit</button>
|
||||
</form>
|
||||
|
||||
{% comment %} <h2>Blank form</h2>
|
||||
<form>
|
||||
{{ blank_form}}
|
||||
</form> {% endcomment %}
|
||||
|
||||
|
||||
{% if case_detail.question_schema %}
|
||||
<h2>Question display</h2>
|
||||
This shows the form as it will be displayed (with the correct answers). If you wish to edit a correct answer you can change it below.
|
||||
|
||||
<form method="POST" class="post-form">
|
||||
{% csrf_token %}
|
||||
{{example_form}}
|
||||
<button type="submit" value="answer" name="submit">Save Correct Answers</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
{% comment %} {{ example_form.media }} {% endcomment %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script>
|
||||
<script src="/static/django_jsonforms/jsoneditor_init.js"></script>
|
||||
<script>
|
||||
document.addEventListener('htmx:afterRequest', function(evt) {
|
||||
// Put the JS code that you want to execute here
|
||||
console.log($('.use-layout').each((n, el) => {
|
||||
el.addEventListener('click', (e) => {
|
||||
console.log(e.target.parentElement.querySelector('.schema').dataset.schema);
|
||||
window.jsoneditor_id_question_schema.set(
|
||||
{
|
||||
json: JSON.parse(e.target.parentElement.querySelector('.schema').dataset.schema)
|
||||
}
|
||||
)
|
||||
});
|
||||
}))
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,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 %}
|
||||
@@ -0,0 +1,69 @@
|
||||
{% extends 'atlas/exams.html' %}
|
||||
|
||||
|
||||
|
||||
{% 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="{{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 casedetail.default_viewerstate %}
|
||||
data-viewerstate={{ casedetail.default_viewerstate }}
|
||||
{% endif %}
|
||||
|
||||
></div>
|
||||
|
||||
<button id="save-viewerstate" type="button" class="btn btn-primary mt-3">
|
||||
Save Viewer State
|
||||
</button>
|
||||
<button id="reset-viewerstate" type="button" class="btn btn-secondary mt-3 ms-2">
|
||||
Reset to Default
|
||||
</button>
|
||||
<div id="viewerstate-save-response" class="mt-2"></div>
|
||||
|
||||
<script>
|
||||
document.getElementById('save-viewerstate').addEventListener('click', function() {
|
||||
let viewerState = window.exportViewerState_root();
|
||||
if (!viewerState) {
|
||||
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
|
||||
return;
|
||||
}
|
||||
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",
|
||||
"X-CSRFToken": "{{ csrf_token }}"
|
||||
},
|
||||
body: JSON.stringify({viewerstate: viewerState})
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
document.getElementById('viewerstate-save-response').innerHTML = html;
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error saving viewer state.</span>";
|
||||
});
|
||||
});
|
||||
document.getElementById('reset-viewerstate').addEventListener('click', function() {
|
||||
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",
|
||||
"X-CSRFToken": "{{ csrf_token }}"
|
||||
},
|
||||
body: JSON.stringify({reset_viewerstate: true})
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
document.getElementById('viewerstate-save-response').innerHTML = html;
|
||||
// Optionally reload the page or viewer here if needed
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error resetting viewer state.</span>";
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -4,67 +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>
|
||||
{% for case, added, relation in available_priors %}
|
||||
|
||||
<li class='
|
||||
{% if added %}
|
||||
added
|
||||
{% else %}
|
||||
not-added
|
||||
{% endif %}
|
||||
' data-case={{case.id}}>
|
||||
{{case.title}} / {{added}} / {{relation}} / <a href="{% url 'atlas:case_detail' case.id %}">View case</a>
|
||||
<br/>{{case.get_series_blocks|safe}}
|
||||
|
||||
<br/>
|
||||
|
||||
|
||||
{% if not added %}
|
||||
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
|
||||
hx-target="#htmx-response-{{case.id}}"
|
||||
_="on htmx:afterRequest log 'test' then remove me"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="prior_case_id" value="{{case.id}}" />
|
||||
<input type="text" name="relation" value="{{relation}}" placeholder="relation to case" />
|
||||
<button class="btn primary">Add to case</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
|
||||
hx-target="#htmx-response-{{case.id}}"
|
||||
_="on htmx:afterRequest log 'test' then remove me"
|
||||
>
|
||||
Case available: {{relation}}
|
||||
<input type="hidden" name="remove" value="{{case.id}}" />
|
||||
<button class="btn primary">Remove from case</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<span id="htmx-response-{{case.id}}"></span>
|
||||
|
||||
</li>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for case, added, relation, visibility in available_priors %}
|
||||
{% 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>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,812 @@
|
||||
{% extends 'atlas/exams.html' %}
|
||||
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_questions" %}
|
||||
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}">{{casedetail.case.title}}</a></h2>
|
||||
|
||||
{% if casedetail.question_schema is not None and not casedetail.question_answers %}
|
||||
|
||||
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h4 class="alert-heading">Answers not defined!</h4>
|
||||
<p>A question schema has been defined but no question answers are found. Please set them below.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<details class="help-text">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p>This form allows you to add questions to a case. A example of how the quesiton will be displayed is shown below.</p>
|
||||
<p>If answers are supplied the question will be automarked</p>
|
||||
|
||||
<p>The question system is built using https://github.com/json-editor/json-editor, this allows for highly flexible forms / question design at a cost of some complexity. To help with this a number of preset questions are avalible to choose from (these can then be edited if needed). It is also possible to copy questions from other cases in the collection. </p>
|
||||
|
||||
<p>In most cases it is not necessary to edit the JSON schema directly as questions can be added / edited via the GUI.</p>
|
||||
|
||||
<p>To save the question schema you need to click the "Save Questions" button at the bottom of the page. The page will then refresh and you can see how questions will be displayed at the bottom of the page.</p>
|
||||
|
||||
</details>
|
||||
|
||||
Import schema from:
|
||||
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
|
||||
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"
|
||||
hx-get="{% url 'atlas:question_schema_schemas' %}"
|
||||
hx-target=".offcanvas-body"
|
||||
>Preset questions</button>
|
||||
|
||||
<details><summary>
|
||||
Question Block Title
|
||||
</summary>
|
||||
<p>Title of the question block.</p>
|
||||
<input id="question_block_title_input" type="text" name="title" value="{{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;">
|
||||
<!-- Questions will be dynamically rendered here -->
|
||||
</div>
|
||||
</details>
|
||||
<details open id="add-question-block"><summary>Add/Edit Question</summary>
|
||||
<div>
|
||||
<label for="question-type-select">Select Question Type:</label>
|
||||
<select id="question-type-select" class="form-select">
|
||||
<option value="radio">Multiple Choice (Radio)</option>
|
||||
<option value="dropdown">Multiple Choice (Dropdown)</option>
|
||||
<option value="dropdown-select">Multiple Choice (Dropdown - Select2)</option>
|
||||
<option value="text">Text Input</option>
|
||||
<option value="textarea">Text Area</option>
|
||||
<option value="multiselect">Multiselect</option>
|
||||
<option value="yesno">Yes/No</option>
|
||||
<option value="truefalse">True/False</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="range">Range</option>
|
||||
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="options-configurator" style="margin-top: 10px; ">
|
||||
<label>Enter Options:</label>
|
||||
<div id="question-options-container">
|
||||
<!-- Individual option input fields will be added here -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="add-option-button" style="margin-top: 10px;">Add Option</button>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<label for="question-title-input">Question Title:</label>
|
||||
<input id="question-title-input" type="text" class="form-control" placeholder="Enter question title">
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 10px;">
|
||||
<label for="question-description-input">Question Description:</label>
|
||||
<input id="question-description-input" type="text" class="form-control" placeholder="Enter question description">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-sm" id="add-new-question">Add New Question</button>
|
||||
<button class="btn btn-secondary btn-sm" id="reset-question-options">Reset</button>
|
||||
</details>
|
||||
|
||||
<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvasRight" aria-labelledby="offcanvasRightLabel"
|
||||
>
|
||||
<div class="offcanvas-header">
|
||||
<h5 id="offcanvasRightLabel">Question schemas</h5>
|
||||
<button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit" value="save" name="submit">Save Questions</button>
|
||||
</form>
|
||||
|
||||
{% comment %} <h2>Blank form</h2>
|
||||
<form>
|
||||
{{ blank_form}}
|
||||
</form> {% endcomment %}
|
||||
|
||||
|
||||
{% 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 %}
|
||||
{# 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 %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
{% comment %} {{ example_form.media }} {% endcomment %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script>
|
||||
<script src="/static/django_jsonforms/jsoneditor_init.js"></script>
|
||||
<script>
|
||||
document.addEventListener('htmx:afterRequest', function(evt) {
|
||||
// Put the JS code that you want to execute here
|
||||
console.log($('.use-layout').each((n, el) => {
|
||||
el.addEventListener('click', (e) => {
|
||||
console.log(e.target.parentElement.querySelector('.schema').dataset.schema);
|
||||
window.jsoneditor_id_question_schema.set(
|
||||
{
|
||||
json: JSON.parse(e.target.parentElement.querySelector('.schema').dataset.schema)
|
||||
}
|
||||
)
|
||||
});
|
||||
}))
|
||||
});
|
||||
|
||||
|
||||
$(document).ready(function() {
|
||||
const question_editor = window.jsoneditor_id_question_schema;
|
||||
const questionBlockTitleInput = document.getElementById('question_block_title_input');
|
||||
// Wrap the existing onChange handler
|
||||
const originalOnChange = question_editor.$$.ctx[question_editor.$$.props.onChange];
|
||||
const addNewQuestionButton = document.getElementById('add-new-question');
|
||||
const optionsConfigurator = document.getElementById('options-configurator');
|
||||
const questionOptionsContainer = document.getElementById('question-options-container');
|
||||
const addOptionButton = document.getElementById('add-option-button');
|
||||
const questionTypeSelect = document.getElementById('question-type-select');
|
||||
const questionTitleInput = document.getElementById('question-title-input');
|
||||
const questionDescriptionInput = document.getElementById('question-description-input');
|
||||
const resetQuestionOptionsButton = document.getElementById('reset-question-options');
|
||||
const questionsContainer = document.getElementById('questions-container');
|
||||
|
||||
// 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
|
||||
if (typeof originalOnChange === 'function') {
|
||||
originalOnChange(updatedContent, previousContent, context);
|
||||
}
|
||||
|
||||
const value = updatedContent.json ? updatedContent.json.title : '';
|
||||
if (value !== questionBlockTitleInput.value) {
|
||||
questionBlockTitleInput.value = value; // Update the input field
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sync changes from the input field to the editor
|
||||
// Yes this is a bit backwards but it works
|
||||
questionBlockTitleInput.addEventListener('input', () => {
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
}
|
||||
catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
if (currentContent) {
|
||||
currentContent.title = questionBlockTitleInput.value; // Update the title in the editor
|
||||
question_editor.update({json: currentContent}); // Apply the changes to the editor
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Show or hide the options configurator based on the selected question type
|
||||
questionTypeSelect.addEventListener('change', () => {
|
||||
const selectedType = questionTypeSelect.value;
|
||||
if (selectedType === 'radio' || selectedType === 'dropdown' || selectedType === 'multiselect') {
|
||||
optionsConfigurator.style.display = 'block';
|
||||
} else {
|
||||
optionsConfigurator.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Add a new option input field
|
||||
addOptionButton.addEventListener('click', () => {
|
||||
const optionIndex = questionOptionsContainer.children.length + 1;
|
||||
const optionInput = document.createElement('div');
|
||||
optionInput.className = 'option-input';
|
||||
optionInput.style.marginBottom = '5px';
|
||||
optionInput.innerHTML = `
|
||||
<input type="text" class="form-control option-field" placeholder="Option ${optionIndex}" style="display: inline-block; width: 90%;">
|
||||
<button type="button" class="btn btn-danger btn-sm remove-option-button" style="display: inline-block; width: 8%;">×</button>
|
||||
`;
|
||||
questionOptionsContainer.appendChild(optionInput);
|
||||
|
||||
// Add event listener to remove the option
|
||||
optionInput.querySelector('.remove-option-button').addEventListener('click', () => {
|
||||
questionOptionsContainer.removeChild(optionInput);
|
||||
});
|
||||
});
|
||||
|
||||
// Reset the question options
|
||||
resetQuestionOptionsButton.onclick = resetQuestion;
|
||||
|
||||
function resetQuestion() {
|
||||
|
||||
// Clear the title and description fields
|
||||
questionTitleInput.value = '';
|
||||
questionDescriptionInput.value = '';
|
||||
|
||||
// Clear all option input fields
|
||||
while (questionOptionsContainer.firstChild) {
|
||||
questionOptionsContainer.removeChild(questionOptionsContainer.firstChild);
|
||||
}
|
||||
|
||||
// Reset the question type to the default value
|
||||
questionTypeSelect.value = 'radio';
|
||||
|
||||
// Hide the options configurator if necessary
|
||||
optionsConfigurator.style.display = 'block';
|
||||
|
||||
// Reset the "Add New Question" button
|
||||
addNewQuestionButton.textContent = 'Add New Question';
|
||||
addNewQuestionButton.onclick = addNewQuestion;
|
||||
|
||||
toastr.success('Question options have been reset.');
|
||||
}
|
||||
// Add a new question
|
||||
addNewQuestionButton.onclick = addNewQuestion;
|
||||
|
||||
function addNewQuestion() {
|
||||
// Get the current content from the editor
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
|
||||
if (!currentContent) {
|
||||
currentContent = {
|
||||
title: questionBlockTitleInput.value || "Case Questions",
|
||||
type: "object",
|
||||
properties: {}
|
||||
};
|
||||
}
|
||||
|
||||
if (!currentContent.properties) {
|
||||
currentContent.properties = {};
|
||||
current_question_number = 1;
|
||||
} else {
|
||||
current_question_number = Object.keys(currentContent.properties).length + 1;
|
||||
}
|
||||
|
||||
// Get the selected question type
|
||||
const selectedType = questionTypeSelect.value;
|
||||
|
||||
// Collect options from individual input fields
|
||||
const options = Array.from(questionOptionsContainer.querySelectorAll('.option-field'))
|
||||
.map(input => input.value.trim())
|
||||
.filter(option => option !== ""); // Exclude empty options
|
||||
|
||||
// Get the title and description
|
||||
const title = questionTitleInput.value || `Question ${current_question_number}`;
|
||||
const description = questionDescriptionInput.value || "Enter the question description here.";
|
||||
|
||||
// Define the new question template based on the selected type
|
||||
let newQuestion;
|
||||
if (selectedType === 'radio') {
|
||||
newQuestion = {
|
||||
enum: options.length > 0 ? options : ["Option 1", "Option 2", "Option 3"], // Default options if none provided
|
||||
type: "string",
|
||||
title: title,
|
||||
format: "radio",
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'text') {
|
||||
newQuestion = {
|
||||
type: "string",
|
||||
title: title,
|
||||
format: "text",
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'dropdown') {
|
||||
newQuestion = {
|
||||
enum: options.length > 0 ? options : ["Option 1", "Option 2", "Option 3"], // Default options if none provided
|
||||
type: "string",
|
||||
title: title,
|
||||
format: "select",
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'dropdown-select') {
|
||||
newQuestion = {
|
||||
enum: options.length > 0 ? options : ["Option 1", "Option 2", "Option 3"], // Default options if none provided
|
||||
type: "string",
|
||||
title: title,
|
||||
format: "select2",
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'multiselect') {
|
||||
newQuestion = {
|
||||
type: "array",
|
||||
items: {
|
||||
enum: options.length > 0 ? options : ["Option 1", "Option 2", "Option 3"], // Default options if none provided
|
||||
type: "string",
|
||||
},
|
||||
title: title,
|
||||
format: "select2", // Multiselect format
|
||||
description: description,
|
||||
uniqueItems: true, // Ensure unique selections
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'yesno') {
|
||||
newQuestion = {
|
||||
enum: ["Yes", "No"],
|
||||
type: "string",
|
||||
title: title,
|
||||
format: "radio",
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'truefalse') {
|
||||
newQuestion = {
|
||||
enum: ["True", "False"],
|
||||
type: "string",
|
||||
title: title,
|
||||
format: "radio",
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'textarea') {
|
||||
newQuestion = {
|
||||
type: "string",
|
||||
title: title,
|
||||
format: "textarea", // Use textarea format
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'number') {
|
||||
newQuestion = {
|
||||
type: "number",
|
||||
title: title,
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'range') {
|
||||
newQuestion = {
|
||||
type: "range",
|
||||
title: title,
|
||||
description: description,
|
||||
minimum: 0, // Default minimum value
|
||||
maximum: 100, // Default maximum value
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
newQuestion.propertyOrder = current_question_number; // Set the property order
|
||||
// Add the new question to the properties
|
||||
currentContent.properties[`question_${current_question_number}`] = newQuestion;
|
||||
|
||||
// Update the editor with the new question
|
||||
question_editor.update({ json: currentContent });
|
||||
toastr.success(`New question added: ${title}`);
|
||||
renderQuestions(); // Re-render the questions list
|
||||
}
|
||||
|
||||
function renderQuestions() {
|
||||
questionsContainer.innerHTML = ''; // Clear the container
|
||||
|
||||
try {
|
||||
let currentContent;
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
if (!currentContent || !currentContent.properties) {
|
||||
console.warn('No properties found in the current content.');
|
||||
return;
|
||||
}
|
||||
const properties = currentContent.properties || {};
|
||||
|
||||
// Sort questions by propertyOrder
|
||||
const sortedKeys = Object.keys(properties).sort((a, b) => {
|
||||
const orderA = properties[a].propertyOrder !== undefined ? properties[a].propertyOrder : 1000;
|
||||
const orderB = properties[b].propertyOrder !== undefined ? properties[b].propertyOrder : 1000;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
sortedKeys.forEach((key, index) => {
|
||||
const question = properties[key];
|
||||
const questionElement = document.createElement('div');
|
||||
questionElement.className = 'question-item';
|
||||
questionElement.style.marginBottom = '10px';
|
||||
questionElement.setAttribute('draggable', 'true'); // Make the element draggable
|
||||
questionElement.setAttribute('data-question-key', key);
|
||||
questionElement.setAttribute('data-index', index);
|
||||
|
||||
questionElement.innerHTML = `
|
||||
<strong>${question.title || key}</strong>
|
||||
<button type="button" class="btn btn-warning btn-sm edit-question-button" data-question-key="${key}" style="margin-left: 10px;">Edit</button>
|
||||
<button type="button" class="btn btn-danger btn-sm delete-question-button" data-question-key="${key}" style="margin-left: 10px;">Delete</button>
|
||||
`;
|
||||
questionsContainer.appendChild(questionElement);
|
||||
|
||||
// Add event listener to delete the question
|
||||
questionElement.querySelector('.delete-question-button').addEventListener('click', (e) => {
|
||||
const questionKey = e.target.getAttribute('data-question-key');
|
||||
deleteQuestion(questionKey);
|
||||
});
|
||||
|
||||
// Add event listener to edit the question
|
||||
questionElement.querySelector('.edit-question-button').addEventListener('click', (e) => {
|
||||
const questionKey = e.target.getAttribute('data-question-key');
|
||||
editQuestion(questionKey);
|
||||
});
|
||||
|
||||
// Add drag-and-drop event listeners
|
||||
questionElement.addEventListener('dragstart', handleDragStart);
|
||||
questionElement.addEventListener('dragover', handleDragOver);
|
||||
questionElement.addEventListener('drop', handleDrop);
|
||||
questionElement.addEventListener('dragend', handleDragEnd);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error rendering questions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-and-drop event handlers
|
||||
let draggedElement = null;
|
||||
|
||||
function handleDragStart(e) {
|
||||
draggedElement = this;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/html', this.outerHTML);
|
||||
this.classList.add('dragging');
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
if (draggedElement !== this) {
|
||||
const draggedIndex = parseInt(draggedElement.getAttribute('data-index'));
|
||||
const targetIndex = parseInt(this.getAttribute('data-index'));
|
||||
|
||||
try {
|
||||
let currentContent;
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
const properties = currentContent.properties || {};
|
||||
const keys = Object.keys(properties);
|
||||
|
||||
// Sort keys by propertyOrder
|
||||
const sortedKeys = keys.sort((a, b) => {
|
||||
const orderA = properties[a].propertyOrder !== undefined ? properties[a].propertyOrder : Infinity;
|
||||
const orderB = properties[b].propertyOrder !== undefined ? properties[b].propertyOrder : Infinity;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
// Move the dragged question to the target position
|
||||
const draggedKey = sortedKeys.splice(draggedIndex, 1)[0]; // Remove dragged key
|
||||
sortedKeys.splice(targetIndex, 0, draggedKey); // Insert dragged key at target position
|
||||
|
||||
// Reassign propertyOrder to ensure uniqueness and correct order
|
||||
sortedKeys.forEach((key, index) => {
|
||||
properties[key].propertyOrder = index + 1;
|
||||
});
|
||||
|
||||
// Assign propertyOrder to any questions without it
|
||||
keys.forEach((key) => {
|
||||
if (properties[key].propertyOrder === undefined) {
|
||||
properties[key].propertyOrder = sortedKeys.length + 1;
|
||||
sortedKeys.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
currentContent.properties = properties;
|
||||
question_editor.update({ json: currentContent });
|
||||
toastr.success('Questions reordered successfully.');
|
||||
renderQuestions(); // Re-render the questions list
|
||||
} catch (error) {
|
||||
console.error('Error reordering questions:', error);
|
||||
toastr.error('Failed to reorder questions.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
this.classList.remove('dragging');
|
||||
}
|
||||
|
||||
// Function to delete a question
|
||||
function deleteQuestion(questionKey) {
|
||||
try {
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
if (currentContent.properties && currentContent.properties[questionKey]) {
|
||||
delete currentContent.properties[questionKey]; // Remove the question
|
||||
question_editor.update({ json: currentContent }); // Update the editor
|
||||
toastr.success(`Question "${questionKey}" has been deleted.`);
|
||||
renderQuestions(); // Re-render the questions list
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting question:', error);
|
||||
toastr.error('Failed to delete the question.');
|
||||
}
|
||||
}
|
||||
// Initial render of questions
|
||||
renderQuestions();
|
||||
|
||||
function editQuestion(questionKey) {
|
||||
try {
|
||||
let currentContent;
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
const properties = currentContent.properties || {};
|
||||
const question = properties[questionKey];
|
||||
|
||||
if (question) {
|
||||
// Populate the form fields with the question's data
|
||||
questionTitleInput.value = question.title || '';
|
||||
questionDescriptionInput.value = question.description || '';
|
||||
questionTypeSelect.value = question.questionType || 'text';
|
||||
|
||||
// Clear existing options and populate them if applicable
|
||||
while (questionOptionsContainer.firstChild) {
|
||||
questionOptionsContainer.removeChild(questionOptionsContainer.firstChild);
|
||||
}
|
||||
// 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';
|
||||
optionInput.innerHTML = `
|
||||
<input type="text" class="form-control option-field" value="${option}" placeholder="Option ${index + 1}" style="display: inline-block; width: 90%;">
|
||||
<button type="button" class="btn btn-danger btn-sm remove-option-button" style="display: inline-block; width: 8%;">×</button>
|
||||
`;
|
||||
questionOptionsContainer.appendChild(optionInput);
|
||||
// Add event listener to remove the option
|
||||
optionInput.querySelector('.remove-option-button').addEventListener('click', () => {
|
||||
questionOptionsContainer.removeChild(optionInput);
|
||||
});
|
||||
});
|
||||
optionsConfigurator.style.display = 'block';
|
||||
} else {
|
||||
optionsConfigurator.style.display = 'none';
|
||||
}
|
||||
// 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';
|
||||
addNewQuestionButton.onclick = () => {
|
||||
saveEditedQuestion(questionKey);
|
||||
};
|
||||
document.getElementById('add-question-block').open = true; // Open the question block
|
||||
document.getElementById('add-question-block').scrollIntoView({ behavior: 'smooth', block: "center" }); // Scroll to the question block
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error editing question:', error);
|
||||
toastr.error('Failed to edit the question.');
|
||||
}
|
||||
}
|
||||
|
||||
function saveEditedQuestion(questionKey) {
|
||||
try {
|
||||
let currentContent;
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
const properties = currentContent.properties || {};
|
||||
const question = properties[questionKey];
|
||||
|
||||
if (question) {
|
||||
// Update the question with the new values from the form
|
||||
question.title = questionTitleInput.value || `Question ${questionKey}`;
|
||||
question.description = questionDescriptionInput.value || '';
|
||||
question.format = questionTypeSelect.value;
|
||||
|
||||
// Update the options if applicable
|
||||
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;
|
||||
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
|
||||
|
||||
// Reset the "Add New Question" button
|
||||
addNewQuestionButton.textContent = 'Add New Question';
|
||||
addNewQuestionButton.onclick = addNewQuestion;
|
||||
resetQuestion(); // Reset the question options
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving edited question:', error);
|
||||
toastr.error('Failed to save the question.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
<style>
|
||||
/* Style the <details> block */
|
||||
details {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
background-color:rgba(13, 8, 8, 0.86);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Style the <summary> element */
|
||||
details summary {
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Add hover effect to <summary> */
|
||||
details summary:hover {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
/* Add padding to the content inside <details> */
|
||||
details > *:not(summary) {
|
||||
margin-top: 10px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
/* Add a border to the expanded <details> */
|
||||
details[open] {
|
||||
border-color: #007bff;
|
||||
background-color: black;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user