Compare commits
909
Commits
d3669e8316
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdcdeb761b | ||
|
|
8e1d6fdd60 | ||
|
|
479e37f346 | ||
|
|
0afed5a983 | ||
|
|
f51faff534 | ||
|
|
f27936b2fb | ||
|
|
685f66854e | ||
|
|
8ae33e32ee | ||
|
|
d322806b80 | ||
|
|
2c56fcd4e7 | ||
|
|
66cca1a512 | ||
|
|
3a3ec75b1d | ||
|
|
89bf6eedc0 | ||
|
|
fa98541e31 | ||
|
|
50b80fd0c7 | ||
|
|
7bd1475daf | ||
|
|
b13261c302 | ||
|
|
94b08c5c21 | ||
|
|
8f7ec905a6 | ||
|
|
5139346d28 | ||
|
|
c6fe4df763 | ||
|
|
5414a5b4f9 | ||
|
|
5a73409aad | ||
|
|
1b2715642a | ||
|
|
5ae51a7e71 | ||
|
|
edb4e071f4 | ||
|
|
e56c1f2d0f | ||
|
|
daebdc1844 | ||
|
|
5a2baaeab8 | ||
|
|
5ce4a67b31 | ||
|
|
89963d6257 | ||
|
|
2744758aa8 | ||
|
|
bb6b4c625c | ||
|
|
55fcf71431 | ||
|
|
277322059c | ||
|
|
d8bacfefa9 | ||
|
|
856fb0bf08 | ||
|
|
ce323c4973 | ||
|
|
a60982c30a | ||
|
|
1296089607 | ||
|
|
077c858024 | ||
|
|
12014fbb23 | ||
|
|
17043722e1 | ||
|
|
1317d6a438 | ||
|
|
2425e62caf | ||
|
|
c34dcdfd5e | ||
|
|
79ba4db4bf | ||
|
|
d07d88bdbf | ||
|
|
d32e2a5231 | ||
|
|
5ad66cb951 | ||
|
|
4e29941ae4 | ||
|
|
2e11e40b7c | ||
|
|
ed77af215f | ||
|
|
28204d7ec1 | ||
|
|
0110a6c4d4 | ||
|
|
24d9594446 | ||
|
|
56cd257474 | ||
|
|
86a1a935e1 | ||
|
|
dbb762bb09 | ||
|
|
6ebe74d785 | ||
|
|
21eabcf3ec | ||
|
|
1ad87d8b31 | ||
|
|
8003dcd714 | ||
|
|
b5d844e3aa | ||
|
|
1b3b21a6f5 | ||
|
|
a573ed78a4 | ||
|
|
c52cf46d65 | ||
|
|
1f0877964b | ||
|
|
c50ace771e | ||
|
|
35ab7443d2 | ||
|
|
fc9f2ed6af | ||
|
|
afd4cc3b87 | ||
|
|
aaee3577ad | ||
|
|
96d5fa4a2e | ||
|
|
6bb9441748 | ||
|
|
e670adf2d0 | ||
|
|
145aa442f0 | ||
|
|
ddfb591921 | ||
|
|
e78a69367f | ||
|
|
d89df3d0a6 | ||
|
|
56bc17d1c7 | ||
|
|
8c0d97d36d | ||
|
|
161da980cf | ||
|
|
9919af2b20 | ||
|
|
390ccc0234 | ||
|
|
a9c417d993 | ||
|
|
0f11f0800e | ||
|
|
3d445fe12e | ||
|
|
2e854c43b6 | ||
|
|
46518a45c1 | ||
|
|
213ee17328 | ||
|
|
92d4ece061 | ||
|
|
74f1ad4b2c | ||
|
|
0412aa82e4 | ||
|
|
f2a52bed23 | ||
|
|
1d85666127 | ||
|
|
40cea43578 | ||
|
|
6aada9f991 | ||
|
|
01d44bc8e4 | ||
|
|
98df8ec176 | ||
|
|
4cc758bc66 | ||
|
|
060333c006 | ||
|
|
d74cfe73e0 | ||
|
|
c1005712e0 | ||
|
|
9592568dbb | ||
|
|
ee4fe2b85b | ||
|
|
6368f525e1 | ||
|
|
797c002e8b | ||
|
|
428db3e55c | ||
|
|
fd3e7280fb | ||
|
|
75be040783 | ||
|
|
734460777b | ||
|
|
f85aefe5c7 | ||
|
|
a7a21fab9c | ||
|
|
bf9190d177 | ||
|
|
c9133c2175 | ||
|
|
8f74b66b97 | ||
|
|
679ce2fd00 | ||
|
|
f2330c8f9f | ||
|
|
da1dd79ef2 | ||
|
|
f0ff3e4bfa | ||
|
|
46f8fc6256 | ||
|
|
dad108d256 | ||
|
|
69da0d6170 | ||
|
|
5e3eb9f985 | ||
|
|
11d4bf0e71 | ||
|
|
3fe3ae5d16 | ||
|
|
437cb66e1f | ||
|
|
bd87c59870 | ||
|
|
d3c7512aa9 | ||
|
|
dcddeb8e15 | ||
|
|
e361e01af8 | ||
|
|
dbb68a6801 | ||
|
|
64138c9038 | ||
|
|
a3ba14f4b5 | ||
|
|
dcf2ad4af7 | ||
|
|
0908608280 | ||
|
|
064f039a24 | ||
|
|
c0845287da | ||
|
|
32c6dc6f65 | ||
|
|
b9f511f636 | ||
|
|
dcc347793d | ||
|
|
63ea4cdf23 | ||
|
|
c55797636a | ||
|
|
e016175d2b | ||
|
|
682bde82b9 | ||
|
|
53109e7cb2 | ||
|
|
3065a279b1 | ||
|
|
2f8b61a4be | ||
|
|
2b10f2e73c | ||
|
|
20e24808fc | ||
|
|
714857e3d2 | ||
|
|
7019ac2ef8 | ||
|
|
16fea039d7 | ||
|
|
fc38776ce2 | ||
|
|
d132625419 | ||
|
|
5614961980 | ||
|
|
f3af6d47ee | ||
|
|
b09fbee2c9 | ||
|
|
4a104be71f | ||
|
|
dbf0fc262c | ||
|
|
026350f9d4 | ||
|
|
b34970ed7f | ||
|
|
867d92bff9 | ||
|
|
4d1423134a | ||
|
|
410b71f07f | ||
|
|
e51e8358bb | ||
|
|
5f69cdcaf9 | ||
|
|
93e24bf855 | ||
|
|
1c76e0696c | ||
|
|
ff00efecf7 | ||
|
|
a9946961f1 | ||
|
|
d5e473d692 | ||
|
|
75e3bca503 | ||
|
|
1775b11e40 | ||
|
|
d77c5149f0 | ||
|
|
c2d8376e22 | ||
|
|
ab9847e22e | ||
|
|
bbf7542451 | ||
|
|
e6a20bb851 | ||
|
|
7ed0a8f378 | ||
|
|
c8d05818a4 | ||
|
|
b6716c9ae1 | ||
|
|
e368d0c7d1 | ||
|
|
b9cbfd20c1 | ||
|
|
5ce337c7c4 | ||
|
|
f4d990676a | ||
|
|
4f61e43b8c | ||
|
|
c947cba5a7 | ||
|
|
0cf655325c | ||
|
|
89e89cef73 | ||
|
|
c94d2fb1a7 | ||
|
|
e28bf641b7 | ||
|
|
59956670df | ||
|
|
d2a4ee9f70 | ||
|
|
c7fd99f6f3 | ||
|
|
cf4cb32c5e | ||
|
|
f4c2c4bdaf | ||
|
|
8da8f27433 | ||
|
|
c3131564e6 | ||
|
|
0bda5e2c41 | ||
|
|
6b9c7be633 | ||
|
|
35887fbcaf | ||
|
|
f9a9b9bb38 | ||
|
|
916ac3b69c | ||
|
|
7ac8517062 | ||
|
|
22c5576fc9 | ||
|
|
52e6e79c1b | ||
|
|
3455dc3855 | ||
|
|
2b9f3e3b68 | ||
|
|
e55a3bf36a | ||
|
|
b24eac862b | ||
|
|
84b032addb | ||
|
|
d046b7a151 | ||
|
|
aaa3ee6313 | ||
|
|
c1e351c7db | ||
|
|
863d65b4b3 | ||
|
|
26a73d1d3b | ||
|
|
d12933e62a | ||
|
|
771abf62e2 | ||
|
|
859042bf96 | ||
|
|
6574c29a70 | ||
|
|
9edac288f5 | ||
|
|
ab7dcaed76 | ||
|
|
2aff9516ac | ||
|
|
5d37cad48d | ||
|
|
6575c50507 | ||
|
|
74cdf14af3 | ||
|
|
9ef4b2dac1 | ||
|
|
2e35fa7036 | ||
|
|
6be7398b42 | ||
|
|
2478de6b98 | ||
|
|
7479b44f53 | ||
|
|
89c8cd565f | ||
|
|
f63f3b3687 | ||
|
|
8201f91697 | ||
|
|
082a260757 | ||
|
|
5bd1338b5e | ||
|
|
7062723433 | ||
|
|
cc100dba89 | ||
|
|
64aa2974ee | ||
|
|
47cfa79083 | ||
|
|
5c217266c4 | ||
|
|
68dfcdfa7e | ||
|
|
e894e02445 | ||
|
|
f7cc51c221 | ||
|
|
56a644c5c1 | ||
|
|
4a787aa3eb | ||
|
|
7031942198 | ||
|
|
585a19013a | ||
|
|
61a83ed836 | ||
|
|
bf12204c51 | ||
|
|
29165811d1 | ||
|
|
02ad8c61e5 | ||
|
|
70ea3788ca | ||
|
|
3772846e55 | ||
|
|
1876a7f845 | ||
|
|
d4f25de9d3 | ||
|
|
0b1f842e31 | ||
|
|
29f9b724b0 | ||
|
|
1f1b324cfe | ||
|
|
474ad205f6 | ||
|
|
1f806d2e60 | ||
|
|
20987274af | ||
|
|
d82551674b | ||
|
|
0d1318564c | ||
|
|
9a8a1a1b57 | ||
|
|
9df74edc8c | ||
|
|
3db2c1a768 | ||
|
|
dc479cafd0 | ||
|
|
b02120d5e7 | ||
|
|
be02269af6 | ||
|
|
5843547a05 | ||
|
|
36a77981c8 | ||
|
|
95793c78c9 | ||
|
|
37e1bf29a3 | ||
|
|
b864f3d3fd | ||
|
|
e82b353ad9 | ||
|
|
854f265623 | ||
|
|
bebac18625 | ||
|
|
068dd3df80 | ||
|
|
9bae94a595 | ||
|
|
df7f56f34c | ||
|
|
f47f408cce | ||
|
|
c0870a8765 | ||
|
|
113424d1e4 | ||
|
|
e007e35f4f | ||
|
|
e162e60f44 | ||
|
|
5c50ee75da | ||
|
|
90c8f0b60d | ||
|
|
463a741185 | ||
|
|
f7b6ec1447 | ||
|
|
3e03413047 | ||
|
|
126d186ad1 | ||
|
|
a749373a21 | ||
|
|
d9331fe279 | ||
|
|
3e936e494a | ||
|
|
5a050fb36d | ||
|
|
2d48a6bad6 | ||
|
|
d4f0c05341 | ||
|
|
7b52cab180 | ||
|
|
ffb730e9c8 | ||
|
|
1b199150e5 | ||
|
|
bf7f88ac6e | ||
|
|
1d19afa72d | ||
|
|
bd0c71140c | ||
|
|
d6487eae88 | ||
|
|
06fe73e2a3 | ||
|
|
ec0bad02c6 | ||
|
|
de5b6c36f3 | ||
|
|
90e22435a5 | ||
|
|
a82e797bae | ||
|
|
97db951c79 | ||
|
|
f70ecb4648 | ||
|
|
a792f0c7cd | ||
|
|
13d73020ee | ||
|
|
4d6d535ca7 | ||
|
|
fbb2a2cc18 | ||
|
|
20b4d6b6af | ||
|
|
d7ed8881d8 | ||
|
|
3ae6ce53bc | ||
|
|
5a5eaa04a2 | ||
|
|
d75d2be547 | ||
|
|
f65e78b959 | ||
|
|
7526f6a902 | ||
|
|
b17774aa7c | ||
|
|
077bd3ed87 | ||
|
|
73a364fd44 | ||
|
|
8733f59882 | ||
|
|
64d4958744 | ||
|
|
9e0e66bcd9 | ||
|
|
ec4dcdba2b | ||
|
|
4512b799a6 | ||
|
|
d313cd083c | ||
|
|
8bbb6b741d | ||
|
|
d00a7f9cf6 | ||
|
|
70602e6f71 | ||
|
|
b35057d059 | ||
|
|
1cadbab742 | ||
|
|
3ea51f976d | ||
|
|
d0686dbe17 | ||
|
|
e391f4f943 | ||
|
|
65208ae7ad | ||
|
|
82bea5ff05 | ||
|
|
71c5cc6c24 | ||
|
|
6c7a37ca4a | ||
|
|
4788d56dec | ||
|
|
42bffc0c07 | ||
|
|
996ef4877c | ||
|
|
b87db68329 | ||
|
|
b87c0a776c | ||
|
|
af9bfabeed | ||
|
|
2c482216c1 | ||
|
|
61c768b80d | ||
|
|
5d0ff3373d | ||
|
|
c14ad2dcf7 | ||
|
|
b503558ee4 | ||
|
|
c177d21688 | ||
|
|
2a1db54a17 | ||
|
|
b1ec4b173e | ||
|
|
4c1e0c3d2a | ||
|
|
4e6f72e045 | ||
|
|
ed3bd3433b | ||
|
|
2c6d0f2613 | ||
|
|
7cac5aa60f | ||
|
|
a5a4084994 | ||
|
|
7caaa425ed | ||
|
|
904c023bf1 | ||
|
|
e2e0e374e6 | ||
|
|
af2dcd95b2 | ||
|
|
6ff8706fa4 | ||
|
|
12e3d47600 | ||
|
|
b3f5684e69 | ||
|
|
166b0a0282 | ||
|
|
ed6418efad | ||
|
|
0c6d33e26a | ||
|
|
b43d441da1 | ||
|
|
b7cf022622 | ||
|
|
419063fdeb | ||
|
|
a6205c95cb | ||
|
|
c00e08d63e | ||
|
|
a3ac91437f | ||
|
|
d7f3ebf950 | ||
|
|
83d1b48213 | ||
|
|
65db1220d8 | ||
|
|
3cdfba0acf | ||
|
|
d899d0d86a | ||
|
|
44d23801b2 | ||
|
|
6e3ab7de41 | ||
|
|
a51a5c8d54 | ||
|
|
c61f92ae9e | ||
|
|
6e30fcc19f | ||
|
|
36da045d71 | ||
|
|
0b6661c018 | ||
|
|
64103cac1e | ||
|
|
990f4534bd | ||
|
|
4382c6070e | ||
|
|
8ba9543c89 | ||
|
|
f2eaf6197e | ||
|
|
b19eefa60d | ||
|
|
0718ea82b2 | ||
|
|
fb9952e7aa | ||
|
|
ed1a33ac89 | ||
|
|
eddfb85d7b | ||
|
|
f133fc3fd0 | ||
|
|
2f90a21abe | ||
|
|
98ce450f11 | ||
|
|
6734036477 | ||
|
|
5243100d7e | ||
|
|
fa82d81a96 | ||
|
|
726057b51e | ||
|
|
647081a7d6 | ||
|
|
57b9c712c2 | ||
|
|
4d78a6b63a | ||
|
|
941c599d24 | ||
|
|
2df789735c | ||
|
|
37812921b8 | ||
|
|
4cdddcf288 | ||
|
|
17d3b4206a | ||
|
|
ec03def164 | ||
|
|
f72ec82525 | ||
|
|
90c96333a1 | ||
|
|
19eb10c089 | ||
|
|
2ab5a8e3b5 | ||
|
|
c2f12f384a | ||
|
|
33f32dab8b | ||
|
|
a676d437bb | ||
|
|
ba5b11cce2 | ||
|
|
a6c5b99620 | ||
|
|
666f49f2c2 | ||
|
|
46608a0758 | ||
|
|
cfdf33b9ce | ||
|
|
bdc97922af | ||
|
|
3626f3918e | ||
|
|
49fbff76e9 | ||
|
|
7ec81133d8 | ||
|
|
c67db4a39e | ||
|
|
d2f4c21883 | ||
|
|
1f5931aab2 | ||
|
|
08c3558861 | ||
|
|
add7a1cf38 | ||
|
|
65f08cce6c | ||
|
|
3c8238d737 | ||
|
|
23f33ac7ae | ||
|
|
ab130a08ed | ||
|
|
45e01a78c1 | ||
|
|
25e5ef78ed | ||
|
|
13dd79b508 | ||
|
|
89986ba2e9 | ||
|
|
f9e69e1206 | ||
|
|
e7de6c8970 | ||
|
|
ea139456a8 | ||
|
|
035da12e96 | ||
|
|
3b9942054d | ||
|
|
5761117e68 | ||
|
|
f316dc9bcb | ||
|
|
bdb7b653b6 | ||
|
|
1eeb0d89d9 | ||
|
|
f3702db5f6 | ||
|
|
a6a7d75144 | ||
|
|
14bba93c4d | ||
|
|
f29df840c6 | ||
|
|
10d30cdaa6 | ||
|
|
dc4cf0c3a2 | ||
|
|
99b951b32b | ||
|
|
c2c8ec5b13 | ||
|
|
6d5303cd0d | ||
|
|
f6b990a00a | ||
|
|
3c0deec140 | ||
|
|
e3241b503d | ||
|
|
04174484b1 | ||
|
|
168a4126d3 | ||
|
|
a0850c0030 | ||
|
|
a882a3592b | ||
|
|
8074b56a80 | ||
|
|
2e79df1eae | ||
|
|
9bf68cd136 | ||
|
|
42163448bf | ||
|
|
1066a3fb9f | ||
|
|
3ade162b28 | ||
|
|
f4186bf923 | ||
|
|
5bb2aea8ef | ||
|
|
8911c09c0e | ||
|
|
7342b11b27 | ||
|
|
953295cc4d | ||
|
|
374ee17911 | ||
|
|
18aef751ca | ||
|
|
f362be43ef | ||
|
|
00680c8b9e | ||
|
|
eea3433042 | ||
|
|
8dad4a5a5c | ||
|
|
75d23c0ba9 | ||
|
|
ff2b6f4c65 | ||
|
|
22e5f9a496 | ||
|
|
16865fab03 | ||
|
|
5e14df4836 | ||
|
|
625f6017a6 | ||
|
|
c45e3c8765 | ||
|
|
ee33f62def | ||
|
|
3a5fe9a85c | ||
|
|
efc47bb2e8 | ||
|
|
38d70f8ff0 | ||
|
|
2159b045cd | ||
|
|
4628f39a49 | ||
|
|
90ceff1b02 | ||
|
|
a512c2ba09 | ||
|
|
a61c8529ae | ||
|
|
a667566db6 | ||
|
|
d070adc326 | ||
|
|
048168e87d | ||
|
|
c881da244b | ||
|
|
797eb9c54a | ||
|
|
0a1b21cec6 | ||
|
|
d3d3638c1c | ||
|
|
81e22dfc88 | ||
|
|
7ea43a6f44 | ||
|
|
eb8fe58cf7 | ||
|
|
f149a88bd6 | ||
|
|
d4d747517c | ||
|
|
0ee378484b | ||
|
|
ea583744c9 | ||
|
|
e0d589f4d0 | ||
|
|
1c95526ff9 | ||
|
|
662fc4e06e | ||
|
|
a333ff492d | ||
|
|
bb9741b786 | ||
|
|
75be7c1739 | ||
|
|
015a5d18b5 | ||
|
|
d16041026f | ||
|
|
774a1b8c8a | ||
|
|
8fc42169f3 | ||
|
|
834d91e84d | ||
|
|
ef03a97e9e | ||
|
|
dd7437bc66 | ||
|
|
21d3130fa5 | ||
|
|
89e7e49e31 | ||
|
|
8bceb10a13 | ||
|
|
13a98a1e66 | ||
|
|
9c16f93820 | ||
|
|
1c27d48a36 | ||
|
|
562ad1c0a4 | ||
|
|
fcd77fd799 | ||
|
|
d8f8ef7326 | ||
|
|
f551d071f0 | ||
|
|
9b6fd31cee | ||
|
|
8b7da1554a | ||
|
|
d4247adb13 | ||
|
|
b89ca918fa | ||
|
|
569ddb4343 | ||
|
|
b040dfcd82 | ||
|
|
db51ef23be | ||
|
|
8c796c3048 | ||
|
|
b837c406cf | ||
|
|
42d209af5a | ||
|
|
3d1b9a57f9 | ||
|
|
8d638cc08b | ||
|
|
20a06323d5 | ||
|
|
3928df6d75 | ||
|
|
bf344f20e1 | ||
|
|
e4cfb1f782 | ||
|
|
f44f3bbcfe | ||
|
|
ba0c380f18 | ||
|
|
459b7f9b93 | ||
|
|
378d784435 | ||
|
|
96e88c05a4 | ||
|
|
457fb06dc0 | ||
|
|
debe093bc9 | ||
|
|
086bd8f589 | ||
|
|
71dc50b307 | ||
|
|
c0c12ea9f9 | ||
|
|
429adfc376 | ||
|
|
36a809ea40 | ||
|
|
443d93c35f | ||
|
|
d7682ab31d | ||
|
|
e600b8eb91 | ||
|
|
3e691d774c | ||
|
|
c3a70b079d | ||
|
|
4ffadd3914 | ||
|
|
fa18c33e8a | ||
|
|
057c391995 | ||
|
|
94fbd38634 | ||
|
|
4ecc32f837 | ||
|
|
768e1bf8c5 | ||
|
|
32c4305754 | ||
|
|
465f819973 | ||
|
|
3e1cb4b54f | ||
|
|
b9d86a7639 | ||
|
|
e5fd99f491 | ||
|
|
efeed2a8b1 | ||
|
|
f14ede86d3 | ||
|
|
517c2ebe06 | ||
|
|
c5f9b830eb | ||
|
|
2ab85357a9 | ||
|
|
91cf2cab29 | ||
|
|
34c4c0d830 | ||
|
|
98cef9ea57 | ||
|
|
df0b26de36 | ||
|
|
17330681a2 | ||
|
|
112c96dc00 | ||
|
|
0bea34d406 | ||
|
|
06ceb9c2ce | ||
|
|
8ac292eb0c | ||
|
|
ff6b8a8a48 | ||
|
|
46313cf5bb | ||
|
|
13a23eee12 | ||
|
|
a1bc96f99a | ||
|
|
9fd5e56356 | ||
|
|
a02f274a4e | ||
|
|
d3d0e865c3 | ||
|
|
7163e51335 | ||
|
|
7b47a75657 | ||
|
|
54e1cf4ce0 | ||
|
|
c071f38920 | ||
|
|
03b23b9178 | ||
|
|
00b736d8d6 | ||
|
|
885ab1aad4 | ||
|
|
77edc17732 | ||
|
|
d98305caff | ||
|
|
12bd66d4d8 | ||
|
|
508f8a22a2 | ||
|
|
158db133b3 | ||
|
|
83a009e31b | ||
|
|
00dfaf9888 | ||
|
|
7e4e66a74f | ||
|
|
7a4cf3627d | ||
|
|
4db1f2ca1a | ||
|
|
cbff1317d0 | ||
|
|
0644ed99f4 | ||
|
|
c01000f06e | ||
|
|
2f014ce1a5 | ||
|
|
654d0ae686 | ||
|
|
18dbb27a3e | ||
|
|
4bec329aff | ||
|
|
1e2300bd70 | ||
|
|
7674f29124 | ||
|
|
d39879bbca | ||
|
|
012c86340d | ||
|
|
6f50c53519 | ||
|
|
53aefa29df | ||
|
|
4617a643d2 | ||
|
|
d7afab15e1 | ||
|
|
ad5e7aeed8 | ||
|
|
b4cf5b9d70 | ||
|
|
f156f4c85e | ||
|
|
4ab2e3a15e | ||
|
|
6daa643df8 | ||
|
|
f8874a2556 | ||
|
|
b4f5992986 | ||
|
|
9a3a8b932a | ||
|
|
eb06e63f77 | ||
|
|
a4f4ba464a | ||
|
|
6fafccc907 | ||
|
|
d96dd77cdc | ||
|
|
4e47083fcd | ||
|
|
bf895e516e | ||
|
|
49229b0408 | ||
|
|
997b5374c2 | ||
|
|
9d434ec80d | ||
|
|
af616a9f4a | ||
|
|
d29c23a799 | ||
|
|
0917947a98 | ||
|
|
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 |
@@ -0,0 +1,31 @@
|
||||
# Ignore git and local environments
|
||||
.git
|
||||
.venv
|
||||
venv
|
||||
ENV
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Ignore Python caches and test artifacts
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.mypy_cache/
|
||||
|
||||
# Ignore local media and database files
|
||||
media/
|
||||
media_testanatomy/
|
||||
backups/
|
||||
db.sqlite3
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Ignore frontend dependencies and test suites in build context
|
||||
**/node_modules/
|
||||
rts/node_modules/
|
||||
rts/tests/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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=8080
|
||||
# Pick a non-privileged HTTPS port to avoid conflicts with host installs
|
||||
NGINX_HTTPS_PORT=8444
|
||||
|
||||
# Use Django's file-based email backend for local development so messages
|
||||
# are written to files under the mounted `./logs/emails` directory and are
|
||||
# easy to inspect on the host.
|
||||
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
|
||||
#EMAIL_BACKEND=django.core.mail.backends.filebased.EmailBackend
|
||||
EMAIL_FILE_PATH=/var/log/rad/emails
|
||||
@@ -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
|
||||
+15
-1
@@ -1,4 +1,5 @@
|
||||
media/
|
||||
mediafiles/
|
||||
|
||||
__pycache__
|
||||
.vscode/
|
||||
@@ -9,5 +10,18 @@ venv
|
||||
.env
|
||||
|
||||
*.log
|
||||
log.txt
|
||||
|
||||
temp/
|
||||
temp/
|
||||
|
||||
media_test/*
|
||||
|
||||
# Loki runtime data
|
||||
docker/loki-data/
|
||||
docker/loki-wal/
|
||||
# Local runtime logs collected by Promtail
|
||||
/logs/
|
||||
|
||||
# Compiled assets for DV3D
|
||||
**/dv3d/assets/
|
||||
/static/dv3d/index.js
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# RAD Project — Agent Guidelines
|
||||
|
||||
Django-based radiology education platform. Multi-app, HTMX-driven, PostgreSQL + Redis + Celery stack.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Apps
|
||||
`generic` · `anatomy` · `physics` · `rapids` · `shorts` · `longs` · `sbas` · `wally` · `atlas` · `rcr` · `rota` · `oef`
|
||||
|
||||
- `generic/` — shared base models, views, forms, mixins, decorators, widgets. **Always check here before adding new base classes or utilities.**
|
||||
- `atlas/` — CaseCollection-based learning (DICOM cases, findings, display sets, differentials)
|
||||
- `rad/` — project config (`settings.py`, `urls.py`, `celery.py`, root API)
|
||||
|
||||
### Request/Response Pattern
|
||||
- HTMX-first: `if request.htmx:` returns a partial, else returns a full page.
|
||||
- Full pages extend `base.html`. HTMX partials live in `{app}/templates/{app}/partials/`.
|
||||
- Partials must be standalone fragments — no `{% extends %}`.
|
||||
- HTMX detection via `django_htmx` middleware (`request.htmx`).
|
||||
|
||||
#### HTMX input
|
||||
- When using htmx input for autoupdating search/other triggers triggers do not use changed in the hx-trigger. Using input is sufficient.
|
||||
e.g. instead of
|
||||
hx-trigger="input changed delay:300ms, focus"
|
||||
use
|
||||
hx-trigger="input delay:300ms, focus"
|
||||
|
||||
### API
|
||||
- Django Ninja (not DRF). Routers in `{app}/api.py`, registered in `rad/api.py` at `/api/v1/`.
|
||||
- Use `ModelSchema` with explicit `Meta.fields` lists (avoid `"__all__"` on public endpoints).
|
||||
|
||||
## Models
|
||||
|
||||
- **Auto-timestamp**: use `created_at = models.DateTimeField(auto_now_add=True)` — match the convention already on the model you are editing.
|
||||
- **Primary keys**: default Django `AutoField` (integer). Do not switch to UUID without discussion.
|
||||
- **Audit trail**: wrap models that need history with `@reversion.register()`.
|
||||
- **Base classes** in `generic/models.py`:
|
||||
- `QuestionBase`, `ExamBase`, `UserAnswerBase` — exam content
|
||||
- `AuthorMixin` — M2M authors field
|
||||
- `CidUser`, `CidUserGroup` — cohort-based access
|
||||
- After any model change: run `python manage.py makemigrations && python manage.py migrate`.
|
||||
|
||||
## Views
|
||||
|
||||
- Mix of FBVs and CBVs. Prefer the pattern already used in the app you are editing.
|
||||
- **Login**: `@login_required` (FBV) or `LoginRequiredMixin` (CBV).
|
||||
- **Permission decorators** in `generic/decorators.py`: `@user_is_cid_user_manager`, `@check_user_in_group`.
|
||||
- **Key CBV mixins** in `generic/views.py`: `CidManagerRequiredMixin`, `AuthorRequiredMixin`, `CheckCanEditMixin`.
|
||||
- Do not expose `can_edit=True` to candidate-facing views. Management and candidate views are intentionally separate.
|
||||
- When editing views please update docstrings to include their scope and functionality.
|
||||
|
||||
## Templates
|
||||
|
||||
- **Inheritance**: all full pages use `{% extends "base.html" %}`.
|
||||
- **Partials path**: `{app}/templates/{app}/partials/_fragment_name.html` (prefix with `_`).
|
||||
- **Template tags**: always load `{% load static %}`, `{% load django_htmx %}`, `{% load crispy_forms_tags %}` as needed at the top of the file.
|
||||
- **Bootstrap 5** (dark theme). Use existing utility classes; do not add inline styles.
|
||||
- To include a partial: `{% include 'app/partials/_fragment.html' %}`. Pass context explicitly when using `with` keyword.
|
||||
- **Multi line comment** NEVER use {# ... #} style comments for multiline comments as it will render into the html, it is designed for single line comments only. Use
|
||||
{% comment "Optional note: You can add a label here if you want" %}
|
||||
This is a multiline comment.
|
||||
None of this text or any {% tags %} inside here
|
||||
will be rendered or visible in the browser's "View Source".
|
||||
{% endcomment %}
|
||||
Instead
|
||||
|
||||
## Forms
|
||||
|
||||
- **Crispy Forms** with `crispy_bootstrap5`. All forms should use a `FormHelper`.
|
||||
- `Layout`, `Fieldset`, `Div`, `Row`, `Column` from `crispy_forms.layout` for structure.
|
||||
- Custom widgets in `generic/widgets.py` — check before creating new widgets.
|
||||
- Autocomplete fields use `django-autocomplete-light` (`dal`).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Runner**: `pytest` (config in `pytest.ini`; `--nomigrations` is on by default).
|
||||
- **Files**: `{app}/tests/test_*.py` or `{app}/tests.py`.
|
||||
- **Fixtures**: shared fixtures in `{app}/tests/conftest.py`. Check `generic/tests/conftest.py` for common fixtures (`create_user`, `create_examination`, etc.).
|
||||
- Do not use `TestCase` unless necessary — prefer plain `pytest` functions with `@pytest.mark.django_db`.
|
||||
- Run tests: `pytest` or `pytest {app}/tests/`.
|
||||
|
||||
## Exceptions
|
||||
- Avoid unnecessary try: except blocks.
|
||||
- We should not generate empty excepty blocks, if there is an error or bug this should be surfaced (even if just to the logs).
|
||||
|
||||
## Celery / Tasks
|
||||
|
||||
- Tasks autodiscovered from `{app}/tasks.py`.
|
||||
- Broker and result backend: Redis at `redis://redis:6379` (Docker service name).
|
||||
- Import the shared Celery app: `from rad.celery import app as celery_app`.
|
||||
|
||||
## Static & Media
|
||||
|
||||
- Static: `python manage.py collectstatic --noinput`. App static dirs at `{app}/static/`.
|
||||
- Media: custom `HashedFilenameFileSystemStorage`. Do not assume filenames are original.
|
||||
- DICOM thumbnails via `easy_thumbnails` + `helpers.images.pil_dicom_image`.
|
||||
|
||||
## Settings & Secrets
|
||||
|
||||
- **Never hardcode secrets**. Use `settings_local.py` (gitignored) or environment variables.
|
||||
- Env vars: `SECRET_KEY`, `DB_NAME/USER/PASSWORD/HOST/PORT`, `MEMCACHE_HOST`, `DEBUG`.
|
||||
- Local overrides: `rad/settings_local.py` (imported at the bottom of `settings.py`).
|
||||
|
||||
## Docker (Dev)
|
||||
|
||||
### Start Dev Environment
|
||||
Start the development server with local configurations:
|
||||
```bash
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
### Run Django Management Commands inside the Container
|
||||
Since PostgreSQL and Redis run in docker, run all database/management commands within the web container:
|
||||
```bash
|
||||
# Run database migrations
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py migrate
|
||||
|
||||
# Generate new migrations
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py makemigrations
|
||||
|
||||
# Check if migrations are missing (useful for CI/pre-commit)
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py makemigrations --check
|
||||
|
||||
# Run system checks
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py check
|
||||
|
||||
# Create a Django superuser
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
### Runserver and service commands
|
||||
To restart the web server/runserver or tail the container output:
|
||||
```bash
|
||||
# Restart the runserver/web service container
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml restart web
|
||||
|
||||
# View container logs (interactive)
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml logs -f web
|
||||
```
|
||||
|
||||
Dev server runs on `http://localhost:8080` (nginx → gunicorn/runserver).
|
||||
|
||||
## Key Conventions
|
||||
|
||||
| Convention | Detail |
|
||||
|---|---|
|
||||
| HTMX partial detection | `if request.htmx: return render(...)` |
|
||||
| Partial naming | `_fragment_name.html` with leading underscore |
|
||||
| Permission checks | Decorators/mixins from `generic/`; never inline `if user.is_staff` in templates |
|
||||
| Model timestamps | `created_at` (auto_now_add) preferred |
|
||||
| API schemas | Django Ninja `ModelSchema`; explicit field lists |
|
||||
| Audit trail | `@reversion.register()` on audited models |
|
||||
| Logging | `loguru` (`from loguru import logger`) |
|
||||
| Candidate vs management views | Keep separate; `can_edit` must not leak to candidate views |
|
||||
|
||||
## Before Opening a PR
|
||||
|
||||
1. `pytest` — all tests pass
|
||||
2. `python manage.py makemigrations --check` — no missing migrations
|
||||
3. `python manage.py check` — no system check errors
|
||||
4. No hardcoded secrets or debug prints
|
||||
@@ -0,0 +1,117 @@
|
||||
README
|
||||
======
|
||||
|
||||
Quick notes for running this repository (development & observability)
|
||||
|
||||
Running locally (development)
|
||||
- Use the included compose files. To run the dev stack (overrides) set `COMPOSE_ENV=dev` so the correct env file is loaded:
|
||||
|
||||
```sh
|
||||
# run nginx on non-privileged ports (see .env.dev)
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
Background task workers (automatic)
|
||||
- The compose stack now includes a dedicated `worker` service which runs:
|
||||
|
||||
```sh
|
||||
python manage.py db_worker
|
||||
```
|
||||
|
||||
- This means workers start automatically with `docker compose up` and restart on failure.
|
||||
- To inspect worker logs:
|
||||
|
||||
```sh
|
||||
docker compose logs -f worker
|
||||
```
|
||||
|
||||
- To scale workers (for higher throughput):
|
||||
|
||||
```sh
|
||||
docker compose up -d --scale worker=2
|
||||
```
|
||||
|
||||
Troubleshooting `SIGKILL` when running manually
|
||||
- A `SIGKILL` on `db_worker` is usually the kernel OOM killer (out-of-memory), not a Django exception.
|
||||
- Check kernel OOM events:
|
||||
|
||||
```sh
|
||||
dmesg -T | grep -i -E "killed process|out of memory|oom"
|
||||
```
|
||||
|
||||
- If OOM is confirmed, prefer running worker inside compose (managed restart + container limits), reduce concurrency/parallel services, or add memory/swap.
|
||||
|
||||
Manual worker startup (non-Docker production)
|
||||
- If you are not yet running production in Docker, start the worker using the restart wrapper script:
|
||||
|
||||
```sh
|
||||
source .venv/bin/activate
|
||||
DJANGO_SETTINGS_MODULE=rad.settings ./scripts/run-db-worker.sh
|
||||
```
|
||||
|
||||
- This script restarts `db_worker` if it exits unexpectedly.
|
||||
|
||||
- To run it in the background and keep logs:
|
||||
|
||||
```sh
|
||||
nohup env DJANGO_SETTINGS_MODULE=rad.settings ./scripts/run-db-worker.sh > logs/db_worker.log 2>&1 &
|
||||
```
|
||||
|
||||
- In fish shell, follow with `disown` so the shell does not keep the job attached:
|
||||
|
||||
```sh
|
||||
nohup env DJANGO_SETTINGS_MODULE=rad.settings ./scripts/run-db-worker.sh > logs/db_worker.log 2>&1 &
|
||||
disown
|
||||
```
|
||||
|
||||
- For server reliability, prefer running the same script under `systemd` (auto-start on reboot, restart-on-failure).
|
||||
- A starter unit file is provided at `scripts/db-worker.service.example`.
|
||||
|
||||
- To verify it is still running:
|
||||
|
||||
```sh
|
||||
ps aux | grep "manage.py db_worker" | grep -v grep
|
||||
tail -f logs/db_worker.log
|
||||
```
|
||||
|
||||
- By default the development nginx ports are set in `.env.dev` to avoid colliding with a host nginx. The defaults now are:
|
||||
- `NGINX_HTTP_PORT=8080`
|
||||
- `NGINX_HTTPS_PORT=8444`
|
||||
|
||||
Logging & observability (Loki + Promtail + Grafana)
|
||||
- Promtail is configured to scrape `/var/log/rad/*.log` and push to Loki. Promtail's config is at `docker/promtail-config.yml` and the Loki config is at `docker/loki-config/local-config.yaml`.
|
||||
- The nginx configs (both `deploy/nginx/prod.conf` and `deploy/nginx/dev.conf`) are configured to write access and error logs to `/var/log/rad/nginx.access.log` and `/var/log/rad/nginx.error.log` respectively.
|
||||
- The compose files mount a host `logs/` folder into `/var/log/rad` so both nginx (write) and promtail (read) can access the same files:
|
||||
- `../logs:/var/log/rad:rw` (nginx)
|
||||
- `../logs:/var/log/rad:ro` (promtail)
|
||||
|
||||
Host setup for logs
|
||||
- Create the host logs folder at the repo root (this repo's `logs/`). In development you can use permissive permissions so containers can write to it quickly:
|
||||
|
||||
```sh
|
||||
mkdir -p logs
|
||||
# development: make writable by all (change this for production)
|
||||
sudo chown $USER:$USER logs
|
||||
chmod 0777 logs
|
||||
```
|
||||
|
||||
- In production prefer setting the folder owner to the nginx worker UID (or run nginx under a specific user) and use `0755` or `0750` as appropriate. Example:
|
||||
|
||||
```sh
|
||||
# run on the host (determine nginx UID as needed)
|
||||
sudo chown -R 101:101 /srv/rad/logs
|
||||
sudo chmod 0755 /srv/rad/logs
|
||||
```
|
||||
|
||||
|
||||
Notes & recommendations
|
||||
- In development the `logs/` folder is ignored by git (`/.gitignore` updated). Do not commit runtime logs.
|
||||
- Consider a log rotation/retention policy for production (e.g., `logrotate` or use Loki retention policies).
|
||||
- If you need nginx to re-resolve the backend service IP without restarting, consider using `resolver` and variable-based upstreams in the nginx config. For many development workflows restarting nginx after web restarts is the simplest approach.
|
||||
|
||||
If you want, I can also add a short `docs/OBSERVABILITY.md` with more details and recommended production settings (TLS/auth for Loki, retention, log rotation).
|
||||
|
||||
|
||||
## Running tests
|
||||
|
||||
Ensure the development Docker environment is running (COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml up -d).
|
||||
@@ -0,0 +1,231 @@
|
||||
# Security Audit Report
|
||||
|
||||
**Date:** 2026-04-30
|
||||
**Scope:** All `views.py` files and `generic/decorators.py` across the entire project.
|
||||
|
||||
---
|
||||
|
||||
## Issues Fixed
|
||||
|
||||
### 1. Missing `@login_required` on `exam_collection_exams_partial` (generic/views.py)
|
||||
|
||||
**Severity:** High
|
||||
**File:** `generic/views.py`
|
||||
The `exam_collection_exams_partial` view was publicly accessible with no authentication. It exposed exam names, candidate counts, and author information from any collection to anonymous users.
|
||||
**Fix:** Added `@login_required` decorator.
|
||||
|
||||
---
|
||||
|
||||
### 2. Unauthenticated `get_*_id` popup-helper views (multiple files)
|
||||
|
||||
**Severity:** High
|
||||
**Files:** `generic/views.py`, `anatomy/views.py`, `rapids/views.py`, `longs/views.py`, `atlas/views.py`
|
||||
|
||||
All of the following views were decorated with `@csrf_exempt` but had **no authentication**. They allowed unauthenticated users to enumerate internal database records (Examinations, BodyParts, Structures, Abnormalities, Regions) by name via GET requests.
|
||||
|
||||
Additionally, all of these views contained a bug: `request.accepts("application/json")()` calls the boolean result of `accepts()` as a function, which raises `TypeError: 'bool' object is not callable`. This made them effectively non-functional (always returning `HttpResponse("/")`).
|
||||
|
||||
| File | View |
|
||||
|---|---|
|
||||
| `generic/views.py` | `get_examination_id` |
|
||||
| `anatomy/views.py` | `get_body_part_id`, `get_examination_id`, `get_structure_id` |
|
||||
| `rapids/views.py` | `get_abnormality_id`, `get_examination_id`, `get_region_id` |
|
||||
| `longs/views.py` | `get_examination_id` |
|
||||
| `atlas/views.py` | `get_examination_id` |
|
||||
|
||||
**Fix:**
|
||||
- Removed `@csrf_exempt` (GET-only views do not need CSRF exemption).
|
||||
- Added `@login_required`.
|
||||
- Fixed `request.accepts("application/json")()` → `request.accepts("application/json")`.
|
||||
- Replaced `request.GET["key"]` dict access (raises `KeyError` on missing key) with `request.GET.get("key", "")`.
|
||||
- Added docstrings documenting scope and functionality.
|
||||
|
||||
---
|
||||
|
||||
### 3. Unauthenticated `answer_suggestion_submit` with CSRF exemption (rad/views.py)
|
||||
|
||||
**Severity:** High
|
||||
**File:** `rad/views.py` (line ~781)
|
||||
|
||||
This view accepted anonymous POST requests with no CSRF protection to create new `Answer` records (marked `proposed=True`) on any existing question. This allowed anyone on the internet to submit arbitrary proposed answers to anatomy or rapids questions, polluting the moderation queue.
|
||||
|
||||
**Fix:**
|
||||
- Removed `@csrf_exempt`.
|
||||
- Added `@login_required`.
|
||||
- Added `if request.method != "POST": return ...` guard.
|
||||
- Fixed a minor typo in the response message (`"submited"` → `"submitted"`).
|
||||
- Added docstring documenting scope and functionality.
|
||||
|
||||
---
|
||||
|
||||
### 4. Debug `print()` in `check_user_in_group` decorator (generic/decorators.py)
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `generic/decorators.py`
|
||||
|
||||
The `check_user_in_group` decorator printed `request.user` to stdout on every invocation, leaking usernames/email addresses to application logs.
|
||||
**Fix:** Removed the `print(request.user)` statement.
|
||||
|
||||
---
|
||||
|
||||
### 5. Debug `print()` statements leaking sensitive data (multiple files)
|
||||
|
||||
**Severity:** Medium
|
||||
**Files:** `generic/views.py`, `atlas/views.py`, `longs/views.py`, `sbas/views.py`, `physics/views.py`, `shorts/views.py`, `rcr/views.py`, `oef/views.py`
|
||||
|
||||
Numerous active (non-commented) `print()` calls throughout view code leaked sensitive data to stdout/production logs, including:
|
||||
- User objects and usernames
|
||||
- CID numbers and passcodes
|
||||
- Email address lists being processed
|
||||
- Exam and queryset data
|
||||
|
||||
**Fix:** All active `print()` statements replaced with `logger.debug()` (using the project-standard `loguru` logger) or removed where they provided no diagnostic value. Commented-out print statements were left in place.
|
||||
|
||||
---
|
||||
|
||||
### 6. `@csrf_exempt` on `collection_case_displaysetup` (atlas/views.py)
|
||||
|
||||
**Severity:** Low
|
||||
**File:** `atlas/views.py`
|
||||
|
||||
The view had `@csrf_exempt` applied alongside a comment saying "Only if you have CSRF issues; otherwise, keep CSRF protection". The view already has proper authentication via `@user_is_collection_author_or_atlas_editor`, so the CSRF exemption was unnecessary and weakened security.
|
||||
**Fix:** Removed `@csrf_exempt`.
|
||||
|
||||
---
|
||||
|
||||
### 7. Missing `@login_required` on `toggle_share_with_supervisor` (generic/views.py)
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `generic/views.py`
|
||||
|
||||
This HTMX view had no authentication check. An anonymous user with a known `CidUserExam` PK could toggle the `share_with_supervisor` flag (although the `request.user != cid_user_exam.user_user` check would prevent the toggle from succeeding, it still leaks whether the record exists).
|
||||
**Fix:** Added `@login_required` and updated docstring.
|
||||
|
||||
---
|
||||
|
||||
### 8. Missing `@login_required` on `active_exams` in sbas/views.py
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `sbas/views.py`
|
||||
|
||||
The `active_exams` view had no authentication. It returned a list of all active SBA exams to anonymous users.
|
||||
**Fix:** Added `@login_required`. Updated docstring.
|
||||
|
||||
---
|
||||
|
||||
### 9. Unreachable dead code in `rcr/views.py`
|
||||
|
||||
**Severity:** Low
|
||||
**File:** `rcr/views.py`
|
||||
|
||||
In the `get_object` method of an `UpdateView` subclass, two lines appeared after a `return obj` statement:
|
||||
```python
|
||||
return obj
|
||||
print("test") # unreachable
|
||||
return super().get_object() # unreachable
|
||||
```
|
||||
**Fix:** Removed the unreachable `print("test")` and `return super().get_object()` lines.
|
||||
|
||||
---
|
||||
|
||||
## Issues Requiring Clarification (Not Fixed)
|
||||
|
||||
### A. `@csrf_exempt` on `exam_submit` (rad/views.py)
|
||||
|
||||
**File:** `rad/views.py`
|
||||
This view accepts POST submissions of exam answers without CSRF protection and without `@login_required`. It appears to be a legacy mobile/external client endpoint that dispatches to `post_exam_answers()` on individual exam view classes, which may perform their own CID/passcode authentication.
|
||||
|
||||
**Risk:** Without authentication at this layer, the endpoint could be abused to submit bogus exam answers for real users if a valid CID/passcode combination is known.
|
||||
|
||||
**Recommendation:** Clarify whether this endpoint is still in active use by a mobile client. If so, implement token-based authentication (e.g. Django REST Framework token auth) or require CID/passcode in the request body and validate before dispatching. If not in use, remove it.
|
||||
|
||||
---
|
||||
|
||||
### B. Unauthenticated views in `rcr/views.py`
|
||||
|
||||
**File:** `rcr/views.py`
|
||||
The following views have no authentication decorators:
|
||||
- `radiology_gap` — renders a full RCR gap analysis page with all curriculum items
|
||||
- `radiology_results` — renders all RCR assessment results
|
||||
|
||||
**Risk:** If these pages are intended to be internal-only (for assessors), they should require authentication. If they are intentionally public (e.g., public-facing curriculum overview), this is acceptable.
|
||||
|
||||
**Recommendation:** Confirm intended visibility. If internal, add `@login_required` (or a more specific decorator).
|
||||
|
||||
---
|
||||
|
||||
### C. Unauthenticated class-based views in `oef/views.py`
|
||||
|
||||
**File:** `oef/views.py`
|
||||
The following CBVs lack `LoginRequiredMixin`:
|
||||
- `EntryUpdateView` — allows updating OEF entry fields
|
||||
- `EntryTableView` — lists all entries
|
||||
- `FormatsCreateView` — creates new Formats objects
|
||||
- `FormatsUpdateView` — updates Formats objects
|
||||
|
||||
The update views in particular are a concern if this is not an intentionally public tool.
|
||||
|
||||
**Recommendation:** Add `LoginRequiredMixin` (and appropriate permission checks) if this app is not intended to be publicly editable.
|
||||
|
||||
---
|
||||
|
||||
### D. Commented-out `@login_required` on `exam_toggle_flag` in physics/views.py
|
||||
|
||||
**File:** `physics/views.py`
|
||||
The `exam_toggle_flag` view has `#@login_required` commented out. The view falls back to `exam.check_user_can_take(cid, passcode, request.user)` for access control, suggesting this is intentionally accessible to CID users (non-Django-auth users). However, the intent should be confirmed.
|
||||
|
||||
**Recommendation:** If the CID/passcode check is the intended authentication mechanism, document this clearly. If the view should also support Django-authenticated users, restore the decorator appropriately.
|
||||
|
||||
---
|
||||
|
||||
### E. `exam_submit` potential `AttributeError` (rad/views.py)
|
||||
|
||||
**File:** `rad/views.py`
|
||||
```python
|
||||
exam_type, exam_id = request.POST.get("eid").split("/")
|
||||
```
|
||||
If `eid` is missing from the POST body, `request.POST.get("eid")` returns `None`, and `.split("/")` raises `AttributeError`. This would return a 500 response rather than a clean error.
|
||||
|
||||
**Recommendation:** Guard with:
|
||||
```python
|
||||
eid = request.POST.get("eid", "")
|
||||
if "/" not in eid:
|
||||
return JsonResponse({"success": False, "error": "Invalid eid"})
|
||||
exam_type, exam_id = eid.split("/", 1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### F. Open redirect risk in `RedirectMixin` and `UpdateUserProfileView` (generic/views.py, rad/views.py)
|
||||
|
||||
**File:** `generic/views.py`, `rad/views.py`
|
||||
Both `RedirectMixin.get_success_url` and `UpdateUserProfileView.get_success_url` use `self.request.GET["redirect"]` / `self.request.GET.get("redirect")` as the redirect target without validation. A malicious actor could craft a URL like `?redirect=https://evil.com` to redirect authenticated users to a phishing site after a successful form submission.
|
||||
|
||||
**Recommendation:** Validate the redirect URL with Django's `url_has_allowed_host_and_scheme`:
|
||||
```python
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
|
||||
redirect_url = self.request.GET.get("redirect", "")
|
||||
if url_has_allowed_host_and_scheme(redirect_url, allowed_hosts={self.request.get_host()}):
|
||||
return redirect_url
|
||||
return super().get_success_url()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### G. Missing login check on `sbas/views.py` `#@login_required` commented-out views
|
||||
|
||||
**File:** `sbas/views.py`
|
||||
Two views have `@login_required` commented out:
|
||||
- `question_view` (around line 222) — currently commented out entirely
|
||||
- `question_detail` (around line 226) — currently commented out entirely
|
||||
|
||||
These are fully commented-out functions; no action needed unless they are re-enabled.
|
||||
|
||||
---
|
||||
|
||||
## Notes on Existing `@csrf_exempt` Usage
|
||||
|
||||
The following `@csrf_exempt` usages remain after fixes. All are on GET-only admin popup helpers that are now also `@login_required`. CSRF protection is not relevant for idempotent GET requests, but the decorators are harmless:
|
||||
|
||||
> All `@csrf_exempt` instances have been removed from the codebase as part of this audit. The pattern `@csrf_exempt` on GET-only views is redundant since CSRF protection only applies to state-changing requests (POST/PUT/PATCH/DELETE).
|
||||
+15
-16
@@ -19,51 +19,50 @@ from django.contrib.auth import get_user_model
|
||||
router = Router()
|
||||
|
||||
class CidUserSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = CidUser
|
||||
|
||||
model_fields = ["cid", "name", "email"]
|
||||
fields = ["cid", "name", "email"]
|
||||
|
||||
class UserUserSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = get_user_model()
|
||||
model_fields = ["id", "email"]
|
||||
fields = ["id", "email"]
|
||||
|
||||
class CidUserExamSchema(ModelSchema):
|
||||
cid_user : CidUserSchema | None
|
||||
user_user : UserUserSchema | None
|
||||
class Config:
|
||||
class Meta:
|
||||
model = CidUserExam
|
||||
model_fields = ["id"]
|
||||
fields = ["id"]
|
||||
|
||||
class ExamStatusSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = ExamUserStatus
|
||||
model_fields = ["id", "datetime", "status", "extra"]
|
||||
fields = ["id", "datetime", "status", "extra"]
|
||||
|
||||
class ExamUserStatusSchema(ModelSchema):
|
||||
|
||||
#cid_user : CidUserSchema | None
|
||||
#user_user : UserUserSchema | None
|
||||
cid_user_exam : CidUserExamSchema | None
|
||||
class Config:
|
||||
class Meta:
|
||||
model = ExamUserStatus
|
||||
model_fields = ["id", "datetime", "status", "extra"]
|
||||
fields = ["id", "datetime", "status", "extra"]
|
||||
|
||||
class QuestionSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Question
|
||||
|
||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
||||
model_fields = "__all__"
|
||||
fields = "__all__"
|
||||
|
||||
#model_exclude = ["answers"]
|
||||
#exclude = ["answers"]
|
||||
|
||||
class ExamSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Exam
|
||||
|
||||
model_fields = ["id", "name", "active", "publish_results"]
|
||||
fields = ["id", "name", "active", "publish_results"]
|
||||
|
||||
@router.get('/')
|
||||
def list_questions(request):
|
||||
|
||||
+3
-2
@@ -61,8 +61,9 @@ class AnatomyUserAnswerFilter(django_filters.FilterSet):
|
||||
)
|
||||
|
||||
def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None):
|
||||
if not request.user.groups.filter(name="anatomy_checker").exists():
|
||||
queryset = queryset.filter(open_access=True) | queryset.filter(author__id=request.user.id)
|
||||
if not request.user.is_superuser:
|
||||
if not request.user.groups.filter(name="anatomy_checker").exists():
|
||||
queryset = queryset.filter(exam__open_access=True) | queryset.filter(exam__author__id=request.user.id)
|
||||
#queryset = queryset.prefetch_related("question", "question__question_type", "exam")
|
||||
super(AnatomyUserAnswerFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
|
||||
pass
|
||||
+51
-3
@@ -34,7 +34,9 @@ from dal import autocomplete
|
||||
from tinymce.widgets import TinyMCE
|
||||
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Submit
|
||||
from crispy_forms.layout import Submit, Layout, Div
|
||||
from crispy_forms.bootstrap import Accordion, AccordionGroup
|
||||
from generic.widgets import ExamSearchWidget
|
||||
|
||||
class AnatomyAnswerForm(ModelForm):
|
||||
class Meta:
|
||||
@@ -127,7 +129,38 @@ class AnatomyQuestionForm(ModelForm):
|
||||
self.fields["exams"] = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=exam_queryset,
|
||||
widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False),
|
||||
widget=ExamSearchWidget(exam_model=Exam),
|
||||
)
|
||||
|
||||
# Now that all fields have been created, set the helper layout so
|
||||
# Crispy can find the referenced field names (including
|
||||
# `answer_suggest_incorrect`). Placing the layout here ensures the
|
||||
# collapse/accordion renders server-side.
|
||||
self.helper.layout = Layout(
|
||||
"question_type",
|
||||
"image",
|
||||
"description",
|
||||
# Place modality → body_part fields in a horizontal flex row
|
||||
Div(
|
||||
Div("modality", css_class="flex-fill me-2"),
|
||||
Div("region", css_class="flex-fill me-2"),
|
||||
Div("structure", css_class="flex-fill me-2"),
|
||||
Div("examination", css_class="flex-fill me-2"),
|
||||
Div("body_part", css_class="flex-fill"),
|
||||
css_class="d-flex flex-wrap align-items-start mb-2",
|
||||
),
|
||||
"answer_help",
|
||||
Accordion(
|
||||
AccordionGroup(
|
||||
"Suggest incorrect answers",
|
||||
"answer_suggest_incorrect",
|
||||
active=False,
|
||||
)
|
||||
),
|
||||
"feedback",
|
||||
"open_access",
|
||||
"exams",
|
||||
Div(Submit("submit", "Submit"), css_class="form-group"),
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
@@ -257,4 +290,19 @@ class ExamGroupsForm(ExamGroupsFormMixin):
|
||||
class SuggestIncorrectAnswerForm(ModelForm):
|
||||
class Meta:
|
||||
model = AnatomyQuestion
|
||||
fields = ["answer_suggest_incorrect"]
|
||||
fields = ["answer_suggest_incorrect"]
|
||||
|
||||
|
||||
class PrimaryAnswerForm(Form):
|
||||
existing_answer = ChoiceField(required=False, choices=[], label="Use existing answer")
|
||||
new_answer = CharField(required=False, widget=Textarea(attrs={"rows":3}), label="Or create new primary answer")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
question = kwargs.pop("question", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
choices = [("", "-- choose existing --")]
|
||||
if question is not None:
|
||||
# Only include existing non-proposed CORRECT answers, ordered by answer text
|
||||
for a in question.answers.filter(proposed=False, status=Answer.MarkOptions.CORRECT).order_by('answer'):
|
||||
choices.append((str(a.pk), a.answer))
|
||||
self.fields["existing_answer"].choices = choices
|
||||
@@ -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,25 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-26 11:05
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0024_exam_results_supervisor_visible'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, help_text='Creation timestamp'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='updated',
|
||||
field=models.DateTimeField(auto_now=True, help_text='Last updated timestamp'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Add primary_answer FK to AnatomyQuestion and backfill existing values.
|
||||
|
||||
This migration adds a nullable FK `primary_answer` on AnatomyQuestion
|
||||
pointing to Answer, then backfills it using the current logic of
|
||||
selecting the first non-proposed CORRECT answer (status '2').
|
||||
"""
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
AnatomyQuestion = apps.get_model('anatomy', 'AnatomyQuestion')
|
||||
Answer = apps.get_model('anatomy', 'Answer')
|
||||
|
||||
# Select first non-proposed CORRECT answer (status '2') per question
|
||||
for q in AnatomyQuestion.objects.all():
|
||||
ans = Answer.objects.filter(question_id=q.pk, proposed=False, status='2').first()
|
||||
if ans:
|
||||
q.primary_answer_id = ans.pk
|
||||
q.save(update_fields=['primary_answer'])
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
AnatomyQuestion = apps.get_model('anatomy', 'AnatomyQuestion')
|
||||
for q in AnatomyQuestion.objects.all():
|
||||
q.primary_answer = None
|
||||
q.save(update_fields=['primary_answer'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0025_exam_created_exam_updated'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='anatomyquestion',
|
||||
name='primary_answer',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_for_questions', to='anatomy.answer'),
|
||||
),
|
||||
migrations.RunPython(forwards, backwards),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-16 09:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0026_add_primary_answer'),
|
||||
('atlas', '0090_casecollection_created_casecollection_updated'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='anatomyquestion',
|
||||
name='primary_answer',
|
||||
field=models.ForeignKey(blank=True, help_text='Optional explicit primary answer for this question', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_for_questions', to='anatomy.answer'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='anatomyquestion',
|
||||
name='structure',
|
||||
field=models.ForeignKey(blank=True, help_text='The structure that is being asked about in the question, this is used for categorisation and search purposes but is not required to be set for a question to be valid.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='atlas.structure'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-28 20:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0027_alter_anatomyquestion_primary_answer_and_more'),
|
||||
('survey', '__first__'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='post_survey',
|
||||
field=models.ForeignKey(blank=True, help_text='Survey to be filled out AFTER completing this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_post_surveys', to='survey.survey'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='pre_survey',
|
||||
field=models.ForeignKey(blank=True, help_text='Survey to be filled out BEFORE taking this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_pre_surveys', to='survey.survey'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-29 08:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0028_exam_post_survey_exam_pre_survey'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='post_survey_mandatory',
|
||||
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the post-take survey to view results.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='pre_survey_mandatory',
|
||||
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the pre-take survey to start.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-29 09:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0029_exam_post_survey_mandatory_exam_pre_survey_mandatory'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='useranswer',
|
||||
name='time_answered',
|
||||
field=models.DateTimeField(blank=True, help_text='Time the question was answered (client-side)', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-13 08:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0030_useranswer_time_answered'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='pass_mark',
|
||||
field=models.FloatField(blank=True, help_text='Pass mark for the exam. If unset, defaults to 50% of the maximum score.', null=True),
|
||||
),
|
||||
]
|
||||
+103
-69
@@ -116,6 +116,8 @@ class AnatomyQuestion(QuestionBase):
|
||||
)
|
||||
structure = models.ForeignKey(
|
||||
Structure, on_delete=models.SET_NULL, null=True, blank=True
|
||||
, help_text="The structure that is being asked about in the question, this is used for categorisation and search purposes but is not required to be set for a question to be valid."
|
||||
|
||||
)
|
||||
|
||||
author = models.ManyToManyField(
|
||||
@@ -125,6 +127,15 @@ class AnatomyQuestion(QuestionBase):
|
||||
related_name="anatomy_authored_questions",
|
||||
)
|
||||
|
||||
primary_answer = models.ForeignKey(
|
||||
'Answer',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='primary_for_questions',
|
||||
help_text="Optional explicit primary answer for this question",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
permissions = ()
|
||||
|
||||
@@ -132,14 +143,16 @@ class AnatomyQuestion(QuestionBase):
|
||||
return "anatomy"
|
||||
|
||||
def __str__(self):
|
||||
# Get first answer
|
||||
return "{}/{}: {} [{}, {}]".format(
|
||||
self.pk,
|
||||
self.question_type,
|
||||
self.get_primary_answer(),
|
||||
self.modality,
|
||||
self.structure,
|
||||
)
|
||||
# Nicely formatted representation
|
||||
pk = self.pk or ""
|
||||
qtype = str(self.question_type) if self.question_type else "N/A"
|
||||
primary = self.get_primary_answer() or "None"
|
||||
modality = str(self.modality) if self.modality else "Unknown"
|
||||
structure = str(self.structure) if self.structure else "Unknown"
|
||||
title = self.get_title() if hasattr(self, "get_title") else (self.description or "")
|
||||
if title and len(title) > 60:
|
||||
title = title[:57] + "..."
|
||||
return f"{title} (Type: {qtype}) — Answer: {primary} — Modality: {modality}; Structure: {structure}"
|
||||
|
||||
def get_link(self):
|
||||
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self)
|
||||
@@ -147,48 +160,50 @@ class AnatomyQuestion(QuestionBase):
|
||||
def get_absolute_url(self):
|
||||
return reverse("anatomy:question_detail", kwargs={"pk": self.pk})
|
||||
|
||||
def get_primary_answer(self):
|
||||
if hasattr(self, "prefetched_primary_answer") and self.prefetched_primary_answer:
|
||||
ans = self.prefetched_primary_answer[0]
|
||||
else:
|
||||
ans = self.answers.filter(
|
||||
proposed=False, status=Answer.MarkOptions.CORRECT
|
||||
).first()
|
||||
# ans = self.answers.first()
|
||||
def get_primary_answer_obj(self):
|
||||
"""Return the Answer instance considered the primary answer, or None.
|
||||
|
||||
Preference order:
|
||||
- explicit `primary_answer` FK if set
|
||||
- `prefetched_primary_answer` if provided by a queryset
|
||||
- first non-proposed Answer with status CORRECT
|
||||
"""
|
||||
if getattr(self, "primary_answer", None):
|
||||
return self.primary_answer
|
||||
|
||||
if hasattr(self, "prefetched_primary_answer") and self.prefetched_primary_answer:
|
||||
return self.prefetched_primary_answer[0]
|
||||
|
||||
return self.answers.filter(proposed=False, status=Answer.MarkOptions.CORRECT).first()
|
||||
|
||||
def get_primary_answer(self):
|
||||
"""Return the primary answer text (string) for templates and display.
|
||||
|
||||
Keeps backward compatibility by returning the answer string when
|
||||
available, or "None yet..." if no primary answer is defined.
|
||||
"""
|
||||
ans = self.get_primary_answer_obj()
|
||||
if ans is None:
|
||||
return "None yet..."
|
||||
else:
|
||||
return ans.answer
|
||||
|
||||
if (
|
||||
self.answers.filter(
|
||||
proposed=False, status=Answer.MarkOptions.CORRECT
|
||||
).count()
|
||||
> 0
|
||||
):
|
||||
return (
|
||||
self.answers.filter(proposed=False, status=Answer.MarkOptions.CORRECT)
|
||||
.first()
|
||||
.answer
|
||||
)
|
||||
else:
|
||||
return "None yet..."
|
||||
return ans.answer
|
||||
|
||||
def get_exams(self):
|
||||
e = self.exams.all().values_list("name", flat=True)
|
||||
exams = ", ".join(e)
|
||||
return exams
|
||||
|
||||
def get_image_url(self):
|
||||
return "{}/{}".format(settings.REMOTE_URL, self.image.url)
|
||||
|
||||
def get_unmarked_user_answer_string(self, exam_pk=None):
|
||||
unmarked_answers = self.get_unmarked_user_answers(exam_pk)
|
||||
|
||||
if not unmarked_answers:
|
||||
return "No answers to mark"
|
||||
return format_html(
|
||||
"<span class='warn'>{} answer unmarked:</span> {}".format(
|
||||
len(unmarked_answers), ", ".join(unmarked_answers)
|
||||
)
|
||||
"<span class='warn'>{} answer unmarked:</span> {}",
|
||||
len(unmarked_answers),
|
||||
", ".join(unmarked_answers),
|
||||
)
|
||||
|
||||
def get_unmarked_user_answers(self, exam_pk: int|None = None):
|
||||
@@ -323,7 +338,21 @@ class Answer(models.Model):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean()
|
||||
return super(Answer, self).save(*args, **kwargs)
|
||||
res = super(Answer, self).save(*args, **kwargs)
|
||||
# Update matching UserAnswer objects in real-time
|
||||
UserAnswer.objects.filter(
|
||||
question=self.question,
|
||||
answer_compare__iexact=self.answer_compare
|
||||
).update(score=self.status)
|
||||
return res
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# Update matching UserAnswer objects back to UNMARKED in real-time
|
||||
UserAnswer.objects.filter(
|
||||
question=self.question,
|
||||
answer_compare__iexact=self.answer_compare
|
||||
).update(score=Answer.MarkOptions.UNMARKED)
|
||||
return super(Answer, self).delete(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
if self.answer:
|
||||
@@ -536,11 +565,30 @@ class UserAnswer(UserAnswerBase):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean()
|
||||
|
||||
# Auto-mark on save from existing template answers
|
||||
ans = self.answer_compare
|
||||
if ans == "":
|
||||
self.score = Answer.MarkOptions.INCORRECT
|
||||
else:
|
||||
marked_ans = Answer.objects.filter(
|
||||
question_id=self.question_id,
|
||||
answer_compare__iexact=ans
|
||||
).first()
|
||||
if marked_ans is not None:
|
||||
self.score = marked_ans.status
|
||||
else:
|
||||
self.score = Answer.MarkOptions.UNMARKED
|
||||
|
||||
return super(UserAnswer, self).save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
if self.answer:
|
||||
self.answer = self.answer.strip()
|
||||
self.answer_compare = get_answer_compare(self.answer)
|
||||
else:
|
||||
self.answer = ""
|
||||
self.answer_compare = ""
|
||||
|
||||
# def get_compare_string(self):
|
||||
# # strip here should be unneccasry (providing clean is now working)
|
||||
@@ -554,30 +602,34 @@ class UserAnswer(UserAnswerBase):
|
||||
return self.answer
|
||||
|
||||
def get_answer_score(self, cached=True):
|
||||
if cached and self.score:
|
||||
if cached and self.score and self.score != Answer.MarkOptions.UNMARKED:
|
||||
if self.score == Answer.MarkOptions.CORRECT:
|
||||
mark = 2
|
||||
return 2
|
||||
elif self.score == Answer.MarkOptions.HALF_MARK:
|
||||
mark = 1
|
||||
return 1
|
||||
elif self.score == Answer.MarkOptions.INCORRECT:
|
||||
mark = 0
|
||||
|
||||
return mark
|
||||
return 0
|
||||
|
||||
q = self.question
|
||||
ans = self.answer_compare
|
||||
|
||||
if ans == "":
|
||||
self.score == Answer.MarkOptions.INCORRECT
|
||||
self.save()
|
||||
if self.score != Answer.MarkOptions.INCORRECT:
|
||||
self.score = Answer.MarkOptions.INCORRECT
|
||||
self.save()
|
||||
return 0
|
||||
|
||||
marked_ans = q.answers.filter(answer_compare__iexact=ans).first()
|
||||
marked_ans = None
|
||||
for a in q.answers.all():
|
||||
if a.answer_compare.lower() == ans.lower():
|
||||
marked_ans = a
|
||||
break
|
||||
|
||||
mark = "unmarked"
|
||||
if marked_ans is not None:
|
||||
self.score = marked_ans.status
|
||||
self.save()
|
||||
if self.score != marked_ans.status:
|
||||
self.score = marked_ans.status
|
||||
self.save()
|
||||
|
||||
if marked_ans.status == Answer.MarkOptions.CORRECT:
|
||||
mark = 2
|
||||
@@ -585,27 +637,9 @@ class UserAnswer(UserAnswerBase):
|
||||
mark = 1
|
||||
elif marked_ans.status == Answer.MarkOptions.INCORRECT:
|
||||
mark = 0
|
||||
|
||||
return mark
|
||||
|
||||
if (
|
||||
q.answers.filter(
|
||||
answer_compare__iexact=ans, status=Answer.MarkOptions.CORRECT
|
||||
).first()
|
||||
is not None
|
||||
):
|
||||
mark = 2
|
||||
elif (
|
||||
q.answers.filter(
|
||||
answer_compare__iexact=ans, status=Answer.MarkOptions.HALF_MARK
|
||||
).first()
|
||||
is not None
|
||||
):
|
||||
mark = 1
|
||||
elif q.answers.filter(
|
||||
answer_compare__iexact=ans, status=Answer.MarkOptions.INCORRECT
|
||||
).first():
|
||||
mark = 0
|
||||
else:
|
||||
mark = "unmarked"
|
||||
if self.score != Answer.MarkOptions.UNMARKED:
|
||||
self.score = Answer.MarkOptions.UNMARKED
|
||||
self.save()
|
||||
|
||||
return mark
|
||||
|
||||
+357
-177
@@ -16,8 +16,15 @@ $(document).ready(function () {
|
||||
clearableTextAreaSetup();
|
||||
|
||||
|
||||
|
||||
|
||||
document.body.addEventListener("htmx:responseError", function(e) {
|
||||
let el = document.getElementById("htmx-error");
|
||||
if (!el) {
|
||||
// No global error container available; log and return to avoid JS errors
|
||||
console.error('htmx:responseError but no #htmx-error element present', e.detail);
|
||||
return;
|
||||
}
|
||||
var error;
|
||||
if (e.detail.pathInfo.requestPath.includes("cimar") && e.detail.xhr.status == 403) {
|
||||
error = "Cimar permission error, please login and refresh the page.";
|
||||
@@ -42,24 +49,27 @@ $(document).ready(function () {
|
||||
// $(element).append($("<span class='google-link' title='search for answer with google'><a href='https://www.google.com/search?q=" + $(element).text() + "' target='_blank'>G</a></span>"));
|
||||
//});
|
||||
|
||||
$(".answer-list span.answer").each(function (index, element) {
|
||||
console.log(element);
|
||||
// Legacy per-element click handler: skip on the new mark2 page which
|
||||
// manages clicks via a delegated handler (it uses #answer-list). This
|
||||
// prevents duplicate/colliding handlers when mark2 is rendered.
|
||||
if (!document.getElementById('answer-list')) {
|
||||
$(".answer-list span.answer").each(function (index, element) {
|
||||
console.log(element);
|
||||
|
||||
$(element).click(function (e) {
|
||||
$(element).click(function (e) {
|
||||
|
||||
var classes = ['answer correct', 'answer half-correct', 'answer incorrect'];
|
||||
$(element).each(function () {
|
||||
let mark = classes[($.inArray(this.className, classes) + 1) % classes.length];
|
||||
this.className = mark
|
||||
this.dataset.newmark = mark.split(" ")[1]
|
||||
});
|
||||
|
||||
prepAnswerData();
|
||||
|
||||
var classes = ['answer correct', 'answer half-correct', 'answer incorrect'];
|
||||
$(element).each(function () {
|
||||
let mark = classes[($.inArray(this.className, classes) + 1) % classes.length];
|
||||
this.className = mark
|
||||
this.dataset.newmark = mark.split(" ")[1]
|
||||
});
|
||||
|
||||
prepAnswerData();
|
||||
|
||||
|
||||
|
||||
});
|
||||
});
|
||||
}
|
||||
prepAnswerData();
|
||||
|
||||
if ($(".post-form").length > 0) {
|
||||
@@ -80,6 +90,14 @@ $(document).ready(function () {
|
||||
}
|
||||
|
||||
}
|
||||
let dicom_images_legacy = document.getElementsByClassName("dicom-image-legacy");
|
||||
if (dicom_images_legacy.length) {
|
||||
|
||||
for (let element of dicom_images_legacy) {
|
||||
setUpDicomLegacy(element)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
loadDicomViewer();
|
||||
@@ -194,6 +212,12 @@ window.loadDicomViewerEvent = new Event('loadDicomViewerUrls');
|
||||
window.addEventListener('loadDicomViewerUrls', function (e) {
|
||||
console.log("LoadDicomViewer unls event", e.detail)
|
||||
|
||||
// Skip loading old viewer if using new dv3d viewer (set on new_uploads page)
|
||||
if (window.skipLoadDicomViewer) {
|
||||
console.log("Skipping old loadDicomViewer - using dv3d instead");
|
||||
return;
|
||||
}
|
||||
|
||||
loadDicomViewer(e.detail.images, e.detail.annotations);
|
||||
}, false);
|
||||
|
||||
@@ -202,7 +226,9 @@ function clearableTextAreaSetup() {
|
||||
|
||||
textFields.forEach((field) => {
|
||||
const wrapper = field.closest(".position-relative");
|
||||
if (!wrapper) return;
|
||||
const clearButton = wrapper.querySelector(".clear-input-button");
|
||||
if (!clearButton) return;
|
||||
|
||||
// Function to toggle the visibility of the clear button
|
||||
const toggleClearButton = () => {
|
||||
@@ -232,9 +258,16 @@ function clearableTextAreaSetup() {
|
||||
}
|
||||
|
||||
function loadDicomViewer(images_to_load, annotations_to_load) {
|
||||
// Skip loading old viewer if using new dv3d viewer (set on new_uploads page)
|
||||
if (window.skipLoadDicomViewer) {
|
||||
console.log("Skipping old loadDicomViewer - using dv3d instead");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("loadDicomViewer", images_to_load);
|
||||
let single_dicom = document.getElementById("single-dicom-viewer");
|
||||
if (single_dicom) {
|
||||
console.log("Load single dicom viewer");
|
||||
$(single_dicom).empty()
|
||||
let images = single_dicom.dataset.images;
|
||||
|
||||
@@ -307,8 +340,8 @@ function prepAnswerData() {
|
||||
$("#id_marked_answers").val(JSON.stringify(window.marked_answers));
|
||||
}
|
||||
|
||||
function loadJsonToolStateOnCurrentImage(json) {
|
||||
let el = document.getElementById("dicom-image");
|
||||
function loadJsonToolStateOnCurrentImage(element, json) {
|
||||
let el = element;
|
||||
|
||||
const toolStateManager = cornerstoneTools.globalImageIdSpecificToolStateManager;
|
||||
|
||||
@@ -330,160 +363,195 @@ function loadJsonToolStateOnCurrentImage(json) {
|
||||
|
||||
}
|
||||
|
||||
//async function setUpDicom(element) {
|
||||
// console.log("setUpDicom (original)", element);
|
||||
//
|
||||
// $(element).bind('contextmenu', function (e) {
|
||||
// return false;
|
||||
// });
|
||||
//
|
||||
// $(element).dblclick((evt) => {
|
||||
// element.requestFullscreen();
|
||||
// })
|
||||
//
|
||||
// cornerstoneBase64ImageLoader.external.cornerstone = cornerstone;
|
||||
// cornerstoneWebImageLoader.external.cornerstone = cornerstone;
|
||||
// cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
|
||||
//
|
||||
// cornerstoneTools.init();
|
||||
// const PanTool = cornerstoneTools.PanTool;
|
||||
// const ZoomTool = cornerstoneTools.ZoomTool;
|
||||
// const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool;
|
||||
// const WwwcTool = cornerstoneTools.WwwcTool;
|
||||
// const WwwcRegionTool = cornerstoneTools.WwwcRegionTool;
|
||||
// const RotateTool = cornerstoneTools.RotateTool;
|
||||
// const StackScrollTool = cornerstoneTools.StackScrollTool;
|
||||
// const MagnifyTool = cornerstoneTools.MagnifyTool;
|
||||
// const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
|
||||
//
|
||||
//
|
||||
// const images = element.dataset.url.split(",");
|
||||
//
|
||||
// //console.log("Dicom - load imageId: ", imageIds);
|
||||
// //for (let index = 0; index < imageIds.length; index++) {
|
||||
// // if (imageIds[index].endsWith("dcm")) {
|
||||
// // imageIds[index] = "wadouri:" + imageIds[index];
|
||||
// // }
|
||||
//
|
||||
// //}
|
||||
// let imageIds = [];
|
||||
// for (let i = 0; i < images.length; i++) {
|
||||
// let data_url = images[i];
|
||||
// // check stack type
|
||||
// if (data_url.startsWith("data:image")) {
|
||||
// let imageId = "base64://" + data_url.split(",")[1];
|
||||
//
|
||||
// imageIds.push(imageId);
|
||||
// } else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) {
|
||||
//
|
||||
// imageIds.push(data_url);
|
||||
//
|
||||
// // Treat application/octet-stream as if they are dicoms
|
||||
// } else if (data_url.startsWith("data:application/dicom") || data_url.startsWith("data:application/octet-stream")) {
|
||||
// //stack = stack.split(";")[1];
|
||||
//
|
||||
// let dfile = await urltoFile(data_url, "dicom", "application/dicom");
|
||||
//
|
||||
// const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
|
||||
// dfile
|
||||
// );
|
||||
//
|
||||
// loadAnnotation(imageId, annotation);
|
||||
//
|
||||
// imageIds.push(imageId);
|
||||
// //cornerstone.loadImage(imageId).then(function(image) {
|
||||
// // tempFunction(image);
|
||||
// //});
|
||||
// } else {
|
||||
// let url;
|
||||
// // This doesn't seem to have any benefit
|
||||
// //if (data_url.startsWith("http")) {
|
||||
// // url = data_url;
|
||||
// //} else {
|
||||
// // url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
|
||||
// //}
|
||||
// url = data_url;
|
||||
//
|
||||
// if (url.endsWith("dcm")) {
|
||||
// url = "wadouri:" + url;
|
||||
// }
|
||||
//
|
||||
// // if there is no extension treat it as a dicom
|
||||
// if (/(?:\/|^)[^.\/]+$/.test(url)) {
|
||||
// url = "wadouri:" + url;
|
||||
// }
|
||||
//
|
||||
// imageIds.push(url);
|
||||
//
|
||||
//
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// cornerstone.enable(element);
|
||||
// cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
|
||||
// cornerstone.displayImage(element, image);
|
||||
//
|
||||
// cornerstoneTools.addToolForElement(element, PanTool);
|
||||
// cornerstoneTools.addToolForElement(element, ZoomTool);
|
||||
// cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool);
|
||||
// cornerstoneTools.addToolForElement(element, WwwcTool);
|
||||
// cornerstoneTools.addToolForElement(element, WwwcRegionTool);
|
||||
// cornerstoneTools.addToolForElement(element, RotateTool);
|
||||
// cornerstoneTools.addToolForElement(element, StackScrollTool);
|
||||
// cornerstoneTools.addToolForElement(element, MagnifyTool);
|
||||
//
|
||||
// cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
|
||||
// configuration: {
|
||||
// getTextCallback: () => {},
|
||||
// changeTextCallback: () => {},
|
||||
// allowEmptyLabel: true,
|
||||
// renderDashed: false,
|
||||
// drawHandles: false,
|
||||
// drawHandlesOnHover: true,
|
||||
// },
|
||||
// });
|
||||
//
|
||||
//
|
||||
//
|
||||
// // Enable our tools
|
||||
// // Avoid incorrect aspect ratio
|
||||
// cornerstoneTools.setToolActiveForElement(element, "Pan", {
|
||||
// mouseButtonMask: 1
|
||||
// });
|
||||
// cornerstoneTools.setToolActiveForElement(element, "Wwwc", {
|
||||
// mouseButtonMask: 2
|
||||
// });
|
||||
// cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", {
|
||||
// mouseButtonMask: 3
|
||||
// });
|
||||
// cornerstoneTools.setToolActiveForElement(element, "Zoom", {
|
||||
// mouseButtonMask: 4
|
||||
// });
|
||||
//
|
||||
// if (element.dataset.edit_annotation == "true") {
|
||||
// cornerstoneTools.setToolActiveForElement(element, "ArrowAnnotate", {
|
||||
// mouseButtonMask: 2
|
||||
// });
|
||||
// } else {
|
||||
// cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate");
|
||||
// }
|
||||
//
|
||||
// if (element.dataset.annotations) {
|
||||
// loadJsonToolStateOnCurrentImage(element.dataset.annotations)
|
||||
// }
|
||||
//
|
||||
// cornerstone.resize(element);
|
||||
//
|
||||
//
|
||||
// }).catch((err, err2) => {
|
||||
// console.log(err);
|
||||
// });
|
||||
//
|
||||
//}
|
||||
//console.log("setUpDicom defined");
|
||||
//
|
||||
//// keep reference to the existing 2D setup
|
||||
//window.setUpDicomClassic = setUpDicom;
|
||||
// Called by HTMX after a fragment swap; initializes any dicom-image-legacy viewers
|
||||
window.initAnatomyFragment = function(fragmentRoot) {
|
||||
console.log("initAnatomyFragment", fragmentRoot);
|
||||
try {
|
||||
const root = fragmentRoot && fragmentRoot.nodeType ? fragmentRoot : (fragmentRoot && fragmentRoot.detail && fragmentRoot.detail.target) ? fragmentRoot.detail.target : document;
|
||||
const imgs = (root && root.querySelectorAll) ? root.querySelectorAll('.dicom-image-legacy') : [];
|
||||
imgs.forEach((el) => {
|
||||
try {
|
||||
// Avoid double initialization
|
||||
if (el.dataset && (el.dataset.dicomInitialised === '1' || el.dataset.dicomInitialised === 'true')) return;
|
||||
if (typeof window.setUpDicomLegacy === 'function') {
|
||||
window.setUpDicomLegacy(el);
|
||||
if (el.dataset) el.dataset.dicomInitialised = '1';
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('initAnatomyFragment: setUpDicomLegacy failed for element', el, err);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('initAnatomyFragment error', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Global fallback: listen for HTMX swaps and initialise any viewers inside
|
||||
document.addEventListener('htmx:afterSwap', function (e) {
|
||||
try {
|
||||
const root = e && e.target ? e.target : (e && e.detail && e.detail.target ? e.detail.target : document);
|
||||
if (window.initAnatomyFragment) window.initAnatomyFragment(root);
|
||||
} catch (err) {
|
||||
console.warn('htmx afterSwap listener error', err);
|
||||
}
|
||||
});
|
||||
|
||||
async function setUpDicomLegacy(element) {
|
||||
console.log("setUpDicom (original)", element);
|
||||
|
||||
$(element).bind('contextmenu', function (e) {
|
||||
return false;
|
||||
});
|
||||
|
||||
$(element).dblclick((evt) => {
|
||||
element.requestFullscreen();
|
||||
})
|
||||
|
||||
cornerstoneBase64ImageLoader.external.cornerstone = cornerstone;
|
||||
cornerstoneWebImageLoader.external.cornerstone = cornerstone;
|
||||
cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
|
||||
|
||||
cornerstoneTools.init();
|
||||
const PanTool = cornerstoneTools.PanTool;
|
||||
const ZoomTool = cornerstoneTools.ZoomTool;
|
||||
const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool;
|
||||
const WwwcTool = cornerstoneTools.WwwcTool;
|
||||
const WwwcRegionTool = cornerstoneTools.WwwcRegionTool;
|
||||
const RotateTool = cornerstoneTools.RotateTool;
|
||||
const StackScrollTool = cornerstoneTools.StackScrollTool;
|
||||
const MagnifyTool = cornerstoneTools.MagnifyTool;
|
||||
const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
|
||||
|
||||
|
||||
const images = element.dataset.url.split(",");
|
||||
|
||||
const annotation = element.dataset.annotations;
|
||||
|
||||
//console.log("Dicom - load imageId: ", imageIds);
|
||||
//for (let index = 0; index < imageIds.length; index++) {
|
||||
// if (imageIds[index].endsWith("dcm")) {
|
||||
// imageIds[index] = "wadouri:" + imageIds[index];
|
||||
// }
|
||||
|
||||
//}
|
||||
let imageIds = [];
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
let data_url = images[i];
|
||||
// check stack type
|
||||
if (data_url.startsWith("data:image")) {
|
||||
let imageId = "base64://" + data_url.split(",")[1];
|
||||
|
||||
imageIds.push(imageId);
|
||||
} else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) {
|
||||
|
||||
imageIds.push(data_url);
|
||||
|
||||
// Treat application/octet-stream as if they are dicoms
|
||||
} else if (data_url.startsWith("data:application/dicom") || data_url.startsWith("data:application/octet-stream")) {
|
||||
//stack = stack.split(";")[1];
|
||||
|
||||
let dfile = await urltoFile(data_url, "dicom", "application/dicom");
|
||||
|
||||
const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
|
||||
dfile
|
||||
);
|
||||
|
||||
loadAnnotation(imageId, annotation);
|
||||
|
||||
imageIds.push(imageId);
|
||||
//cornerstone.loadImage(imageId).then(function(image) {
|
||||
// tempFunction(image);
|
||||
//});
|
||||
} else {
|
||||
let url;
|
||||
// This doesn't seem to have any benefit
|
||||
//if (data_url.startsWith("http")) {
|
||||
// url = data_url;
|
||||
//} else {
|
||||
// url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
|
||||
//}
|
||||
url = data_url;
|
||||
|
||||
if (url.endsWith("dcm")) {
|
||||
url = "wadouri:" + url;
|
||||
}
|
||||
|
||||
// if there is no extension treat it as a dicom
|
||||
if (/(?:\/|^)[^.\/]+$/.test(url)) {
|
||||
url = "wadouri:" + url;
|
||||
}
|
||||
|
||||
imageIds.push(url);
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
cornerstone.enable(element);
|
||||
cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
|
||||
cornerstone.displayImage(element, image);
|
||||
|
||||
cornerstoneTools.addToolForElement(element, PanTool);
|
||||
cornerstoneTools.addToolForElement(element, ZoomTool);
|
||||
cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool);
|
||||
cornerstoneTools.addToolForElement(element, WwwcTool);
|
||||
cornerstoneTools.addToolForElement(element, WwwcRegionTool);
|
||||
cornerstoneTools.addToolForElement(element, RotateTool);
|
||||
cornerstoneTools.addToolForElement(element, StackScrollTool);
|
||||
cornerstoneTools.addToolForElement(element, MagnifyTool);
|
||||
|
||||
cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
|
||||
configuration: {
|
||||
getTextCallback: () => {},
|
||||
changeTextCallback: () => {},
|
||||
allowEmptyLabel: true,
|
||||
renderDashed: false,
|
||||
drawHandles: false,
|
||||
drawHandlesOnHover: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Enable our tools
|
||||
// Avoid incorrect aspect ratio
|
||||
cornerstoneTools.setToolActiveForElement(element, "Pan", {
|
||||
mouseButtonMask: 1
|
||||
});
|
||||
cornerstoneTools.setToolActiveForElement(element, "Wwwc", {
|
||||
mouseButtonMask: 2
|
||||
});
|
||||
cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", {
|
||||
mouseButtonMask: 3
|
||||
});
|
||||
cornerstoneTools.setToolActiveForElement(element, "Zoom", {
|
||||
mouseButtonMask: 4
|
||||
});
|
||||
|
||||
if (element.dataset.edit_annotation == "true") {
|
||||
cornerstoneTools.setToolActiveForElement(element, "ArrowAnnotate", {
|
||||
mouseButtonMask: 2
|
||||
});
|
||||
} else {
|
||||
cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate");
|
||||
}
|
||||
|
||||
if (element.dataset.annotations) {
|
||||
loadJsonToolStateOnCurrentImage(element, element.dataset.annotations)
|
||||
}
|
||||
|
||||
cornerstone.resize(element);
|
||||
|
||||
|
||||
}).catch((err, err2) => {
|
||||
console.log(err);
|
||||
});
|
||||
|
||||
}
|
||||
console.log("setUpDicom defined");
|
||||
|
||||
// keep reference to the existing 2D setup
|
||||
window.setUpDicomLegacy = setUpDicomLegacy;
|
||||
|
||||
async function setUpDicom3d(element) {
|
||||
console.log("setUpDicom3d (cornerstone3d) for", element);
|
||||
@@ -546,13 +614,28 @@ async function setUpDicom3d(element) {
|
||||
console.log(renderingEngine)
|
||||
|
||||
const viewportInput = {
|
||||
viewportId,
|
||||
type: Enums.ViewportType.STACK,
|
||||
element,
|
||||
viewportId,
|
||||
type: Enums.ViewportType.STACK,
|
||||
element,
|
||||
//viewportType: Enums.ViewportType.STACK, // Stack viewport for slice stacks
|
||||
};
|
||||
|
||||
renderingEngine.enableElement(viewportInput);
|
||||
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);
|
||||
|
||||
@@ -597,10 +680,50 @@ async function setUpDicom3d(element) {
|
||||
|
||||
|
||||
// Prepare imageIds: ensure remote URLs are usable by dicom-image-loader (wadouri:)
|
||||
// but avoid forcing wadouri: for standard web images (png/jpg/etc) because
|
||||
// the dicom parser will fail when given non-DICOM files. For such images
|
||||
// return the raw URL so the appropriate web-image loader can handle them.
|
||||
// If any image looks like a plain web image (png/jpg/etc) prefer the
|
||||
// existing 2D cornerstone loader which handles http(s) image schemes.
|
||||
// This avoids a situation where the 3D dicom loader has no handler for
|
||||
// the 'https' scheme and throws "No image loader found for scheme 'https'".
|
||||
const looksLikeWebImage = (u) => {
|
||||
if (!u) return false;
|
||||
try { u = u.trim(); } catch (e) {}
|
||||
if (u.startsWith('data:image')) return true;
|
||||
if (u.match(/\.(png|jpe?g|gif|bmp|webp)$/i)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
if (images.some(looksLikeWebImage)) {
|
||||
// Fallback: use the 2D cornerstone loader which has web-image handling
|
||||
// and is more tolerant of raw http(s) URLs. We reuse the project's
|
||||
// dicomViewer helper (it expects a jQuery element).
|
||||
console.warn('setUpDicom3d: contains web-image(s) — falling back to 2D loader');
|
||||
try {
|
||||
// Remove any partially-created dv3d DOM and ensure the element is a
|
||||
// clean container for the 2D viewer. This ensures the `.canvas-panel`
|
||||
// appended by `loadCornerstone` overlays exactly the element area.
|
||||
try { element.innerHTML = ""; } catch (e) { $(element).empty(); }
|
||||
element.style.position = element.style.position || 'relative';
|
||||
// annotations are not available in this scope in the 3d code path; pass
|
||||
// undefined for annotations and let the loader handle it.
|
||||
dicomViewer.loadCornerstone($(element), null, images, undefined, images.length > 5);
|
||||
// store a lightweight marker so callers can inspect what happened
|
||||
element._cornerstone3d = { fallback: 'cornerstone2d', imageCount: images.length };
|
||||
return element._cornerstone3d;
|
||||
} catch (err) {
|
||||
console.warn('Fallback 2D loader failed; will continue to try 3D loader', err);
|
||||
// fallthrough to attempt 3D loading below
|
||||
}
|
||||
}
|
||||
|
||||
const imageIds = images.map((url) => {
|
||||
url = url.trim();
|
||||
// if it's already a scheme we pass through; otherwise treat as wadouri
|
||||
if (/^(wadouri:|base64:|data:)/i.test(url)) return url;
|
||||
// if it's already a scheme we pass through
|
||||
if (/^(wadouri:|base64:|data:|http:|https:)/i.test(url)) return url;
|
||||
|
||||
// default: treat as wadouri (likely a DICOM file)
|
||||
return `wadouri:${url}`;
|
||||
});
|
||||
|
||||
@@ -617,7 +740,23 @@ async function setUpDicom3d(element) {
|
||||
}
|
||||
}
|
||||
|
||||
await viewport.setStack(imageIds, 0);
|
||||
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();
|
||||
|
||||
@@ -738,3 +877,44 @@ function initializeClock(id, endtime) {
|
||||
|
||||
window.initializeClock = initializeClock
|
||||
|
||||
|
||||
|
||||
// Unwrap single-root <p> elements from TinyMCE content before any form submit.
|
||||
// This prevents a single wrapping <p> from being sent to the server while
|
||||
// preserving other markup. We use a delegated submit listener so it works
|
||||
// for dynamically-added forms as well (e.g. admin inlines).
|
||||
document.addEventListener('submit', function(evt) {
|
||||
try {
|
||||
var form = evt.target;
|
||||
if (!form || !form.querySelector) return;
|
||||
// Find all tinyMCE textareas inside the form
|
||||
var areas = form.querySelectorAll('textarea.tinymce');
|
||||
areas.forEach(function(area) {
|
||||
var id = area.id;
|
||||
if (!id) return;
|
||||
var ed = (typeof tinyMCE !== 'undefined') ? tinyMCE.get(id) : null;
|
||||
if (!ed) return;
|
||||
var html = (ed.getContent && typeof ed.getContent === 'function') ? ed.getContent({format: 'html'}) : area.value;
|
||||
if (!html) return;
|
||||
// Use a temporary container to inspect top-level elements safely
|
||||
var tmp = document.createElement('div');
|
||||
tmp.innerHTML = html.trim();
|
||||
// If there is exactly one top-level element and it is a <p>, unwrap it
|
||||
if (tmp.childElementCount === 1 && tmp.firstElementChild && tmp.firstElementChild.tagName.toLowerCase() === 'p') {
|
||||
var inner = tmp.firstElementChild.innerHTML;
|
||||
// Update editor content and the underlying textarea value
|
||||
try {
|
||||
ed.setContent(inner);
|
||||
} catch (e) {
|
||||
// fallback: set textarea value directly
|
||||
area.value = inner;
|
||||
}
|
||||
// Ensure the textarea value matches (some setups sync on save, some don't)
|
||||
area.value = inner;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// be resilient; don't prevent form submission if something goes wrong here
|
||||
console.error('TinyMCE submit postprocess error', e);
|
||||
}
|
||||
}, true);
|
||||
+3
-5
@@ -1,7 +1,7 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import A
|
||||
|
||||
from generic.tables import SingleImageColumn
|
||||
from generic.tables import SingleImageColumn, SelectionTable
|
||||
|
||||
from .models import AnatomyQuestion, UserAnswer
|
||||
|
||||
@@ -13,7 +13,7 @@ from easy_thumbnails.exceptions import InvalidImageFormatError
|
||||
|
||||
#from generic.tables import UserAnswerTable
|
||||
|
||||
class AnatomyQuestionTable(tables.Table):
|
||||
class AnatomyQuestionTable(SelectionTable):
|
||||
edit = tables.LinkColumn(
|
||||
"anatomy:anatomy_question_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
@@ -30,9 +30,7 @@ class AnatomyQuestionTable(tables.Table):
|
||||
)
|
||||
exams = tables.ManyToManyColumn(verbose_name="Exams")
|
||||
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = AnatomyQuestion
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = (
|
||||
|
||||
@@ -228,23 +228,30 @@
|
||||
|
||||
<h2>{{object|yesno:"Update,Create"}} Question</h2>
|
||||
{% help "Help" %}
|
||||
<p>This form can be used to create an Anatomy question.</p>
|
||||
<p>This form can be used to create an Anatomy question. Once created, the question can be edited or deleted as needed.</p>
|
||||
|
||||
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p>
|
||||
|
||||
<p>Answers can be defined once the question has been created (and labelled).</p>
|
||||
|
||||
<p>Not all fields are required.</p>
|
||||
|
||||
<p>Correct and incorrect answers can be defined once the question has been created.</p>
|
||||
|
||||
<p>Images can be selected either directly via the browse button or by dragging and dropping them into the designated area below.</p>
|
||||
|
||||
<p>Supported formats include JPEG, PNG, and GIF. It is also possible to use DICOM images.</p>
|
||||
{% endhelp %}
|
||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||
{% csrf_token %}
|
||||
<a href="/anatomy/examination/create" id="add_examination" class="add-popup"
|
||||
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
|
||||
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
|
||||
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
|
||||
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
|
||||
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
|
||||
|
||||
{{ form|crispy }}
|
||||
{% crispy form %}
|
||||
<div id="drop-container" class="drop-target">Drop image here
|
||||
<div id="drop-filenames"></div>
|
||||
</div>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -28,32 +28,60 @@
|
||||
{% if object %}
|
||||
{% include 'anatomy/question_link_header.html' %}
|
||||
{% endif %}
|
||||
<h2>Question Answers</h2>
|
||||
<details class="help-text">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p>This page can be used to add and modify answers associated with a question. Answers in here will be used for automarking submitted answers so it is best not to remove. Updating an answer here will not automatically update a submitted answers (scores cache would need to be refreshed)</p>
|
||||
</details>
|
||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||
{% csrf_token %}
|
||||
<h3>Answers:</h3>
|
||||
<div id="form_set">
|
||||
{% for form in answer_formset %}
|
||||
<ul class="no-error answer-formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form.as_ul }}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h2 class="mb-0">Question Answers</h2>
|
||||
<small class="text-muted">Manage answer keys for auto-marking</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<details class="help-text mb-3">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p class="mb-0">This page can be used to add and modify answers associated with a question. Answers here are used for auto-marking submitted responses; avoid removing entries unless you intend to re-score submissions.</p>
|
||||
</details>
|
||||
|
||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<h3 class="h5 mt-2">Answers</h3>
|
||||
<div id="form_set" class="mt-3">
|
||||
{% for form in answer_formset %}
|
||||
<div class="answer-card mb-3 p-3 border rounded bg-body-secondary">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
<div class="answer-fields">
|
||||
{{ form.as_ul }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{{ answer_formset.management_form }}
|
||||
|
||||
<div class="d-flex gap-2 align-items-center mt-3">
|
||||
<button type="button" id="add_more" class="btn btn-outline-secondary btn-sm">Add another answer</button>
|
||||
<input type="submit" class="submit-button btn btn-primary btn-sm ms-auto" value="Save answers" name="submit">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="empty_form" class="d-none">
|
||||
<div class='answer-card p-3 mb-3 border rounded bg-body-secondary'>
|
||||
{{ answer_formset.empty_form.as_ul }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ answer_formset.management_form }}
|
||||
<input type="button" value="Add More Answers" id="add_more">
|
||||
<p>
|
||||
<input type="submit" class="submit-button btn btn-primary" value="Submit" name="submit">
|
||||
</p>
|
||||
</form>
|
||||
<div id="empty_form" style="display:none">
|
||||
<ul class='no_error answer-formset'>
|
||||
{{ answer_formset.empty_form.as_ul }}
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
/* Answer card layout */
|
||||
.answer-card label { font-weight: 600; }
|
||||
.answer-card li { list-style: none; margin-bottom: .5rem; }
|
||||
.answer-card input[type="text"], .answer-card textarea, .answer-card select { width: 100%; }
|
||||
.help-text summary { cursor: pointer; font-weight:600; }
|
||||
/* Make the Add button more prominent on small screens */
|
||||
@media (max-width: 576px) {
|
||||
.submit-button { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -6,39 +6,39 @@
|
||||
|
||||
{% block navigation %}
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-dark submenu">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{% url 'anatomy:index' %}">Anatomy</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse">
|
||||
<ul class="navbar-nav">
|
||||
{% if request.user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'anatomy:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'anatomy:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{% url 'anatomy:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'anatomy:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark submenu">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{% url 'anatomy:index' %}">Anatomy</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#anatomyNavbarNav" aria-controls="anatomyNavbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="anatomyNavbarNav">
|
||||
<ul class="navbar-nav">
|
||||
{% if request.user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'anatomy:user_answer_table_view' %}" title="User answers">Answers</a>
|
||||
<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 %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% if simple_content %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% extends 'generic/exam_scores_base.html' %}
|
||||
{% extends base_template|default:'generic/exam_scores_base.html' %}
|
||||
|
||||
{% block table_answers %}
|
||||
{% for question in questions %}
|
||||
@@ -10,10 +10,12 @@
|
||||
{% endfor %} {% endcomment %}
|
||||
{% for cid in cids %}
|
||||
{% with by_question|get_item:question|get_item:cid as ans_score %}
|
||||
<td class="anatomy-ans user-answer-score-{{ans_score.1}}"
|
||||
<td class="anatomy-ans user-answer-score-{{ans_score.1}} col-cid-cell col-cid-{{ cid|slugify }}"
|
||||
title="answer score: {{ans_score.1}}"
|
||||
data-examquestionindex={{forloop.parentloop.counter0}}
|
||||
data-cid={{cid}}
|
||||
data-cid-name="{{ cids_user_id_map|get_item:cid|lower }}"
|
||||
data-cid-val="{{ cid|lower }}"
|
||||
>{{ans_score.0}}<a class="show-on-hover" href="{% url 'anatomy:exam_question_user_answer' exam.pk forloop.parentloop.counter0 cid|slice:':1' cid|slice:'2:' %}"><i class="bi bi-pen"></i>
|
||||
</a></td>
|
||||
{% endwith %}
|
||||
|
||||
@@ -1,81 +1,165 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load help_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="question-display-block">
|
||||
<div class="hide-show">HIDE/SHOW</div>
|
||||
<div class="inner-display-block">
|
||||
Question <span class="question-number">No question loaded</span><br />
|
||||
<span id="question-title"></span><br />
|
||||
<span id="question-question"></span>
|
||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''>
|
||||
<div class="container anatomy my-3">
|
||||
{% include 'user_score_header.html' %}
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-2 d-flex align-items-center justify-content-between">
|
||||
<div class="small text-muted">Filter questions by marks</div>
|
||||
<div>
|
||||
<select id="filter-score" class="form-select form-select-sm" style="min-width:120px;">
|
||||
<option value="all">All scores</option>
|
||||
<option value="2">2 (full)</option>
|
||||
<option value="1">1</option>
|
||||
<option value="0">0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush score-answer-list">
|
||||
{% for ans, score, correct_answer in answers_and_marks %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start" data-score="{{ score }}">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="me-3">
|
||||
<span class="badge bg-secondary">{{ forloop.counter }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold">Question {{ forloop.counter }}</div>
|
||||
<div class="small text-muted">Correct answer: {% if exam.publish_results %}<span class="correct-answer">{{ correct_answer }}</span>{% else %}—{% endif %}</div>
|
||||
<div class="mt-2"><pre class="mb-0">{{ ans }}</pre></div>
|
||||
{% if exam.publish_results or view_all_results %}
|
||||
<div class="small mt-2">
|
||||
Marks:
|
||||
{% if score == 2 %}
|
||||
<span class="badge bg-success ms-1">{{ score }}</span>
|
||||
{% elif score == 1 %}
|
||||
<span class="badge bg-warning text-dark ms-1">{{ score }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary ms-1">{{ score }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end ms-3">
|
||||
{% if exam.publish_results %}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary view-question-button" data-qn={{forloop.counter0}}>View</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" disabled>View</button>
|
||||
{% endif %}
|
||||
{% if request.user.is_staff %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-2 admin-edit-button" data-qn="{{ forloop.counter0 }}">Edit</button>
|
||||
{% endif %}
|
||||
{% if request.user.is_staff or view_all_results or request.user in exam.author.all %}
|
||||
{% with q=questions|get_item:forloop.counter0 %}
|
||||
{% with ua=user_answers_map|get_item:q.pk %}
|
||||
{% if ua %}
|
||||
<a href="{% url 'generic:view_answer_history' app_name='anatomy' model_name='UserAnswer' pk=ua.pk %}" class="btn btn-sm btn-outline-info ms-2">History</a>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'user_scores_footer.html' %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
{% if exam.publish_results %}
|
||||
<div class="card sticky-top" style="top:1rem;">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Viewer</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="hide-viewer">Hide</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="viewer-block">
|
||||
<div class="viewer-body overflow-auto p-3" style="max-height: calc(100vh - 160px);">
|
||||
<div>Question <span class="question-number">No question loaded</span></div>
|
||||
<div id="question-title" class="fw-bold my-2"></div>
|
||||
<div id="question-question" class="mb-2"></div>
|
||||
<div id="single-dicom-viewer" class="dicom-viewer mb-2" data-images="" data-annotations=''></div>
|
||||
<div class="small text-muted">Answers:</div>
|
||||
<div class="answers mt-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="answers">Answers: </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="anatomy">
|
||||
{% include 'user_score_header.html' %}
|
||||
<ul class="score-answer-list">
|
||||
{% for ans, score, correct_answer in answers_and_marks %}
|
||||
<li class="user-answer-li" data-question-number="{{forloop.counter}}">Question {{forloop.counter}} - Correct answer: {% if exam.publish_results %}<span class="correct-answer">{{correct_answer}}</span>{% endif %}
|
||||
<br/>
|
||||
<span class="user-answer-score user-answer-score-{{score}}">
|
||||
<span class="submitted-user-answer">
|
||||
<pre>{{ans}}</pre>
|
||||
</span>
|
||||
{% if exam.publish_results or view_all_results%}
|
||||
<span class="answer-score">{{score}}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="view-question-link" data-qn={{forloop.counter0}}>View</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% include 'user_scores_footer.html' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block css %}
|
||||
<style>
|
||||
/* Ensure the sticky viewer stays within its column and the canvas scales */
|
||||
.anatomy .card.sticky-top { max-width: 100%; }
|
||||
/* Keep viewer usable by enforcing a minimum width on larger viewports */
|
||||
@media (min-width: 768px) {
|
||||
.anatomy .card.sticky-top { min-width: 600px; }
|
||||
}
|
||||
.anatomy .viewer-block .viewer-body { overflow: auto; }
|
||||
.viewer-block .answers { background: #f8f9fa; padding: .5rem; border-radius: .25rem; color: #212529; }
|
||||
.viewer-block .answers, .viewer-block .answers * { color: #212529 !important; }
|
||||
.viewer-block .answers pre { margin: 0; white-space: pre-wrap; }
|
||||
.dicom-viewer, .dicom-viewer .canvas-panel, .dicom-viewer .cornerstone-element { max-width: 100%; box-sizing: border-box; }
|
||||
.dicom-viewer canvas { max-width: 100% !important; width: 100% !important; height: auto !important; display: block; }
|
||||
#single-dicom-viewer { width: 100%; }
|
||||
/* hide horizontal overflow from the card body so the page doesn't scroll sideways */
|
||||
.anatomy .card .card-body { overflow-x: hidden; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$(".hide-show").click((evt, el) => {
|
||||
$(".inner-display-block").toggle();
|
||||
})
|
||||
{% if exam.publish_results %}
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$("#hide-viewer").on('click', function(){ $('.viewer-block .viewer-body').toggle(); });
|
||||
|
||||
$(".view-question-link").on("click", function (e) {
|
||||
console.log("click", e)
|
||||
question_number = e.currentTarget.dataset.qn;
|
||||
$("#single-dicom-viewer").empty();
|
||||
$.ajax({
|
||||
url: `{% url 'anatomy:question_review' pk=exam.pk %}`,
|
||||
data: {
|
||||
csrfmiddlewaretoken: "{{ csrf_token }}",
|
||||
question_number: question_number
|
||||
},
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
})
|
||||
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
|
||||
.done(function (data) {
|
||||
console.log(data);
|
||||
|
||||
let event = new CustomEvent('loadDicomViewerUrls', {
|
||||
"detail": { "images": data.images, "annotations": data.annotations }
|
||||
// Filter questions by score using Bootstrap's d-none for consistent layout
|
||||
$("#filter-score").on('change', function(){
|
||||
const val = $(this).val();
|
||||
$(".list-group-item[data-score]").each(function(){
|
||||
const s = $(this).data('score');
|
||||
if(val === 'all' || String(s) === String(val)) $(this).removeClass('d-none'); else $(this).addClass('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
window.dispatchEvent(event);
|
||||
$(".question-display-block .answers").empty().append(data.answers.toString());
|
||||
$("#question-title").empty().append(data.title);
|
||||
$("#question-question").empty().append(data.question);
|
||||
n = parseInt(question_number) + 1
|
||||
$(".question-display-block .question-number").empty().append(n);
|
||||
$(".inner-display-block").show();
|
||||
})
|
||||
.always(function () {
|
||||
console.log('[Done]');
|
||||
})
|
||||
})
|
||||
$(".view-question-button").on("click", function (e) {
|
||||
question_number = e.currentTarget.dataset.qn;
|
||||
$("#single-dicom-viewer").empty();
|
||||
$.ajax({
|
||||
url: `{% url 'anatomy:question_review' pk=exam.pk %}`,
|
||||
data: {
|
||||
csrfmiddlewaretoken: "{{ csrf_token }}",
|
||||
question_number: question_number
|
||||
},
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
})
|
||||
.done(function (data) {
|
||||
let event = new CustomEvent('loadDicomViewerUrls', { "detail": { "images": data.images, "annotations": data.annotations } });
|
||||
window.dispatchEvent(event);
|
||||
$(".viewer-block .answers").empty().append(data.answers.toString());
|
||||
$("#question-title").empty().append(data.title);
|
||||
$("#question-question").empty().append(data.question);
|
||||
n = parseInt(question_number) + 1
|
||||
$(".viewer-block .question-number").empty().append(n);
|
||||
$(".viewer-block .viewer-body").show();
|
||||
})
|
||||
.always(function () { console.log('[Done]'); });
|
||||
});
|
||||
|
||||
$(".view-question-link").first().click();
|
||||
|
||||
});
|
||||
</script>
|
||||
// auto-click first view button to populate viewer
|
||||
const firstView = $(".view-question-button:visible").first();
|
||||
if(firstView.length){ firstView.click(); }
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,14 +2,30 @@
|
||||
|
||||
{% block navigation %}
|
||||
{{block.super}}
|
||||
<br/>
|
||||
Exams: {{exam}}->
|
||||
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a> /
|
||||
{% if exam.exam_mode %}
|
||||
<a href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a> /
|
||||
<a href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a> /
|
||||
<a href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a> /
|
||||
<a href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a> /
|
||||
{% endif %}
|
||||
{% comment %} <a href="{% url 'anatomy:question_create_exam' pk=exam.pk %}" title="Add a new question directly into the exam.">Add New Question</a> {% endcomment %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
|
||||
<div class="container-fluid px-3">
|
||||
<span class="navbar-text me-3 text-white">
|
||||
<strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
|
||||
</span>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="examNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a></li>
|
||||
{% if exam.exam_mode %}
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark2_overview' pk=exam.pk %}">Mark2</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a></li>
|
||||
{% endif %}
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a></li>
|
||||
</ul>
|
||||
|
||||
{% include 'generic/partials/exams/dropdown_menu.html'%}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
<details>
|
||||
<summary>Suggest incorrect answers:</summary>
|
||||
{% 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
|
||||
@@ -43,7 +43,7 @@
|
||||
<div class="border border-secondary">
|
||||
{% include "anatomy/question_detail.html#suggest-incorrect-form" %}
|
||||
</div>
|
||||
</details>
|
||||
</details> {% endcomment %}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
{% extends 'anatomy/exams.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h5 class="card-title mb-1">Marking question {{ question_details.current }} of {{ question_details.total }}</h5>
|
||||
<div class="mb-2">
|
||||
<a class="btn btn-sm btn-outline-secondary me-1" href="{% url 'anatomy:question_detail' question.id %}" title="View the Question">View</a>
|
||||
<a class="btn btn-sm btn-outline-secondary me-1" href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
|
||||
{% if request.user.is_superuser %}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}" title="Admin Edit">Admin Edit</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'anatomy:mark' exam.id question_number %}">Classic mark</a>
|
||||
</div>
|
||||
<h6 class="mb-1">{{ question.question_type }}</h6>
|
||||
{% if question.structure %}
|
||||
<div class="small text-muted">Structure: {{ question.structure }}</div>
|
||||
{% endif %}
|
||||
<div class="mt-2">Primary answer: <span id="primary-answer" title="The primary answer of the question">{{ question.get_primary_answer }}</span></div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
{% if question.answer_help %}
|
||||
<button class="btn btn-sm btn-outline-info" data-bs-toggle="collapse" data-bs-target="#marking-help">Answer Help</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if question.answer_help %}
|
||||
<div class="collapse mt-2" id="marking-help">
|
||||
<div class="card card-body">
|
||||
{{ question.answer_help|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3">
|
||||
<form method="POST" class="post-form">{% csrf_token %}
|
||||
<p class="small text-muted">Click each answer to toggle through marks awarded (as per colour). This UI writes marks immediately.</p>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div></div>
|
||||
<div>
|
||||
{% if question_details.current > 1 %}
|
||||
<button type="submit" name="previous" class="btn btn-outline-secondary btn-sm me-1">Previous</button>
|
||||
{% endif %}
|
||||
<button type="submit" name="save" class="btn btn-primary btn-sm me-1">Save</button>
|
||||
{% if question_details.current < question_details.total %}
|
||||
<button type="submit" name="next" class="btn btn-outline-primary btn-sm me-1">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<button class="btn btn-sm btn-outline-info me-1" hx-get="{% url 'anatomy:mark2_exam_marked' exam.id question_number %}" hx-target="#exam-marked-container" hx-swap="innerHTML">Show exam-submitted answers</button>
|
||||
<button class="btn btn-sm btn-outline-info" hx-get="{% url 'anatomy:mark2_question_marked' question.pk %}" hx-target="#question-marked-container" hx-swap="innerHTML">Show all stored answers for question</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h6>Answers</h6>
|
||||
<ul id="answer-list" class="list-group answer-list mb-3">
|
||||
{# Render currently-stored marked answers first, then unmarked submissions #}
|
||||
{% include 'anatomy/partials/mark2_marked_list_fragment.html' %}
|
||||
{% include 'anatomy/partials/mark2_unmarked_list_fragment.html' %}
|
||||
</ul>
|
||||
<div id="exam-marked-container" class="mt-2"></div>
|
||||
<div id="question-marked-container" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="small text-muted key">Key: <span class="key-item correct">2 Marks</span>, <span class="key-item half-correct">1 Mark</span>, <span class="key-item incorrect">0 Marks</span></div>
|
||||
</div>
|
||||
|
||||
<span class="visually-hidden">{{ form.as_p }}</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div id="single-dicom-viewer" class="marking-dicom" data-images="{{ question.get_image_url_array }}" data-annotations='{{ question.get_image_annotations }}'></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
/* Ensure answer states are visible and clickable in the new layout */
|
||||
.answer { cursor: pointer; }
|
||||
/* Use border to indicate mark state instead of background */
|
||||
.answer.correct { color: #155724; background-color: transparent; }
|
||||
.answer.half-correct { color: #856404; background-color: transparent; }
|
||||
.answer.incorrect { color: #721c24; background-color: transparent; }
|
||||
.answer.not-marked { color: inherit; background-color: transparent; }
|
||||
.answer-list .list-group-item pre { margin: 0; font-family: monospace; white-space: pre-wrap; }
|
||||
.mark-controls { display:flex; gap:0.25rem; }
|
||||
.mark-btn { padding: 0.15rem 0.4rem; font-size: 0.8rem; }
|
||||
/* Per-state border indicators on the list item */
|
||||
.marked-item { }
|
||||
.marked-correct { border-left: 4px solid #2a9d3f; }
|
||||
.marked-half-correct { border-left: 4px solid #ffb020; }
|
||||
.marked-incorrect { border-left: 4px solid #d3413a; }
|
||||
|
||||
/* Key items use a small left-border swatch to match the list markers */
|
||||
.key .key-item { display:inline-block; padding-left:8px; margin-left:6px; }
|
||||
.key .key-item.correct { border-left: 8px solid #2a9d3f; }
|
||||
.key .key-item.half-correct { border-left: 8px solid #ffb020; }
|
||||
.key .key-item.incorrect { border-left: 8px solid #d3413a; }
|
||||
/* Right-floating action icons for answer external links */
|
||||
.answer-actions { min-width: 6rem; }
|
||||
.answer-actions .action-icon { margin-left: 0.5rem; color: #6c757d; text-decoration: none; border: none; background: none; }
|
||||
.answer-actions .action-icon:hover { color: #000; text-decoration: none; }
|
||||
.answer-actions .copy-to-clipboard { cursor: pointer; }
|
||||
/* Active state for numeric mark buttons */
|
||||
.mark-btn.active { background-color: #0d6efd; color: #fff; border-color: #0d6efd; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script>
|
||||
(function(){
|
||||
// CSRF helper for fetch
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
const csrftoken = getCookie('csrftoken');
|
||||
|
||||
// Delegated click handler for answers — only updates local state.
|
||||
document.addEventListener('click', function(e){
|
||||
// Ignore clicks on action icons or mark controls
|
||||
if (e.target.closest('.answer-actions') || e.target.closest('.mark-controls') || e.target.closest('.copy-to-clipboard')) return;
|
||||
|
||||
// Prefer a clicked span.answer if present
|
||||
let spanEl = e.target.closest('span.answer');
|
||||
// Otherwise fall back to the list-item's span
|
||||
if (!spanEl) {
|
||||
const li = e.target.closest('#answer-list li');
|
||||
if (!li) return;
|
||||
spanEl = li.querySelector('span.answer');
|
||||
if (!spanEl) return;
|
||||
}
|
||||
|
||||
// Ensure span has an explicit state class
|
||||
if (!spanEl.classList.contains('correct') && !spanEl.classList.contains('half-correct') && !spanEl.classList.contains('incorrect') && !spanEl.classList.contains('not-marked')) {
|
||||
spanEl.classList.add('not-marked');
|
||||
}
|
||||
|
||||
// cycle states: not-marked -> correct -> half-correct -> incorrect -> not-marked
|
||||
const states = ['not-marked', 'correct', 'half-correct', 'incorrect'];
|
||||
let currentState = 'not-marked';
|
||||
for (let p of spanEl.classList) {
|
||||
if (states.includes(p)) { currentState = p; break; }
|
||||
}
|
||||
let idx = (states.indexOf(currentState) + 1) % states.length;
|
||||
const newState = states[idx];
|
||||
|
||||
// map to numeric for setMarkForSpan
|
||||
let numeric = null;
|
||||
if (newState === 'correct') numeric = 2;
|
||||
else if (newState === 'half-correct') numeric = 1;
|
||||
else if (newState === 'incorrect') numeric = 0;
|
||||
|
||||
// delegate to setMarkForSpan to keep behaviour consistent (updates classes, active buttons, hidden field)
|
||||
setMarkForSpan(spanEl, numeric);
|
||||
|
||||
}, false);
|
||||
|
||||
function updateMarkedAnswersField() {
|
||||
const marked = { 'correct': [], 'half-correct': [], 'incorrect': [] };
|
||||
document.querySelectorAll('#answer-list span.answer').forEach(function(el) {
|
||||
if (el.classList.contains('correct')) marked.correct.push((el.title || el.textContent).trim());
|
||||
else if (el.classList.contains('half-correct')) marked['half-correct'].push((el.title || el.textContent).trim());
|
||||
else if (el.classList.contains('incorrect')) marked.incorrect.push((el.title || el.textContent).trim());
|
||||
});
|
||||
|
||||
// Ensure the hidden input exists
|
||||
let hidden = document.getElementById('id_marked_answers');
|
||||
if (!hidden) {
|
||||
hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = 'marked_answers';
|
||||
hidden.id = 'id_marked_answers';
|
||||
const form = document.querySelector('form.post-form');
|
||||
if (form) form.appendChild(hidden);
|
||||
}
|
||||
hidden.value = JSON.stringify(marked);
|
||||
}
|
||||
|
||||
// Enhance list items with numeric mark controls (2,1,0,Clear)
|
||||
function attachNumericControls() {
|
||||
function makeButton(label, cls) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'btn btn-sm btn-outline-secondary me-1 mark-btn ' + cls;
|
||||
b.textContent = label;
|
||||
return b;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.answer-list li').forEach(function(li) {
|
||||
if (li.querySelector('.mark-controls')) return; // already attached
|
||||
const span = li.querySelector('span.answer');
|
||||
if (!span) return;
|
||||
// Ensure span has an explicit default state class so logic is consistent
|
||||
if (!span.classList.contains('correct') && !span.classList.contains('half-correct') && !span.classList.contains('incorrect') && !span.classList.contains('not-marked')) {
|
||||
span.classList.add('not-marked');
|
||||
}
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'mark-controls mt-2';
|
||||
const b2 = makeButton('2', 'mark-2');
|
||||
const b1 = makeButton('1', 'mark-1');
|
||||
const b0 = makeButton('0', 'mark-0');
|
||||
const bClear = makeButton('⨯', 'mark-clear');
|
||||
|
||||
controls.appendChild(b2);
|
||||
controls.appendChild(b1);
|
||||
controls.appendChild(b0);
|
||||
controls.appendChild(bClear);
|
||||
// append after the pre
|
||||
li.appendChild(controls);
|
||||
|
||||
// wire clicks
|
||||
b2.addEventListener('click', function(){ setMarkForSpan(span, 2); });
|
||||
b1.addEventListener('click', function(){ setMarkForSpan(span, 1); });
|
||||
b0.addEventListener('click', function(){ setMarkForSpan(span, 0); });
|
||||
bClear.addEventListener('click', function(){ setMarkForSpan(span, null); });
|
||||
|
||||
// set initial active button based on existing state
|
||||
(function(){
|
||||
let state = null;
|
||||
if (span.classList.contains('correct')) state = 2;
|
||||
else if (span.classList.contains('half-correct')) state = 1;
|
||||
else if (span.classList.contains('incorrect')) state = 0;
|
||||
// clear any pre-existing active flags then set the correct one
|
||||
[b2, b1, b0, bClear].forEach(b => b.classList.remove('active'));
|
||||
if (state === 2) b2.classList.add('active');
|
||||
else if (state === 1) b1.classList.add('active');
|
||||
else if (state === 0) b0.classList.add('active');
|
||||
else bClear.classList.add('active');
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
function setMarkForSpan(el, numeric) {
|
||||
// numeric: 2 -> correct, 1 -> half-correct, 0 -> incorrect, null -> not-marked
|
||||
const li = el.closest('li');
|
||||
let state = 'not-marked';
|
||||
if (numeric === 2) state = 'correct';
|
||||
else if (numeric === 1) state = 'half-correct';
|
||||
else if (numeric === 0) state = 'incorrect';
|
||||
el.className = 'answer ' + state;
|
||||
if (li) {
|
||||
// clear previous per-state classes
|
||||
li.classList.remove('marked-correct','marked-half-correct','marked-incorrect','marked-item');
|
||||
if (state === 'not-marked') {
|
||||
// nothing
|
||||
} else if (state === 'correct') {
|
||||
li.classList.add('marked-item','marked-correct');
|
||||
} else if (state === 'half-correct') {
|
||||
li.classList.add('marked-item','marked-half-correct');
|
||||
} else if (state === 'incorrect') {
|
||||
li.classList.add('marked-item','marked-incorrect');
|
||||
}
|
||||
}
|
||||
// update active state of numeric controls for this list item
|
||||
try {
|
||||
const li = el.closest('li');
|
||||
if (li) {
|
||||
const btn2 = li.querySelector('.mark-2');
|
||||
const btn1 = li.querySelector('.mark-1');
|
||||
const btn0 = li.querySelector('.mark-0');
|
||||
const btnClear = li.querySelector('.mark-clear');
|
||||
[btn2, btn1, btn0, btnClear].forEach(b => { if (b) { b.classList.remove('active'); } });
|
||||
if (numeric === 2 && btn2) btn2.classList.add('active');
|
||||
else if (numeric === 1 && btn1) btn1.classList.add('active');
|
||||
else if (numeric === 0 && btn0) btn0.classList.add('active');
|
||||
else if (numeric === null && btnClear) btnClear.classList.add('active');
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
updateMarkedAnswersField();
|
||||
}
|
||||
|
||||
// Run once on load to add controls. Also re-run after HTMX swaps so controls
|
||||
// appear when partials are replaced.
|
||||
function initMark2() {
|
||||
attachNumericControls();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initMark2);
|
||||
} else {
|
||||
initMark2();
|
||||
}
|
||||
|
||||
// If HTMX is used to update parts of the page, re-run attachNumericControls
|
||||
// after swaps so newly inserted list items get controls.
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
initMark2();
|
||||
});
|
||||
|
||||
// Ensure the hidden marked_answers field is updated before any form submit
|
||||
const postForm = document.querySelector('form.post-form');
|
||||
if (postForm) {
|
||||
postForm.addEventListener('submit', function(e){
|
||||
try { updateMarkedAnswersField(); } catch (err) { /* still submit */ }
|
||||
});
|
||||
}
|
||||
|
||||
// Copy-to-clipboard handler for dropdown items
|
||||
document.addEventListener('click', function(e){
|
||||
const btn = e.target.closest('.copy-to-clipboard');
|
||||
if (!btn) return;
|
||||
const text = btn.getAttribute('data-text');
|
||||
if (!text) return;
|
||||
try {
|
||||
navigator.clipboard.writeText(text);
|
||||
// simple visual feedback: change the button text briefly
|
||||
const prev = btn.textContent;
|
||||
btn.textContent = 'Copied';
|
||||
setTimeout(function(){ btn.textContent = prev; }, 1200);
|
||||
} catch (err) {
|
||||
console.warn('copy failed', err);
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,79 @@
|
||||
{% extends 'anatomy/exams.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-4 anatomy">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="h4 mb-1">Mark2 — Marking: {{ exam }}</h2>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="btn-group me-2" role="group" aria-label="listing actions">
|
||||
<button class="btn btn-sm btn-outline-secondary show-all-button">Show all</button>
|
||||
<button class="btn btn-sm btn-outline-secondary show-unmarked-button">Show unmarked</button>
|
||||
</div>
|
||||
<a href="{% url 'anatomy:mark2' exam_pk=exam.pk sk=0 %}" class="btn btn-sm btn-primary">Start mark2</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-2">
|
||||
<ul class="list-group list-group-flush" id="question-mark-list">
|
||||
{% for question, unmarked_count, unmarked_count2 in question_unmarked_map %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start" data-unmarked="{{ unmarked_count }}">
|
||||
<div class="me-3">
|
||||
<a href="{% url 'anatomy:mark2' exam_pk=exam.pk sk=forloop.counter0 %}" class="fw-semibold">Question {{ forloop.counter }}:</a>
|
||||
<div class="small text-truncate" style="max-width:60vw;">{{ question }}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="small text-muted">Unmarked</div>
|
||||
{% if unmarked_count > 0 %}
|
||||
<span class="badge bg-danger">{{ unmarked_count }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ unmarked_count }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="list-group-item small text-muted">No questions found.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
function $(sel, ctx){ return (ctx||document).querySelector(sel); }
|
||||
function $all(sel, ctx){ return Array.from((ctx||document).querySelectorAll(sel)); }
|
||||
|
||||
var showAllBtn = $('.show-all-button');
|
||||
var showUnmarkedBtn = $('.show-unmarked-button');
|
||||
var list = $('#question-mark-list');
|
||||
if (!list) return;
|
||||
|
||||
function showAll(){
|
||||
$all('#question-mark-list .list-group-item').forEach(function(li){ li.classList.remove('d-none'); });
|
||||
showAllBtn && showAllBtn.classList.add('active');
|
||||
showUnmarkedBtn && showUnmarkedBtn.classList.remove('active');
|
||||
}
|
||||
|
||||
function showUnmarked(){
|
||||
$all('#question-mark-list .list-group-item').forEach(function(li){
|
||||
var u = parseInt(li.getAttribute('data-unmarked') || '0', 10);
|
||||
if (u > 0) li.classList.remove('d-none'); else li.classList.add('d-none');
|
||||
});
|
||||
showUnmarkedBtn && showUnmarkedBtn.classList.add('active');
|
||||
showAllBtn && showAllBtn.classList.remove('active');
|
||||
}
|
||||
|
||||
if (showAllBtn) showAllBtn.addEventListener('click', function(e){ e.preventDefault(); showAll(); });
|
||||
if (showUnmarkedBtn) showUnmarkedBtn.addEventListener('click', function(e){ e.preventDefault(); showUnmarked(); });
|
||||
|
||||
// Initialize to show all
|
||||
showAll();
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
.show-all-button.active, .show-unmarked-button.active { box-shadow: inset 0 -2px 0 rgba(0,0,0,0.08); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% load static %}
|
||||
<div id="exam-list">
|
||||
<form id="exam-edit-form">
|
||||
{% csrf_token %}
|
||||
{{ form.media }}
|
||||
<div class="input-group mb-2">
|
||||
{{ form.exam }}
|
||||
<button class="btn btn-sm btn-primary" type="button" hx-post="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-include="#id_exam" hx-target="#exam-list" hx-swap="innerHTML">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('exam-list').innerHTML='';">Close</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mb-2 small">Current exams:
|
||||
{% for exam in question.exams.all %}
|
||||
<form class="d-inline me-1" hx-post="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-target="#exam-list" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exam_id" value="{{ exam.id }}">
|
||||
<input type="hidden" name="remove" value="true">
|
||||
<button class="btn btn-sm btn-outline-danger mb-1" type="submit" hx-confirm="Are you sure you want to remove this exam?">{{ exam }}</button>
|
||||
</form>
|
||||
{% empty %}
|
||||
<span class="text-muted">No exams</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<div id="exam-links" class="small" hx-swap-oob="outerHTML">
|
||||
{% if question.exams.all %}
|
||||
{% for exam in question.exams.all %}
|
||||
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}" class="me-2">{{ exam }}</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
No exams
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -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,27 @@
|
||||
<div id="primary-answer-container" class="position-relative primary-answer-wrap py-1">
|
||||
<strong>{{ question.get_primary_answer }}</strong>
|
||||
{% if can_edit %}
|
||||
<button
|
||||
class="edit-primary-btn btn btn-sm btn-outline-secondary position-absolute"
|
||||
style="right:0; top:0;"
|
||||
hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}"
|
||||
hx-target="#primary-answer-container"
|
||||
hx-swap="outerHTML"
|
||||
aria-label="Edit primary answer"
|
||||
>
|
||||
Edit primary answer
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
/* Scoped styles for primary answer edit control: match outline-secondary look */
|
||||
.primary-answer-wrap .edit-primary-btn {
|
||||
opacity: 0.9;
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
.primary-answer-wrap:hover .edit-primary-btn {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
<div id="primary-answer-container">
|
||||
<span class="small text-muted">No primary answer set</span>
|
||||
<button class="btn btn-sm btn-link" hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}" hx-target="#primary-answer-container" hx-swap="outerHTML">Add primary answer</button>
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<div id="primary-answer-container">
|
||||
<form hx-post="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}" hx-target="#primary-answer-container" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="mb-2">
|
||||
<label for="id_existing_answer" class="form-label">Select existing answer</label>
|
||||
<select name="existing_answer" id="id_existing_answer" class="form-select">
|
||||
{% for val, text in form.existing_answer.field.choices %}
|
||||
<option value="{{ val }}" {% if val == form.initial.existing_answer %}selected{% endif %}>{{ text }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label for="id_new_answer" class="form-label">Or add a new primary answer</label>
|
||||
<textarea name="new_answer" id="id_new_answer" class="form-control" rows="3"></textarea>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const sel = document.getElementById('id_existing_answer');
|
||||
const ta = document.getElementById('id_new_answer');
|
||||
if(!sel || !ta) return;
|
||||
|
||||
function update() {
|
||||
try {
|
||||
if (ta.value && ta.value.trim().length > 0) {
|
||||
sel.disabled = true;
|
||||
} else {
|
||||
sel.disabled = false;
|
||||
}
|
||||
} catch(e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// initial state
|
||||
update();
|
||||
|
||||
// listeners
|
||||
sel.addEventListener('change', function(){
|
||||
if(this.value && this.value !== '') {
|
||||
ta.disabled = true;
|
||||
} else {
|
||||
ta.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
ta.addEventListener('input', update);
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary btn-sm" type="submit">Save primary answer</button>
|
||||
<button class="btn btn-secondary btn-sm" type="button" hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}?cancel=1" hx-target="#primary-answer-container" hx-swap="outerHTML">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<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>
|
||||
{% if can_edit %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</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,118 +1,186 @@
|
||||
{% extends 'anatomy/base.html' %}
|
||||
{% load partials %}
|
||||
|
||||
{% block content %}
|
||||
{% load static %}
|
||||
{% include 'anatomy/question_link_header.html' %}
|
||||
<div id="dicom-image" class="dicom-image" data-url="{{ remote_url }}{{ question.image.url}}"
|
||||
data-annotations='{{question.image_annotations}}' data-edit_annotation=true>
|
||||
<details class="help-text">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p>Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. To save the changes click the "Save Annotations" button.</p>
|
||||
|
||||
<p>There is no limit to the number of arrows that can be added to the image.</p>
|
||||
</details>
|
||||
<button id="save-annotations">Save Annotations</button>
|
||||
|
||||
</div>
|
||||
<div class="question">
|
||||
<div class="date">
|
||||
Created: {{ question.created_date|date:"d/m/Y" }}
|
||||
</div>
|
||||
<h2>Question type: {{question.question_type}}</h2>
|
||||
<h3>Primary answer: {{ question.get_primary_answer }}</h3>
|
||||
<div>
|
||||
<details>
|
||||
<summary title="Click to view the question answers">
|
||||
Answers:
|
||||
</summary>
|
||||
<table>
|
||||
<tr><th>Answer</th><th>Score</th></tr>
|
||||
{% for answer in question.answers.all|dictsortreversed:"status" %}
|
||||
<tr>
|
||||
<td {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="anatomy"
|
||||
{% endif %}>
|
||||
<span >
|
||||
{{ answer }}
|
||||
</span>
|
||||
<td>
|
||||
<td>{{answer.status}}</td>
|
||||
{% if answer.proposed %}
|
||||
<td>
|
||||
<button class="btn btn-sm accept-button" data-aid={{answer.id}}
|
||||
hx-get="{% url 'anatomy:confirm_answer' answer.id %}"
|
||||
title="Click to accept the proposed answer">Accept</button>
|
||||
<button class="btn btn-sm delete-button" data-aid={{answer.id}}
|
||||
hx-get="{% url 'anatomy:delete_answer' answer.id %}"
|
||||
title="Click to delete the proposed answer"
|
||||
>Delete</button>
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</details>
|
||||
</div>
|
||||
<div>
|
||||
Answer help: {{ question.answer_help|safe }}
|
||||
</div>
|
||||
<div>
|
||||
{% partialdef suggest-incorrect-form inline %}
|
||||
<span id="incorrect-answers">
|
||||
Answer suggest incorrect: {{ question.answer_suggest_incorrect }}
|
||||
<button id="answer-suggest-incorrect-button" class="btn btn-sm" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}"
|
||||
hx-target="#incorrect-answers"
|
||||
hx-swap="innerHTML">Edit suggestions</button>
|
||||
</span>
|
||||
{% endpartialdef %}
|
||||
</div>
|
||||
<div>
|
||||
Description: {{ question.description }}
|
||||
</div>
|
||||
<div>
|
||||
Exams: {% for exam in question.exams.all %}
|
||||
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">{{ exam }}</a>
|
||||
{% endfor %}
|
||||
<button class="btn btn-sm" hx-get="{% url 'anatomy:question_add_exam' question_id=question.pk %}"
|
||||
hx-target="#exam-list"
|
||||
hx-swap="innerHTML">Edit exam(s)</button>
|
||||
<span id="exam-list"></span>
|
||||
</div>
|
||||
<div>
|
||||
Modality: {{ question.modality }}
|
||||
</div>
|
||||
<div>
|
||||
Region: {{ question.region }}
|
||||
</div>
|
||||
<div>
|
||||
Structure: {{ question.structure }}
|
||||
</div>
|
||||
<div>
|
||||
Body Part: {{ question.body_part }}
|
||||
</div>
|
||||
<div>
|
||||
Author: {% for author in question.author.all %}
|
||||
{{ author }},
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
Open access: {{ question.open_access }}
|
||||
</div>
|
||||
{% include 'question_notes.html' %}
|
||||
<div>
|
||||
<details>
|
||||
<summary>
|
||||
<span id="annotation-json-toggle">Annotation JSON (+/-):</span> <span id="annotation-json-content">
|
||||
</summary>
|
||||
|
||||
{% if question.image_annotations %}
|
||||
<pre>{{ question.image_annotations }}</pre>
|
||||
{% else %}
|
||||
No saved annotations.
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div id="anatomy-dicom-image" class="dicom-image-legacy w-100" data-url="{{ question.get_image_url }}" data-annotations='{{question.image_annotations}}' data-edit_annotation="{{ can_edit|yesno:'true,false' }}" style="min-height:300px">
|
||||
</div>
|
||||
{% if not question.image_annotations %}
|
||||
<div class="alert alert-warning mt-3" role="alert">
|
||||
<strong>No image annotations defined.</strong>
|
||||
<div class="small">If important markers or arrows are not burned onto the image, please add annotations using the image annotation tool and click <em>Save Annotations</em>. Please note not all questions required image annotations in which case you can ignore this message.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</span>
|
||||
</details>
|
||||
{% if can_edit %}
|
||||
<div class="mt-3 align-items-start">
|
||||
<div class="col" style="min-width:0;">
|
||||
<details class="mb-0">
|
||||
<summary class="small text-muted"><i class="bi bi-info-circle"></i> Image annotation 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. If required use the middle button to zoom and the left button to pan.</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="d-flex gap-2">
|
||||
<button id="save-annotations" class="btn btn-sm btn-primary">Save Annotations</button>
|
||||
<button id="delete-annotations" class="btn btn-sm btn-outline-danger">Delete Annotations</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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>
|
||||
{% if question.primary_answer %}
|
||||
{% include 'anatomy/partials/primary_answer_display.html' %}
|
||||
{% else %}
|
||||
<div id="primary-answer-container" hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}" hx-trigger="load" hx-swap="outerHTML"></div>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
.answer.correct { color: #155724; }
|
||||
.answer.half-correct { color: #856404; }
|
||||
.answer.incorrect { color: #721c24; }
|
||||
.marked-correct { border-left: 4px solid #2a9d3f; }
|
||||
.marked-half-correct { border-left: 4px solid #ffb020; }
|
||||
.marked-incorrect { border-left: 4px solid #d3413a; }
|
||||
</style>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<h6 class="mb-0">Answers</h6>
|
||||
<div>
|
||||
{% if can_edit %}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'anatomy:question_answer_update' pk=question.pk %}">Edit answers</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<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" %}
|
||||
{% comment %}Add visual mark state classes consistent with mark2{% endcomment %}
|
||||
<tr class="{% if answer.status == '2' %}marked-correct{% elif answer.status == '1' %}marked-half-correct{% elif answer.status == '0' %}marked-incorrect{% endif %}">
|
||||
<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>
|
||||
{% if answer.status == '2' %}
|
||||
<span class="answer correct">{{ answer.get_status_display|default:'Correct' }}</span>
|
||||
{% elif answer.status == '1' %}
|
||||
<span class="answer half-correct">{{ answer.get_status_display|default:'Half mark' }}</span>
|
||||
{% elif answer.status == '0' %}
|
||||
<span class="answer incorrect">{{ answer.get_status_display|default:'Incorrect' }}</span>
|
||||
{% else %}
|
||||
<span class="answer not-marked">{{ answer.get_status_display|default:'Unmarked' }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if answer.proposed and can_edit %}
|
||||
<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 %}
|
||||
|
||||
<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>
|
||||
{% include 'anatomy/partials/exam_links.html' %}
|
||||
{% if can_edit %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
{% include 'atlas/partials/json_pretty.html' with json_value=question.image_annotations title='Image annotations' %}
|
||||
{% else %}
|
||||
No saved annotations.
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
@@ -154,11 +222,34 @@
|
||||
console.log('[Done]');
|
||||
})
|
||||
})
|
||||
$("#delete-annotations").click(function () {
|
||||
if (!confirm('Clear saved annotations for this question? This cannot be undone.')) return;
|
||||
$.ajax({
|
||||
url: "{% url 'anatomy:question_clear_annotation' pk=question.pk %}",
|
||||
data: {
|
||||
csrfmiddlewaretoken: "{{ csrf_token }}",
|
||||
},
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
})
|
||||
.done(function (data) {
|
||||
if (data.status == "success") {
|
||||
toastr.info('Annotations cleared');
|
||||
// Optionally reload part of the page or remove the annotation overlay
|
||||
location.reload();
|
||||
} else {
|
||||
toastr.warning('Error clearing annotations');
|
||||
}
|
||||
})
|
||||
.fail(function () {
|
||||
toastr.warning('Error clearing annotations');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
function getJsonToolStateNoId() {
|
||||
const el = document.getElementById("dicom-image");
|
||||
const el = document.getElementById("anatomy-dicom-image");
|
||||
|
||||
const c = cornerstone.getEnabledElement(el);
|
||||
|
||||
@@ -174,6 +265,99 @@
|
||||
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// HTMX quick-add helpers for word buttons inside the answers table
|
||||
var SUGGEST_URL = "{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}";
|
||||
var CSRF_TOKEN = "{{ csrf_token }}";
|
||||
|
||||
function toggleWords(btn) {
|
||||
var container = btn.nextElementSibling; // the flex-grow-1 wrapper
|
||||
if (!container) return;
|
||||
var wordContainer = container.querySelector('.word-buttons');
|
||||
if (!wordContainer) return;
|
||||
// populate if empty
|
||||
if (!wordContainer.dataset.populated) {
|
||||
var pre = container.querySelector('pre');
|
||||
var text = pre ? pre.textContent || pre.innerText : '';
|
||||
buildWordButtons(wordContainer, text);
|
||||
wordContainer.dataset.populated = '1';
|
||||
}
|
||||
var expanded = !wordContainer.classList.contains('d-none');
|
||||
if (expanded) {
|
||||
wordContainer.classList.add('d-none');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
wordContainer.setAttribute('aria-hidden', 'true');
|
||||
} else {
|
||||
wordContainer.classList.remove('d-none');
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
wordContainer.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function buildWordButtons(container, text) {
|
||||
if (!text) return;
|
||||
var toks = (text.toLowerCase().match(/\w+/g) || []);
|
||||
var seen = {};
|
||||
toks.forEach(function(t){
|
||||
if (t.length < 3) return;
|
||||
if (seen[t]) return;
|
||||
seen[t]=true;
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'btn btn-sm btn-outline-primary me-1 mb-1';
|
||||
b.textContent = t;
|
||||
b.dataset.word = t;
|
||||
// do not attach inline handlers here; use delegated listener
|
||||
container.appendChild(b);
|
||||
});
|
||||
if (!Object.keys(seen).length) {
|
||||
var span = document.createElement('div');
|
||||
span.className = 'small text-muted';
|
||||
span.textContent = 'No suitable words';
|
||||
container.appendChild(span);
|
||||
}
|
||||
}
|
||||
|
||||
// Delegated click handler to keep markup unobtrusive and work for HTMX swaps
|
||||
document.addEventListener('click', function(e){
|
||||
var toggle = e.target.closest('.toggle-words-btn');
|
||||
if (toggle) {
|
||||
e.preventDefault();
|
||||
toggleWords(toggle);
|
||||
return;
|
||||
}
|
||||
var wordBtn = e.target.closest('.word-buttons button');
|
||||
if (wordBtn) {
|
||||
e.preventDefault();
|
||||
var word = wordBtn.dataset.word || wordBtn.textContent.trim();
|
||||
if (word) addQuickSuggestion(word);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
function addQuickSuggestion(word) {
|
||||
if (!word) return;
|
||||
if (typeof htmx === 'undefined') {
|
||||
// Fallback: simple POST via fetch
|
||||
fetch(SUGGEST_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': CSRF_TOKEN },
|
||||
body: new URLSearchParams({ suggestion: word, quick: '1' })
|
||||
}).then(function(r){ return r.text(); }).then(function(html){
|
||||
var wrapper = document.getElementById('incorrect-answers');
|
||||
if (wrapper) wrapper.innerHTML = html;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
htmx.ajax('POST', SUGGEST_URL, {
|
||||
values: { suggestion: word, quick: '1' },
|
||||
headers: { 'X-CSRFToken': CSRF_TOKEN },
|
||||
target: '#incorrect-answers',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
@@ -182,9 +366,9 @@
|
||||
content: "\F505";
|
||||
font-family: bootstrap-icons;
|
||||
}
|
||||
/* Ensure pre-wrapped content inside cards and tables so long answers don't overflow */
|
||||
.card pre, .table pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
.question div {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.card .mb-2, .card .mb-3 { margin-bottom: .75rem; }
|
||||
</style>
|
||||
{% endblock css %}
|
||||
@@ -1,28 +1,98 @@
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
|
||||
<div class="container-fluid px-3">
|
||||
<span class="navbar-text me-3 text-white">
|
||||
<strong>Question:</strong>
|
||||
{% if question.stem %}
|
||||
{{ question.stem|striptags|truncatechars:80 }}
|
||||
{% else %}
|
||||
#{{ question.pk }}
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<div class="floating-header">
|
||||
<a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
|
||||
<a href="{% url 'anatomy:question_clone' question.id %}" title="Clone the Question">Clone</a>
|
||||
<a href="{% url 'anatomy:question_delete' pk=question.pk %}" title="Delete the Question">Delete</a>
|
||||
<a href="{% url 'anatomy:question_answer_update' pk=question.pk %}" title="Update the question answers">Edit Answers</a>
|
||||
<a href="#"
|
||||
onclick="return window.create_popup_window('{% url 'feedback_create' question_type='anatomy' pk=question.pk %}')">
|
||||
Add Note</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#questionNav" aria-controls="questionNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
<a href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}"
|
||||
title="Edit the Question using the admin interface">Admin Edit</a>
|
||||
<a href="{% url 'anatomy:question_user_answers' question.id %}" title="View user answers associated with this question">User answers</a>
|
||||
{% endif %}
|
||||
{% if exam %}
|
||||
<div>
|
||||
|
||||
{% if previous > -1 %}
|
||||
<a href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous question</a>
|
||||
<div class="collapse navbar-collapse" id="questionNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
{% if exam %}
|
||||
<li class="nav-item">
|
||||
{% if previous > -1 %}
|
||||
<a class="nav-link" href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li class="nav-item align-self-center">
|
||||
Viewing as part of exam: <a class="nav-link d-inline p-0" href="{% url 'anatomy:exam_overview' exam.id %}">{{ exam }}</a>
|
||||
<span class="navbar-text text-muted ms-2">[{{ pos }}/{{ exam_length }}]</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
{% if next %}
|
||||
<a class="nav-link" href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
Viewing question as part of exam: <a href="{% url 'anatomy:exam_overview' exam.id %}">{{exam}}</a> [{{pos}}/{{exam_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next question</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="questionActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="questionActions">
|
||||
<li>
|
||||
<a class="dropdown-item text-primary" href="{% url 'anatomy:question_detail' question.id %}">
|
||||
<i class="bi bi-eye me-2"></i> View
|
||||
</a>
|
||||
</li>
|
||||
{% if can_edit %}
|
||||
<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>
|
||||
{% endif %}
|
||||
<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>
|
||||
|
||||
{% if can_edit %}
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item text-warning" href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}">
|
||||
<i class="bi bi-tools me-2"></i> Admin Edit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item text-muted" href="{% url 'anatomy:question_user_answers' question.id %}">
|
||||
<i class="bi bi-people me-2"></i> User answers
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -240,12 +240,11 @@ def test_exams(db, client):
|
||||
res = client.post(reverse("global_exam_answers_submit"), data=post_json)
|
||||
json_res = json.loads(res.content)
|
||||
assert json_res["success"] == False
|
||||
assert json_res["error"] == "Invalid data"
|
||||
assert json_res["error"] in ("Invalid data", "user / cid mismatch")
|
||||
|
||||
valid_answers = []
|
||||
|
||||
for questions in exam_json["questions"]:
|
||||
for qid in questions:
|
||||
for qid in exam_json["questions"]:
|
||||
post_data = {
|
||||
"eid": exam_json["eid"],
|
||||
"cid": post_cid,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.test import Client
|
||||
|
||||
SAFE_STATUS_CODES = {200, 301, 302, 303, 403, 404, 405}
|
||||
|
||||
@pytest.fixture
|
||||
def candidate_client(db):
|
||||
user = User.objects.create_user(username="candidate", password="password", email="candidate@test.com")
|
||||
client = Client()
|
||||
client.force_login(user)
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def superuser_client(db):
|
||||
user = User.objects.create_superuser(username="superuser", password="password", email="superuser@test.com")
|
||||
# Add to editor and manager groups to satisfy all custom mixins
|
||||
for group_name in ["anatomy_editor", "cid_user_manager"]:
|
||||
g, _ = Group.objects.get_or_create(name=group_name)
|
||||
user.groups.add(g)
|
||||
client = Client()
|
||||
client.force_login(user)
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def anonymous_client(db):
|
||||
return Client()
|
||||
|
||||
@pytest.fixture
|
||||
def smoke_data(db, settings, tmp_path):
|
||||
from anatomy.models import AnatomyQuestion, Exam, UserAnswer, QuestionType, Structure, Answer
|
||||
import tempfile
|
||||
|
||||
settings.MEDIA_ROOT = str(tmp_path)
|
||||
|
||||
create_user = User.objects.create_user(username="smoke_user", email="smoke_user@test.com")
|
||||
|
||||
qtype, _ = QuestionType.objects.get_or_create(text="Test QType")
|
||||
structure, _ = Structure.objects.get_or_create(name="Test Structure")
|
||||
|
||||
image_file = tempfile.NamedTemporaryFile(dir=settings.MEDIA_ROOT, suffix=".jpg", delete=False)
|
||||
|
||||
question = AnatomyQuestion.objects.create(
|
||||
question_type=qtype,
|
||||
structure=structure,
|
||||
image=image_file.name
|
||||
)
|
||||
|
||||
exam = Exam.objects.create(
|
||||
name="Test Anatomy Exam",
|
||||
exam_mode=True,
|
||||
active=True
|
||||
)
|
||||
exam.author.add(create_user)
|
||||
|
||||
answer = Answer.objects.create(
|
||||
question=question,
|
||||
answer="testanswer",
|
||||
status=Answer.MarkOptions.CORRECT
|
||||
)
|
||||
|
||||
user_answer = UserAnswer.objects.create(
|
||||
user=create_user,
|
||||
exam=exam,
|
||||
question=question
|
||||
)
|
||||
|
||||
return {
|
||||
"question": question,
|
||||
"exam": exam,
|
||||
"user_answer": user_answer,
|
||||
}
|
||||
|
||||
URL_SPECS = [
|
||||
("anatomy:question_view", None),
|
||||
("anatomy:question_create", None),
|
||||
("anatomy:anatomy_question_update", lambda d: {"pk": d["question"].pk}),
|
||||
("anatomy:question_answer_update", lambda d: {"pk": d["question"].pk}),
|
||||
("anatomy:question_clone", lambda d: {"pk": d["question"].pk}),
|
||||
("anatomy:question_delete", lambda d: {"pk": d["question"].pk}),
|
||||
("anatomy:exam_markers", lambda d: {"pk": d["exam"].pk}),
|
||||
("anatomy:exam_groups_edit", lambda d: {"pk": d["exam"].pk}),
|
||||
("anatomy:exam_authors", lambda d: {"pk": d["exam"].pk}),
|
||||
("anatomy:exam_create", None),
|
||||
("anatomy:exam_clone", lambda d: {"exam_id": d["exam"].pk}),
|
||||
("anatomy:exam_update", lambda d: {"pk": d["exam"].pk}),
|
||||
("anatomy:exam_delete", lambda d: {"pk": d["exam"].pk}),
|
||||
("anatomy:user_answer_table_view", None),
|
||||
("anatomy:user_answer_view", lambda d: {"pk": d["user_answer"].pk}),
|
||||
("anatomy:user_answer_delete", lambda d: {"pk": d["user_answer"].pk}),
|
||||
("anatomy:mark2_overview", lambda d: {"pk": d["exam"].pk}),
|
||||
("anatomy:index", None),
|
||||
("anatomy:help", None),
|
||||
]
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS)
|
||||
def test_url_smoke_anonymous(anonymous_client, smoke_data, url_name, kwargs_lambda):
|
||||
kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None
|
||||
url = reverse(url_name, kwargs=kwargs)
|
||||
response = anonymous_client.get(url, follow=False)
|
||||
assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Anonymous"
|
||||
assert response.status_code in {200, 302, 403, 404}, f"Unexpected status code {response.status_code} on {url_name} for Anonymous"
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS)
|
||||
def test_url_smoke_candidate(candidate_client, smoke_data, url_name, kwargs_lambda):
|
||||
kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None
|
||||
url = reverse(url_name, kwargs=kwargs)
|
||||
response = candidate_client.get(url, follow=False)
|
||||
assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Candidate"
|
||||
assert response.status_code in SAFE_STATUS_CODES, f"Unexpected status code {response.status_code} on {url_name} for Candidate"
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS)
|
||||
def test_url_smoke_superuser(superuser_client, smoke_data, url_name, kwargs_lambda):
|
||||
kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None
|
||||
url = reverse(url_name, kwargs=kwargs)
|
||||
response = superuser_client.get(url, follow=False)
|
||||
assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Superuser"
|
||||
assert response.status_code in {200, 301, 302, 303}, f"Unexpected status code {response.status_code} on {url_name} for Superuser"
|
||||
@@ -33,11 +33,21 @@ urlpatterns = [
|
||||
views.question_save_annotation,
|
||||
name="question_save_annotation",
|
||||
),
|
||||
path(
|
||||
"question/<int:pk>/clear_annotation",
|
||||
views.question_clear_annotation,
|
||||
name="question_clear_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(
|
||||
@@ -92,6 +102,24 @@ urlpatterns = [
|
||||
views.UserAnswerDelete.as_view(),
|
||||
name="user_answer_delete",
|
||||
),
|
||||
path(
|
||||
"exam/<int:pk>/review/<int:q_index>/responses",
|
||||
views.exam_review_question_responses,
|
||||
name="exam_review_question_responses",
|
||||
),
|
||||
path(
|
||||
"exam/<int:pk>/review/<int:q_index>/summary",
|
||||
views.exam_review_question_summary,
|
||||
name="exam_review_question_summary",
|
||||
),
|
||||
# New improved marking UI (mark2) and small toggle endpoint used by JS/HTMX
|
||||
path("exam/<int:exam_pk>/<int:sk>/mark2", views.mark2, name="mark2"),
|
||||
path("exam/<int:pk>/mark2", views.mark2_overview, name="mark2_overview"),
|
||||
path("exam/mark2/toggle", views.mark2_toggle_answer, name="mark2_toggle_answer"),
|
||||
path("exam/<int:exam_pk>/<int:sk>/mark2/exam_marked", views.mark2_exam_marked, name="mark2_exam_marked"),
|
||||
path("question/<int:pk>/mark2/question_marked", views.mark2_question_marked, name="mark2_question_marked"),
|
||||
path("exam/mark2/edit_answer", views.mark2_edit_answer, name="mark2_edit_answer"),
|
||||
path("question/<int:pk>/primary_answer/", views.primary_answer_htmx, name="primary_answer_htmx"),
|
||||
]
|
||||
|
||||
urlpatterns.extend(generic_view_urls(views.GenericViews))
|
||||
|
||||
+954
-117
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
# Atlas Widgets
|
||||
|
||||
This document describes the reusable search widgets provided by Atlas, how they work, and how to use them safely.
|
||||
|
||||
## Scope
|
||||
|
||||
These widgets are Django inclusion tags implemented in `atlas/templatetags/case_widgets.py` and rendered through partial templates in `atlas/templates/atlas/partials/`.
|
||||
|
||||
Current widgets:
|
||||
- `case_search_widget`
|
||||
- `finding_search_widget`
|
||||
- `displayset_search_widget`
|
||||
|
||||
They are HTMX-first widgets with JavaScript fallback to `fetch`.
|
||||
|
||||
## Load The Tags
|
||||
|
||||
In any template that uses these widgets:
|
||||
|
||||
```django
|
||||
{% load case_widgets %}
|
||||
```
|
||||
|
||||
## 1) case_search_widget
|
||||
|
||||
### Tag Signature
|
||||
|
||||
```django
|
||||
{% case_search_widget
|
||||
collection=None
|
||||
cases=None
|
||||
recent_cases=None
|
||||
input_id='case-search-input'
|
||||
target_id='case-search-results'
|
||||
selection_mode='single'
|
||||
action_url=None
|
||||
show_select_button=False
|
||||
%}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
- `collection`:
|
||||
Optional collection object. When set, search requests include `collection=<pk>` and rows render an `Add` action.
|
||||
- `cases`:
|
||||
Optional initial case queryset/list for first render.
|
||||
- `recent_cases`:
|
||||
Optional initial recent list. If omitted, widget auto-populates with up to 5 recent cases authored by the current user.
|
||||
- `input_id`:
|
||||
DOM id for the search input. Must be unique per page.
|
||||
- `target_id`:
|
||||
DOM id for results container. Must be unique per page.
|
||||
- `selection_mode`:
|
||||
Present in tag API but currently not used by rendering logic.
|
||||
- `action_url`:
|
||||
Present in tag API but currently not used by rendering logic.
|
||||
- `show_select_button`:
|
||||
When `True`, rows include a `Select` button and clicking rows emits selection events.
|
||||
|
||||
### How It Works
|
||||
|
||||
- Input triggers search on: `keyup changed delay:400ms`.
|
||||
- HTMX call target: `atlas:case_search` (`/atlas/collection/case_search`).
|
||||
- The widget includes all local hidden state via:
|
||||
- `hx-include="closest .case-search-widget"`
|
||||
- Search results are swapped into the configured `target_id`.
|
||||
- If JavaScript cannot use HTMX, fallback `fetch` performs the same GET and swaps `innerHTML`.
|
||||
|
||||
### Events Emitted
|
||||
|
||||
The widget dispatches a bubbling custom event from the widget container:
|
||||
|
||||
- Event name: `case:selected`
|
||||
- Detail payload:
|
||||
- `casePk` (string)
|
||||
- `caseTitle` (string)
|
||||
|
||||
Example listener:
|
||||
|
||||
```javascript
|
||||
document.body.addEventListener('case:selected', function (e) {
|
||||
const pk = e.detail && e.detail.casePk;
|
||||
const title = e.detail && e.detail.caseTitle;
|
||||
if (!pk) return;
|
||||
// consume selection
|
||||
});
|
||||
```
|
||||
|
||||
### Result Row Contract
|
||||
|
||||
Rows are rendered with:
|
||||
- `data-case-pk`
|
||||
- visible title in `h6`
|
||||
|
||||
Actions per mode:
|
||||
- `collection` provided: `Add` form + `View`
|
||||
- no collection, `show_select_button=True`: `Select` + `View`
|
||||
- no collection, `show_select_button=False`: `View`
|
||||
|
||||
### Common Usage Patterns
|
||||
|
||||
#### A. Case picker behavior (select destination/import target)
|
||||
|
||||
```django
|
||||
{% case_search_widget
|
||||
collection=None
|
||||
input_id='move-series-case-search-input'
|
||||
target_id='move-series-case-search-results'
|
||||
show_select_button=True
|
||||
%}
|
||||
```
|
||||
|
||||
Then consume `case:selected` and write `casePk` to your hidden form field.
|
||||
|
||||
#### B. Add to collection flow
|
||||
|
||||
```django
|
||||
{% case_search_widget
|
||||
collection=collection
|
||||
cases=cases
|
||||
recent_cases=recent_cases
|
||||
input_id='case-search-input-collection-'|add:collection.pk|stringformat:'s'
|
||||
target_id='case-search-results-collection-'|add:collection.pk|stringformat:'s'
|
||||
%}
|
||||
```
|
||||
|
||||
No event handling is required; rows provide server-posted `Add` action.
|
||||
|
||||
### Endpoint Notes
|
||||
|
||||
The widget uses `atlas:case_search` for HTMX/fetch updates. That view supports:
|
||||
- `q` search text
|
||||
- `collection` optional collection id
|
||||
- `show_select` optional flag to preserve select-button rendering on refresh
|
||||
|
||||
## 2) finding_search_widget
|
||||
|
||||
### Tag Signature
|
||||
|
||||
```django
|
||||
{% finding_search_widget
|
||||
case=None
|
||||
findings=None
|
||||
input_id='finding-search-input'
|
||||
target_id='finding-search-results'
|
||||
%}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
- `case`:
|
||||
Optional case instance; when present, search is filtered to findings linked to series in that case.
|
||||
- `findings`:
|
||||
Optional initial findings list.
|
||||
- `input_id`, `target_id`:
|
||||
DOM ids. Must be unique per page.
|
||||
|
||||
### How It Works
|
||||
|
||||
- HTMX endpoint: `atlas:finding_search_partial` / `atlas:finding_search`.
|
||||
- Search query is sent as `q`.
|
||||
- Case filter (if set) is sent as `case=<pk>`.
|
||||
- Widget shows a case filter badge and a clear (`x`) button.
|
||||
- Script mirrors active case into dataset fields used by markup insertion workflows:
|
||||
- `input.dataset.currentCase`
|
||||
- nearby `textarea.dataset.currentCase`
|
||||
|
||||
### Result Row Contract
|
||||
|
||||
Rows are rendered with:
|
||||
- `data-finding-pk`
|
||||
- `data-seriesfinding-pk`
|
||||
- `data-label`
|
||||
|
||||
These attributes are consumed by `atlas/static/atlas/js/markup_inserter.js` to insert tokens like:
|
||||
- `[[seriesfinding:<pk>|<label>]]`
|
||||
- `[[finding:<pk>|<label>]]`
|
||||
|
||||
## 3) displayset_search_widget
|
||||
|
||||
### Tag Signature
|
||||
|
||||
```django
|
||||
{% displayset_search_widget
|
||||
case=None
|
||||
displaysets=None
|
||||
input_id='displayset-search-input'
|
||||
target_id='displayset-search-results'
|
||||
%}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
- `case`:
|
||||
Optional case instance; when present, search is filtered to display sets in that case.
|
||||
- `displaysets`:
|
||||
Optional initial list.
|
||||
- `input_id`, `target_id`:
|
||||
DOM ids. Must be unique per page.
|
||||
|
||||
### How It Works
|
||||
|
||||
- HTMX endpoint: `atlas:displayset_search_partial` / `atlas:displayset_search`.
|
||||
- Query sent as `q`; case scoping sent as `case=<pk>`.
|
||||
- Includes case badge + clear button behavior similar to finding widget.
|
||||
- Also mirrors case context into input/textarea datasets for markup insertion workflows.
|
||||
|
||||
### Result Row Contract
|
||||
|
||||
Rows are rendered with:
|
||||
- `data-ds-pk`
|
||||
- `data-label`
|
||||
|
||||
Used by `atlas/static/atlas/js/markup_inserter.js` to insert:
|
||||
- `[[displayset:<pk>|<label>]]`
|
||||
|
||||
## Integration Guidelines
|
||||
|
||||
- Keep each widget instance ids unique:
|
||||
- unique `input_id`
|
||||
- unique `target_id`
|
||||
- Prefer one event listener per feature container or page for `case:selected`.
|
||||
- Do not depend on button text (`Select`, `View`) in JS. Use data attributes and events.
|
||||
- If your workflow needs selection, set `show_select_button=True` on `case_search_widget`.
|
||||
- If your workflow is add-to-collection, pass `collection` and rely on row `Add` forms.
|
||||
|
||||
## Known Constraints
|
||||
|
||||
- `selection_mode` and `action_url` are currently API placeholders for `case_search_widget`; they are not applied by templates/views yet.
|
||||
- Widget scripts are inline with each widget include. If many instances are rendered, listeners are instance-scoped but still duplicated per include.
|
||||
|
||||
## Quick Troubleshooting
|
||||
|
||||
- No results update:
|
||||
- verify `input_id` and `target_id` are unique and present in DOM.
|
||||
- verify endpoint routes exist and user is authenticated.
|
||||
- Selection not received:
|
||||
- ensure `show_select_button=True` and listener is bound (for example on `document.body`).
|
||||
- inspect `case:selected` payload in DevTools.
|
||||
- Case scoping not applied in finding/displayset widgets:
|
||||
- ensure `case` is passed to the tag.
|
||||
- verify `case=<pk>` appears in network requests.
|
||||
|
||||
## Source Files
|
||||
|
||||
- `atlas/templatetags/case_widgets.py`
|
||||
- `atlas/templates/atlas/partials/case_search_widget.html`
|
||||
- `atlas/templates/atlas/partials/case_search_results.html`
|
||||
- `atlas/templates/atlas/partials/_case_search_item.html`
|
||||
- `atlas/templates/atlas/partials/finding_search_widget.html`
|
||||
- `atlas/templates/atlas/partials/_finding_search_results.html`
|
||||
- `atlas/templates/atlas/partials/displayset_search_widget.html`
|
||||
- `atlas/templates/atlas/partials/_displayset_search_results.html`
|
||||
- `atlas/views.py` (`case_search`, `finding_search`, `displayset_search`)
|
||||
- `atlas/static/atlas/js/markup_inserter.js`
|
||||
+241
@@ -1,8 +1,10 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib import messages
|
||||
from .models import (
|
||||
Case,
|
||||
CaseDetail,
|
||||
CaseCollection,
|
||||
NormalCase,
|
||||
Differential,
|
||||
Series,
|
||||
SeriesImage,
|
||||
@@ -14,24 +16,35 @@ from .models import (
|
||||
Structure,
|
||||
PathologicalProcess,
|
||||
Presentation,
|
||||
Procedure,
|
||||
UncategorisedDicom,
|
||||
SeriesDetail,
|
||||
Resource,
|
||||
CaseDisplaySet,
|
||||
UserReportAnswer,
|
||||
CidReportAnswer,
|
||||
APIToken,
|
||||
)
|
||||
|
||||
from django.forms import ModelForm
|
||||
|
||||
from reversion.admin import VersionAdmin
|
||||
from django.forms.widgets import RadioSelect
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from tinymce.widgets import TinyMCE
|
||||
|
||||
# from generic.models import Examination, Sign, Site, Condition
|
||||
from generic.models import Examination
|
||||
|
||||
try:
|
||||
from django_tasks_db.models import DBTaskResult
|
||||
except ImportError:
|
||||
DBTaskResult = None
|
||||
|
||||
# Register your models here.
|
||||
# admin.site.register(Examination)
|
||||
# admin.site.register(Site)
|
||||
@@ -50,6 +63,7 @@ admin.site.register(UncategorisedDicom)
|
||||
admin.site.register(SeriesDetail)
|
||||
admin.site.register(Resource)
|
||||
admin.site.register(CaseDisplaySet)
|
||||
admin.site.register(Procedure)
|
||||
admin.site.register(UserReportAnswer)
|
||||
admin.site.register(CidReportAnswer)
|
||||
|
||||
@@ -79,6 +93,22 @@ class CaseCollectionAdmin(VersionAdmin):
|
||||
|
||||
admin.site.register(CaseCollection, CaseCollectionAdmin)
|
||||
|
||||
|
||||
class NormalCaseAdmin(VersionAdmin):
|
||||
list_display = ("case", "display_age", "examination", "modality", "added_date")
|
||||
list_filter = ("examination", "modality")
|
||||
search_fields = ("case__title", "case__pk")
|
||||
|
||||
def display_age(self, obj):
|
||||
try:
|
||||
return obj.display_age()
|
||||
except Exception:
|
||||
return ""
|
||||
display_age.short_description = "Age"
|
||||
|
||||
|
||||
admin.site.register(NormalCase, NormalCaseAdmin)
|
||||
|
||||
class AtlasAdminForm(ModelForm):
|
||||
class Meta:
|
||||
model = Case
|
||||
@@ -123,3 +153,214 @@ class SeriesAdmin(VersionAdmin):
|
||||
|
||||
|
||||
admin.site.register(Series, SeriesAdmin)
|
||||
|
||||
|
||||
class APITokenAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "name", "revoked", "created", "expires", "last_used")
|
||||
search_fields = ("user__username", "name")
|
||||
readonly_fields = ("token_hash", "created", "last_used")
|
||||
|
||||
|
||||
admin.site.register(APIToken, APITokenAdmin)
|
||||
|
||||
|
||||
if DBTaskResult is not None:
|
||||
try:
|
||||
# Ensure package admin registration has run before we customize it.
|
||||
import django_tasks_db.admin # noqa: F401
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def traceback_link(self, obj):
|
||||
if not obj.traceback:
|
||||
return "-"
|
||||
url = reverse(
|
||||
f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change",
|
||||
args=[obj.pk],
|
||||
)
|
||||
return format_html('<a href="{}">View full traceback</a>', url)
|
||||
|
||||
traceback_link.short_description = "Traceback"
|
||||
|
||||
def traceback_pretty(self, obj):
|
||||
if not obj.traceback:
|
||||
return ""
|
||||
return format_html(
|
||||
'<pre style="white-space: pre-wrap; margin: 0;">{}</pre>',
|
||||
obj.traceback,
|
||||
)
|
||||
|
||||
traceback_pretty.short_description = "Traceback"
|
||||
|
||||
@admin.action(description="Reset FAILED tasks to READY")
|
||||
def reset_failed_to_ready(self, request, queryset):
|
||||
failed_qs = queryset.filter(status="FAILED")
|
||||
updated = failed_qs.update(
|
||||
status="READY",
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
worker_ids="",
|
||||
exception_class_path="",
|
||||
traceback="",
|
||||
return_value=None,
|
||||
)
|
||||
self.message_user(
|
||||
request,
|
||||
f"Reset {updated} failed task(s) to READY.",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
|
||||
@admin.action(description="Reset selected tasks to READY (except RUNNING)")
|
||||
def reset_selected_to_ready(self, request, queryset):
|
||||
blocked = queryset.filter(status="RUNNING").count()
|
||||
reset_qs = queryset.exclude(status="RUNNING")
|
||||
updated = reset_qs.update(
|
||||
status="READY",
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
worker_ids="",
|
||||
exception_class_path="",
|
||||
traceback="",
|
||||
return_value=None,
|
||||
)
|
||||
msg = f"Reset {updated} task(s) to READY."
|
||||
if blocked:
|
||||
msg += f" Skipped {blocked} running task(s)."
|
||||
self.message_user(request, msg, level=messages.SUCCESS)
|
||||
|
||||
@admin.action(description="Reset stale RUNNING tasks to READY (>30 min)")
|
||||
def reset_stale_running_to_ready(self, request, queryset):
|
||||
cutoff = timezone.now() - timedelta(minutes=30)
|
||||
updated = queryset.filter(status="RUNNING", started_at__lt=cutoff).update(
|
||||
status="READY",
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
worker_ids="",
|
||||
exception_class_path="",
|
||||
traceback="",
|
||||
return_value=None,
|
||||
)
|
||||
self.message_user(
|
||||
request,
|
||||
f"Reset {updated} stale RUNNING task(s) to READY (older than 30 minutes).",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
|
||||
@admin.action(description="Delete selected COMPLETED tasks")
|
||||
def delete_selected_completed(self, request, queryset):
|
||||
completed_qs = queryset.filter(status="SUCCESSFUL")
|
||||
deleted, _ = completed_qs.delete()
|
||||
self.message_user(
|
||||
request,
|
||||
f"Deleted {deleted} completed task record(s).",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
|
||||
@admin.action(description="Delete selected FAILED tasks")
|
||||
def delete_selected_failed(self, request, queryset):
|
||||
failed_qs = queryset.filter(status="FAILED")
|
||||
deleted, _ = failed_qs.delete()
|
||||
self.message_user(
|
||||
request,
|
||||
f"Deleted {deleted} failed task record(s).",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
task_admin = admin.site._registry.get(DBTaskResult)
|
||||
if task_admin is not None:
|
||||
admin_cls = task_admin.__class__
|
||||
admin_cls.list_display = (
|
||||
"id",
|
||||
"task_path",
|
||||
"status",
|
||||
"queue_name",
|
||||
"priority",
|
||||
"enqueued_at",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"worker_ids",
|
||||
"exception_class_path",
|
||||
"traceback_link",
|
||||
)
|
||||
admin_cls.list_filter = ("status", "queue_name", "task_path", "backend_name")
|
||||
admin_cls.search_fields = (
|
||||
"id",
|
||||
"task_path",
|
||||
"queue_name",
|
||||
"worker_ids",
|
||||
"exception_class_path",
|
||||
"traceback",
|
||||
)
|
||||
admin_cls.ordering = ("-enqueued_at",)
|
||||
admin_cls.actions = (
|
||||
"reset_failed_to_ready",
|
||||
"reset_selected_to_ready",
|
||||
"reset_stale_running_to_ready",
|
||||
"delete_selected_completed",
|
||||
"delete_selected_failed",
|
||||
)
|
||||
admin_cls.readonly_fields = (
|
||||
"id",
|
||||
"status",
|
||||
"enqueued_at",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"args_kwargs",
|
||||
"priority",
|
||||
"task_path",
|
||||
"worker_ids",
|
||||
"queue_name",
|
||||
"backend_name",
|
||||
"run_after",
|
||||
"return_value",
|
||||
"exception_class_path",
|
||||
"traceback_pretty",
|
||||
)
|
||||
admin_cls.fieldsets = (
|
||||
(
|
||||
"Task",
|
||||
{
|
||||
"fields": (
|
||||
"id",
|
||||
"task_path",
|
||||
"status",
|
||||
"queue_name",
|
||||
"priority",
|
||||
"backend_name",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Timing",
|
||||
{
|
||||
"fields": (
|
||||
"enqueued_at",
|
||||
"run_after",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"worker_ids",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Payload",
|
||||
{"fields": ("args_kwargs", "return_value")},
|
||||
),
|
||||
(
|
||||
"Error",
|
||||
{
|
||||
"fields": ("exception_class_path", "traceback_pretty"),
|
||||
},
|
||||
),
|
||||
)
|
||||
admin_cls.traceback_link = traceback_link
|
||||
admin_cls.traceback_pretty = traceback_pretty
|
||||
admin_cls.reset_failed_to_ready = reset_failed_to_ready
|
||||
admin_cls.reset_selected_to_ready = reset_selected_to_ready
|
||||
admin_cls.reset_stale_running_to_ready = reset_stale_running_to_ready
|
||||
admin_cls.delete_selected_completed = delete_selected_completed
|
||||
admin_cls.delete_selected_failed = delete_selected_failed
|
||||
admin_cls.has_add_permission = has_add_permission
|
||||
|
||||
+387
-78
@@ -2,6 +2,8 @@ from collections import defaultdict
|
||||
from typing import List, Tuple
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
import json
|
||||
from django.http import JsonResponse, HttpResponse
|
||||
from ninja import ModelSchema, Router, Schema, Field
|
||||
|
||||
from django.db import transaction, IntegrityError
|
||||
@@ -14,14 +16,22 @@ from pydicom.errors import InvalidDicomError
|
||||
from generic.decorators import check_user_in_group
|
||||
from generic.constants import Group
|
||||
|
||||
from ninja.security import django_auth
|
||||
from ninja.security import django_auth, HttpBearer
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from ninja import NinjaAPI, File
|
||||
from ninja.files import UploadedFile
|
||||
import hashlib
|
||||
import secrets
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth import authenticate
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.middleware.csrf import CsrfViewMiddleware
|
||||
|
||||
from generic.models import Examination, Modality
|
||||
|
||||
from .models import Case, DuplicateDicom, Series, SeriesImage, UncategorisedDicom
|
||||
from .models import Case, DuplicateDicom, Series, SeriesImage, UncategorisedDicom, APIToken
|
||||
from atlas.helpers import get_cases_available_to_user
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -29,12 +39,78 @@ from loguru import logger
|
||||
router = Router()
|
||||
|
||||
|
||||
class BearerAuth(HttpBearer):
|
||||
"""Ninja HttpBearer auth that validates APIToken entries.
|
||||
|
||||
Returns the token owner user on success or raises HttpError(401) on failure.
|
||||
"""
|
||||
def authenticate(self, request, token: str):
|
||||
if not token:
|
||||
raise HttpError(status_code=401)
|
||||
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
try:
|
||||
api_token = APIToken.objects.get(token_hash=token_hash)
|
||||
except APIToken.DoesNotExist:
|
||||
raise HttpError(status_code=401)
|
||||
|
||||
if api_token.revoked:
|
||||
raise HttpError(status_code=401)
|
||||
|
||||
if api_token.expires and api_token.expires < timezone.now():
|
||||
raise HttpError(status_code=401)
|
||||
|
||||
try:
|
||||
api_token.mark_used()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Attach for downstream handlers
|
||||
request.api_token = api_token
|
||||
# Also set `request.user` so code that expects Django's user works
|
||||
try:
|
||||
request.user = api_token.user
|
||||
# Some middleware caches the user on `_cached_user`
|
||||
setattr(request, "_cached_user", api_token.user)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return api_token.user
|
||||
|
||||
|
||||
class SessionOrBearer:
|
||||
"""Composite auth: prefer Django session (request.user), fall back to bearer token.
|
||||
|
||||
Use as `auth=SessionOrBearer()` on Ninja router endpoints.
|
||||
"""
|
||||
def __call__(self, request):
|
||||
# Session-backed auth already populated by Django middleware
|
||||
if getattr(request, "user", None) and getattr(request.user, "is_authenticated", False):
|
||||
return request.user
|
||||
|
||||
# Try bearer token from header
|
||||
auth = request.META.get("HTTP_AUTHORIZATION")
|
||||
if not auth:
|
||||
raise HttpError(status_code=401)
|
||||
parts = auth.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HttpError(status_code=401)
|
||||
token = parts[1]
|
||||
# reuse BearerAuth logic
|
||||
bearer = BearerAuth()
|
||||
return bearer.authenticate(request, token)
|
||||
|
||||
|
||||
# Backwards-compatible alias used in existing decorators
|
||||
TokenAuth = SessionOrBearer
|
||||
|
||||
|
||||
class SeriesSchema(ModelSchema):
|
||||
case_id: List[int] = []
|
||||
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Series
|
||||
model_fields = [
|
||||
fields = [
|
||||
"id",
|
||||
"modality",
|
||||
"examination",
|
||||
@@ -48,12 +124,12 @@ class SeriesSchema(ModelSchema):
|
||||
|
||||
|
||||
class CaseSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Case
|
||||
model_fields = ["id", "title"]
|
||||
fields = ["id", "title"]
|
||||
|
||||
|
||||
@router.post("/upload_dicom", auth=django_auth)
|
||||
@router.post("/upload_dicom", auth=TokenAuth())
|
||||
def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
||||
uploaded = []
|
||||
duplicate = []
|
||||
@@ -70,11 +146,8 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
||||
except DuplicateDicom as e:
|
||||
duplicate.append((file.name, ud.image_blake3_hash))
|
||||
|
||||
|
||||
if type(e.duplicate) == SeriesImage:
|
||||
if isinstance(e.duplicate, SeriesImage) and e.duplicate.series is not None:
|
||||
duplicate_series.add(e.duplicate.series.get_absolute_url())
|
||||
|
||||
pass
|
||||
except InvalidDicomError:
|
||||
failed.append(file.name)
|
||||
|
||||
@@ -86,7 +159,7 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/generate_image_hash", auth=django_auth)
|
||||
@router.post("/generate_image_hash", auth=TokenAuth())
|
||||
def generate_image_hash(request, id: int):
|
||||
s = SeriesImage.objects.get(pk=id)
|
||||
s.generate_hashes()
|
||||
@@ -98,7 +171,7 @@ def generate_image_hash(request, id: int):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/clear_dicoms", auth=django_auth)
|
||||
@router.post("/clear_dicoms", auth=TokenAuth())
|
||||
def clear_dicoms(request):
|
||||
if "selection" in request.POST:
|
||||
dicoms = UncategorisedDicom.objects.filter(
|
||||
@@ -113,7 +186,7 @@ def clear_dicoms(request):
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/uncategorised_dicoms", auth=django_auth)
|
||||
@router.get("/uncategorised_dicoms", auth=TokenAuth())
|
||||
def uncategorised_dicoms(request):
|
||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||
|
||||
@@ -127,13 +200,15 @@ def uncategorised_dicoms(request):
|
||||
return data
|
||||
|
||||
|
||||
def import_dicoms_helper(request, case_id: int | None = None):
|
||||
if "selection" in request.POST:
|
||||
dicoms = UncategorisedDicom.objects.filter(
|
||||
series_instance_uid__in=request.POST.getlist("selection")
|
||||
)
|
||||
else:
|
||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||
def import_dicoms_helper(request, case_id: int | None = None, dicoms=None):
|
||||
# Allow callers to pass an explicit queryset of UncategorisedDicom objects
|
||||
if dicoms is None:
|
||||
if "selection" in request.POST:
|
||||
dicoms = UncategorisedDicom.objects.filter(
|
||||
series_instance_uid__in=request.POST.getlist("selection")
|
||||
)
|
||||
else:
|
||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||
|
||||
if "order-series" in request.POST:
|
||||
order = request.POST["order-series"]
|
||||
@@ -145,6 +220,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
for d in dicoms.iterator(): # Use iterator() to avoid loading all at once
|
||||
tags = d.basic_dicom_tags
|
||||
series_uid = tags["SeriesInstanceUID"]
|
||||
study_uid = tags.get("StudyInstanceUID")
|
||||
|
||||
if series_uid not in series_map:
|
||||
# Get or create Series and related objects as needed
|
||||
@@ -161,6 +237,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
defaults={
|
||||
"modality": modality,
|
||||
"description": tags.get("SeriesDescription", ""),
|
||||
"study_instance_uid": study_uid,
|
||||
}
|
||||
)
|
||||
if created and tags.get("StudyDescription"):
|
||||
@@ -175,7 +252,12 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
except IntegrityError:
|
||||
series = Series.objects.get(series_instance_uid=series_uid)
|
||||
|
||||
if study_uid and not series.study_instance_uid:
|
||||
series.study_instance_uid = study_uid
|
||||
series.save(update_fields=["study_instance_uid"])
|
||||
|
||||
# Add author and case if needed
|
||||
# Attach author to the series
|
||||
series.author.add(request.user)
|
||||
if case_id is not None:
|
||||
case_object = get_object_or_404(Case, pk=case_id)
|
||||
@@ -212,7 +294,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
|
||||
|
||||
@router.post(
|
||||
"/import_dicoms", auth=django_auth, response=List[Tuple[SeriesSchema, str]]
|
||||
"/import_dicoms", auth=TokenAuth(), response=List[Tuple[SeriesSchema, str]]
|
||||
)
|
||||
def import_dicoms(request):
|
||||
return import_dicoms_helper(request)
|
||||
@@ -220,19 +302,73 @@ def import_dicoms(request):
|
||||
|
||||
@router.post(
|
||||
"/import_dicoms/{case_id}",
|
||||
auth=django_auth,
|
||||
auth=TokenAuth(),
|
||||
response=List[Tuple[SeriesSchema, str]],
|
||||
)
|
||||
def import_dicoms_case(request, case_id: int):
|
||||
return import_dicoms_helper(request, case_id=case_id)
|
||||
|
||||
|
||||
@router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema])
|
||||
@router.post("/upload_dicom_case/{case_id}", auth=TokenAuth())
|
||||
def upload_dicom_case(request, case_id: int, files: List[UploadedFile] = File(...)):
|
||||
"""Upload DICOM files and immediately import them into the given case.
|
||||
|
||||
This saves each uploaded file as an `UncategorisedDicom` owned by the user,
|
||||
then calls the existing import helper which will create Series/SeriesImage
|
||||
objects and attach them to the specified case.
|
||||
"""
|
||||
uploaded = []
|
||||
duplicate = []
|
||||
failed = []
|
||||
duplicate_series = set()
|
||||
|
||||
created_ids = []
|
||||
|
||||
for file in files:
|
||||
try:
|
||||
ud = UncategorisedDicom(image=file, user=request.user)
|
||||
ud.save()
|
||||
|
||||
uploaded.append((file.name, ud.image_blake3_hash))
|
||||
created_ids.append(ud.pk)
|
||||
except DuplicateDicom as e:
|
||||
duplicate.append((file.name, getattr(ud, "image_blake3_hash", None)))
|
||||
|
||||
dup_obj = getattr(e, "duplicate", None)
|
||||
if isinstance(dup_obj, SeriesImage):
|
||||
try:
|
||||
if dup_obj.series is not None:
|
||||
duplicate_series.add(dup_obj.series.get_absolute_url())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
continue
|
||||
except InvalidDicomError:
|
||||
failed.append(file.name)
|
||||
|
||||
# Import into the target case (consume only the uploaded UncategorisedDicom objects)
|
||||
if created_ids:
|
||||
try:
|
||||
dicom_qs = UncategorisedDicom.objects.filter(pk__in=created_ids)
|
||||
import_dicoms_helper(request, case_id=case_id, dicoms=dicom_qs)
|
||||
except Exception:
|
||||
# don't let import errors prevent returning upload status
|
||||
logger.error("Error importing dicoms after upload", exc_info=True)
|
||||
|
||||
return {
|
||||
"uploaded": uploaded,
|
||||
"duplicates": duplicate,
|
||||
"failed": failed,
|
||||
"duplicate_series": list(duplicate_series),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/orphan_series", auth=TokenAuth(), response=List[SeriesSchema])
|
||||
def orphan_series(request):
|
||||
return request.user.series.filter(case=None)
|
||||
|
||||
|
||||
@router.get("/series_remove_duplicate_images", auth=django_auth)
|
||||
@router.get("/series_remove_duplicate_images", auth=TokenAuth())
|
||||
def series_remove_duplicate_images(request, series_id: int):
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
|
||||
@@ -249,34 +385,188 @@ def series_remove_duplicate_images(request, series_id: int):
|
||||
return len(dupes)
|
||||
|
||||
|
||||
@router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth)
|
||||
def series_truncate(request, series_id: int, start: int, end: int):
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
|
||||
if not series.check_user_can_edit(request.user):
|
||||
return {"status": "permission denied"}
|
||||
|
||||
images_removed = []
|
||||
for n, image in enumerate(series.get_images()):
|
||||
if n >= start and n <= end:
|
||||
continue
|
||||
else:
|
||||
image.removed = True
|
||||
image.image = None
|
||||
image.save()
|
||||
images_removed.append(image.pk)
|
||||
|
||||
series.get_total_image_size(refresh=True)
|
||||
|
||||
return {"status": "success", "images_removed": images_removed}
|
||||
|
||||
|
||||
@router.get("/get_cases_user", auth=django_auth, response=List[CaseSchema])
|
||||
@router.get("/get_cases_user", auth=TokenAuth(), response=List[CaseSchema])
|
||||
def get_cases_user(request):
|
||||
return Case.objects.filter(author=request.user)
|
||||
|
||||
|
||||
@router.get("/check_image_hash/{hash}", auth=django_auth)
|
||||
@router.get("/get_cases_available", auth=TokenAuth(), response=List[CaseSchema])
|
||||
def get_cases_available(request):
|
||||
"""Return cases available to the authenticated user (via get_cases_available_to_user)."""
|
||||
logger.error(f"Getting cases available to user {request.user}")
|
||||
qs = get_cases_available_to_user(request.user)
|
||||
return qs.order_by('-created_date')[:200]
|
||||
|
||||
|
||||
class APITokenOut(Schema):
|
||||
id: int
|
||||
name: str
|
||||
scopes: str = ""
|
||||
created: str
|
||||
expires: str | None = None
|
||||
revoked: bool = False
|
||||
last_used: str | None = None
|
||||
|
||||
|
||||
@router.get("/api_tokens", auth=TokenAuth(), response=List[APITokenOut])
|
||||
def list_api_tokens(request):
|
||||
tokens = APIToken.objects.filter(user=request.user).order_by("-created")
|
||||
out = []
|
||||
for t in tokens:
|
||||
out.append(
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"scopes": t.scopes,
|
||||
"created": t.created.isoformat(),
|
||||
"expires": t.expires.isoformat() if t.expires else None,
|
||||
"revoked": t.revoked,
|
||||
"last_used": t.last_used.isoformat() if t.last_used else None,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class CreateTokenIn(Schema):
|
||||
name: str = Field("", description="Friendly name for token")
|
||||
scopes: str = Field("", description="Space-separated scopes")
|
||||
expires_days: int | None = Field(None, description="Expire after N days")
|
||||
|
||||
|
||||
class TokenAuthIn(Schema):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@router.post("/create_api_token")
|
||||
def token_auth(request, payload: TokenAuthIn):
|
||||
"""Authenticate username/password and return a new API token.
|
||||
|
||||
Returns JSON: {"token": "<raw-token>", "id": <token_id>}
|
||||
"""
|
||||
user = authenticate(request, username=payload.username, password=payload.password)
|
||||
if user is None:
|
||||
return HttpResponse(status=401, content=json.dumps({"detail": "Invalid credentials"}), content_type="application/json")
|
||||
|
||||
# Create a new token for this login (no scopes by default)
|
||||
token, obj = APIToken.create_token(user, name=f"login-{timezone.now().isoformat()}")
|
||||
|
||||
return {"token": token, "id": obj.id, "username": user.username}
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@router.post("/token_check")
|
||||
def token_check(request):
|
||||
"""Check whether a token is valid.
|
||||
|
||||
Accepts either `Authorization: Bearer <token>` header or POST JSON
|
||||
body `{ "token": "<token>" }`.
|
||||
|
||||
Returns JSON with keys: `valid` (bool), and when valid: `id`, `username`,
|
||||
`scopes`, `expires`, `revoked`.
|
||||
"""
|
||||
auth = request.META.get("HTTP_AUTHORIZATION")
|
||||
token_obj = None
|
||||
|
||||
# If header present, validate bearer token explicitly
|
||||
if auth:
|
||||
parts = auth.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return JsonResponse({"valid": False}, status=401)
|
||||
token = parts[1]
|
||||
try:
|
||||
bearer = BearerAuth()
|
||||
user = bearer.authenticate(request, token)
|
||||
token_obj = getattr(request, "api_token", None)
|
||||
except HttpError:
|
||||
return JsonResponse({"valid": False}, status=401)
|
||||
else:
|
||||
# try JSON body
|
||||
try:
|
||||
data = json.loads(request.body.decode("utf-8")) if request.body else {}
|
||||
except Exception:
|
||||
data = {}
|
||||
token = data.get("token")
|
||||
if token:
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
try:
|
||||
t = APIToken.objects.get(token_hash=token_hash)
|
||||
token_obj = t
|
||||
except APIToken.DoesNotExist:
|
||||
token_obj = None
|
||||
|
||||
if not token_obj:
|
||||
return JsonResponse({"valid": False}, status=401)
|
||||
|
||||
# Validate token state
|
||||
valid = True
|
||||
if token_obj.revoked:
|
||||
valid = False
|
||||
if token_obj.expires and token_obj.expires < timezone.now():
|
||||
valid = False
|
||||
|
||||
if not valid:
|
||||
return JsonResponse({"valid": False, "revoked": token_obj.revoked}, status=401)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"valid": True,
|
||||
"id": token_obj.id,
|
||||
"username": token_obj.user.username,
|
||||
"scopes": token_obj.scopes,
|
||||
"expires": token_obj.expires.isoformat() if token_obj.expires else None,
|
||||
"revoked": token_obj.revoked,
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
|
||||
#@csrf_exempt
|
||||
#@router.post("/create_api_token")
|
||||
#def create_api_token(request, payload: CreateTokenIn):
|
||||
# """Create an API token.
|
||||
#
|
||||
# This endpoint accepts either normal session auth (`request.user`) or
|
||||
# a bearer token. If no authenticated user is found a 403 is returned.
|
||||
# """
|
||||
# # If caller is not using Authorization header, enforce CSRF via middleware
|
||||
# if not request.META.get("HTTP_AUTHORIZATION"):
|
||||
# mw = CsrfViewMiddleware()
|
||||
# resp = mw.process_view(request, create_api_token, (), {})
|
||||
# if resp is not None:
|
||||
# return resp
|
||||
# # Determine authenticated user: prefer session auth, fall back to token auth
|
||||
# user = None
|
||||
# if getattr(request, "user", None) and getattr(request.user, "is_authenticated", False):
|
||||
# user = request.user
|
||||
# else:
|
||||
# user = TokenAuth()(request)
|
||||
#
|
||||
# if not user:
|
||||
# raise PermissionDenied()
|
||||
#
|
||||
# expires = None
|
||||
# if payload.expires_days:
|
||||
# expires = timezone.now() + timezone.timedelta(days=payload.expires_days)
|
||||
#
|
||||
# token, obj = APIToken.create_token(user, name=payload.name, scopes=payload.scopes, expires=expires)
|
||||
# # Return raw token once — callers must store it
|
||||
# return {"token": token, "id": obj.id}
|
||||
|
||||
|
||||
@router.post("/api_tokens/{token_id}/revoke", auth=TokenAuth())
|
||||
def revoke_api_token(request, token_id: int):
|
||||
try:
|
||||
t = APIToken.objects.get(pk=token_id, user=request.user)
|
||||
except APIToken.DoesNotExist:
|
||||
return {"status": "not found"}
|
||||
t.revoked = True
|
||||
t.save(update_fields=["revoked"])
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
@router.get("/check_image_hash/{hash}", auth=TokenAuth())
|
||||
def check_image_hash(request, hash: str):
|
||||
try:
|
||||
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
|
||||
@@ -291,41 +581,61 @@ def check_image_hash(request, hash: str):
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/check_image_hashes/", auth=django_auth)
|
||||
@router.post("/check_image_hashes/", auth=TokenAuth())
|
||||
def check_images_hashes(request, hashes: List[str]):
|
||||
"""Checks a list of image hashes and returns the series id / url if found
|
||||
"""Checks a list of image hashes and returns the series id / url if found.
|
||||
|
||||
Uses two bulk queries (one for SeriesImage, one for UncategorisedDicom) to
|
||||
handle large batches efficiently regardless of batch size.
|
||||
|
||||
Return format
|
||||
{ "hash_id": {"id": "series_id|false", "url": "series_url|false"}, ...}
|
||||
"""
|
||||
|
||||
hash_status = {}
|
||||
try:
|
||||
hash_status: dict = {h: {"id": False, "url": False} for h in hashes}
|
||||
|
||||
for hash in hashes:
|
||||
try:
|
||||
# TOOD also check against uncategorised dicoms?
|
||||
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
|
||||
data = {
|
||||
"id": series_image.pk,
|
||||
"url": series_image.series.get_absolute_url(),
|
||||
"type": "series",
|
||||
}
|
||||
except SeriesImage.DoesNotExist:
|
||||
# Single bulk query for all SeriesImage matches
|
||||
series_images = (
|
||||
SeriesImage.objects.filter(image_blake3_hash__in=hashes)
|
||||
.select_related("series")
|
||||
.only("pk", "image_blake3_hash", "series__id")
|
||||
)
|
||||
matched_hashes: set = set()
|
||||
for si in series_images:
|
||||
if si.image_blake3_hash in matched_hashes:
|
||||
continue # keep first match per hash
|
||||
try:
|
||||
uncategorised_dicom = UncategorisedDicom.objects.get(
|
||||
image_blake3_hash=hash
|
||||
)
|
||||
data = {
|
||||
"id": uncategorised_dicom.pk,
|
||||
"url": reverse("atlas:user_uploads"),
|
||||
"type": "uncategorised",
|
||||
}
|
||||
except UncategorisedDicom.DoesNotExist:
|
||||
data = {"id": False, "url": False}
|
||||
url = si.series.get_absolute_url()
|
||||
except Exception:
|
||||
url = None
|
||||
hash_status[si.image_blake3_hash] = {"id": si.pk, "url": url, "type": "series"}
|
||||
matched_hashes.add(si.image_blake3_hash)
|
||||
|
||||
hash_status[hash] = data
|
||||
# Single bulk query for remaining hashes in UncategorisedDicom
|
||||
remaining = [h for h in hashes if h not in matched_hashes]
|
||||
if remaining:
|
||||
try:
|
||||
uploads_url = reverse("atlas:user_uploads")
|
||||
except Exception:
|
||||
uploads_url = None
|
||||
uncategorised_qs = (
|
||||
UncategorisedDicom.objects.filter(image_blake3_hash__in=remaining)
|
||||
.only("pk", "image_blake3_hash")
|
||||
)
|
||||
for uc in uncategorised_qs:
|
||||
if uc.image_blake3_hash not in matched_hashes:
|
||||
hash_status[uc.image_blake3_hash] = {
|
||||
"id": uc.pk,
|
||||
"url": uploads_url,
|
||||
"type": "uncategorised",
|
||||
}
|
||||
matched_hashes.add(uc.image_blake3_hash)
|
||||
|
||||
return hash_status
|
||||
return hash_status
|
||||
except Exception:
|
||||
logger.exception("check_images_hashes failed")
|
||||
return JsonResponse({"detail": "Internal server error"}, status=500)
|
||||
|
||||
|
||||
# @router.get("/generate_image_hash/{id}", auth=django_auth)
|
||||
@@ -339,16 +649,15 @@ def check_images_hashes(request, hashes: List[str]):
|
||||
# print(series_image)
|
||||
|
||||
|
||||
@router.get("/view_dicom_tags/{hash}", auth=django_auth)
|
||||
@router.get("/view_dicom_tags/{hash}", auth=TokenAuth())
|
||||
def view_dicom_tags(request, hash: str):
|
||||
item = SeriesImage.objects.get(image_blake3_hash=hash)
|
||||
return item.get_dicom_json()
|
||||
|
||||
|
||||
@router.get("/series_split_by_dicom_tag/{series_id}/{dicom_tag}", auth=django_auth)
|
||||
@router.get("/series_split_by_dicom_tag/{series_id}/{dicom_tag}", auth=TokenAuth())
|
||||
def series_split_by_tag(request, series_id: int, dicom_tag: str):
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
|
||||
if not series.check_user_can_edit(request.user):
|
||||
return {"status": "permission denied"}
|
||||
|
||||
@@ -393,7 +702,7 @@ def series_split_by_tag(request, series_id: int, dicom_tag: str):
|
||||
return new_series
|
||||
|
||||
|
||||
@router.get("/split_order_by_dicom_tag/{series_id}/{dicom_tag}", auth=django_auth)
|
||||
@router.get("/split_order_by_dicom_tag/{series_id}/{dicom_tag}", auth=TokenAuth())
|
||||
def series_order_by_tag(request, series_id: int, dicom_tag: str):
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
|
||||
|
||||
+45
-6
@@ -3,8 +3,17 @@ from .models import Case, CaseCollection, Series
|
||||
|
||||
|
||||
def user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access(function):
|
||||
"""Decorator to check if user is author of the series,
|
||||
an atlas editor, an atlas marker, or if the series is open access.
|
||||
Used for series-level permissions.
|
||||
|
||||
Requires the decorated view to have a "pk" or "series_id" kwarg to identify the series.
|
||||
"""
|
||||
def wrap(request, *args, **kwargs):
|
||||
series = Series.objects.get(pk=kwargs["pk"])
|
||||
if "pk" in kwargs:
|
||||
series = Series.objects.get(pk=kwargs["pk"])
|
||||
elif "series_id" in kwargs:
|
||||
series = Series.objects.get(pk=kwargs["series_id"])
|
||||
if (
|
||||
request.user in series.get_author_objects()
|
||||
or series.open_access
|
||||
@@ -23,7 +32,10 @@ def user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access(functi
|
||||
|
||||
def user_is_author_or_atlas_series_checker(function):
|
||||
def wrap(request, *args, **kwargs):
|
||||
series = Series.objects.get(pk=kwargs["pk"])
|
||||
if "pk" in kwargs:
|
||||
series = Series.objects.get(pk=kwargs["pk"])
|
||||
elif "series_id" in kwargs:
|
||||
series = Series.objects.get(pk=kwargs["series_id"])
|
||||
if (
|
||||
request.user in series.get_author_objects()
|
||||
or request.user.groups.filter(name="atlas_editor").exists()
|
||||
@@ -40,7 +52,10 @@ def user_is_author_or_atlas_series_checker(function):
|
||||
|
||||
def user_has_case_view_access(function):
|
||||
def wrap(request, *args, **kwargs):
|
||||
atlas = Case.objects.get(pk=kwargs["pk"])
|
||||
if "pk" in kwargs:
|
||||
atlas = Case.objects.get(pk=kwargs["pk"])
|
||||
elif "case_id" in kwargs:
|
||||
atlas = Case.objects.get(pk=kwargs["case_id"])
|
||||
|
||||
# If open access everyone can view
|
||||
if atlas.open_access:
|
||||
@@ -62,7 +77,10 @@ def user_has_case_view_access(function):
|
||||
|
||||
def user_is_author_or_atlas_editor(function):
|
||||
def wrap(request, *args, **kwargs):
|
||||
atlas = Case.objects.get(pk=kwargs["pk"])
|
||||
if "pk" in kwargs:
|
||||
atlas = Case.objects.get(pk=kwargs["pk"])
|
||||
elif "case_id" in kwargs:
|
||||
atlas = Case.objects.get(pk=kwargs["case_id"])
|
||||
if (
|
||||
request.user in atlas.author.all()
|
||||
or request.user.groups.filter(name="atlas_editor").exists()
|
||||
@@ -90,8 +108,29 @@ def user_is_collection_author_or_atlas_editor(function):
|
||||
or request.user.is_superuser
|
||||
):
|
||||
return function(request, *args, **kwargs)
|
||||
else:
|
||||
raise PermissionDenied
|
||||
|
||||
# Allow access if request.user is the supervisor of the target user and sharing is enabled
|
||||
user_pk = kwargs.get("user_pk") or kwargs.get("user_id")
|
||||
if user_pk:
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from generic.models import CidUserExam
|
||||
try:
|
||||
target_user = get_user_model().objects.get(pk=user_pk)
|
||||
sup = target_user.userprofile.supervisor
|
||||
if sup and sup.user_id == request.user.id:
|
||||
ct = ContentType.objects.get_for_model(CaseCollection)
|
||||
cid_user_exam = CidUserExam.objects.filter(
|
||||
content_type=ct,
|
||||
object_id=atlas.pk,
|
||||
user_user=target_user
|
||||
).first()
|
||||
if (cid_user_exam and cid_user_exam.share_with_supervisor) or atlas.results_supervisor_visible:
|
||||
return function(request, *args, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise PermissionDenied
|
||||
|
||||
wrap.__doc__ = function.__doc__
|
||||
wrap.__name__ = function.__name__
|
||||
|
||||
+318
-6
@@ -1,4 +1,8 @@
|
||||
import django_filters
|
||||
from django import forms
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Div, Field
|
||||
from collections import OrderedDict
|
||||
|
||||
from .models import (
|
||||
Case,
|
||||
@@ -7,13 +11,18 @@ from .models import (
|
||||
Finding,
|
||||
PathologicalProcess,
|
||||
Presentation,
|
||||
Procedure,
|
||||
QuestionSchema,
|
||||
Series,
|
||||
Structure,
|
||||
Subspecialty,
|
||||
NormalCase,
|
||||
Resource,
|
||||
Site,
|
||||
)
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.http import QueryDict
|
||||
|
||||
|
||||
def get_authors(request):
|
||||
@@ -78,6 +87,22 @@ class CaseCollectionFilter(django_filters.FilterSet):
|
||||
user=None,
|
||||
request=None,
|
||||
):
|
||||
# Default the archive filter to False when not provided so collections
|
||||
# are not archived by default in listings.
|
||||
if data is None:
|
||||
data = QueryDict('', mutable=True)
|
||||
data['archive'] = 'false'
|
||||
else:
|
||||
# QueryDict instances need to be copied to be mutable
|
||||
try:
|
||||
if 'archive' not in data:
|
||||
data = data.copy()
|
||||
data['archive'] = 'false'
|
||||
except Exception:
|
||||
# Fallback for non-QueryDict mappings
|
||||
if isinstance(data, dict) and 'archive' not in data:
|
||||
data = dict(data)
|
||||
data['archive'] = 'false'
|
||||
if not request.user.groups.filter(name="atlas_editor").exists():
|
||||
queryset = queryset.prefetch_related("author").filter(
|
||||
author__id=request.user.id
|
||||
@@ -92,9 +117,22 @@ class CaseFilter(django_filters.FilterSet):
|
||||
author = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=get_authors, null_label="No author"
|
||||
)
|
||||
# Show a distinct list of `Series.examination` values instead of raw Series
|
||||
# entries so examinations that differ only by plane/description appear once.
|
||||
series = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=get_series, null_label="No series"
|
||||
field_name='series__examination',
|
||||
queryset=lambda request: __import__('generic.models', fromlist=['Examination']).Examination.objects.filter(atlas_series_examination__isnull=False).distinct(),
|
||||
label='Examination',
|
||||
)
|
||||
# Free-text search to help find the correct examination when there are
|
||||
# many similarly-named Series/Examination entries.
|
||||
examination_text = django_filters.CharFilter(method='filter_examination_text', label='Examination (text)')
|
||||
# Full list of conditions and a text search to find conditions
|
||||
condition = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Condition.objects.all(),
|
||||
label='Condition',
|
||||
)
|
||||
condition_text = django_filters.CharFilter(method='filter_condition_text', label='Condition (text)')
|
||||
differential_case = django_filters.ModelMultipleChoiceFilter(
|
||||
label="Differential",
|
||||
field_name="differentialcase__condition",
|
||||
@@ -129,6 +167,58 @@ class CaseFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_examination_text(self, queryset, name, value):
|
||||
"""Filter Cases by a text search on related Examination records.
|
||||
|
||||
Matches against the Examination string representation and any
|
||||
common identifying fields (name/code). Uses a case-insensitive
|
||||
contains lookup and filters Cases which have a Series linked to
|
||||
a matching Examination.
|
||||
"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
from generic.models import Examination
|
||||
|
||||
# Only attempt to match PK when the provided value looks like an integer
|
||||
try:
|
||||
pk_val = int(value)
|
||||
except Exception:
|
||||
pk_val = None
|
||||
|
||||
if pk_val is not None:
|
||||
matches = Examination.objects.filter(
|
||||
Q(examination__icontains=value) | Q(pk=pk_val)
|
||||
).distinct()
|
||||
else:
|
||||
matches = Examination.objects.filter(
|
||||
Q(examination__icontains=value)
|
||||
).distinct()
|
||||
|
||||
return queryset.filter(series__examination__in=matches).distinct()
|
||||
|
||||
def filter_condition_text(self, queryset, name, value):
|
||||
"""Filter Cases by a text search on related Condition records.
|
||||
|
||||
Matches both direct `condition` M2M and `differential` entries.
|
||||
"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
# Only try PK match when numeric
|
||||
try:
|
||||
pk_val = int(value)
|
||||
except Exception:
|
||||
pk_val = None
|
||||
|
||||
if pk_val is not None:
|
||||
matches = Condition.objects.filter(Q(name__icontains=value) | Q(pk=pk_val)).distinct()
|
||||
else:
|
||||
matches = Condition.objects.filter(Q(name__icontains=value)).distinct()
|
||||
|
||||
# Match either direct condition M2M or differential (through Differential)
|
||||
return queryset.filter(Q(condition__in=matches) | Q(differential__in=matches)).distinct()
|
||||
|
||||
|
||||
class SeriesFilter(django_filters.FilterSet):
|
||||
case = django_filters.ModelMultipleChoiceFilter(
|
||||
@@ -161,13 +251,16 @@ class SeriesFilter(django_filters.FilterSet):
|
||||
|
||||
|
||||
class ConditionFilter(django_filters.FilterSet):
|
||||
# Replace legacy `primary` filter with `is_canonical` and provide a
|
||||
# `synonym` filter which will filter by the canonical group of the
|
||||
# selected condition.
|
||||
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
||||
synonym = django_filters.ModelChoiceFilter(queryset=Condition.objects.all(), method="filter_synonym", label="Synonym group")
|
||||
|
||||
class Meta:
|
||||
model = Condition
|
||||
fields = {
|
||||
"name": ["icontains"],
|
||||
"primary": ["exact"],
|
||||
"synonym": ["exact"],
|
||||
#"parent": ["exact"],
|
||||
"subspecialty": ["exact"],
|
||||
"rcr_curriculum": ["exact"],
|
||||
}
|
||||
@@ -186,11 +279,32 @@ class ConditionFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_is_canonical(self, queryset, name, value):
|
||||
if value in (True, "True", "true", 1, "1"):
|
||||
return queryset.filter(canonical__isnull=True)
|
||||
if value in (False, "False", "false", 0, "0"):
|
||||
return queryset.filter(canonical__isnull=False)
|
||||
return queryset
|
||||
|
||||
def filter_synonym(self, queryset, name, value):
|
||||
# value is a Condition instance selected in the filter. Return all
|
||||
# Conditions that belong to the same canonical group as `value`.
|
||||
if not value:
|
||||
return queryset
|
||||
master = value.canonical if value.canonical else value
|
||||
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
||||
|
||||
|
||||
class FindingFilter(django_filters.FilterSet):
|
||||
# Replace legacy `primary`/`synonym` with canonical-based filters
|
||||
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
||||
synonym = django_filters.ModelChoiceFilter(queryset=Finding.objects.all(), method="filter_synonym", label="Synonym group")
|
||||
|
||||
class Meta:
|
||||
model = Finding
|
||||
fields = ("name", "primary", "synonym")
|
||||
fields = {
|
||||
"name": ["icontains"],
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -206,11 +320,29 @@ class FindingFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_is_canonical(self, queryset, name, value):
|
||||
if value in (True, "True", "true", 1, "1"):
|
||||
return queryset.filter(canonical__isnull=True)
|
||||
if value in (False, "False", "false", 0, "0"):
|
||||
return queryset.filter(canonical__isnull=False)
|
||||
return queryset
|
||||
|
||||
def filter_synonym(self, queryset, name, value):
|
||||
# value is a Finding instance selected in the filter. Return all
|
||||
# Findings that belong to the same canonical group as `value`.
|
||||
if not value:
|
||||
return queryset
|
||||
master = value.canonical if value.canonical else value
|
||||
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
||||
|
||||
|
||||
class StructureFilter(django_filters.FilterSet):
|
||||
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
||||
synonym = django_filters.ModelChoiceFilter(queryset=Structure.objects.all(), method="filter_synonym", label="Synonym group")
|
||||
|
||||
class Meta:
|
||||
model = Structure
|
||||
fields = {"name": ["icontains"], "primary": ["exact"], "synonym": ["exact"]}
|
||||
fields = {"name": ["icontains"]}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -226,6 +358,19 @@ class StructureFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_is_canonical(self, queryset, name, value):
|
||||
if value in (True, "True", "true", 1, "1"):
|
||||
return queryset.filter(canonical__isnull=True)
|
||||
if value in (False, "False", "false", 0, "0"):
|
||||
return queryset.filter(canonical__isnull=False)
|
||||
return queryset
|
||||
|
||||
def filter_synonym(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
master = value.canonical if value.canonical else value
|
||||
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
||||
|
||||
|
||||
class PresentationFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
@@ -286,6 +431,173 @@ class SubspecialtyFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
|
||||
class NormalCaseFilter(django_filters.FilterSet):
|
||||
"""FilterSet for the NormalCase model allowing filtering by
|
||||
examination, modality and nearest-year age ranges.
|
||||
"""
|
||||
examination = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
||||
modality = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
||||
procedures = django_filters.ModelMultipleChoiceFilter(field_name="case__procedures", queryset=Procedure.objects.all(), label="Procedure")
|
||||
# Accept min/max as years in the UI but filter against the canonical
|
||||
# `age_days` field by converting years -> days.
|
||||
# Numeric min/max values entered by the user (interpreted via `age_unit`)
|
||||
min_value = django_filters.NumberFilter(label="Min", method="filter_min_value")
|
||||
max_value = django_filters.NumberFilter(label="Max", method="filter_max_value")
|
||||
AGE_UNIT_CHOICES = (
|
||||
("days", "Days"),
|
||||
("weeks", "Weeks"),
|
||||
("months", "Months"),
|
||||
("years", "Years"),
|
||||
)
|
||||
age_unit = django_filters.ChoiceFilter(label="Unit", choices=AGE_UNIT_CHOICES, method="filter_age_unit")
|
||||
|
||||
class Meta:
|
||||
model = NormalCase
|
||||
fields = ("examination", "modality", "procedures", "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
|
||||
|
||||
# We'll set the actual querysets for the filters after applying
|
||||
# any NormalCase queryset restrictions for the current user so
|
||||
# that the choices only include examinations/modalities that
|
||||
# actually have NormalCase records available.
|
||||
|
||||
# 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)))
|
||||
|
||||
# Determine the NormalCase queryset we'll use to build choices
|
||||
nc_qs = queryset if queryset is not None else NormalCase.objects.all()
|
||||
|
||||
# Limit examination and modality choices to those present on the
|
||||
# available NormalCase records (respecting user restrictions).
|
||||
try:
|
||||
exam_ids = nc_qs.values_list("examination_id", flat=True)
|
||||
mod_ids = nc_qs.values_list("modality_id", flat=True)
|
||||
proc_ids = nc_qs.values_list("case__procedures", flat=True)
|
||||
self.base_filters.get("examination").queryset = Examination.objects.filter(id__in=exam_ids).distinct()
|
||||
self.base_filters.get("modality").queryset = Modality.objects.filter(id__in=mod_ids).distinct()
|
||||
self.base_filters.get("procedures").queryset = Procedure.objects.filter(id__in=proc_ids).distinct()
|
||||
except Exception:
|
||||
# Fallback to all if something goes wrong
|
||||
self.base_filters.get("examination").queryset = Examination.objects.all()
|
||||
self.base_filters.get("modality").queryset = Modality.objects.all()
|
||||
|
||||
super(NormalCaseFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
|
||||
|
||||
# Attach a Crispy FormHelper to render the filter form as a
|
||||
# horizontal Bootstrap grid. We set `form_tag=False` because the
|
||||
# template already provides the <form> element.
|
||||
try:
|
||||
helper = FormHelper()
|
||||
helper.form_method = "get"
|
||||
helper.form_tag = False
|
||||
helper.form_class = "row g-2"
|
||||
helper.layout = Layout(
|
||||
Div(
|
||||
Div("examination", css_class="col-md-3"),
|
||||
Div("modality", css_class="col-md-3"),
|
||||
Div("procedures", css_class="col-md-3"),
|
||||
css_class="row g-2",
|
||||
),
|
||||
Div(
|
||||
Div(
|
||||
Field("min_value", wrapper_class="mb-0 d-flex align-items-center"),
|
||||
Field("max_value", wrapper_class="mb-0 d-flex align-items-center"),
|
||||
Field("age_unit", wrapper_class="mb-0 d-flex align-items-center"),
|
||||
css_class="col-12 d-flex gap-2 align-items-center",
|
||||
),
|
||||
css_class="row g-2",
|
||||
),
|
||||
)
|
||||
# Assign helper to the generated form instance and tweak widgets
|
||||
if hasattr(self, 'form') and self.form is not None:
|
||||
# Wider numeric inputs and default unit
|
||||
try:
|
||||
form = self.form
|
||||
if "min_value" in form.fields:
|
||||
form.fields["min_value"].widget = forms.NumberInput(attrs={"class": "form-control w-auto", "min": "0", "step": "1", "style": "min-width:140px;"})
|
||||
if "max_value" in form.fields:
|
||||
form.fields["max_value"].widget = forms.NumberInput(attrs={"class": "form-control w-auto", "min": "0", "step": "1", "style": "min-width:140px;"})
|
||||
if "age_unit" in form.fields:
|
||||
form.fields["age_unit"].initial = "days"
|
||||
form.fields["age_unit"].widget.attrs.update({"class": "form-select w-auto", "style": "min-width:220px;"})
|
||||
form.helper = helper
|
||||
except Exception:
|
||||
# don't crash if form structure differs
|
||||
self.form.helper = helper
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _convert_to_days(self, value, unit: str) -> int:
|
||||
try:
|
||||
v = float(value)
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
if unit == "days":
|
||||
return int(v)
|
||||
if unit == "weeks":
|
||||
return int(v * 7)
|
||||
if unit == "months":
|
||||
# approximate month length
|
||||
return int(v * (365.25 / 12.0))
|
||||
# default: years
|
||||
return int(v * 365.25)
|
||||
|
||||
def filter_min_value(self, queryset, name, value):
|
||||
try:
|
||||
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "days")
|
||||
except Exception:
|
||||
unit = "days"
|
||||
try:
|
||||
days = self._convert_to_days(value, unit)
|
||||
except Exception:
|
||||
return queryset
|
||||
return queryset.filter(age_days__gte=days)
|
||||
|
||||
def filter_max_value(self, queryset, name, value):
|
||||
try:
|
||||
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "days")
|
||||
except Exception:
|
||||
unit = "days"
|
||||
try:
|
||||
days = self._convert_to_days(value, unit)
|
||||
except Exception:
|
||||
return queryset
|
||||
return queryset.filter(age_days__lte=days)
|
||||
|
||||
def filter_age_unit(self, queryset, name, value):
|
||||
# `age_unit` is a helper input used by min/max conversion and does not
|
||||
# map to a database field on NormalCase.
|
||||
return queryset
|
||||
|
||||
|
||||
class ResourceFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(field_name='name', lookup_expr='icontains')
|
||||
site = django_filters.ModelMultipleChoiceFilter(field_name='sites', queryset=lambda request: Site.objects.all() if True else Site.objects.none())
|
||||
subspecialty = django_filters.ModelMultipleChoiceFilter(field_name='subspecialty', queryset=Subspecialty.objects.all())
|
||||
author = django_filters.ModelMultipleChoiceFilter(queryset=get_authors, null_label='No author')
|
||||
has_file = django_filters.BooleanFilter(method='filter_has_file', label='Has file')
|
||||
|
||||
class Meta:
|
||||
model = Resource
|
||||
fields = ('name', 'site', 'subspecialty', 'author')
|
||||
|
||||
def filter_has_file(self, queryset, name, value):
|
||||
if value is True:
|
||||
return queryset.exclude(file__isnull=True).exclude(file__exact='')
|
||||
elif value is False:
|
||||
return queryset.filter(file__isnull=True) | queryset.filter(file__exact='')
|
||||
return queryset
|
||||
|
||||
class QuestionSchemaFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
model = QuestionSchema
|
||||
|
||||
+449
-78
@@ -40,10 +40,15 @@ from atlas.models import (
|
||||
Condition,
|
||||
Structure,
|
||||
Subspecialty,
|
||||
Presentation,
|
||||
PathologicalProcess,
|
||||
Procedure,
|
||||
UncategorisedDicom,
|
||||
UserReportAnswer,
|
||||
CaseDisplaySet,
|
||||
CaseReviewMessage,
|
||||
)
|
||||
from .models import NormalCase
|
||||
|
||||
from anatomy.models import Modality
|
||||
|
||||
@@ -53,7 +58,8 @@ from generic.models import Examination # , Sign
|
||||
|
||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||
from django.forms.widgets import RadioSelect, TextInput, Textarea, Select
|
||||
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML
|
||||
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML, Button
|
||||
from django.urls import reverse
|
||||
from crispy_forms.bootstrap import Accordion, AccordionGroup
|
||||
|
||||
from tinymce.widgets import TinyMCE
|
||||
@@ -111,15 +117,34 @@ class CaseSelect(Select):
|
||||
pass
|
||||
|
||||
|
||||
class MoveSelectedSeriesForm(Form):
|
||||
destination_case = ModelChoiceField(
|
||||
queryset=Case.objects.none(),
|
||||
widget=HiddenInput(),
|
||||
label="Move selected series to case",
|
||||
required=True,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop("user", None)
|
||||
source_case = kwargs.pop("source_case", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
available_cases = get_cases_available_to_user(user)
|
||||
if source_case is not None:
|
||||
available_cases = available_cases.exclude(pk=source_case.pk)
|
||||
|
||||
self.fields["destination_case"].queryset = available_cases.order_by("pk")
|
||||
|
||||
|
||||
class ConditionForm(ModelForm):
|
||||
class Meta:
|
||||
model = Condition
|
||||
exclude = ["rcr_curriculum"]
|
||||
|
||||
widgets = {
|
||||
"synonym": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:condition-autocomplete"
|
||||
),
|
||||
# Use an AJAX/autocomplete single-select for canonical so users
|
||||
# can search for existing canonical Conditions by name.
|
||||
"canonical": autocomplete.ModelSelect2(url="atlas:condition-autocomplete"),
|
||||
"parent": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:condition-autocomplete"
|
||||
),
|
||||
@@ -128,6 +153,86 @@ class ConditionForm(ModelForm):
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Crispy helper to render a submit button consistently
|
||||
try:
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = True
|
||||
# Primary submit
|
||||
self.helper.add_input(Submit("submit", "Submit"))
|
||||
# Cancel as a button that redirects back to the condition list
|
||||
try:
|
||||
cancel_url = reverse("atlas:condition_view")
|
||||
except Exception:
|
||||
cancel_url = "#"
|
||||
self.helper.add_input(
|
||||
Button(
|
||||
"cancel",
|
||||
"Cancel",
|
||||
css_class="btn btn-secondary ms-2",
|
||||
onclick=f"window.location='{cancel_url}'",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# If crispy not available or something goes wrong, don't break form
|
||||
pass
|
||||
|
||||
|
||||
class ConditionRelationshipForm(Form):
|
||||
parents = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=Condition.objects.none(),
|
||||
widget=autocomplete.ModelSelect2Multiple(url="atlas:condition-autocomplete"),
|
||||
)
|
||||
children = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=Condition.objects.none(),
|
||||
widget=autocomplete.ModelSelect2Multiple(url="atlas:condition-autocomplete"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.instance = kwargs.pop("instance", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
condition_qs = Condition.objects.all().order_by("name")
|
||||
if self.instance is not None and getattr(self.instance, "pk", None):
|
||||
condition_qs = condition_qs.exclude(pk=self.instance.pk)
|
||||
|
||||
self.fields["parents"].queryset = condition_qs
|
||||
self.fields["children"].queryset = condition_qs
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = False
|
||||
self.helper.layout = Layout(
|
||||
Fieldset(
|
||||
"Condition relationships",
|
||||
"parents",
|
||||
"children",
|
||||
)
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
if self.instance is None:
|
||||
raise ValueError("ConditionRelationshipForm requires an instance")
|
||||
|
||||
parents = list(self.cleaned_data.get("parents", []))
|
||||
children = list(self.cleaned_data.get("children", []))
|
||||
|
||||
self.instance.parent.set(parents)
|
||||
|
||||
current_children = list(self.instance.child.all())
|
||||
for child in current_children:
|
||||
if child not in children:
|
||||
self.instance.child.remove(child)
|
||||
for child in children:
|
||||
if child not in current_children:
|
||||
self.instance.child.add(child)
|
||||
|
||||
return self.instance
|
||||
|
||||
|
||||
class CaseCollectionForm(ModelForm):
|
||||
class Meta:
|
||||
@@ -141,6 +246,38 @@ class CaseCollectionForm(ModelForm):
|
||||
ModelForm.__init__(self, *args, **kwargs)
|
||||
super(CaseCollectionForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Render collection_type as radio buttons and ensure it appears near the top
|
||||
if "collection_type" in self.fields:
|
||||
# Replace the model field with an explicit ChoiceField that uses RadioSelect
|
||||
original = self.fields["collection_type"]
|
||||
choices = getattr(original, "choices", ())
|
||||
initial = original.initial if hasattr(original, "initial") else None
|
||||
required = original.required
|
||||
label = original.label
|
||||
widget = RadioSelect(attrs={"class": "btn-check"})
|
||||
# assign custom template for rendering as button group
|
||||
#widget.template_name = "widgets/radioselect_buttons.html"
|
||||
self.fields["collection_type"] = forms.ChoiceField(
|
||||
choices=choices,
|
||||
widget=widget,
|
||||
initial=initial,
|
||||
required=required,
|
||||
label=label,
|
||||
)
|
||||
|
||||
if "prerequisites" in self.fields:
|
||||
self.fields["prerequisites"].widget = autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:casecollection-prerequisite-autocomplete",
|
||||
attrs={
|
||||
"data-placeholder": "Search authored collections...",
|
||||
"data-minimum-input-length": 0,
|
||||
},
|
||||
)
|
||||
authored_collections = CaseCollection.objects.filter(author=self.user).distinct().order_by("name")
|
||||
if getattr(self.instance, "pk", None):
|
||||
authored_collections = authored_collections.exclude(pk=self.instance.pk)
|
||||
self.fields["prerequisites"].queryset = authored_collections
|
||||
|
||||
# Identify show_ fields
|
||||
show_fields = [f for f in self.fields if f.startswith("show_")]
|
||||
show_pre_fields = [f for f in show_fields if f.endswith("_pre")]
|
||||
@@ -178,6 +315,10 @@ class CaseCollectionForm(ModelForm):
|
||||
"show_description_post",
|
||||
"show_discussion_post",
|
||||
"show_report_post",
|
||||
"show_case_link_post",
|
||||
"show_findings_post",
|
||||
"show_displaysets_post",
|
||||
"show_differentials_post",
|
||||
]
|
||||
if f in self.fields
|
||||
]
|
||||
@@ -186,6 +327,7 @@ class CaseCollectionForm(ModelForm):
|
||||
Fieldset(
|
||||
"Collection Details",
|
||||
"name",
|
||||
Field("collection_type", css_class="mb-2"),
|
||||
Fieldset(
|
||||
"Dates",
|
||||
"restrict_to_dates",
|
||||
@@ -213,10 +355,26 @@ class CaseCollectionForm(ModelForm):
|
||||
"exam_mode",
|
||||
"self_review",
|
||||
"feedback_once_collection_complete",
|
||||
"collection_type",
|
||||
"case_query_messaging_enabled",
|
||||
"show_results_sharing_information",
|
||||
"viewer_mode",
|
||||
"question_time_limit",
|
||||
"answer_entry_grace_period",
|
||||
"prerequisites",
|
||||
"pre_survey",
|
||||
"pre_survey_mandatory",
|
||||
"post_survey",
|
||||
"post_survey_mandatory",
|
||||
Fieldset(
|
||||
"Custom Start Screen",
|
||||
"start_screen_content",
|
||||
"start_screen_resources",
|
||||
),
|
||||
Fieldset(
|
||||
"Custom End Overview",
|
||||
"end_overview_content",
|
||||
"end_overview_resources",
|
||||
),
|
||||
),
|
||||
Fieldset("Valid User Groups", *user_fields) if user_fields else None,
|
||||
Div(
|
||||
@@ -236,35 +394,71 @@ class CaseCollectionForm(ModelForm):
|
||||
Submit("submit", "Save Collection", css_class="btn btn-primary"),
|
||||
)
|
||||
|
||||
self.fields["start_date"] = SplitDateTimeFieldDefaultTime(
|
||||
widget=SplitDateTimeWidget(
|
||||
date_attrs={"type": "date", "class": "datepicker"},
|
||||
time_attrs={"type": "time", "class": "timepicker"},
|
||||
date_format="%Y-%m-%d",
|
||||
time_format="%H:%M",
|
||||
|
||||
class NormalCaseForm(ModelForm):
|
||||
# Keep non-model input fields for age entry so users can provide a
|
||||
# value + unit (e.g. 6 months) which will be converted to canonical
|
||||
# days on save. The model now stores only `age_days`.
|
||||
age_value = forms.IntegerField(required=False, min_value=0)
|
||||
age_unit = forms.ChoiceField(required=False, choices=NormalCase.AGE_UNIT_CHOICES)
|
||||
|
||||
class Meta:
|
||||
model = NormalCase
|
||||
fields = ("examination", "modality", "notes")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# If editing an existing NormalCase, prepopulate age_value/age_unit
|
||||
try:
|
||||
instance = kwargs.get('instance')
|
||||
except Exception:
|
||||
instance = None
|
||||
if instance is None:
|
||||
# also support self.instance when ModelForm sets it
|
||||
instance = getattr(self, 'instance', None)
|
||||
if instance is not None and getattr(instance, 'age_days', None) is not None:
|
||||
try:
|
||||
val, unit = NormalCase.days_to_value_unit(instance.age_days)
|
||||
if val is not None and unit is not None:
|
||||
self.fields['age_value'].initial = val
|
||||
self.fields['age_unit'].initial = unit
|
||||
except Exception:
|
||||
pass
|
||||
self.helper = FormHelper()
|
||||
# Do not render a <form> tag from crispy here because the template
|
||||
# already provides the surrounding form element (we inject this
|
||||
# fragment into a modal that contains the form). Rendering a second
|
||||
# <form> would nest forms and cause fields to be outside the
|
||||
# submitted form (HTMX would only send the outer form's fields).
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = False
|
||||
self.helper.layout = Layout(
|
||||
Div(
|
||||
Field("age_value", wrapper_class="col-md-3"),
|
||||
Field("age_unit", wrapper_class="col-md-3"),
|
||||
Field("examination", wrapper_class="col-md-3"),
|
||||
Field("modality", wrapper_class="col-md-3"),
|
||||
css_class="row",
|
||||
),
|
||||
required=False,
|
||||
help_text="The date the exam is due to start (time is optional)",
|
||||
)
|
||||
self.fields["end_date"] = SplitDateTimeFieldDefaultTimeEnd(
|
||||
widget=SplitDateTimeWidget(
|
||||
date_attrs={"type": "date", "class": "datepicker"},
|
||||
time_attrs={"type": "time", "class": "timepicker"},
|
||||
date_format="%Y-%m-%d",
|
||||
time_format="%H:%M",
|
||||
),
|
||||
required=False,
|
||||
help_text="The date the exam is due to start (time is optional)",
|
||||
Field("notes"),
|
||||
# Submit button rendered in modal footer; avoid duplicate button here
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
# Get the unsaved Case instance
|
||||
instance = ModelForm.save(self, False)
|
||||
# Respect commit flag. Compute canonical `age_days` from the
|
||||
# provided age_value/age_unit and set it on the instance.
|
||||
instance = super().save(commit=False)
|
||||
age_value = self.cleaned_data.get("age_value")
|
||||
age_unit = self.cleaned_data.get("age_unit")
|
||||
if age_value is not None and age_unit:
|
||||
instance.age_days = NormalCase.to_days(age_value, age_unit)
|
||||
else:
|
||||
# If the user left fields blank, clear any previous value.
|
||||
instance.age_days = None
|
||||
|
||||
# Do we need to save all changes now?
|
||||
# if commit:
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
if commit:
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
|
||||
return instance
|
||||
|
||||
@@ -273,9 +467,10 @@ class FindingForm(ModelForm):
|
||||
class Meta:
|
||||
model = Finding
|
||||
exclude = []
|
||||
|
||||
# Use the canonical FK in forms (if users want to mark this Finding
|
||||
# as an alias of another). The old `synonym` M2M has been removed.
|
||||
widgets = {
|
||||
"synonym": autocomplete.ModelSelect2Multiple(
|
||||
"canonical": autocomplete.ModelSelect2(
|
||||
url="atlas:finding-autocomplete"
|
||||
),
|
||||
}
|
||||
@@ -285,14 +480,47 @@ class StructureForm(ModelForm):
|
||||
class Meta:
|
||||
model = Structure
|
||||
exclude = []
|
||||
|
||||
widgets = {
|
||||
"synonym": autocomplete.ModelSelect2Multiple(
|
||||
"canonical": autocomplete.ModelSelect2(
|
||||
url="atlas:structure-autocomplete"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class SubspecialtyForm(ModelForm):
|
||||
class Meta:
|
||||
model = Subspecialty
|
||||
exclude = []
|
||||
|
||||
|
||||
class PresentationForm(ModelForm):
|
||||
class Meta:
|
||||
model = Presentation
|
||||
exclude = []
|
||||
|
||||
|
||||
class PathologicalProcessForm(ModelForm):
|
||||
class Meta:
|
||||
model = PathologicalProcess
|
||||
exclude = []
|
||||
|
||||
|
||||
class ProcedureForm(ModelForm):
|
||||
class Meta:
|
||||
model = Procedure
|
||||
exclude = []
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
try:
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
# Prevent crispy from rendering its own <form> tag so our
|
||||
# template-level form wrapper contains the submit buttons.
|
||||
self.helper.form_tag = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class SeriesFindingForm(ModelForm):
|
||||
class Meta:
|
||||
model = SeriesFinding
|
||||
@@ -473,8 +701,9 @@ class CaseForm(ModelForm):
|
||||
|
||||
# This is populated if we create the case from an exam/collection
|
||||
self.collection = None
|
||||
if kwargs["initial"] is not None and "exams" in kwargs["initial"]:
|
||||
collections = kwargs["initial"].pop("exams")
|
||||
initial = kwargs.setdefault("initial", {})
|
||||
if initial is not None and "exams" in initial:
|
||||
collections = initial.pop("exams")
|
||||
logger.debug(collections)
|
||||
|
||||
self.collection = get_object_or_404(CaseCollection, pk=collections[0])
|
||||
@@ -515,12 +744,14 @@ class CaseForm(ModelForm):
|
||||
"presentation",
|
||||
"discussion",
|
||||
"report",
|
||||
"procedures",
|
||||
"condition",
|
||||
"pathological_process",
|
||||
"open_access",
|
||||
"previous_case",
|
||||
"diagnostic_certainty",
|
||||
"cimar_uuid",
|
||||
#cimar_uuid",
|
||||
"study_date",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -546,13 +777,15 @@ class CaseForm(ModelForm):
|
||||
"history",
|
||||
"presentation",
|
||||
"discussion",
|
||||
"procedures",
|
||||
"condition",
|
||||
"pathological_process",
|
||||
"report",
|
||||
"open_access",
|
||||
"previous_case",
|
||||
"diagnostic_certainty",
|
||||
"cimar_uuid",
|
||||
#cimar_uuid",
|
||||
"study_date",
|
||||
]
|
||||
# fields = ['question', 'findings', 'subspecialty', 'references']
|
||||
widgets = {
|
||||
@@ -575,6 +808,9 @@ class CaseForm(ModelForm):
|
||||
"subspecialty": CheckboxSelectMultiple(),
|
||||
"pathological_process": CheckboxSelectMultiple(),
|
||||
"previous_case": CaseSelect(),
|
||||
"procedures": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:procedure-autocomplete"
|
||||
),
|
||||
}
|
||||
|
||||
def clean_cimar_uuid(self):
|
||||
@@ -606,23 +842,23 @@ class CaseForm(ModelForm):
|
||||
self.save_m2m = save_m2m
|
||||
|
||||
# Do we need to save all changes now?
|
||||
# if commit:
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
if commit:
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
|
||||
logger.debug(f"{self.collection=}")
|
||||
|
||||
if self.collection is not None:
|
||||
logger.debug(f"{self.collection=}")
|
||||
case_no = self.collection.cases.count() + 1
|
||||
logger.debug(f"{case_no=}")
|
||||
logger.debug(f"{instance=}")
|
||||
|
||||
# Create through model
|
||||
# cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
|
||||
self.collection.cases.add(
|
||||
instance, through_defaults={"sort_order": case_no}
|
||||
)
|
||||
if self.collection is not None:
|
||||
logger.debug(f"{self.collection=}")
|
||||
case_no = self.collection.cases.count() + 1
|
||||
logger.debug(f"{case_no=}")
|
||||
logger.debug(f"{instance=}")
|
||||
|
||||
# Create through model
|
||||
# cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
|
||||
self.collection.cases.add(
|
||||
instance, through_defaults={"sort_order": case_no}
|
||||
)
|
||||
|
||||
return instance
|
||||
|
||||
@@ -644,7 +880,7 @@ CaseDifferentialFormSet = inlineformset_factory(
|
||||
class ResourceForm(ModelForm):
|
||||
class Meta:
|
||||
model = Resource
|
||||
exclude = ["author"]
|
||||
exclude = ["author", "created_date"]
|
||||
|
||||
widgets = {
|
||||
"description": Textarea(attrs={"cols": 80, "rows": 5}),
|
||||
@@ -721,7 +957,13 @@ CaseCollectionCaseFormSet = inlineformset_factory(
|
||||
CaseCollection,
|
||||
Case.casecollection_set.through,
|
||||
form=CaseCollectionCaseForm,
|
||||
exclude=["question_schema", "question_answers"],
|
||||
exclude=[
|
||||
"question_schema",
|
||||
"question_answers",
|
||||
"series_visibility_config",
|
||||
"learner_comment",
|
||||
"learner_review_comment",
|
||||
],
|
||||
can_delete=True,
|
||||
extra=0,
|
||||
max_num=50,
|
||||
@@ -791,7 +1033,7 @@ class BaseReportAnswerForm(ModelForm):
|
||||
# "answer": "Write your answer in here."
|
||||
# }
|
||||
|
||||
def __init__(self, *args, case_detail, **kwargs):
|
||||
def __init__(self, *args, casedetail, **kwargs):
|
||||
super(BaseReportAnswerForm, self).__init__(*args, **kwargs)
|
||||
self.fields["answer"].required = False
|
||||
|
||||
@@ -807,12 +1049,12 @@ class BaseQuestionAnswerForm(ModelForm):
|
||||
# "answer": "Write your answer in here."
|
||||
# }
|
||||
|
||||
def __init__(self, *args, case_detail, **kwargs):
|
||||
def __init__(self, *args, casedetail, **kwargs):
|
||||
super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs)
|
||||
# self.fields["json_answer"].schema = case_detail.question_schema
|
||||
if case_detail.question_schema is not None:
|
||||
# self.fields["json_answer"].schema = casedetail.question_schema
|
||||
if casedetail.question_schema is not None:
|
||||
self.fields["json_answer"] = JSONSchemaField(
|
||||
schema=case_detail.question_schema, options="schema/options.json"
|
||||
schema=casedetail.question_schema, options="schema/options.json"
|
||||
)
|
||||
self.fields["json_answer"].label = ""
|
||||
|
||||
@@ -934,6 +1176,14 @@ class AddCollectionToCaseForm(Form):
|
||||
|
||||
|
||||
class SelfReviewForm(ModelForm):
|
||||
SCORE_CHOICES = [
|
||||
(1, "1 - Poor"),
|
||||
(2, "2 - Limited"),
|
||||
(3, "3 - Adequate"),
|
||||
(4, "4 - Good"),
|
||||
(5, "5 - Excellent"),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
model = SelfReview
|
||||
fields = ["user_exam", "case", "comments", "findings", "interpretation"]
|
||||
@@ -948,6 +1198,75 @@ class SelfReviewForm(ModelForm):
|
||||
ModelForm.__init__(self, *args, **kwargs)
|
||||
super(SelfReviewForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Render scoring fields as explicit 1-5 selector choices for cleaner UX.
|
||||
self.fields["findings"] = forms.TypedChoiceField(
|
||||
choices=self.SCORE_CHOICES,
|
||||
coerce=int,
|
||||
empty_value=None,
|
||||
required=False,
|
||||
widget=RadioSelect,
|
||||
label="Findings",
|
||||
help_text="How well did you identify key findings?",
|
||||
)
|
||||
self.fields["interpretation"] = forms.TypedChoiceField(
|
||||
choices=self.SCORE_CHOICES,
|
||||
coerce=int,
|
||||
empty_value=None,
|
||||
required=False,
|
||||
widget=RadioSelect,
|
||||
label="Interpretation",
|
||||
help_text="How strong was your overall interpretation?",
|
||||
)
|
||||
|
||||
self.fields["comments"].widget.attrs.update(
|
||||
{
|
||||
"rows": 5,
|
||||
"placeholder": "What went well? What would you do differently next time?",
|
||||
}
|
||||
)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = False
|
||||
self.helper.layout = Layout(
|
||||
Field("user_exam"),
|
||||
Field("case"),
|
||||
Field("comments"),
|
||||
Field("findings"),
|
||||
Field("interpretation"),
|
||||
)
|
||||
|
||||
|
||||
class CaseReviewMessageForm(ModelForm):
|
||||
class Meta:
|
||||
model = CaseReviewMessage
|
||||
fields = ["message", "requires_acknowledgement"]
|
||||
|
||||
def __init__(self, *args, allow_ack=False, message_placeholder=None, **kwargs):
|
||||
super(CaseReviewMessageForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.fields["message"].widget.attrs.update(
|
||||
{
|
||||
"rows": 3,
|
||||
"placeholder": message_placeholder or "Write a message...",
|
||||
"class": "form-control",
|
||||
}
|
||||
)
|
||||
|
||||
self.fields["requires_acknowledgement"].widget.attrs.update(
|
||||
{
|
||||
"class": "form-check-input",
|
||||
}
|
||||
)
|
||||
|
||||
if not allow_ack:
|
||||
self.fields["requires_acknowledgement"].widget = HiddenInput()
|
||||
self.fields["requires_acknowledgement"].required = False
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = False
|
||||
|
||||
|
||||
class UncategorisedDicomForm(ModelForm):
|
||||
class Meta:
|
||||
@@ -973,6 +1292,37 @@ class ConditionAutocomplete(ModelAutocomplete):
|
||||
model = Condition
|
||||
search_attrs = ["name"]
|
||||
|
||||
@classmethod
|
||||
def get_items_from_keys(cls, keys, context_obj=None):
|
||||
"""Return items for given keys, ignoring empty strings.
|
||||
|
||||
The autocomplete frontend sometimes sends empty string keys which
|
||||
causes the ORM to raise ValueError when filtering numeric PK fields.
|
||||
Filter out falsy/blank keys before querying. If no valid keys remain
|
||||
return an empty queryset.
|
||||
"""
|
||||
# normalize and remove empty/blank values
|
||||
clean_keys = [k for k in keys if k is not None and str(k).strip() != ""]
|
||||
if not clean_keys:
|
||||
return cls.model.objects.none()
|
||||
|
||||
# try to convert numeric keys to ints where possible
|
||||
parsed_keys = []
|
||||
for k in clean_keys:
|
||||
try:
|
||||
parsed_keys.append(int(k))
|
||||
except Exception:
|
||||
parsed_keys.append(k)
|
||||
|
||||
qs = cls.model.objects.filter(pk__in=parsed_keys)
|
||||
|
||||
# Return a list of dicts matching the autocomplete core expectations
|
||||
# (items subscriptable by keys like 'key' / 'label').
|
||||
return [
|
||||
{"key": obj.pk, "label": getattr(obj, "name", str(obj)), "value": str(obj)}
|
||||
for obj in qs
|
||||
]
|
||||
|
||||
|
||||
class ConditionAutocompleteForm(Form):
|
||||
condition = ModelChoiceField(
|
||||
@@ -1027,7 +1377,14 @@ class CaseQuestionForm(ModelForm):
|
||||
class CaseDetailForm(ModelForm):
|
||||
class Meta:
|
||||
model = CaseDetail
|
||||
fields = ["redact_history", "override_history"]
|
||||
fields = [
|
||||
"redact_history",
|
||||
"override_history",
|
||||
"learner_comment",
|
||||
"learner_review_comment",
|
||||
"question_time_limit_override",
|
||||
"answer_entry_grace_period_override",
|
||||
]
|
||||
|
||||
def __init__(self, *args, case_history: str = None, **kwargs):
|
||||
"""
|
||||
@@ -1050,6 +1407,16 @@ class CaseDetailForm(ModelForm):
|
||||
self.fields["override_history"].widget.attrs.setdefault("rows", 6)
|
||||
self.fields["override_history"].widget.attrs.setdefault("class", "form-control")
|
||||
|
||||
if "question_time_limit_override" in self.fields:
|
||||
self.fields["question_time_limit_override"].help_text = (
|
||||
"Leave blank to use the collection default answer time limit."
|
||||
)
|
||||
|
||||
if "answer_entry_grace_period_override" in self.fields:
|
||||
self.fields["answer_entry_grace_period_override"].help_text = (
|
||||
"Leave blank to use the collection default answer-only grace period."
|
||||
)
|
||||
|
||||
# Build crispy helper/layout embedding the original history and buttons tied to override_history
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
@@ -1087,7 +1454,17 @@ class CaseDetailForm(ModelForm):
|
||||
self.helper.layout = Layout(
|
||||
HTML(history_block),
|
||||
Field("override_history"),
|
||||
Field("redact_history")
|
||||
Field("redact_history"),
|
||||
Fieldset(
|
||||
"Learner Comments",
|
||||
Field("learner_comment"),
|
||||
Field("learner_review_comment"),
|
||||
),
|
||||
Fieldset(
|
||||
"Case Timing Overrides",
|
||||
Field("question_time_limit_override"),
|
||||
Field("answer_entry_grace_period_override"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1113,32 +1490,26 @@ class PriorCaseForm(Form):
|
||||
|
||||
prior_visibility = CharField(max_length=2, required=True)
|
||||
|
||||
# def __init__(self, *args, **kwargs):
|
||||
# self.case_detail = kwargs.pop(
|
||||
# "case_detail"
|
||||
# ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
|
||||
|
||||
# super(PriorCaseForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# prior_cases = self.case_detail.case.get_all_prior_cases()
|
||||
|
||||
# if not prior_cases:
|
||||
# prior_cases = Case.objects.none()
|
||||
|
||||
# self.fields["case"] = ChoiceField(
|
||||
# required=False,
|
||||
# # widget=Select(verbose_name="Series", is_stacked=False),
|
||||
# )
|
||||
|
||||
|
||||
class CaseSeriesForm(forms.ModelForm):
|
||||
def __init__(self, *args, queryset, user=None, is_atlas_editor=False, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["series"].queryset = queryset
|
||||
self.fields["series"].label_from_instance = lambda obj: f"Series {obj.pk}: {obj.description or ''} ({obj.modality or ''})"
|
||||
|
||||
class Meta:
|
||||
model = SeriesDetail # through model for Case.series
|
||||
|
||||
fields = ["series", "sort_order", "feedback"]
|
||||
widgets = {
|
||||
"series": autocomplete.ModelSelect2(
|
||||
url="atlas:series-autocomplete",
|
||||
attrs={
|
||||
"data-placeholder": "Search by Series ID or description...",
|
||||
"data-minimum-input-length": 0,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class CaseUpdateSeriesForm(forms.ModelForm):
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-26 11:05
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0089_remove_condition_primary_remove_condition_synonym'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, help_text='Creation timestamp'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='updated',
|
||||
field=models.DateTimeField(auto_now=True, help_text='Last updated timestamp'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-16 13:28
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0090_casecollection_created_casecollection_updated'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='normalcase',
|
||||
name='added_by',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-16 13:29
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0091_remove_normalcase_added_by'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='normalcase',
|
||||
name='author',
|
||||
field=models.ManyToManyField(blank=True, related_name='normal_cases', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-16 14:01
|
||||
|
||||
import atlas.models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0092_normalcase_author'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Procedure',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
('canonical', models.ForeignKey(blank=True, help_text='If set, this Procedure is an alias and points to the canonical Procedure.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.procedure')),
|
||||
('subspecialty', models.ManyToManyField(blank=True, to='atlas.subspecialty')),
|
||||
],
|
||||
bases=(atlas.models.SynMixin, models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='case',
|
||||
name='procedures',
|
||||
field=models.ManyToManyField(blank=True, help_text='Procedure(s) or operations the patient has undergone.', to='atlas.procedure'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-23 11:51
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0093_procedure_case_procedures'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='APIToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, help_text='Friendly name for this token', max_length=200)),
|
||||
('token_hash', models.CharField(db_index=True, max_length=64)),
|
||||
('scopes', models.CharField(blank=True, help_text='Space-separated scopes', max_length=255)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('expires', models.DateTimeField(blank=True, null=True)),
|
||||
('revoked', models.BooleanField(default=False)),
|
||||
('last_used', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'API token',
|
||||
'verbose_name_plural': 'API tokens',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-13 13:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0094_apitoken'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='show_case_link_post',
|
||||
field=models.BooleanField(default=False, help_text='Show a link to the case (post exam)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='show_displaysets_post',
|
||||
field=models.BooleanField(default=False, help_text='Show display sets for the case (post exam)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='show_findings_post',
|
||||
field=models.BooleanField(default=False, help_text='Show findings related to the case (post exam)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-04-13 13:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0095_casecollection_show_case_link_post_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='show_differentials_post',
|
||||
field=models.BooleanField(default=False, help_text='Show differentials for the case (post exam)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("generic", "0031_flag"),
|
||||
("atlas", "0096_casecollection_show_differentials_post"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="CaseReviewMessage",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("message_type", models.CharField(choices=[("feedback", "Feedback"), ("query", "Query"), ("reply", "Reply")], default="query", max_length=20)),
|
||||
("message", models.TextField()),
|
||||
("author_label", models.CharField(blank=True, max_length=255)),
|
||||
("requires_acknowledgement", models.BooleanField(default=False)),
|
||||
("acknowledged_at", models.DateTimeField(blank=True, null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("author", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages_authored", to=settings.AUTH_USER_MODEL)),
|
||||
("case", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages", to="atlas.case")),
|
||||
("cid_user_exam", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages", to="generic.ciduserexam")),
|
||||
],
|
||||
options={
|
||||
"ordering": ("created_at",),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-27 21:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0097_casereviewmessage'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='case_query_messaging_enabled',
|
||||
field=models.BooleanField(default=True, help_text='Enable per-case query and messaging for this collection.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("atlas", "0098_casecollection_case_query_messaging_enabled"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="casedetail",
|
||||
name="answer_entry_grace_period_override",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text="Optional per-case override for additional answer-only time in seconds after case view lock.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="casedetail",
|
||||
name="question_time_limit_override",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text="Optional per-case override for the answer time limit in seconds.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="casecollection",
|
||||
name="answer_entry_grace_period",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
default=0,
|
||||
help_text="Additional seconds after the case view is locked during which answers can still be entered.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="casecollection",
|
||||
name="end_overview_content",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
help_text="Optional HTML content shown on the collection end overview screen.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="casecollection",
|
||||
name="end_overview_resources",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Optional resources shown on the collection end overview screen.",
|
||||
related_name="end_overview_collections",
|
||||
to="atlas.resource",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="casecollection",
|
||||
name="start_screen_content",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
help_text="Optional HTML content shown on the collection start screen (for example embedded resources).",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="casecollection",
|
||||
name="start_screen_resources",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Optional resources shown on the collection start screen.",
|
||||
related_name="start_screen_collections",
|
||||
to="atlas.resource",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("atlas", "0099_casecollection_workflow_customisation"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="casecollection",
|
||||
name="show_results_sharing_information",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text="If true, show result-sharing information on candidate start and overview screens.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-11 08:56
|
||||
|
||||
from django.db import migrations, models
|
||||
import pydicom
|
||||
|
||||
|
||||
def backfill_series_study_instance_uid(apps, schema_editor):
|
||||
Series = apps.get_model('atlas', 'Series')
|
||||
SeriesImage = apps.get_model('atlas', 'SeriesImage')
|
||||
|
||||
series_qs = Series.objects.filter(study_instance_uid__isnull=True)
|
||||
|
||||
for series in series_qs.iterator():
|
||||
image_obj = (
|
||||
SeriesImage.objects.filter(series_id=series.pk, removed=False)
|
||||
.exclude(image='')
|
||||
.order_by('pk')
|
||||
.first()
|
||||
)
|
||||
if image_obj is None:
|
||||
image_obj = (
|
||||
SeriesImage.objects.filter(series_id=series.pk)
|
||||
.exclude(image='')
|
||||
.order_by('pk')
|
||||
.first()
|
||||
)
|
||||
|
||||
if image_obj is None or not image_obj.image:
|
||||
continue
|
||||
|
||||
try:
|
||||
with image_obj.image.open('rb') as fp:
|
||||
ds = pydicom.dcmread(fp, stop_before_pixels=True, force=True)
|
||||
value = ds.get('StudyInstanceUID')
|
||||
if value is None:
|
||||
continue
|
||||
if hasattr(value, 'value'):
|
||||
value = value.value
|
||||
study_uid = str(value).strip()
|
||||
if not study_uid:
|
||||
continue
|
||||
|
||||
Series.objects.filter(pk=series.pk).update(study_instance_uid=study_uid)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def noop_reverse(apps, schema_editor):
|
||||
return
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0100_casecollection_show_results_sharing_information'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='series',
|
||||
name='study_instance_uid',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.RunPython(backfill_series_study_instance_uid, noop_reverse),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-11 11:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0101_series_study_instance_uid'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='seriesimage',
|
||||
name='image_blake3_hash',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=64, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='uncategoriseddicom',
|
||||
name='image_blake3_hash',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=64, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 6.0.1 on 2026-05-12 20:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0102_alter_seriesimage_image_blake3_hash_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='auto_apply_question_template',
|
||||
field=models.BooleanField(default=False, help_text='If enabled, the case_question_template is automatically applied to new cases added to this collection that do not have an existing question_schema.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='case_question_max_score',
|
||||
field=models.PositiveIntegerField(blank=True, help_text='Maximum score per case used for normalisation. Defaults to 10 when not set.', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='case_question_template',
|
||||
field=models.JSONField(blank=True, help_text='JSON schema applied as a default question template for all cases in this collection. Individual cases can override via CaseDetail.question_schema.', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='marking_guidance',
|
||||
field=models.TextField(blank=True, help_text='Plain-language marking guidance displayed to markers.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='marking_scheme_name',
|
||||
field=models.CharField(blank=True, help_text='Human-readable mark scheme name shown to markers and in exports.', max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("atlas", "0103_casecollection_auto_apply_question_template_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="series",
|
||||
name="source_series_instance_uid",
|
||||
field=models.CharField(blank=True, db_index=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-01 09:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0104_series_source_series_instance_uid'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='case',
|
||||
name='study_date',
|
||||
field=models.DateField(blank=True, help_text='Optional manually overridden study date for linked case series.', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-01 11:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0105_case_study_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casedetail',
|
||||
name='learner_comment',
|
||||
field=models.TextField(blank=True, default='', help_text='Comment shown to the learner while taking the case and on review.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casedetail',
|
||||
name='learner_review_comment',
|
||||
field=models.TextField(blank=True, default='', help_text='Comment shown to the learner only on review.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casedetail',
|
||||
name='series_visibility_config',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Per-series visibility map for this case in this collection.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-11 20:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0106_casedetail_learner_comment_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='caseprior',
|
||||
name='relation_type',
|
||||
field=models.CharField(choices=[('PR', 'Prior'), ('FU', 'Future')], default='PR', help_text='Defines whether the related case is a prior or a future exam', max_length=2),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-16 11:53
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0107_caseprior_relation_type'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MultiuserSession',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(blank=True, max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('active_case', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='atlas.case')),
|
||||
('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_multiuser_sessions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-28 20:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0108_multiusersession'),
|
||||
('survey', '__first__'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='post_survey',
|
||||
field=models.ForeignKey(blank=True, help_text='Survey to be filled out AFTER completing this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_post_surveys', to='survey.survey'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='pre_survey',
|
||||
field=models.ForeignKey(blank=True, help_text='Survey to be filled out BEFORE taking this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_pre_surveys', to='survey.survey'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-29 08:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0109_casecollection_post_survey_casecollection_pre_survey'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='post_survey_mandatory',
|
||||
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the post-take survey to view results.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='pre_survey_mandatory',
|
||||
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the pre-take survey to start.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-13 10:41
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0110_casecollection_post_survey_mandatory_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='MultiuserSession',
|
||||
),
|
||||
]
|
||||
+1315
-90
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,483 @@
|
||||
// Simple markup inserter module
|
||||
(function(global){
|
||||
console.log('markup_inserter.js loaded');
|
||||
function insertAtCursor(el, text){
|
||||
if(!el) return;
|
||||
const start = el.selectionStart || 0;
|
||||
const end = el.selectionEnd || 0;
|
||||
const val = el.value || '';
|
||||
el.value = val.slice(0, start) + text + val.slice(end);
|
||||
el.selectionStart = el.selectionEnd = start + text.length;
|
||||
el.focus();
|
||||
}
|
||||
|
||||
function createToolbar(){
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'd-flex gap-2 mb-2 markup-inserter-toolbar';
|
||||
const group = document.createElement('div');
|
||||
group.className = 'btn-group';
|
||||
const makeBtn = function(type, text){
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'btn btn-sm btn-outline-secondary markup-insert-button';
|
||||
b.dataset.type = type; b.textContent = text; return b;
|
||||
};
|
||||
group.appendChild(makeBtn('case','Insert case'));
|
||||
group.appendChild(makeBtn('finding','Insert finding'));
|
||||
group.appendChild(makeBtn('displayset','Insert displayset'));
|
||||
const hint = document.createElement('div'); hint.className='flex-fill text-muted small align-self-center'; hint.textContent='Use the buttons to insert markup like [[case:123]]';
|
||||
wrap.appendChild(group); wrap.appendChild(hint);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function attachToolbarToTextarea(textarea){
|
||||
if(!textarea) return null;
|
||||
// avoid double attaching
|
||||
if(textarea._markupToolbarAttached) return null;
|
||||
console.log('Attaching markup toolbar to textarea', textarea);
|
||||
const toolbar = createToolbar();
|
||||
textarea.parentNode.insertBefore(toolbar, textarea);
|
||||
toolbar.querySelectorAll('.markup-insert-button').forEach(function(btn){
|
||||
btn.addEventListener('click', function(){
|
||||
const t = btn.dataset.type;
|
||||
// Respect explicit dataset provided by the widget or page meta only
|
||||
var detectedCase = textarea && textarea.dataset && textarea.dataset.currentCase ? textarea.dataset.currentCase : (document.getElementById('current-case-meta') ? document.getElementById('current-case-meta').getAttribute('data-current-case') : null);
|
||||
if(t === 'case'){
|
||||
openCasePicker(textarea);
|
||||
} else if(t === 'finding'){
|
||||
openFindingPicker(textarea, detectedCase);
|
||||
} else if(t === 'displayset'){
|
||||
openDisplaysetPicker(textarea, detectedCase);
|
||||
}
|
||||
});
|
||||
});
|
||||
textarea._markupToolbarAttached = true;
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
function initForNames(names){
|
||||
if(!Array.isArray(names)) return;
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
console.log('markupInserter.initForNames DOMContentLoaded handler running for', names);
|
||||
names.forEach(function(name){
|
||||
const el = document.querySelector('textarea[name="'+name+'"]');
|
||||
if(el) attachToolbarToTextarea(el);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function attachToSelector(selector){
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
document.querySelectorAll(selector).forEach(function(el){
|
||||
if(el.tagName === 'TEXTAREA') attachToolbarToTextarea(el);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-attach to placeholders rendered in HTMX responses or server-side includes.
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
console.log('markupInserter DOMContentLoaded auto-attach running');
|
||||
// Attach to explicit placeholders
|
||||
document.querySelectorAll('.markup-toolbar-placeholder').forEach(function(ph){
|
||||
let ta = ph.nextElementSibling;
|
||||
if(ta && ta.tagName === 'TEXTAREA') attachToolbarToTextarea(ta);
|
||||
});
|
||||
|
||||
// Also auto-init for common case textarea names to cover full-page forms
|
||||
try{ initForNames(['description','history','discussion','report','notes']); }catch(e){}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterSwap', function(evt){
|
||||
console.log('markupInserter htmx:afterSwap event', evt && evt.detail && evt.detail.target);
|
||||
try{
|
||||
const target = evt.detail && evt.detail.target ? evt.detail.target : document;
|
||||
(target.querySelectorAll ? target : document).querySelectorAll('.markup-toolbar-placeholder').forEach(function(ph){
|
||||
let ta = ph.nextElementSibling;
|
||||
if(ta && ta.tagName === 'TEXTAREA') attachToolbarToTextarea(ta);
|
||||
});
|
||||
|
||||
// Also attach to any textareas within the swapped target that match our common names
|
||||
var ctx = evt.detail && evt.detail.target ? evt.detail.target : document;
|
||||
['description','history','discussion','report','notes'].forEach(function(name){
|
||||
ctx.querySelectorAll && ctx.querySelectorAll('textarea[name="'+name+'"]') && ctx.querySelectorAll('textarea[name="'+name+'"]').forEach(function(ta){ attachToolbarToTextarea(ta); });
|
||||
});
|
||||
}catch(e){/* ignore */}
|
||||
});
|
||||
|
||||
// Fallback: attach toolbar when a textarea receives focus (covers dynamically created editors)
|
||||
document.addEventListener('focusin', function(e){
|
||||
console.log('markupInserter focusin event for', e && e.target);
|
||||
try{
|
||||
var el = e.target;
|
||||
if(!el || el.tagName !== 'TEXTAREA') return;
|
||||
// Attach for common names or if a placeholder is present
|
||||
var name = el.getAttribute('name') || '';
|
||||
if(['description','history','discussion','report','notes'].includes(name) || (el.previousElementSibling && el.previousElementSibling.classList && el.previousElementSibling.classList.contains('markup-toolbar-placeholder'))){
|
||||
attachToolbarToTextarea(el);
|
||||
}
|
||||
}catch(err){/* ignore */}
|
||||
}, true);
|
||||
|
||||
// Expose API
|
||||
global.markupInserter = {
|
||||
attachToolbarToTextarea: attachToolbarToTextarea,
|
||||
initForNames: initForNames,
|
||||
attachToSelector: attachToSelector
|
||||
};
|
||||
|
||||
// --- Case picker modal ---
|
||||
function openCasePicker(textarea){
|
||||
try{
|
||||
console.log('Opening case picker for', textarea);
|
||||
// Create overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'markup-case-picker-overlay';
|
||||
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'markup-case-picker-dialog card';
|
||||
Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
|
||||
|
||||
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center';
|
||||
header.innerHTML = '<strong>Select case</strong>';
|
||||
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close');
|
||||
closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); });
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
const body = document.createElement('div'); body.className='card-body';
|
||||
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search cases...';
|
||||
const results = document.createElement('div'); results.className='case-search-results';
|
||||
body.appendChild(input); body.appendChild(results);
|
||||
|
||||
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
input.focus();
|
||||
|
||||
var timer = null;
|
||||
input.addEventListener('input', function(){
|
||||
if(timer) clearTimeout(timer);
|
||||
timer = setTimeout(function(){
|
||||
var q = encodeURIComponent(input.value || '');
|
||||
// Fetch server-side partial that renders result list-group
|
||||
var url = window.location.origin + '/atlas/search/cases/?q=' + q;
|
||||
fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){
|
||||
results.innerHTML = html;
|
||||
}).catch(function(e){ console.error('case picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; });
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Delegate click on result items
|
||||
results.addEventListener('click', function(e){
|
||||
var item = e.target.closest('.list-group-item');
|
||||
if(!item) return;
|
||||
var pk = item.getAttribute('data-case-pk');
|
||||
if(!pk){
|
||||
var a = item.querySelector('a');
|
||||
if(a){ var m = a.getAttribute('href').match(/case\/(\d+)\/?/); if(m) pk = m[1]; }
|
||||
}
|
||||
if(!pk) return;
|
||||
var defaultLabel = item.getAttribute('data-label') || (item.querySelector('h6') ? item.querySelector('h6').textContent.trim() : '');
|
||||
var custom = window.prompt('Custom text (leave empty to use default)', defaultLabel || '');
|
||||
if(custom === null){ if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); return; }
|
||||
var labelToUse = custom && custom.length ? custom : (defaultLabel || null);
|
||||
var token = '[[' + 'case:' + pk + (labelToUse ? '|' + labelToUse : '') + ']]';
|
||||
insertAtCursor(textarea, token);
|
||||
// remove modal
|
||||
if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
});
|
||||
|
||||
// Allow ESC to close
|
||||
overlay.addEventListener('keydown', function(ev){ if(ev.key === 'Escape'){ if(overlay.parentNode) overlay.parentNode.removeChild(overlay); } });
|
||||
// make overlay focusable so keydown works
|
||||
overlay.tabIndex = -1; overlay.focus();
|
||||
// load initial recent results (empty query)
|
||||
fetch(window.location.origin + '/atlas/search/cases/').then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){});
|
||||
}catch(err){ console.error('openCasePicker error', err); }
|
||||
}
|
||||
|
||||
function openFindingPicker(textarea, caseId){
|
||||
try{
|
||||
console.log('Opening finding picker for', textarea);
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'markup-case-picker-overlay';
|
||||
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
|
||||
const dialog = document.createElement('div'); dialog.className='markup-case-picker-dialog card'; Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
|
||||
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center'; header.innerHTML = '<strong>Select finding</strong>';
|
||||
// Determine case filter from explicit dataset on the textarea or provided caseId
|
||||
var detectedF = (textarea && textarea.dataset && textarea.dataset.currentCase) ? { id: textarea.dataset.currentCase, title: textarea.dataset.currentCaseTitle } : {};
|
||||
var currentCaseF = caseId || detectedF.id || null;
|
||||
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close'); closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); }); header.appendChild(closeBtn);
|
||||
const body = document.createElement('div'); body.className='card-body';
|
||||
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search findings...';
|
||||
const results = document.createElement('div'); results.className='finding-search-results'; body.appendChild(input); body.appendChild(results);
|
||||
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog); document.body.appendChild(overlay); input.focus();
|
||||
|
||||
var timer = null;
|
||||
input.addEventListener('input', function(){ if(timer) clearTimeout(timer); timer = setTimeout(function(){ var q = encodeURIComponent(input.value || ''); var url = window.location.origin + '/atlas/search/findings/?q=' + q + (currentCaseF ? '&case=' + currentCaseF : ''); fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(e){ console.error('finding picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; }); }, 300); });
|
||||
|
||||
results.addEventListener('click', function(e){
|
||||
var item = e.target.closest('.list-group-item');
|
||||
if(!item) return;
|
||||
var sfpk = item.getAttribute('data-seriesfinding-pk');
|
||||
var fpk = item.getAttribute('data-finding-pk');
|
||||
var defaultLabel = item.getAttribute('data-label') || (item.querySelector('strong') ? item.querySelector('strong').textContent.trim() : '');
|
||||
var custom = window.prompt('Custom text (leave empty to use default)', defaultLabel || '');
|
||||
if(custom === null){ if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); return; }
|
||||
var labelToUse = custom && custom.length ? custom : (defaultLabel || null);
|
||||
if(sfpk){
|
||||
var token = '[['+'seriesfinding:'+sfpk + (labelToUse ? '|' + labelToUse : '') + ']]';
|
||||
insertAtCursor(textarea, token);
|
||||
} else if(fpk){
|
||||
var token = '[['+'finding:'+fpk + (labelToUse ? '|' + labelToUse : '') + ']]';
|
||||
insertAtCursor(textarea, token);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
});
|
||||
overlay.tabIndex = -1; overlay.focus();
|
||||
function fetchInitialFindings(){ var initFindingsUrl = window.location.origin + '/atlas/search/findings/' + (currentCaseF ? '?case=' + currentCaseF : ''); fetch(initFindingsUrl).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){}); }
|
||||
fetchInitialFindings();
|
||||
}catch(err){ console.error('openFindingPicker error', err); }
|
||||
}
|
||||
|
||||
function openDisplaysetPicker(textarea, caseId){
|
||||
try{
|
||||
console.log('Opening displayset picker for', textarea);
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'markup-case-picker-overlay';
|
||||
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
|
||||
const dialog = document.createElement('div'); dialog.className='markup-case-picker-dialog card'; Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
|
||||
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center';
|
||||
header.innerHTML = '<strong>Select display set</strong>';
|
||||
// Determine case filter from explicit dataset on the textarea or provided caseId
|
||||
var detectedD = (textarea && textarea.dataset && textarea.dataset.currentCase) ? { id: textarea.dataset.currentCase, title: textarea.dataset.currentCaseTitle } : {};
|
||||
var currentCaseD = caseId || detectedD.id || null;
|
||||
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close'); closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); }); header.appendChild(closeBtn);
|
||||
const body = document.createElement('div'); body.className='card-body';
|
||||
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search display sets...';
|
||||
const results = document.createElement('div'); results.className='displayset-search-results'; body.appendChild(input); body.appendChild(results);
|
||||
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog); document.body.appendChild(overlay); input.focus();
|
||||
|
||||
var timer = null;
|
||||
input.addEventListener('input', function(){ if(timer) clearTimeout(timer); timer = setTimeout(function(){ var q = encodeURIComponent(input.value || ''); var url = window.location.origin + '/atlas/search/displaysets/?q=' + q + (currentCaseD ? '&case=' + currentCaseD : ''); fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(e){ console.error('displayset picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; }); }, 300); });
|
||||
|
||||
results.addEventListener('click', function(e){
|
||||
var item = e.target.closest('.list-group-item');
|
||||
if(!item) return;
|
||||
var pk = item.getAttribute('data-ds-pk');
|
||||
if(!pk) return;
|
||||
var defaultLabel = item.getAttribute('data-label') || (item.querySelector('strong') ? item.querySelector('strong').textContent.trim() : '');
|
||||
var custom = window.prompt('Custom text (leave empty to use default)', defaultLabel || '');
|
||||
if(custom === null){ if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); return; }
|
||||
var labelToUse = custom && custom.length ? custom : (defaultLabel || null);
|
||||
var token = '[['+'displayset:'+pk + (labelToUse ? '|' + labelToUse : '') + ']]';
|
||||
insertAtCursor(textarea, token);
|
||||
if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
});
|
||||
overlay.tabIndex = -1; overlay.focus();
|
||||
function fetchInitialDisplaysets(){ var initDisplaysetsUrl = window.location.origin + '/atlas/search/displaysets/' + (currentCaseD ? '?case=' + currentCaseD : ''); fetch(initDisplaysetsUrl).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){}); }
|
||||
fetchInitialDisplaysets();
|
||||
}catch(err){ console.error('openDisplaysetPicker error', err); }
|
||||
}
|
||||
|
||||
// Simplified detection: rely on explicit dataset attributes set by the widget
|
||||
// When a textarea includes `data-current-case` and optionally `data-current-case-title`, that will be used as the default filter.
|
||||
|
||||
// --- Render inserted markup tokens as styled items with popup/modal actions ---
|
||||
function injectMarkupStyles(){
|
||||
if(document.getElementById('markup-inserter-styles')) return;
|
||||
var s = document.createElement('style'); s.id = 'markup-inserter-styles';
|
||||
s.textContent = '\n.markup-token{cursor:pointer;display:inline-block;margin:0 .25rem .25rem 0;padding:.25rem .5rem;border-radius:.375rem;font-size:0.85em;}\n.markup-popup{position:fixed;z-index:1060;background:#fff;border:1px solid rgba(0,0,0,.12);box-shadow:0 6px 18px rgba(0,0,0,.12);padding:.5rem;border-radius:.25rem;}\n.markup-modal-backdrop{position:fixed;left:0;right:0;top:0;bottom:0;background:rgba(0,0,0,.45);z-index:1080;display:flex;align-items:flex-start;justify-content:center;padding-top:4vh;}\n.markup-modal{background:#fff;border-radius:.375rem;max-width:95%;max-height:85vh;overflow:auto;}\n';
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function getDetailUrl(type, pk){
|
||||
if(!type || !pk) return null;
|
||||
if(type === 'seriesfinding') return window.location.origin + '/atlas/series_finding/' + pk + '/preview/';
|
||||
if(type === 'finding') return window.location.origin + '/atlas/finding/' + pk;
|
||||
if(type === 'displayset') return window.location.origin + '/atlas/displayset/' + pk + '/preview/';
|
||||
if(type === 'case') return window.location.origin + '/atlas/case/' + pk + '/';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reusable modal element
|
||||
var __markup_modal = null;
|
||||
function ensureModal(){
|
||||
if(__markup_modal) return __markup_modal;
|
||||
var backdrop = document.createElement('div'); backdrop.className = 'markup-modal-backdrop'; backdrop.style.display='none';
|
||||
var modal = document.createElement('div'); modal.className = 'markup-modal card'; modal.style.marginTop='1rem'; modal.style.maxHeight='80vh'; modal.style.overflow='auto';
|
||||
var header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center';
|
||||
var h = document.createElement('strong'); h.textContent = 'Detail';
|
||||
var closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.addEventListener('click', function(){ backdrop.style.display='none'; modal.querySelector('.card-body') && (modal.querySelector('.card-body').innerHTML=''); });
|
||||
header.appendChild(h); header.appendChild(closeBtn);
|
||||
var body = document.createElement('div'); body.className='card-body'; body.style.maxHeight='70vh'; body.style.overflow='auto';
|
||||
modal.appendChild(header); modal.appendChild(body); backdrop.appendChild(modal); document.body.appendChild(backdrop);
|
||||
__markup_modal = { backdrop: backdrop, modal: modal, body: body };
|
||||
return __markup_modal;
|
||||
}
|
||||
|
||||
function showModalForUrl(url, title){
|
||||
var m = ensureModal();
|
||||
m.backdrop.style.display = 'flex';
|
||||
if(title){ var h = m.modal.querySelector('strong'); if(h) h.textContent = title; }
|
||||
m.body.innerHTML = '<div class="p-3 text-muted">Loading…</div>';
|
||||
fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ m.body.innerHTML = html; }).catch(function(e){ m.body.innerHTML = '<div class="p-3 text-danger">Failed to load</div>'; console.error('modal fetch error', e); });
|
||||
}
|
||||
|
||||
function showPopupFor(element){
|
||||
// remove existing
|
||||
var existing = document.getElementById('markup-popup'); if(existing) existing.remove();
|
||||
var type = element.getAttribute('data-type'); var pk = element.getAttribute('data-pk'); var label = element.getAttribute('data-label') || element.textContent || '';
|
||||
var popup = document.createElement('div'); popup.id = 'markup-popup'; popup.className = 'markup-popup';
|
||||
popup.innerHTML = '<div style="font-weight:600;margin-bottom:.25rem;">'+ (label||type+':'+pk) +'</div>';
|
||||
var openBtn = document.createElement('button'); openBtn.type='button'; openBtn.className='btn btn-sm btn-outline-primary me-2'; openBtn.textContent='Open';
|
||||
openBtn.addEventListener('click', function(){
|
||||
var url = getDetailUrl(type, pk);
|
||||
try{
|
||||
// Prefer page-specific modal loaders if present so we reuse existing Bootstrap modals/viewers
|
||||
if(type === 'displayset'){
|
||||
if(typeof window.loadDisplaysetModal === 'function'){
|
||||
window.loadDisplaysetModal(url, pk);
|
||||
popup.remove();
|
||||
return;
|
||||
}
|
||||
var dsBtn = document.querySelector('.view-displayset-modal[data-ds-id="'+pk+'"]');
|
||||
if(dsBtn){ dsBtn.click(); popup.remove(); return; }
|
||||
}
|
||||
if(type === 'seriesfinding' || type === 'finding'){
|
||||
if(typeof window.loadFindingModal === 'function'){
|
||||
window.loadFindingModal(pk, url);
|
||||
popup.remove();
|
||||
return;
|
||||
}
|
||||
var fBtn = document.querySelector('.view-finding-modal[data-finding-id="'+pk+'"]');
|
||||
if(fBtn){ fBtn.click(); popup.remove(); return; }
|
||||
}
|
||||
}catch(e){ console.warn('error invoking page modal loader', e); }
|
||||
if(url) showModalForUrl(url, label);
|
||||
popup.remove();
|
||||
});
|
||||
var closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn btn-sm btn-outline-secondary'; closeBtn.textContent='Close'; closeBtn.addEventListener('click', function(){ popup.remove(); });
|
||||
popup.appendChild(openBtn); popup.appendChild(closeBtn);
|
||||
document.body.appendChild(popup);
|
||||
// position near element
|
||||
try{
|
||||
var r = element.getBoundingClientRect();
|
||||
popup.style.top = (window.scrollY + r.bottom + 6) + 'px';
|
||||
popup.style.left = Math.min(window.innerWidth - 240, window.scrollX + r.left) + 'px';
|
||||
popup.style.minWidth = '160px';
|
||||
}catch(e){}
|
||||
// clicking outside closes it
|
||||
setTimeout(function(){
|
||||
var onDoc = function(ev){ if(!popup.contains(ev.target) && ev.target !== element) { popup.remove(); document.removeEventListener('click', onDoc); } };
|
||||
document.addEventListener('click', onDoc);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
// Try to open a modal for a token (prefer page-specific modal loaders)
|
||||
function openModalForToken(element){
|
||||
var type = element.getAttribute('data-type'); var pk = element.getAttribute('data-pk'); var label = element.getAttribute('data-label') || element.textContent || '';
|
||||
var url = getDetailUrl(type, pk);
|
||||
try{
|
||||
if(type === 'displayset'){
|
||||
if(typeof window.loadDisplaysetModal === 'function'){
|
||||
window.loadDisplaysetModal(url, pk); return;
|
||||
}
|
||||
var dsBtn = document.querySelector('.view-displayset-modal[data-ds-id="'+pk+'"]');
|
||||
if(dsBtn){ dsBtn.click(); return; }
|
||||
}
|
||||
if(type === 'seriesfinding' || type === 'finding'){
|
||||
if(typeof window.loadFindingModal === 'function'){
|
||||
window.loadFindingModal(pk, url); return;
|
||||
}
|
||||
var fBtn = document.querySelector('.view-finding-modal[data-finding-id="'+pk+'"]');
|
||||
if(fBtn){ fBtn.click(); return; }
|
||||
}
|
||||
}catch(e){ console.warn('error invoking page modal loader', e); }
|
||||
if(url) showModalForUrl(url, label);
|
||||
}
|
||||
|
||||
// Replace tokens in text nodes with styled spans
|
||||
function renderMarkupTokens(root){
|
||||
injectMarkupStyles();
|
||||
root = root || document.body;
|
||||
// Convert server-rendered anchor tokens into our interactive spans
|
||||
try{
|
||||
if(root.querySelectorAll){
|
||||
root.querySelectorAll('a.markup-link').forEach(function(a){
|
||||
try{
|
||||
var type = a.getAttribute('data-type') || '';
|
||||
var args = [];
|
||||
try{ args = JSON.parse(a.getAttribute('data-args') || '[]'); }catch(e){ args = []; }
|
||||
var pk = (args && args.length) ? (args[0] + '') : (a.getAttribute('data-pk') || '');
|
||||
var label = a.textContent ? a.textContent.trim() : null;
|
||||
var span = document.createElement('span');
|
||||
span.className = 'markup-token';
|
||||
if(type) span.setAttribute('data-type', type);
|
||||
if(pk) span.setAttribute('data-pk', pk);
|
||||
if(label) span.setAttribute('data-label', label);
|
||||
if(type === 'case') span.className += ' badge bg-primary text-white';
|
||||
else if(type === 'displayset') span.className += ' badge bg-success text-white';
|
||||
else span.className += ' badge bg-info text-dark';
|
||||
span.textContent = label ? label : (type + ':' + pk);
|
||||
span.addEventListener('click', function(e){ e.stopPropagation(); openModalForToken(span); });
|
||||
a.parentNode.replaceChild(span, a);
|
||||
}catch(e){}
|
||||
});
|
||||
}
|
||||
}catch(e){}
|
||||
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
|
||||
var nodes = [];
|
||||
while(walker.nextNode()) nodes.push(walker.currentNode);
|
||||
var re = /\[\[(seriesfinding|finding|displayset|case):(\d+)(?:\|([^\]]+))?\]\]/gi;
|
||||
nodes.forEach(function(textNode){
|
||||
if(!textNode.nodeValue || !re.test(textNode.nodeValue)) return;
|
||||
var parentTag = textNode.parentElement && textNode.parentElement.tagName ? textNode.parentElement.tagName.toLowerCase() : '';
|
||||
if(['script','style','textarea','code','pre','a','button'].includes(parentTag)) return;
|
||||
// rebuild the fragment
|
||||
var frag = document.createDocumentFragment();
|
||||
var txt = textNode.nodeValue;
|
||||
var lastIndex = 0; var m;
|
||||
re.lastIndex = 0;
|
||||
while((m = re.exec(txt)) !== null){
|
||||
var before = txt.slice(lastIndex, m.index);
|
||||
if(before) frag.appendChild(document.createTextNode(before));
|
||||
var type = m[1].toLowerCase(); var pk = m[2]; var label = m[3] ? m[3] : null;
|
||||
var span = document.createElement('span'); span.className = 'markup-token'; span.setAttribute('data-type', type); span.setAttribute('data-pk', pk); if(label) span.setAttribute('data-label', label);
|
||||
// style by type
|
||||
if(type === 'case') span.className += ' badge bg-primary text-white';
|
||||
else if(type === 'displayset') span.className += ' badge bg-success text-white';
|
||||
else span.className += ' badge bg-info text-dark';
|
||||
span.textContent = label ? label : (type + ':' + pk);
|
||||
span.addEventListener('click', function(e){ e.stopPropagation(); openModalForToken(span); });
|
||||
frag.appendChild(span);
|
||||
lastIndex = re.lastIndex;
|
||||
}
|
||||
var rest = txt.slice(lastIndex); if(rest) frag.appendChild(document.createTextNode(rest));
|
||||
textNode.parentNode.replaceChild(frag, textNode);
|
||||
});
|
||||
|
||||
// Ensure any server-rendered span.markup-token elements have click handlers
|
||||
try{
|
||||
if(root.querySelectorAll){
|
||||
root.querySelectorAll('span.markup-token').forEach(function(span){
|
||||
try{
|
||||
if(span._markupBound) return;
|
||||
span._markupBound = true;
|
||||
// ensure data attributes are present
|
||||
var type = span.getAttribute('data-type') || '';
|
||||
var pk = span.getAttribute('data-pk') || '';
|
||||
var label = span.getAttribute('data-label') || span.textContent || '';
|
||||
span.addEventListener('click', function(e){ e.stopPropagation(); openModalForToken(span); });
|
||||
}catch(e){}
|
||||
});
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
// Run rendering on DOMContentLoaded and after HTMX swaps
|
||||
document.addEventListener('DOMContentLoaded', function(){ try{ renderMarkupTokens(document.body); }catch(e){console.error(e);} });
|
||||
document.addEventListener('htmx:afterSwap', function(evt){ try{ var target = evt.detail && evt.detail.target ? evt.detail.target : document; renderMarkupTokens(target); }catch(e){/*ignore*/} });
|
||||
|
||||
})(window);
|
||||
|
||||
+83
-19
@@ -14,10 +14,15 @@ from .models import (
|
||||
Structure,
|
||||
Finding,
|
||||
Subspecialty,
|
||||
Resource,
|
||||
Procedure,
|
||||
)
|
||||
|
||||
from django.utils.html import format_html
|
||||
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.template.defaultfilters import filesizeformat
|
||||
|
||||
from easy_thumbnails.files import get_thumbnailer
|
||||
|
||||
from easy_thumbnails.exceptions import InvalidImageFormatError
|
||||
@@ -82,12 +87,14 @@ class CaseTable(SelectionTable):
|
||||
|
||||
def get_view_cell(record):
|
||||
if record.open_access:
|
||||
return format_html(f"""<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">
|
||||
<path fill-opacity=".4" d="M12.995 8.195c0 .937-.312 1.912-.78 2.693l1.99 1.99c.976-1.327 1.6-2.966 1.6-4.683 0-1.795-.624-3.434-1.561-4.76l-2.068 2.028c.468.781.78 1.679.78 2.732h.04Z"/>
|
||||
<path d="M8 13.151a4.995 4.995 0 1 1 0-9.99c1.015 0 1.951.273 2.732.82l1.95-2.03a7.805 7.805 0 1 0 .04 12.449l-1.951-2.03a5.072 5.072 0 0 1-2.732.781z"/>
|
||||
</svg> View""")
|
||||
return mark_safe(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">'
|
||||
'<path fill-opacity=".4" d="M12.995 8.195c0 .937-.312 1.912-.78 2.693l1.99 1.99c.976-1.327 1.6-2.966 1.6-4.683 0-1.795-.624-3.434-1.561-4.76l-2.068 2.028c.468.781.78 1.679.78 2.732h.04Z"/>'
|
||||
'<path d="M8 13.151a4.995 4.995 0 1 1 0-9.99c1.015 0 1.951.273 2.732.82l1.95-2.03a7.805 7.805 0 1 0 .04 12.449l-1.951-2.03a5.072 5.072 0 0 1-2.732.781z"/>'
|
||||
'</svg> View'
|
||||
)
|
||||
else:
|
||||
return f'View'
|
||||
return 'View'
|
||||
|
||||
def render_author(self, value):
|
||||
max_length = 15 # Set the maximum length for truncating individual author names
|
||||
@@ -107,7 +114,7 @@ class CaseTable(SelectionTable):
|
||||
)
|
||||
)
|
||||
# Join all authors with commas
|
||||
return format_html(", ".join(authors))
|
||||
return mark_safe(", ".join(authors))
|
||||
|
||||
def render_associated_cases(self, record):
|
||||
links = []
|
||||
@@ -129,7 +136,7 @@ class CaseTable(SelectionTable):
|
||||
except Case.DoesNotExist:
|
||||
pass
|
||||
if links:
|
||||
return format_html(", ".join(links))
|
||||
return mark_safe(", ".join(links))
|
||||
return "No associated cases"
|
||||
|
||||
def render_collections(self, record):
|
||||
@@ -150,9 +157,10 @@ class CaseTable(SelectionTable):
|
||||
)
|
||||
for collection in collections
|
||||
]
|
||||
return format_html(", ".join(links))
|
||||
return mark_safe(", ".join(links))
|
||||
return "No collections"
|
||||
|
||||
|
||||
associated_cases = tables.Column(
|
||||
verbose_name="Associated Cases", accessor="pk", orderable=False
|
||||
)
|
||||
@@ -165,7 +173,7 @@ class CaseTable(SelectionTable):
|
||||
"atlas:case_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
view = tables.LinkColumn(
|
||||
"atlas:case_detail", text=get_view_cell, args=[A("pk")], orderable=False
|
||||
"atlas:case_detail", text=get_view_cell, args=[A("pk")], accessor="pk", orderable=False
|
||||
)
|
||||
clone = tables.LinkColumn(
|
||||
"atlas:case_clone", text="Clone", args=[A("pk")], orderable=False
|
||||
@@ -198,7 +206,7 @@ class PopupLinkColumn(tables.Column):
|
||||
obj = value.first()
|
||||
|
||||
if obj is None:
|
||||
return format_html("<span>No image<span>")
|
||||
return mark_safe("<span>No image</span>")
|
||||
|
||||
return obj.get_thumbnail()[0]
|
||||
|
||||
@@ -208,7 +216,7 @@ class SeriesTable(SelectionTable):
|
||||
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
view = tables.LinkColumn(
|
||||
"atlas:series_detail", text="View", args=[A("pk")], orderable=False
|
||||
"atlas:series_detail", text="View", args=[A("pk")], accessor="pk", orderable=False
|
||||
)
|
||||
popup = tables.Column(empty_values=(), orderable=False)
|
||||
|
||||
@@ -275,7 +283,7 @@ class ConditionTable(SelectionTable):
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Condition
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
||||
fields = ("subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
||||
sequence = ("name",)
|
||||
|
||||
|
||||
@@ -289,7 +297,7 @@ class ConditionTable(SelectionTable):
|
||||
)
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return mark_safe(record.get_synonym_link())
|
||||
return f"{record.get_synonym_link()}"
|
||||
|
||||
|
||||
@@ -311,12 +319,12 @@ class FindingTable(SelectionTable):
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Finding
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "primary")
|
||||
fields = ("name",)
|
||||
sequence = ("name",)
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return mark_safe(record.get_synonym_link())
|
||||
|
||||
|
||||
class StructureTable(SelectionTable):
|
||||
@@ -338,12 +346,12 @@ class StructureTable(SelectionTable):
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Structure
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "primary")
|
||||
fields = ("name",)
|
||||
sequence = ("name",)
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return mark_safe(record.get_synonym_link())
|
||||
|
||||
|
||||
class PresentationTable(SelectionTable):
|
||||
@@ -390,6 +398,26 @@ class PathologicalProcessTable(SelectionTable):
|
||||
sequence = ("name",)
|
||||
|
||||
|
||||
class ProcedureTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:procedure_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="Procedure",
|
||||
)
|
||||
|
||||
edit = tables.LinkColumn(
|
||||
"atlas:procedure_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
delete = tables.LinkColumn(
|
||||
"atlas:procedure_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Procedure
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name",)
|
||||
sequence = ("name",)
|
||||
|
||||
|
||||
class SubspecialtyTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:subspecialty_detail", {"pk": tables.A("pk")}),
|
||||
@@ -411,16 +439,31 @@ class SubspecialtyTable(SelectionTable):
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return mark_safe(record.get_synonym_link())
|
||||
return f"{record.get_synonym_link()}"
|
||||
|
||||
|
||||
class ResourceTable(SelectionTable):
|
||||
name = tables.Column(linkify=("atlas:resource_detail", {"pk": tables.A("pk")}), verbose_name="Resource")
|
||||
|
||||
edit = tables.LinkColumn("atlas:resource_update", text="Edit", args=[A("pk")], orderable=False)
|
||||
delete = tables.LinkColumn("atlas:resource_delete", text="Delete", args=[A("pk")], orderable=False)
|
||||
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Resource
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "description")
|
||||
|
||||
class CaseCollectionTable(SelectionTable):
|
||||
edit = tables.LinkColumn(
|
||||
"atlas:exam_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
view = tables.LinkColumn(
|
||||
"atlas:collection_detail", text="View", args=[A("pk")], orderable=False
|
||||
"atlas:collection_detail", text="View", args=[A("pk")], accessor="pk", orderable=False
|
||||
)
|
||||
|
||||
created = tables.DateTimeColumn(verbose_name="Created", accessor="created", orderable=True)
|
||||
updated = tables.DateTimeColumn(verbose_name="Updated", accessor="updated", orderable=True)
|
||||
#clone = tables.LinkColumn(
|
||||
# "atlas:case_clone", text="Clone", args=[A("pk")], orderable=False
|
||||
#)
|
||||
@@ -436,6 +479,8 @@ class CaseCollectionTable(SelectionTable):
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = (
|
||||
"name",
|
||||
"created",
|
||||
"updated",
|
||||
"collection_type",
|
||||
"publish_results",
|
||||
"active",
|
||||
@@ -453,6 +498,25 @@ class CaseCollectionTable(SelectionTable):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def render_created(self, value):
|
||||
if not value:
|
||||
return ""
|
||||
# compact date for display, full datetime in tooltip
|
||||
return format_html(
|
||||
'<span class="small text-muted" title="{}">{}</span>',
|
||||
value.strftime("%Y-%m-%d %H:%M"),
|
||||
value.strftime("%Y-%m-%d"),
|
||||
)
|
||||
|
||||
def render_updated(self, value):
|
||||
if not value:
|
||||
return ""
|
||||
return format_html(
|
||||
'<span class="small text-muted" title="{}">{}</span>',
|
||||
value.strftime("%Y-%m-%d %H:%M"),
|
||||
value.strftime("%Y-%m-%d"),
|
||||
)
|
||||
|
||||
class QuestionSchemaTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:question_schema_detail", {"pk": tables.A("pk")}),
|
||||
|
||||
+560
-4
@@ -2,14 +2,201 @@ 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 enum import Enum
|
||||
|
||||
try:
|
||||
from django_tasks import task
|
||||
HAS_DJANGO_TASKS = True
|
||||
except ImportError:
|
||||
from celery import shared_task
|
||||
from celery.result import AsyncResult
|
||||
|
||||
HAS_DJANGO_TASKS = False
|
||||
|
||||
class _CompatTaskResultStatus(Enum):
|
||||
READY = "READY"
|
||||
RUNNING = "RUNNING"
|
||||
FAILED = "FAILED"
|
||||
SUCCESSFUL = "SUCCESSFUL"
|
||||
|
||||
class _CompatError:
|
||||
def __init__(self, traceback):
|
||||
self.traceback = traceback
|
||||
|
||||
class _CompatTaskResult:
|
||||
def __init__(self, async_result):
|
||||
self._async_result = async_result
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self._async_result.id
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
state = self._async_result.state
|
||||
if state in ("PENDING",):
|
||||
return _CompatTaskResultStatus.READY
|
||||
if state in ("STARTED", "RETRY"):
|
||||
return _CompatTaskResultStatus.RUNNING
|
||||
if state == "SUCCESS":
|
||||
return _CompatTaskResultStatus.SUCCESSFUL
|
||||
if state == "FAILURE":
|
||||
return _CompatTaskResultStatus.FAILED
|
||||
return _CompatTaskResultStatus.RUNNING
|
||||
|
||||
def refresh(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def return_value(self):
|
||||
return self._async_result.result
|
||||
|
||||
@property
|
||||
def errors(self):
|
||||
if self._async_result.state == "FAILURE" and self._async_result.traceback:
|
||||
return [_CompatError(self._async_result.traceback)]
|
||||
return []
|
||||
|
||||
def task(func=None, *, takes_context=False, **kwargs):
|
||||
def decorator(inner_func):
|
||||
if takes_context:
|
||||
@shared_task(bind=True)
|
||||
def wrapped(self, *args, **inner_kwargs):
|
||||
class _Ctx:
|
||||
class _TaskResultRef:
|
||||
id = self.request.id
|
||||
|
||||
task_result = _TaskResultRef()
|
||||
attempt = 1
|
||||
|
||||
return inner_func(_Ctx(), *args, **inner_kwargs)
|
||||
else:
|
||||
wrapped = shared_task(inner_func)
|
||||
|
||||
wrapped.enqueue = wrapped.delay
|
||||
wrapped.get_result = lambda result_id: _CompatTaskResult(AsyncResult(result_id))
|
||||
return wrapped
|
||||
|
||||
if func is not None:
|
||||
return decorator(func)
|
||||
return decorator
|
||||
|
||||
from atlas.models import Case, Series, SeriesImage
|
||||
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
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.cache import cache
|
||||
import copy
|
||||
import io
|
||||
|
||||
@shared_task()
|
||||
|
||||
@task(takes_context=True)
|
||||
def series_downsample_task(context, series_id, user_id, downsample_pct):
|
||||
"""Downsample a series asynchronously while preserving hash continuity."""
|
||||
from loguru import logger
|
||||
from atlas import views as atlas_views
|
||||
|
||||
progress_key = f"series_downsample_progress:{context.task_result.id}"
|
||||
cache.set(
|
||||
progress_key,
|
||||
{"current": 0, "total": 0, "message": "Preparing downsample..."},
|
||||
timeout=60 * 60,
|
||||
)
|
||||
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
user = get_object_or_404(User, pk=user_id)
|
||||
|
||||
if not series.check_user_can_edit(user):
|
||||
raise PermissionError("Permission denied")
|
||||
|
||||
images = list(series.get_images())
|
||||
total_images = len(images)
|
||||
replacement_series_uid = generate_uid()
|
||||
source_series_uid = series.series_instance_uid
|
||||
updated_images = []
|
||||
|
||||
cache.set(
|
||||
progress_key,
|
||||
{"current": 0, "total": total_images, "message": "Downsample started"},
|
||||
timeout=60 * 60,
|
||||
)
|
||||
|
||||
for idx, image in enumerate(images, start=1):
|
||||
ds = atlas_views._read_series_image_dataset(image)
|
||||
if ds is None:
|
||||
cache.set(
|
||||
progress_key,
|
||||
{"current": idx, "total": total_images, "message": f"Skipping unreadable image ({idx}/{total_images})"},
|
||||
timeout=60 * 60,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
original_hash = image.image_blake3_hash
|
||||
ds_out = atlas_views._downsample_dicom_dataset(
|
||||
ds,
|
||||
int(downsample_pct),
|
||||
series_instance_uid=replacement_series_uid,
|
||||
source_series_instance_uid=source_series_uid,
|
||||
series_description=series.description,
|
||||
)
|
||||
out_io = io.BytesIO()
|
||||
ds_out.save_as(out_io, write_like_original=False)
|
||||
out_io.seek(0)
|
||||
|
||||
image.image.save(
|
||||
f"downsample_{downsample_pct}_{image.pk}.dcm",
|
||||
ContentFile(out_io.getvalue()),
|
||||
save=False,
|
||||
)
|
||||
image.image_blake3_hash = original_hash
|
||||
image.save(update_fields=["image", "image_blake3_hash"])
|
||||
updated_images.append(image.pk)
|
||||
except Exception as exc:
|
||||
logger.warning("Downsample failed for image {}: {}", image.pk, exc)
|
||||
|
||||
cache.set(
|
||||
progress_key,
|
||||
{
|
||||
"current": idx,
|
||||
"total": total_images,
|
||||
"message": f"Downsampling image {idx}/{total_images}",
|
||||
},
|
||||
timeout=60 * 60,
|
||||
)
|
||||
|
||||
if updated_images:
|
||||
series.modified = Series.SeriesModifiedChocies.RE
|
||||
series.source_series_instance_uid = source_series_uid
|
||||
series.series_instance_uid = replacement_series_uid
|
||||
series.save(update_fields=["modified", "source_series_instance_uid", "series_instance_uid"])
|
||||
try:
|
||||
series.get_total_image_size(refresh=True)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to refresh total_image_size for series {}: {}", series.pk, exc)
|
||||
|
||||
cache.set(
|
||||
progress_key,
|
||||
{
|
||||
"current": total_images,
|
||||
"total": total_images,
|
||||
"message": "Downsample complete",
|
||||
},
|
||||
timeout=10 * 60,
|
||||
)
|
||||
|
||||
return {
|
||||
"series_id": series.pk,
|
||||
"downsample_pct": int(downsample_pct),
|
||||
"updated_images": len(updated_images),
|
||||
"series_instance_uid": replacement_series_uid,
|
||||
"source_series_instance_uid": source_series_uid,
|
||||
}
|
||||
|
||||
@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)
|
||||
@@ -49,4 +236,373 @@ def push_case_to_cimar_task(case_id):
|
||||
|
||||
cimar_case.refresh_study()
|
||||
|
||||
return 10
|
||||
return 10
|
||||
|
||||
|
||||
@task(takes_context=True)
|
||||
def series_reconstruct_task(
|
||||
context,
|
||||
series_id,
|
||||
user_id,
|
||||
recon_planes,
|
||||
slice_thickness_val=None,
|
||||
slice_spacing_val=None,
|
||||
recon_thickness_mode="mean",
|
||||
):
|
||||
"""Generate reconstructions asynchronously for a series with progress updates."""
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
from atlas import views as atlas_views
|
||||
|
||||
progress_key = f"series_reconstruct_progress:{context.task_result.id}"
|
||||
cache.set(
|
||||
progress_key,
|
||||
{"current": 0, "total": 0, "message": "Preparing reconstruction..."},
|
||||
timeout=60 * 60,
|
||||
)
|
||||
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
user = get_object_or_404(User, pk=user_id)
|
||||
|
||||
if not series.check_user_can_edit(user):
|
||||
raise PermissionError("Permission denied")
|
||||
|
||||
images = list(series.get_images())
|
||||
dicom_items = []
|
||||
for image in images:
|
||||
ds = atlas_views._read_series_image_dataset(image)
|
||||
if ds is None:
|
||||
continue
|
||||
try:
|
||||
arr = ds.pixel_array
|
||||
if arr.ndim != 2:
|
||||
continue
|
||||
dicom_items.append((image, ds, arr))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if len(dicom_items) < 2:
|
||||
raise ValueError("Need at least 2 valid DICOM images in series for reconstruction")
|
||||
|
||||
base_shape = dicom_items[0][2].shape
|
||||
dicom_items = [item for item in dicom_items if item[2].shape == base_shape]
|
||||
if len(dicom_items) < 2:
|
||||
raise ValueError("Not enough consistently-sized slices for reconstruction")
|
||||
|
||||
geom = atlas_views._extract_recon_geometry(dicom_items)
|
||||
sorted_items = geom["sorted_items"]
|
||||
volume = np.stack([item[2] for item in sorted_items], axis=0)
|
||||
|
||||
template_ds = sorted_items[0][1]
|
||||
origin_ipp = geom["origin_ipp"]
|
||||
row_dir = geom["row_dir"]
|
||||
col_dir = geom["col_dir"]
|
||||
normal_dir = geom["normal_dir"]
|
||||
native_row_spacing = geom["row_spacing"]
|
||||
native_col_spacing = geom["col_spacing"]
|
||||
native_z_spacing = geom["native_z_spacing"]
|
||||
|
||||
requested_slice_spacing = float(slice_spacing_val) if slice_spacing_val is not None else None
|
||||
requested_slab_thickness = float(slice_thickness_val) if slice_thickness_val is not None else None
|
||||
|
||||
# Determine source acquisition plane from normal vector.
|
||||
dominant_normal_axis = int(np.argmax(np.abs(normal_dir)))
|
||||
source_plane = {0: "sagittal", 1: "coronal", 2: "axial"}.get(dominant_normal_axis, "unknown")
|
||||
|
||||
# Reorient the reconstructed volume into patient axes order [z, y, x] so
|
||||
# generated plane names are anatomically correct regardless of source plane.
|
||||
# NOTE: DICOM stores ImageOrientationPatient as:
|
||||
# - first triplet: direction across columns (x-axis in pixel grid)
|
||||
# - second triplet: direction across rows (y-axis in pixel grid)
|
||||
# In _extract_recon_geometry names are historically row_dir/col_dir, so we
|
||||
# map axis spacing/indexing explicitly to avoid directional mixups.
|
||||
source_axis_dirs = [normal_dir, col_dir, row_dir]
|
||||
source_axis_spacings = [float(native_z_spacing), float(native_row_spacing), float(native_col_spacing)]
|
||||
source_axis_sizes = [int(volume.shape[0]), int(volume.shape[1]), int(volume.shape[2])]
|
||||
|
||||
source_for_patient_axis = {}
|
||||
sign_for_patient_axis = {}
|
||||
patient_axes_claimed = set()
|
||||
|
||||
for src_axis, vec in enumerate(source_axis_dirs):
|
||||
patient_axis = int(np.argmax(np.abs(vec)))
|
||||
if patient_axis in patient_axes_claimed:
|
||||
raise ValueError("Could not infer unique source orientation axes for reconstruction")
|
||||
patient_axes_claimed.add(patient_axis)
|
||||
source_for_patient_axis[patient_axis] = src_axis
|
||||
sign_for_patient_axis[patient_axis] = 1 if float(vec[patient_axis]) >= 0 else -1
|
||||
|
||||
src_x = source_for_patient_axis[0]
|
||||
src_y = source_for_patient_axis[1]
|
||||
src_z = source_for_patient_axis[2]
|
||||
|
||||
# Build patient-axis basis vectors expressed in source/world space.
|
||||
# This preserves source world-frame orientation (including obliquity/sign)
|
||||
# for derived series instead of hardcoding cardinal axes.
|
||||
patient_x_vec = np.asarray(source_axis_dirs[src_x], dtype=float) * float(sign_for_patient_axis[0])
|
||||
patient_y_vec = np.asarray(source_axis_dirs[src_y], dtype=float) * float(sign_for_patient_axis[1])
|
||||
patient_z_vec = np.asarray(source_axis_dirs[src_z], dtype=float) * float(sign_for_patient_axis[2])
|
||||
|
||||
def _norm_vec(vec):
|
||||
nrm = float(np.linalg.norm(vec))
|
||||
if nrm == 0:
|
||||
return np.asarray(vec, dtype=float)
|
||||
return np.asarray(vec, dtype=float) / nrm
|
||||
|
||||
patient_x_vec = _norm_vec(patient_x_vec)
|
||||
patient_y_vec = _norm_vec(patient_y_vec)
|
||||
patient_z_vec = _norm_vec(patient_z_vec)
|
||||
|
||||
def _orientation_pair(col_vec, row_vec, desired_normal):
|
||||
col = _norm_vec(col_vec)
|
||||
row = _norm_vec(row_vec)
|
||||
desired = _norm_vec(desired_normal)
|
||||
normal = np.cross(col, row)
|
||||
if float(np.dot(normal, desired)) < 0:
|
||||
row = -row
|
||||
return [float(col[0]), float(col[1]), float(col[2]), float(row[0]), float(row[1]), float(row[2])]
|
||||
|
||||
spacing_x = source_axis_spacings[src_x]
|
||||
spacing_y = source_axis_spacings[src_y]
|
||||
spacing_z = source_axis_spacings[src_z]
|
||||
|
||||
vol_zyx = np.transpose(volume, (src_z, src_y, src_x))
|
||||
|
||||
if sign_for_patient_axis[2] < 0:
|
||||
vol_zyx = np.flip(vol_zyx, axis=0)
|
||||
if sign_for_patient_axis[1] < 0:
|
||||
vol_zyx = np.flip(vol_zyx, axis=1)
|
||||
if sign_for_patient_axis[0] < 0:
|
||||
vol_zyx = np.flip(vol_zyx, axis=2)
|
||||
|
||||
def source_indices_from_patient_zyx(iz, iy, ix):
|
||||
src_indices = [0.0, 0.0, 0.0]
|
||||
|
||||
z_index = float(iz)
|
||||
y_index = float(iy)
|
||||
x_index = float(ix)
|
||||
|
||||
if sign_for_patient_axis[2] < 0:
|
||||
z_index = source_axis_sizes[src_z] - 1 - z_index
|
||||
if sign_for_patient_axis[1] < 0:
|
||||
y_index = source_axis_sizes[src_y] - 1 - y_index
|
||||
if sign_for_patient_axis[0] < 0:
|
||||
x_index = source_axis_sizes[src_x] - 1 - x_index
|
||||
|
||||
src_indices[src_z] = z_index
|
||||
src_indices[src_y] = y_index
|
||||
src_indices[src_x] = x_index
|
||||
return src_indices
|
||||
|
||||
def position_from_patient_zyx(iz, iy, ix):
|
||||
src_k, src_r, src_c = source_indices_from_patient_zyx(iz, iy, ix)
|
||||
pos = (
|
||||
np.asarray(origin_ipp, dtype=float)
|
||||
+ (np.asarray(normal_dir, dtype=float) * (float(src_k) * float(native_z_spacing)))
|
||||
+ (np.asarray(col_dir, dtype=float) * (float(src_r) * float(native_row_spacing)))
|
||||
+ (np.asarray(row_dir, dtype=float) * (float(src_c) * float(native_col_spacing)))
|
||||
)
|
||||
return [float(pos[0]), float(pos[1]), float(pos[2])]
|
||||
|
||||
def _reduce_slab(slab, reduce_axis=0):
|
||||
if recon_thickness_mode == "max":
|
||||
out = np.max(slab, axis=reduce_axis)
|
||||
elif recon_thickness_mode == "min":
|
||||
out = np.min(slab, axis=reduce_axis)
|
||||
else:
|
||||
out = np.mean(slab, axis=reduce_axis)
|
||||
|
||||
if np.issubdtype(volume.dtype, np.integer):
|
||||
info = np.iinfo(volume.dtype)
|
||||
out = np.clip(np.rint(out), info.min, info.max).astype(volume.dtype)
|
||||
else:
|
||||
out = out.astype(volume.dtype, copy=False)
|
||||
return out
|
||||
|
||||
def _slice_indices_for_axis(axis_length, axis_spacing, output_spacing, slab_thickness):
|
||||
if output_spacing <= 0:
|
||||
raise ValueError("Slice spacing must be greater than 0")
|
||||
if slab_thickness <= 0:
|
||||
raise ValueError("Slice thickness must be greater than 0")
|
||||
|
||||
axis_positions_mm = np.arange(axis_length, dtype=float) * float(axis_spacing)
|
||||
end_pos = axis_positions_mm[-1] if axis_positions_mm.size else 0.0
|
||||
center_positions_mm = np.arange(0.0, end_pos + 1e-6, output_spacing, dtype=float)
|
||||
if center_positions_mm.size == 0:
|
||||
center_positions_mm = np.array([0.0], dtype=float)
|
||||
|
||||
slabs = []
|
||||
centers_idx = []
|
||||
half_thickness = slab_thickness / 2.0
|
||||
for center_mm in center_positions_mm:
|
||||
mask = np.abs(axis_positions_mm - center_mm) <= (half_thickness + 1e-6)
|
||||
if not mask.any():
|
||||
nearest = int(np.argmin(np.abs(axis_positions_mm - center_mm)))
|
||||
idxs = np.array([nearest], dtype=int)
|
||||
else:
|
||||
idxs = np.where(mask)[0].astype(int)
|
||||
|
||||
slabs.append(idxs)
|
||||
centers_idx.append(float(center_mm / axis_spacing) if axis_spacing > 0 else float(idxs[0]))
|
||||
|
||||
return slabs, centers_idx
|
||||
|
||||
plane_builders = {}
|
||||
for plane in recon_planes:
|
||||
plane_norm = plane.lower()
|
||||
if plane_norm == "axial":
|
||||
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_z)
|
||||
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
|
||||
slab_indices, slab_centers_idx = _slice_indices_for_axis(
|
||||
int(vol_zyx.shape[0]), float(spacing_z), plane_spacing, plane_thickness
|
||||
)
|
||||
plane_builders[plane_norm] = {
|
||||
"count": int(len(slab_indices)),
|
||||
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[slabs[idx], :, :]),
|
||||
"pixel_spacing_out": [float(spacing_y), float(spacing_x)],
|
||||
"spacing_between_slices_out": float(plane_spacing),
|
||||
"slice_thickness_out": float(plane_thickness),
|
||||
"image_orientation_out": _orientation_pair(patient_x_vec, patient_y_vec, patient_z_vec),
|
||||
"plane_normal_out": [float(patient_z_vec[0]), float(patient_z_vec[1]), float(patient_z_vec[2])],
|
||||
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(centers[idx], 0.0, 0.0),
|
||||
}
|
||||
elif plane_norm == "coronal":
|
||||
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_y)
|
||||
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
|
||||
slab_indices, slab_centers_idx = _slice_indices_for_axis(
|
||||
int(vol_zyx.shape[1]), float(spacing_y), plane_spacing, plane_thickness
|
||||
)
|
||||
plane_builders[plane_norm] = {
|
||||
"count": int(len(slab_indices)),
|
||||
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[:, slabs[idx], :], reduce_axis=1),
|
||||
"pixel_spacing_out": [float(spacing_z), float(spacing_x)],
|
||||
"spacing_between_slices_out": float(plane_spacing),
|
||||
"slice_thickness_out": float(plane_thickness),
|
||||
"image_orientation_out": _orientation_pair(patient_x_vec, patient_z_vec, patient_y_vec),
|
||||
"plane_normal_out": [float(patient_y_vec[0]), float(patient_y_vec[1]), float(patient_y_vec[2])],
|
||||
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(0.0, centers[idx], 0.0),
|
||||
}
|
||||
elif plane_norm == "sagittal":
|
||||
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_x)
|
||||
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
|
||||
slab_indices, slab_centers_idx = _slice_indices_for_axis(
|
||||
int(vol_zyx.shape[2]), float(spacing_x), plane_spacing, plane_thickness
|
||||
)
|
||||
plane_builders[plane_norm] = {
|
||||
"count": int(len(slab_indices)),
|
||||
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[:, :, slabs[idx]], reduce_axis=2),
|
||||
"pixel_spacing_out": [float(spacing_z), float(spacing_y)],
|
||||
"spacing_between_slices_out": float(plane_spacing),
|
||||
"slice_thickness_out": float(plane_thickness),
|
||||
"image_orientation_out": _orientation_pair(patient_y_vec, patient_z_vec, patient_x_vec),
|
||||
"plane_normal_out": [float(patient_x_vec[0]), float(patient_x_vec[1]), float(patient_x_vec[2])],
|
||||
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(0.0, 0.0, centers[idx]),
|
||||
}
|
||||
|
||||
if not plane_builders:
|
||||
raise ValueError("No valid reconstruction planes selected")
|
||||
|
||||
total_slices = sum(v["count"] for v in plane_builders.values())
|
||||
cache.set(
|
||||
progress_key,
|
||||
{"current": 0, "total": total_slices, "message": "Reconstruction started"},
|
||||
timeout=60 * 60,
|
||||
)
|
||||
|
||||
processed = 0
|
||||
created_series = []
|
||||
|
||||
for plane_norm, cfg in plane_builders.items():
|
||||
recon_series = atlas_views._create_series_derivative(series, f"Recon {plane_norm.title()} ({source_plane.title()} src)")
|
||||
|
||||
for idx in range(int(cfg["count"])):
|
||||
arr2d = cfg["slice_fn"](idx)
|
||||
ds_new = copy.deepcopy(template_ds)
|
||||
arr2d = np.asarray(arr2d, dtype=dicom_items[0][2].dtype)
|
||||
|
||||
ds_new.Rows = int(arr2d.shape[0])
|
||||
ds_new.Columns = int(arr2d.shape[1])
|
||||
ds_new.InstanceNumber = idx + 1
|
||||
atlas_views._stamp_derived_dataset(
|
||||
ds_new,
|
||||
series_instance_uid=recon_series.series_instance_uid,
|
||||
source_series_instance_uid=series.series_instance_uid,
|
||||
series_description=recon_series.description or f"Recon {plane_norm.title()}",
|
||||
derivation_description=(
|
||||
f"{plane_norm.title()} reconstruction; mode={recon_thickness_mode}; "
|
||||
f"thickness={float(cfg['slice_thickness_out']):.3f}mm; spacing={float(cfg['spacing_between_slices_out']):.3f}mm"
|
||||
),
|
||||
image_type=["DERIVED", "SECONDARY", "MPR"],
|
||||
)
|
||||
ds_new.PixelData = arr2d.tobytes()
|
||||
ds_new.PixelSpacing = [float(cfg["pixel_spacing_out"][0]), float(cfg["pixel_spacing_out"][1])]
|
||||
ds_new.ImageOrientationPatient = cfg["image_orientation_out"]
|
||||
ipp = cfg["position_fn"](idx)
|
||||
ds_new.ImagePositionPatient = ipp
|
||||
ds_new.SliceThickness = float(cfg["slice_thickness_out"])
|
||||
ds_new.SpacingBetweenSlices = float(cfg["spacing_between_slices_out"])
|
||||
try:
|
||||
normal = np.asarray(cfg["plane_normal_out"], dtype=float)
|
||||
ds_new.SliceLocation = float(np.dot(np.asarray(ipp, dtype=float), normal))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
out_io = io.BytesIO()
|
||||
ds_new.save_as(out_io, write_like_original=False)
|
||||
out_io.seek(0)
|
||||
|
||||
recon_image = SeriesImage(
|
||||
series=recon_series,
|
||||
position=idx + 1,
|
||||
upload_filename=f"recon_{plane_norm}_{idx + 1}.dcm",
|
||||
)
|
||||
recon_image.image.save(
|
||||
f"recon_{plane_norm}_{recon_series.pk}_{idx + 1}.dcm",
|
||||
ContentFile(out_io.getvalue()),
|
||||
save=False,
|
||||
)
|
||||
recon_image.save()
|
||||
|
||||
processed += 1
|
||||
cache.set(
|
||||
progress_key,
|
||||
{
|
||||
"current": processed,
|
||||
"total": total_slices,
|
||||
"message": f"Generating {plane_norm} reconstruction ({processed}/{total_slices})",
|
||||
},
|
||||
timeout=60 * 60,
|
||||
)
|
||||
|
||||
created_series.append(
|
||||
{
|
||||
"id": recon_series.pk,
|
||||
"url": recon_series.get_absolute_url(),
|
||||
"description": recon_series.description or str(recon_series.pk),
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Reconstruction task complete for series {} with {} outputs",
|
||||
series.pk,
|
||||
len(created_series),
|
||||
)
|
||||
|
||||
cache.set(
|
||||
progress_key,
|
||||
{
|
||||
"current": total_slices,
|
||||
"total": total_slices,
|
||||
"message": "Reconstruction complete",
|
||||
},
|
||||
timeout=10 * 60,
|
||||
)
|
||||
|
||||
return {
|
||||
"series_id": series.pk,
|
||||
"created_series": created_series,
|
||||
"target_spacing": float(requested_slice_spacing) if requested_slice_spacing is not None else None,
|
||||
"slab_thickness": float(requested_slab_thickness) if requested_slab_thickness is not None else None,
|
||||
"mode": recon_thickness_mode,
|
||||
"source_plane": source_plane,
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
<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>
|
||||
{% elif group_name == 'procedures' %}
|
||||
<a href="{% url 'atlas:procedure_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% else %}
|
||||
{{ item.name }}
|
||||
{% endif %}
|
||||
@@ -27,7 +29,7 @@
|
||||
{% 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 %}
|
||||
{% 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 and not results.procedures.exists %}
|
||||
<p class="text-muted">No matches found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<span id="add-case-form">
|
||||
This form allows you to add an existing case to the collection. You may find it easier to do this from the individual case pages or the <a href="{% url 'atlas:case_view' %}">case overview view</a> which allows you to add multiple cases at once.
|
||||
<form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<label for="case">Select Case to add:</label>
|
||||
<select name="case" id="case">
|
||||
{% for case in cases %}
|
||||
<option value="{{ case.id }}">{{ case.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Add</button>
|
||||
<button _="on click remove #add-case-form">Cancel</button>
|
||||
</form>
|
||||
</span
|
||||
<div id="add-case-form" class="p-2 border rounded">
|
||||
<p class="mb-2">Add an existing case to this collection. You can also use the case overview view to add multiple cases at once: <a href="{% url 'atlas:case_view' %}">case overview</a>.</p>
|
||||
|
||||
{% load case_widgets %}
|
||||
{% url 'atlas:add_case_to_collection' collection.pk as add_case_url %}
|
||||
{% case_search_widget collection=collection cases=cases recent_cases=recent_cases selection_mode='multiple' action_url=add_case_url input_id='case-search-input-collection-'|add:collection.pk|stringformat:'s' target_id='case-search-results-collection-'|add:collection.pk|stringformat:'s' %}
|
||||
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-secondary" _="on click remove #add-case-form">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="container my-4">
|
||||
<h3>API Tokens</h3>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<input name="name" class="form-control" placeholder="Name (e.g. CLI)" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input name="scopes" class="form-control" placeholder="Scopes (space separated)" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input name="expires_days" type="number" min="0" class="form-control" placeholder="Days" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-primary w-100" type="submit" name="action" value="create">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if created_token %}
|
||||
<div class="alert alert-info">Created token. Save it now — it won't be shown again.
|
||||
<pre class="mt-2">{{ created_token }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">Your tokens</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>Name</th><th>Scopes</th><th>Created</th><th>Expires</th><th>Revoked</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in tokens %}
|
||||
<tr>
|
||||
<td>{{ t.name }}</td>
|
||||
<td>{{ t.scopes }}</td>
|
||||
<td>{{ t.created }}</td>
|
||||
<td>{{ t.expires }}</td>
|
||||
<td>{{ t.revoked }}</td>
|
||||
<td>
|
||||
{% if not t.revoked %}
|
||||
<form method="post" style="display:inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="revoke_id" value="{{ t.id }}" />
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit" name="action" value="revoke">Revoke</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6" class="text-muted">No tokens</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -46,6 +46,21 @@
|
||||
<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>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:case_search_page' %}"><i class="bi bi-search me-1" aria-hidden="true"></i>Search Cases</a>
|
||||
</li>
|
||||
{% if request.user.is_staff %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:case_admin_overview' %}"><i class="bi bi-speedometer2 me-1" aria-hidden="true"></i>Case Admin Overview</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:cases_by_size' %}"><i class="bi bi-hDD-stack me-1" aria-hidden="true"></i>Large cases</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
@@ -63,21 +78,79 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</a>
|
||||
<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>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:procedure_view' %}">Procedures</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>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:procedure_create' %}">New procedure</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a>
|
||||
<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 class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads"><i class="bi bi-upload me-1" aria-hidden="true"></i>Uploads</a>
|
||||
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle Uploads Menu</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:user_uploads' %}"><i class="bi bi-hourglass-split me-1" aria-hidden="true"></i>Awaiting Import</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:new_uploads' %}"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Web Upload</a>
|
||||
</li>
|
||||
{% if request.user.is_staff %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:uploads_hash_search' %}" title="Search uploads and imported images by hash, series UID or study UID"><i class="bi bi-fingerprint me-1" aria-hidden="true"></i>Upload Search</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</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 class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" title="Additional Features"><i class="bi bi-three-dots me-1" aria-hidden="true"></i>Other</a>
|
||||
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<ul class="dropdown-menu">
|
||||
<a class="dropdown-item" 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>
|
||||
<a class="dropdown-item" href="{% url 'atlas:help' %}" title="View the atlas help pages"><i class="bi bi-question-circle me-1" aria-hidden="true"></i>Help</a>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE HTML>
|
||||
{% load static %}
|
||||
{% load django_htmx %}
|
||||
{% load crispy_forms_tags %}
|
||||
<html data-bs-theme="dark">
|
||||
<head>
|
||||
<title>Create Resource</title>
|
||||
<link href="{% static 'css/bootstrap.min.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'css/widgets.css' %}" type="text/css" media="all" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{% static 'css/bootstrap-icons.css' %}">
|
||||
<script src="{% static 'js/jquery-3.7.1.min.js' %}"></script>
|
||||
<script src="{% static 'js/bootstrap.bundle.min.js' %}"></script>
|
||||
{% block js %}
|
||||
{% endblock %}
|
||||
<link rel="stylesheet" href="{% static 'css/forms.css' %}">
|
||||
</head>
|
||||
<body class="p-3">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user