Compare commits
786
Commits
9c61068026
...
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 | ||
|
|
3f5f39c41c | ||
|
|
92663bc620 | ||
|
|
359a399cc8 | ||
|
|
883b11a689 | ||
|
|
2e22846d23 | ||
|
|
ef8d8f3ef1 | ||
|
|
51029d1b83 | ||
|
|
1d04f9ae9f | ||
|
|
48e6339ecb | ||
|
|
3616d1b9c5 | ||
|
|
41dd5b7086 | ||
|
|
707a98629d | ||
|
|
8833fb6105 | ||
|
|
e6d8317fc5 | ||
|
|
d1249f14d8 | ||
|
|
6f96f79303 | ||
|
|
ea42f7f0c7 |
@@ -0,0 +1,22 @@
|
|||||||
|
# Django
|
||||||
|
DEBUG=1
|
||||||
|
SECRET_KEY=w(s0&(_eb058wvmg@44_repv8)r9@5p8fx*g_@c)1dm&d*ew^u
|
||||||
|
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
|
||||||
|
|
||||||
|
# External DB (you mentioned production uses an external DB)
|
||||||
|
DB_HOST=db-postgresql-lon1-05515-jan-22-backup-do-user-8165014-0.c.db.ondigitalocean.com
|
||||||
|
DB_PORT=25060
|
||||||
|
DB_NAME=testing
|
||||||
|
DB_USER=django
|
||||||
|
DB_PASSWORD=AVNS_NG_s4i7SMMobWLO
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# Gunicorn tuning
|
||||||
|
GUNICORN_WORKERS=3
|
||||||
|
GUNICORN_LOGLEVEL=info
|
||||||
|
|
||||||
|
# Nginx host ports for local development (override prod defaults)
|
||||||
|
NGINX_HTTP_PORT=8000
|
||||||
|
NGINX_HTTPS_PORT=8443
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Copy this to .env.prod on the server and fill in production values (do NOT commit secrets).
|
||||||
|
|
||||||
|
# Django
|
||||||
|
SECRET_KEY=replace-me-with-a-secure-random-string
|
||||||
|
DEBUG=0
|
||||||
|
ALLOWED_HOSTS=example.com
|
||||||
|
|
||||||
|
# External DB (you mentioned production uses an external DB)
|
||||||
|
DATABASE_HOST=your.db.host
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_NAME=rad
|
||||||
|
DATABASE_USER=django
|
||||||
|
DATABASE_PASSWORD=strong-db-password
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# Gunicorn tuning
|
||||||
|
GUNICORN_WORKERS=3
|
||||||
|
GUNICORN_LOGLEVEL=info
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
media/
|
media/
|
||||||
|
mediafiles/
|
||||||
|
|
||||||
__pycache__
|
__pycache__
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -11,3 +12,7 @@ venv
|
|||||||
*.log
|
*.log
|
||||||
|
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
|
# Loki runtime data
|
||||||
|
/docker/loki-data/
|
||||||
|
/docker/loki-wal/
|
||||||
+10
-4
@@ -1,5 +1,6 @@
|
|||||||
# pull official base image
|
# 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
|
# set work directory
|
||||||
WORKDIR /usr/src/rad
|
WORKDIR /usr/src/rad
|
||||||
@@ -13,13 +14,18 @@ ENV PYTHONUNBUFFERED 1
|
|||||||
# python3 python3-dev gcc \
|
# python3 python3-dev gcc \
|
||||||
# gfortran musl-dev \
|
# gfortran musl-dev \
|
||||||
# libffi-dev openssl-dev
|
# libffi-dev openssl-dev
|
||||||
RUN apt-get update && apt-get -y install postgresql postgresql-contrib git libcairo2-dev pkg-config gcc
|
RUN apt-get update && apt-get -y install postgresql postgresql-contrib git libcairo2-dev pkg-config gcc redis-tools watchman
|
||||||
|
|
||||||
|
|
||||||
# install dependencies
|
# install dependencies
|
||||||
RUN pip install --upgrade pip
|
#RUN pip install --upgrade pip
|
||||||
COPY ./requirements.txt .
|
COPY ./requirements.txt .
|
||||||
RUN pip install -r requirements.txt
|
RUN uv venv /opt/venv
|
||||||
|
# Use the virtual environment automatically
|
||||||
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
|
# Place entry points in the environment at the front of the path
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
RUN uv pip install -r requirements.txt
|
||||||
|
|
||||||
# copy project
|
# copy project
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -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()
|
router = Router()
|
||||||
|
|
||||||
class CidUserSchema(ModelSchema):
|
class CidUserSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = CidUser
|
model = CidUser
|
||||||
|
|
||||||
model_fields = ["cid", "name", "email"]
|
fields = ["cid", "name", "email"]
|
||||||
|
|
||||||
class UserUserSchema(ModelSchema):
|
class UserUserSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = get_user_model()
|
model = get_user_model()
|
||||||
model_fields = ["id", "email"]
|
fields = ["id", "email"]
|
||||||
|
|
||||||
class CidUserExamSchema(ModelSchema):
|
class CidUserExamSchema(ModelSchema):
|
||||||
cid_user : CidUserSchema | None
|
cid_user : CidUserSchema | None
|
||||||
user_user : UserUserSchema | None
|
user_user : UserUserSchema | None
|
||||||
class Config:
|
class Meta:
|
||||||
model = CidUserExam
|
model = CidUserExam
|
||||||
model_fields = ["id"]
|
fields = ["id"]
|
||||||
|
|
||||||
class ExamStatusSchema(ModelSchema):
|
class ExamStatusSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = ExamUserStatus
|
model = ExamUserStatus
|
||||||
model_fields = ["id", "datetime", "status", "extra"]
|
fields = ["id", "datetime", "status", "extra"]
|
||||||
|
|
||||||
class ExamUserStatusSchema(ModelSchema):
|
class ExamUserStatusSchema(ModelSchema):
|
||||||
|
|
||||||
#cid_user : CidUserSchema | None
|
#cid_user : CidUserSchema | None
|
||||||
#user_user : UserUserSchema | None
|
#user_user : UserUserSchema | None
|
||||||
cid_user_exam : CidUserExamSchema | None
|
cid_user_exam : CidUserExamSchema | None
|
||||||
class Config:
|
class Meta:
|
||||||
model = ExamUserStatus
|
model = ExamUserStatus
|
||||||
model_fields = ["id", "datetime", "status", "extra"]
|
fields = ["id", "datetime", "status", "extra"]
|
||||||
|
|
||||||
class QuestionSchema(ModelSchema):
|
class QuestionSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Question
|
model = Question
|
||||||
|
|
||||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
fields = "__all__"
|
||||||
model_fields = "__all__"
|
|
||||||
|
|
||||||
#model_exclude = ["answers"]
|
#exclude = ["answers"]
|
||||||
|
|
||||||
class ExamSchema(ModelSchema):
|
class ExamSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Exam
|
model = Exam
|
||||||
|
|
||||||
model_fields = ["id", "name", "active", "publish_results"]
|
fields = ["id", "name", "active", "publish_results"]
|
||||||
|
|
||||||
@router.get('/')
|
@router.get('/')
|
||||||
def list_questions(request):
|
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):
|
if not exam.check_user_can_edit(request.user):
|
||||||
raise HttpError(HTTPStatus.UNAUTHORIZED,"Exam author required")
|
raise HttpError(HTTPStatus.UNAUTHORIZED,"Exam author required")
|
||||||
|
|
||||||
print(exam_id, cid)
|
|
||||||
|
|
||||||
return exam.exam_user_status.filter(cid_user_exam__cid_user__cid=cid)
|
return exam.exam_user_status.filter(cid_user_exam__cid_user__cid=cid)
|
||||||
|
|
||||||
@router.get('/exam/{exam_id}/user_status/{user_id}/user', response=List[ExamStatusSchema])
|
@router.get('/exam/{exam_id}/user_status/{user_id}/user', response=List[ExamStatusSchema])
|
||||||
|
|||||||
+20
-4
@@ -33,6 +33,9 @@ from dal import autocomplete
|
|||||||
|
|
||||||
from tinymce.widgets import TinyMCE
|
from tinymce.widgets import TinyMCE
|
||||||
|
|
||||||
|
from crispy_forms.helper import FormHelper
|
||||||
|
from crispy_forms.layout import Submit
|
||||||
|
|
||||||
class AnatomyAnswerForm(ModelForm):
|
class AnatomyAnswerForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = UserAnswer
|
model = UserAnswer
|
||||||
@@ -76,6 +79,14 @@ class AnatomyQuestionForm(ModelForm):
|
|||||||
ModelForm.__init__(self, *args, **kwargs)
|
ModelForm.__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
super(AnatomyQuestionForm, self).__init__(*args, **kwargs)
|
super(AnatomyQuestionForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_id = 'id-anatomy-question-form'
|
||||||
|
#self.helper.form_class = 'blueForms'
|
||||||
|
#self.helper.form_method = 'post'
|
||||||
|
#self.helper.form_action = ''
|
||||||
|
|
||||||
|
#self.helper.add_input(Submit('submit', 'Submit'))
|
||||||
# self.fields['question'].widget.attrs = {'class': 'question-form', 'rows': 10, 'cols': 100}
|
# self.fields['question'].widget.attrs = {'class': 'question-form', 'rows': 10, 'cols': 100}
|
||||||
# self.fields['feedback'].widget.attrs = {'class': 'feedback-form', 'rows': 10, 'cols': 100}
|
# self.fields['feedback'].widget.attrs = {'class': 'feedback-form', 'rows': 10, 'cols': 100}
|
||||||
self.fields["question_type"] = ModelChoiceField(
|
self.fields["question_type"] = ModelChoiceField(
|
||||||
@@ -145,15 +156,15 @@ class AnatomyQuestionForm(ModelForm):
|
|||||||
fields = [
|
fields = [
|
||||||
"question_type",
|
"question_type",
|
||||||
"image",
|
"image",
|
||||||
"answer_help",
|
"description",
|
||||||
"answer_suggest_incorrect",
|
|
||||||
"region",
|
|
||||||
"modality",
|
"modality",
|
||||||
|
"region",
|
||||||
"structure",
|
"structure",
|
||||||
"feedback",
|
"feedback",
|
||||||
"examination",
|
"examination",
|
||||||
"body_part",
|
"body_part",
|
||||||
"description",
|
"answer_help",
|
||||||
|
"answer_suggest_incorrect",
|
||||||
"open_access",
|
"open_access",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -242,3 +253,8 @@ class ExamMarkerForm(ExamMarkerFormMixin):
|
|||||||
class ExamGroupsForm(ExamGroupsFormMixin):
|
class ExamGroupsForm(ExamGroupsFormMixin):
|
||||||
class Meta(ExamGroupsFormMixin.Meta):
|
class Meta(ExamGroupsFormMixin.Meta):
|
||||||
model = Exam
|
model = Exam
|
||||||
|
|
||||||
|
class SuggestIncorrectAnswerForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = AnatomyQuestion
|
||||||
|
fields = ["answer_suggest_incorrect"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""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,36 @@
|
|||||||
|
# Generated by Django 5.0.2 on 2024-10-07 10:05
|
||||||
|
|
||||||
|
import anatomy.models
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('anatomy', '0020_alter_exam_markers'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='anatomyquestion',
|
||||||
|
name='answer_help',
|
||||||
|
field=models.TextField(blank=True, default='', help_text='Helpful information for marking, this will not be displayed to the user taking the exam.', null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='anatomyquestion',
|
||||||
|
name='answer_suggest_incorrect',
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255, null=True), blank=True, default=list, help_text='An array that defines text that if found in the answer is likely incorrect. This is used to aid marking', size=None),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='anatomyquestion',
|
||||||
|
name='image',
|
||||||
|
field=models.ImageField(help_text="The image to use for the question. Ideally use unmarked images and annotate (arrow) them on the test system. If you wish to reuse an image that is already uploaded 'clone' the question that contains it.", upload_to=anatomy.models.image_directory_path),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='anatomyquestion',
|
||||||
|
name='question_type',
|
||||||
|
field=models.ForeignKey(default=1, help_text='Type of question, this is the question text that will be displayed to the user.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='anatomy.questiontype'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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.'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+33
-14
@@ -22,6 +22,7 @@ from collections import defaultdict
|
|||||||
from helpers.images import image_as_base64
|
from helpers.images import image_as_base64
|
||||||
|
|
||||||
import reversion
|
import reversion
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
image_storage = FileSystemStorage(
|
image_storage = FileSystemStorage(
|
||||||
# Physical file location ROOT
|
# Physical file location ROOT
|
||||||
@@ -72,12 +73,12 @@ class QuestionType(models.Model):
|
|||||||
@reversion.register
|
@reversion.register
|
||||||
class AnatomyQuestion(QuestionBase):
|
class AnatomyQuestion(QuestionBase):
|
||||||
question_type = models.ForeignKey(
|
question_type = models.ForeignKey(
|
||||||
QuestionType, on_delete=models.SET_NULL, null=True, default=1
|
QuestionType, on_delete=models.SET_NULL, null=True, default=1, help_text="Type of question, this is the question text that will be displayed to the user."
|
||||||
)
|
)
|
||||||
|
|
||||||
image = models.ImageField(
|
image = models.ImageField(
|
||||||
upload_to=image_directory_path,
|
upload_to=image_directory_path,
|
||||||
help_text="The image to use for the question. Ideally use use unmarked images and annotate (arrow) them on the test system. If you wish to reuse an image that is already uploaded 'clone' the question that contains it.",
|
help_text="The image to use for the question. Ideally use unmarked images and annotate (arrow) them on the test system. If you wish to reuse an image that is already uploaded 'clone' the question that contains it.",
|
||||||
)
|
)
|
||||||
|
|
||||||
image_annotations = models.TextField(
|
image_annotations = models.TextField(
|
||||||
@@ -90,9 +91,9 @@ class AnatomyQuestion(QuestionBase):
|
|||||||
help_text="Short description of the image e.g. 'Sagittal CT Chest, Abdomen & Pelvis', will be displayed as the title.",
|
help_text="Short description of the image e.g. 'Sagittal CT Chest, Abdomen & Pelvis', will be displayed as the title.",
|
||||||
)
|
)
|
||||||
|
|
||||||
answer_help = models.TextField(default="", blank=True, null=True, help_text="Helpful information for marking")
|
answer_help = models.TextField(default="", blank=True, null=True, help_text="Helpful information for marking, this will not be displayed to the user taking the exam.")
|
||||||
|
|
||||||
answer_suggest_incorrect = ArrayField(models.CharField(max_length=255, null=True), default=list, blank=True)
|
answer_suggest_incorrect = ArrayField(models.CharField(max_length=255, null=True), default=list, blank=True, help_text="An array that defines text that if found in the answer is likely incorrect. This is used to aid marking")
|
||||||
|
|
||||||
examination = models.ForeignKey(
|
examination = models.ForeignKey(
|
||||||
Examination, on_delete=models.SET_NULL, null=True, blank=True
|
Examination, on_delete=models.SET_NULL, null=True, blank=True
|
||||||
@@ -131,14 +132,16 @@ class AnatomyQuestion(QuestionBase):
|
|||||||
return "anatomy"
|
return "anatomy"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
# Get first answer
|
# Nicely formatted representation
|
||||||
return "{}/{}: {} [{}, {}]".format(
|
pk = self.pk or ""
|
||||||
self.pk,
|
qtype = str(self.question_type) if self.question_type else "N/A"
|
||||||
self.question_type,
|
primary = self.get_primary_answer() or "None"
|
||||||
self.get_primary_answer(),
|
modality = str(self.modality) if self.modality else "Unknown"
|
||||||
self.modality,
|
structure = str(self.structure) if self.structure else "Unknown"
|
||||||
self.structure,
|
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):
|
def get_link(self):
|
||||||
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self)
|
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self)
|
||||||
@@ -179,6 +182,9 @@ class AnatomyQuestion(QuestionBase):
|
|||||||
exams = ", ".join(e)
|
exams = ", ".join(e)
|
||||||
return exams
|
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):
|
def get_unmarked_user_answer_string(self, exam_pk=None):
|
||||||
unmarked_answers = self.get_unmarked_user_answers(exam_pk)
|
unmarked_answers = self.get_unmarked_user_answers(exam_pk)
|
||||||
|
|
||||||
@@ -329,6 +335,19 @@ class Answer(models.Model):
|
|||||||
self.answer = self.answer.strip()
|
self.answer = self.answer.strip()
|
||||||
self.answer_compare = get_answer_compare(self.answer)
|
self.answer_compare = get_answer_compare(self.answer)
|
||||||
|
|
||||||
|
def can_edit(self, user):
|
||||||
|
"""Check if user can edit the answer
|
||||||
|
|
||||||
|
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):
|
# def get_compare_string(self):
|
||||||
# s = self.answer.lower().strip()
|
# s = self.answer.lower().strip()
|
||||||
# s = s.translate(str.maketrans('', '', string.punctuation))
|
# s = s.translate(str.maketrans('', '', string.punctuation))
|
||||||
@@ -390,7 +409,6 @@ class Exam(ExamBase):
|
|||||||
markers = models.ManyToManyField(
|
markers = models.ManyToManyField(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
|
||||||
help_text="Authorised markers for the exam",
|
help_text="Authorised markers for the exam",
|
||||||
related_name="anatomy_exam_markers",
|
related_name="anatomy_exam_markers",
|
||||||
)
|
)
|
||||||
@@ -479,7 +497,6 @@ class Exam(ExamBase):
|
|||||||
return answer
|
return answer
|
||||||
|
|
||||||
def get_question_user_user_answer(self, question_index, user):
|
def get_question_user_user_answer(self, question_index, user):
|
||||||
print(user)
|
|
||||||
question = self.get_question_by_index(question_index)
|
question = self.get_question_by_index(question_index)
|
||||||
answer = UserAnswer.objects.filter(question=question, user=user.pk, exam=self)
|
answer = UserAnswer.objects.filter(question=question, user=user.pk, exam=self)
|
||||||
|
|
||||||
@@ -535,6 +552,8 @@ class UserAnswer(UserAnswerBase):
|
|||||||
# s = self.answer.lower().strip()
|
# s = self.answer.lower().strip()
|
||||||
# s = s.translate(str.maketrans('', '', string.punctuation))
|
# s = s.translate(str.maketrans('', '', string.punctuation))
|
||||||
# return s
|
# return s
|
||||||
|
def get_answer_url_safe(self):
|
||||||
|
return urllib.parse.quote(self.answer_compare)
|
||||||
|
|
||||||
def get_answer_string(self):
|
def get_answer_string(self):
|
||||||
return self.answer
|
return self.answer
|
||||||
|
|||||||
@@ -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")
|
|
||||||
+393
-130
@@ -12,9 +12,24 @@ window.control_pressed = false;
|
|||||||
|
|
||||||
|
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
|
// Select all text input fields and textareas with the clear button
|
||||||
|
clearableTextAreaSetup();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
document.body.addEventListener("htmx:responseError", function(e) {
|
document.body.addEventListener("htmx:responseError", function(e) {
|
||||||
let error = e.detail.xhr.response;
|
|
||||||
let el = document.getElementById("htmx-error");
|
let el = document.getElementById("htmx-error");
|
||||||
|
var error;
|
||||||
|
if (e.detail.pathInfo.requestPath.includes("cimar") && e.detail.xhr.status == 403) {
|
||||||
|
error = "Cimar permission error, please login and refresh the page.";
|
||||||
|
//document.getElementById("cimar-login-form").focus();
|
||||||
|
htmx.trigger("#cimar-login-needed", "cimar-login-needed");
|
||||||
|
|
||||||
|
|
||||||
|
} else {
|
||||||
|
error = e.detail.xhr.response;
|
||||||
|
}
|
||||||
el.innerHTML = error;
|
el.innerHTML = error;
|
||||||
el.classList.remove("hidden");
|
el.classList.remove("hidden");
|
||||||
});
|
});
|
||||||
@@ -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>"));
|
// $(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) {
|
// Legacy per-element click handler: skip on the new mark2 page which
|
||||||
console.log(element);
|
// 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();
|
prepAnswerData();
|
||||||
|
|
||||||
if ($(".post-form").length > 0) {
|
if ($(".post-form").length > 0) {
|
||||||
@@ -63,7 +81,15 @@ $(document).ready(function () {
|
|||||||
if (dicom_images.length) {
|
if (dicom_images.length) {
|
||||||
|
|
||||||
for (let element of dicom_images) {
|
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);
|
loadDicomViewer(e.detail.images, e.detail.annotations);
|
||||||
}, false);
|
}, false);
|
||||||
|
|
||||||
|
function clearableTextAreaSetup() {
|
||||||
|
const textFields = document.querySelectorAll(".position-relative input[type='text'], .position-relative textarea");
|
||||||
|
|
||||||
|
textFields.forEach((field) => {
|
||||||
|
const wrapper = field.closest(".position-relative");
|
||||||
|
const clearButton = wrapper.querySelector(".clear-input-button");
|
||||||
|
|
||||||
|
// Function to toggle the visibility of the clear button
|
||||||
|
const toggleClearButton = () => {
|
||||||
|
if (field.value.trim() !== "" && wrapper.matches(":hover")) {
|
||||||
|
clearButton.style.display = "inline-block"; // Show the button
|
||||||
|
} else {
|
||||||
|
clearButton.style.display = "none"; // Hide the button
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
toggleClearButton();
|
||||||
|
|
||||||
|
// Add event listeners to monitor changes in the input field or textarea
|
||||||
|
field.addEventListener("input", toggleClearButton);
|
||||||
|
|
||||||
|
// Add hover event listeners to the wrapper
|
||||||
|
wrapper.addEventListener("mouseenter", toggleClearButton);
|
||||||
|
wrapper.addEventListener("mouseleave", toggleClearButton);
|
||||||
|
|
||||||
|
// Add click event to clear the field or textarea
|
||||||
|
clearButton.addEventListener("click", function () {
|
||||||
|
field.value = ""; // Clear the field
|
||||||
|
toggleClearButton();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function loadDicomViewer(images_to_load, annotations_to_load) {
|
function loadDicomViewer(images_to_load, annotations_to_load) {
|
||||||
console.log("loadDicomViewer", images_to_load);
|
console.log("loadDicomViewer", images_to_load);
|
||||||
let single_dicom = document.getElementById("single-dicom-viewer");
|
let single_dicom = document.getElementById("single-dicom-viewer");
|
||||||
if (single_dicom) {
|
if (single_dicom) {
|
||||||
|
console.log("Load single dicom viewer");
|
||||||
$(single_dicom).empty()
|
$(single_dicom).empty()
|
||||||
let images = single_dicom.dataset.images;
|
let images = single_dicom.dataset.images;
|
||||||
|
|
||||||
@@ -201,7 +262,6 @@ function loadDicomViewer(images_to_load, annotations_to_load) {
|
|||||||
images = JSON.parse(images);
|
images = JSON.parse(images);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(images);
|
|
||||||
|
|
||||||
let annotations = single_dicom.dataset.annotations;
|
let annotations = single_dicom.dataset.annotations;
|
||||||
if (annotations_to_load != undefined) {
|
if (annotations_to_load != undefined) {
|
||||||
@@ -261,8 +321,8 @@ function prepAnswerData() {
|
|||||||
$("#id_marked_answers").val(JSON.stringify(window.marked_answers));
|
$("#id_marked_answers").val(JSON.stringify(window.marked_answers));
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadJsonToolStateOnCurrentImage(json) {
|
function loadJsonToolStateOnCurrentImage(element, json) {
|
||||||
let el = document.getElementById("dicom-image");
|
let el = element;
|
||||||
|
|
||||||
const toolStateManager = cornerstoneTools.globalImageIdSpecificToolStateManager;
|
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) {
|
$(element).bind('contextmenu', function (e) {
|
||||||
return false;
|
return false;
|
||||||
@@ -314,6 +406,8 @@ async function setUpDicom(element) {
|
|||||||
|
|
||||||
const images = element.dataset.url.split(",");
|
const images = element.dataset.url.split(",");
|
||||||
|
|
||||||
|
const annotation = element.dataset.annotations;
|
||||||
|
|
||||||
//console.log("Dicom - load imageId: ", imageIds);
|
//console.log("Dicom - load imageId: ", imageIds);
|
||||||
//for (let index = 0; index < imageIds.length; index++) {
|
//for (let index = 0; index < imageIds.length; index++) {
|
||||||
// if (imageIds[index].endsWith("dcm")) {
|
// if (imageIds[index].endsWith("dcm")) {
|
||||||
@@ -424,7 +518,7 @@ async function setUpDicom(element) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (element.dataset.annotations) {
|
if (element.dataset.annotations) {
|
||||||
loadJsonToolStateOnCurrentImage(element.dataset.annotations)
|
loadJsonToolStateOnCurrentImage(element, element.dataset.annotations)
|
||||||
}
|
}
|
||||||
|
|
||||||
cornerstone.resize(element);
|
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) {
|
function create_popup_window(url, title) {
|
||||||
@@ -636,3 +857,45 @@ function initializeClock(id, endtime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -585,13 +585,14 @@ export function loadCornerstone(main_element, db, images, annotations_to_load, l
|
|||||||
//});
|
//});
|
||||||
} else {
|
} else {
|
||||||
let url;
|
let url;
|
||||||
// This doesn't seem to have any benefit
|
// Cornerstone needs the whole url to load? (not relative)
|
||||||
//if (data_url.startsWith("http")) {
|
if (data_url.startsWith("http")) {
|
||||||
// url = data_url;
|
url = data_url;
|
||||||
//} else {
|
} else {
|
||||||
// url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
|
url = window.location.href.replace(/\/\#\/?$/, '').replace("index.html", "") + "/" + data_url
|
||||||
//}
|
}
|
||||||
url = data_url;
|
//url = data_url;
|
||||||
|
|
||||||
|
|
||||||
if (url.endsWith("dcm")) {
|
if (url.endsWith("dcm")) {
|
||||||
url = "wadouri:" + url;
|
url = "wadouri:" + url;
|
||||||
@@ -618,7 +619,11 @@ export function loadCornerstone(main_element, db, images, annotations_to_load, l
|
|||||||
|
|
||||||
//console.log("LOAD and cache, then", image);
|
//console.log("LOAD and cache, then", image);
|
||||||
loadCornerstoneMainImage(element, image, stack, db, load_as_stack);
|
loadCornerstoneMainImage(element, image, stack, db, load_as_stack);
|
||||||
checkAnonStatus(image.data)
|
|
||||||
|
// We can only check dicom data if it exists (e.g. not on jpegs)
|
||||||
|
if (image.data != undefined) {
|
||||||
|
checkAnonStatus(image.data)
|
||||||
|
}
|
||||||
|
|
||||||
let load_annotation_image = true;
|
let load_annotation_image = true;
|
||||||
if (load_annotation_image) {
|
if (load_annotation_image) {
|
||||||
@@ -1079,6 +1084,10 @@ function onNewImage(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function checkAnonStatus(dataSet) {
|
function checkAnonStatus(dataSet) {
|
||||||
|
// dataSet will be undefined if the image is not a dicom
|
||||||
|
if (dataSet == undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let accession_number = dataSet.string('x00080050').toLowerCase();
|
let accession_number = dataSet.string('x00080050').toLowerCase();
|
||||||
let patient_id = dataSet.string('x00100020').toLowerCase();
|
let patient_id = dataSet.string('x00100020').toLowerCase();
|
||||||
|
|
||||||
@@ -1215,6 +1224,16 @@ export function loadStackIndex(new_index, dicom_element) {
|
|||||||
//c = cornerstone.getEnabledElement(dicom_element)
|
//c = cornerstone.getEnabledElement(dicom_element)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadImageById(imageId, dicom_element) {
|
||||||
|
// Update stack index
|
||||||
|
let c = cornerstone.getEnabledElement(dicom_element);
|
||||||
|
let new_index = c.toolStateManager.toolState.stack.data[0].imageIds.indexOf(imageId);
|
||||||
|
c.toolStateManager.toolState.stack.data[0].currentImageIdIndex = new_index;
|
||||||
|
cornerstone.loadImage(imageId).then(b => {
|
||||||
|
cornerstone.displayImage(dicom_element, b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function urltoFile(url, filename, mimeType) {
|
function urltoFile(url, filename, mimeType) {
|
||||||
return fetch(url)
|
return fetch(url)
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
@@ -1277,34 +1296,34 @@ export function getNextAnnotationImage(element) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function manualScrollDicom(n, dicom_element) {
|
function manualScrollDicom(n, dicom_element) {
|
||||||
// There must be a better way to do this...
|
// There must be a better way to do this...
|
||||||
//dicom_element = document.getElementById("dicom-image");
|
//dicom_element = document.getElementById("dicom-image");
|
||||||
let c = cornerstone.getEnabledElement(dicom_element);
|
let c = cornerstone.getEnabledElement(dicom_element);
|
||||||
|
|
||||||
let max = c.toolStateManager.toolState.stack.data[0].imageIds.length;
|
let max = c.toolStateManager.toolState.stack.data[0].imageIds.length;
|
||||||
|
|
||||||
if (max < 2) {
|
if (max < 2) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let current_index =
|
let current_index =
|
||||||
c.toolStateManager.toolState.stack.data[0].currentImageIdIndex;
|
c.toolStateManager.toolState.stack.data[0].currentImageIdIndex;
|
||||||
|
|
||||||
let new_index = current_index + n
|
let new_index = current_index + n
|
||||||
|
|
||||||
let corrected_index = Math.min(Math.max(new_index, 0), max)
|
let corrected_index = Math.min(Math.max(new_index, 0), max)
|
||||||
|
|
||||||
// TODO: if loop
|
// TODO: if loop
|
||||||
//if (new_index >= max) {
|
//if (new_index >= max) {
|
||||||
// corrected_index = new_index - max;
|
// corrected_index = new_index - max;
|
||||||
//} else if (new_index < 0) {
|
//} else if (new_index < 0) {
|
||||||
// corrected_index = new_index + max;
|
// corrected_index = new_index + max;
|
||||||
//}
|
//}
|
||||||
|
|
||||||
c.toolStateManager.toolState.stack.data[0].currentImageIdIndex = corrected_index;
|
c.toolStateManager.toolState.stack.data[0].currentImageIdIndex = corrected_index;
|
||||||
let id = c.toolStateManager.toolState.stack.data[0].imageIds[corrected_index];
|
let id = c.toolStateManager.toolState.stack.data[0].imageIds[corrected_index];
|
||||||
cornerstone.loadImage(id).then((b) => {
|
cornerstone.loadImage(id).then((b) => {
|
||||||
cornerstone.displayImage(dicom_element, b);
|
cornerstone.displayImage(dicom_element, b);
|
||||||
});
|
});
|
||||||
// c = cornerstone.getEnabledElement(dicom_element)
|
// c = cornerstone.getEnabledElement(dicom_element)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
let active_file_inputs = new Set();
|
var active_file_inputs = new Set();
|
||||||
|
|
||||||
function add_input_form() {
|
function add_input_form() {
|
||||||
var form_idx = $('#id_images-TOTAL_FORMS').val();
|
var form_idx = $('#id_images-TOTAL_FORMS').val();
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class AnatomyQuestionTable(tables.Table):
|
|||||||
"author",
|
"author",
|
||||||
)
|
)
|
||||||
sequence = ("view", "image", "exams")
|
sequence = ("view", "image", "exams")
|
||||||
|
order_by = "-created_date"
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
|
|||||||
@@ -1,73 +1,80 @@
|
|||||||
{% extends "anatomy/base.html" %}
|
{% extends "anatomy/base.html" %}
|
||||||
|
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
{% load help_tags %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
Anatomy
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block js %}
|
{% block js %}
|
||||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function showEditPopup(url) {
|
function showEditPopup(url) {
|
||||||
var win = window.open(url, "Edit",
|
var win = window.open(url, "Edit",
|
||||||
'height=500,width=800,resizable=yes,scrollbars=yes');
|
'height=500,width=800,resizable=yes,scrollbars=yes');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
function showAddPopup(triggeringLink) {
|
function showAddPopup(triggeringLink) {
|
||||||
var name = triggeringLink.id.replace(/^add_/, '');
|
var name = triggeringLink.id.replace(/^add_/, '');
|
||||||
href = triggeringLink.href;
|
href = triggeringLink.href;
|
||||||
var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
|
var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
|
||||||
win.focus();
|
win.focus();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
function closePopup(win, newID, newRepr, id) {
|
function closePopup(win, newID, newRepr, id) {
|
||||||
console.log(id)
|
console.log(id)
|
||||||
$(id).append('<option value=' + newID + ` title=${newRepr} >` + newRepr + '</option>').removeAttr("selected").val(newID)
|
$(id).append('<option value=' + newID + ` title=${newRepr} >` + newRepr + '</option>').removeAttr("selected").val(newID)
|
||||||
win.close();
|
win.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('drop', function (e) { e.preventDefault(); }, false);
|
document.addEventListener('drop', function (e) { e.preventDefault(); }, false);
|
||||||
|
|
||||||
|
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$("#add_examination").appendTo($("label[for='id_examination']"));
|
$("#add_examination").appendTo($("label[for='id_examination']"));
|
||||||
$("#add_body_part").appendTo($("label[for='id_body_part']"));
|
$("#add_body_part").appendTo($("label[for='id_body_part']"));
|
||||||
|
|
||||||
|
|
||||||
dropContainer = document.getElementById("drop-container");
|
dropContainer = document.getElementById("drop-container");
|
||||||
|
|
||||||
dropContainer.ondragover = function (evt) {
|
dropContainer.ondragover = function (evt) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
dropContainer.ondragenter = function (evt) {
|
dropContainer.ondragenter = function (evt) {
|
||||||
$(evt.target).addClass("drop-target-active")
|
$(evt.target).addClass("drop-target-active")
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
dropContainer.ondragleave = function (evt) {
|
dropContainer.ondragleave = function (evt) {
|
||||||
$(evt.target).removeClass("drop-target-active")
|
$(evt.target).removeClass("drop-target-active")
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
dropContainer.ondrop = function (evt) {
|
dropContainer.ondrop = function (evt) {
|
||||||
console.log("drop event, ", evt);
|
console.log("drop event, ", evt);
|
||||||
$(evt.target).removeClass("drop-target-active");
|
$(evt.target).removeClass("drop-target-active");
|
||||||
|
|
||||||
// Get all input elements
|
// Get all input elements
|
||||||
input = document.getElementById("id_image");
|
input = document.getElementById("id_image");
|
||||||
//fileInput = document.getElementById("id_images-0-image");
|
//fileInput = document.getElementById("id_images-0-image");
|
||||||
|
|
||||||
f = evt.dataTransfer.files[0]
|
f = evt.dataTransfer.files[0]
|
||||||
|
|
||||||
let dT = new DataTransfer();
|
let dT = new DataTransfer();
|
||||||
dT.clearData();
|
dT.clearData();
|
||||||
dT.items.add(f);
|
dT.items.add(f);
|
||||||
|
|
||||||
input.files = dT.files;
|
input.files = dT.files;
|
||||||
|
|
||||||
ocr(input);
|
ocr(input);
|
||||||
|
|
||||||
// Make sure we have enough input targets
|
// Make sure we have enough input targets
|
||||||
//input_diff = (evt.dataTransfer.files.length - inputs.length)
|
//input_diff = (evt.dataTransfer.files.length - inputs.length)
|
||||||
@@ -113,50 +120,50 @@
|
|||||||
// dT.items.add(evt.dataTransfer.files[3]);
|
// dT.items.add(evt.dataTransfer.files[3]);
|
||||||
// fileInput.files = dT.files;
|
// fileInput.files = dT.files;
|
||||||
|
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
updateFileList();
|
updateFileList();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
$('#add_more').click(() => { add_input_form() });
|
$('#add_more').click(() => { add_input_form() });
|
||||||
|
|
||||||
function updateFileList() {
|
function updateFileList() {
|
||||||
$("#drop-filenames").empty()
|
$("#drop-filenames").empty()
|
||||||
$("#id_image").each((n, el) => {
|
$("#id_image").each((n, el) => {
|
||||||
console.log(el);
|
console.log(el);
|
||||||
if (el.files.length > 0) {
|
if (el.files.length > 0) {
|
||||||
extra_class = " image-ident-loading";
|
extra_class = " image-ident-loading";
|
||||||
if ($(el).hasClass("image-ident-warning")) {
|
if ($(el).hasClass("image-ident-warning")) {
|
||||||
extra_class = " image-ident-warning"
|
extra_class = " image-ident-warning"
|
||||||
} else if ($(el).hasClass("image-ident-ok")) {
|
} else if ($(el).hasClass("image-ident-ok")) {
|
||||||
extra_class = " 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 () {
|
$("input[type=file]").on('change', function () {
|
||||||
$(this).removeClass("image-ident-warning");
|
$(this).removeClass("image-ident-warning");
|
||||||
$(this).removeClass("image-ident-ok");
|
$(this).removeClass("image-ident-ok");
|
||||||
updateFileList();
|
updateFileList();
|
||||||
console.log("input change1");
|
console.log("input change1");
|
||||||
console.log("input change", this);
|
console.log("input change", this);
|
||||||
ocr(this);
|
ocr(this);
|
||||||
});
|
});
|
||||||
|
|
||||||
//document.getElementById("id_question_type").selectedIndex = 1;
|
//document.getElementById("id_question_type").selectedIndex = 1;
|
||||||
answer_1_status = document.getElementById("id_answers-0-status").selectedIndex;
|
answer_1_status = document.getElementById("id_answers-0-status").selectedIndex;
|
||||||
if (answer_1_status == 0) {
|
if (answer_1_status == 0) {
|
||||||
document.getElementById("id_answers-0-status").selectedIndex = 3;
|
document.getElementById("id_answers-0-status").selectedIndex = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
function ocr(input) {
|
function ocr(input) {
|
||||||
// Tesseract.recognize(f, "eng",{ logger: m => console.log(m) }
|
// Tesseract.recognize(f, "eng",{ logger: m => console.log(m) }
|
||||||
// ).then(({ data: { text } }) => {
|
// ).then(({ data: { text } }) => {
|
||||||
// console.log(text);
|
// console.log(text);
|
||||||
@@ -166,73 +173,78 @@
|
|||||||
// }
|
// }
|
||||||
// })
|
// })
|
||||||
|
|
||||||
const worker = Tesseract.createWorker({
|
const worker = Tesseract.createWorker({
|
||||||
workerPath: '{% static "worker.min.js" %}',
|
workerPath: '{% static "worker.min.js" %}',
|
||||||
langPath: '{% static "" %}',
|
langPath: '{% static "" %}',
|
||||||
//langPath: '{{ STATIC_URL }}',
|
//langPath: '{{ STATIC_URL }}',
|
||||||
corePath: '{% static "tesseract-core.wasm.js" %}',
|
corePath: '{% static "tesseract-core.wasm.js" %}',
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = URL.createObjectURL(input.files[0]);
|
const url = URL.createObjectURL(input.files[0]);
|
||||||
let img = new Image;
|
let img = new Image;
|
||||||
img.src = url;
|
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 }
|
// const rectangle = { left: 0, top: 0, width: width, height: 10 }
|
||||||
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await worker.load();
|
await worker.load();
|
||||||
await worker.loadLanguage('eng');
|
await worker.loadLanguage('eng');
|
||||||
await worker.initialize('eng');
|
await worker.initialize('eng');
|
||||||
// const { data: { text } } = await worker.recognize(input.files[0], { rectangle });
|
// 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);
|
//console.log(text);
|
||||||
l = text.toLowerCase();
|
l = text.toLowerCase();
|
||||||
$(`img[data-input-id='${input.id}'`).removeClass("image-ident-loading");
|
$(`img[data-input-id='${input.id}'`).removeClass("image-ident-loading");
|
||||||
console.log(l);
|
console.log(l);
|
||||||
if (l.includes("accesion") || l.includes("number") || l.search(/(ref|rk9|rh8|ra9|rbz)\d+/g) > -1) {
|
if (l.includes("accesion") || l.includes("number") || l.search(/(ref|rk9|rh8|ra9|rbz)\d+/g) > -1) {
|
||||||
console.log("SHIT", input);
|
console.log("SHIT", input);
|
||||||
$(input).addClass("image-ident-warning");
|
$(input).addClass("image-ident-warning");
|
||||||
$(`img[data-input-id='${input.id}'`).addClass("image-ident-warning");
|
$(`img[data-input-id='${input.id}'`).addClass("image-ident-warning");
|
||||||
} else {
|
} else {
|
||||||
$(input).addClass("image-ident-ok");
|
$(input).addClass("image-ident-ok");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await worker.terminate();
|
await worker.terminate();
|
||||||
})();
|
})();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{{ form.media }}
|
{{ form.media }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{% if object %}
|
{% if object %}
|
||||||
{% include 'anatomy/question_link_header.html' %}
|
{% include 'anatomy/question_link_header.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h2>{{object|yesno:"Update,Create"}} Question</h2>
|
<h2>{{object|yesno:"Update,Create"}} Question</h2>
|
||||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
{% help "Help" %}
|
||||||
{% csrf_token %}
|
<p>This form can be used to create an Anatomy question.</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
<table>
|
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p>
|
||||||
{{ form.as_table }}
|
|
||||||
</table>
|
<p>Correct and incorrect answers can be defined once the question has been created.</p>
|
||||||
<div id="drop-container" class="drop-target">Drop image here
|
{% endhelp %}
|
||||||
<div id="drop-filenames"></div>
|
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||||
</div>
|
{% csrf_token %}
|
||||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
<a href="/anatomy/examination/create" id="add_examination" class="add-popup"
|
||||||
</form>
|
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 %}
|
{% endblock %}
|
||||||
@@ -28,29 +28,60 @@
|
|||||||
{% if object %}
|
{% if object %}
|
||||||
{% include 'anatomy/question_link_header.html' %}
|
{% include 'anatomy/question_link_header.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<h2>Question Answers</h2>
|
<div class="card mb-4">
|
||||||
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)
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
<h2 class="mb-0">Question Answers</h2>
|
||||||
{% csrf_token %}
|
<small class="text-muted">Manage answer keys for auto-marking</small>
|
||||||
<h3>Answers:</h3>
|
</div>
|
||||||
<div id="form_set">
|
<div class="card-body">
|
||||||
{% for form in answer_formset %}
|
<details class="help-text mb-3">
|
||||||
<ul class="no-error answer-formset">
|
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||||
{{form.non_field_errors}}
|
<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>
|
||||||
{{form.errors}}
|
</details>
|
||||||
{{ form.as_ul }}
|
|
||||||
</ul>
|
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||||
{% endfor %}
|
{% 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>
|
</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>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block navigation %}
|
{% block navigation %}
|
||||||
Anatomy:
|
|
||||||
{% if request.user.is_authenticated %}
|
<nav class="navbar navbar-expand-lg navbar-dark submenu">
|
||||||
<a href="{% url 'anatomy:index' %}">Packets</a> /
|
<div class="container-fluid">
|
||||||
<a href="{% url 'anatomy:exam_list' %}">Exams</a> /
|
<a class="navbar-brand" href="{% url 'anatomy:index' %}">Anatomy</a>
|
||||||
<a href="{% url 'anatomy:exam_create' %}" title="Create a new exam">Create Exam</a> /
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<a href="{% url 'anatomy:question_list' %}">Questions</a> /
|
<span class="navbar-toggler-icon"></span>
|
||||||
<a href="{% url 'anatomy:anatomy_question_create' %}" title="Create a new question">Create Question</a>
|
</button>
|
||||||
{% if request.user.is_superuser %}
|
<div class="collapse navbar-collapse">
|
||||||
/ <a href="{% url 'anatomy:user_answer_table_view' %}" title="User answers">Answers</a>
|
<ul class="navbar-nav">
|
||||||
{% endif %}
|
{% if request.user.is_authenticated %}
|
||||||
{% endif %}
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{% url 'anatomy:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{% url 'anatomy:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="{% url 'anatomy:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'anatomy:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% if request.user.is_superuser %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{% url 'anatomy:user_answer_table_view' %}" title="User answers">Answers</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if simple_content %}
|
{% if simple_content %}
|
||||||
|
|||||||
@@ -15,7 +15,13 @@
|
|||||||
<li data-question_pk={{question.pk}}>
|
<li data-question_pk={{question.pk}}>
|
||||||
<span class="flex-col">
|
<span class="flex-col">
|
||||||
<a href="{% url 'anatomy:exam_question_detail' pk=exam.pk sk=forloop.counter0 %}">
|
<a href="{% url 'anatomy:exam_question_detail' pk=exam.pk sk=forloop.counter0 %}">
|
||||||
<img src="{{ question.image|thumbnail_url:'exam-list' }}" alt="thumbail" />
|
|
||||||
|
{% if question.image %}
|
||||||
|
<img src="{{ question.image|thumbnail_url:'exam-list' }}" alt="thumbail" />
|
||||||
|
{% else %}
|
||||||
|
No image added.
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
<span class="flex-col-4">
|
<span class="flex-col-4">
|
||||||
@@ -37,22 +43,8 @@
|
|||||||
{{ author }},
|
{{ author }},
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% if can_edit %}
|
{% include 'generic/exam_footer.html' %}
|
||||||
<p>
|
|
||||||
<button id='button-edit-order' title='click and drag questions to change order' data-posturl="{% url 'anatomy:exam_json_edit' pk=exam.pk %}">Edit question order / Delete questions</button>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
<div>
|
|
||||||
JSON creation time: {{exam.json_creation_time}} ({{exam.json_creation_time|date:"c"}}),
|
|
||||||
JSON creation id: {{exam.exam_json_id}}
|
|
||||||
</div>
|
|
||||||
<a href="{% url 'anatomy:exam_json' pk=exam.pk %}">JSON</a>
|
|
||||||
<a href="{% url 'anatomy:exam_json_unbased' pk=exam.pk %}">JSON (unbased)</a>
|
|
||||||
<a href="{% url 'anatomy:exam_json_recreate' pk=exam.pk %}">Refresh JSON cache</a>
|
|
||||||
{% if request.user.is_superuser %}
|
|
||||||
<button id='button-open-access' data-posturl="{% url 'anatomy:exam_json_edit' pk=exam.pk %}">Make questions open access</button>
|
|
||||||
<button id='button-closed-access' data-posturl="{% url 'anatomy:exam_json_edit' pk=exam.pk %}">Make questions closed access</button>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% include 'exam_overview_js.html' %}
|
{% include 'exam_overview_js.html' %}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<pre>{{ans}}</pre>
|
<pre>{{ans}}</pre>
|
||||||
</span>
|
</span>
|
||||||
{% if exam.publish_results or view_all_results%}
|
{% if exam.publish_results or view_all_results%}
|
||||||
<span class="answer-score">{{score}}</span>
|
Marks: <span class="answer-score">{{score}}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span class="view-question-link" data-qn={{forloop.counter0}}>View</span>
|
<span class="view-question-link" data-qn={{forloop.counter0}}>View</span>
|
||||||
|
|||||||
@@ -2,14 +2,30 @@
|
|||||||
|
|
||||||
{% block navigation %}
|
{% block navigation %}
|
||||||
{{block.super}}
|
{{block.super}}
|
||||||
<br/>
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
|
||||||
Exams: {{exam}}->
|
<div class="container-fluid px-3">
|
||||||
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a> /
|
<span class="navbar-text me-3 text-white">
|
||||||
{% if exam.exam_mode %}
|
<strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
|
||||||
<a href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a> /
|
</span>
|
||||||
<a href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a> /
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<a href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a> /
|
<span class="navbar-toggler-icon"></span>
|
||||||
<a href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a> /
|
</button>
|
||||||
{% endif %}
|
|
||||||
<a href="{% url 'anatomy:anatomy_create_exam' pk=exam.pk %}">Add New Question</a>
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<h2>Marking question {{question_details.current}} of {{question_details.total}}</h2>
|
<h2>Marking question {{question_details.current}} of {{question_details.total}}</h2>
|
||||||
<a href="{% url 'anatomy:question_detail' question.id %}" title="View the Question">View</a> <a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a> <a href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}" title="Edit the Question using the admin interface">Admin Edit</a>
|
<a href="{% url 'anatomy:question_detail' question.id %}" title="View the Question">View</a>
|
||||||
|
<a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
|
||||||
|
{% if request.user.is_superuser %}<a href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}" title="Edit the Question using the admin interface">Admin Edit</a>{% endif %}
|
||||||
<h3>{{ question.question_type }}</h3>
|
<h3>{{ question.question_type }}</h3>
|
||||||
{% if question.structure %}
|
{% if question.structure %}
|
||||||
Structure: {{question.structure}}<br/>
|
Structure: {{question.structure}}<br/>
|
||||||
@@ -13,10 +15,36 @@
|
|||||||
{% if question.answer_help %}
|
{% if question.answer_help %}
|
||||||
<details>
|
<details>
|
||||||
<summary>Marking help:</summary>
|
<summary>Marking help:</summary>
|
||||||
|
<div class="border border-secondary">
|
||||||
{{question.answer_help|safe}}
|
{{question.answer_help|safe}}
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
{% endif %}
|
{% 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 %}
|
{% for answer in user_answers %}
|
||||||
|
|
||||||
{% if answer in answer_suggest_incorrect %}
|
{% if answer in answer_suggest_incorrect %}
|
||||||
<li><pre><span class="answer incorrect suggest" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
|
<li><pre><span class="answer incorrect suggest" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li><pre><span class="answer not-marked" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
|
<li><pre><span class="answer not-marked" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
Marked:
|
Marked:
|
||||||
<ul id="marked-answer-list" class="answer-list">
|
<ul id="marked-answer-list" class="answer-list">
|
||||||
{% for answer in correct_answers %}
|
{% for answer in correct_answers %}
|
||||||
<li><pre><span class="answer correct" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
|
<li><pre><span class="answer correct" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for answer in half_mark_answers %}
|
{% for answer in half_mark_answers %}
|
||||||
<li><pre><span class="answer half-correct" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
|
<li><pre><span class="answer half-correct" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for answer in incorrect_answers %}
|
{% for answer in incorrect_answers %}
|
||||||
<li><pre><span class="answer incorrect" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
|
<li><pre><span class="answer incorrect" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="mark-buttons">
|
<div class="mark-buttons">
|
||||||
|
|||||||
@@ -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,87 +1,138 @@
|
|||||||
{% extends 'anatomy/base.html' %}
|
{% extends 'anatomy/base.html' %}
|
||||||
|
{% load partials %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
<!-- testing -->
|
|
||||||
<!--<div id="dicom-image" data-url="http://localhost:8000/static/abdoct.jpg"
|
|
||||||
data-annotations='{{question.image_annotations}}' data-edit_annotation=true></div>-->
|
|
||||||
{% include 'anatomy/question_link_header.html' %}
|
{% include 'anatomy/question_link_header.html' %}
|
||||||
<button id="save-annotations">Save Annotations</button>
|
|
||||||
<div id="dicom-image" class="dicom-image" data-url="https://www.penracourses.org.uk{{ question.image.url}}"
|
|
||||||
data-annotations='{{question.image_annotations}}' data-edit_annotation=true></div>
|
|
||||||
<div class="question">
|
|
||||||
<div class="date">
|
|
||||||
Created: {{ question.created_date|date:"d/m/Y" }}
|
|
||||||
</div>
|
|
||||||
<h1>{{ question.get_primary_answer }}</h1>
|
|
||||||
<h2>{{question.question_type}}</h2>
|
|
||||||
<details>
|
|
||||||
<summary>
|
|
||||||
Answers:
|
|
||||||
</summary>
|
|
||||||
<table>
|
|
||||||
<tr><th>Answer</th><th>Score</th></tr>
|
|
||||||
{% for answer in question.answers.all|dictsortreversed:"status" %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="anatomy"
|
|
||||||
{% endif %}>
|
|
||||||
{{ answer }}
|
|
||||||
</span>
|
|
||||||
<td>
|
|
||||||
<td>{{answer.status}}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
</details>
|
|
||||||
<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 %}
|
|
||||||
</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 %}
|
<div class="row g-3">
|
||||||
<pre>{{ question.image_annotations }}</pre>
|
<div class="col-12 col-lg-6">
|
||||||
{% else %}
|
<div class="card">
|
||||||
No saved annotations.
|
<div class="card-body">
|
||||||
|
<div id="anatomy-dicom-image" class="dicom-image-legacy w-100" data-url="{{ question.get_image_url }}" data-annotations='{{question.image_annotations}}' data-edit_annotation=true style="min-height:300px">
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 d-flex justify-content-between align-items-center">
|
||||||
|
<details class="mb-0">
|
||||||
|
<summary class="small text-muted"><i class="bi bi-info-circle"></i> Image help</summary>
|
||||||
|
<div class="small text-muted mt-2">Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. Click <strong>Save Annotations</strong> to persist.</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<button id="save-annotations" class="btn btn-sm btn-primary">Save Annotations</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{% if question.answer_help %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6>Answer help</h6>
|
||||||
|
<div class="small text-muted">{{ question.answer_help|safe }}</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
@@ -127,7 +178,7 @@
|
|||||||
|
|
||||||
|
|
||||||
function getJsonToolStateNoId() {
|
function getJsonToolStateNoId() {
|
||||||
const el = document.getElementById("dicom-image");
|
const el = document.getElementById("anatomy-dicom-image");
|
||||||
|
|
||||||
const c = cornerstone.getEnabledElement(el);
|
const c = cornerstone.getEnabledElement(el);
|
||||||
|
|
||||||
@@ -143,4 +194,110 @@
|
|||||||
|
|
||||||
}
|
}
|
||||||
</script>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block css %}
|
||||||
|
<style>
|
||||||
|
.proposed-answer::before {
|
||||||
|
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; }
|
||||||
|
|
||||||
|
.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">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#questionNav" aria-controls="questionNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
|
<span class="navbar-toggler-icon"></span>
|
||||||
<a href="{% url 'anatomy:question_clone' question.id %}" title="Clone the Question">Clone</a>
|
</button>
|
||||||
<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>
|
|
||||||
|
|
||||||
{% if request.user.is_superuser %}
|
<div class="collapse navbar-collapse" id="questionNav">
|
||||||
<a href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}"
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
title="Edit the Question using the admin interface">Admin Edit</a>
|
{% if exam %}
|
||||||
<a href="{% url 'anatomy:question_user_answers' question.id %}" title="View user answers associated with this question">User answers</a>
|
<li class="nav-item">
|
||||||
{% endif %}
|
{% if previous > -1 %}
|
||||||
{% if exam %}
|
<a class="nav-link" href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous</a>
|
||||||
<div>
|
{% endif %}
|
||||||
|
</li>
|
||||||
{% if previous > -1 %}
|
<li class="nav-item align-self-center">
|
||||||
<a href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous question</a>
|
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 %}
|
{% endif %}
|
||||||
Viewing question as part of exam: <a href="{% url 'anatomy:exam_overview' exam.id %}">{{exam}}</a> [{{pos}}/{{exam_length}}]
|
</ul>
|
||||||
{% if next %}
|
|
||||||
<a href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next question</a>
|
<ul class="navbar-nav ms-auto">
|
||||||
{% endif %}
|
<li class="nav-item dropdown">
|
||||||
</div>
|
<a class="nav-link dropdown-toggle" href="#" id="questionActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
|
||||||
{% endif %}
|
<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>
|
</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 %}
|
{% block content %}
|
||||||
|
|
||||||
<div id="view-filter-options">
|
<div id="view-filter-options">
|
||||||
<h3>Filter Anatomy Questions</h3>
|
<h3>Filter Anatomy Questions</h3>
|
||||||
<form action="" method="get">
|
<form action="" method="get">
|
||||||
{{ filter.form }}
|
{{ filter.form }}
|
||||||
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
|
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% render_table table %}
|
{% render_table table %}
|
||||||
|
|
||||||
<button id="button-select-add-exam"
|
<button id="button-select-add-exam"
|
||||||
data-exam_json_edit_url="{% url 'generic:generic_exam_json_edit' %}"
|
hx-get="{% url 'generic:generic_exam_htmx' %}?type=anatomy"
|
||||||
data-exam_list_url="{% url 'api-1:anatomy_user_exams' %}"
|
hx-target="#action-result"
|
||||||
data-type="anatomy"
|
hx-swap="innerHTML"
|
||||||
data-csrf="{{ csrf_token}}">Add selected questions to
|
data-exam_json_edit_url="{% url 'generic:generic_exam_json_edit' %}"
|
||||||
exam</button>
|
data-exam_list_url="{% url 'api-1:anatomy_user_exams' %}"
|
||||||
<div id="exam-options"></div>
|
data-type="anatomy"
|
||||||
|
data-csrf="{{ csrf_token }}">
|
||||||
|
Add selected questions to exam
|
||||||
|
</button>
|
||||||
|
<div id="exam-options"></div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -34,7 +34,7 @@ APP_NAME = "anatomy"
|
|||||||
|
|
||||||
def create_exam(db):
|
def create_exam(db):
|
||||||
exam: Exam = AnatomyExamViews.Exam.objects.create(
|
exam: Exam = AnatomyExamViews.Exam.objects.create(
|
||||||
name="Cid Exam", exam_mode=True, active=True
|
name="Cid Exam", exam_mode=True, active=True, open_access=False, exam_open_access=False
|
||||||
)
|
)
|
||||||
|
|
||||||
group1: CidUserGroup = CidUserGroup.objects.get(name="Group1")
|
group1: CidUserGroup = CidUserGroup.objects.get(name="Group1")
|
||||||
@@ -365,7 +365,7 @@ def test_exams(db, client):
|
|||||||
cid_exam_scores_soup = BeautifulSoup(cid_exam_scores_res.content, "html.parser")
|
cid_exam_scores_soup = BeautifulSoup(cid_exam_scores_res.content, "html.parser")
|
||||||
|
|
||||||
# Check that we have an alert that exams are not published
|
# Check that we have an alert that exams are not published
|
||||||
alert = cid_exam_scores_soup.find("div", {"class": "alert"})
|
alert = cid_exam_scores_soup.find("div", {"class": "no-results"})
|
||||||
assert "Results are not currently published." in str(alert)
|
assert "Results are not currently published." in str(alert)
|
||||||
|
|
||||||
answer_lis = cid_exam_scores_soup.find(
|
answer_lis = cid_exam_scores_soup.find(
|
||||||
|
|||||||
+38
-8
@@ -7,21 +7,16 @@ from django.views.decorators.cache import cache_page
|
|||||||
app_name = "anatomy"
|
app_name = "anatomy"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# path('', views.question_list, name='question_list'),
|
# path('', views.question_view, name='question_view'),
|
||||||
path(
|
path(
|
||||||
"question/",
|
"question/",
|
||||||
views.AnatomyQuestionView.as_view(),
|
views.AnatomyQuestionView.as_view(),
|
||||||
name="question_list",
|
name="question_view",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"question/create/",
|
"question/create/",
|
||||||
views.AnatomyQuestionCreate.as_view(),
|
views.AnatomyQuestionCreate.as_view(),
|
||||||
name="anatomy_question_create",
|
name="question_create",
|
||||||
),
|
|
||||||
path(
|
|
||||||
"create/exam/<int:pk>",
|
|
||||||
views.AnatomyQuestionCreate.as_view(),
|
|
||||||
name="anatomy_create_exam",
|
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"question/<int:pk>/update",
|
"question/<int:pk>/update",
|
||||||
@@ -38,6 +33,17 @@ urlpatterns = [
|
|||||||
views.question_save_annotation,
|
views.question_save_annotation,
|
||||||
name="question_save_annotation",
|
name="question_save_annotation",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"question/<int:pk>/suggest_incorrect_answers",
|
||||||
|
views.question_suggest_incorrect_answers,
|
||||||
|
name="question_suggest_incorrect_answers",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"question/<int:pk>/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>/answer/", views.answer_question, name="answer_question"),
|
||||||
path(
|
path(
|
||||||
"question/<int:pk>/clone", views.QuestionClone.as_view(), name="question_clone"
|
"question/<int:pk>/clone", views.QuestionClone.as_view(), name="question_clone"
|
||||||
@@ -47,6 +53,13 @@ urlpatterns = [
|
|||||||
views.QuestionDelete.as_view(),
|
views.QuestionDelete.as_view(),
|
||||||
name="question_delete",
|
name="question_delete",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"question/<int:question_id>/add_exam",
|
||||||
|
views.question_add_exam,
|
||||||
|
name="question_add_exam",
|
||||||
|
),
|
||||||
|
path("answer/<int:answer_id>/confirm", views.confirm_answer, name="confirm_answer"),
|
||||||
|
path("answer/<int:answer_id>/delete", views.delete_answer, name="delete_answer"),
|
||||||
path("exam/<int:exam_pk>/<int:sk>/mark", views.mark, name="mark"),
|
path("exam/<int:exam_pk>/<int:sk>/mark", views.mark, name="mark"),
|
||||||
path("exam/<int:exam_pk>/<int:sk>/mark/all", views.mark_all, name="mark_all"),
|
path("exam/<int:exam_pk>/<int:sk>/mark/all", views.mark_all, name="mark_all"),
|
||||||
path(
|
path(
|
||||||
@@ -84,6 +97,23 @@ urlpatterns = [
|
|||||||
views.UserAnswerDelete.as_view(),
|
views.UserAnswerDelete.as_view(),
|
||||||
name="user_answer_delete",
|
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))
|
urlpatterns.extend(generic_view_urls(views.GenericViews))
|
||||||
|
|||||||
+960
-55
File diff suppressed because it is too large
Load Diff
+25
-1
@@ -3,6 +3,7 @@ from .models import (
|
|||||||
Case,
|
Case,
|
||||||
CaseDetail,
|
CaseDetail,
|
||||||
CaseCollection,
|
CaseCollection,
|
||||||
|
NormalCase,
|
||||||
Differential,
|
Differential,
|
||||||
Series,
|
Series,
|
||||||
SeriesImage,
|
SeriesImage,
|
||||||
@@ -16,7 +17,10 @@ from .models import (
|
|||||||
Presentation,
|
Presentation,
|
||||||
UncategorisedDicom,
|
UncategorisedDicom,
|
||||||
SeriesDetail,
|
SeriesDetail,
|
||||||
Resource
|
Resource,
|
||||||
|
CaseDisplaySet,
|
||||||
|
UserReportAnswer,
|
||||||
|
CidReportAnswer,
|
||||||
)
|
)
|
||||||
|
|
||||||
from django.forms import ModelForm
|
from django.forms import ModelForm
|
||||||
@@ -46,6 +50,9 @@ admin.site.register(CaseDetail)
|
|||||||
admin.site.register(UncategorisedDicom)
|
admin.site.register(UncategorisedDicom)
|
||||||
admin.site.register(SeriesDetail)
|
admin.site.register(SeriesDetail)
|
||||||
admin.site.register(Resource)
|
admin.site.register(Resource)
|
||||||
|
admin.site.register(CaseDisplaySet)
|
||||||
|
admin.site.register(UserReportAnswer)
|
||||||
|
admin.site.register(CidReportAnswer)
|
||||||
|
|
||||||
|
|
||||||
class DifferentialInline(admin.TabularInline):
|
class DifferentialInline(admin.TabularInline):
|
||||||
@@ -73,6 +80,22 @@ class CaseCollectionAdmin(VersionAdmin):
|
|||||||
|
|
||||||
admin.site.register(CaseCollection, CaseCollectionAdmin)
|
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 AtlasAdminForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Case
|
model = Case
|
||||||
@@ -87,6 +110,7 @@ class AtlasAdminForm(ModelForm):
|
|||||||
"subspecialty",
|
"subspecialty",
|
||||||
"author",
|
"author",
|
||||||
"series",
|
"series",
|
||||||
|
"cimar_uuid"
|
||||||
]
|
]
|
||||||
|
|
||||||
widgets = {
|
widgets = {
|
||||||
|
|||||||
+95
-93
@@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from ninja import ModelSchema, Router, Schema, Field
|
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 .decorators import user_is_author_or_rapid_checker
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
@@ -32,9 +32,9 @@ router = Router()
|
|||||||
class SeriesSchema(ModelSchema):
|
class SeriesSchema(ModelSchema):
|
||||||
case_id: List[int] = []
|
case_id: List[int] = []
|
||||||
|
|
||||||
class Config:
|
class Meta:
|
||||||
model = Series
|
model = Series
|
||||||
model_fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"modality",
|
"modality",
|
||||||
"examination",
|
"examination",
|
||||||
@@ -48,9 +48,9 @@ class SeriesSchema(ModelSchema):
|
|||||||
|
|
||||||
|
|
||||||
class CaseSchema(ModelSchema):
|
class CaseSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Case
|
model = Case
|
||||||
model_fields = ["id", "title"]
|
fields = ["id", "title"]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload_dicom", auth=django_auth)
|
@router.post("/upload_dicom", auth=django_auth)
|
||||||
@@ -70,8 +70,10 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
|||||||
except DuplicateDicom as e:
|
except DuplicateDicom as e:
|
||||||
duplicate.append((file.name, ud.image_blake3_hash))
|
duplicate.append((file.name, ud.image_blake3_hash))
|
||||||
|
|
||||||
duplicate_series.add(e.duplicate.get_series().get_absolute_url())
|
|
||||||
print(duplicate_series)
|
if type(e.duplicate) == SeriesImage:
|
||||||
|
duplicate_series.add(e.duplicate.series.get_absolute_url())
|
||||||
|
|
||||||
pass
|
pass
|
||||||
except InvalidDicomError:
|
except InvalidDicomError:
|
||||||
failed.append(file.name)
|
failed.append(file.name)
|
||||||
@@ -107,7 +109,6 @@ def clear_dicoms(request):
|
|||||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||||
|
|
||||||
dicoms.delete()
|
dicoms.delete()
|
||||||
print(dicoms)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -127,12 +128,10 @@ def uncategorised_dicoms(request):
|
|||||||
|
|
||||||
|
|
||||||
def import_dicoms_helper(request, case_id: int | None = None):
|
def import_dicoms_helper(request, case_id: int | None = None):
|
||||||
# dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
|
||||||
if "selection" in request.POST:
|
if "selection" in request.POST:
|
||||||
dicoms = UncategorisedDicom.objects.filter(
|
dicoms = UncategorisedDicom.objects.filter(
|
||||||
series_instance_uid__in=request.POST.getlist("selection")
|
series_instance_uid__in=request.POST.getlist("selection")
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||||
|
|
||||||
@@ -141,70 +140,75 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
|||||||
else:
|
else:
|
||||||
order = None
|
order = None
|
||||||
|
|
||||||
data = defaultdict(list)
|
# Group dicoms by SeriesInstanceUID, but don't keep all tags in memory
|
||||||
for d in dicoms:
|
series_map = {}
|
||||||
|
for d in dicoms.iterator(): # Use iterator() to avoid loading all at once
|
||||||
tags = d.basic_dicom_tags
|
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 = []
|
series_tags = tags # Save the first tag for this series
|
||||||
for series_uid in data:
|
try:
|
||||||
modality = get_object_or_404(Modality, short_code=tags["Modality"])
|
with transaction.atomic():
|
||||||
tags = data[series_uid][0][1]
|
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)
|
# Add author and case if needed
|
||||||
if Series.objects.filter(
|
series.author.add(request.user)
|
||||||
series_instance_uid=tags["SeriesInstanceUID"]
|
if case_id is not None:
|
||||||
).exists():
|
case_object = get_object_or_404(Case, pk=case_id)
|
||||||
series = Series.objects.get(series_instance_uid=tags["SeriesInstanceUID"])
|
try:
|
||||||
else:
|
series.case.add(case_object)
|
||||||
series = Series(
|
except IntegrityError:
|
||||||
modality=modality,
|
|
||||||
description=tags["SeriesDescription"],
|
|
||||||
series_instance_uid=tags["SeriesInstanceUID"],
|
|
||||||
)
|
|
||||||
|
|
||||||
if tags["StudyDescription"]:
|
|
||||||
examination, created = Examination.objects.get_or_create(
|
|
||||||
examination=tags["StudyDescription"]
|
|
||||||
)
|
|
||||||
if created:
|
|
||||||
examination.modality = modality
|
|
||||||
examination.save()
|
|
||||||
series.examination = examination
|
|
||||||
|
|
||||||
series.save()
|
|
||||||
|
|
||||||
match order:
|
|
||||||
case "order-series-instance-number":
|
|
||||||
series.order_by_dicom("InstanceNumber")
|
|
||||||
case "order-series-slice-location":
|
|
||||||
series.order_by_dicom("SliceLocation")
|
|
||||||
case _:
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# We might only want to add the author during creation....
|
series_map[series_uid] = series
|
||||||
series.author.add(request.user)
|
else:
|
||||||
|
series = series_map[series_uid]
|
||||||
|
|
||||||
if case_id is not None:
|
# Create and save SeriesImage immediately
|
||||||
case_object = get_object_or_404(Case, pk=case_id)
|
series_image = SeriesImage(
|
||||||
if not series.case.contains(case_object):
|
image=d.image,
|
||||||
series.case.add(case_object)
|
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]:
|
# Optionally, re-order series if needed
|
||||||
series_image = SeriesImage(
|
for series in series_map.values():
|
||||||
image=dicom.image,
|
match order:
|
||||||
series=series,
|
case "order-series-instance-number":
|
||||||
is_dicom=True,
|
series.order_by_dicom("InstanceNumber")
|
||||||
image_blake3_hash=dicom.image_blake3_hash,
|
case "order-series-slice-location":
|
||||||
)
|
series.order_by_dicom("SliceLocation")
|
||||||
series_image.save()
|
case _:
|
||||||
# dicom.image.delete()
|
pass
|
||||||
|
|
||||||
dicom.delete()
|
return [(series, series.get_link()) for series in series_map.values()]
|
||||||
series_list.append((series, series.get_link()))
|
|
||||||
|
|
||||||
return series_list
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -225,7 +229,6 @@ def import_dicoms_case(request, case_id: int):
|
|||||||
|
|
||||||
@router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema])
|
@router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema])
|
||||||
def orphan_series(request):
|
def orphan_series(request):
|
||||||
# print(request.user.series.filter(case=None))
|
|
||||||
return 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)
|
@router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth)
|
||||||
def series_truncate(request, series_id: int, start: int, end: int):
|
def series_truncate(request, series_id: int, start: int, end: int):
|
||||||
print(start, end)
|
|
||||||
series = get_object_or_404(Series, pk=series_id)
|
series = get_object_or_404(Series, pk=series_id)
|
||||||
|
|
||||||
if not series.check_user_can_edit(request.user):
|
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:
|
if n >= start and n <= end:
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
print(n, image)
|
|
||||||
image.removed = True
|
image.removed = True
|
||||||
image.image = None
|
image.image = None
|
||||||
image.save()
|
image.save()
|
||||||
images_removed.append(image.pk)
|
images_removed.append(image.pk)
|
||||||
|
|
||||||
|
series.get_total_image_size(refresh=True)
|
||||||
|
|
||||||
return {"status": "success", "images_removed": images_removed}
|
return {"status": "success", "images_removed": images_removed}
|
||||||
|
|
||||||
|
|
||||||
@@ -275,7 +279,6 @@ def get_cases_user(request):
|
|||||||
@router.get("/check_image_hash/{hash}", auth=django_auth)
|
@router.get("/check_image_hash/{hash}", auth=django_auth)
|
||||||
def check_image_hash(request, hash: str):
|
def check_image_hash(request, hash: str):
|
||||||
try:
|
try:
|
||||||
print(hash)
|
|
||||||
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
|
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
|
||||||
data = {
|
data = {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@@ -354,39 +357,38 @@ def series_split_by_tag(request, series_id: int, dicom_tag: str):
|
|||||||
|
|
||||||
image_map = defaultdict(list)
|
image_map = defaultdict(list)
|
||||||
|
|
||||||
# Split teh associated images
|
# Use iterator to avoid loading all images into memory
|
||||||
for image in series.images.all():
|
for image in series.images.iterator():
|
||||||
print(image)
|
|
||||||
ds = image.get_dicom_data()
|
ds = image.get_dicom_data()
|
||||||
|
|
||||||
# print("2a", ds[dicom_tag])
|
|
||||||
|
|
||||||
if dicom_tag in ds:
|
if dicom_tag in ds:
|
||||||
val = ds[dicom_tag].value
|
val = ds[dicom_tag].value
|
||||||
|
if isinstance(val, pydicom.multival.MultiValue):
|
||||||
if type(val) == pydicom.multival.MultiValue:
|
key = ",".join(map(str, val))
|
||||||
image_map[",".join((val))].append(image)
|
|
||||||
else:
|
else:
|
||||||
image_map[val].append(image)
|
key = str(val)
|
||||||
|
image_map[key].append(image)
|
||||||
else:
|
else:
|
||||||
return [f"{dicom_tag} not found {image}"]
|
return [f"{dicom_tag} not found {image}"]
|
||||||
|
|
||||||
|
case = list(series.case.all())
|
||||||
|
authors = list(series.author.all())
|
||||||
new_series = []
|
new_series = []
|
||||||
case = series.case.all()
|
|
||||||
authors = series.author.all()
|
|
||||||
|
|
||||||
for n, option in enumerate(image_map):
|
# Use a transaction for all DB changes
|
||||||
if n == 0:
|
with transaction.atomic():
|
||||||
series.images.set(image_map[option])
|
for n, (option, images) in enumerate(image_map.items()):
|
||||||
|
if n == 0:
|
||||||
else:
|
# Update the original series
|
||||||
series.pk = None
|
series.images.set(images)
|
||||||
series.save()
|
new_series.append(series.pk)
|
||||||
series.case.set(case)
|
else:
|
||||||
series.author.set(authors)
|
# Clone the series
|
||||||
series.images.set(image_map[option])
|
series.pk = None
|
||||||
|
series.save()
|
||||||
new_series.append(series.pk)
|
series.case.set(case)
|
||||||
|
series.author.set(authors)
|
||||||
|
series.images.set(images)
|
||||||
|
new_series.append(series.pk)
|
||||||
|
|
||||||
return new_series
|
return new_series
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ def user_is_collection_author_or_atlas_editor(function):
|
|||||||
def wrap(request, *args, **kwargs):
|
def wrap(request, *args, **kwargs):
|
||||||
if "exam_id" in kwargs:
|
if "exam_id" in kwargs:
|
||||||
atlas = CaseCollection.objects.get(pk=kwargs["exam_id"])
|
atlas = CaseCollection.objects.get(pk=kwargs["exam_id"])
|
||||||
|
elif "collection_id" in kwargs:
|
||||||
|
atlas = CaseCollection.objects.get(pk=kwargs["collection_id"])
|
||||||
else:
|
else:
|
||||||
atlas = CaseCollection.objects.get(pk=kwargs["pk"])
|
atlas = CaseCollection.objects.get(pk=kwargs["pk"])
|
||||||
if (
|
if (
|
||||||
|
|||||||
+132
-5
@@ -11,6 +11,9 @@ from .models import (
|
|||||||
Series,
|
Series,
|
||||||
Structure,
|
Structure,
|
||||||
Subspecialty,
|
Subspecialty,
|
||||||
|
NormalCase,
|
||||||
|
Resource,
|
||||||
|
Site,
|
||||||
)
|
)
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
@@ -49,6 +52,8 @@ def get_series(request):
|
|||||||
else:
|
else:
|
||||||
queryset = Series.objects.all()
|
queryset = Series.objects.all()
|
||||||
|
|
||||||
|
queryset = queryset.prefetch_related("examination", "plane")
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
@@ -159,13 +164,16 @@ class SeriesFilter(django_filters.FilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class ConditionFilter(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:
|
class Meta:
|
||||||
model = Condition
|
model = Condition
|
||||||
fields = {
|
fields = {
|
||||||
"name": ["icontains"],
|
"name": ["icontains"],
|
||||||
"primary": ["exact"],
|
|
||||||
"synonym": ["exact"],
|
|
||||||
#"parent": ["exact"],
|
|
||||||
"subspecialty": ["exact"],
|
"subspecialty": ["exact"],
|
||||||
"rcr_curriculum": ["exact"],
|
"rcr_curriculum": ["exact"],
|
||||||
}
|
}
|
||||||
@@ -184,11 +192,32 @@ class ConditionFilter(django_filters.FilterSet):
|
|||||||
)
|
)
|
||||||
pass
|
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):
|
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:
|
class Meta:
|
||||||
model = Finding
|
model = Finding
|
||||||
fields = ("name", "primary", "synonym")
|
fields = {
|
||||||
|
"name": ["icontains"],
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -204,11 +233,29 @@ class FindingFilter(django_filters.FilterSet):
|
|||||||
)
|
)
|
||||||
pass
|
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):
|
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:
|
class Meta:
|
||||||
model = Structure
|
model = Structure
|
||||||
fields = {"name": ["icontains"], "primary": ["exact"], "synonym": ["exact"]}
|
fields = {"name": ["icontains"]}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -224,6 +271,19 @@ class StructureFilter(django_filters.FilterSet):
|
|||||||
)
|
)
|
||||||
pass
|
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 PresentationFilter(django_filters.FilterSet):
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -284,6 +344,73 @@ class SubspecialtyFilter(django_filters.FilterSet):
|
|||||||
)
|
)
|
||||||
pass
|
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 QuestionSchemaFilter(django_filters.FilterSet):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = QuestionSchema
|
model = QuestionSchema
|
||||||
|
|||||||
+660
-137
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.2 on 2024-10-07 08:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('atlas', '0060_seriesfinding_current_image_id_index'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='seriesfinding',
|
||||||
|
name='current_image_id_index',
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
+782
-88
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
|
import django_tables2 as tables
|
||||||
from django_tables2.utils import A
|
from django_tables2.utils import A
|
||||||
|
|
||||||
from generic.tables import SeriesImageColumn
|
from generic.tables import SeriesImageColumn, SelectionTable
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Case,
|
Case,
|
||||||
@@ -14,6 +14,7 @@ from .models import (
|
|||||||
Structure,
|
Structure,
|
||||||
Finding,
|
Finding,
|
||||||
Subspecialty,
|
Subspecialty,
|
||||||
|
Resource,
|
||||||
)
|
)
|
||||||
|
|
||||||
from django.utils.html import format_html
|
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 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):
|
def get_view_cell(record):
|
||||||
if record.open_access:
|
if record.open_access:
|
||||||
return format_html(f"""<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">
|
return format_html(f"""<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">
|
||||||
@@ -36,6 +90,78 @@ class CaseTable(tables.Table):
|
|||||||
else:
|
else:
|
||||||
return f'View'
|
return f'View'
|
||||||
|
|
||||||
|
def render_author(self, value):
|
||||||
|
max_length = 15 # Set the maximum length for truncating individual author names
|
||||||
|
authors = []
|
||||||
|
for author in value.all():
|
||||||
|
full_name = str(author) # Convert the author object to a string (e.g., full name)
|
||||||
|
if len(full_name) > max_length:
|
||||||
|
truncated_name = f"{full_name[:max_length]}..." # Truncate and add ellipsis
|
||||||
|
else:
|
||||||
|
truncated_name = full_name
|
||||||
|
# Add a tooltip with the full name
|
||||||
|
authors.append(
|
||||||
|
format_html(
|
||||||
|
'<span title="{}">{}</span>',
|
||||||
|
full_name, # Full name for the tooltip
|
||||||
|
truncated_name, # Truncated name for display
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Join all authors with commas
|
||||||
|
return format_html(", ".join(authors))
|
||||||
|
|
||||||
|
def render_associated_cases(self, record):
|
||||||
|
links = []
|
||||||
|
if record.previous_case:
|
||||||
|
links.append(
|
||||||
|
format_html(
|
||||||
|
'<a href="{}" title="View previous case">Previous Case</a>',
|
||||||
|
record.previous_case.get_absolute_url(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if record.next_case:
|
||||||
|
links.append(
|
||||||
|
format_html(
|
||||||
|
'<a href="{}" title="View next case">Next Case</a>',
|
||||||
|
record.next_case.get_absolute_url(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Case.DoesNotExist:
|
||||||
|
pass
|
||||||
|
if links:
|
||||||
|
return format_html(", ".join(links))
|
||||||
|
return "No associated cases"
|
||||||
|
|
||||||
|
def render_collections(self, record):
|
||||||
|
collections = [
|
||||||
|
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(
|
edit = tables.LinkColumn(
|
||||||
"atlas:case_update", text="Edit", args=[A("pk")], orderable=False
|
"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
|
"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(SelectionTable.Meta):
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Case
|
model = Case
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = (
|
fields = (
|
||||||
"title",
|
"title",
|
||||||
"description",
|
"description",
|
||||||
#"history",
|
|
||||||
#"condition",
|
|
||||||
"created_date",
|
"created_date",
|
||||||
|
"series",
|
||||||
"author",
|
"author",
|
||||||
|
"associated_cases",
|
||||||
|
"collections",
|
||||||
)
|
)
|
||||||
sequence = ("view", )#, "series")
|
sequence = ("view", )
|
||||||
|
order_by = "-created_date"
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
|
||||||
super().__init__(
|
|
||||||
data.prefetch_related(
|
|
||||||
"author",#"condition", "condition__synonym"
|
|
||||||
),
|
|
||||||
*args,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
class PopupLinkColumn(tables.Column):
|
class PopupLinkColumn(tables.Column):
|
||||||
def render(self, value):
|
def render(self, value):
|
||||||
@@ -86,12 +204,7 @@ class PopupLinkColumn(tables.Column):
|
|||||||
return obj.get_thumbnail()[0]
|
return obj.get_thumbnail()[0]
|
||||||
|
|
||||||
|
|
||||||
def create_link(test):
|
class SeriesTable(SelectionTable):
|
||||||
print(test)
|
|
||||||
return "Hello"
|
|
||||||
|
|
||||||
|
|
||||||
class SeriesTable(tables.Table):
|
|
||||||
edit = tables.LinkColumn(
|
edit = tables.LinkColumn(
|
||||||
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False
|
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False
|
||||||
)
|
)
|
||||||
@@ -105,13 +218,12 @@ class SeriesTable(tables.Table):
|
|||||||
delete = tables.LinkColumn(
|
delete = tables.LinkColumn(
|
||||||
"atlas:series_delete", text="Delete", args=[A("pk")], orderable=False
|
"atlas:series_delete", text="Delete", args=[A("pk")], orderable=False
|
||||||
)
|
)
|
||||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
|
||||||
|
|
||||||
case = tables.ManyToManyColumn(verbose_name="Cases")
|
case = tables.ManyToManyColumn(verbose_name="Cases")
|
||||||
findings = tables.ManyToManyColumn(verbose_name="Findings")
|
findings = tables.ManyToManyColumn(verbose_name="Findings")
|
||||||
structures = tables.ManyToManyColumn(verbose_name="Structures")
|
structures = tables.ManyToManyColumn(verbose_name="Structures")
|
||||||
|
|
||||||
class Meta:
|
class Meta(SelectionTable.Meta):
|
||||||
model = Series
|
model = Series
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = (
|
fields = (
|
||||||
@@ -125,6 +237,8 @@ class SeriesTable(tables.Table):
|
|||||||
"modified_date",
|
"modified_date",
|
||||||
)
|
)
|
||||||
sequence = ("view", "popup", "images", "case")
|
sequence = ("view", "popup", "images", "case")
|
||||||
|
order_by = "-created_date"
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -142,7 +256,7 @@ class SeriesTable(tables.Table):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConditionTable(tables.Table):
|
class ConditionTable(SelectionTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=("atlas:condition_detail", {"pk": tables.A("pk")}),
|
linkify=("atlas:condition_detail", {"pk": tables.A("pk")}),
|
||||||
verbose_name="Condition",
|
verbose_name="Condition",
|
||||||
@@ -154,18 +268,18 @@ class ConditionTable(tables.Table):
|
|||||||
delete = tables.LinkColumn(
|
delete = tables.LinkColumn(
|
||||||
"atlas:condition_delete", text="Delete", args=[A("pk")], orderable=False
|
"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.ManyToManyColumn(verbose_name="Synonyms")
|
||||||
synonym = tables.Column(empty_values=())
|
synonym = tables.Column(empty_values=())
|
||||||
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
||||||
|
|
||||||
class Meta:
|
class Meta(SelectionTable.Meta):
|
||||||
model = Condition
|
model = Condition
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
fields = ("subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
data.prefetch_related(
|
data.prefetch_related(
|
||||||
@@ -180,7 +294,7 @@ class ConditionTable(tables.Table):
|
|||||||
return f"{record.get_synonym_link()}"
|
return f"{record.get_synonym_link()}"
|
||||||
|
|
||||||
|
|
||||||
class FindingTable(tables.Table):
|
class FindingTable(SelectionTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=("atlas:finding_detail", {"pk": tables.A("pk")}), verbose_name="Finding"
|
linkify=("atlas:finding_detail", {"pk": tables.A("pk")}), verbose_name="Finding"
|
||||||
)
|
)
|
||||||
@@ -190,23 +304,23 @@ class FindingTable(tables.Table):
|
|||||||
delete = tables.LinkColumn(
|
delete = tables.LinkColumn(
|
||||||
"atlas:finding_delete", text="Delete", args=[A("pk")], orderable=False
|
"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.ManyToManyColumn(verbose_name="Synonyms")
|
||||||
synonym = tables.Column(empty_values=())
|
synonym = tables.Column(empty_values=())
|
||||||
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
||||||
|
|
||||||
class Meta:
|
class Meta(SelectionTable.Meta):
|
||||||
model = Finding
|
model = Finding
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name", "primary")
|
fields = ("name",)
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
|
||||||
|
|
||||||
def render_synonym(self, value, record):
|
def render_synonym(self, value, record):
|
||||||
return format_html(record.get_synonym_link())
|
return format_html(record.get_synonym_link())
|
||||||
|
|
||||||
|
|
||||||
class StructureTable(tables.Table):
|
class StructureTable(SelectionTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=("atlas:structure_detail", {"pk": tables.A("pk")}),
|
linkify=("atlas:structure_detail", {"pk": tables.A("pk")}),
|
||||||
verbose_name="Structure",
|
verbose_name="Structure",
|
||||||
@@ -217,23 +331,23 @@ class StructureTable(tables.Table):
|
|||||||
delete = tables.LinkColumn(
|
delete = tables.LinkColumn(
|
||||||
"atlas:structure_delete", text="Delete", args=[A("pk")], orderable=False
|
"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.ManyToManyColumn(verbose_name="Synonyms")
|
||||||
synonym = tables.Column(empty_values=())
|
synonym = tables.Column(empty_values=())
|
||||||
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
||||||
|
|
||||||
class Meta:
|
class Meta(SelectionTable.Meta):
|
||||||
model = Structure
|
model = Structure
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name", "primary")
|
fields = ("name",)
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
|
||||||
|
|
||||||
def render_synonym(self, value, record):
|
def render_synonym(self, value, record):
|
||||||
return format_html(record.get_synonym_link())
|
return format_html(record.get_synonym_link())
|
||||||
|
|
||||||
|
|
||||||
class PresentationTable(tables.Table):
|
class PresentationTable(SelectionTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=("atlas:presentation_detail", {"pk": tables.A("pk")}),
|
linkify=("atlas:presentation_detail", {"pk": tables.A("pk")}),
|
||||||
verbose_name="Presentation",
|
verbose_name="Presentation",
|
||||||
@@ -245,18 +359,19 @@ class PresentationTable(tables.Table):
|
|||||||
# delete = tables.LinkColumn(
|
# delete = tables.LinkColumn(
|
||||||
# "atlas:presentation_delete", text="Delete", args=[A("pk")], orderable=False
|
# "atlas:presentation_delete", text="Delete", args=[A("pk")], orderable=False
|
||||||
# )
|
# )
|
||||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
|
||||||
|
|
||||||
subspecialty = tables.ManyToManyColumn(verbose_name="Subspecialty")
|
subspecialty = tables.ManyToManyColumn(verbose_name="Subspecialty")
|
||||||
|
|
||||||
class Meta:
|
class Meta(SelectionTable.Meta):
|
||||||
model = Presentation
|
model = Presentation
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name",)
|
fields = ("name",)
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
|
||||||
|
|
||||||
class PathologicalProcessTable(tables.Table):
|
|
||||||
|
|
||||||
|
class PathologicalProcessTable(SelectionTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=("atlas:pathological_process_detail", {"pk": tables.A("pk")}),
|
linkify=("atlas:pathological_process_detail", {"pk": tables.A("pk")}),
|
||||||
verbose_name="PathologicalProcess",
|
verbose_name="PathologicalProcess",
|
||||||
@@ -268,16 +383,15 @@ class PathologicalProcessTable(tables.Table):
|
|||||||
# delete = tables.LinkColumn(
|
# delete = tables.LinkColumn(
|
||||||
# "atlas:pathological_process_delete", text="Delete", args=[A("pk")], orderable=False
|
# "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
|
model = PathologicalProcess
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name",)
|
fields = ("name",)
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
|
||||||
|
|
||||||
class SubspecialtyTable(tables.Table):
|
class SubspecialtyTable(SelectionTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=("atlas:subspecialty_detail", {"pk": tables.A("pk")}),
|
linkify=("atlas:subspecialty_detail", {"pk": tables.A("pk")}),
|
||||||
verbose_name="Subspecialty",
|
verbose_name="Subspecialty",
|
||||||
@@ -289,19 +403,31 @@ class SubspecialtyTable(tables.Table):
|
|||||||
# delete = tables.LinkColumn(
|
# delete = tables.LinkColumn(
|
||||||
# "atlas:subspecialty_delete", text="Delete", args=[A("pk")], orderable=False
|
# "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
|
model = Subspecialty
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ()
|
fields = ()
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
|
||||||
|
|
||||||
def render_synonym(self, value, record):
|
def render_synonym(self, value, record):
|
||||||
return format_html(record.get_synonym_link())
|
return format_html(record.get_synonym_link())
|
||||||
return f"{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(
|
edit = tables.LinkColumn(
|
||||||
"atlas:exam_update", text="Edit", args=[A("pk")], orderable=False
|
"atlas:exam_update", text="Edit", args=[A("pk")], orderable=False
|
||||||
)
|
)
|
||||||
@@ -317,9 +443,8 @@ class CaseCollectionTable(tables.Table):
|
|||||||
|
|
||||||
#differential = tables.ManyToManyColumn(verbose_name="Differential")
|
#differential = tables.ManyToManyColumn(verbose_name="Differential")
|
||||||
|
|
||||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta(SelectionTable.Meta):
|
||||||
model = CaseCollection
|
model = CaseCollection
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = (
|
fields = (
|
||||||
@@ -331,6 +456,7 @@ class CaseCollectionTable(tables.Table):
|
|||||||
)
|
)
|
||||||
sequence = ("view", )#, "series")
|
sequence = ("view", )#, "series")
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
data.prefetch_related(
|
data.prefetch_related(
|
||||||
@@ -340,7 +466,7 @@ class CaseCollectionTable(tables.Table):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
class QuestionSchemaTable(tables.Table):
|
class QuestionSchemaTable(SelectionTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=("atlas:question_schema_detail", {"pk": tables.A("pk")}),
|
linkify=("atlas:question_schema_detail", {"pk": tables.A("pk")}),
|
||||||
verbose_name="QuestionSchema",
|
verbose_name="QuestionSchema",
|
||||||
@@ -353,10 +479,11 @@ class QuestionSchemaTable(tables.Table):
|
|||||||
"atlas:question_schema_delete", text="Delete", args=[A("pk")], orderable=False
|
"atlas:question_schema_delete", text="Delete", args=[A("pk")], orderable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta(SelectionTable.Meta):
|
||||||
model = QuestionSchema
|
model = QuestionSchema
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name", "description", "schema")
|
fields = ("name", "description", "schema")
|
||||||
|
|
||||||
|
|
||||||
def render_schema(self, value, record):
|
def render_schema(self, value, record):
|
||||||
return format_html("<details><summary>Schema</summary>{}</details>", value)
|
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">
|
<div id="add-case-form" class="p-2 border rounded">
|
||||||
<form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML">
|
<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>
|
||||||
{% csrf_token %}
|
|
||||||
<label for="case">Select Case to add:</label>
|
{% include 'atlas/partials/case_search_widget.html' %}
|
||||||
<select name="case" id="case">
|
|
||||||
{% for case in cases %}
|
<div class="mt-2">
|
||||||
<option value="{{ case.id }}">{{ case.title }}</option>
|
<button class="btn btn-sm btn-secondary" _="on click remove #add-case-form">Close</button>
|
||||||
{% endfor %}
|
</div>
|
||||||
</select>
|
</div>
|
||||||
<button type="submit">Add</button>
|
|
||||||
<button _="on click remove #add-case-form">Cancel</button>
|
|
||||||
</form>
|
|
||||||
</span
|
|
||||||
+115
-12
@@ -5,19 +5,122 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block navigation %}
|
{% block navigation %}
|
||||||
Atlas:
|
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
<a href="{% url 'atlas:collection_view' %}">Collections</a> /
|
<nav class="navbar navbar-expand-lg navbar-dark submenu">
|
||||||
<a href="{% url 'atlas:case_view' %}?sort=-created_date">Cases</a> /
|
<div class="container-fluid">
|
||||||
<a href="{% url 'atlas:series_view' %}">Series</a> /
|
<a class="navbar-brand" href="{% url 'atlas:index' %}">Atlas</a>
|
||||||
<a href="{% url 'atlas:categories_list' %}">Categories</a> /
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
{% comment %} <a href="{% url 'atlas:resource_view' %}">Resources</a> / {% endcomment %}
|
<span class="navbar-toggler-icon"></span>
|
||||||
<a href="{% url 'atlas:case_create' %}" title="Create a new atlas case">Create Case</a> /
|
</button>
|
||||||
<a href="{% url 'atlas:series_create' %}" title="Create a new image series">Create Series</a> /
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
<a href="{% url 'atlas:resource_view' %}" title="Resources">Resources</a> /
|
<ul class="navbar-nav">
|
||||||
<a href="{% url 'atlas:user_uploads' %}" title="View unimported uploads">Uploads</a> /
|
<li class="nav-item dropdown">
|
||||||
<a href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas">Question Schemas</a> /
|
<a class="nav-link d-inline" href="{% url 'atlas:collection_view' %}"><i class="bi bi-collection me-1" aria-hidden="true"></i>Collections</a>
|
||||||
<a href="{% url 'atlas:help' %}" title="View the atlas help pages">Help</a>
|
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<span class="visually-hidden">Toggle Dropdown</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{% url 'atlas:user_collections' %}"><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 %}
|
{% endif %}
|
||||||
{% comment %} </br>
|
{% comment %} </br>
|
||||||
Questions by:
|
Questions by:
|
||||||
|
|||||||
@@ -1,13 +1,168 @@
|
|||||||
<div class="atlas {% if case.scrapped %}atlas-scrapped{% endif %}">
|
{% load partials %}
|
||||||
<div><a href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">View case in OHIF</a> <a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">(new tab)</a>
|
|
||||||
|
|
||||||
<div class="date">
|
{% partialdef case-series %}
|
||||||
{{ case.created_date|date:"d/m/Y" }}
|
{% 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>
|
||||||
<div class="id">
|
{% endif %}
|
||||||
ID: {{ case.id }}
|
<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>
|
</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>
|
<p class="pre-whitespace"><b>Description:</b> {{ case.description }}</p>
|
||||||
|
|
||||||
@@ -20,11 +175,32 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="pre-whitespace"><b>History:</b> {{ case.history }}</p>
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-12 col-md-12">
|
||||||
<p class="pre-whitespace"><b>Discussion:</b> {{ case.discussion }}</p>
|
<div class="card h-100">
|
||||||
|
<div class="card-header">History</div>
|
||||||
<p class="pre-whitespace"><b>Report:</b> {{ case.report }}</p>
|
<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 %}
|
<p class="pre-whitespace"><b>Resource</b><br/>{% for caseresource in case.caseresource_set.all %}
|
||||||
<a href='{% url "atlas:resource_detail" caseresource.resource.pk %}'>{{caseresource.resource.name}}</a>
|
<a href='{% url "atlas:resource_detail" caseresource.resource.pk %}'>{{caseresource.resource.name}}</a>
|
||||||
{% if caseresource.pre_review %}
|
{% if caseresource.pre_review %}
|
||||||
@@ -37,40 +213,35 @@
|
|||||||
|
|
||||||
<div class="pre-whitespace multi-image-block"><b>Series:</b>
|
<div class="pre-whitespace multi-image-block"><b>Series:</b>
|
||||||
<form>
|
<form>
|
||||||
{% for series in case.series.all %}
|
<div class="d-flex flex-wrap gap-2 align-items-start" id="case-series-row">
|
||||||
<span class="series-block">
|
{% partial case-series %}
|
||||||
<span>
|
</div>
|
||||||
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
|
|
||||||
<a href="{% url 'atlas:series_detail' pk=series.pk %}">
|
|
||||||
{{series.get_block}}
|
|
||||||
</a>
|
|
||||||
<br>
|
|
||||||
<input type="checkbox" name="series-ids" value="{{series.pk}}">
|
|
||||||
<span class="series-block-popup-link">
|
|
||||||
<a href="#"
|
|
||||||
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
</form>
|
</form>
|
||||||
<span>
|
<span>
|
||||||
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Add new series</a><br />
|
<button type="button"
|
||||||
<a href="{% url 'atlas:user_uploads_case' case_id=case.pk %}">Import new uploads</a><br />
|
class="btn btn-info"
|
||||||
<details>
|
onclick="window.location.href='{% url 'atlas:user_uploads_case' case_id=case.pk %}'">
|
||||||
<summary>+</summary>
|
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 %}"
|
<button hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
|
||||||
title="order dicom by slice location"
|
title="order dicom by slice location"
|
||||||
hx-target="this"
|
hx-target="#series-action-results"
|
||||||
hx-swap="outerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-confirm="This will reorder all case series based upon slice location"
|
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>
|
||||||
<button hx-post="{% url 'atlas:combine_series' %}"
|
<button hx-post="{% url 'atlas:combine_series' %}"
|
||||||
title="merge series"
|
title="merge series"
|
||||||
hx-include="[name='series-ids']"
|
hx-include="[name='series-ids']"
|
||||||
hx-target="#series-merge-results"
|
hx-target="#series-action-results"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-confirm="This will merge all selected series into one"
|
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 %}"
|
<button hx-post="{% url 'atlas:use_dates_as_descriptions' case.pk %}"
|
||||||
title="merge series"
|
title="merge series"
|
||||||
hx-include="[name='series-ids']"
|
hx-include="[name='series-ids']"
|
||||||
hx-target="#series-merge-results"
|
hx-target="#series-action-results"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-confirm="This will use the dicom date as the series description"
|
hx-confirm="This will use the dicom date as the series description"
|
||||||
>
|
>
|
||||||
Use dates as description
|
Use dates as description
|
||||||
</button>
|
</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/>
|
<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>
|
</details>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
@@ -95,7 +285,7 @@
|
|||||||
href="{% url 'atlas:case_scrap' pk=case.pk %}">(toggle)</a> {% endcomment %}
|
href="{% url 'atlas:case_scrap' pk=case.pk %}">(toggle)</a> {% endcomment %}
|
||||||
|
|
||||||
<div class="atlas-answer">
|
<div class="atlas-answer">
|
||||||
<p class="pre-whitespace">
|
<div class="pre-whitespace">
|
||||||
Condition:
|
Condition:
|
||||||
<ul>
|
<ul>
|
||||||
{% for con in case.condition.all %}
|
{% for con in case.condition.all %}
|
||||||
@@ -103,23 +293,46 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<div>
|
<div>
|
||||||
<details>
|
<details open>
|
||||||
<summary><b>Findings</b></summary>
|
<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 %}
|
{% for finding in series.findings.all %}
|
||||||
<div class="finding-box">
|
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}">
|
||||||
<span>
|
<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>
|
</span>
|
||||||
{% if finding.findings.all %}
|
{% if finding.findings %}
|
||||||
<span>
|
<span>
|
||||||
Findings: {% for f in finding.findings.all %}
|
Findings: {% for f in finding.findings.all %}
|
||||||
{{f.get_link}},
|
{{f.get_link}},
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% 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>
|
<span>
|
||||||
Structure: {% for s in finding.structures.all %}
|
Structure: {% for s in finding.structures.all %}
|
||||||
{{s.get_link}},
|
{{s.get_link}},
|
||||||
@@ -137,6 +350,75 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
No series associated with case.
|
No series associated with case.
|
||||||
{% endif %}
|
{% 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>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -149,35 +431,54 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</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>
|
<b>Collections:</b>
|
||||||
<button
|
|
||||||
hx-get="{% url 'atlas:case_collection_form' case.pk %}"
|
{% if can_edit %}
|
||||||
hx-target="#collection-form">
|
<button
|
||||||
Add collection</button>
|
hx-get="{% url 'atlas:case_collection_form' case.pk %}"
|
||||||
|
hx-target="#collection-form">
|
||||||
|
Add to collection</button>
|
||||||
|
{% endif %}
|
||||||
<div id="collection-form"></div>
|
<div id="collection-form"></div>
|
||||||
<ul>
|
<ul>
|
||||||
{% for collection in case.casecollection_set.all %}
|
{% for collection in case.casecollection_set.all %}
|
||||||
<li><a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{collection.name}}</a></li>
|
<li id="collection-{{ collection.pk }}">
|
||||||
|
<a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{ collection.name }}</a>
|
||||||
|
{% if can_edit %}
|
||||||
|
<button
|
||||||
|
class="btn btn-danger btn-sm remove-button"
|
||||||
|
hx-post="{% url 'atlas:remove_case_from_collection' case.pk collection.pk %}"
|
||||||
|
hx-target="#collection-{{ collection.pk }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Are you sure you want to remove this case from the collection?">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
{% 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>Previous case:</b> {{ case.previous_case.get_link }}</p>
|
||||||
<p class="pre-whitespace"><b>Next case:</b> {{ case.next_case.get_link }}</p>
|
<p class="pre-whitespace"><b>Next case:</b>
|
||||||
|
{% if case.next_case %}
|
||||||
|
{{ case.next_case.get_link }}
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'atlas:case_clone_next' case.pk %}">Clone and add next case</a>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
<div>
|
<div>
|
||||||
{% include 'question_notes.html' %}
|
{% include 'question_notes.html' %}
|
||||||
|
|
||||||
@@ -188,3 +489,312 @@
|
|||||||
|
|
||||||
<p><b>Author(s):</b> {% for author in case.author.all %} <a
|
<p><b>Author(s):</b> {% for author in case.author.all %} <a
|
||||||
href="{% url 'atlas:author_detail' pk=author.pk %}">{{author}}</a>, {% endfor %}</p>
|
href="{% url 'atlas:author_detail' pk=author.pk %}">{{author}}</a>, {% endfor %}</p>
|
||||||
|
|
||||||
|
|
||||||
|
<p><b>Case size:</b> {{ case.get_total_series_images_size | filesizeformat }}</p>
|
||||||
|
|
||||||
|
<div class="modal fade" id="findingModal" tabindex="-1" aria-labelledby="findingModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="findingModalLabel">Finding Details</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="findingModalBody">
|
||||||
|
<!-- Finding details will be loaded here dynamically -->
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 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" %}
|
{% extends "atlas/base.html" %}
|
||||||
<!-- {% load static from static %} -->
|
<!-- {% load static from static %} -->
|
||||||
|
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
{% block css %}
|
{% block css %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block js %}
|
{% block js %}
|
||||||
@@ -50,8 +51,70 @@
|
|||||||
$('#add_more_resource').click(() => { add_resource_input_form() });
|
$('#add_more_resource').click(() => { add_resource_input_form() });
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
{% comment %} document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
// Select all text input fields and textareas
|
||||||
|
const textFields = document.querySelectorAll("input[type='text'], textarea");
|
||||||
|
|
||||||
|
textFields.forEach((field) => {
|
||||||
|
// Create a clear button for each text field or textarea
|
||||||
|
const clearButton = document.createElement("button");
|
||||||
|
clearButton.type = "button";
|
||||||
|
clearButton.className = "btn btn-danger btn-sm clear-input-button";
|
||||||
|
clearButton.textContent = "×";
|
||||||
|
|
||||||
|
// Wrap the input field or textarea in a container with position-relative
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "position-relative";
|
||||||
|
field.parentNode.insertBefore(wrapper, field);
|
||||||
|
wrapper.appendChild(field);
|
||||||
|
wrapper.appendChild(clearButton);
|
||||||
|
|
||||||
|
// Function to toggle the visibility of the clear button
|
||||||
|
const toggleClearButton = () => {
|
||||||
|
if (field.value.trim() !== "") {
|
||||||
|
clearButton.classList.add("show"); // Add the "show" class
|
||||||
|
} else {
|
||||||
|
clearButton.classList.remove("show"); // Remove the "show" class
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
toggleClearButton();
|
||||||
|
|
||||||
|
// Add event listeners to monitor changes in the input field or textarea
|
||||||
|
field.addEventListener("input", toggleClearButton);
|
||||||
|
|
||||||
|
// Add click event to clear the field or textarea
|
||||||
|
clearButton.addEventListener("click", function () {
|
||||||
|
field.value = ""; // Clear the field
|
||||||
|
toggleClearButton(); // Update the button visibility
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
{% endcomment %}
|
||||||
|
</script>
|
||||||
|
{% comment %} <style>
|
||||||
|
/* Ensure the parent container is positioned relatively */
|
||||||
|
.position-relative {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the clear button */
|
||||||
|
.clear-input-button {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: none; /* Hidden by default */
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show the clear button only when the input field or textarea is hovered and contains text */
|
||||||
|
.position-relative:hover .clear-input-button.show {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style> {% endcomment %}
|
||||||
<!-- {{ form.media }} -->
|
<!-- {{ form.media }} -->
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -59,6 +122,8 @@
|
|||||||
{% include "atlas/question_link_header.html" %}
|
{% include "atlas/question_link_header.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<h2>Submit Case</h2>
|
<h2>Submit Case</h2>
|
||||||
<details>
|
<details>
|
||||||
<summary>Instructions</summary>
|
<summary>Instructions</summary>
|
||||||
@@ -68,74 +133,87 @@
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
{% if form.collection %}
|
{% if form.collection %}
|
||||||
<div class="alert alert-info" role="alert">Creating a case in the collection: <a href="{% url 'atlas:collection_detail' pk=form.collection.pk %}">{{form.collection.name}}</a></div>
|
<div class="alert alert-info" role="alert">Creating a case in the collection: <a href="{% url 'atlas:collection_detail' pk=form.collection.pk %}">{{form.collection.name}}</a></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form">
|
<form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form">
|
||||||
{% csrf_token %}
|
{% crispy form form.helper %}
|
||||||
|
|
||||||
{{ form }}
|
{% comment %} {% crispy series_formset %} {% endcomment %}
|
||||||
<h3>Series:</h3>
|
|
||||||
Add image sets here. These can only be added once created (they can also be added to cases on creation).
|
{% comment %} {{ form }} {% endcomment %}
|
||||||
<input type="button" value="Add More Series" id="add_more_series">
|
|
||||||
<div id="series_formset" class="list_formset">
|
|
||||||
<ol>
|
|
||||||
{% for form in series_formset %}
|
<!-- Differential Collapsible Panel -->
|
||||||
<li class="no-error series-formset">
|
<div class="card my-3 bg-dark text-white">
|
||||||
{{form.non_field_errors}}
|
<div class="card-header">
|
||||||
{{form.errors}}
|
<a class="collapsed text-white" data-bs-toggle="collapse" href="#differentialPanel" role="button" aria-expanded="false" aria-controls="differentialPanel">
|
||||||
{{ form }}
|
<h3 class="d-inline">Differential</h3>
|
||||||
</li>
|
<span class="ms-2">(click to expand/collapse)</span>
|
||||||
{% endfor %}
|
</a>
|
||||||
</ol>
|
</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>
|
</div>
|
||||||
{{ series_formset.management_form }}
|
</div>
|
||||||
<h3>Differential:</h3>
|
</div>
|
||||||
Add differential here.
|
|
||||||
<input type="button" value="Add Another Differential" id="add_more_differential">
|
<!-- Resource Collapsible Panel -->
|
||||||
<div id="casedifferential_formset" class="list_formset">
|
<div class="card my-3 bg-dark text-white">
|
||||||
<ol>
|
<div class="card-header">
|
||||||
{% for form in casedifferential_formset %}
|
<a class="collapsed text-white" data-bs-toggle="collapse" href="#resourcePanel" role="button" aria-expanded="false" aria-controls="resourcePanel">
|
||||||
<li class="no-error casedifferential_formset">
|
<h3 class="d-inline">Resource</h3>
|
||||||
{{form.non_field_errors}}
|
<span class="ms-2">(click to expand/collapse)</span>
|
||||||
{{form.errors}}
|
</a>
|
||||||
{{ form }}
|
</div>
|
||||||
</li>
|
<div class="collapse" id="resourcePanel">
|
||||||
{% endfor %}
|
<input type="button" value="Add Another Resource" id="add_more_resource" class="btn btn-sm btn-primary float-end">
|
||||||
</ol>
|
<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>
|
</div>
|
||||||
{{ casedifferential_formset.management_form }}
|
</div>
|
||||||
<h3>Resource:</h3>
|
</div>
|
||||||
Add resource here. Resource can be anything that is useful for the case (videos, pdf, etc). This can either be avalaible before attempting the case (such a for a pre reading list) or after.
|
|
||||||
<input type="button" value="Add Another Resource" id="add_more_resource">
|
|
||||||
<div id="caseresource_formset" class="list_formset">
|
|
||||||
<ol>
|
|
||||||
{% for form in caseresource_formset %}
|
|
||||||
<li class="no-error caseresource_formset">
|
|
||||||
{{form.non_field_errors}}
|
|
||||||
{{form.errors}}
|
|
||||||
{{ form }}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
{{ caseresource_formset.management_form }}
|
|
||||||
<br/>
|
<br/>
|
||||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||||
</form>
|
</form>
|
||||||
<div id="empty_series_form" style="display:none">
|
<div id="empty_series_form" style="display:none">
|
||||||
<li class='no_error series-formset'>
|
<li class='no_error series-formset'>
|
||||||
{{ series_formset.empty_form }}
|
{{ series_formset.empty_form | crispy}}
|
||||||
</li>
|
</li>
|
||||||
</div>
|
</div>
|
||||||
<div id="empty_casedifferential_form" style="display:none">
|
<div id="empty_casedifferential_form" style="display:none">
|
||||||
<li class='no_error casedifferential_formset'>
|
<li class='no_error casedifferential_formset'>
|
||||||
{{ casedifferential_formset.empty_form }}
|
{{ casedifferential_formset.empty_form | crispy}}
|
||||||
</li>
|
</li>
|
||||||
</div>
|
</div>
|
||||||
<div id="empty_caseresource_form" style="display:none">
|
<div id="empty_caseresource_form" style="display:none">
|
||||||
<li class='no_error caseresource_formset'>
|
<li class='no_error caseresource_formset'>
|
||||||
{{ caseresource_formset.empty_form }}
|
{{ caseresource_formset.empty_form | crispy}}
|
||||||
</li>
|
</li>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<details class="case-details">
|
||||||
|
<summary>Cases</summary>
|
||||||
|
<input type="text" placeholder="Search Cases..."
|
||||||
|
_="on keyup
|
||||||
|
if the event's key is 'Escape'
|
||||||
|
set my value to ''
|
||||||
|
trigger keyup
|
||||||
|
else
|
||||||
|
log 'test' then
|
||||||
|
show .case-option in #case-options when its textContent contains my value
|
||||||
|
">
|
||||||
|
<ul id="case-options">
|
||||||
|
{% for group_name, group_choices, group_index in widget.optgroups reversed %}
|
||||||
|
{% for widget in group_choices reversed %}
|
||||||
|
|
||||||
|
{% with widget.value.instance as case %}
|
||||||
|
{% if not forloop.parentloop.first %}
|
||||||
|
<li class="case-option">
|
||||||
|
<span class="d-flex flex-row ">
|
||||||
|
<span class="p-2">{{case.id}}</span>
|
||||||
|
<span class="p-2">{{case.title}}</span>
|
||||||
|
<span class="p-2">{{case.created_date}}</span>
|
||||||
|
<button class="p-2 btn btn-sm border border-dark rounded text-primary"
|
||||||
|
type="button"
|
||||||
|
_="on click set (next <select/>)'s value to '{{case.pk}}' then remove @open from .case-details then go to top of (next <select/>)'s parent"
|
||||||
|
>select</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
|
||||||
|
<optgroup label="{{ group_name }}">{% endif %}{% for widget in group_choices %}
|
||||||
|
{% include widget.template_name %}{% endfor %}{% if group_name %}
|
||||||
|
</optgroup>{% endif %}{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
@@ -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" %}
|
{% extends "atlas/exams.html" %}
|
||||||
<!-- {% load static from static %} -->
|
<!-- {% load static from static %} -->
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
{% block css %}
|
{% 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 %}
|
{% endblock %}
|
||||||
{% block js %}
|
{% block js %}
|
||||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
<!--<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">
|
<form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{{ form }}
|
{% crispy form form.helper %}
|
||||||
<h3>Cases:</h3>
|
{% 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.
|
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" 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" />
|
<input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" />
|
||||||
@@ -54,13 +89,14 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
{{ case_formset.management_form }}
|
{{ case_formset.management_form }}
|
||||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
|
||||||
</form>
|
</form>
|
||||||
<div id="empty_case_form" style="display:none">
|
<div id="empty_case_form" style="display:none">
|
||||||
<li class='no_error case-formset'>
|
<li class='no_error case-formset'>
|
||||||
{{ case_formset.empty_form }}
|
{{ case_formset.empty_form }}
|
||||||
</li>
|
</li>
|
||||||
</div>
|
</div> {% endcomment %}
|
||||||
|
{% comment %} <input type="submit" class="submit-button" value="Submit" name="submit"> {% endcomment %}
|
||||||
|
</form>
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(() => {
|
$(document).ready(() => {
|
||||||
sortable('.sortable');
|
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>
|
<li><a href="{% url 'atlas:pathological_process_view' %}">Pathological Process</li></a>
|
||||||
</ul>
|
</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 %}
|
{% if request.user.is_staff %}
|
||||||
<p>Manage <a href="{% url 'generic:examination_view' %}">Examinations</a>
|
<p>Manage <a href="{% url 'generic:examination_view' %}">Examinations</a>
|
||||||
</p>
|
</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 %}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user