Compare commits
735
Commits
a1aba7836a
...
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 |
@@ -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
|
||||
+14
-1
@@ -10,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,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),
|
||||
),
|
||||
]
|
||||
+90
-61
@@ -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 = ()
|
||||
|
||||
@@ -149,33 +160,32 @@ 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)
|
||||
@@ -191,9 +201,9 @@ class AnatomyQuestion(QuestionBase):
|
||||
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):
|
||||
@@ -328,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:
|
||||
@@ -541,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)
|
||||
@@ -559,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
|
||||
@@ -590,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
|
||||
|
||||
@@ -20,6 +20,11 @@ $(document).ready(function () {
|
||||
|
||||
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.";
|
||||
@@ -207,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);
|
||||
|
||||
@@ -215,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 = () => {
|
||||
@@ -245,6 +258,12 @@ 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) {
|
||||
|
||||
+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,11 +228,19 @@
|
||||
|
||||
<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 %}
|
||||
@@ -241,10 +249,9 @@
|
||||
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
|
||||
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
|
||||
|
||||
{{ form|crispy }}
|
||||
{% 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 %}
|
||||
@@ -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%}
|
||||
Marks: <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,16 +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:mark2_overview' pk=exam.pk %}">Mark2</a> /
|
||||
<a href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a> /
|
||||
<a href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a> /
|
||||
<a href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a> /
|
||||
{% endif %}
|
||||
<a href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a>
|
||||
{% comment %} <a href="{% url 'anatomy:question_create_exam' pk=exam.pk %}" title="Add a new question directly into the exam.">Add New Question</a> {% endcomment %}
|
||||
<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 %}
|
||||
|
||||
@@ -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,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>
|
||||
@@ -9,8 +9,9 @@
|
||||
<span class="small text-muted">No suggestions</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-1">
|
||||
<button id="answer-suggest-incorrect-button" class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="innerHTML">Edit suggestions</button>
|
||||
</div>
|
||||
{% 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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends 'anatomy/base.html' %}
|
||||
{% load partials %}
|
||||
|
||||
{% block content %}
|
||||
{% load static %}
|
||||
{% include 'anatomy/question_link_header.html' %}
|
||||
@@ -8,16 +8,30 @@
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div id="anatomy-dicom-image" class="dicom-image-legacy w-100" data-url="{{ question.get_image_url }}" data-annotations='{{question.image_annotations}}' data-edit_annotation=true style="min-height:300px">
|
||||
</div>
|
||||
<div class="mt-3 d-flex justify-content-between align-items-center">
|
||||
<details class="mb-0">
|
||||
<summary class="small text-muted"><i class="bi bi-info-circle"></i> Image help</summary>
|
||||
<div class="small text-muted mt-2">Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. Click <strong>Save Annotations</strong> to persist.</div>
|
||||
</details>
|
||||
|
||||
<button id="save-annotations" class="btn btn-sm btn-primary">Save Annotations</button>
|
||||
<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 %}
|
||||
{% 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>
|
||||
@@ -36,9 +50,29 @@
|
||||
</div>
|
||||
|
||||
<h6 class="mt-2">Primary answer</h6>
|
||||
<div class="mb-2"><strong>{{ question.get_primary_answer }}</strong></div>
|
||||
{% 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 %}
|
||||
|
||||
<h6>Answers</h6>
|
||||
<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">
|
||||
@@ -47,7 +81,8 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for answer in question.answers.all|dictsortreversed:"status" %}
|
||||
<tr>
|
||||
{% 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">
|
||||
@@ -63,9 +98,19 @@
|
||||
<pre class="mb-0">{{ answer.answer }}</pre>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ answer.get_status_display|default:answer.status }}</td>
|
||||
<td>
|
||||
{% if answer.proposed %}
|
||||
{% 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>
|
||||
@@ -111,15 +156,12 @@
|
||||
|
||||
<div class="mt-3">
|
||||
<h6>Exams</h6>
|
||||
<div class="small">
|
||||
{% for exam in question.exams.all %}
|
||||
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}" class="me-2">{{ exam }}</a>
|
||||
{% empty %}
|
||||
No exams
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-2"><button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-target="#exam-list" hx-swap="innerHTML">Edit exam(s)</button>
|
||||
<span id="exam-list"></span></div>
|
||||
{% 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' %}
|
||||
@@ -127,7 +169,13 @@
|
||||
<div class="mt-3">
|
||||
<details>
|
||||
<summary class="small">Annotation JSON</summary>
|
||||
<div class="mt-2 small">{% if question.image_annotations %}<pre class="mb-0">{{ question.image_annotations }}</pre>{% else %}No saved annotations.{% endif %}</div>
|
||||
<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>
|
||||
|
||||
@@ -174,6 +222,29 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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,6 +33,11 @@ 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,
|
||||
@@ -114,6 +119,7 @@ urlpatterns = [
|
||||
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))
|
||||
|
||||
+168
-137
@@ -30,6 +30,7 @@ from dal import autocomplete
|
||||
from django.conf import settings
|
||||
from django.utils.html import format_html_join
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
|
||||
from .forms import (
|
||||
@@ -46,6 +47,7 @@ from .forms import (
|
||||
AnatomyQuestionForm,
|
||||
ExamForm,
|
||||
SuggestIncorrectAnswerForm,
|
||||
PrimaryAnswerForm,
|
||||
)
|
||||
from .models import (
|
||||
AnatomyQuestion,
|
||||
@@ -58,6 +60,21 @@ from .models import (
|
||||
# HalfMarkAnswers,
|
||||
# IncorrectAnswers,
|
||||
)
|
||||
from generic.widgets import ExamSearchWidget
|
||||
|
||||
|
||||
class AddExamForm(forms.Form):
|
||||
exam = forms.ModelChoiceField(queryset=Exam.objects.none(), required=True, widget=ExamSearchWidget(exam_model=Exam), label="Exam to add")
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if user is None:
|
||||
qs = Exam.objects.none()
|
||||
elif user.groups.filter(name="anatomy_checker").exists():
|
||||
qs = Exam.objects.all()
|
||||
else:
|
||||
qs = Exam.objects.filter(author__id=user.id) | Exam.objects.filter(open_access=True)
|
||||
self.fields["exam"].queryset = qs.distinct()
|
||||
from generic.models import CidUser, Examination
|
||||
from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamCreateBase, ExamDeleteBase, ExamGroupsUpdateBase, ExamUpdateBase, ExamViews, GenericViewBase, UpdateQuestionMixin
|
||||
from reversion.views import RevisionMixin
|
||||
@@ -87,6 +104,8 @@ from django.db.models import Count
|
||||
class AuthorOrCheckerRequiredMixin(object):
|
||||
def get_object(self, *args, **kwargs):
|
||||
obj = super().get_object(*args, **kwargs)
|
||||
if self.request.user.is_superuser:
|
||||
return obj
|
||||
if self.request.user.groups.filter(name="anatomy_checker").exists():
|
||||
return obj
|
||||
if self.request.user not in obj.author.all():
|
||||
@@ -786,6 +805,9 @@ def mark2_overview(request, pk):
|
||||
"""Overview page for mark2 workflow: lists questions and unmarked counts and links into mark2."""
|
||||
exam = get_object_or_404(Exam, pk=pk)
|
||||
|
||||
if not GenericExamViews.check_user_marker_access(request.user, pk):
|
||||
raise PermissionDenied
|
||||
|
||||
if not exam.exam_mode:
|
||||
raise Http404("Packet not in exam mode")
|
||||
|
||||
@@ -1045,102 +1067,6 @@ def mark2_question_marked(request, pk):
|
||||
return render(request, 'anatomy/partials/mark2_question_marked_fragment.html', context)
|
||||
|
||||
|
||||
|
||||
#@login_required
|
||||
#def exam_scores_refresh(request, pk):
|
||||
# exam = get_object_or_404(Exam, pk=pk)
|
||||
#
|
||||
# if not exam.exam_mode:
|
||||
# raise Http404("Packet not in exam mode")
|
||||
#
|
||||
# questions = exam.exam_questions.all() # Don't worry about order here
|
||||
#
|
||||
# cids = UserAnswer.objects.filter(question__in=questions, exam__id=pk)
|
||||
#
|
||||
# # Force a score update
|
||||
# for c in cids:
|
||||
# c.get_answer_score(cached=False)
|
||||
#
|
||||
# return render(
|
||||
# request,
|
||||
# "anatomy/base.html",
|
||||
# {
|
||||
# "simple_content": "Answer scores updated",
|
||||
# },
|
||||
# )
|
||||
|
||||
|
||||
def exam_scores_cid_user(request, pk, cid, passcode):
|
||||
exam = get_object_or_404(Exam, pk=pk)
|
||||
|
||||
if not exam.exam_mode:
|
||||
raise Http404("Packet not in exam mode")
|
||||
|
||||
if not exam.check_cid_user(cid, passcode, request.user):
|
||||
raise Http404("Error accessing exam")
|
||||
|
||||
questions = exam.get_questions().prefetch_related("answers")
|
||||
|
||||
answers_and_marks = []
|
||||
answers_marks = []
|
||||
answers = []
|
||||
|
||||
view_all_results = False
|
||||
if request.user.groups.filter(name="view_all_results").exists():
|
||||
view_all_results = True
|
||||
|
||||
for q in questions:
|
||||
# Get user answer
|
||||
user_answer = q.cid_user_answers.filter(cid=cid, exam__id=pk).first()
|
||||
if not user_answer:
|
||||
# skip if no answer
|
||||
answers_marks.append(0)
|
||||
answers.append("")
|
||||
answer_score = 0
|
||||
ans = "Not answered"
|
||||
else:
|
||||
ans = user_answer.answer
|
||||
|
||||
answer_score = user_answer.get_answer_score()
|
||||
|
||||
correct_answer = q.get_primary_answer()
|
||||
|
||||
if not exam.publish_results and not view_all_results:
|
||||
correct_answer = "*****"
|
||||
answer_score = 0
|
||||
answers.append(ans)
|
||||
answers_marks.append(answer_score)
|
||||
answers_and_marks.append((ans, answer_score, correct_answer))
|
||||
|
||||
if "unmarked" in answers_marks:
|
||||
answered = [i for i in answers_marks if type(i) == int]
|
||||
total_score = sum(answered)
|
||||
unmarked_number = len(answers_marks) - len(answered)
|
||||
total_score = "{} ({} unmarked)".format(total_score, unmarked_number)
|
||||
else:
|
||||
total_score = sum(answers_marks)
|
||||
|
||||
max_score = len(questions) * 2
|
||||
|
||||
return render(
|
||||
request,
|
||||
"anatomy/exam_scores_user.html",
|
||||
{
|
||||
"exam": exam,
|
||||
"cid": cid,
|
||||
"passcode": passcode,
|
||||
"questions": questions,
|
||||
"answers": answers,
|
||||
"answers_marks": answers_marks,
|
||||
"total_score": total_score,
|
||||
"max_score": max_score,
|
||||
"answers_and_marks": answers_and_marks,
|
||||
"view_all_results": view_all_results,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
class AnatomyQuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
|
||||
model = AnatomyQuestion
|
||||
form_class = AnatomyQuestionForm
|
||||
@@ -1318,10 +1244,16 @@ def create_body_part(request):
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@login_required
|
||||
def get_body_part_id(request):
|
||||
if request.accepts("application/json")():
|
||||
body_part_name = request.GET["body_part_name"]
|
||||
"""Return the primary key for a BodyPart looked up by name (admin popup helper).
|
||||
|
||||
Scope: authenticated users only.
|
||||
Functionality: looks up a BodyPart by exact name and returns its ID as JSON;
|
||||
used by inline admin form popups.
|
||||
"""
|
||||
if request.accepts("application/json"):
|
||||
body_part_name = request.GET.get("body_part_name", "")
|
||||
body_part_id = BodyPart.objects.get(name=body_part_name).id
|
||||
data = {
|
||||
"body_part_id": body_part_id,
|
||||
@@ -1344,10 +1276,16 @@ def create_examination(request):
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@login_required
|
||||
def get_examination_id(request):
|
||||
if request.accepts("application/json")():
|
||||
examination_name = request.GET["examination_name"]
|
||||
"""Return the primary key for an Examination looked up by name (admin popup helper).
|
||||
|
||||
Scope: authenticated users only.
|
||||
Functionality: looks up an Examination by exact name and returns its ID as JSON;
|
||||
used by inline admin form popups.
|
||||
"""
|
||||
if request.accepts("application/json"):
|
||||
examination_name = request.GET.get("examination_name", "")
|
||||
examination_id = Examination.objects.get(name=examination_name).id
|
||||
data = {
|
||||
"examination_id": examination_id,
|
||||
@@ -1358,10 +1296,16 @@ def get_examination_id(request):
|
||||
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@login_required
|
||||
def get_structure_id(request):
|
||||
if request.accepts("application/json")():
|
||||
structure_name = request.GET["structure_name"]
|
||||
"""Return the primary key for a Structure looked up by name (admin popup helper).
|
||||
|
||||
Scope: authenticated users only.
|
||||
Functionality: looks up a Structure by exact name and returns its ID as JSON;
|
||||
used by inline admin form popups.
|
||||
"""
|
||||
if request.accepts("application/json"):
|
||||
structure_name = request.GET.get("structure_name", "")
|
||||
structure_id = Structure.objects.get(name=structure_name).id
|
||||
data = {
|
||||
"structure_id": structure_id,
|
||||
@@ -1596,6 +1540,17 @@ def question_save_annotation(request, pk):
|
||||
return JsonResponse(data, status=400)
|
||||
|
||||
|
||||
@user_is_author_or_anatomy_checker
|
||||
def question_clear_annotation(request, pk):
|
||||
"""Clear the stored image annotations for a question."""
|
||||
if request.method == "POST":
|
||||
question = get_object_or_404(AnatomyQuestion, pk=pk)
|
||||
question.image_annotations = ""
|
||||
question.save(update_fields=["image_annotations"])
|
||||
return JsonResponse({"status": "success"}, status=200)
|
||||
return JsonResponse({"status": "error"}, status=400)
|
||||
|
||||
|
||||
GenericExamViews = ExamViews(
|
||||
Exam, AnatomyQuestion, Answer, UserAnswer, "anatomy", "anatomy"
|
||||
)
|
||||
@@ -1708,44 +1663,120 @@ def question_add_exam(request, question_id: int):
|
||||
return HttpResponse("Invalid permissions", status=403)
|
||||
|
||||
|
||||
if request.POST:
|
||||
# POST: handle add/remove; if exam_id provided use existing behaviour
|
||||
if request.method == "POST":
|
||||
exam_id = request.POST.get("exam_id", None)
|
||||
|
||||
if exam_id is None:
|
||||
return HttpResponse("No exam id provided", status=400)
|
||||
if exam_id:
|
||||
exam = get_object_or_404(Exam, pk=exam_id)
|
||||
if request.POST.get("remove", False) == "true":
|
||||
question.exams.remove(exam)
|
||||
else:
|
||||
question.exams.add(exam)
|
||||
|
||||
exam = get_object_or_404(Exam, pk=exam_id)
|
||||
# return out-of-band swap: update exam links and remove the editor
|
||||
links_html = render_to_string("anatomy/partials/exam_links.html", {"question": question}, request=request)
|
||||
oob_remove = '<div id="exam-list" hx-swap-oob="outerHTML"></div>'
|
||||
return HttpResponse(mark_safe(links_html + oob_remove))
|
||||
|
||||
if request.POST.get("remove", False) == "true":
|
||||
question.exams.remove(exam)
|
||||
return HttpResponse("Question removed from exam.")
|
||||
# else try to process from the search form or widget-provided values
|
||||
# The ExamSearchWidget may submit a list; accept first value if present.
|
||||
exam_vals = request.POST.getlist('exam') if hasattr(request.POST, 'getlist') else []
|
||||
if not exam_vals:
|
||||
# also accept single 'exam' param
|
||||
single = request.POST.get('exam')
|
||||
if single:
|
||||
exam_vals = [single]
|
||||
|
||||
if exam_vals:
|
||||
try:
|
||||
exam = get_object_or_404(Exam, pk=int(exam_vals[0]))
|
||||
except (ValueError, TypeError):
|
||||
exam = None
|
||||
|
||||
if exam:
|
||||
if request.POST.get("remove", False) == "true":
|
||||
question.exams.remove(exam)
|
||||
else:
|
||||
question.exams.add(exam)
|
||||
|
||||
links_html = render_to_string("anatomy/partials/exam_links.html", {"question": question}, request=request)
|
||||
oob_remove = '<div id="exam-list" hx-swap-oob="outerHTML"></div>'
|
||||
return HttpResponse(mark_safe(links_html + oob_remove))
|
||||
|
||||
# else try to process via bound form (fallback)
|
||||
form = AddExamForm(request.POST, user=request.user)
|
||||
if form.is_valid():
|
||||
exam = form.cleaned_data.get("exam")
|
||||
if request.POST.get("remove", False) == "true":
|
||||
question.exams.remove(exam)
|
||||
else:
|
||||
question.exams.add(exam)
|
||||
|
||||
links_html = render_to_string("anatomy/partials/exam_links.html", {"question": question}, request=request)
|
||||
oob_remove = '<div id="exam-list" hx-swap-oob="outerHTML"></div>'
|
||||
return HttpResponse(mark_safe(links_html + oob_remove))
|
||||
else:
|
||||
question.exams.add(exam)
|
||||
# render form with errors
|
||||
return render(request, "anatomy/partials/exam_edit_fragment.html", {"form": form, "question": question})
|
||||
|
||||
return HttpResponse("Question added to exam.")
|
||||
# GET: render the fragment with the add form and current exams
|
||||
form = AddExamForm(user=request.user)
|
||||
return render(request, "anatomy/partials/exam_edit_fragment.html", {"form": form, "question": question})
|
||||
|
||||
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
@login_required
|
||||
def primary_answer_htmx(request, pk: int):
|
||||
"""HTMX endpoint to render or update a question's primary answer.
|
||||
|
||||
GET: render the form for selecting/creating primary answer.
|
||||
POST: create/select answer and set `question.primary_answer`, then
|
||||
return the display fragment.
|
||||
"""
|
||||
if not request.htmx:
|
||||
raise PermissionDenied()
|
||||
|
||||
question = get_object_or_404(AnatomyQuestion, pk=pk)
|
||||
|
||||
if not question.can_edit(request.user):
|
||||
return HttpResponse("Invalid permissions", status=403)
|
||||
|
||||
if request.method == "POST":
|
||||
form = PrimaryAnswerForm(request.POST, question=question)
|
||||
if form.is_valid():
|
||||
new_text = form.cleaned_data.get("new_answer", "").strip()
|
||||
existing = form.cleaned_data.get("existing_answer", "")
|
||||
|
||||
if new_text:
|
||||
# create a new Answer and mark it CORRECT
|
||||
ans = Answer(question=question, answer=new_text, proposed=False, status=Answer.MarkOptions.CORRECT)
|
||||
ans.save()
|
||||
question.primary_answer = ans
|
||||
question.save(update_fields=["primary_answer"])
|
||||
elif existing:
|
||||
try:
|
||||
aid = int(existing)
|
||||
ans = Answer.objects.get(pk=aid, question=question)
|
||||
question.primary_answer = ans
|
||||
question.save(update_fields=["primary_answer"])
|
||||
except (ValueError, Answer.DoesNotExist):
|
||||
return HttpResponse("Invalid answer choice", status=400)
|
||||
|
||||
# return display fragment
|
||||
return render(request, "anatomy/partials/primary_answer_display.html", {"question": question, "can_edit": question.can_edit(request.user)})
|
||||
else:
|
||||
# Return a list of exams that we can add to the question
|
||||
return HttpResponse("Invalid form", status=400)
|
||||
|
||||
exams = Exam.objects.filter(author=request.user)
|
||||
exams = exams.difference(question.exams.all())
|
||||
# Handle cancel request to restore original state
|
||||
if request.method == "GET" and request.GET.get("cancel"):
|
||||
if question.primary_answer:
|
||||
return render(request, "anatomy/partials/primary_answer_display.html", {"question": question, "can_edit": question.can_edit(request.user)})
|
||||
else:
|
||||
return render(request, "anatomy/partials/primary_answer_empty.html", {"question": question, "can_edit": question.can_edit(request.user)})
|
||||
|
||||
if not exams:
|
||||
return HttpResponse("No exams available to add")
|
||||
|
||||
html = "Exams to add to question: <br>"
|
||||
for exam in exams:
|
||||
html = html + (f"<span id='htmx-exam-list'><form><input name='exam_id' value='{exam.id}' type='hidden'><button hx-post=\"{request.path}\""
|
||||
f">{exam.name}: {exam.id}</button></form>"
|
||||
)
|
||||
html = html + "<br>Exams to remove from question: <br>"
|
||||
for exam in question.exams.all():
|
||||
html = html + (f"<span id='htmx-exam-list'><form><input name='exam_id' value='{exam.id}' type='hidden'><input name='remove' value='true' type='hidden'><button hx-post=\"{request.path}\""
|
||||
f"hx-confirm='Are you sure you want to remove this exam from the question?'"
|
||||
f">{exam.name}: {exam.id}</button></form>"
|
||||
)
|
||||
html = html + "<button _='on click remove #htmx-exam-list'>Cancel</button>"
|
||||
|
||||
return HttpResponse(mark_safe(html))
|
||||
|
||||
|
||||
raise PermissionDenied()
|
||||
# GET - render form
|
||||
form = PrimaryAnswerForm(question=question)
|
||||
return render(request, "anatomy/partials/primary_answer_form.html", {"form": form, "question": question, "can_edit": question.can_edit(request.user)})
|
||||
@@ -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`
|
||||
+225
-1
@@ -1,4 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib import messages
|
||||
from .models import (
|
||||
Case,
|
||||
CaseDetail,
|
||||
@@ -15,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)
|
||||
@@ -51,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)
|
||||
|
||||
@@ -82,7 +95,7 @@ admin.site.register(CaseCollection, CaseCollectionAdmin)
|
||||
|
||||
|
||||
class NormalCaseAdmin(VersionAdmin):
|
||||
list_display = ("case", "display_age", "examination", "modality", "added_by", "added_date")
|
||||
list_display = ("case", "display_age", "examination", "modality", "added_date")
|
||||
list_filter = ("examination", "modality")
|
||||
search_fields = ("case__title", "case__pk")
|
||||
|
||||
@@ -140,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__
|
||||
|
||||
+258
-16
@@ -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,6 +11,7 @@ from .models import (
|
||||
Finding,
|
||||
PathologicalProcess,
|
||||
Presentation,
|
||||
Procedure,
|
||||
QuestionSchema,
|
||||
Series,
|
||||
Structure,
|
||||
@@ -17,6 +22,7 @@ from .models import (
|
||||
)
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.http import QueryDict
|
||||
|
||||
|
||||
def get_authors(request):
|
||||
@@ -81,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
|
||||
@@ -95,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",
|
||||
@@ -132,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(
|
||||
@@ -164,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"],
|
||||
}
|
||||
@@ -189,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,
|
||||
@@ -209,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,
|
||||
@@ -229,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:
|
||||
@@ -296,22 +438,32 @@ class NormalCaseFilter(django_filters.FilterSet):
|
||||
"""
|
||||
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.
|
||||
min_age = django_filters.NumberFilter(label="Min age", method="filter_min_age")
|
||||
max_age = django_filters.NumberFilter(label="Max age", method="filter_max_age")
|
||||
# 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", "age_days")
|
||||
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
|
||||
|
||||
# Set up the actual querysets for the filters
|
||||
self.base_filters.get("examination").queryset = Examination.objects.all()
|
||||
self.base_filters.get("modality").queryset = Modality.objects.all()
|
||||
# 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():
|
||||
@@ -321,22 +473,112 @@ class NormalCaseFilter(django_filters.FilterSet):
|
||||
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)
|
||||
|
||||
def filter_min_age(self, queryset, name, value):
|
||||
# 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:
|
||||
days = int(float(value) * 365.25)
|
||||
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_age(self, queryset, name, value):
|
||||
def filter_max_value(self, queryset, name, value):
|
||||
try:
|
||||
days = int(float(value) * 365.25)
|
||||
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')
|
||||
|
||||
+404
-54
@@ -40,9 +40,13 @@ from atlas.models import (
|
||||
Condition,
|
||||
Structure,
|
||||
Subspecialty,
|
||||
Presentation,
|
||||
PathologicalProcess,
|
||||
Procedure,
|
||||
UncategorisedDicom,
|
||||
UserReportAnswer,
|
||||
CaseDisplaySet,
|
||||
CaseReviewMessage,
|
||||
)
|
||||
from .models import NormalCase
|
||||
|
||||
@@ -54,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
|
||||
@@ -112,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"
|
||||
),
|
||||
@@ -129,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:
|
||||
@@ -142,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")]
|
||||
@@ -179,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
|
||||
]
|
||||
@@ -187,6 +327,7 @@ class CaseCollectionForm(ModelForm):
|
||||
Fieldset(
|
||||
"Collection Details",
|
||||
"name",
|
||||
Field("collection_type", css_class="mb-2"),
|
||||
Fieldset(
|
||||
"Dates",
|
||||
"restrict_to_dates",
|
||||
@@ -214,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(
|
||||
@@ -251,6 +408,22 @@ class NormalCaseForm(ModelForm):
|
||||
|
||||
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
|
||||
@@ -268,7 +441,7 @@ class NormalCaseForm(ModelForm):
|
||||
css_class="row",
|
||||
),
|
||||
Field("notes"),
|
||||
Submit("submit", "Save", css_class="btn btn-primary"),
|
||||
# Submit button rendered in modal footer; avoid duplicate button here
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
@@ -294,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"
|
||||
),
|
||||
}
|
||||
@@ -306,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
|
||||
@@ -494,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])
|
||||
@@ -536,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",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -567,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 = {
|
||||
@@ -596,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):
|
||||
@@ -627,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
|
||||
|
||||
@@ -742,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,
|
||||
@@ -812,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
|
||||
|
||||
@@ -828,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 = ""
|
||||
|
||||
@@ -955,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"]
|
||||
@@ -969,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:
|
||||
@@ -994,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(
|
||||
@@ -1048,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):
|
||||
"""
|
||||
@@ -1071,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
|
||||
@@ -1108,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"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1134,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,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',
|
||||
),
|
||||
]
|
||||
+1141
-98
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);
|
||||
|
||||
+70
-19
@@ -15,10 +15,14 @@ from .models import (
|
||||
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
|
||||
@@ -83,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
|
||||
@@ -108,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 = []
|
||||
@@ -130,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):
|
||||
@@ -151,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
|
||||
)
|
||||
@@ -166,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
|
||||
@@ -199,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]
|
||||
|
||||
@@ -209,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)
|
||||
|
||||
@@ -276,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",)
|
||||
|
||||
|
||||
@@ -290,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()}"
|
||||
|
||||
|
||||
@@ -312,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):
|
||||
@@ -339,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):
|
||||
@@ -391,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")}),
|
||||
@@ -412,7 +439,7 @@ 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()}"
|
||||
|
||||
|
||||
@@ -432,8 +459,11 @@ class CaseCollectionTable(SelectionTable):
|
||||
"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
|
||||
#)
|
||||
@@ -449,6 +479,8 @@ class CaseCollectionTable(SelectionTable):
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = (
|
||||
"name",
|
||||
"created",
|
||||
"updated",
|
||||
"collection_type",
|
||||
"publish_results",
|
||||
"active",
|
||||
@@ -466,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 %}
|
||||
@@ -49,6 +49,18 @@
|
||||
<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">
|
||||
@@ -66,8 +78,34 @@
|
||||
</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 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>
|
||||
@@ -83,16 +121,36 @@
|
||||
</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>
|
||||
@@ -0,0 +1,116 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-3">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<h3 class="mb-0">Case Admin Overview</h3>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_view' %}">Back to case list</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Total cases</div>
|
||||
<div class="fs-4 fw-semibold">{{ totals.total_cases }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Authors with cases</div>
|
||||
<div class="fs-4 fw-semibold">{{ totals.total_authors }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Open-access cases</div>
|
||||
<div class="fs-4 fw-semibold">{{ totals.open_access_cases }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Archived cases</div>
|
||||
<div class="fs-4 fw-semibold">{{ totals.archived_cases }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-5">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">Cases per user</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped mb-0 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th class="text-end">Case count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in author_rows %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'atlas:case_view' %}?author={{ row.pk }}">
|
||||
{% if row.get_full_name %}{{ row.get_full_name }}{% else %}{{ row.username }}{% endif %}
|
||||
</a>
|
||||
<div class="text-muted small">{{ row.username }}</div>
|
||||
</td>
|
||||
<td class="text-end fw-semibold">
|
||||
<a href="{% url 'atlas:case_view' %}?author={{ row.pk }}">{{ row.case_count }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="2" class="text-muted">No case authors found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7 d-flex flex-column gap-3">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Recently added cases</span>
|
||||
<button class="btn btn-sm btn-outline-info"
|
||||
hx-get="{% url 'atlas:case_admin_recent_cases_partial' %}"
|
||||
hx-target="#admin-recent-cases"
|
||||
hx-swap="innerHTML">
|
||||
Load
|
||||
</button>
|
||||
</div>
|
||||
<div id="admin-recent-cases" class="card-body text-muted small">
|
||||
Click Load to fetch recently added cases.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Largest cases</span>
|
||||
<button class="btn btn-sm btn-outline-warning"
|
||||
hx-get="{% url 'atlas:case_admin_largest_cases_partial' %}"
|
||||
hx-target="#admin-largest-cases"
|
||||
hx-swap="innerHTML">
|
||||
Load
|
||||
</button>
|
||||
</div>
|
||||
<div id="admin-largest-cases" class="card-body text-muted small">
|
||||
Click Load to fetch the largest cases by image footprint.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
<div class="case-displayset-modal">
|
||||
<div class="mb-3">
|
||||
<h5>{{ ds.name }}</h5>
|
||||
{% if can_edit %}
|
||||
<a href="{% url 'atlas:case_displaysets' ds.case.pk %}?displayset={{ ds.pk }}&edit=true" class="btn btn-sm btn-outline-secondary ms-2">Edit display set</a>
|
||||
{% endif %}
|
||||
{% if ds.description %}
|
||||
<div class="text-muted">{{ ds.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Viewer area for the modal - mounted by rad/static/dv3d bundle #}
|
||||
<div class="mb-3">
|
||||
<div id="modal_viewer_{{ ds.pk }}" class="dicom-viewer-root" style="height:420px;" data-auto-cache-stack="false" data-annotationjson="{{ ds.annotations }}" data-viewerstate="{{ ds.viewerstate }}"></div>
|
||||
</div>
|
||||
|
||||
{% if ds.viewerstate and ds.annotations %}
|
||||
<div class="mb-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick='(window.importAnnotations_modal_viewer_{{ ds.pk }}||window.importAnnotations_modal_viewer||window.importAnnotations)({{ ds.annotations|safe }}); (window.importViewerState_modal_viewer_{{ ds.pk }}||window.importViewerState_modal_viewer||window.importViewerState)({{ ds.viewerstate|safe }});'
|
||||
>Reload display set</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2">
|
||||
<strong>Findings:</strong>
|
||||
{% if ds.findings.all %}
|
||||
<ul>
|
||||
{% for finding in ds.findings.all %}
|
||||
<li>{{ finding }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Structures:</strong>
|
||||
{% if ds.structures.all %}
|
||||
<ul>
|
||||
{% for structure in ds.structures.all %}
|
||||
<li>{{ structure }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Conditions:</strong>
|
||||
{% if ds.conditions.all %}
|
||||
<ul>
|
||||
{% for condition in ds.conditions.all %}
|
||||
<li>{{ condition }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<details>
|
||||
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
|
||||
<div>
|
||||
{% include 'atlas/partials/json_pretty.html' with json_value=ds.viewerstate title='Viewer State' %}
|
||||
</div>
|
||||
<div>
|
||||
{% include 'atlas/partials/json_pretty.html' with json_value=ds.annotations title='Annotations' %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
<ul class="list-group mb-4" id="display-set-list">
|
||||
{% for ds in case.display_sets.all %}
|
||||
{% include 'atlas/partials/displayset_row.html' %}
|
||||
{% include 'atlas/partials/displayset_row.html' %}
|
||||
{% empty %}
|
||||
<li class="list-group-item text-muted">No display sets for this case.</li>
|
||||
{% endfor %}
|
||||
@@ -38,20 +38,49 @@
|
||||
{% block js %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
window.mountDicomViewers();
|
||||
// Helper: wait for mount function and mount the viewers safely
|
||||
function waitForMountFn(timeout = 5000) {
|
||||
if (window.mountDicomViewers) return Promise.resolve(window.mountDicomViewers);
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
function poll() {
|
||||
if (window.mountDicomViewers) return resolve(window.mountDicomViewers);
|
||||
if (Date.now() - start > timeout) return reject(new Error('mountDicomViewers not available'));
|
||||
requestAnimationFrame(poll);
|
||||
}
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
async function mountDicomViewersSafely(timeout = 5000) {
|
||||
try {
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const mountFn = await waitForMountFn(timeout);
|
||||
try { mountFn(); } catch (e) { console.warn('mountDicomViewers failed', e); }
|
||||
requestAnimationFrame(() => { try { window.dispatchEvent(new Event('resize')); } catch (e) {} });
|
||||
} catch (err) {
|
||||
console.warn('Viewer initialization deferred/fallback:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize viewers deterministically
|
||||
mountDicomViewersSafely();
|
||||
|
||||
|
||||
{% if selected_displayset %}
|
||||
setTimeout(function() {
|
||||
importAnnotations_main_viewer({{ selected_displayset.annotations|safe }});
|
||||
importViewerState_main_viewer({{ selected_displayset.viewerstate|safe }});
|
||||
// Highlight the selected displayset row
|
||||
var selectedRow = document.getElementById("displayset-{{ selected_displayset.pk }}");
|
||||
if (selectedRow) {
|
||||
selectedRow.style.border = "2px solid #ffc107";
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
(async function(){
|
||||
try {
|
||||
await mountDicomViewersSafely();
|
||||
importAnnotations_main_viewer({{ selected_displayset.annotations|safe }});
|
||||
importViewerState_main_viewer({{ selected_displayset.viewerstate|safe }});
|
||||
// Highlight the selected displayset row
|
||||
var selectedRow = document.getElementById("displayset-{{ selected_displayset.pk }}");
|
||||
if (selectedRow) {
|
||||
selectedRow.style.border = "2px solid #ffc107";
|
||||
}
|
||||
} catch (e) { console.warn('failed to import selected displayset state', e); }
|
||||
})();
|
||||
|
||||
{% if edit %}
|
||||
var editButton = document.getElementById("displayset-{{ selected_displayset.pk }}-edit-btn");
|
||||
if (editButton) {
|
||||
|
||||
@@ -146,58 +146,58 @@
|
||||
|
||||
|
||||
<!-- Differential Collapsible Panel -->
|
||||
<div class="card my-3 bg-dark text-white">
|
||||
<div class="card-header">
|
||||
<a class="collapsed text-white" data-bs-toggle="collapse" href="#differentialPanel" role="button" aria-expanded="false" aria-controls="differentialPanel">
|
||||
<h3 class="d-inline">Differential</h3>
|
||||
<span class="ms-2">(click to expand/collapse)</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="collapse" id="differentialPanel">
|
||||
<input type="button" value="Add Another Differential" id="add_more_differential" class="btn btn-sm btn-primary float-end">
|
||||
<div class="card-body">
|
||||
<div id="casedifferential_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in casedifferential_formset %}
|
||||
<li class="no-error casedifferential_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form | crispy}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
<div class="card my-3 bg-dark text-white">
|
||||
<div class="card-header">
|
||||
<a class="collapsed text-white" data-bs-toggle="collapse" href="#differentialPanel" role="button" aria-expanded="false" aria-controls="differentialPanel">
|
||||
<h3 class="d-inline">Differential</h3>
|
||||
<span class="ms-2">(click to expand/collapse)</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="collapse" id="differentialPanel">
|
||||
<input type="button" value="Add Another Differential" id="add_more_differential" class="btn btn-sm btn-primary float-end">
|
||||
<div class="card-body">
|
||||
<div id="casedifferential_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in casedifferential_formset %}
|
||||
<li class="no-error casedifferential_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form | crispy}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{{ casedifferential_formset.management_form | crispy }}
|
||||
</div>
|
||||
</div>
|
||||
{{ casedifferential_formset.management_form | crispy }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resource Collapsible Panel -->
|
||||
<div class="card my-3 bg-dark text-white">
|
||||
<div class="card-header">
|
||||
<a class="collapsed text-white" data-bs-toggle="collapse" href="#resourcePanel" role="button" aria-expanded="false" aria-controls="resourcePanel">
|
||||
<h3 class="d-inline">Resource</h3>
|
||||
<span class="ms-2">(click to expand/collapse)</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="collapse" id="resourcePanel">
|
||||
<input type="button" value="Add Another Resource" id="add_more_resource" class="btn btn-sm btn-primary float-end">
|
||||
<div class="card-body">
|
||||
<div id="caseresource_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in caseresource_formset %}
|
||||
<li class="no-error caseresource_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form | crispy}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
<div class="card my-3 bg-dark text-white">
|
||||
<div class="card-header">
|
||||
<a class="collapsed text-white" data-bs-toggle="collapse" href="#resourcePanel" role="button" aria-expanded="false" aria-controls="resourcePanel">
|
||||
<h3 class="d-inline">Resource</h3>
|
||||
<span class="ms-2">(click to expand/collapse)</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="collapse" id="resourcePanel">
|
||||
<input type="button" value="Add Another Resource" id="add_more_resource" class="btn btn-sm btn-primary float-end">
|
||||
<div class="card-body">
|
||||
<div id="caseresource_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in caseresource_formset %}
|
||||
<li class="no-error caseresource_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form | crispy}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{{ caseresource_formset.management_form | crispy}}
|
||||
</div>
|
||||
</div>
|
||||
{{ caseresource_formset.management_form | crispy}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
@@ -216,4 +216,11 @@
|
||||
{{ caseresource_formset.empty_form | crispy}}
|
||||
</li>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
if(window.markupInserter && typeof markupInserter.initForNames === 'function'){
|
||||
markupInserter.initForNames(['description','history','discussion','report','notes']);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Search Cases</h2>
|
||||
|
||||
<form method="get" class="row g-2 mb-3" id="case-search-form">
|
||||
<div class="col-md-6">
|
||||
<input type="search" name="q" value="{{ q }}" class="form-control" placeholder="Search cases...">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="field" class="form-select">
|
||||
<option value="all" {% if field == 'all' %}selected{% endif %}>All fields</option>
|
||||
<option value="title" {% if field == 'title' %}selected{% endif %}>Title</option>
|
||||
<option value="description" {% if field == 'description' %}selected{% endif %}>Description</option>
|
||||
<option value="history" {% if field == 'history' %}selected{% endif %}>History</option>
|
||||
<option value="author" {% if field == 'author' %}selected{% endif %}>Author</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<select name="subspecialty" class="form-select">
|
||||
<option value="">Any subspecialty</option>
|
||||
{% for s in subspecialty_queryset %}
|
||||
<option value="{{ s.pk }}" {% if selected_subspecialty and selected_subspecialty|stringformat:"s" == s.pk|stringformat:"s" %}selected{% endif %}>{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 d-flex gap-2">
|
||||
<label class="form-check-label me-2"><input type="checkbox" name="my" value="1" class="form-check-input" {% if my %}checked{% endif %}> My cases</label>
|
||||
<label class="form-check-label me-2"><input type="checkbox" name="open" value="1" class="form-check-input" {% if open %}checked{% endif %}> Open access</label>
|
||||
<label class="form-check-label"><input type="checkbox" name="has_images" value="1" class="form-check-input" {% if has_images %}checked{% endif %}> Has images</label>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
<a href="{% url 'atlas:case_search_page' %}" class="btn btn-outline-secondary ms-2">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="case-search-results">
|
||||
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=None q=q %}
|
||||
|
||||
{# pagination controls #}
|
||||
{% if cases.has_other_pages %}
|
||||
<nav aria-label="Case results pagination">
|
||||
<ul class="pagination mt-3">
|
||||
{% if cases.has_previous %}
|
||||
<li class="page-item"><a class="page-link" href="?{% if q %}q={{ q|urlencode }}&{% endif %}{% if field %}field={{ field }}&{% endif %}{% if my %}my=1&{% endif %}{% if open %}open=1&{% endif %}{% if has_images %}has_images=1&{% endif %}{% if selected_subspecialty %}subspecialty={{ selected_subspecialty }}&{% endif %}page={{ cases.previous_page_number }}">Previous</a></li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">Previous</span></li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item disabled"><span class="page-link">Page {{ cases.number }} of {{ cases.paginator.num_pages }}</span></li>
|
||||
|
||||
{% if cases.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?{% if q %}q={{ q|urlencode }}&{% endif %}{% if field %}field={{ field }}&{% endif %}{% if my %}my=1&{% endif %}{% if open %}open=1&{% endif %}{% if has_images %}has_images=1&{% endif %}{% if selected_subspecialty %}subspecialty={{ selected_subspecialty }}&{% endif %}page={{ cases.next_page_number }}">Next</a></li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">Next</span></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% load render_table from django_tables2 %}
|
||||
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -3,6 +3,40 @@
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
/* Turn the existing form-check radio markup into horizontal button-like radios */
|
||||
#div_id_collection_type .form-check { display: inline-block; margin-right: .5rem; }
|
||||
#div_id_collection_type .form-check-input {
|
||||
/* visually hide the native radio but keep it focusable */
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0,0,0,0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
#div_id_collection_type .form-check-label {
|
||||
display: inline-block;
|
||||
padding: .375rem .75rem;
|
||||
border: 1px solid #0d6efd;
|
||||
border-radius: .375rem;
|
||||
color: #0d6efd;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
#div_id_collection_type .form-check-input:focus + .form-check-label {
|
||||
box-shadow: 0 0 0 .25rem rgba(13,110,253,.25);
|
||||
}
|
||||
#div_id_collection_type .form-check-input:checked + .form-check-label {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
/* small spacing tweak when legend present */
|
||||
#div_id_collection_type fieldset { border: 0; padding: 0; margin: 0 0 1rem 0; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
||||
@@ -38,29 +72,6 @@
|
||||
{% csrf_token %}
|
||||
|
||||
{% crispy form form.helper %}
|
||||
{% comment %} <h3>Cases:</h3>
|
||||
Add cases here. These can only be added once created (they can also be added to cases on creation). Click and drag to change order.
|
||||
<input type="button" value="Add More Cases" id="add_more_case">
|
||||
<input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" />
|
||||
<div id="case_formset" class="sortable list_formset">
|
||||
<ol>
|
||||
{% for form in case_formset %}
|
||||
<li class="no-error case-formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
</div>
|
||||
{{ case_formset.management_form }}
|
||||
</form>
|
||||
<div id="empty_case_form" style="display:none">
|
||||
<li class='no_error case-formset'>
|
||||
{{ case_formset.empty_form }}
|
||||
</li>
|
||||
</div> {% endcomment %}
|
||||
{% comment %} <input type="submit" class="submit-button" value="Submit" name="submit"> {% endcomment %}
|
||||
</form>
|
||||
<script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Categories</h1>
|
||||
<p>The platform has numberous different ways to tag and categorise content. These help with organsing cases and series, given the ability to find cases by a particuarly property (such as a finding) or find similar cases.</p><p>These can be browsed below.</p>
|
||||
<h1>Categories</h1>
|
||||
<p>The platform has numberous different ways to tag and categorise content. These help with organsing cases and series, given the ability to find cases by a particuarly property (such as a finding) or find similar cases.</p><p>These can be browsed below.</p>
|
||||
<ul>
|
||||
<li><a href="{% url 'atlas:condition_view' %}">Conditions</a></li>
|
||||
<li><a href="{% url 'atlas:finding_view' %}">Findings</a></li>
|
||||
@@ -10,113 +10,123 @@
|
||||
<li><a href="{% url 'atlas:subspecialty_view' %}">Subspecialties</li></a>
|
||||
<li><a href="{% url 'atlas:presentation_view' %}">Presentations</li></a>
|
||||
<li><a href="{% url 'atlas:pathological_process_view' %}">Pathological Process</li></a>
|
||||
<li><a href="{% url 'atlas:procedure_view' %}">Procedures</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-4 mb-3">
|
||||
<div class="mt-4 mb-3">
|
||||
{# HTMX loading indicator styles: hide by default, show when HTMX adds the .htmx-request class #}
|
||||
<style>
|
||||
<style>
|
||||
#category-search-loading { display: none; }
|
||||
#category-search-loading.htmx-request { display: inline-block; }
|
||||
</style>
|
||||
</style>
|
||||
|
||||
<p>Alternatively you can use the search function below</p>
|
||||
<p>Alternatively you can use the search function below</p>
|
||||
|
||||
<form method="get" class="d-flex flex-column" action="" id="category-search-form">
|
||||
<div class="d-flex align-items-center">
|
||||
<input id="category-search-input" type="search" name="q" placeholder="Search all categories (conditions, findings, structures...)" value="{{ query|default:'' }}" class="form-control me-2"
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-trigger="keyup changed delay:500ms"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<form method="get" class="d-flex flex-column" action="" id="category-search-form">
|
||||
<div class="d-flex align-items-center">
|
||||
<input id="category-search-input" type="search" name="q" placeholder="Search all categories (conditions, findings, structures...)" value="{{ query|default:'' }}" class="form-control me-2"
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-trigger="keyup delay:500ms"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
|
||||
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
|
||||
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
|
||||
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary ms-2" type="submit">Search</button>
|
||||
</div>
|
||||
|
||||
{# Collapsed advanced filters block - defaults to collapsed #}
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-link p-0" type="button" data-bs-toggle="collapse" data-bs-target="#category-filters" aria-expanded="false" aria-controls="category-filters">
|
||||
Advanced filters ▾
|
||||
</button>
|
||||
|
||||
<div class="collapse mt-2" id="category-filters">
|
||||
<div class="card card-body">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-conditions" name="types" value="conditions" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-conditions">Conditions</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-findings" name="types" value="findings" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-findings">Findings</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-structures" name="types" value="structures" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-structures">Structures</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-subspecialties" name="types" value="subspecialties" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-subspecialties">Subspecialties</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-presentations" name="types" value="presentations" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-presentations">Presentations</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
|
||||
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
|
||||
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary ms-2" type="submit">Search</button>
|
||||
</div>
|
||||
|
||||
{# Collapsed advanced filters block - defaults to collapsed #}
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-link p-0" type="button" data-bs-toggle="collapse" data-bs-target="#category-filters" aria-expanded="false" aria-controls="category-filters">
|
||||
Advanced filters ▾
|
||||
</button>
|
||||
|
||||
<div class="collapse mt-2" id="category-filters">
|
||||
<div class="card card-body">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-conditions" name="types" value="conditions" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-conditions">Conditions</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-findings" name="types" value="findings" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-findings">Findings</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-structures" name="types" value="structures" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-structures">Structures</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-subspecialties" name="types" value="subspecialties" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-subspecialties">Subspecialties</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-presentations" name="types" value="presentations" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-presentations">Presentations</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-procedures" name="types" value="procedures" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-procedures">Procedures</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
|
||||
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
|
||||
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
|
||||
{% comment %} <button class="btn btn-primary ms-2" type="submit">Search</button> {% endcomment %}
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="category-search-results">
|
||||
{% if results is not None %}
|
||||
{% include 'atlas/_categories_search_results.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if request.user.is_staff %}
|
||||
<p>Manage <a href="{% url 'generic:examination_view' %}">Examinations</a>
|
||||
</p>
|
||||
<div id="category-search-results">
|
||||
{% if results is not None %}
|
||||
{% include 'atlas/_categories_search_results.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if request.user.is_staff %}
|
||||
<p>Manage <a href="{% url 'generic:examination_view' %}">Examinations</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,17 +3,9 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div>
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id previous.id %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id next.id %}">Next question</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_details" %}
|
||||
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}">{{casedetail.case.title}}</a></h2>
|
||||
|
||||
<p>This page allows you to configure how the case is displayed as part of the collection.</p>
|
||||
|
||||
@@ -22,13 +14,54 @@
|
||||
<form method="POST" class="post-form">
|
||||
{% csrf_token %}
|
||||
{% crispy form form.helper %}
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<strong>Series Visibility</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted mb-3">Choose how each series is shown for this case in this collection.</p>
|
||||
{% if series_visibility_rows %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Series</th>
|
||||
<th>Visibility</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in series_visibility_rows %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold">Series {{ forloop.counter }}</div>
|
||||
<div class="small text-muted">{{ row.series }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" name="series_visibility_{{ row.series.pk }}">
|
||||
{% for value, label in visibility_choices %}
|
||||
<option value="{{ value }}" {% if row.value == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted">No series linked to this case.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" value="answer" name="submit" class="btn btn-primary">Save Changes</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script>
|
||||
</script>
|
||||
<style></style>
|
||||
<script>
|
||||
</script>
|
||||
<style></style>
|
||||
{% endblock %}
|
||||
@@ -3,66 +3,66 @@
|
||||
|
||||
|
||||
{% block content %}
|
||||
<h2>Setup default display for case</h2>
|
||||
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_displaysetup" %}
|
||||
<h2>Setup default display for case</h2>
|
||||
|
||||
<div id="root" class="dicom-viewer-root viewer-frame-setup" data-images="{{casedetail.case.get_series_images_nested}}"
|
||||
data-auto-cache-stack="true"
|
||||
{% if casedetail.default_viewerstate %}
|
||||
data-viewerstate={{ casedetail.default_viewerstate }}
|
||||
{% endif %}
|
||||
|
||||
<div id="root" class="dicom-viewer-root" data-images="{{case_detail.case.get_series_images_nested}}"
|
||||
style="box-sizing: border-box; background: #222; width: 100vw; height: 800px; max-width: 1600px;"
|
||||
data-auto-cache-stack="true"
|
||||
{% if case_detail.default_viewerstate %}
|
||||
data-viewerstate={{ case_detail.default_viewerstate }}
|
||||
{% endif %}
|
||||
|
||||
></div>
|
||||
|
||||
<button id="save-viewerstate" type="button" class="btn btn-primary mt-3">
|
||||
Save Viewer State
|
||||
</button>
|
||||
<button id="reset-viewerstate" type="button" class="btn btn-secondary mt-3 ms-2">
|
||||
Reset to Default
|
||||
</button>
|
||||
<div id="viewerstate-save-response" class="mt-2"></div>
|
||||
<button id="save-viewerstate" type="button" class="btn btn-primary mt-3">
|
||||
Save Viewer State
|
||||
</button>
|
||||
<button id="reset-viewerstate" type="button" class="btn btn-secondary mt-3 ms-2">
|
||||
Reset to Default
|
||||
</button>
|
||||
<div id="viewerstate-save-response" class="mt-2"></div>
|
||||
|
||||
<script>
|
||||
document.getElementById('save-viewerstate').addEventListener('click', function() {
|
||||
let viewerState = window.exportViewerState_root();
|
||||
if (!viewerState) {
|
||||
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
|
||||
return;
|
||||
}
|
||||
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRFToken": "{{ csrf_token }}"
|
||||
},
|
||||
body: JSON.stringify({viewerstate: viewerState})
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
document.getElementById('viewerstate-save-response').innerHTML = html;
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error saving viewer state.</span>";
|
||||
});
|
||||
});
|
||||
document.getElementById('reset-viewerstate').addEventListener('click', function() {
|
||||
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRFToken": "{{ csrf_token }}"
|
||||
},
|
||||
body: JSON.stringify({reset_viewerstate: true})
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
document.getElementById('viewerstate-save-response').innerHTML = html;
|
||||
<script>
|
||||
document.getElementById('save-viewerstate').addEventListener('click', function() {
|
||||
let viewerState = window.exportViewerState_root();
|
||||
if (!viewerState) {
|
||||
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
|
||||
return;
|
||||
}
|
||||
fetch({% if case_number %}"{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk case_number %}"{% else %}"{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}"{% endif %}, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRFToken": "{{ csrf_token }}"
|
||||
},
|
||||
body: JSON.stringify({viewerstate: viewerState})
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
document.getElementById('viewerstate-save-response').innerHTML = html;
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error saving viewer state.</span>";
|
||||
});
|
||||
});
|
||||
document.getElementById('reset-viewerstate').addEventListener('click', function() {
|
||||
fetch({% if case_number %}"{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk case_number %}"{% else %}"{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}"{% endif %}, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRFToken": "{{ csrf_token }}"
|
||||
},
|
||||
body: JSON.stringify({reset_viewerstate: true})
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
document.getElementById('viewerstate-save-response').innerHTML = html;
|
||||
// Optionally reload the page or viewer here if needed
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error resetting viewer state.</span>";
|
||||
});
|
||||
});
|
||||
</script>
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error resetting viewer state.</span>";
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -4,106 +4,40 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div>
|
||||
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_priors" %}
|
||||
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_questions' collection.id previous.id %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'atlas:collection_case_questions' collection.id next.id %}">Next question</a>
|
||||
{% endif %}
|
||||
<h2 class="mb-3">Managing Priors for Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}" class="text-decoration-none">{{casedetail.case.title}}</a></h2>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<p class="mb-1">This page allows you to add series from prior cases to a case in a collection. In order to be available here the cases must be linked on the system.</p>
|
||||
<p class="mb-0">It is necessary to give a description of how the cases are related that will be displayed to the user when viewing the case.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-3">Available Prior Exams</h3>
|
||||
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
|
||||
|
||||
<p>This page allows you to add series from prior cases to a case in a collection. In order to be available here the cases must be linked on the system.</p>
|
||||
<p>It is necessary to give a description of how the cases are related that will be displayed to the user when viewing the case.</p>
|
||||
|
||||
<h3>Available priors</h3>
|
||||
|
||||
<ul>
|
||||
{% for case, added, relation, visibility in available_priors %}
|
||||
|
||||
<li class='
|
||||
{% if added %}
|
||||
added
|
||||
{% else %}
|
||||
not-added
|
||||
{% endif %}
|
||||
' data-case={{case.id}}>
|
||||
{{case.title}} / {{added}} / {{relation}} / <a href="{% url 'atlas:case_detail' case.id %}">View case</a>
|
||||
<br/>{{case.get_series_blocks|safe}}
|
||||
|
||||
<br/>
|
||||
|
||||
{% if not added %}
|
||||
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
|
||||
hx-target="#htmx-response-{{case.id}}"
|
||||
_="on htmx:afterRequest log 'test' then remove me">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="prior_case_id" value="{{case.id}}" />
|
||||
<input type="text" name="relation" value="{{relation}}" placeholder="relation to case" />
|
||||
<br />
|
||||
<label for="prior_visibility_{{case.id}}">Visibility:</label>
|
||||
<select name="prior_visibility" id="prior_visibility_{{case.id}}">
|
||||
<option value="AL"
|
||||
{% if visibility == 'AL' %}
|
||||
selected
|
||||
{% endif %}
|
||||
>Always</option>
|
||||
<option value="RE"
|
||||
{% if visibility == 'RE' %}
|
||||
selected
|
||||
{% endif %}
|
||||
|
||||
>Review</option>
|
||||
<option value="NO"
|
||||
{% if visibility == 'NO' %}
|
||||
selected
|
||||
{% endif %}
|
||||
>None</option>
|
||||
</select>
|
||||
<br />
|
||||
<button class="btn primary">Add to case</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
|
||||
hx-target="#htmx-response-{{case.id}}"
|
||||
_="on htmx:afterRequest log 'test' then remove me">
|
||||
Case available: {{relation}}
|
||||
<input type="hidden" name="remove" value="{{case.id}}" />
|
||||
<br />
|
||||
<label for="prior_visibility_{{case.id}}">Visibility:</label>
|
||||
<select name="prior_visibility" id="prior_visibility_{{case.id}}" disabled>
|
||||
<option value="AL"
|
||||
{% if visibility == 'AL' %}
|
||||
selected
|
||||
{% endif %}
|
||||
>Always</option>
|
||||
<option value="RE"
|
||||
{% if visibility == 'RE' %}
|
||||
selected
|
||||
{% endif %}
|
||||
|
||||
>Review</option>
|
||||
<option value="NO"
|
||||
{% if visibility == 'NO' %}
|
||||
selected
|
||||
{% endif %}
|
||||
>None</option>
|
||||
</select>
|
||||
<br />
|
||||
<button class="btn primary">Remove from case</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<span id="htmx-response-{{case.id}}"></span>
|
||||
|
||||
</li>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
{% for case, added, relation, visibility, relation_type in available_priors %}
|
||||
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility relation_type=relation_type collection=collection %}
|
||||
{% empty %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">No available prior exams found for the case.</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-3">Available Future Exams</h3>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
{% for case, added, relation, visibility, relation_type in available_futures %}
|
||||
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility relation_type=relation_type collection=collection %}
|
||||
{% empty %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">No available future exams found for the case.</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,21 +4,11 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div>
|
||||
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_questions" %}
|
||||
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_questions' collection.id previous.id %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'atlas:collection_case_questions' collection.id next.id %}">Next question</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}">{{casedetail.case.title}}</a></h2>
|
||||
|
||||
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
|
||||
|
||||
{% if case_detail.question_schema is not None and not case_detail.question_answers %}
|
||||
{% if casedetail.question_schema is not None and not casedetail.question_answers %}
|
||||
|
||||
|
||||
<div class="alert alert-danger" role="alert">
|
||||
@@ -42,7 +32,7 @@
|
||||
|
||||
Import schema from:
|
||||
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
|
||||
hx-get="{% url 'atlas:collection_question_schemas' case_detail.collection.id %}"
|
||||
hx-get="{% url 'atlas:collection_question_schemas' casedetail.collection.id %}"
|
||||
hx-target=".offcanvas-body"
|
||||
>Collection questions</button>
|
||||
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
|
||||
@@ -54,7 +44,7 @@
|
||||
Question Block Title
|
||||
</summary>
|
||||
<p>Title of the question block.</p>
|
||||
<input id="question_block_title_input" type="text" name="title" value="{{case_detail.question_schema.title}}" class="form-control" placeholder="Question title" aria-label="Question title">
|
||||
<input id="question_block_title_input" type="text" name="title" value="{{casedetail.question_schema.title}}" class="form-control" placeholder="Question title" aria-label="Question title">
|
||||
</details>
|
||||
<details><summary>Modify Questions</summary>
|
||||
<div id="questions-container" style="margin-top: 20px;">
|
||||
@@ -123,7 +113,7 @@
|
||||
</form> {% endcomment %}
|
||||
|
||||
|
||||
{% if case_detail.question_schema %}
|
||||
{% if casedetail.question_schema %}
|
||||
<h2>Question display</h2>
|
||||
This shows the form as it will be displayed (with the correct answers). If you wish to edit a correct answer you can change it below.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,20 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2>Case: {{ case.title }}</h2>
|
||||
{{ case_detail.question_schema }}
|
||||
<h2>Case: {{ case.title }}</h2>
|
||||
{% if casedetail.learner_comment %}
|
||||
<div class="alert alert-info small">
|
||||
<strong>Collection note:</strong> {{ casedetail.learner_comment|linebreaks }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if casedetail.learner_review_comment %}
|
||||
<div class="alert alert-secondary small">
|
||||
<strong>Review note:</strong> {{ casedetail.learner_review_comment|linebreaks }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ casedetail.question_schema }}
|
||||
|
||||
|
||||
{{ case.question_answers }}
|
||||
{{ case.question_answers }}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,142 +1,335 @@
|
||||
{% extends 'atlas/exams.html' %}
|
||||
|
||||
{% load partials %}
|
||||
|
||||
{% partialdef casedetails-management-links %}
|
||||
|
||||
(<a href="{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-display" title="Setup default display"></i></a>
|
||||
<div class="btn-toolbar" role="toolbar" aria-label="Case management links">
|
||||
{% include 'atlas/partials/casedetails_management_links.html' %}
|
||||
</div>
|
||||
|
||||
{% if casedetail.default_viewerstate %}
|
||||
<i class="bi bi-check text-success" title="This case has a default viewerstate defined"></i>
|
||||
{% endif %}
|
||||
)
|
||||
|
||||
(<a href="{% url 'atlas:collection_case_details' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-info-square" title="Case details"></i></a>)
|
||||
|
||||
{% if collection.collection_type == "QUE" %}
|
||||
(<a href='{% url "atlas:collection_case_questions" casedetail.collection.pk casedetail.case.pk %}'>
|
||||
{% if casedetail.question_schema %}
|
||||
<i class="bi bi-question-square text-success" title="This case has questions defined."></i>
|
||||
{% else %}
|
||||
<i class="bi bi-question-square text-danger" title="This case has no questions defined."></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
)
|
||||
{% endif %}
|
||||
|
||||
{% if casedetail.case.previous_case %}
|
||||
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>
|
||||
<i class="bi bi-link-45deg" title="Manage priors"></i></a>)
|
||||
{% endif %}
|
||||
{% endpartialdef %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{{collection.name}}</h2>
|
||||
|
||||
{% include 'exam_notes.html' %}
|
||||
<div class="container">
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h2 class="card-title mb-2">{{ collection.name }}</h2>
|
||||
|
||||
<div>
|
||||
Exam mode: {{collection.exam_mode}} (open access: {{collection.exam_open_access}})<br />
|
||||
{% include "generic/partials/exams/exam_status.html#publish-results" %}
|
||||
{% include "generic/partials/exams/exam_status.html#exam-active" %}
|
||||
Collection Type: {{collection.get_collection_type_display}}<br />
|
||||
Self review: {{collection.self_review}}<br />
|
||||
Open access: {{collection.open_access}}<br />
|
||||
{% if collection.prerequisites.exists %}
|
||||
Prerequisite collections:
|
||||
<ul>
|
||||
{% for prereq in collection.prerequisites.all %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a>
|
||||
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% include 'exam_notes.html' %}
|
||||
{% if casesdetails|length > 20 %}
|
||||
<div id="collection-warning-{{ collection.pk }}" class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||
<strong>Performance warning:</strong> This collection contains {{ casesdetails|length }} cases — in some situations this may adversely affect performance, all features will still work but page loading may be slower. Consider splitting into smaller collections if performance is an issue.
|
||||
<button id="collection-warning-close-{{ collection.pk }}" type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
<button
|
||||
title="Sync users from all prerequisite collections into this collection"
|
||||
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
class="btn btn-sm btn-secondary"
|
||||
>Sync prerequisite users</button>
|
||||
</p>
|
||||
<p class="mb-1">
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
{% include "generic/partials/exams/exam_status.html#publish-results" %} {% include "generic/partials/exams/exam_status.html#exam-active" %}
|
||||
</p>
|
||||
|
||||
<p class="mb-1">
|
||||
Collection type: <span class="badge bg-secondary me-2">{{ collection.get_collection_type_display }}</span>
|
||||
Self review: <span class="badge bg-light text-dark me-2">{{ collection.self_review }}</span>
|
||||
Open access:
|
||||
{% if collection.open_access %}
|
||||
<span class="badge bg-success">Open</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Restricted</span>
|
||||
{% endif %}
|
||||
Exam mode: <span class="badge bg-info text-dark me-2">{{ collection.exam_mode }}</span>
|
||||
</p>
|
||||
|
||||
{% if collection.prerequisites.exists %}
|
||||
<div class="mt-3 mb-2">
|
||||
<strong>Prerequisites</strong>
|
||||
<ul class="mb-0">
|
||||
{% for prereq in collection.prerequisites.all %}
|
||||
<li><a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 d-flex flex-column align-items-md-end mt-3 mt-md-0">
|
||||
<div class="mb-2 w-100 text-md-end">
|
||||
{% if collection.collection_type == "VIV" %}
|
||||
<a href="{% url 'atlas:collection_viva' collection.pk %}" class="btn btn-primary mb-2">Start Viva</a>
|
||||
<a href="{% url 'atlas:collection_viva_local' collection.pk %}" class="btn btn-outline-primary mb-2">Start Viva (Local)</a>
|
||||
{% else %}
|
||||
<div class="d-inline-block">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{{ collection.get_take_url }}" id="take-url-link">Open</a>
|
||||
<button
|
||||
_="on click
|
||||
writeText(#take-url-link's href) on navigator.clipboard
|
||||
put 'copied url!' into me
|
||||
wait 1s
|
||||
put 'copy link' into me"
|
||||
class="btn btn-sm btn-outline-secondary ms-1">Copy link</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if collection.prerequisites.exists %}
|
||||
<div class="w-100 text-md-end">
|
||||
<button
|
||||
title="Sync users from all prerequisite collections into this collection"
|
||||
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
class="btn btn-sm btn-secondary"
|
||||
>Sync prerequisite users</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card border-secondary w-100 mt-3">
|
||||
<div class="card-body py-2 px-3 text-start text-md-end">
|
||||
{% if collection.case_query_messaging_enabled %}
|
||||
<div class="small text-muted">Outstanding messages</div>
|
||||
<div class="fw-semibold {% if total_outstanding_messages %}text-danger{% endif %}">
|
||||
{{ total_outstanding_messages }} pending acknowledgement
|
||||
</div>
|
||||
<div class="mt-2 d-flex flex-wrap gap-2 justify-content-md-end">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_history' collection.pk %}">Open History</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:user_messages_inbox' %}">Global Inbox</a>
|
||||
</div>
|
||||
|
||||
{% if user_message_rows %}
|
||||
<div class="mt-2 small text-muted">Users</div>
|
||||
<div class="d-flex flex-column gap-1">
|
||||
{% for row in user_message_rows|slice:":5" %}
|
||||
<a class="small {% if row.outstanding_message_count %}text-danger{% endif %}" href="{% url 'atlas:collection_user_messages' collection.pk row.user_user.pk %}">
|
||||
{{ row.get_user_name }}
|
||||
{% if row.outstanding_message_count %}
|
||||
({{ row.outstanding_message_count }} outstanding)
|
||||
{% else %}
|
||||
({{ row.total_review_message_count }} history)
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if cid_message_rows %}
|
||||
<div class="mt-2 small text-muted">CID users</div>
|
||||
<div class="d-flex flex-column gap-1">
|
||||
{% for row in cid_message_rows|slice:":5" %}
|
||||
<a class="small {% if row.outstanding_message_count %}text-danger{% endif %}" href="{% url 'atlas:collection_cid_messages' collection.pk row.cid_user.cid %}">
|
||||
{{ row.cid_user }}
|
||||
{% if row.outstanding_message_count %}
|
||||
({{ row.outstanding_message_count }} outstanding)
|
||||
{% else %}
|
||||
({{ row.total_review_message_count }} history)
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="small text-muted">Case query/messaging</div>
|
||||
<div class="fw-semibold text-muted">Disabled for this collection</div>
|
||||
<div class="mt-2 d-flex flex-wrap gap-2 justify-content-md-end">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_history' collection.pk %}">Open History</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h3 class="mb-3">Cases</h3>
|
||||
|
||||
<ol id="full-question-list" class="sortable list-group list-group-numbered">
|
||||
{% for casedetail in casesdetails %}
|
||||
{% include 'atlas/partials/case_list_item.html' %}
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button id='button-edit-order'
|
||||
title='click and drag questions to change order'
|
||||
data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
Edit case order / Remove cases
|
||||
</button>
|
||||
|
||||
<button id='button-add-case'
|
||||
title='click to add a case to this collection'
|
||||
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list"
|
||||
class="btn btn-sm btn-secondary">
|
||||
Add case
|
||||
</button>
|
||||
|
||||
<div id='case-list'></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<details>
|
||||
<summary>Answer management</summary>
|
||||
|
||||
<div class="alert alert-danger mt-3">
|
||||
<p class="mb-1">Manage answers for this collection.</p>
|
||||
<small class="text-muted">Please note these are permanent and cannot be undone.</small>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button title="This will clear all user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
|
||||
class="btn btn-danger btn-sm"
|
||||
>Reset all answers</button>
|
||||
|
||||
<button title="This will specific user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user_list' collection.pk %}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#user-list"
|
||||
class="btn btn-warning btn-sm">
|
||||
Reset user answers</button>
|
||||
</div>
|
||||
|
||||
<div id="user-list" class="mt-3"></div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include 'exam_overview_js.html' %}
|
||||
<!-- Modal for case fields (history / discussion / report) -->
|
||||
<div id="case-field-modal" class="modal fade" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if collection.collection_type == "VIV" %}
|
||||
<p><a href='{% url "atlas:collection_viva" collection.pk %}'><button class="btn btn-primary">Start Viva</button></a>
|
||||
{% else %}
|
||||
<p>This collection will be available to view/take <a href='{{collection.get_take_url}}' id="take-url">here</a>{% if not collection.active %} (when active){% endif %} <button
|
||||
_="on click
|
||||
writeText(#take-url's href) on navigator.clipboard
|
||||
put 'copied url!' into me
|
||||
wait 1s
|
||||
put 'copy url' into me"
|
||||
class="btn btn-sm">copy link</button>
|
||||
<p>Review collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
|
||||
{% endif %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
try {
|
||||
var pk = '{{ collection.pk }}';
|
||||
var key = 'collection_warning_dismissed_' + pk;
|
||||
var alertEl = document.getElementById('collection-warning-' + pk);
|
||||
if (!alertEl) return;
|
||||
if (localStorage.getItem(key) === '1') {
|
||||
alertEl.remove();
|
||||
return;
|
||||
}
|
||||
var btn = document.getElementById('collection-warning-close-' + pk);
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
try { localStorage.setItem(key, '1'); } catch (e) { /* ignore */ }
|
||||
});
|
||||
}
|
||||
} catch (e) { console.warn('collection warning persistence error', e); }
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Listen for case selection events from a case-search-widget included in this page/modal
|
||||
document.body.addEventListener('case:selected', async function(e) {
|
||||
try {
|
||||
const detail = e.detail || {};
|
||||
const casePk = detail.casePk;
|
||||
const caseTitle = detail.caseTitle || ('#' + casePk);
|
||||
if (!casePk) return;
|
||||
|
||||
if (!window.confirm(`Add case ${caseTitle} to collection {{ collection.name }}?`)) return;
|
||||
|
||||
const url = "{% url 'atlas:add_case_to_collection' collection.pk %}";
|
||||
const form = new URLSearchParams();
|
||||
form.append('case', casePk);
|
||||
const csrfToken = '{{ csrf_token }}';
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: form.toString(),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const html = await resp.text();
|
||||
// Append the returned case item into the list
|
||||
const list = document.getElementById('full-question-list');
|
||||
if (list) list.insertAdjacentHTML('beforeend', html);
|
||||
} catch (err) {
|
||||
console.error('Add case to collection failed', err);
|
||||
alert('Failed to add case to collection — check console.');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Global handler for markup links inserted by render_markup
|
||||
document.body.addEventListener('click', function(ev){
|
||||
var el = ev.target.closest && ev.target.closest('.markup-link');
|
||||
if(!el) return;
|
||||
ev.preventDefault();
|
||||
try{
|
||||
var typ = el.getAttribute('data-type');
|
||||
var args = el.getAttribute('data-args');
|
||||
var href = el.getAttribute('href') || '#';
|
||||
|
||||
if(typ === 'case'){
|
||||
// open case — give option to open in new tab
|
||||
if(window.confirm('Open case in new tab?')){
|
||||
window.open(href, '_blank');
|
||||
} else {
|
||||
window.location = href;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
<h3>Cases</h3>
|
||||
<ol id="full-question-list" class="sortable">
|
||||
{% for casedetail in casesdetails %}
|
||||
<li data-question_pk={{casedetail.case.pk}}>
|
||||
// For finding/displayset open modal with basic options — endpoints can be wired later
|
||||
var modal = document.getElementById('case-field-modal');
|
||||
var content = modal && modal.querySelector('.modal-content');
|
||||
var parsedArgs = [];
|
||||
try{ parsedArgs = JSON.parse(args || '[]'); }catch(e){ parsedArgs = []; }
|
||||
|
||||
if(typ === 'finding'){
|
||||
var findingId = parsedArgs[0] || '';
|
||||
var html = '<div class="modal-header"><h5 class="modal-title">Finding '+findingId+'</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>';
|
||||
html += '<div class="modal-body">';
|
||||
html += '<p>This will open the finding viewer for ID '+findingId+'.</p>';
|
||||
html += '<p><a class="btn btn-sm btn-primary" href="/atlas/finding/'+findingId+'" target="_blank">Open in new tab</a> ';
|
||||
html += '<button class="btn btn-sm btn-secondary" type="button" data-bs-dismiss="modal">Close</button></p>';
|
||||
html += '</div>';
|
||||
if(content) content.innerHTML = html;
|
||||
var bs = new bootstrap.Modal(modal);
|
||||
bs.show();
|
||||
return;
|
||||
}
|
||||
|
||||
<a href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a>
|
||||
:
|
||||
{% if casedetail.case.series.count == 0 %}
|
||||
<i class="bi bi-exclamation-circle text-warning" title="No series attached to this case"></i>
|
||||
{% endif %}
|
||||
{{casedetail.case.title}}
|
||||
if(typ === 'displayset'){
|
||||
var caseId = parsedArgs[0] || '';
|
||||
var setName = parsedArgs[1] || '';
|
||||
var html = '<div class="modal-header"><h5 class="modal-title">Display set '+setName+'</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>';
|
||||
html += '<div class="modal-body">';
|
||||
html += '<p>Display set <strong>'+setName+'</strong> from case #'+caseId+'.</p>';
|
||||
html += '<p><a class="btn btn-sm btn-primary" href="/atlas/collection/'+caseId+'" target="_blank">Open case in new tab</a> ';
|
||||
html += '<button class="btn btn-sm btn-secondary" type="button" data-bs-dismiss="modal">Close</button></p>';
|
||||
html += '</div>';
|
||||
if(content) content.innerHTML = html;
|
||||
var bs2 = new bootstrap.Modal(modal);
|
||||
bs2.show();
|
||||
return;
|
||||
}
|
||||
|
||||
{% partial casedetails-management-links %}
|
||||
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
|
||||
|
||||
|
||||
{% if can_edit %}
|
||||
<p><button id='button-edit-order' title='click and drag questions to change order' data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}">Edit case order / Remove cases</button></p>
|
||||
<p><button id='button-add-case'
|
||||
title='click to add a case to this collection'
|
||||
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list">
|
||||
Add case
|
||||
</button>
|
||||
<div id='case-list'></div>
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary>Answer management</summary>
|
||||
|
||||
<div class="alert alert-danger">
|
||||
<p>Manage answers for this collection. </p>
|
||||
|
||||
Please note these are permanant and cannot be undone.
|
||||
</div>
|
||||
|
||||
<button title="This will clear all user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
|
||||
>Reset all answers</button>
|
||||
|
||||
<button title="This will specific user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user_list' collection.pk %}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#user-list"
|
||||
>Reset user answers</button>
|
||||
<div id="user-list"></div>
|
||||
|
||||
</details>
|
||||
{% endif %}
|
||||
{% include 'exam_overview_js.html' %}
|
||||
}catch(err){ console.error('markup link handler error', err); }
|
||||
}, false);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="h4 mb-1">{{ collection.name }}</h2>
|
||||
<div class="text-muted small">
|
||||
{% if cid is not None %}
|
||||
CID: <strong>{{ cid }}</strong>
|
||||
{% else %}
|
||||
Feedback hub
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card border-secondary summary-card">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="small text-muted">Feedback Summary</div>
|
||||
<div class="fw-semibold">{{ total_outstanding_feedback }} outstanding feedback item{{ total_outstanding_feedback|pluralize }}</div>
|
||||
<div class="small text-muted mt-1">Across {{ outstanding_case_count }} case{{ outstanding_case_count|pluralize }}</div>
|
||||
<div class="mt-2">
|
||||
<a class="btn btn-sm btn-outline-secondary"
|
||||
href="{% if cid is not None %}{% url 'atlas:collection_take_overview' pk=collection.id cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_take_overview_user' pk=collection.id %}{% endif %}">
|
||||
Back to Overview
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if outstanding_rows %}
|
||||
<div class="mb-4">
|
||||
<h3 class="h5 mb-3">Outstanding Feedback</h3>
|
||||
<div class="d-flex flex-column gap-3">
|
||||
{% for row in outstanding_rows %}
|
||||
<section id="case-feedback-{{ row.casedetail.case.id }}" class="card border-danger-subtle">
|
||||
<div class="card-header d-flex justify-content-between align-items-center gap-2">
|
||||
<div>
|
||||
<strong>Case {{ row.casedetail.sort_order|add:1 }}</strong>
|
||||
{% if collection.show_title_post %}
|
||||
<span class="ms-1">{{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="badge bg-danger">{{ row.stats.outstanding_feedback_count }} awaiting acknowledgement</span>
|
||||
</div>
|
||||
<div class="card-body pt-0">
|
||||
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}&passcode={{ passcode }}{% endif %}"
|
||||
hx-trigger="load"
|
||||
hx-target="this"
|
||||
hx-swap="innerHTML">
|
||||
<div class="small text-muted p-3">Loading conversation...</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-success">No outstanding feedback needs acknowledgement.</div>
|
||||
{% endif %}
|
||||
|
||||
{% if history_rows %}
|
||||
<div>
|
||||
<h3 class="h5 mb-3">Previous Conversations</h3>
|
||||
<div class="accordion" id="feedbackHistoryAccordion">
|
||||
{% for row in history_rows %}
|
||||
<div class="accordion-item" id="case-feedback-{{ row.casedetail.case.id }}">
|
||||
<h2 class="accordion-header" id="heading-{{ row.casedetail.case.id }}">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-{{ row.casedetail.case.id }}" aria-expanded="false" aria-controls="collapse-{{ row.casedetail.case.id }}">
|
||||
Case {{ row.casedetail.sort_order|add:1 }}
|
||||
{% if collection.show_title_post %}
|
||||
: {{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}
|
||||
{% endif %}
|
||||
<span class="badge bg-secondary ms-2">{{ row.stats.message_count }} message{{ row.stats.message_count|pluralize }}</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapse-{{ row.casedetail.case.id }}" class="accordion-collapse collapse" aria-labelledby="heading-{{ row.casedetail.case.id }}" data-bs-parent="#feedbackHistoryAccordion">
|
||||
<div class="accordion-body pt-0">
|
||||
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}&passcode={{ passcode }}{% endif %}"
|
||||
hx-trigger="revealed once"
|
||||
hx-target="this"
|
||||
hx-swap="innerHTML">
|
||||
<div class="small text-muted p-3">Open to load conversation history...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif not outstanding_rows %}
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body text-muted small">No case conversations have been started yet.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,20 +1,46 @@
|
||||
{% if request.user.is_authenticated %}
|
||||
Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a> /
|
||||
<a href="{% url 'atlas:collection_case_update' collection.pk %}">Cases</a> /
|
||||
<a href="{% url 'atlas:collection_history' collection.pk %}">History</a> /
|
||||
<a href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a> /
|
||||
<a href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a> /
|
||||
<a href="{% url 'atlas:exam_cids' collection.pk %}">Candidates</a> /
|
||||
<a href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a> /
|
||||
<div class="floating-header">
|
||||
<a href="{% url 'atlas:exam_update' collection.id %}" title="Edit the Collection">Edit</a>
|
||||
\ <a href="{% url 'atlas:exam_deleted' collection.id %}" title="Delete the Collection">Delete</a>
|
||||
\ <a href="{% url 'atlas:exam_clone' collection.id %}" title="Clone the Collection">Clone</a>
|
||||
\ <a href="{% url 'atlas:collection_authors' collection.pk %}" title="Edit Authors">Authors</a>
|
||||
{% if request.user.is_authenticated and collection %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
|
||||
<div class="container-fluid px-3">
|
||||
<span class="navbar-text me-3 text-white">
|
||||
<strong>Collection:</strong> {{ collection.name }}
|
||||
</span>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#collectionNav" aria-controls="collectionNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
\ <a href="{% url 'admin:atlas_casecollection_change' collection.id %}" title="Edit the Collection using the admin interface">Admin Edit</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="collapse navbar-collapse" id="collectionNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_case_update' collection.pk %}">Cases</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_history' collection.pk %}">History</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:exam_cids' collection.pk %}">Candidates</a></li>
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="collectionActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="collectionActions">
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:collection_case_update' collection.pk %}">Edit Cases</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:exam_update' collection.id %}">Edit</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:exam_clone' collection.id %}">Clone</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:collection_authors' collection.pk %}">Authors</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item text-danger" href="{% url 'atlas:exam_deleted' collection.id %}">Delete</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:collection_viva' collection.pk %}">View as viva</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:collection_viva_local' collection.pk %}">View as viva (local)</a></li>
|
||||
{% if request.user.is_superuser %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -1,54 +1,177 @@
|
||||
{% extends 'atlas/exams.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{{collection.name}}</h2>
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="h4 mb-1">{{ collection.name }}</h2>
|
||||
<div class="text-muted small">Collection attempt history and message activity</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_detail' collection.pk %}">Collection Detail</a>
|
||||
{% if collection.case_query_messaging_enabled %}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:user_messages_inbox' %}">Global Messages Inbox</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="card border-secondary h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="small text-muted">Cases</div>
|
||||
<div class="h4 mb-0">{{ total_cases }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="card border-secondary h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="small text-muted">Attempts</div>
|
||||
<div class="h4 mb-0">{{ total_attempt_count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="card border-secondary h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="small text-muted">Completed Attempts</div>
|
||||
<div class="h4 mb-0">{{ completed_attempt_count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="card border-secondary h-100 {% if total_outstanding_messages %}border-danger-subtle{% endif %}">
|
||||
<div class="card-body py-3">
|
||||
{% if collection.case_query_messaging_enabled %}
|
||||
<div class="small text-muted">Outstanding Messages</div>
|
||||
<div class="h4 mb-0 {% if total_outstanding_messages %}text-danger{% endif %}">{{ total_outstanding_messages }}</div>
|
||||
{% else %}
|
||||
<div class="small text-muted">Case Messaging</div>
|
||||
<div class="h6 mb-0 text-muted">Disabled</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if userexams %}
|
||||
<h3>Users</h3>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h3 class="h5 mb-0">Users</h3>
|
||||
<div class="small text-muted">{{ userexams|length }} user attempt{{ userexams|length|pluralize }}</div>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
<div class="d-flex flex-column gap-3 mb-4">
|
||||
{% for userexam in userexams %}
|
||||
<li id="user-history-{{ userexam.user_user.pk }}">
|
||||
<b><a href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">{{userexam.get_user_name}}</a><b><br/>
|
||||
Completed: {{userexam.completed}}<br/>
|
||||
Started: {{userexam.start_time}}, Ended: {{userexam.end_time}}<br/>
|
||||
|
||||
<button
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk userexam.user_user.pk %}"
|
||||
hx-target="#user-history-{{ userexam.user_user.pk }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this user's collection history?"
|
||||
class="btn btn-danger btn-sm remove-button">
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
<div id="user-history-{{ userexam.user_user.pk }}" class="card {% if userexam.outstanding_feedback_count %}border-danger-subtle{% else %}border-secondary-subtle{% endif %}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<div class="fw-semibold">
|
||||
<a href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">{{ userexam.get_user_name }}</a>
|
||||
{% if userexam.completed %}
|
||||
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">In progress</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="small text-muted mt-1">Started: {{ userexam.start_time|default:"-" }}</div>
|
||||
<div class="small text-muted">Ended: {{ userexam.end_time|default:"-" }}</div>
|
||||
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||
{% if collection.case_query_messaging_enabled %}
|
||||
{% if userexam.outstanding_feedback_count %}
|
||||
<span class="badge bg-danger">{{ userexam.outstanding_feedback_count }} outstanding message{{ userexam.outstanding_feedback_count|pluralize }}</span>
|
||||
{% endif %}
|
||||
{% if userexam.total_review_message_count %}
|
||||
<span class="badge bg-secondary">{{ userexam.total_review_message_count }} conversation message{{ userexam.total_review_message_count|pluralize }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-dark-subtle text-body-secondary">No conversation yet</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-sm btn-primary" href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">Open Attempt</a>
|
||||
{% if collection.case_query_messaging_enabled %}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_user_messages' collection.pk userexam.user_user.pk %}">Messages</a>
|
||||
{% endif %}
|
||||
<button
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk userexam.user_user.pk %}"
|
||||
hx-target="#user-history-{{ userexam.user_user.pk }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this user's collection history?"
|
||||
class="btn btn-danger btn-sm remove-button">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if cidexams %}
|
||||
<h3>CID Users</h3>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h3 class="h5 mb-0">CID Users</h3>
|
||||
<div class="small text-muted">{{ cidexams|length }} CID attempt{{ cidexams|length|pluralize }}</div>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
<div class="d-flex flex-column gap-3">
|
||||
{% for cidexam in cidexams %}
|
||||
<li id="cid-history-{{ cidexam.cid_user.pk }}">
|
||||
<b><a href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">{{cidexam.cid_user}}</a><b><br/>
|
||||
Completed: {{cidexam.completed}}<br/>
|
||||
Started: {{cidexam.start_time}}, Ended: {{cidexam.end_time}}<br/>
|
||||
|
||||
<button
|
||||
hx-post="{% url 'atlas:collection_reset_answers_ciduser' collection.pk cidexam.cid_user.cid %}"
|
||||
hx-target="#cid-history-{{ cidexam.cid_user.cid }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this CID user's collection history?"
|
||||
class="btn btn-danger btn-sm remove-button">
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
<div id="cid-history-{{ cidexam.cid_user.cid }}" class="card {% if cidexam.outstanding_feedback_count %}border-danger-subtle{% else %}border-secondary-subtle{% endif %}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<div class="fw-semibold">
|
||||
<a href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">{{ cidexam.cid_user }}</a>
|
||||
{% if cidexam.completed %}
|
||||
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">In progress</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="small text-muted mt-1">Started: {{ cidexam.start_time|default:"-" }}</div>
|
||||
<div class="small text-muted">Ended: {{ cidexam.end_time|default:"-" }}</div>
|
||||
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||
{% if collection.case_query_messaging_enabled %}
|
||||
{% if cidexam.outstanding_feedback_count %}
|
||||
<span class="badge bg-danger">{{ cidexam.outstanding_feedback_count }} outstanding message{{ cidexam.outstanding_feedback_count|pluralize }}</span>
|
||||
{% endif %}
|
||||
{% if cidexam.total_review_message_count %}
|
||||
<span class="badge bg-secondary">{{ cidexam.total_review_message_count }} conversation message{{ cidexam.total_review_message_count|pluralize }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-dark-subtle text-body-secondary">No conversation yet</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-sm btn-primary" href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">Open Attempt</a>
|
||||
{% if collection.case_query_messaging_enabled %}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_cid_messages' collection.pk cidexam.cid_user.cid %}">Messages</a>
|
||||
{% endif %}
|
||||
<button
|
||||
hx-post="{% url 'atlas:collection_reset_answers_ciduser' collection.pk cidexam.cid_user.cid %}"
|
||||
hx-target="#cid-history-{{ cidexam.cid_user.cid }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this CID user's collection history?"
|
||||
class="btn btn-danger btn-sm remove-button">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not userexams and not cidexams %}
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body text-muted small">No attempts have been recorded for this collection yet.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,101 +1,165 @@
|
||||
{% extends 'atlas/exams.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Collection: {{collection.name}}</h2>
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
|
||||
<div>
|
||||
<h2 class="h4 mb-1">{{ collection.name }}</h2>
|
||||
<div class="text-muted small">Learner: <strong>{{ user }}</strong></div>
|
||||
</div>
|
||||
<div class="card border-secondary summary-card">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="small text-muted">Attempt Progress</div>
|
||||
<div class="fw-semibold">{{ answer_count }} / {{ collection_length }} answered</div>
|
||||
<div class="progress progress-compact mt-2">
|
||||
<div class="progress-bar" role="progressbar" style="width: {% widthratio answer_count collection_length 100 %}%"></div>
|
||||
</div>
|
||||
<div class="mt-2 small">Started: {{ cid_user_exam.start_time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>User: {{user}}</h3>
|
||||
|
||||
<ul>
|
||||
{% for casedetail, user_answer in user_answers %}
|
||||
<li class="case">
|
||||
|
||||
<h4>{{forloop.counter}} / Case: {{casedetail.case.title}}</h4>
|
||||
Question started: {{user_answer.started_at}} - Answer submitted: {{user_answer.submitted_at}}
|
||||
{% if request.user.is_superuser and user_answer %}
|
||||
(<a href="{% url 'admin:atlas_userreportanswer_change' user_answer.pk %}" target="_blank">Edit in admin</a>)
|
||||
{% endif %}
|
||||
<br/>
|
||||
<br/>
|
||||
{% if not user_answer %}
|
||||
<span class="case-not-answered">Case not answered.</span>
|
||||
{% else %}
|
||||
<div class="answer-block">
|
||||
User answer:
|
||||
<div class="user-answer">
|
||||
{% if user_answer.answer %}
|
||||
{{user_answer.answer}}
|
||||
{% else %}
|
||||
{{user_answer.json_answer}}
|
||||
{% for value, user_answer, correct_answer, answer_is_correct, automark in user_answer.get_correct_json_answers %}
|
||||
{% if not user_answer %}
|
||||
Not answered
|
||||
{% else %}
|
||||
<div class="{% if answer_is_correct %}
|
||||
correct
|
||||
{% else %}
|
||||
incorrect
|
||||
{% endif %}
|
||||
{% if automark %}
|
||||
automark
|
||||
{% endif %}
|
||||
|
||||
">
|
||||
<h5>{{value.title}}</h5>
|
||||
{{value.description}}
|
||||
<div
|
||||
>
|
||||
Answer : {{user_answer}}
|
||||
|
||||
{% if not answer_is_correct %}
|
||||
<br/>Correct answer: {{correct_answer}}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<div class="list-group mb-4">
|
||||
{% for row in history_rows %}
|
||||
<div id="case-history-{{ row.casedetail.case.id }}" class="list-group-item {% if not row.user_answer %}bg-danger-subtle border-danger-subtle{% endif %}">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-2">
|
||||
<div>
|
||||
<div class="fw-semibold">
|
||||
Case {{ forloop.counter }}: {{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}
|
||||
{% if row.user_answer and row.user_answer.completed %}
|
||||
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
|
||||
{% elif row.user_answer %}
|
||||
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">Saved</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger-subtle text-danger-emphasis ms-2">Not answered</span>
|
||||
{% endif %}
|
||||
{% if row.self_reviews %}
|
||||
<span class="badge bg-info-subtle text-info-emphasis ms-1">{{ row.self_reviews|length }} self review{{ row.self_reviews|length|pluralize }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
Started: {{ row.user_answer.started_at|default:'-' }}
|
||||
{% if row.user_answer %}
|
||||
· Submitted: {{ row.user_answer.submitted_at|default:'-' }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-sm btn-primary"
|
||||
href="{% url 'atlas:collection_case_view_take_review_user' pk=collection.id case_number=forloop.counter0 user_pk=user.pk %}">
|
||||
Open Case
|
||||
</a>
|
||||
{% if request.user.is_superuser and row.user_answer %}
|
||||
{% if row.user_answer.user_id %}
|
||||
<a class="btn btn-sm btn-outline-secondary"
|
||||
href="{% url 'admin:atlas_userreportanswer_change' row.user_answer.pk %}"
|
||||
target="_blank">
|
||||
Edit in admin
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="btn btn-sm btn-outline-secondary"
|
||||
href="{% url 'admin:atlas_cidreportanswer_change' row.user_answer.pk %}"
|
||||
target="_blank">
|
||||
Edit in admin
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if row.self_reviews %}
|
||||
{% for review in row.self_reviews %}
|
||||
<a class="btn btn-sm btn-outline-info"
|
||||
href="{% url 'atlas:self_review_detail' cid_user_exam.id row.casedetail.case.id review.id %}">
|
||||
View Self Review {{ forloop.counter }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not row.user_answer %}
|
||||
<div class="small text-muted">This case has not been answered yet.</div>
|
||||
{% else %}
|
||||
<details class="mb-3" open>
|
||||
<summary class="small fw-semibold">Answer / Report</summary>
|
||||
<div class="small mt-2 p-3 rounded bg-body-tertiary border">
|
||||
{% if row.user_answer.answer %}
|
||||
{{ row.user_answer.answer|linebreaksbr }}
|
||||
{% else %}
|
||||
<div class="mb-2">{{ row.user_answer.json_answer }}</div>
|
||||
{% for value, single_answer, correct_answer, answer_is_correct, automark in row.user_answer.get_correct_json_answers %}
|
||||
<div class="mb-2 p-2 border rounded {% if answer_is_correct %}border-success-subtle{% else %}border-danger-subtle{% endif %}">
|
||||
<div class="fw-semibold">
|
||||
{{ value.title }}
|
||||
{% if answer_is_correct %}
|
||||
<span class="text-success">✓</span>
|
||||
{% elif automark %}
|
||||
<span class="text-danger">✗</span>
|
||||
{% else %}
|
||||
<span class="text-warning">✗</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="small text-muted">{{ value.description }}</div>
|
||||
<div class="small mt-1">Answer: {{ single_answer|default:'Not answered' }}</div>
|
||||
{% if not answer_is_correct and correct_answer %}
|
||||
<div class="small text-muted">Correct answer: {{ correct_answer }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
</li>
|
||||
{% if row.self_reviews %}
|
||||
<details class="mb-3">
|
||||
<summary class="small fw-semibold">Self Reviews</summary>
|
||||
<div class="d-flex flex-column gap-2 mt-2">
|
||||
{% for review in row.self_reviews %}
|
||||
<div class="border rounded p-3 bg-body-tertiary">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2 mb-2">
|
||||
<div class="small text-muted">Updated: {{ review.review_update_date|date:"Y-m-d H:i" }}</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-sm btn-outline-info"
|
||||
href="{% url 'atlas:self_review_detail' cid_user_exam.id row.casedetail.case.id review.id %}">
|
||||
View
|
||||
</a>
|
||||
<a class="btn btn-sm btn-outline-warning"
|
||||
href="{% url 'atlas:self_review_update' cid_user_exam.id row.casedetail.case.id review.id %}">
|
||||
Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="small mb-1"><strong>Findings:</strong> {{ review.findings|default:'-' }}/5</div>
|
||||
<div class="small mb-2"><strong>Interpretation:</strong> {{ review.interpretation|default:'-' }}/5</div>
|
||||
<div class="small text-body-secondary">{{ review.comments|default:'No comments added.'|linebreaksbr }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% if collection.case_query_messaging_enabled %}
|
||||
<details class="mt-3" {% if row.case_review_stats.has_outstanding_any %}open{% endif %}>
|
||||
<summary class="small fw-semibold d-flex flex-wrap align-items-center gap-2">
|
||||
<span>Case conversation</span>
|
||||
{% if row.case_review_stats.has_outstanding_any %}
|
||||
<span class="badge bg-danger">{{ row.case_review_stats.outstanding_total_count }} unacknowledged message{{ row.case_review_stats.outstanding_total_count|pluralize }}</span>
|
||||
{% elif row.case_review_stats.has_messages %}
|
||||
<span class="badge bg-secondary">{{ row.case_review_stats.message_count }} message{{ row.case_review_stats.message_count|pluralize }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">No messages yet</span>
|
||||
{% endif %}
|
||||
</summary>
|
||||
|
||||
<div class="mt-2"
|
||||
hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}{% endif %}"
|
||||
hx-trigger="load"
|
||||
hx-target="this"
|
||||
hx-swap="innerHTML">
|
||||
<div class="small text-muted">Loading case conversation...</div>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
{{ block.super }}
|
||||
<style>
|
||||
.correct {
|
||||
}
|
||||
|
||||
.correct h5::after {
|
||||
content: '✓';
|
||||
color: green;
|
||||
}
|
||||
|
||||
.incorrect h5::after {
|
||||
content: '✗';
|
||||
color: orange;
|
||||
}
|
||||
|
||||
.incorrect.automark h5::after {
|
||||
content: '✗';
|
||||
color: red;
|
||||
}
|
||||
|
||||
.answer-block>div {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.case:has(.case-not-answered) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user