Compare commits
775
Commits
87dde70fd3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdcdeb761b | ||
|
|
8e1d6fdd60 | ||
|
|
479e37f346 | ||
|
|
0afed5a983 | ||
|
|
f51faff534 | ||
|
|
f27936b2fb | ||
|
|
685f66854e | ||
|
|
8ae33e32ee | ||
|
|
d322806b80 | ||
|
|
2c56fcd4e7 | ||
|
|
66cca1a512 | ||
|
|
3a3ec75b1d | ||
|
|
89bf6eedc0 | ||
|
|
fa98541e31 | ||
|
|
50b80fd0c7 | ||
|
|
7bd1475daf | ||
|
|
b13261c302 | ||
|
|
94b08c5c21 | ||
|
|
8f7ec905a6 | ||
|
|
5139346d28 | ||
|
|
c6fe4df763 | ||
|
|
5414a5b4f9 | ||
|
|
5a73409aad | ||
|
|
1b2715642a | ||
|
|
5ae51a7e71 | ||
|
|
edb4e071f4 | ||
|
|
e56c1f2d0f | ||
|
|
daebdc1844 | ||
|
|
5a2baaeab8 | ||
|
|
5ce4a67b31 | ||
|
|
89963d6257 | ||
|
|
2744758aa8 | ||
|
|
bb6b4c625c | ||
|
|
55fcf71431 | ||
|
|
277322059c | ||
|
|
d8bacfefa9 | ||
|
|
856fb0bf08 | ||
|
|
ce323c4973 | ||
|
|
a60982c30a | ||
|
|
1296089607 | ||
|
|
077c858024 | ||
|
|
12014fbb23 | ||
|
|
17043722e1 | ||
|
|
1317d6a438 | ||
|
|
2425e62caf | ||
|
|
c34dcdfd5e | ||
|
|
79ba4db4bf | ||
|
|
d07d88bdbf | ||
|
|
d32e2a5231 | ||
|
|
5ad66cb951 | ||
|
|
4e29941ae4 | ||
|
|
2e11e40b7c | ||
|
|
ed77af215f | ||
|
|
28204d7ec1 | ||
|
|
0110a6c4d4 | ||
|
|
24d9594446 | ||
|
|
56cd257474 | ||
|
|
86a1a935e1 | ||
|
|
dbb762bb09 | ||
|
|
6ebe74d785 | ||
|
|
21eabcf3ec | ||
|
|
1ad87d8b31 | ||
|
|
8003dcd714 | ||
|
|
b5d844e3aa | ||
|
|
1b3b21a6f5 | ||
|
|
a573ed78a4 | ||
|
|
c52cf46d65 | ||
|
|
1f0877964b | ||
|
|
c50ace771e | ||
|
|
35ab7443d2 | ||
|
|
fc9f2ed6af | ||
|
|
afd4cc3b87 | ||
|
|
aaee3577ad | ||
|
|
96d5fa4a2e | ||
|
|
6bb9441748 | ||
|
|
e670adf2d0 | ||
|
|
145aa442f0 | ||
|
|
ddfb591921 | ||
|
|
e78a69367f | ||
|
|
d89df3d0a6 | ||
|
|
56bc17d1c7 | ||
|
|
8c0d97d36d | ||
|
|
161da980cf | ||
|
|
9919af2b20 | ||
|
|
390ccc0234 | ||
|
|
a9c417d993 | ||
|
|
0f11f0800e | ||
|
|
3d445fe12e | ||
|
|
2e854c43b6 | ||
|
|
46518a45c1 | ||
|
|
213ee17328 | ||
|
|
92d4ece061 | ||
|
|
74f1ad4b2c | ||
|
|
0412aa82e4 | ||
|
|
f2a52bed23 | ||
|
|
1d85666127 | ||
|
|
40cea43578 | ||
|
|
6aada9f991 | ||
|
|
01d44bc8e4 | ||
|
|
98df8ec176 | ||
|
|
4cc758bc66 | ||
|
|
060333c006 | ||
|
|
d74cfe73e0 | ||
|
|
c1005712e0 | ||
|
|
9592568dbb | ||
|
|
ee4fe2b85b | ||
|
|
6368f525e1 | ||
|
|
797c002e8b | ||
|
|
428db3e55c | ||
|
|
fd3e7280fb | ||
|
|
75be040783 | ||
|
|
734460777b | ||
|
|
f85aefe5c7 | ||
|
|
a7a21fab9c | ||
|
|
bf9190d177 | ||
|
|
c9133c2175 | ||
|
|
8f74b66b97 | ||
|
|
679ce2fd00 | ||
|
|
f2330c8f9f | ||
|
|
da1dd79ef2 | ||
|
|
f0ff3e4bfa | ||
|
|
46f8fc6256 | ||
|
|
dad108d256 | ||
|
|
69da0d6170 | ||
|
|
5e3eb9f985 | ||
|
|
11d4bf0e71 | ||
|
|
3fe3ae5d16 | ||
|
|
437cb66e1f | ||
|
|
bd87c59870 | ||
|
|
d3c7512aa9 | ||
|
|
dcddeb8e15 | ||
|
|
e361e01af8 | ||
|
|
dbb68a6801 | ||
|
|
64138c9038 | ||
|
|
a3ba14f4b5 | ||
|
|
dcf2ad4af7 | ||
|
|
0908608280 | ||
|
|
064f039a24 | ||
|
|
c0845287da | ||
|
|
32c6dc6f65 | ||
|
|
b9f511f636 | ||
|
|
dcc347793d | ||
|
|
63ea4cdf23 | ||
|
|
c55797636a | ||
|
|
e016175d2b | ||
|
|
682bde82b9 | ||
|
|
53109e7cb2 | ||
|
|
3065a279b1 | ||
|
|
2f8b61a4be | ||
|
|
2b10f2e73c | ||
|
|
20e24808fc | ||
|
|
714857e3d2 | ||
|
|
7019ac2ef8 | ||
|
|
16fea039d7 | ||
|
|
fc38776ce2 | ||
|
|
d132625419 | ||
|
|
5614961980 | ||
|
|
f3af6d47ee | ||
|
|
b09fbee2c9 | ||
|
|
4a104be71f | ||
|
|
dbf0fc262c | ||
|
|
026350f9d4 | ||
|
|
b34970ed7f | ||
|
|
867d92bff9 | ||
|
|
4d1423134a | ||
|
|
410b71f07f | ||
|
|
e51e8358bb | ||
|
|
5f69cdcaf9 | ||
|
|
93e24bf855 | ||
|
|
1c76e0696c | ||
|
|
ff00efecf7 | ||
|
|
a9946961f1 | ||
|
|
d5e473d692 | ||
|
|
75e3bca503 | ||
|
|
1775b11e40 | ||
|
|
d77c5149f0 | ||
|
|
c2d8376e22 | ||
|
|
ab9847e22e | ||
|
|
bbf7542451 | ||
|
|
e6a20bb851 | ||
|
|
7ed0a8f378 | ||
|
|
c8d05818a4 | ||
|
|
b6716c9ae1 | ||
|
|
e368d0c7d1 | ||
|
|
b9cbfd20c1 | ||
|
|
5ce337c7c4 | ||
|
|
f4d990676a | ||
|
|
4f61e43b8c | ||
|
|
c947cba5a7 | ||
|
|
0cf655325c | ||
|
|
89e89cef73 | ||
|
|
c94d2fb1a7 | ||
|
|
e28bf641b7 | ||
|
|
59956670df | ||
|
|
d2a4ee9f70 | ||
|
|
c7fd99f6f3 | ||
|
|
cf4cb32c5e | ||
|
|
f4c2c4bdaf | ||
|
|
8da8f27433 | ||
|
|
c3131564e6 | ||
|
|
0bda5e2c41 | ||
|
|
6b9c7be633 | ||
|
|
35887fbcaf | ||
|
|
f9a9b9bb38 | ||
|
|
916ac3b69c | ||
|
|
7ac8517062 | ||
|
|
22c5576fc9 | ||
|
|
52e6e79c1b | ||
|
|
3455dc3855 | ||
|
|
2b9f3e3b68 | ||
|
|
e55a3bf36a | ||
|
|
b24eac862b | ||
|
|
84b032addb | ||
|
|
d046b7a151 | ||
|
|
aaa3ee6313 | ||
|
|
c1e351c7db | ||
|
|
863d65b4b3 | ||
|
|
26a73d1d3b | ||
|
|
d12933e62a | ||
|
|
771abf62e2 | ||
|
|
859042bf96 | ||
|
|
6574c29a70 | ||
|
|
9edac288f5 | ||
|
|
ab7dcaed76 | ||
|
|
2aff9516ac | ||
|
|
5d37cad48d | ||
|
|
6575c50507 | ||
|
|
74cdf14af3 | ||
|
|
9ef4b2dac1 | ||
|
|
2e35fa7036 | ||
|
|
6be7398b42 | ||
|
|
2478de6b98 | ||
|
|
7479b44f53 | ||
|
|
89c8cd565f | ||
|
|
f63f3b3687 | ||
|
|
8201f91697 | ||
|
|
082a260757 | ||
|
|
5bd1338b5e | ||
|
|
7062723433 | ||
|
|
cc100dba89 | ||
|
|
64aa2974ee | ||
|
|
47cfa79083 | ||
|
|
5c217266c4 | ||
|
|
68dfcdfa7e | ||
|
|
e894e02445 | ||
|
|
f7cc51c221 | ||
|
|
56a644c5c1 | ||
|
|
4a787aa3eb | ||
|
|
7031942198 | ||
|
|
585a19013a | ||
|
|
61a83ed836 | ||
|
|
bf12204c51 | ||
|
|
29165811d1 | ||
|
|
02ad8c61e5 | ||
|
|
70ea3788ca | ||
|
|
3772846e55 | ||
|
|
1876a7f845 | ||
|
|
d4f25de9d3 | ||
|
|
0b1f842e31 | ||
|
|
29f9b724b0 | ||
|
|
1f1b324cfe | ||
|
|
474ad205f6 | ||
|
|
1f806d2e60 | ||
|
|
20987274af | ||
|
|
d82551674b | ||
|
|
0d1318564c | ||
|
|
9a8a1a1b57 | ||
|
|
9df74edc8c | ||
|
|
3db2c1a768 | ||
|
|
dc479cafd0 | ||
|
|
b02120d5e7 | ||
|
|
be02269af6 | ||
|
|
5843547a05 | ||
|
|
36a77981c8 | ||
|
|
95793c78c9 | ||
|
|
37e1bf29a3 | ||
|
|
b864f3d3fd | ||
|
|
e82b353ad9 | ||
|
|
854f265623 | ||
|
|
bebac18625 | ||
|
|
068dd3df80 | ||
|
|
9bae94a595 | ||
|
|
df7f56f34c | ||
|
|
f47f408cce | ||
|
|
c0870a8765 | ||
|
|
113424d1e4 | ||
|
|
e007e35f4f | ||
|
|
e162e60f44 | ||
|
|
5c50ee75da | ||
|
|
90c8f0b60d | ||
|
|
463a741185 | ||
|
|
f7b6ec1447 | ||
|
|
3e03413047 | ||
|
|
126d186ad1 | ||
|
|
a749373a21 | ||
|
|
d9331fe279 | ||
|
|
3e936e494a | ||
|
|
5a050fb36d | ||
|
|
2d48a6bad6 | ||
|
|
d4f0c05341 | ||
|
|
7b52cab180 | ||
|
|
ffb730e9c8 | ||
|
|
1b199150e5 | ||
|
|
bf7f88ac6e | ||
|
|
1d19afa72d | ||
|
|
bd0c71140c | ||
|
|
d6487eae88 | ||
|
|
06fe73e2a3 | ||
|
|
ec0bad02c6 | ||
|
|
de5b6c36f3 | ||
|
|
90e22435a5 | ||
|
|
a82e797bae | ||
|
|
97db951c79 | ||
|
|
f70ecb4648 | ||
|
|
a792f0c7cd | ||
|
|
13d73020ee | ||
|
|
4d6d535ca7 | ||
|
|
fbb2a2cc18 | ||
|
|
20b4d6b6af | ||
|
|
d7ed8881d8 | ||
|
|
3ae6ce53bc | ||
|
|
5a5eaa04a2 | ||
|
|
d75d2be547 | ||
|
|
f65e78b959 | ||
|
|
7526f6a902 | ||
|
|
b17774aa7c | ||
|
|
077bd3ed87 | ||
|
|
73a364fd44 | ||
|
|
8733f59882 | ||
|
|
64d4958744 | ||
|
|
9e0e66bcd9 | ||
|
|
ec4dcdba2b | ||
|
|
4512b799a6 | ||
|
|
d313cd083c | ||
|
|
8bbb6b741d | ||
|
|
d00a7f9cf6 | ||
|
|
70602e6f71 | ||
|
|
b35057d059 | ||
|
|
1cadbab742 | ||
|
|
3ea51f976d | ||
|
|
d0686dbe17 | ||
|
|
e391f4f943 | ||
|
|
65208ae7ad | ||
|
|
82bea5ff05 | ||
|
|
71c5cc6c24 | ||
|
|
6c7a37ca4a | ||
|
|
4788d56dec | ||
|
|
42bffc0c07 | ||
|
|
996ef4877c | ||
|
|
b87db68329 | ||
|
|
b87c0a776c | ||
|
|
af9bfabeed | ||
|
|
2c482216c1 | ||
|
|
61c768b80d | ||
|
|
5d0ff3373d | ||
|
|
c14ad2dcf7 | ||
|
|
b503558ee4 | ||
|
|
c177d21688 | ||
|
|
2a1db54a17 | ||
|
|
b1ec4b173e | ||
|
|
4c1e0c3d2a | ||
|
|
4e6f72e045 | ||
|
|
ed3bd3433b | ||
|
|
2c6d0f2613 | ||
|
|
7cac5aa60f | ||
|
|
a5a4084994 | ||
|
|
7caaa425ed | ||
|
|
904c023bf1 | ||
|
|
e2e0e374e6 | ||
|
|
af2dcd95b2 | ||
|
|
6ff8706fa4 | ||
|
|
12e3d47600 | ||
|
|
b3f5684e69 | ||
|
|
166b0a0282 | ||
|
|
ed6418efad | ||
|
|
0c6d33e26a | ||
|
|
b43d441da1 | ||
|
|
b7cf022622 | ||
|
|
419063fdeb | ||
|
|
a6205c95cb | ||
|
|
c00e08d63e | ||
|
|
a3ac91437f | ||
|
|
d7f3ebf950 | ||
|
|
83d1b48213 | ||
|
|
65db1220d8 | ||
|
|
3cdfba0acf | ||
|
|
d899d0d86a | ||
|
|
44d23801b2 | ||
|
|
6e3ab7de41 | ||
|
|
a51a5c8d54 | ||
|
|
c61f92ae9e | ||
|
|
6e30fcc19f | ||
|
|
36da045d71 | ||
|
|
0b6661c018 | ||
|
|
64103cac1e | ||
|
|
990f4534bd | ||
|
|
4382c6070e | ||
|
|
8ba9543c89 | ||
|
|
f2eaf6197e | ||
|
|
b19eefa60d | ||
|
|
0718ea82b2 | ||
|
|
fb9952e7aa | ||
|
|
ed1a33ac89 | ||
|
|
eddfb85d7b | ||
|
|
f133fc3fd0 | ||
|
|
2f90a21abe | ||
|
|
98ce450f11 | ||
|
|
6734036477 | ||
|
|
5243100d7e | ||
|
|
fa82d81a96 | ||
|
|
726057b51e | ||
|
|
647081a7d6 | ||
|
|
57b9c712c2 | ||
|
|
4d78a6b63a | ||
|
|
941c599d24 | ||
|
|
2df789735c | ||
|
|
37812921b8 | ||
|
|
4cdddcf288 | ||
|
|
17d3b4206a | ||
|
|
ec03def164 | ||
|
|
f72ec82525 | ||
|
|
90c96333a1 | ||
|
|
19eb10c089 | ||
|
|
2ab5a8e3b5 | ||
|
|
c2f12f384a | ||
|
|
33f32dab8b | ||
|
|
a676d437bb | ||
|
|
ba5b11cce2 | ||
|
|
a6c5b99620 | ||
|
|
666f49f2c2 | ||
|
|
46608a0758 | ||
|
|
cfdf33b9ce | ||
|
|
bdc97922af | ||
|
|
3626f3918e | ||
|
|
49fbff76e9 | ||
|
|
7ec81133d8 | ||
|
|
c67db4a39e | ||
|
|
d2f4c21883 | ||
|
|
1f5931aab2 | ||
|
|
08c3558861 | ||
|
|
add7a1cf38 | ||
|
|
65f08cce6c | ||
|
|
3c8238d737 | ||
|
|
23f33ac7ae | ||
|
|
ab130a08ed | ||
|
|
45e01a78c1 | ||
|
|
25e5ef78ed | ||
|
|
13dd79b508 | ||
|
|
89986ba2e9 | ||
|
|
f9e69e1206 | ||
|
|
e7de6c8970 | ||
|
|
ea139456a8 | ||
|
|
035da12e96 | ||
|
|
3b9942054d | ||
|
|
5761117e68 | ||
|
|
f316dc9bcb | ||
|
|
bdb7b653b6 | ||
|
|
1eeb0d89d9 | ||
|
|
f3702db5f6 | ||
|
|
a6a7d75144 | ||
|
|
14bba93c4d | ||
|
|
f29df840c6 | ||
|
|
10d30cdaa6 | ||
|
|
dc4cf0c3a2 | ||
|
|
99b951b32b | ||
|
|
c2c8ec5b13 | ||
|
|
6d5303cd0d | ||
|
|
f6b990a00a | ||
|
|
3c0deec140 | ||
|
|
e3241b503d | ||
|
|
04174484b1 | ||
|
|
168a4126d3 | ||
|
|
a0850c0030 | ||
|
|
a882a3592b | ||
|
|
8074b56a80 | ||
|
|
2e79df1eae | ||
|
|
9bf68cd136 | ||
|
|
42163448bf | ||
|
|
1066a3fb9f | ||
|
|
3ade162b28 | ||
|
|
f4186bf923 | ||
|
|
5bb2aea8ef | ||
|
|
8911c09c0e | ||
|
|
7342b11b27 | ||
|
|
953295cc4d | ||
|
|
374ee17911 | ||
|
|
18aef751ca | ||
|
|
f362be43ef | ||
|
|
00680c8b9e | ||
|
|
eea3433042 | ||
|
|
8dad4a5a5c | ||
|
|
75d23c0ba9 | ||
|
|
ff2b6f4c65 | ||
|
|
22e5f9a496 | ||
|
|
16865fab03 | ||
|
|
5e14df4836 | ||
|
|
625f6017a6 | ||
|
|
c45e3c8765 | ||
|
|
ee33f62def | ||
|
|
3a5fe9a85c | ||
|
|
efc47bb2e8 | ||
|
|
38d70f8ff0 | ||
|
|
2159b045cd | ||
|
|
4628f39a49 | ||
|
|
90ceff1b02 | ||
|
|
a512c2ba09 | ||
|
|
a61c8529ae | ||
|
|
a667566db6 | ||
|
|
d070adc326 | ||
|
|
048168e87d | ||
|
|
c881da244b | ||
|
|
797eb9c54a | ||
|
|
0a1b21cec6 | ||
|
|
d3d3638c1c | ||
|
|
81e22dfc88 | ||
|
|
7ea43a6f44 | ||
|
|
eb8fe58cf7 | ||
|
|
f149a88bd6 | ||
|
|
d4d747517c | ||
|
|
0ee378484b | ||
|
|
ea583744c9 | ||
|
|
e0d589f4d0 | ||
|
|
1c95526ff9 | ||
|
|
662fc4e06e | ||
|
|
a333ff492d | ||
|
|
bb9741b786 | ||
|
|
75be7c1739 | ||
|
|
015a5d18b5 | ||
|
|
d16041026f | ||
|
|
774a1b8c8a | ||
|
|
8fc42169f3 | ||
|
|
834d91e84d | ||
|
|
ef03a97e9e | ||
|
|
dd7437bc66 | ||
|
|
21d3130fa5 | ||
|
|
89e7e49e31 | ||
|
|
8bceb10a13 | ||
|
|
13a98a1e66 | ||
|
|
9c16f93820 | ||
|
|
1c27d48a36 | ||
|
|
562ad1c0a4 | ||
|
|
fcd77fd799 | ||
|
|
d8f8ef7326 | ||
|
|
f551d071f0 | ||
|
|
9b6fd31cee | ||
|
|
8b7da1554a | ||
|
|
d4247adb13 | ||
|
|
b89ca918fa | ||
|
|
569ddb4343 | ||
|
|
b040dfcd82 | ||
|
|
db51ef23be | ||
|
|
8c796c3048 | ||
|
|
b837c406cf | ||
|
|
42d209af5a | ||
|
|
3d1b9a57f9 | ||
|
|
8d638cc08b | ||
|
|
20a06323d5 | ||
|
|
3928df6d75 | ||
|
|
bf344f20e1 | ||
|
|
e4cfb1f782 | ||
|
|
f44f3bbcfe | ||
|
|
ba0c380f18 | ||
|
|
459b7f9b93 | ||
|
|
378d784435 | ||
|
|
96e88c05a4 | ||
|
|
457fb06dc0 | ||
|
|
debe093bc9 | ||
|
|
086bd8f589 | ||
|
|
71dc50b307 | ||
|
|
c0c12ea9f9 | ||
|
|
429adfc376 | ||
|
|
36a809ea40 | ||
|
|
443d93c35f | ||
|
|
d7682ab31d | ||
|
|
e600b8eb91 | ||
|
|
3e691d774c | ||
|
|
c3a70b079d | ||
|
|
4ffadd3914 | ||
|
|
fa18c33e8a | ||
|
|
057c391995 | ||
|
|
94fbd38634 | ||
|
|
4ecc32f837 | ||
|
|
768e1bf8c5 | ||
|
|
32c4305754 | ||
|
|
465f819973 | ||
|
|
3e1cb4b54f | ||
|
|
b9d86a7639 | ||
|
|
e5fd99f491 | ||
|
|
efeed2a8b1 | ||
|
|
f14ede86d3 | ||
|
|
517c2ebe06 | ||
|
|
c5f9b830eb | ||
|
|
2ab85357a9 | ||
|
|
91cf2cab29 | ||
|
|
34c4c0d830 | ||
|
|
98cef9ea57 | ||
|
|
df0b26de36 | ||
|
|
17330681a2 | ||
|
|
112c96dc00 | ||
|
|
0bea34d406 | ||
|
|
06ceb9c2ce | ||
|
|
8ac292eb0c | ||
|
|
ff6b8a8a48 | ||
|
|
46313cf5bb | ||
|
|
13a23eee12 | ||
|
|
a1bc96f99a | ||
|
|
9fd5e56356 | ||
|
|
a02f274a4e | ||
|
|
d3d0e865c3 | ||
|
|
7163e51335 | ||
|
|
7b47a75657 | ||
|
|
54e1cf4ce0 | ||
|
|
c071f38920 | ||
|
|
03b23b9178 | ||
|
|
00b736d8d6 | ||
|
|
885ab1aad4 | ||
|
|
77edc17732 | ||
|
|
d98305caff | ||
|
|
12bd66d4d8 | ||
|
|
508f8a22a2 | ||
|
|
158db133b3 | ||
|
|
83a009e31b | ||
|
|
00dfaf9888 | ||
|
|
7e4e66a74f | ||
|
|
7a4cf3627d | ||
|
|
4db1f2ca1a | ||
|
|
cbff1317d0 | ||
|
|
0644ed99f4 | ||
|
|
c01000f06e | ||
|
|
2f014ce1a5 | ||
|
|
654d0ae686 | ||
|
|
18dbb27a3e | ||
|
|
4bec329aff | ||
|
|
1e2300bd70 | ||
|
|
7674f29124 | ||
|
|
d39879bbca | ||
|
|
012c86340d | ||
|
|
6f50c53519 | ||
|
|
53aefa29df | ||
|
|
4617a643d2 | ||
|
|
d7afab15e1 | ||
|
|
ad5e7aeed8 | ||
|
|
b4cf5b9d70 | ||
|
|
f156f4c85e | ||
|
|
4ab2e3a15e | ||
|
|
6daa643df8 | ||
|
|
f8874a2556 | ||
|
|
b4f5992986 | ||
|
|
9a3a8b932a | ||
|
|
eb06e63f77 | ||
|
|
a4f4ba464a | ||
|
|
6fafccc907 | ||
|
|
d96dd77cdc | ||
|
|
4e47083fcd | ||
|
|
bf895e516e | ||
|
|
49229b0408 | ||
|
|
997b5374c2 | ||
|
|
9d434ec80d | ||
|
|
af616a9f4a | ||
|
|
d29c23a799 | ||
|
|
0917947a98 | ||
|
|
02a9118faf | ||
|
|
63c6a648d7 | ||
|
|
517c30ba0e | ||
|
|
590e0b76ed | ||
|
|
e9a04e823c | ||
|
|
9ed789ba90 | ||
|
|
fd6e25d55e | ||
|
|
f074e5b086 | ||
|
|
2157b6f781 | ||
|
|
359682a0cd | ||
|
|
78577076c1 | ||
|
|
3635275573 | ||
|
|
0c412dfff6 | ||
|
|
3f00311136 | ||
|
|
e8c35efd83 | ||
|
|
0badca4c4f | ||
|
|
25f4e478b3 | ||
|
|
33cba3d005 | ||
|
|
efca426359 | ||
|
|
f713632fdd | ||
|
|
785638ee23 | ||
|
|
e8020c5d8f | ||
|
|
5ccf755e72 | ||
|
|
6308fbfcd7 | ||
|
|
8a871d6332 | ||
|
|
6eeb4cbb1d | ||
|
|
9d57d03cbb | ||
|
|
21de543313 | ||
|
|
a12a9e38f3 | ||
|
|
81dc5bf48f | ||
|
|
4abb57900d | ||
|
|
08c9138cdb | ||
|
|
fb833a2b90 | ||
|
|
2f1e2f6abe | ||
|
|
ffa717a9ef | ||
|
|
2dd9f4af32 | ||
|
|
feb5a85af2 | ||
|
|
292f81ad74 | ||
|
|
349c46c6db | ||
|
|
681a7560ba | ||
|
|
aa90ae5e09 | ||
|
|
f55bb1b18e | ||
|
|
5e78cc4603 | ||
|
|
a9cbb1303a | ||
|
|
24558f92ce | ||
|
|
0f8bae8a14 | ||
|
|
6e658aca93 | ||
|
|
81cd109449 | ||
|
|
d86f990be2 | ||
|
|
b6478c44dc | ||
|
|
12b7fa6629 | ||
|
|
7f4570417e | ||
|
|
0098fa5dfc | ||
|
|
740d013198 | ||
|
|
b47bb141e8 | ||
|
|
712ecec25d | ||
|
|
0193daf166 | ||
|
|
279d6344f1 | ||
|
|
8c8d484dce | ||
|
|
9760be8f27 | ||
|
|
5e0068d490 | ||
|
|
875744a08a | ||
|
|
c02a46fab1 | ||
|
|
6b73006122 | ||
|
|
7eb478b111 | ||
|
|
10c18bd514 | ||
|
|
1407fe83a2 | ||
|
|
e9716f3caf | ||
|
|
749450b03e | ||
|
|
7e72f731b3 | ||
|
|
e407326c25 | ||
|
|
0d8177f0b4 | ||
|
|
3312f3058a | ||
|
|
478dcb7217 | ||
|
|
a1aba7836a | ||
|
|
80b1ef3817 | ||
|
|
835d0382ba | ||
|
|
8096a36a11 | ||
|
|
97d65f0caa | ||
|
|
b73e89393b | ||
|
|
ec036a6266 | ||
|
|
14e6514b90 | ||
|
|
639e91fc1e | ||
|
|
74765e70a2 | ||
|
|
ca2b7feb5b | ||
|
|
453fbf91be | ||
|
|
a2e9d5e681 | ||
|
|
8da2dd27da | ||
|
|
970242e1fb | ||
|
|
f8be1918f6 | ||
|
|
5ac998454b | ||
|
|
d259d0e8dd | ||
|
|
5fb3c98941 | ||
|
|
a70354c18d | ||
|
|
345977e2ef | ||
|
|
726c7ea401 | ||
|
|
7fa08037f0 | ||
|
|
f786e88de9 | ||
|
|
9441541feb | ||
|
|
5cca1b079a | ||
|
|
2e551d411a | ||
|
|
d8055a509c | ||
|
|
208252a858 | ||
|
|
ea9dca5804 | ||
|
|
da04138861 | ||
|
|
ec0245fe7c | ||
|
|
c39d30c746 | ||
|
|
d781508364 | ||
|
|
06ea87be14 | ||
|
|
ce1b439681 | ||
|
|
c8375a28fa | ||
|
|
6be39ab69d | ||
|
|
e9d0e402be | ||
|
|
dc23dffaf9 |
@@ -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
|
||||
+13
@@ -10,5 +10,18 @@ venv
|
||||
.env
|
||||
|
||||
*.log
|
||||
log.txt
|
||||
|
||||
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
|
||||
+50
-2
@@ -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):
|
||||
@@ -258,3 +291,18 @@ class SuggestIncorrectAnswerForm(ModelForm):
|
||||
class Meta:
|
||||
model = AnatomyQuestion
|
||||
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 %}
|
||||
@@ -28,32 +28,60 @@
|
||||
{% if object %}
|
||||
{% include 'anatomy/question_link_header.html' %}
|
||||
{% endif %}
|
||||
<h2>Question Answers</h2>
|
||||
<details class="help-text">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p>This page can be used to add and modify answers associated with a question. Answers in here will be used for automarking submitted answers so it is best not to remove. Updating an answer here will not automatically update a submitted answers (scores cache would need to be refreshed)</p>
|
||||
</details>
|
||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||
{% csrf_token %}
|
||||
<h3>Answers:</h3>
|
||||
<div id="form_set">
|
||||
{% for form in answer_formset %}
|
||||
<ul class="no-error answer-formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form.as_ul }}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h2 class="mb-0">Question Answers</h2>
|
||||
<small class="text-muted">Manage answer keys for auto-marking</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<details class="help-text mb-3">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p class="mb-0">This page can be used to add and modify answers associated with a question. Answers here are used for auto-marking submitted responses; avoid removing entries unless you intend to re-score submissions.</p>
|
||||
</details>
|
||||
|
||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<h3 class="h5 mt-2">Answers</h3>
|
||||
<div id="form_set" class="mt-3">
|
||||
{% for form in answer_formset %}
|
||||
<div class="answer-card mb-3 p-3 border rounded bg-body-secondary">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
<div class="answer-fields">
|
||||
{{ form.as_ul }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{{ answer_formset.management_form }}
|
||||
|
||||
<div class="d-flex gap-2 align-items-center mt-3">
|
||||
<button type="button" id="add_more" class="btn btn-outline-secondary btn-sm">Add another answer</button>
|
||||
<input type="submit" class="submit-button btn btn-primary btn-sm ms-auto" value="Save answers" name="submit">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="empty_form" class="d-none">
|
||||
<div class='answer-card p-3 mb-3 border rounded bg-body-secondary'>
|
||||
{{ answer_formset.empty_form.as_ul }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ answer_formset.management_form }}
|
||||
<input type="button" value="Add More Answers" id="add_more">
|
||||
<p>
|
||||
<input type="submit" class="submit-button btn btn-primary" value="Submit" name="submit">
|
||||
</p>
|
||||
</form>
|
||||
<div id="empty_form" style="display:none">
|
||||
<ul class='no_error answer-formset'>
|
||||
{{ answer_formset.empty_form.as_ul }}
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
/* Answer card layout */
|
||||
.answer-card label { font-weight: 600; }
|
||||
.answer-card li { list-style: none; margin-bottom: .5rem; }
|
||||
.answer-card input[type="text"], .answer-card textarea, .answer-card select { width: 100%; }
|
||||
.help-text summary { cursor: pointer; font-weight:600; }
|
||||
/* Make the Add button more prominent on small screens */
|
||||
@media (max-width: 576px) {
|
||||
.submit-button { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -6,39 +6,39 @@
|
||||
|
||||
{% block navigation %}
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-dark submenu">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{% url 'anatomy:index' %}">Anatomy</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse">
|
||||
<ul class="navbar-nav">
|
||||
{% if request.user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'anatomy:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'anatomy:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{% url 'anatomy:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'anatomy:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark submenu">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{% url 'anatomy:index' %}">Anatomy</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#anatomyNavbarNav" aria-controls="anatomyNavbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="anatomyNavbarNav">
|
||||
<ul class="navbar-nav">
|
||||
{% if request.user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'anatomy:user_answer_table_view' %}" title="User answers">Answers</a>
|
||||
<a class="nav-link" href="{% url 'anatomy:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'anatomy:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{% url 'anatomy:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'anatomy:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'anatomy:user_answer_table_view' %}" title="User answers">Answers</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% if simple_content %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% extends 'generic/exam_scores_base.html' %}
|
||||
{% extends base_template|default:'generic/exam_scores_base.html' %}
|
||||
|
||||
{% block table_answers %}
|
||||
{% for question in questions %}
|
||||
@@ -10,10 +10,12 @@
|
||||
{% endfor %} {% endcomment %}
|
||||
{% for cid in cids %}
|
||||
{% with by_question|get_item:question|get_item:cid as ans_score %}
|
||||
<td class="anatomy-ans user-answer-score-{{ans_score.1}}"
|
||||
<td class="anatomy-ans user-answer-score-{{ans_score.1}} col-cid-cell col-cid-{{ cid|slugify }}"
|
||||
title="answer score: {{ans_score.1}}"
|
||||
data-examquestionindex={{forloop.parentloop.counter0}}
|
||||
data-cid={{cid}}
|
||||
data-cid-name="{{ cids_user_id_map|get_item:cid|lower }}"
|
||||
data-cid-val="{{ cid|lower }}"
|
||||
>{{ans_score.0}}<a class="show-on-hover" href="{% url 'anatomy:exam_question_user_answer' exam.pk forloop.parentloop.counter0 cid|slice:':1' cid|slice:'2:' %}"><i class="bi bi-pen"></i>
|
||||
</a></td>
|
||||
{% endwith %}
|
||||
|
||||
@@ -1,81 +1,165 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load help_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="question-display-block">
|
||||
<div class="hide-show">HIDE/SHOW</div>
|
||||
<div class="inner-display-block">
|
||||
Question <span class="question-number">No question loaded</span><br />
|
||||
<span id="question-title"></span><br />
|
||||
<span id="question-question"></span>
|
||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''>
|
||||
<div class="container anatomy my-3">
|
||||
{% include 'user_score_header.html' %}
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-2 d-flex align-items-center justify-content-between">
|
||||
<div class="small text-muted">Filter questions by marks</div>
|
||||
<div>
|
||||
<select id="filter-score" class="form-select form-select-sm" style="min-width:120px;">
|
||||
<option value="all">All scores</option>
|
||||
<option value="2">2 (full)</option>
|
||||
<option value="1">1</option>
|
||||
<option value="0">0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush score-answer-list">
|
||||
{% for ans, score, correct_answer in answers_and_marks %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start" data-score="{{ score }}">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="me-3">
|
||||
<span class="badge bg-secondary">{{ forloop.counter }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold">Question {{ forloop.counter }}</div>
|
||||
<div class="small text-muted">Correct answer: {% if exam.publish_results %}<span class="correct-answer">{{ correct_answer }}</span>{% else %}—{% endif %}</div>
|
||||
<div class="mt-2"><pre class="mb-0">{{ ans }}</pre></div>
|
||||
{% if exam.publish_results or view_all_results %}
|
||||
<div class="small mt-2">
|
||||
Marks:
|
||||
{% if score == 2 %}
|
||||
<span class="badge bg-success ms-1">{{ score }}</span>
|
||||
{% elif score == 1 %}
|
||||
<span class="badge bg-warning text-dark ms-1">{{ score }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary ms-1">{{ score }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end ms-3">
|
||||
{% if exam.publish_results %}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary view-question-button" data-qn={{forloop.counter0}}>View</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" disabled>View</button>
|
||||
{% endif %}
|
||||
{% if request.user.is_staff %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-2 admin-edit-button" data-qn="{{ forloop.counter0 }}">Edit</button>
|
||||
{% endif %}
|
||||
{% if request.user.is_staff or view_all_results or request.user in exam.author.all %}
|
||||
{% with q=questions|get_item:forloop.counter0 %}
|
||||
{% with ua=user_answers_map|get_item:q.pk %}
|
||||
{% if ua %}
|
||||
<a href="{% url 'generic:view_answer_history' app_name='anatomy' model_name='UserAnswer' pk=ua.pk %}" class="btn btn-sm btn-outline-info ms-2">History</a>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'user_scores_footer.html' %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
{% if exam.publish_results %}
|
||||
<div class="card sticky-top" style="top:1rem;">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Viewer</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="hide-viewer">Hide</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="viewer-block">
|
||||
<div class="viewer-body overflow-auto p-3" style="max-height: calc(100vh - 160px);">
|
||||
<div>Question <span class="question-number">No question loaded</span></div>
|
||||
<div id="question-title" class="fw-bold my-2"></div>
|
||||
<div id="question-question" class="mb-2"></div>
|
||||
<div id="single-dicom-viewer" class="dicom-viewer mb-2" data-images="" data-annotations=''></div>
|
||||
<div class="small text-muted">Answers:</div>
|
||||
<div class="answers mt-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="answers">Answers: </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="anatomy">
|
||||
{% include 'user_score_header.html' %}
|
||||
<ul class="score-answer-list">
|
||||
{% for ans, score, correct_answer in answers_and_marks %}
|
||||
<li class="user-answer-li" data-question-number="{{forloop.counter}}">Question {{forloop.counter}} - Correct answer: {% if exam.publish_results %}<span class="correct-answer">{{correct_answer}}</span>{% endif %}
|
||||
<br/>
|
||||
<span class="user-answer-score user-answer-score-{{score}}">
|
||||
<span class="submitted-user-answer">
|
||||
<pre>{{ans}}</pre>
|
||||
</span>
|
||||
{% if exam.publish_results or view_all_results%}
|
||||
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 %}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="h4 mb-1">Mark2 — Marking: {{ exam }}</h2>
|
||||
<p class="small text-muted mb-0">Use the improved HTMX marking UI by opening a question below.</p>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="btn-group me-2" role="group" aria-label="listing actions">
|
||||
|
||||
@@ -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 %}
|
||||
</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.")
|
||||
else:
|
||||
# Return a list of exams that we can add to the question
|
||||
|
||||
exams = Exam.objects.filter(author=request.user)
|
||||
exams = exams.difference(question.exams.all())
|
||||
|
||||
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))
|
||||
# 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 HttpResponse("Invalid form", status=400)
|
||||
|
||||
# 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)})
|
||||
|
||||
# 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`
|
||||
+241
@@ -1,8 +1,10 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib import messages
|
||||
from .models import (
|
||||
Case,
|
||||
CaseDetail,
|
||||
CaseCollection,
|
||||
NormalCase,
|
||||
Differential,
|
||||
Series,
|
||||
SeriesImage,
|
||||
@@ -14,24 +16,35 @@ from .models import (
|
||||
Structure,
|
||||
PathologicalProcess,
|
||||
Presentation,
|
||||
Procedure,
|
||||
UncategorisedDicom,
|
||||
SeriesDetail,
|
||||
Resource,
|
||||
CaseDisplaySet,
|
||||
UserReportAnswer,
|
||||
CidReportAnswer,
|
||||
APIToken,
|
||||
)
|
||||
|
||||
from django.forms import ModelForm
|
||||
|
||||
from reversion.admin import VersionAdmin
|
||||
from django.forms.widgets import RadioSelect
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from tinymce.widgets import TinyMCE
|
||||
|
||||
# from generic.models import Examination, Sign, Site, Condition
|
||||
from generic.models import Examination
|
||||
|
||||
try:
|
||||
from django_tasks_db.models import DBTaskResult
|
||||
except ImportError:
|
||||
DBTaskResult = None
|
||||
|
||||
# Register your models here.
|
||||
# admin.site.register(Examination)
|
||||
# admin.site.register(Site)
|
||||
@@ -50,6 +63,7 @@ admin.site.register(UncategorisedDicom)
|
||||
admin.site.register(SeriesDetail)
|
||||
admin.site.register(Resource)
|
||||
admin.site.register(CaseDisplaySet)
|
||||
admin.site.register(Procedure)
|
||||
admin.site.register(UserReportAnswer)
|
||||
admin.site.register(CidReportAnswer)
|
||||
|
||||
@@ -79,6 +93,22 @@ class CaseCollectionAdmin(VersionAdmin):
|
||||
|
||||
admin.site.register(CaseCollection, CaseCollectionAdmin)
|
||||
|
||||
|
||||
class NormalCaseAdmin(VersionAdmin):
|
||||
list_display = ("case", "display_age", "examination", "modality", "added_date")
|
||||
list_filter = ("examination", "modality")
|
||||
search_fields = ("case__title", "case__pk")
|
||||
|
||||
def display_age(self, obj):
|
||||
try:
|
||||
return obj.display_age()
|
||||
except Exception:
|
||||
return ""
|
||||
display_age.short_description = "Age"
|
||||
|
||||
|
||||
admin.site.register(NormalCase, NormalCaseAdmin)
|
||||
|
||||
class AtlasAdminForm(ModelForm):
|
||||
class Meta:
|
||||
model = Case
|
||||
@@ -123,3 +153,214 @@ class SeriesAdmin(VersionAdmin):
|
||||
|
||||
|
||||
admin.site.register(Series, SeriesAdmin)
|
||||
|
||||
|
||||
class APITokenAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "name", "revoked", "created", "expires", "last_used")
|
||||
search_fields = ("user__username", "name")
|
||||
readonly_fields = ("token_hash", "created", "last_used")
|
||||
|
||||
|
||||
admin.site.register(APIToken, APITokenAdmin)
|
||||
|
||||
|
||||
if DBTaskResult is not None:
|
||||
try:
|
||||
# Ensure package admin registration has run before we customize it.
|
||||
import django_tasks_db.admin # noqa: F401
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def traceback_link(self, obj):
|
||||
if not obj.traceback:
|
||||
return "-"
|
||||
url = reverse(
|
||||
f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change",
|
||||
args=[obj.pk],
|
||||
)
|
||||
return format_html('<a href="{}">View full traceback</a>', url)
|
||||
|
||||
traceback_link.short_description = "Traceback"
|
||||
|
||||
def traceback_pretty(self, obj):
|
||||
if not obj.traceback:
|
||||
return ""
|
||||
return format_html(
|
||||
'<pre style="white-space: pre-wrap; margin: 0;">{}</pre>',
|
||||
obj.traceback,
|
||||
)
|
||||
|
||||
traceback_pretty.short_description = "Traceback"
|
||||
|
||||
@admin.action(description="Reset FAILED tasks to READY")
|
||||
def reset_failed_to_ready(self, request, queryset):
|
||||
failed_qs = queryset.filter(status="FAILED")
|
||||
updated = failed_qs.update(
|
||||
status="READY",
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
worker_ids="",
|
||||
exception_class_path="",
|
||||
traceback="",
|
||||
return_value=None,
|
||||
)
|
||||
self.message_user(
|
||||
request,
|
||||
f"Reset {updated} failed task(s) to READY.",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
|
||||
@admin.action(description="Reset selected tasks to READY (except RUNNING)")
|
||||
def reset_selected_to_ready(self, request, queryset):
|
||||
blocked = queryset.filter(status="RUNNING").count()
|
||||
reset_qs = queryset.exclude(status="RUNNING")
|
||||
updated = reset_qs.update(
|
||||
status="READY",
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
worker_ids="",
|
||||
exception_class_path="",
|
||||
traceback="",
|
||||
return_value=None,
|
||||
)
|
||||
msg = f"Reset {updated} task(s) to READY."
|
||||
if blocked:
|
||||
msg += f" Skipped {blocked} running task(s)."
|
||||
self.message_user(request, msg, level=messages.SUCCESS)
|
||||
|
||||
@admin.action(description="Reset stale RUNNING tasks to READY (>30 min)")
|
||||
def reset_stale_running_to_ready(self, request, queryset):
|
||||
cutoff = timezone.now() - timedelta(minutes=30)
|
||||
updated = queryset.filter(status="RUNNING", started_at__lt=cutoff).update(
|
||||
status="READY",
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
worker_ids="",
|
||||
exception_class_path="",
|
||||
traceback="",
|
||||
return_value=None,
|
||||
)
|
||||
self.message_user(
|
||||
request,
|
||||
f"Reset {updated} stale RUNNING task(s) to READY (older than 30 minutes).",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
|
||||
@admin.action(description="Delete selected COMPLETED tasks")
|
||||
def delete_selected_completed(self, request, queryset):
|
||||
completed_qs = queryset.filter(status="SUCCESSFUL")
|
||||
deleted, _ = completed_qs.delete()
|
||||
self.message_user(
|
||||
request,
|
||||
f"Deleted {deleted} completed task record(s).",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
|
||||
@admin.action(description="Delete selected FAILED tasks")
|
||||
def delete_selected_failed(self, request, queryset):
|
||||
failed_qs = queryset.filter(status="FAILED")
|
||||
deleted, _ = failed_qs.delete()
|
||||
self.message_user(
|
||||
request,
|
||||
f"Deleted {deleted} failed task record(s).",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
task_admin = admin.site._registry.get(DBTaskResult)
|
||||
if task_admin is not None:
|
||||
admin_cls = task_admin.__class__
|
||||
admin_cls.list_display = (
|
||||
"id",
|
||||
"task_path",
|
||||
"status",
|
||||
"queue_name",
|
||||
"priority",
|
||||
"enqueued_at",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"worker_ids",
|
||||
"exception_class_path",
|
||||
"traceback_link",
|
||||
)
|
||||
admin_cls.list_filter = ("status", "queue_name", "task_path", "backend_name")
|
||||
admin_cls.search_fields = (
|
||||
"id",
|
||||
"task_path",
|
||||
"queue_name",
|
||||
"worker_ids",
|
||||
"exception_class_path",
|
||||
"traceback",
|
||||
)
|
||||
admin_cls.ordering = ("-enqueued_at",)
|
||||
admin_cls.actions = (
|
||||
"reset_failed_to_ready",
|
||||
"reset_selected_to_ready",
|
||||
"reset_stale_running_to_ready",
|
||||
"delete_selected_completed",
|
||||
"delete_selected_failed",
|
||||
)
|
||||
admin_cls.readonly_fields = (
|
||||
"id",
|
||||
"status",
|
||||
"enqueued_at",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"args_kwargs",
|
||||
"priority",
|
||||
"task_path",
|
||||
"worker_ids",
|
||||
"queue_name",
|
||||
"backend_name",
|
||||
"run_after",
|
||||
"return_value",
|
||||
"exception_class_path",
|
||||
"traceback_pretty",
|
||||
)
|
||||
admin_cls.fieldsets = (
|
||||
(
|
||||
"Task",
|
||||
{
|
||||
"fields": (
|
||||
"id",
|
||||
"task_path",
|
||||
"status",
|
||||
"queue_name",
|
||||
"priority",
|
||||
"backend_name",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Timing",
|
||||
{
|
||||
"fields": (
|
||||
"enqueued_at",
|
||||
"run_after",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"worker_ids",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Payload",
|
||||
{"fields": ("args_kwargs", "return_value")},
|
||||
),
|
||||
(
|
||||
"Error",
|
||||
{
|
||||
"fields": ("exception_class_path", "traceback_pretty"),
|
||||
},
|
||||
),
|
||||
)
|
||||
admin_cls.traceback_link = traceback_link
|
||||
admin_cls.traceback_pretty = traceback_pretty
|
||||
admin_cls.reset_failed_to_ready = reset_failed_to_ready
|
||||
admin_cls.reset_selected_to_ready = reset_selected_to_ready
|
||||
admin_cls.reset_stale_running_to_ready = reset_stale_running_to_ready
|
||||
admin_cls.delete_selected_completed = delete_selected_completed
|
||||
admin_cls.delete_selected_failed = delete_selected_failed
|
||||
admin_cls.has_add_permission = has_add_permission
|
||||
|
||||
+387
-78
@@ -2,6 +2,8 @@ from collections import defaultdict
|
||||
from typing import List, Tuple
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
import json
|
||||
from django.http import JsonResponse, HttpResponse
|
||||
from ninja import ModelSchema, Router, Schema, Field
|
||||
|
||||
from django.db import transaction, IntegrityError
|
||||
@@ -14,14 +16,22 @@ from pydicom.errors import InvalidDicomError
|
||||
from generic.decorators import check_user_in_group
|
||||
from generic.constants import Group
|
||||
|
||||
from ninja.security import django_auth
|
||||
from ninja.security import django_auth, HttpBearer
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from ninja import NinjaAPI, File
|
||||
from ninja.files import UploadedFile
|
||||
import hashlib
|
||||
import secrets
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth import authenticate
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.middleware.csrf import CsrfViewMiddleware
|
||||
|
||||
from generic.models import Examination, Modality
|
||||
|
||||
from .models import Case, DuplicateDicom, Series, SeriesImage, UncategorisedDicom
|
||||
from .models import Case, DuplicateDicom, Series, SeriesImage, UncategorisedDicom, APIToken
|
||||
from atlas.helpers import get_cases_available_to_user
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -29,12 +39,78 @@ from loguru import logger
|
||||
router = Router()
|
||||
|
||||
|
||||
class BearerAuth(HttpBearer):
|
||||
"""Ninja HttpBearer auth that validates APIToken entries.
|
||||
|
||||
Returns the token owner user on success or raises HttpError(401) on failure.
|
||||
"""
|
||||
def authenticate(self, request, token: str):
|
||||
if not token:
|
||||
raise HttpError(status_code=401)
|
||||
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
try:
|
||||
api_token = APIToken.objects.get(token_hash=token_hash)
|
||||
except APIToken.DoesNotExist:
|
||||
raise HttpError(status_code=401)
|
||||
|
||||
if api_token.revoked:
|
||||
raise HttpError(status_code=401)
|
||||
|
||||
if api_token.expires and api_token.expires < timezone.now():
|
||||
raise HttpError(status_code=401)
|
||||
|
||||
try:
|
||||
api_token.mark_used()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Attach for downstream handlers
|
||||
request.api_token = api_token
|
||||
# Also set `request.user` so code that expects Django's user works
|
||||
try:
|
||||
request.user = api_token.user
|
||||
# Some middleware caches the user on `_cached_user`
|
||||
setattr(request, "_cached_user", api_token.user)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return api_token.user
|
||||
|
||||
|
||||
class SessionOrBearer:
|
||||
"""Composite auth: prefer Django session (request.user), fall back to bearer token.
|
||||
|
||||
Use as `auth=SessionOrBearer()` on Ninja router endpoints.
|
||||
"""
|
||||
def __call__(self, request):
|
||||
# Session-backed auth already populated by Django middleware
|
||||
if getattr(request, "user", None) and getattr(request.user, "is_authenticated", False):
|
||||
return request.user
|
||||
|
||||
# Try bearer token from header
|
||||
auth = request.META.get("HTTP_AUTHORIZATION")
|
||||
if not auth:
|
||||
raise HttpError(status_code=401)
|
||||
parts = auth.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HttpError(status_code=401)
|
||||
token = parts[1]
|
||||
# reuse BearerAuth logic
|
||||
bearer = BearerAuth()
|
||||
return bearer.authenticate(request, token)
|
||||
|
||||
|
||||
# Backwards-compatible alias used in existing decorators
|
||||
TokenAuth = SessionOrBearer
|
||||
|
||||
|
||||
class SeriesSchema(ModelSchema):
|
||||
case_id: List[int] = []
|
||||
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Series
|
||||
model_fields = [
|
||||
fields = [
|
||||
"id",
|
||||
"modality",
|
||||
"examination",
|
||||
@@ -48,12 +124,12 @@ class SeriesSchema(ModelSchema):
|
||||
|
||||
|
||||
class CaseSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Case
|
||||
model_fields = ["id", "title"]
|
||||
fields = ["id", "title"]
|
||||
|
||||
|
||||
@router.post("/upload_dicom", auth=django_auth)
|
||||
@router.post("/upload_dicom", auth=TokenAuth())
|
||||
def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
||||
uploaded = []
|
||||
duplicate = []
|
||||
@@ -70,11 +146,8 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
||||
except DuplicateDicom as e:
|
||||
duplicate.append((file.name, ud.image_blake3_hash))
|
||||
|
||||
|
||||
if type(e.duplicate) == SeriesImage:
|
||||
if isinstance(e.duplicate, SeriesImage) and e.duplicate.series is not None:
|
||||
duplicate_series.add(e.duplicate.series.get_absolute_url())
|
||||
|
||||
pass
|
||||
except InvalidDicomError:
|
||||
failed.append(file.name)
|
||||
|
||||
@@ -86,7 +159,7 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/generate_image_hash", auth=django_auth)
|
||||
@router.post("/generate_image_hash", auth=TokenAuth())
|
||||
def generate_image_hash(request, id: int):
|
||||
s = SeriesImage.objects.get(pk=id)
|
||||
s.generate_hashes()
|
||||
@@ -98,7 +171,7 @@ def generate_image_hash(request, id: int):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/clear_dicoms", auth=django_auth)
|
||||
@router.post("/clear_dicoms", auth=TokenAuth())
|
||||
def clear_dicoms(request):
|
||||
if "selection" in request.POST:
|
||||
dicoms = UncategorisedDicom.objects.filter(
|
||||
@@ -113,7 +186,7 @@ def clear_dicoms(request):
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/uncategorised_dicoms", auth=django_auth)
|
||||
@router.get("/uncategorised_dicoms", auth=TokenAuth())
|
||||
def uncategorised_dicoms(request):
|
||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||
|
||||
@@ -127,13 +200,15 @@ def uncategorised_dicoms(request):
|
||||
return data
|
||||
|
||||
|
||||
def import_dicoms_helper(request, case_id: int | None = None):
|
||||
if "selection" in request.POST:
|
||||
dicoms = UncategorisedDicom.objects.filter(
|
||||
series_instance_uid__in=request.POST.getlist("selection")
|
||||
)
|
||||
else:
|
||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||
def import_dicoms_helper(request, case_id: int | None = None, dicoms=None):
|
||||
# Allow callers to pass an explicit queryset of UncategorisedDicom objects
|
||||
if dicoms is None:
|
||||
if "selection" in request.POST:
|
||||
dicoms = UncategorisedDicom.objects.filter(
|
||||
series_instance_uid__in=request.POST.getlist("selection")
|
||||
)
|
||||
else:
|
||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||
|
||||
if "order-series" in request.POST:
|
||||
order = request.POST["order-series"]
|
||||
@@ -145,6 +220,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
for d in dicoms.iterator(): # Use iterator() to avoid loading all at once
|
||||
tags = d.basic_dicom_tags
|
||||
series_uid = tags["SeriesInstanceUID"]
|
||||
study_uid = tags.get("StudyInstanceUID")
|
||||
|
||||
if series_uid not in series_map:
|
||||
# Get or create Series and related objects as needed
|
||||
@@ -161,6 +237,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
defaults={
|
||||
"modality": modality,
|
||||
"description": tags.get("SeriesDescription", ""),
|
||||
"study_instance_uid": study_uid,
|
||||
}
|
||||
)
|
||||
if created and tags.get("StudyDescription"):
|
||||
@@ -175,7 +252,12 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
except IntegrityError:
|
||||
series = Series.objects.get(series_instance_uid=series_uid)
|
||||
|
||||
if study_uid and not series.study_instance_uid:
|
||||
series.study_instance_uid = study_uid
|
||||
series.save(update_fields=["study_instance_uid"])
|
||||
|
||||
# Add author and case if needed
|
||||
# Attach author to the series
|
||||
series.author.add(request.user)
|
||||
if case_id is not None:
|
||||
case_object = get_object_or_404(Case, pk=case_id)
|
||||
@@ -212,7 +294,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
|
||||
|
||||
@router.post(
|
||||
"/import_dicoms", auth=django_auth, response=List[Tuple[SeriesSchema, str]]
|
||||
"/import_dicoms", auth=TokenAuth(), response=List[Tuple[SeriesSchema, str]]
|
||||
)
|
||||
def import_dicoms(request):
|
||||
return import_dicoms_helper(request)
|
||||
@@ -220,19 +302,73 @@ def import_dicoms(request):
|
||||
|
||||
@router.post(
|
||||
"/import_dicoms/{case_id}",
|
||||
auth=django_auth,
|
||||
auth=TokenAuth(),
|
||||
response=List[Tuple[SeriesSchema, str]],
|
||||
)
|
||||
def import_dicoms_case(request, case_id: int):
|
||||
return import_dicoms_helper(request, case_id=case_id)
|
||||
|
||||
|
||||
@router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema])
|
||||
@router.post("/upload_dicom_case/{case_id}", auth=TokenAuth())
|
||||
def upload_dicom_case(request, case_id: int, files: List[UploadedFile] = File(...)):
|
||||
"""Upload DICOM files and immediately import them into the given case.
|
||||
|
||||
This saves each uploaded file as an `UncategorisedDicom` owned by the user,
|
||||
then calls the existing import helper which will create Series/SeriesImage
|
||||
objects and attach them to the specified case.
|
||||
"""
|
||||
uploaded = []
|
||||
duplicate = []
|
||||
failed = []
|
||||
duplicate_series = set()
|
||||
|
||||
created_ids = []
|
||||
|
||||
for file in files:
|
||||
try:
|
||||
ud = UncategorisedDicom(image=file, user=request.user)
|
||||
ud.save()
|
||||
|
||||
uploaded.append((file.name, ud.image_blake3_hash))
|
||||
created_ids.append(ud.pk)
|
||||
except DuplicateDicom as e:
|
||||
duplicate.append((file.name, getattr(ud, "image_blake3_hash", None)))
|
||||
|
||||
dup_obj = getattr(e, "duplicate", None)
|
||||
if isinstance(dup_obj, SeriesImage):
|
||||
try:
|
||||
if dup_obj.series is not None:
|
||||
duplicate_series.add(dup_obj.series.get_absolute_url())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
continue
|
||||
except InvalidDicomError:
|
||||
failed.append(file.name)
|
||||
|
||||
# Import into the target case (consume only the uploaded UncategorisedDicom objects)
|
||||
if created_ids:
|
||||
try:
|
||||
dicom_qs = UncategorisedDicom.objects.filter(pk__in=created_ids)
|
||||
import_dicoms_helper(request, case_id=case_id, dicoms=dicom_qs)
|
||||
except Exception:
|
||||
# don't let import errors prevent returning upload status
|
||||
logger.error("Error importing dicoms after upload", exc_info=True)
|
||||
|
||||
return {
|
||||
"uploaded": uploaded,
|
||||
"duplicates": duplicate,
|
||||
"failed": failed,
|
||||
"duplicate_series": list(duplicate_series),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/orphan_series", auth=TokenAuth(), response=List[SeriesSchema])
|
||||
def orphan_series(request):
|
||||
return request.user.series.filter(case=None)
|
||||
|
||||
|
||||
@router.get("/series_remove_duplicate_images", auth=django_auth)
|
||||
@router.get("/series_remove_duplicate_images", auth=TokenAuth())
|
||||
def series_remove_duplicate_images(request, series_id: int):
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
|
||||
@@ -249,34 +385,188 @@ def series_remove_duplicate_images(request, series_id: int):
|
||||
return len(dupes)
|
||||
|
||||
|
||||
@router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth)
|
||||
def series_truncate(request, series_id: int, start: int, end: int):
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
|
||||
if not series.check_user_can_edit(request.user):
|
||||
return {"status": "permission denied"}
|
||||
|
||||
images_removed = []
|
||||
for n, image in enumerate(series.get_images()):
|
||||
if n >= start and n <= end:
|
||||
continue
|
||||
else:
|
||||
image.removed = True
|
||||
image.image = None
|
||||
image.save()
|
||||
images_removed.append(image.pk)
|
||||
|
||||
series.get_total_image_size(refresh=True)
|
||||
|
||||
return {"status": "success", "images_removed": images_removed}
|
||||
|
||||
|
||||
@router.get("/get_cases_user", auth=django_auth, response=List[CaseSchema])
|
||||
@router.get("/get_cases_user", auth=TokenAuth(), response=List[CaseSchema])
|
||||
def get_cases_user(request):
|
||||
return Case.objects.filter(author=request.user)
|
||||
|
||||
|
||||
@router.get("/check_image_hash/{hash}", auth=django_auth)
|
||||
@router.get("/get_cases_available", auth=TokenAuth(), response=List[CaseSchema])
|
||||
def get_cases_available(request):
|
||||
"""Return cases available to the authenticated user (via get_cases_available_to_user)."""
|
||||
logger.error(f"Getting cases available to user {request.user}")
|
||||
qs = get_cases_available_to_user(request.user)
|
||||
return qs.order_by('-created_date')[:200]
|
||||
|
||||
|
||||
class APITokenOut(Schema):
|
||||
id: int
|
||||
name: str
|
||||
scopes: str = ""
|
||||
created: str
|
||||
expires: str | None = None
|
||||
revoked: bool = False
|
||||
last_used: str | None = None
|
||||
|
||||
|
||||
@router.get("/api_tokens", auth=TokenAuth(), response=List[APITokenOut])
|
||||
def list_api_tokens(request):
|
||||
tokens = APIToken.objects.filter(user=request.user).order_by("-created")
|
||||
out = []
|
||||
for t in tokens:
|
||||
out.append(
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"scopes": t.scopes,
|
||||
"created": t.created.isoformat(),
|
||||
"expires": t.expires.isoformat() if t.expires else None,
|
||||
"revoked": t.revoked,
|
||||
"last_used": t.last_used.isoformat() if t.last_used else None,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class CreateTokenIn(Schema):
|
||||
name: str = Field("", description="Friendly name for token")
|
||||
scopes: str = Field("", description="Space-separated scopes")
|
||||
expires_days: int | None = Field(None, description="Expire after N days")
|
||||
|
||||
|
||||
class TokenAuthIn(Schema):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@router.post("/create_api_token")
|
||||
def token_auth(request, payload: TokenAuthIn):
|
||||
"""Authenticate username/password and return a new API token.
|
||||
|
||||
Returns JSON: {"token": "<raw-token>", "id": <token_id>}
|
||||
"""
|
||||
user = authenticate(request, username=payload.username, password=payload.password)
|
||||
if user is None:
|
||||
return HttpResponse(status=401, content=json.dumps({"detail": "Invalid credentials"}), content_type="application/json")
|
||||
|
||||
# Create a new token for this login (no scopes by default)
|
||||
token, obj = APIToken.create_token(user, name=f"login-{timezone.now().isoformat()}")
|
||||
|
||||
return {"token": token, "id": obj.id, "username": user.username}
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@router.post("/token_check")
|
||||
def token_check(request):
|
||||
"""Check whether a token is valid.
|
||||
|
||||
Accepts either `Authorization: Bearer <token>` header or POST JSON
|
||||
body `{ "token": "<token>" }`.
|
||||
|
||||
Returns JSON with keys: `valid` (bool), and when valid: `id`, `username`,
|
||||
`scopes`, `expires`, `revoked`.
|
||||
"""
|
||||
auth = request.META.get("HTTP_AUTHORIZATION")
|
||||
token_obj = None
|
||||
|
||||
# If header present, validate bearer token explicitly
|
||||
if auth:
|
||||
parts = auth.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return JsonResponse({"valid": False}, status=401)
|
||||
token = parts[1]
|
||||
try:
|
||||
bearer = BearerAuth()
|
||||
user = bearer.authenticate(request, token)
|
||||
token_obj = getattr(request, "api_token", None)
|
||||
except HttpError:
|
||||
return JsonResponse({"valid": False}, status=401)
|
||||
else:
|
||||
# try JSON body
|
||||
try:
|
||||
data = json.loads(request.body.decode("utf-8")) if request.body else {}
|
||||
except Exception:
|
||||
data = {}
|
||||
token = data.get("token")
|
||||
if token:
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
try:
|
||||
t = APIToken.objects.get(token_hash=token_hash)
|
||||
token_obj = t
|
||||
except APIToken.DoesNotExist:
|
||||
token_obj = None
|
||||
|
||||
if not token_obj:
|
||||
return JsonResponse({"valid": False}, status=401)
|
||||
|
||||
# Validate token state
|
||||
valid = True
|
||||
if token_obj.revoked:
|
||||
valid = False
|
||||
if token_obj.expires and token_obj.expires < timezone.now():
|
||||
valid = False
|
||||
|
||||
if not valid:
|
||||
return JsonResponse({"valid": False, "revoked": token_obj.revoked}, status=401)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"valid": True,
|
||||
"id": token_obj.id,
|
||||
"username": token_obj.user.username,
|
||||
"scopes": token_obj.scopes,
|
||||
"expires": token_obj.expires.isoformat() if token_obj.expires else None,
|
||||
"revoked": token_obj.revoked,
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
|
||||
#@csrf_exempt
|
||||
#@router.post("/create_api_token")
|
||||
#def create_api_token(request, payload: CreateTokenIn):
|
||||
# """Create an API token.
|
||||
#
|
||||
# This endpoint accepts either normal session auth (`request.user`) or
|
||||
# a bearer token. If no authenticated user is found a 403 is returned.
|
||||
# """
|
||||
# # If caller is not using Authorization header, enforce CSRF via middleware
|
||||
# if not request.META.get("HTTP_AUTHORIZATION"):
|
||||
# mw = CsrfViewMiddleware()
|
||||
# resp = mw.process_view(request, create_api_token, (), {})
|
||||
# if resp is not None:
|
||||
# return resp
|
||||
# # Determine authenticated user: prefer session auth, fall back to token auth
|
||||
# user = None
|
||||
# if getattr(request, "user", None) and getattr(request.user, "is_authenticated", False):
|
||||
# user = request.user
|
||||
# else:
|
||||
# user = TokenAuth()(request)
|
||||
#
|
||||
# if not user:
|
||||
# raise PermissionDenied()
|
||||
#
|
||||
# expires = None
|
||||
# if payload.expires_days:
|
||||
# expires = timezone.now() + timezone.timedelta(days=payload.expires_days)
|
||||
#
|
||||
# token, obj = APIToken.create_token(user, name=payload.name, scopes=payload.scopes, expires=expires)
|
||||
# # Return raw token once — callers must store it
|
||||
# return {"token": token, "id": obj.id}
|
||||
|
||||
|
||||
@router.post("/api_tokens/{token_id}/revoke", auth=TokenAuth())
|
||||
def revoke_api_token(request, token_id: int):
|
||||
try:
|
||||
t = APIToken.objects.get(pk=token_id, user=request.user)
|
||||
except APIToken.DoesNotExist:
|
||||
return {"status": "not found"}
|
||||
t.revoked = True
|
||||
t.save(update_fields=["revoked"])
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
@router.get("/check_image_hash/{hash}", auth=TokenAuth())
|
||||
def check_image_hash(request, hash: str):
|
||||
try:
|
||||
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
|
||||
@@ -291,41 +581,61 @@ def check_image_hash(request, hash: str):
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/check_image_hashes/", auth=django_auth)
|
||||
@router.post("/check_image_hashes/", auth=TokenAuth())
|
||||
def check_images_hashes(request, hashes: List[str]):
|
||||
"""Checks a list of image hashes and returns the series id / url if found
|
||||
"""Checks a list of image hashes and returns the series id / url if found.
|
||||
|
||||
Uses two bulk queries (one for SeriesImage, one for UncategorisedDicom) to
|
||||
handle large batches efficiently regardless of batch size.
|
||||
|
||||
Return format
|
||||
{ "hash_id": {"id": "series_id|false", "url": "series_url|false"}, ...}
|
||||
"""
|
||||
|
||||
hash_status = {}
|
||||
try:
|
||||
hash_status: dict = {h: {"id": False, "url": False} for h in hashes}
|
||||
|
||||
for hash in hashes:
|
||||
try:
|
||||
# TOOD also check against uncategorised dicoms?
|
||||
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
|
||||
data = {
|
||||
"id": series_image.pk,
|
||||
"url": series_image.series.get_absolute_url(),
|
||||
"type": "series",
|
||||
}
|
||||
except SeriesImage.DoesNotExist:
|
||||
# Single bulk query for all SeriesImage matches
|
||||
series_images = (
|
||||
SeriesImage.objects.filter(image_blake3_hash__in=hashes)
|
||||
.select_related("series")
|
||||
.only("pk", "image_blake3_hash", "series__id")
|
||||
)
|
||||
matched_hashes: set = set()
|
||||
for si in series_images:
|
||||
if si.image_blake3_hash in matched_hashes:
|
||||
continue # keep first match per hash
|
||||
try:
|
||||
uncategorised_dicom = UncategorisedDicom.objects.get(
|
||||
image_blake3_hash=hash
|
||||
)
|
||||
data = {
|
||||
"id": uncategorised_dicom.pk,
|
||||
"url": reverse("atlas:user_uploads"),
|
||||
"type": "uncategorised",
|
||||
}
|
||||
except UncategorisedDicom.DoesNotExist:
|
||||
data = {"id": False, "url": False}
|
||||
url = si.series.get_absolute_url()
|
||||
except Exception:
|
||||
url = None
|
||||
hash_status[si.image_blake3_hash] = {"id": si.pk, "url": url, "type": "series"}
|
||||
matched_hashes.add(si.image_blake3_hash)
|
||||
|
||||
hash_status[hash] = data
|
||||
# Single bulk query for remaining hashes in UncategorisedDicom
|
||||
remaining = [h for h in hashes if h not in matched_hashes]
|
||||
if remaining:
|
||||
try:
|
||||
uploads_url = reverse("atlas:user_uploads")
|
||||
except Exception:
|
||||
uploads_url = None
|
||||
uncategorised_qs = (
|
||||
UncategorisedDicom.objects.filter(image_blake3_hash__in=remaining)
|
||||
.only("pk", "image_blake3_hash")
|
||||
)
|
||||
for uc in uncategorised_qs:
|
||||
if uc.image_blake3_hash not in matched_hashes:
|
||||
hash_status[uc.image_blake3_hash] = {
|
||||
"id": uc.pk,
|
||||
"url": uploads_url,
|
||||
"type": "uncategorised",
|
||||
}
|
||||
matched_hashes.add(uc.image_blake3_hash)
|
||||
|
||||
return hash_status
|
||||
return hash_status
|
||||
except Exception:
|
||||
logger.exception("check_images_hashes failed")
|
||||
return JsonResponse({"detail": "Internal server error"}, status=500)
|
||||
|
||||
|
||||
# @router.get("/generate_image_hash/{id}", auth=django_auth)
|
||||
@@ -339,16 +649,15 @@ def check_images_hashes(request, hashes: List[str]):
|
||||
# print(series_image)
|
||||
|
||||
|
||||
@router.get("/view_dicom_tags/{hash}", auth=django_auth)
|
||||
@router.get("/view_dicom_tags/{hash}", auth=TokenAuth())
|
||||
def view_dicom_tags(request, hash: str):
|
||||
item = SeriesImage.objects.get(image_blake3_hash=hash)
|
||||
return item.get_dicom_json()
|
||||
|
||||
|
||||
@router.get("/series_split_by_dicom_tag/{series_id}/{dicom_tag}", auth=django_auth)
|
||||
@router.get("/series_split_by_dicom_tag/{series_id}/{dicom_tag}", auth=TokenAuth())
|
||||
def series_split_by_tag(request, series_id: int, dicom_tag: str):
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
|
||||
if not series.check_user_can_edit(request.user):
|
||||
return {"status": "permission denied"}
|
||||
|
||||
@@ -393,7 +702,7 @@ def series_split_by_tag(request, series_id: int, dicom_tag: str):
|
||||
return new_series
|
||||
|
||||
|
||||
@router.get("/split_order_by_dicom_tag/{series_id}/{dicom_tag}", auth=django_auth)
|
||||
@router.get("/split_order_by_dicom_tag/{series_id}/{dicom_tag}", auth=TokenAuth())
|
||||
def series_order_by_tag(request, series_id: int, dicom_tag: str):
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
|
||||
|
||||
+45
-6
@@ -3,8 +3,17 @@ from .models import Case, CaseCollection, Series
|
||||
|
||||
|
||||
def user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access(function):
|
||||
"""Decorator to check if user is author of the series,
|
||||
an atlas editor, an atlas marker, or if the series is open access.
|
||||
Used for series-level permissions.
|
||||
|
||||
Requires the decorated view to have a "pk" or "series_id" kwarg to identify the series.
|
||||
"""
|
||||
def wrap(request, *args, **kwargs):
|
||||
series = Series.objects.get(pk=kwargs["pk"])
|
||||
if "pk" in kwargs:
|
||||
series = Series.objects.get(pk=kwargs["pk"])
|
||||
elif "series_id" in kwargs:
|
||||
series = Series.objects.get(pk=kwargs["series_id"])
|
||||
if (
|
||||
request.user in series.get_author_objects()
|
||||
or series.open_access
|
||||
@@ -23,7 +32,10 @@ def user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access(functi
|
||||
|
||||
def user_is_author_or_atlas_series_checker(function):
|
||||
def wrap(request, *args, **kwargs):
|
||||
series = Series.objects.get(pk=kwargs["pk"])
|
||||
if "pk" in kwargs:
|
||||
series = Series.objects.get(pk=kwargs["pk"])
|
||||
elif "series_id" in kwargs:
|
||||
series = Series.objects.get(pk=kwargs["series_id"])
|
||||
if (
|
||||
request.user in series.get_author_objects()
|
||||
or request.user.groups.filter(name="atlas_editor").exists()
|
||||
@@ -40,7 +52,10 @@ def user_is_author_or_atlas_series_checker(function):
|
||||
|
||||
def user_has_case_view_access(function):
|
||||
def wrap(request, *args, **kwargs):
|
||||
atlas = Case.objects.get(pk=kwargs["pk"])
|
||||
if "pk" in kwargs:
|
||||
atlas = Case.objects.get(pk=kwargs["pk"])
|
||||
elif "case_id" in kwargs:
|
||||
atlas = Case.objects.get(pk=kwargs["case_id"])
|
||||
|
||||
# If open access everyone can view
|
||||
if atlas.open_access:
|
||||
@@ -62,7 +77,10 @@ def user_has_case_view_access(function):
|
||||
|
||||
def user_is_author_or_atlas_editor(function):
|
||||
def wrap(request, *args, **kwargs):
|
||||
atlas = Case.objects.get(pk=kwargs["pk"])
|
||||
if "pk" in kwargs:
|
||||
atlas = Case.objects.get(pk=kwargs["pk"])
|
||||
elif "case_id" in kwargs:
|
||||
atlas = Case.objects.get(pk=kwargs["case_id"])
|
||||
if (
|
||||
request.user in atlas.author.all()
|
||||
or request.user.groups.filter(name="atlas_editor").exists()
|
||||
@@ -90,8 +108,29 @@ def user_is_collection_author_or_atlas_editor(function):
|
||||
or request.user.is_superuser
|
||||
):
|
||||
return function(request, *args, **kwargs)
|
||||
else:
|
||||
raise PermissionDenied
|
||||
|
||||
# Allow access if request.user is the supervisor of the target user and sharing is enabled
|
||||
user_pk = kwargs.get("user_pk") or kwargs.get("user_id")
|
||||
if user_pk:
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from generic.models import CidUserExam
|
||||
try:
|
||||
target_user = get_user_model().objects.get(pk=user_pk)
|
||||
sup = target_user.userprofile.supervisor
|
||||
if sup and sup.user_id == request.user.id:
|
||||
ct = ContentType.objects.get_for_model(CaseCollection)
|
||||
cid_user_exam = CidUserExam.objects.filter(
|
||||
content_type=ct,
|
||||
object_id=atlas.pk,
|
||||
user_user=target_user
|
||||
).first()
|
||||
if (cid_user_exam and cid_user_exam.share_with_supervisor) or atlas.results_supervisor_visible:
|
||||
return function(request, *args, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise PermissionDenied
|
||||
|
||||
wrap.__doc__ = function.__doc__
|
||||
wrap.__name__ = function.__name__
|
||||
|
||||
+318
-6
@@ -1,4 +1,8 @@
|
||||
import django_filters
|
||||
from django import forms
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Div, Field
|
||||
from collections import OrderedDict
|
||||
|
||||
from .models import (
|
||||
Case,
|
||||
@@ -7,13 +11,18 @@ from .models import (
|
||||
Finding,
|
||||
PathologicalProcess,
|
||||
Presentation,
|
||||
Procedure,
|
||||
QuestionSchema,
|
||||
Series,
|
||||
Structure,
|
||||
Subspecialty,
|
||||
NormalCase,
|
||||
Resource,
|
||||
Site,
|
||||
)
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.http import QueryDict
|
||||
|
||||
|
||||
def get_authors(request):
|
||||
@@ -78,6 +87,22 @@ class CaseCollectionFilter(django_filters.FilterSet):
|
||||
user=None,
|
||||
request=None,
|
||||
):
|
||||
# Default the archive filter to False when not provided so collections
|
||||
# are not archived by default in listings.
|
||||
if data is None:
|
||||
data = QueryDict('', mutable=True)
|
||||
data['archive'] = 'false'
|
||||
else:
|
||||
# QueryDict instances need to be copied to be mutable
|
||||
try:
|
||||
if 'archive' not in data:
|
||||
data = data.copy()
|
||||
data['archive'] = 'false'
|
||||
except Exception:
|
||||
# Fallback for non-QueryDict mappings
|
||||
if isinstance(data, dict) and 'archive' not in data:
|
||||
data = dict(data)
|
||||
data['archive'] = 'false'
|
||||
if not request.user.groups.filter(name="atlas_editor").exists():
|
||||
queryset = queryset.prefetch_related("author").filter(
|
||||
author__id=request.user.id
|
||||
@@ -92,9 +117,22 @@ class CaseFilter(django_filters.FilterSet):
|
||||
author = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=get_authors, null_label="No author"
|
||||
)
|
||||
# Show a distinct list of `Series.examination` values instead of raw Series
|
||||
# entries so examinations that differ only by plane/description appear once.
|
||||
series = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=get_series, null_label="No series"
|
||||
field_name='series__examination',
|
||||
queryset=lambda request: __import__('generic.models', fromlist=['Examination']).Examination.objects.filter(atlas_series_examination__isnull=False).distinct(),
|
||||
label='Examination',
|
||||
)
|
||||
# Free-text search to help find the correct examination when there are
|
||||
# many similarly-named Series/Examination entries.
|
||||
examination_text = django_filters.CharFilter(method='filter_examination_text', label='Examination (text)')
|
||||
# Full list of conditions and a text search to find conditions
|
||||
condition = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Condition.objects.all(),
|
||||
label='Condition',
|
||||
)
|
||||
condition_text = django_filters.CharFilter(method='filter_condition_text', label='Condition (text)')
|
||||
differential_case = django_filters.ModelMultipleChoiceFilter(
|
||||
label="Differential",
|
||||
field_name="differentialcase__condition",
|
||||
@@ -129,6 +167,58 @@ class CaseFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_examination_text(self, queryset, name, value):
|
||||
"""Filter Cases by a text search on related Examination records.
|
||||
|
||||
Matches against the Examination string representation and any
|
||||
common identifying fields (name/code). Uses a case-insensitive
|
||||
contains lookup and filters Cases which have a Series linked to
|
||||
a matching Examination.
|
||||
"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
from generic.models import Examination
|
||||
|
||||
# Only attempt to match PK when the provided value looks like an integer
|
||||
try:
|
||||
pk_val = int(value)
|
||||
except Exception:
|
||||
pk_val = None
|
||||
|
||||
if pk_val is not None:
|
||||
matches = Examination.objects.filter(
|
||||
Q(examination__icontains=value) | Q(pk=pk_val)
|
||||
).distinct()
|
||||
else:
|
||||
matches = Examination.objects.filter(
|
||||
Q(examination__icontains=value)
|
||||
).distinct()
|
||||
|
||||
return queryset.filter(series__examination__in=matches).distinct()
|
||||
|
||||
def filter_condition_text(self, queryset, name, value):
|
||||
"""Filter Cases by a text search on related Condition records.
|
||||
|
||||
Matches both direct `condition` M2M and `differential` entries.
|
||||
"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
# Only try PK match when numeric
|
||||
try:
|
||||
pk_val = int(value)
|
||||
except Exception:
|
||||
pk_val = None
|
||||
|
||||
if pk_val is not None:
|
||||
matches = Condition.objects.filter(Q(name__icontains=value) | Q(pk=pk_val)).distinct()
|
||||
else:
|
||||
matches = Condition.objects.filter(Q(name__icontains=value)).distinct()
|
||||
|
||||
# Match either direct condition M2M or differential (through Differential)
|
||||
return queryset.filter(Q(condition__in=matches) | Q(differential__in=matches)).distinct()
|
||||
|
||||
|
||||
class SeriesFilter(django_filters.FilterSet):
|
||||
case = django_filters.ModelMultipleChoiceFilter(
|
||||
@@ -161,13 +251,16 @@ class SeriesFilter(django_filters.FilterSet):
|
||||
|
||||
|
||||
class ConditionFilter(django_filters.FilterSet):
|
||||
# Replace legacy `primary` filter with `is_canonical` and provide a
|
||||
# `synonym` filter which will filter by the canonical group of the
|
||||
# selected condition.
|
||||
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
||||
synonym = django_filters.ModelChoiceFilter(queryset=Condition.objects.all(), method="filter_synonym", label="Synonym group")
|
||||
|
||||
class Meta:
|
||||
model = Condition
|
||||
fields = {
|
||||
"name": ["icontains"],
|
||||
"primary": ["exact"],
|
||||
"synonym": ["exact"],
|
||||
#"parent": ["exact"],
|
||||
"subspecialty": ["exact"],
|
||||
"rcr_curriculum": ["exact"],
|
||||
}
|
||||
@@ -186,11 +279,32 @@ class ConditionFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_is_canonical(self, queryset, name, value):
|
||||
if value in (True, "True", "true", 1, "1"):
|
||||
return queryset.filter(canonical__isnull=True)
|
||||
if value in (False, "False", "false", 0, "0"):
|
||||
return queryset.filter(canonical__isnull=False)
|
||||
return queryset
|
||||
|
||||
def filter_synonym(self, queryset, name, value):
|
||||
# value is a Condition instance selected in the filter. Return all
|
||||
# Conditions that belong to the same canonical group as `value`.
|
||||
if not value:
|
||||
return queryset
|
||||
master = value.canonical if value.canonical else value
|
||||
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
||||
|
||||
|
||||
class FindingFilter(django_filters.FilterSet):
|
||||
# Replace legacy `primary`/`synonym` with canonical-based filters
|
||||
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
||||
synonym = django_filters.ModelChoiceFilter(queryset=Finding.objects.all(), method="filter_synonym", label="Synonym group")
|
||||
|
||||
class Meta:
|
||||
model = Finding
|
||||
fields = ("name", "primary", "synonym")
|
||||
fields = {
|
||||
"name": ["icontains"],
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -206,11 +320,29 @@ class FindingFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_is_canonical(self, queryset, name, value):
|
||||
if value in (True, "True", "true", 1, "1"):
|
||||
return queryset.filter(canonical__isnull=True)
|
||||
if value in (False, "False", "false", 0, "0"):
|
||||
return queryset.filter(canonical__isnull=False)
|
||||
return queryset
|
||||
|
||||
def filter_synonym(self, queryset, name, value):
|
||||
# value is a Finding instance selected in the filter. Return all
|
||||
# Findings that belong to the same canonical group as `value`.
|
||||
if not value:
|
||||
return queryset
|
||||
master = value.canonical if value.canonical else value
|
||||
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
||||
|
||||
|
||||
class StructureFilter(django_filters.FilterSet):
|
||||
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
||||
synonym = django_filters.ModelChoiceFilter(queryset=Structure.objects.all(), method="filter_synonym", label="Synonym group")
|
||||
|
||||
class Meta:
|
||||
model = Structure
|
||||
fields = {"name": ["icontains"], "primary": ["exact"], "synonym": ["exact"]}
|
||||
fields = {"name": ["icontains"]}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -226,6 +358,19 @@ class StructureFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_is_canonical(self, queryset, name, value):
|
||||
if value in (True, "True", "true", 1, "1"):
|
||||
return queryset.filter(canonical__isnull=True)
|
||||
if value in (False, "False", "false", 0, "0"):
|
||||
return queryset.filter(canonical__isnull=False)
|
||||
return queryset
|
||||
|
||||
def filter_synonym(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
master = value.canonical if value.canonical else value
|
||||
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
||||
|
||||
|
||||
class PresentationFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
@@ -286,6 +431,173 @@ class SubspecialtyFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
|
||||
class NormalCaseFilter(django_filters.FilterSet):
|
||||
"""FilterSet for the NormalCase model allowing filtering by
|
||||
examination, modality and nearest-year age ranges.
|
||||
"""
|
||||
examination = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
||||
modality = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
||||
procedures = django_filters.ModelMultipleChoiceFilter(field_name="case__procedures", queryset=Procedure.objects.all(), label="Procedure")
|
||||
# Accept min/max as years in the UI but filter against the canonical
|
||||
# `age_days` field by converting years -> days.
|
||||
# Numeric min/max values entered by the user (interpreted via `age_unit`)
|
||||
min_value = django_filters.NumberFilter(label="Min", method="filter_min_value")
|
||||
max_value = django_filters.NumberFilter(label="Max", method="filter_max_value")
|
||||
AGE_UNIT_CHOICES = (
|
||||
("days", "Days"),
|
||||
("weeks", "Weeks"),
|
||||
("months", "Months"),
|
||||
("years", "Years"),
|
||||
)
|
||||
age_unit = django_filters.ChoiceFilter(label="Unit", choices=AGE_UNIT_CHOICES, method="filter_age_unit")
|
||||
|
||||
class Meta:
|
||||
model = NormalCase
|
||||
fields = ("examination", "modality", "procedures", "age_days")
|
||||
|
||||
def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None):
|
||||
# Use the global Examination and Modality querysets from the Series model
|
||||
from generic.models import Examination, Modality
|
||||
|
||||
# We'll set the actual querysets for the filters after applying
|
||||
# any NormalCase queryset restrictions for the current user so
|
||||
# that the choices only include examinations/modalities that
|
||||
# actually have NormalCase records available.
|
||||
|
||||
# If not an atlas editor restrict to cases authored by the user or open access cases
|
||||
if request is not None and not request.user.groups.filter(name="atlas_editor").exists():
|
||||
from django.db.models import Q
|
||||
|
||||
if queryset is None:
|
||||
queryset = NormalCase.objects.select_related("case")
|
||||
queryset = queryset.filter((Q(case__author__id=request.user.id) | Q(case__open_access=True)))
|
||||
|
||||
# Determine the NormalCase queryset we'll use to build choices
|
||||
nc_qs = queryset if queryset is not None else NormalCase.objects.all()
|
||||
|
||||
# Limit examination and modality choices to those present on the
|
||||
# available NormalCase records (respecting user restrictions).
|
||||
try:
|
||||
exam_ids = nc_qs.values_list("examination_id", flat=True)
|
||||
mod_ids = nc_qs.values_list("modality_id", flat=True)
|
||||
proc_ids = nc_qs.values_list("case__procedures", flat=True)
|
||||
self.base_filters.get("examination").queryset = Examination.objects.filter(id__in=exam_ids).distinct()
|
||||
self.base_filters.get("modality").queryset = Modality.objects.filter(id__in=mod_ids).distinct()
|
||||
self.base_filters.get("procedures").queryset = Procedure.objects.filter(id__in=proc_ids).distinct()
|
||||
except Exception:
|
||||
# Fallback to all if something goes wrong
|
||||
self.base_filters.get("examination").queryset = Examination.objects.all()
|
||||
self.base_filters.get("modality").queryset = Modality.objects.all()
|
||||
|
||||
super(NormalCaseFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
|
||||
|
||||
# Attach a Crispy FormHelper to render the filter form as a
|
||||
# horizontal Bootstrap grid. We set `form_tag=False` because the
|
||||
# template already provides the <form> element.
|
||||
try:
|
||||
helper = FormHelper()
|
||||
helper.form_method = "get"
|
||||
helper.form_tag = False
|
||||
helper.form_class = "row g-2"
|
||||
helper.layout = Layout(
|
||||
Div(
|
||||
Div("examination", css_class="col-md-3"),
|
||||
Div("modality", css_class="col-md-3"),
|
||||
Div("procedures", css_class="col-md-3"),
|
||||
css_class="row g-2",
|
||||
),
|
||||
Div(
|
||||
Div(
|
||||
Field("min_value", wrapper_class="mb-0 d-flex align-items-center"),
|
||||
Field("max_value", wrapper_class="mb-0 d-flex align-items-center"),
|
||||
Field("age_unit", wrapper_class="mb-0 d-flex align-items-center"),
|
||||
css_class="col-12 d-flex gap-2 align-items-center",
|
||||
),
|
||||
css_class="row g-2",
|
||||
),
|
||||
)
|
||||
# Assign helper to the generated form instance and tweak widgets
|
||||
if hasattr(self, 'form') and self.form is not None:
|
||||
# Wider numeric inputs and default unit
|
||||
try:
|
||||
form = self.form
|
||||
if "min_value" in form.fields:
|
||||
form.fields["min_value"].widget = forms.NumberInput(attrs={"class": "form-control w-auto", "min": "0", "step": "1", "style": "min-width:140px;"})
|
||||
if "max_value" in form.fields:
|
||||
form.fields["max_value"].widget = forms.NumberInput(attrs={"class": "form-control w-auto", "min": "0", "step": "1", "style": "min-width:140px;"})
|
||||
if "age_unit" in form.fields:
|
||||
form.fields["age_unit"].initial = "days"
|
||||
form.fields["age_unit"].widget.attrs.update({"class": "form-select w-auto", "style": "min-width:220px;"})
|
||||
form.helper = helper
|
||||
except Exception:
|
||||
# don't crash if form structure differs
|
||||
self.form.helper = helper
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _convert_to_days(self, value, unit: str) -> int:
|
||||
try:
|
||||
v = float(value)
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
if unit == "days":
|
||||
return int(v)
|
||||
if unit == "weeks":
|
||||
return int(v * 7)
|
||||
if unit == "months":
|
||||
# approximate month length
|
||||
return int(v * (365.25 / 12.0))
|
||||
# default: years
|
||||
return int(v * 365.25)
|
||||
|
||||
def filter_min_value(self, queryset, name, value):
|
||||
try:
|
||||
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "days")
|
||||
except Exception:
|
||||
unit = "days"
|
||||
try:
|
||||
days = self._convert_to_days(value, unit)
|
||||
except Exception:
|
||||
return queryset
|
||||
return queryset.filter(age_days__gte=days)
|
||||
|
||||
def filter_max_value(self, queryset, name, value):
|
||||
try:
|
||||
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "days")
|
||||
except Exception:
|
||||
unit = "days"
|
||||
try:
|
||||
days = self._convert_to_days(value, unit)
|
||||
except Exception:
|
||||
return queryset
|
||||
return queryset.filter(age_days__lte=days)
|
||||
|
||||
def filter_age_unit(self, queryset, name, value):
|
||||
# `age_unit` is a helper input used by min/max conversion and does not
|
||||
# map to a database field on NormalCase.
|
||||
return queryset
|
||||
|
||||
|
||||
class ResourceFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(field_name='name', lookup_expr='icontains')
|
||||
site = django_filters.ModelMultipleChoiceFilter(field_name='sites', queryset=lambda request: Site.objects.all() if True else Site.objects.none())
|
||||
subspecialty = django_filters.ModelMultipleChoiceFilter(field_name='subspecialty', queryset=Subspecialty.objects.all())
|
||||
author = django_filters.ModelMultipleChoiceFilter(queryset=get_authors, null_label='No author')
|
||||
has_file = django_filters.BooleanFilter(method='filter_has_file', label='Has file')
|
||||
|
||||
class Meta:
|
||||
model = Resource
|
||||
fields = ('name', 'site', 'subspecialty', 'author')
|
||||
|
||||
def filter_has_file(self, queryset, name, value):
|
||||
if value is True:
|
||||
return queryset.exclude(file__isnull=True).exclude(file__exact='')
|
||||
elif value is False:
|
||||
return queryset.filter(file__isnull=True) | queryset.filter(file__exact='')
|
||||
return queryset
|
||||
|
||||
class QuestionSchemaFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
model = QuestionSchema
|
||||
|
||||
+449
-78
@@ -40,10 +40,15 @@ from atlas.models import (
|
||||
Condition,
|
||||
Structure,
|
||||
Subspecialty,
|
||||
Presentation,
|
||||
PathologicalProcess,
|
||||
Procedure,
|
||||
UncategorisedDicom,
|
||||
UserReportAnswer,
|
||||
CaseDisplaySet,
|
||||
CaseReviewMessage,
|
||||
)
|
||||
from .models import NormalCase
|
||||
|
||||
from anatomy.models import Modality
|
||||
|
||||
@@ -53,7 +58,8 @@ from generic.models import Examination # , Sign
|
||||
|
||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||
from django.forms.widgets import RadioSelect, TextInput, Textarea, Select
|
||||
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML
|
||||
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML, Button
|
||||
from django.urls import reverse
|
||||
from crispy_forms.bootstrap import Accordion, AccordionGroup
|
||||
|
||||
from tinymce.widgets import TinyMCE
|
||||
@@ -111,15 +117,34 @@ class CaseSelect(Select):
|
||||
pass
|
||||
|
||||
|
||||
class MoveSelectedSeriesForm(Form):
|
||||
destination_case = ModelChoiceField(
|
||||
queryset=Case.objects.none(),
|
||||
widget=HiddenInput(),
|
||||
label="Move selected series to case",
|
||||
required=True,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop("user", None)
|
||||
source_case = kwargs.pop("source_case", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
available_cases = get_cases_available_to_user(user)
|
||||
if source_case is not None:
|
||||
available_cases = available_cases.exclude(pk=source_case.pk)
|
||||
|
||||
self.fields["destination_case"].queryset = available_cases.order_by("pk")
|
||||
|
||||
|
||||
class ConditionForm(ModelForm):
|
||||
class Meta:
|
||||
model = Condition
|
||||
exclude = ["rcr_curriculum"]
|
||||
|
||||
widgets = {
|
||||
"synonym": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:condition-autocomplete"
|
||||
),
|
||||
# Use an AJAX/autocomplete single-select for canonical so users
|
||||
# can search for existing canonical Conditions by name.
|
||||
"canonical": autocomplete.ModelSelect2(url="atlas:condition-autocomplete"),
|
||||
"parent": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:condition-autocomplete"
|
||||
),
|
||||
@@ -128,6 +153,86 @@ class ConditionForm(ModelForm):
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Crispy helper to render a submit button consistently
|
||||
try:
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = True
|
||||
# Primary submit
|
||||
self.helper.add_input(Submit("submit", "Submit"))
|
||||
# Cancel as a button that redirects back to the condition list
|
||||
try:
|
||||
cancel_url = reverse("atlas:condition_view")
|
||||
except Exception:
|
||||
cancel_url = "#"
|
||||
self.helper.add_input(
|
||||
Button(
|
||||
"cancel",
|
||||
"Cancel",
|
||||
css_class="btn btn-secondary ms-2",
|
||||
onclick=f"window.location='{cancel_url}'",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# If crispy not available or something goes wrong, don't break form
|
||||
pass
|
||||
|
||||
|
||||
class ConditionRelationshipForm(Form):
|
||||
parents = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=Condition.objects.none(),
|
||||
widget=autocomplete.ModelSelect2Multiple(url="atlas:condition-autocomplete"),
|
||||
)
|
||||
children = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=Condition.objects.none(),
|
||||
widget=autocomplete.ModelSelect2Multiple(url="atlas:condition-autocomplete"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.instance = kwargs.pop("instance", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
condition_qs = Condition.objects.all().order_by("name")
|
||||
if self.instance is not None and getattr(self.instance, "pk", None):
|
||||
condition_qs = condition_qs.exclude(pk=self.instance.pk)
|
||||
|
||||
self.fields["parents"].queryset = condition_qs
|
||||
self.fields["children"].queryset = condition_qs
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = False
|
||||
self.helper.layout = Layout(
|
||||
Fieldset(
|
||||
"Condition relationships",
|
||||
"parents",
|
||||
"children",
|
||||
)
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
if self.instance is None:
|
||||
raise ValueError("ConditionRelationshipForm requires an instance")
|
||||
|
||||
parents = list(self.cleaned_data.get("parents", []))
|
||||
children = list(self.cleaned_data.get("children", []))
|
||||
|
||||
self.instance.parent.set(parents)
|
||||
|
||||
current_children = list(self.instance.child.all())
|
||||
for child in current_children:
|
||||
if child not in children:
|
||||
self.instance.child.remove(child)
|
||||
for child in children:
|
||||
if child not in current_children:
|
||||
self.instance.child.add(child)
|
||||
|
||||
return self.instance
|
||||
|
||||
|
||||
class CaseCollectionForm(ModelForm):
|
||||
class Meta:
|
||||
@@ -141,6 +246,38 @@ class CaseCollectionForm(ModelForm):
|
||||
ModelForm.__init__(self, *args, **kwargs)
|
||||
super(CaseCollectionForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Render collection_type as radio buttons and ensure it appears near the top
|
||||
if "collection_type" in self.fields:
|
||||
# Replace the model field with an explicit ChoiceField that uses RadioSelect
|
||||
original = self.fields["collection_type"]
|
||||
choices = getattr(original, "choices", ())
|
||||
initial = original.initial if hasattr(original, "initial") else None
|
||||
required = original.required
|
||||
label = original.label
|
||||
widget = RadioSelect(attrs={"class": "btn-check"})
|
||||
# assign custom template for rendering as button group
|
||||
#widget.template_name = "widgets/radioselect_buttons.html"
|
||||
self.fields["collection_type"] = forms.ChoiceField(
|
||||
choices=choices,
|
||||
widget=widget,
|
||||
initial=initial,
|
||||
required=required,
|
||||
label=label,
|
||||
)
|
||||
|
||||
if "prerequisites" in self.fields:
|
||||
self.fields["prerequisites"].widget = autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:casecollection-prerequisite-autocomplete",
|
||||
attrs={
|
||||
"data-placeholder": "Search authored collections...",
|
||||
"data-minimum-input-length": 0,
|
||||
},
|
||||
)
|
||||
authored_collections = CaseCollection.objects.filter(author=self.user).distinct().order_by("name")
|
||||
if getattr(self.instance, "pk", None):
|
||||
authored_collections = authored_collections.exclude(pk=self.instance.pk)
|
||||
self.fields["prerequisites"].queryset = authored_collections
|
||||
|
||||
# Identify show_ fields
|
||||
show_fields = [f for f in self.fields if f.startswith("show_")]
|
||||
show_pre_fields = [f for f in show_fields if f.endswith("_pre")]
|
||||
@@ -178,6 +315,10 @@ class CaseCollectionForm(ModelForm):
|
||||
"show_description_post",
|
||||
"show_discussion_post",
|
||||
"show_report_post",
|
||||
"show_case_link_post",
|
||||
"show_findings_post",
|
||||
"show_displaysets_post",
|
||||
"show_differentials_post",
|
||||
]
|
||||
if f in self.fields
|
||||
]
|
||||
@@ -186,6 +327,7 @@ class CaseCollectionForm(ModelForm):
|
||||
Fieldset(
|
||||
"Collection Details",
|
||||
"name",
|
||||
Field("collection_type", css_class="mb-2"),
|
||||
Fieldset(
|
||||
"Dates",
|
||||
"restrict_to_dates",
|
||||
@@ -213,10 +355,26 @@ class CaseCollectionForm(ModelForm):
|
||||
"exam_mode",
|
||||
"self_review",
|
||||
"feedback_once_collection_complete",
|
||||
"collection_type",
|
||||
"case_query_messaging_enabled",
|
||||
"show_results_sharing_information",
|
||||
"viewer_mode",
|
||||
"question_time_limit",
|
||||
"answer_entry_grace_period",
|
||||
"prerequisites",
|
||||
"pre_survey",
|
||||
"pre_survey_mandatory",
|
||||
"post_survey",
|
||||
"post_survey_mandatory",
|
||||
Fieldset(
|
||||
"Custom Start Screen",
|
||||
"start_screen_content",
|
||||
"start_screen_resources",
|
||||
),
|
||||
Fieldset(
|
||||
"Custom End Overview",
|
||||
"end_overview_content",
|
||||
"end_overview_resources",
|
||||
),
|
||||
),
|
||||
Fieldset("Valid User Groups", *user_fields) if user_fields else None,
|
||||
Div(
|
||||
@@ -236,35 +394,71 @@ class CaseCollectionForm(ModelForm):
|
||||
Submit("submit", "Save Collection", css_class="btn btn-primary"),
|
||||
)
|
||||
|
||||
self.fields["start_date"] = SplitDateTimeFieldDefaultTime(
|
||||
widget=SplitDateTimeWidget(
|
||||
date_attrs={"type": "date", "class": "datepicker"},
|
||||
time_attrs={"type": "time", "class": "timepicker"},
|
||||
date_format="%Y-%m-%d",
|
||||
time_format="%H:%M",
|
||||
|
||||
class NormalCaseForm(ModelForm):
|
||||
# Keep non-model input fields for age entry so users can provide a
|
||||
# value + unit (e.g. 6 months) which will be converted to canonical
|
||||
# days on save. The model now stores only `age_days`.
|
||||
age_value = forms.IntegerField(required=False, min_value=0)
|
||||
age_unit = forms.ChoiceField(required=False, choices=NormalCase.AGE_UNIT_CHOICES)
|
||||
|
||||
class Meta:
|
||||
model = NormalCase
|
||||
fields = ("examination", "modality", "notes")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# If editing an existing NormalCase, prepopulate age_value/age_unit
|
||||
try:
|
||||
instance = kwargs.get('instance')
|
||||
except Exception:
|
||||
instance = None
|
||||
if instance is None:
|
||||
# also support self.instance when ModelForm sets it
|
||||
instance = getattr(self, 'instance', None)
|
||||
if instance is not None and getattr(instance, 'age_days', None) is not None:
|
||||
try:
|
||||
val, unit = NormalCase.days_to_value_unit(instance.age_days)
|
||||
if val is not None and unit is not None:
|
||||
self.fields['age_value'].initial = val
|
||||
self.fields['age_unit'].initial = unit
|
||||
except Exception:
|
||||
pass
|
||||
self.helper = FormHelper()
|
||||
# Do not render a <form> tag from crispy here because the template
|
||||
# already provides the surrounding form element (we inject this
|
||||
# fragment into a modal that contains the form). Rendering a second
|
||||
# <form> would nest forms and cause fields to be outside the
|
||||
# submitted form (HTMX would only send the outer form's fields).
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = False
|
||||
self.helper.layout = Layout(
|
||||
Div(
|
||||
Field("age_value", wrapper_class="col-md-3"),
|
||||
Field("age_unit", wrapper_class="col-md-3"),
|
||||
Field("examination", wrapper_class="col-md-3"),
|
||||
Field("modality", wrapper_class="col-md-3"),
|
||||
css_class="row",
|
||||
),
|
||||
required=False,
|
||||
help_text="The date the exam is due to start (time is optional)",
|
||||
)
|
||||
self.fields["end_date"] = SplitDateTimeFieldDefaultTimeEnd(
|
||||
widget=SplitDateTimeWidget(
|
||||
date_attrs={"type": "date", "class": "datepicker"},
|
||||
time_attrs={"type": "time", "class": "timepicker"},
|
||||
date_format="%Y-%m-%d",
|
||||
time_format="%H:%M",
|
||||
),
|
||||
required=False,
|
||||
help_text="The date the exam is due to start (time is optional)",
|
||||
Field("notes"),
|
||||
# Submit button rendered in modal footer; avoid duplicate button here
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
# Get the unsaved Case instance
|
||||
instance = ModelForm.save(self, False)
|
||||
# Respect commit flag. Compute canonical `age_days` from the
|
||||
# provided age_value/age_unit and set it on the instance.
|
||||
instance = super().save(commit=False)
|
||||
age_value = self.cleaned_data.get("age_value")
|
||||
age_unit = self.cleaned_data.get("age_unit")
|
||||
if age_value is not None and age_unit:
|
||||
instance.age_days = NormalCase.to_days(age_value, age_unit)
|
||||
else:
|
||||
# If the user left fields blank, clear any previous value.
|
||||
instance.age_days = None
|
||||
|
||||
# Do we need to save all changes now?
|
||||
# if commit:
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
if commit:
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
|
||||
return instance
|
||||
|
||||
@@ -273,9 +467,10 @@ class FindingForm(ModelForm):
|
||||
class Meta:
|
||||
model = Finding
|
||||
exclude = []
|
||||
|
||||
# Use the canonical FK in forms (if users want to mark this Finding
|
||||
# as an alias of another). The old `synonym` M2M has been removed.
|
||||
widgets = {
|
||||
"synonym": autocomplete.ModelSelect2Multiple(
|
||||
"canonical": autocomplete.ModelSelect2(
|
||||
url="atlas:finding-autocomplete"
|
||||
),
|
||||
}
|
||||
@@ -285,14 +480,47 @@ class StructureForm(ModelForm):
|
||||
class Meta:
|
||||
model = Structure
|
||||
exclude = []
|
||||
|
||||
widgets = {
|
||||
"synonym": autocomplete.ModelSelect2Multiple(
|
||||
"canonical": autocomplete.ModelSelect2(
|
||||
url="atlas:structure-autocomplete"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class SubspecialtyForm(ModelForm):
|
||||
class Meta:
|
||||
model = Subspecialty
|
||||
exclude = []
|
||||
|
||||
|
||||
class PresentationForm(ModelForm):
|
||||
class Meta:
|
||||
model = Presentation
|
||||
exclude = []
|
||||
|
||||
|
||||
class PathologicalProcessForm(ModelForm):
|
||||
class Meta:
|
||||
model = PathologicalProcess
|
||||
exclude = []
|
||||
|
||||
|
||||
class ProcedureForm(ModelForm):
|
||||
class Meta:
|
||||
model = Procedure
|
||||
exclude = []
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
try:
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
# Prevent crispy from rendering its own <form> tag so our
|
||||
# template-level form wrapper contains the submit buttons.
|
||||
self.helper.form_tag = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class SeriesFindingForm(ModelForm):
|
||||
class Meta:
|
||||
model = SeriesFinding
|
||||
@@ -473,8 +701,9 @@ class CaseForm(ModelForm):
|
||||
|
||||
# This is populated if we create the case from an exam/collection
|
||||
self.collection = None
|
||||
if kwargs["initial"] is not None and "exams" in kwargs["initial"]:
|
||||
collections = kwargs["initial"].pop("exams")
|
||||
initial = kwargs.setdefault("initial", {})
|
||||
if initial is not None and "exams" in initial:
|
||||
collections = initial.pop("exams")
|
||||
logger.debug(collections)
|
||||
|
||||
self.collection = get_object_or_404(CaseCollection, pk=collections[0])
|
||||
@@ -515,12 +744,14 @@ class CaseForm(ModelForm):
|
||||
"presentation",
|
||||
"discussion",
|
||||
"report",
|
||||
"procedures",
|
||||
"condition",
|
||||
"pathological_process",
|
||||
"open_access",
|
||||
"previous_case",
|
||||
"diagnostic_certainty",
|
||||
"cimar_uuid",
|
||||
#cimar_uuid",
|
||||
"study_date",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -546,13 +777,15 @@ class CaseForm(ModelForm):
|
||||
"history",
|
||||
"presentation",
|
||||
"discussion",
|
||||
"procedures",
|
||||
"condition",
|
||||
"pathological_process",
|
||||
"report",
|
||||
"open_access",
|
||||
"previous_case",
|
||||
"diagnostic_certainty",
|
||||
"cimar_uuid",
|
||||
#cimar_uuid",
|
||||
"study_date",
|
||||
]
|
||||
# fields = ['question', 'findings', 'subspecialty', 'references']
|
||||
widgets = {
|
||||
@@ -575,6 +808,9 @@ class CaseForm(ModelForm):
|
||||
"subspecialty": CheckboxSelectMultiple(),
|
||||
"pathological_process": CheckboxSelectMultiple(),
|
||||
"previous_case": CaseSelect(),
|
||||
"procedures": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:procedure-autocomplete"
|
||||
),
|
||||
}
|
||||
|
||||
def clean_cimar_uuid(self):
|
||||
@@ -606,23 +842,23 @@ class CaseForm(ModelForm):
|
||||
self.save_m2m = save_m2m
|
||||
|
||||
# Do we need to save all changes now?
|
||||
# if commit:
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
if commit:
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
|
||||
logger.debug(f"{self.collection=}")
|
||||
|
||||
if self.collection is not None:
|
||||
logger.debug(f"{self.collection=}")
|
||||
case_no = self.collection.cases.count() + 1
|
||||
logger.debug(f"{case_no=}")
|
||||
logger.debug(f"{instance=}")
|
||||
|
||||
# Create through model
|
||||
# cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
|
||||
self.collection.cases.add(
|
||||
instance, through_defaults={"sort_order": case_no}
|
||||
)
|
||||
if self.collection is not None:
|
||||
logger.debug(f"{self.collection=}")
|
||||
case_no = self.collection.cases.count() + 1
|
||||
logger.debug(f"{case_no=}")
|
||||
logger.debug(f"{instance=}")
|
||||
|
||||
# Create through model
|
||||
# cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
|
||||
self.collection.cases.add(
|
||||
instance, through_defaults={"sort_order": case_no}
|
||||
)
|
||||
|
||||
return instance
|
||||
|
||||
@@ -644,7 +880,7 @@ CaseDifferentialFormSet = inlineformset_factory(
|
||||
class ResourceForm(ModelForm):
|
||||
class Meta:
|
||||
model = Resource
|
||||
exclude = ["author"]
|
||||
exclude = ["author", "created_date"]
|
||||
|
||||
widgets = {
|
||||
"description": Textarea(attrs={"cols": 80, "rows": 5}),
|
||||
@@ -721,7 +957,13 @@ CaseCollectionCaseFormSet = inlineformset_factory(
|
||||
CaseCollection,
|
||||
Case.casecollection_set.through,
|
||||
form=CaseCollectionCaseForm,
|
||||
exclude=["question_schema", "question_answers"],
|
||||
exclude=[
|
||||
"question_schema",
|
||||
"question_answers",
|
||||
"series_visibility_config",
|
||||
"learner_comment",
|
||||
"learner_review_comment",
|
||||
],
|
||||
can_delete=True,
|
||||
extra=0,
|
||||
max_num=50,
|
||||
@@ -791,7 +1033,7 @@ class BaseReportAnswerForm(ModelForm):
|
||||
# "answer": "Write your answer in here."
|
||||
# }
|
||||
|
||||
def __init__(self, *args, case_detail, **kwargs):
|
||||
def __init__(self, *args, casedetail, **kwargs):
|
||||
super(BaseReportAnswerForm, self).__init__(*args, **kwargs)
|
||||
self.fields["answer"].required = False
|
||||
|
||||
@@ -807,12 +1049,12 @@ class BaseQuestionAnswerForm(ModelForm):
|
||||
# "answer": "Write your answer in here."
|
||||
# }
|
||||
|
||||
def __init__(self, *args, case_detail, **kwargs):
|
||||
def __init__(self, *args, casedetail, **kwargs):
|
||||
super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs)
|
||||
# self.fields["json_answer"].schema = case_detail.question_schema
|
||||
if case_detail.question_schema is not None:
|
||||
# self.fields["json_answer"].schema = casedetail.question_schema
|
||||
if casedetail.question_schema is not None:
|
||||
self.fields["json_answer"] = JSONSchemaField(
|
||||
schema=case_detail.question_schema, options="schema/options.json"
|
||||
schema=casedetail.question_schema, options="schema/options.json"
|
||||
)
|
||||
self.fields["json_answer"].label = ""
|
||||
|
||||
@@ -934,6 +1176,14 @@ class AddCollectionToCaseForm(Form):
|
||||
|
||||
|
||||
class SelfReviewForm(ModelForm):
|
||||
SCORE_CHOICES = [
|
||||
(1, "1 - Poor"),
|
||||
(2, "2 - Limited"),
|
||||
(3, "3 - Adequate"),
|
||||
(4, "4 - Good"),
|
||||
(5, "5 - Excellent"),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
model = SelfReview
|
||||
fields = ["user_exam", "case", "comments", "findings", "interpretation"]
|
||||
@@ -948,6 +1198,75 @@ class SelfReviewForm(ModelForm):
|
||||
ModelForm.__init__(self, *args, **kwargs)
|
||||
super(SelfReviewForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Render scoring fields as explicit 1-5 selector choices for cleaner UX.
|
||||
self.fields["findings"] = forms.TypedChoiceField(
|
||||
choices=self.SCORE_CHOICES,
|
||||
coerce=int,
|
||||
empty_value=None,
|
||||
required=False,
|
||||
widget=RadioSelect,
|
||||
label="Findings",
|
||||
help_text="How well did you identify key findings?",
|
||||
)
|
||||
self.fields["interpretation"] = forms.TypedChoiceField(
|
||||
choices=self.SCORE_CHOICES,
|
||||
coerce=int,
|
||||
empty_value=None,
|
||||
required=False,
|
||||
widget=RadioSelect,
|
||||
label="Interpretation",
|
||||
help_text="How strong was your overall interpretation?",
|
||||
)
|
||||
|
||||
self.fields["comments"].widget.attrs.update(
|
||||
{
|
||||
"rows": 5,
|
||||
"placeholder": "What went well? What would you do differently next time?",
|
||||
}
|
||||
)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = False
|
||||
self.helper.layout = Layout(
|
||||
Field("user_exam"),
|
||||
Field("case"),
|
||||
Field("comments"),
|
||||
Field("findings"),
|
||||
Field("interpretation"),
|
||||
)
|
||||
|
||||
|
||||
class CaseReviewMessageForm(ModelForm):
|
||||
class Meta:
|
||||
model = CaseReviewMessage
|
||||
fields = ["message", "requires_acknowledgement"]
|
||||
|
||||
def __init__(self, *args, allow_ack=False, message_placeholder=None, **kwargs):
|
||||
super(CaseReviewMessageForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.fields["message"].widget.attrs.update(
|
||||
{
|
||||
"rows": 3,
|
||||
"placeholder": message_placeholder or "Write a message...",
|
||||
"class": "form-control",
|
||||
}
|
||||
)
|
||||
|
||||
self.fields["requires_acknowledgement"].widget.attrs.update(
|
||||
{
|
||||
"class": "form-check-input",
|
||||
}
|
||||
)
|
||||
|
||||
if not allow_ack:
|
||||
self.fields["requires_acknowledgement"].widget = HiddenInput()
|
||||
self.fields["requires_acknowledgement"].required = False
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = False
|
||||
|
||||
|
||||
class UncategorisedDicomForm(ModelForm):
|
||||
class Meta:
|
||||
@@ -973,6 +1292,37 @@ class ConditionAutocomplete(ModelAutocomplete):
|
||||
model = Condition
|
||||
search_attrs = ["name"]
|
||||
|
||||
@classmethod
|
||||
def get_items_from_keys(cls, keys, context_obj=None):
|
||||
"""Return items for given keys, ignoring empty strings.
|
||||
|
||||
The autocomplete frontend sometimes sends empty string keys which
|
||||
causes the ORM to raise ValueError when filtering numeric PK fields.
|
||||
Filter out falsy/blank keys before querying. If no valid keys remain
|
||||
return an empty queryset.
|
||||
"""
|
||||
# normalize and remove empty/blank values
|
||||
clean_keys = [k for k in keys if k is not None and str(k).strip() != ""]
|
||||
if not clean_keys:
|
||||
return cls.model.objects.none()
|
||||
|
||||
# try to convert numeric keys to ints where possible
|
||||
parsed_keys = []
|
||||
for k in clean_keys:
|
||||
try:
|
||||
parsed_keys.append(int(k))
|
||||
except Exception:
|
||||
parsed_keys.append(k)
|
||||
|
||||
qs = cls.model.objects.filter(pk__in=parsed_keys)
|
||||
|
||||
# Return a list of dicts matching the autocomplete core expectations
|
||||
# (items subscriptable by keys like 'key' / 'label').
|
||||
return [
|
||||
{"key": obj.pk, "label": getattr(obj, "name", str(obj)), "value": str(obj)}
|
||||
for obj in qs
|
||||
]
|
||||
|
||||
|
||||
class ConditionAutocompleteForm(Form):
|
||||
condition = ModelChoiceField(
|
||||
@@ -1027,7 +1377,14 @@ class CaseQuestionForm(ModelForm):
|
||||
class CaseDetailForm(ModelForm):
|
||||
class Meta:
|
||||
model = CaseDetail
|
||||
fields = ["redact_history", "override_history"]
|
||||
fields = [
|
||||
"redact_history",
|
||||
"override_history",
|
||||
"learner_comment",
|
||||
"learner_review_comment",
|
||||
"question_time_limit_override",
|
||||
"answer_entry_grace_period_override",
|
||||
]
|
||||
|
||||
def __init__(self, *args, case_history: str = None, **kwargs):
|
||||
"""
|
||||
@@ -1050,6 +1407,16 @@ class CaseDetailForm(ModelForm):
|
||||
self.fields["override_history"].widget.attrs.setdefault("rows", 6)
|
||||
self.fields["override_history"].widget.attrs.setdefault("class", "form-control")
|
||||
|
||||
if "question_time_limit_override" in self.fields:
|
||||
self.fields["question_time_limit_override"].help_text = (
|
||||
"Leave blank to use the collection default answer time limit."
|
||||
)
|
||||
|
||||
if "answer_entry_grace_period_override" in self.fields:
|
||||
self.fields["answer_entry_grace_period_override"].help_text = (
|
||||
"Leave blank to use the collection default answer-only grace period."
|
||||
)
|
||||
|
||||
# Build crispy helper/layout embedding the original history and buttons tied to override_history
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
@@ -1087,7 +1454,17 @@ class CaseDetailForm(ModelForm):
|
||||
self.helper.layout = Layout(
|
||||
HTML(history_block),
|
||||
Field("override_history"),
|
||||
Field("redact_history")
|
||||
Field("redact_history"),
|
||||
Fieldset(
|
||||
"Learner Comments",
|
||||
Field("learner_comment"),
|
||||
Field("learner_review_comment"),
|
||||
),
|
||||
Fieldset(
|
||||
"Case Timing Overrides",
|
||||
Field("question_time_limit_override"),
|
||||
Field("answer_entry_grace_period_override"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1113,32 +1490,26 @@ class PriorCaseForm(Form):
|
||||
|
||||
prior_visibility = CharField(max_length=2, required=True)
|
||||
|
||||
# def __init__(self, *args, **kwargs):
|
||||
# self.case_detail = kwargs.pop(
|
||||
# "case_detail"
|
||||
# ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
|
||||
|
||||
# super(PriorCaseForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# prior_cases = self.case_detail.case.get_all_prior_cases()
|
||||
|
||||
# if not prior_cases:
|
||||
# prior_cases = Case.objects.none()
|
||||
|
||||
# self.fields["case"] = ChoiceField(
|
||||
# required=False,
|
||||
# # widget=Select(verbose_name="Series", is_stacked=False),
|
||||
# )
|
||||
|
||||
|
||||
class CaseSeriesForm(forms.ModelForm):
|
||||
def __init__(self, *args, queryset, user=None, is_atlas_editor=False, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["series"].queryset = queryset
|
||||
self.fields["series"].label_from_instance = lambda obj: f"Series {obj.pk}: {obj.description or ''} ({obj.modality or ''})"
|
||||
|
||||
class Meta:
|
||||
model = SeriesDetail # through model for Case.series
|
||||
|
||||
fields = ["series", "sort_order", "feedback"]
|
||||
widgets = {
|
||||
"series": autocomplete.ModelSelect2(
|
||||
url="atlas:series-autocomplete",
|
||||
attrs={
|
||||
"data-placeholder": "Search by Series ID or description...",
|
||||
"data-minimum-input-length": 0,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class CaseUpdateSeriesForm(forms.ModelForm):
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-13 21:43
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0079_casecollection_prerequisites'),
|
||||
('generic', '0029_add_unique_constraint_supervisor_email'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NormalCase',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('age_years', models.PositiveSmallIntegerField(blank=True, help_text='Age of the patient at scan in years (rounded to nearest year).', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(150)])),
|
||||
('added_date', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('notes', models.TextField(blank=True, null=True)),
|
||||
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='added_normals', to=settings.AUTH_USER_MODEL)),
|
||||
('case', models.OneToOneField(help_text='The Case that is marked as a normal example.', on_delete=django.db.models.deletion.CASCADE, related_name='normal_case', to='atlas.case')),
|
||||
('examination', models.ForeignKey(blank=True, help_text='Primary examination for this normal case (optional).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='normal_cases', to='generic.examination')),
|
||||
('modality', models.ForeignKey(blank=True, help_text='Primary modality for this normal case (optional).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='normal_cases', to='generic.modality')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-added_date'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-13 22:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0080_normalcase'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='normalcase',
|
||||
name='age_years',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='normalcase',
|
||||
name='age_days',
|
||||
field=models.PositiveIntegerField(blank=True, help_text='Patient age at scan in days (canonical).', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-14 20:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0081_remove_normalcase_age_years_normalcase_age_days'),
|
||||
('generic', '0029_add_unique_constraint_supervisor_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='sites',
|
||||
field=models.ManyToManyField(blank=True, help_text='The site(s) this resource is associated with (if any).', related_name='resources', to='generic.site'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='subspecialty',
|
||||
field=models.ManyToManyField(blank=True, help_text='Subspecialty or subspecialties this resource relates to.', to='atlas.subspecialty'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-14 21:02
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0082_resource_sites_resource_subspecialty'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='created_date',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='modified_date',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-15 22:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0083_resource_created_date_resource_modified_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='open_access',
|
||||
field=models.BooleanField(default=True, help_text='If true the resource is available to all users, otherwise only in the context of cases.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-17 10:28
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0084_resource_open_access'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='caseprior',
|
||||
old_name='case_detail',
|
||||
new_name='casedetail',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='caseprior',
|
||||
unique_together={('casedetail', 'prior_case')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Condition = apps.get_model("atlas", "Condition")
|
||||
# Build connected components using existing M2M 'synonym' links
|
||||
visited = set()
|
||||
|
||||
for cond in Condition.objects.all():
|
||||
if cond.pk in visited:
|
||||
continue
|
||||
# BFS/DFS to collect component
|
||||
stack = [cond]
|
||||
comp = []
|
||||
while stack:
|
||||
c = stack.pop()
|
||||
if c.pk in visited:
|
||||
continue
|
||||
visited.add(c.pk)
|
||||
comp.append(c)
|
||||
# access existing M2M relations
|
||||
for s in c.synonym.all():
|
||||
if s.pk not in visited:
|
||||
stack.append(s)
|
||||
|
||||
# choose canonical: prefer primary==True else lowest pk
|
||||
canon = None
|
||||
for c in comp:
|
||||
if getattr(c, "primary", False):
|
||||
canon = c
|
||||
break
|
||||
if not canon:
|
||||
canon = min(comp, key=lambda x: x.pk)
|
||||
|
||||
# set canonical for all non-canonical members
|
||||
for c in comp:
|
||||
if c.pk != canon.pk:
|
||||
c.canonical_id = canon.pk
|
||||
c.save()
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
Condition = apps.get_model("atlas", "Condition")
|
||||
for c in Condition.objects.all():
|
||||
if c.canonical_id:
|
||||
c.canonical_id = None
|
||||
c.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("atlas", "0085_rename_case_detail_caseprior_casedetail_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="condition",
|
||||
name="canonical",
|
||||
field=models.ForeignKey(
|
||||
related_name="aliases",
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
to="atlas.condition",
|
||||
blank=True,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(forwards, backwards),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-20 22:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0086_add_canonical'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='condition',
|
||||
name='canonical',
|
||||
field=models.ForeignKey(blank=True, help_text='If set, this Condition is an alias and points to the canonical Condition.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.condition'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-24 10:17
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0087_alter_condition_canonical'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='finding',
|
||||
name='primary',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='finding',
|
||||
name='synonym',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='structure',
|
||||
name='primary',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='structure',
|
||||
name='synonym',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='finding',
|
||||
name='canonical',
|
||||
field=models.ForeignKey(blank=True, help_text='If set, this Finding is an alias and points to the canonical Finding.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.finding'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='structure',
|
||||
name='canonical',
|
||||
field=models.ForeignKey(blank=True, help_text='If set, this Structure is an alias and points to the canonical Structure.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.structure'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-24 10:31
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0088_remove_finding_primary_remove_finding_synonym_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='condition',
|
||||
name='primary',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='condition',
|
||||
name='synonym',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-26 11:05
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0089_remove_condition_primary_remove_condition_synonym'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, help_text='Creation timestamp'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='updated',
|
||||
field=models.DateTimeField(auto_now=True, help_text='Last updated timestamp'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-16 13:28
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0090_casecollection_created_casecollection_updated'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='normalcase',
|
||||
name='added_by',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-16 13:29
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0091_remove_normalcase_added_by'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='normalcase',
|
||||
name='author',
|
||||
field=models.ManyToManyField(blank=True, related_name='normal_cases', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-16 14:01
|
||||
|
||||
import atlas.models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0092_normalcase_author'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Procedure',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
('canonical', models.ForeignKey(blank=True, help_text='If set, this Procedure is an alias and points to the canonical Procedure.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.procedure')),
|
||||
('subspecialty', models.ManyToManyField(blank=True, to='atlas.subspecialty')),
|
||||
],
|
||||
bases=(atlas.models.SynMixin, models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='case',
|
||||
name='procedures',
|
||||
field=models.ManyToManyField(blank=True, help_text='Procedure(s) or operations the patient has undergone.', to='atlas.procedure'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-23 11:51
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0093_procedure_case_procedures'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='APIToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, help_text='Friendly name for this token', max_length=200)),
|
||||
('token_hash', models.CharField(db_index=True, max_length=64)),
|
||||
('scopes', models.CharField(blank=True, help_text='Space-separated scopes', max_length=255)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('expires', models.DateTimeField(blank=True, null=True)),
|
||||
('revoked', models.BooleanField(default=False)),
|
||||
('last_used', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'API token',
|
||||
'verbose_name_plural': 'API tokens',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-13 13:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0094_apitoken'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='show_case_link_post',
|
||||
field=models.BooleanField(default=False, help_text='Show a link to the case (post exam)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='show_displaysets_post',
|
||||
field=models.BooleanField(default=False, help_text='Show display sets for the case (post exam)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='show_findings_post',
|
||||
field=models.BooleanField(default=False, help_text='Show findings related to the case (post exam)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-04-13 13:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0095_casecollection_show_case_link_post_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='show_differentials_post',
|
||||
field=models.BooleanField(default=False, help_text='Show differentials for the case (post exam)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("generic", "0031_flag"),
|
||||
("atlas", "0096_casecollection_show_differentials_post"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="CaseReviewMessage",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("message_type", models.CharField(choices=[("feedback", "Feedback"), ("query", "Query"), ("reply", "Reply")], default="query", max_length=20)),
|
||||
("message", models.TextField()),
|
||||
("author_label", models.CharField(blank=True, max_length=255)),
|
||||
("requires_acknowledgement", models.BooleanField(default=False)),
|
||||
("acknowledged_at", models.DateTimeField(blank=True, null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("author", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages_authored", to=settings.AUTH_USER_MODEL)),
|
||||
("case", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages", to="atlas.case")),
|
||||
("cid_user_exam", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages", to="generic.ciduserexam")),
|
||||
],
|
||||
options={
|
||||
"ordering": ("created_at",),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-27 21:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0097_casereviewmessage'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='case_query_messaging_enabled',
|
||||
field=models.BooleanField(default=True, help_text='Enable per-case query and messaging for this collection.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("atlas", "0098_casecollection_case_query_messaging_enabled"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="casedetail",
|
||||
name="answer_entry_grace_period_override",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text="Optional per-case override for additional answer-only time in seconds after case view lock.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="casedetail",
|
||||
name="question_time_limit_override",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text="Optional per-case override for the answer time limit in seconds.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="casecollection",
|
||||
name="answer_entry_grace_period",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
default=0,
|
||||
help_text="Additional seconds after the case view is locked during which answers can still be entered.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="casecollection",
|
||||
name="end_overview_content",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
help_text="Optional HTML content shown on the collection end overview screen.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="casecollection",
|
||||
name="end_overview_resources",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Optional resources shown on the collection end overview screen.",
|
||||
related_name="end_overview_collections",
|
||||
to="atlas.resource",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="casecollection",
|
||||
name="start_screen_content",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
help_text="Optional HTML content shown on the collection start screen (for example embedded resources).",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="casecollection",
|
||||
name="start_screen_resources",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Optional resources shown on the collection start screen.",
|
||||
related_name="start_screen_collections",
|
||||
to="atlas.resource",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("atlas", "0099_casecollection_workflow_customisation"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="casecollection",
|
||||
name="show_results_sharing_information",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text="If true, show result-sharing information on candidate start and overview screens.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-11 08:56
|
||||
|
||||
from django.db import migrations, models
|
||||
import pydicom
|
||||
|
||||
|
||||
def backfill_series_study_instance_uid(apps, schema_editor):
|
||||
Series = apps.get_model('atlas', 'Series')
|
||||
SeriesImage = apps.get_model('atlas', 'SeriesImage')
|
||||
|
||||
series_qs = Series.objects.filter(study_instance_uid__isnull=True)
|
||||
|
||||
for series in series_qs.iterator():
|
||||
image_obj = (
|
||||
SeriesImage.objects.filter(series_id=series.pk, removed=False)
|
||||
.exclude(image='')
|
||||
.order_by('pk')
|
||||
.first()
|
||||
)
|
||||
if image_obj is None:
|
||||
image_obj = (
|
||||
SeriesImage.objects.filter(series_id=series.pk)
|
||||
.exclude(image='')
|
||||
.order_by('pk')
|
||||
.first()
|
||||
)
|
||||
|
||||
if image_obj is None or not image_obj.image:
|
||||
continue
|
||||
|
||||
try:
|
||||
with image_obj.image.open('rb') as fp:
|
||||
ds = pydicom.dcmread(fp, stop_before_pixels=True, force=True)
|
||||
value = ds.get('StudyInstanceUID')
|
||||
if value is None:
|
||||
continue
|
||||
if hasattr(value, 'value'):
|
||||
value = value.value
|
||||
study_uid = str(value).strip()
|
||||
if not study_uid:
|
||||
continue
|
||||
|
||||
Series.objects.filter(pk=series.pk).update(study_instance_uid=study_uid)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def noop_reverse(apps, schema_editor):
|
||||
return
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0100_casecollection_show_results_sharing_information'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='series',
|
||||
name='study_instance_uid',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.RunPython(backfill_series_study_instance_uid, noop_reverse),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-11 11:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0101_series_study_instance_uid'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='seriesimage',
|
||||
name='image_blake3_hash',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=64, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='uncategoriseddicom',
|
||||
name='image_blake3_hash',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=64, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 6.0.1 on 2026-05-12 20:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0102_alter_seriesimage_image_blake3_hash_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='auto_apply_question_template',
|
||||
field=models.BooleanField(default=False, help_text='If enabled, the case_question_template is automatically applied to new cases added to this collection that do not have an existing question_schema.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='case_question_max_score',
|
||||
field=models.PositiveIntegerField(blank=True, help_text='Maximum score per case used for normalisation. Defaults to 10 when not set.', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='case_question_template',
|
||||
field=models.JSONField(blank=True, help_text='JSON schema applied as a default question template for all cases in this collection. Individual cases can override via CaseDetail.question_schema.', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='marking_guidance',
|
||||
field=models.TextField(blank=True, help_text='Plain-language marking guidance displayed to markers.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='marking_scheme_name',
|
||||
field=models.CharField(blank=True, help_text='Human-readable mark scheme name shown to markers and in exports.', max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("atlas", "0103_casecollection_auto_apply_question_template_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="series",
|
||||
name="source_series_instance_uid",
|
||||
field=models.CharField(blank=True, db_index=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-01 09:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0104_series_source_series_instance_uid'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='case',
|
||||
name='study_date',
|
||||
field=models.DateField(blank=True, help_text='Optional manually overridden study date for linked case series.', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-01 11:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0105_case_study_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casedetail',
|
||||
name='learner_comment',
|
||||
field=models.TextField(blank=True, default='', help_text='Comment shown to the learner while taking the case and on review.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casedetail',
|
||||
name='learner_review_comment',
|
||||
field=models.TextField(blank=True, default='', help_text='Comment shown to the learner only on review.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casedetail',
|
||||
name='series_visibility_config',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Per-series visibility map for this case in this collection.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-11 20:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0106_casedetail_learner_comment_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='caseprior',
|
||||
name='relation_type',
|
||||
field=models.CharField(choices=[('PR', 'Prior'), ('FU', 'Future')], default='PR', help_text='Defines whether the related case is a prior or a future exam', max_length=2),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-16 11:53
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0107_caseprior_relation_type'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MultiuserSession',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(blank=True, max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('active_case', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='atlas.case')),
|
||||
('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_multiuser_sessions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-28 20:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0108_multiusersession'),
|
||||
('survey', '__first__'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='post_survey',
|
||||
field=models.ForeignKey(blank=True, help_text='Survey to be filled out AFTER completing this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_post_surveys', to='survey.survey'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='pre_survey',
|
||||
field=models.ForeignKey(blank=True, help_text='Survey to be filled out BEFORE taking this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_pre_surveys', to='survey.survey'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-29 08:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0109_casecollection_post_survey_casecollection_pre_survey'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='post_survey_mandatory',
|
||||
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the post-take survey to view results.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='pre_survey_mandatory',
|
||||
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the pre-take survey to start.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-13 10:41
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0110_casecollection_post_survey_mandatory_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='MultiuserSession',
|
||||
),
|
||||
]
|
||||
+1314
-89
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,483 @@
|
||||
// Simple markup inserter module
|
||||
(function(global){
|
||||
console.log('markup_inserter.js loaded');
|
||||
function insertAtCursor(el, text){
|
||||
if(!el) return;
|
||||
const start = el.selectionStart || 0;
|
||||
const end = el.selectionEnd || 0;
|
||||
const val = el.value || '';
|
||||
el.value = val.slice(0, start) + text + val.slice(end);
|
||||
el.selectionStart = el.selectionEnd = start + text.length;
|
||||
el.focus();
|
||||
}
|
||||
|
||||
function createToolbar(){
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'd-flex gap-2 mb-2 markup-inserter-toolbar';
|
||||
const group = document.createElement('div');
|
||||
group.className = 'btn-group';
|
||||
const makeBtn = function(type, text){
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'btn btn-sm btn-outline-secondary markup-insert-button';
|
||||
b.dataset.type = type; b.textContent = text; return b;
|
||||
};
|
||||
group.appendChild(makeBtn('case','Insert case'));
|
||||
group.appendChild(makeBtn('finding','Insert finding'));
|
||||
group.appendChild(makeBtn('displayset','Insert displayset'));
|
||||
const hint = document.createElement('div'); hint.className='flex-fill text-muted small align-self-center'; hint.textContent='Use the buttons to insert markup like [[case:123]]';
|
||||
wrap.appendChild(group); wrap.appendChild(hint);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function attachToolbarToTextarea(textarea){
|
||||
if(!textarea) return null;
|
||||
// avoid double attaching
|
||||
if(textarea._markupToolbarAttached) return null;
|
||||
console.log('Attaching markup toolbar to textarea', textarea);
|
||||
const toolbar = createToolbar();
|
||||
textarea.parentNode.insertBefore(toolbar, textarea);
|
||||
toolbar.querySelectorAll('.markup-insert-button').forEach(function(btn){
|
||||
btn.addEventListener('click', function(){
|
||||
const t = btn.dataset.type;
|
||||
// Respect explicit dataset provided by the widget or page meta only
|
||||
var detectedCase = textarea && textarea.dataset && textarea.dataset.currentCase ? textarea.dataset.currentCase : (document.getElementById('current-case-meta') ? document.getElementById('current-case-meta').getAttribute('data-current-case') : null);
|
||||
if(t === 'case'){
|
||||
openCasePicker(textarea);
|
||||
} else if(t === 'finding'){
|
||||
openFindingPicker(textarea, detectedCase);
|
||||
} else if(t === 'displayset'){
|
||||
openDisplaysetPicker(textarea, detectedCase);
|
||||
}
|
||||
});
|
||||
});
|
||||
textarea._markupToolbarAttached = true;
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
function initForNames(names){
|
||||
if(!Array.isArray(names)) return;
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
console.log('markupInserter.initForNames DOMContentLoaded handler running for', names);
|
||||
names.forEach(function(name){
|
||||
const el = document.querySelector('textarea[name="'+name+'"]');
|
||||
if(el) attachToolbarToTextarea(el);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function attachToSelector(selector){
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
document.querySelectorAll(selector).forEach(function(el){
|
||||
if(el.tagName === 'TEXTAREA') attachToolbarToTextarea(el);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-attach to placeholders rendered in HTMX responses or server-side includes.
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
console.log('markupInserter DOMContentLoaded auto-attach running');
|
||||
// Attach to explicit placeholders
|
||||
document.querySelectorAll('.markup-toolbar-placeholder').forEach(function(ph){
|
||||
let ta = ph.nextElementSibling;
|
||||
if(ta && ta.tagName === 'TEXTAREA') attachToolbarToTextarea(ta);
|
||||
});
|
||||
|
||||
// Also auto-init for common case textarea names to cover full-page forms
|
||||
try{ initForNames(['description','history','discussion','report','notes']); }catch(e){}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterSwap', function(evt){
|
||||
console.log('markupInserter htmx:afterSwap event', evt && evt.detail && evt.detail.target);
|
||||
try{
|
||||
const target = evt.detail && evt.detail.target ? evt.detail.target : document;
|
||||
(target.querySelectorAll ? target : document).querySelectorAll('.markup-toolbar-placeholder').forEach(function(ph){
|
||||
let ta = ph.nextElementSibling;
|
||||
if(ta && ta.tagName === 'TEXTAREA') attachToolbarToTextarea(ta);
|
||||
});
|
||||
|
||||
// Also attach to any textareas within the swapped target that match our common names
|
||||
var ctx = evt.detail && evt.detail.target ? evt.detail.target : document;
|
||||
['description','history','discussion','report','notes'].forEach(function(name){
|
||||
ctx.querySelectorAll && ctx.querySelectorAll('textarea[name="'+name+'"]') && ctx.querySelectorAll('textarea[name="'+name+'"]').forEach(function(ta){ attachToolbarToTextarea(ta); });
|
||||
});
|
||||
}catch(e){/* ignore */}
|
||||
});
|
||||
|
||||
// Fallback: attach toolbar when a textarea receives focus (covers dynamically created editors)
|
||||
document.addEventListener('focusin', function(e){
|
||||
console.log('markupInserter focusin event for', e && e.target);
|
||||
try{
|
||||
var el = e.target;
|
||||
if(!el || el.tagName !== 'TEXTAREA') return;
|
||||
// Attach for common names or if a placeholder is present
|
||||
var name = el.getAttribute('name') || '';
|
||||
if(['description','history','discussion','report','notes'].includes(name) || (el.previousElementSibling && el.previousElementSibling.classList && el.previousElementSibling.classList.contains('markup-toolbar-placeholder'))){
|
||||
attachToolbarToTextarea(el);
|
||||
}
|
||||
}catch(err){/* ignore */}
|
||||
}, true);
|
||||
|
||||
// Expose API
|
||||
global.markupInserter = {
|
||||
attachToolbarToTextarea: attachToolbarToTextarea,
|
||||
initForNames: initForNames,
|
||||
attachToSelector: attachToSelector
|
||||
};
|
||||
|
||||
// --- Case picker modal ---
|
||||
function openCasePicker(textarea){
|
||||
try{
|
||||
console.log('Opening case picker for', textarea);
|
||||
// Create overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'markup-case-picker-overlay';
|
||||
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'markup-case-picker-dialog card';
|
||||
Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
|
||||
|
||||
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center';
|
||||
header.innerHTML = '<strong>Select case</strong>';
|
||||
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close');
|
||||
closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); });
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
const body = document.createElement('div'); body.className='card-body';
|
||||
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search cases...';
|
||||
const results = document.createElement('div'); results.className='case-search-results';
|
||||
body.appendChild(input); body.appendChild(results);
|
||||
|
||||
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
input.focus();
|
||||
|
||||
var timer = null;
|
||||
input.addEventListener('input', function(){
|
||||
if(timer) clearTimeout(timer);
|
||||
timer = setTimeout(function(){
|
||||
var q = encodeURIComponent(input.value || '');
|
||||
// Fetch server-side partial that renders result list-group
|
||||
var url = window.location.origin + '/atlas/search/cases/?q=' + q;
|
||||
fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){
|
||||
results.innerHTML = html;
|
||||
}).catch(function(e){ console.error('case picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; });
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Delegate click on result items
|
||||
results.addEventListener('click', function(e){
|
||||
var item = e.target.closest('.list-group-item');
|
||||
if(!item) return;
|
||||
var pk = item.getAttribute('data-case-pk');
|
||||
if(!pk){
|
||||
var a = item.querySelector('a');
|
||||
if(a){ var m = a.getAttribute('href').match(/case\/(\d+)\/?/); if(m) pk = m[1]; }
|
||||
}
|
||||
if(!pk) return;
|
||||
var defaultLabel = item.getAttribute('data-label') || (item.querySelector('h6') ? item.querySelector('h6').textContent.trim() : '');
|
||||
var custom = window.prompt('Custom text (leave empty to use default)', defaultLabel || '');
|
||||
if(custom === null){ if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); return; }
|
||||
var labelToUse = custom && custom.length ? custom : (defaultLabel || null);
|
||||
var token = '[[' + 'case:' + pk + (labelToUse ? '|' + labelToUse : '') + ']]';
|
||||
insertAtCursor(textarea, token);
|
||||
// remove modal
|
||||
if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
});
|
||||
|
||||
// Allow ESC to close
|
||||
overlay.addEventListener('keydown', function(ev){ if(ev.key === 'Escape'){ if(overlay.parentNode) overlay.parentNode.removeChild(overlay); } });
|
||||
// make overlay focusable so keydown works
|
||||
overlay.tabIndex = -1; overlay.focus();
|
||||
// load initial recent results (empty query)
|
||||
fetch(window.location.origin + '/atlas/search/cases/').then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){});
|
||||
}catch(err){ console.error('openCasePicker error', err); }
|
||||
}
|
||||
|
||||
function openFindingPicker(textarea, caseId){
|
||||
try{
|
||||
console.log('Opening finding picker for', textarea);
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'markup-case-picker-overlay';
|
||||
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
|
||||
const dialog = document.createElement('div'); dialog.className='markup-case-picker-dialog card'; Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
|
||||
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center'; header.innerHTML = '<strong>Select finding</strong>';
|
||||
// Determine case filter from explicit dataset on the textarea or provided caseId
|
||||
var detectedF = (textarea && textarea.dataset && textarea.dataset.currentCase) ? { id: textarea.dataset.currentCase, title: textarea.dataset.currentCaseTitle } : {};
|
||||
var currentCaseF = caseId || detectedF.id || null;
|
||||
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close'); closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); }); header.appendChild(closeBtn);
|
||||
const body = document.createElement('div'); body.className='card-body';
|
||||
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search findings...';
|
||||
const results = document.createElement('div'); results.className='finding-search-results'; body.appendChild(input); body.appendChild(results);
|
||||
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog); document.body.appendChild(overlay); input.focus();
|
||||
|
||||
var timer = null;
|
||||
input.addEventListener('input', function(){ if(timer) clearTimeout(timer); timer = setTimeout(function(){ var q = encodeURIComponent(input.value || ''); var url = window.location.origin + '/atlas/search/findings/?q=' + q + (currentCaseF ? '&case=' + currentCaseF : ''); fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(e){ console.error('finding picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; }); }, 300); });
|
||||
|
||||
results.addEventListener('click', function(e){
|
||||
var item = e.target.closest('.list-group-item');
|
||||
if(!item) return;
|
||||
var sfpk = item.getAttribute('data-seriesfinding-pk');
|
||||
var fpk = item.getAttribute('data-finding-pk');
|
||||
var defaultLabel = item.getAttribute('data-label') || (item.querySelector('strong') ? item.querySelector('strong').textContent.trim() : '');
|
||||
var custom = window.prompt('Custom text (leave empty to use default)', defaultLabel || '');
|
||||
if(custom === null){ if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); return; }
|
||||
var labelToUse = custom && custom.length ? custom : (defaultLabel || null);
|
||||
if(sfpk){
|
||||
var token = '[['+'seriesfinding:'+sfpk + (labelToUse ? '|' + labelToUse : '') + ']]';
|
||||
insertAtCursor(textarea, token);
|
||||
} else if(fpk){
|
||||
var token = '[['+'finding:'+fpk + (labelToUse ? '|' + labelToUse : '') + ']]';
|
||||
insertAtCursor(textarea, token);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
});
|
||||
overlay.tabIndex = -1; overlay.focus();
|
||||
function fetchInitialFindings(){ var initFindingsUrl = window.location.origin + '/atlas/search/findings/' + (currentCaseF ? '?case=' + currentCaseF : ''); fetch(initFindingsUrl).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){}); }
|
||||
fetchInitialFindings();
|
||||
}catch(err){ console.error('openFindingPicker error', err); }
|
||||
}
|
||||
|
||||
function openDisplaysetPicker(textarea, caseId){
|
||||
try{
|
||||
console.log('Opening displayset picker for', textarea);
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'markup-case-picker-overlay';
|
||||
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
|
||||
const dialog = document.createElement('div'); dialog.className='markup-case-picker-dialog card'; Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
|
||||
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center';
|
||||
header.innerHTML = '<strong>Select display set</strong>';
|
||||
// Determine case filter from explicit dataset on the textarea or provided caseId
|
||||
var detectedD = (textarea && textarea.dataset && textarea.dataset.currentCase) ? { id: textarea.dataset.currentCase, title: textarea.dataset.currentCaseTitle } : {};
|
||||
var currentCaseD = caseId || detectedD.id || null;
|
||||
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close'); closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); }); header.appendChild(closeBtn);
|
||||
const body = document.createElement('div'); body.className='card-body';
|
||||
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search display sets...';
|
||||
const results = document.createElement('div'); results.className='displayset-search-results'; body.appendChild(input); body.appendChild(results);
|
||||
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog); document.body.appendChild(overlay); input.focus();
|
||||
|
||||
var timer = null;
|
||||
input.addEventListener('input', function(){ if(timer) clearTimeout(timer); timer = setTimeout(function(){ var q = encodeURIComponent(input.value || ''); var url = window.location.origin + '/atlas/search/displaysets/?q=' + q + (currentCaseD ? '&case=' + currentCaseD : ''); fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(e){ console.error('displayset picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; }); }, 300); });
|
||||
|
||||
results.addEventListener('click', function(e){
|
||||
var item = e.target.closest('.list-group-item');
|
||||
if(!item) return;
|
||||
var pk = item.getAttribute('data-ds-pk');
|
||||
if(!pk) return;
|
||||
var defaultLabel = item.getAttribute('data-label') || (item.querySelector('strong') ? item.querySelector('strong').textContent.trim() : '');
|
||||
var custom = window.prompt('Custom text (leave empty to use default)', defaultLabel || '');
|
||||
if(custom === null){ if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); return; }
|
||||
var labelToUse = custom && custom.length ? custom : (defaultLabel || null);
|
||||
var token = '[['+'displayset:'+pk + (labelToUse ? '|' + labelToUse : '') + ']]';
|
||||
insertAtCursor(textarea, token);
|
||||
if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
});
|
||||
overlay.tabIndex = -1; overlay.focus();
|
||||
function fetchInitialDisplaysets(){ var initDisplaysetsUrl = window.location.origin + '/atlas/search/displaysets/' + (currentCaseD ? '?case=' + currentCaseD : ''); fetch(initDisplaysetsUrl).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){}); }
|
||||
fetchInitialDisplaysets();
|
||||
}catch(err){ console.error('openDisplaysetPicker error', err); }
|
||||
}
|
||||
|
||||
// Simplified detection: rely on explicit dataset attributes set by the widget
|
||||
// When a textarea includes `data-current-case` and optionally `data-current-case-title`, that will be used as the default filter.
|
||||
|
||||
// --- Render inserted markup tokens as styled items with popup/modal actions ---
|
||||
function injectMarkupStyles(){
|
||||
if(document.getElementById('markup-inserter-styles')) return;
|
||||
var s = document.createElement('style'); s.id = 'markup-inserter-styles';
|
||||
s.textContent = '\n.markup-token{cursor:pointer;display:inline-block;margin:0 .25rem .25rem 0;padding:.25rem .5rem;border-radius:.375rem;font-size:0.85em;}\n.markup-popup{position:fixed;z-index:1060;background:#fff;border:1px solid rgba(0,0,0,.12);box-shadow:0 6px 18px rgba(0,0,0,.12);padding:.5rem;border-radius:.25rem;}\n.markup-modal-backdrop{position:fixed;left:0;right:0;top:0;bottom:0;background:rgba(0,0,0,.45);z-index:1080;display:flex;align-items:flex-start;justify-content:center;padding-top:4vh;}\n.markup-modal{background:#fff;border-radius:.375rem;max-width:95%;max-height:85vh;overflow:auto;}\n';
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function getDetailUrl(type, pk){
|
||||
if(!type || !pk) return null;
|
||||
if(type === 'seriesfinding') return window.location.origin + '/atlas/series_finding/' + pk + '/preview/';
|
||||
if(type === 'finding') return window.location.origin + '/atlas/finding/' + pk;
|
||||
if(type === 'displayset') return window.location.origin + '/atlas/displayset/' + pk + '/preview/';
|
||||
if(type === 'case') return window.location.origin + '/atlas/case/' + pk + '/';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reusable modal element
|
||||
var __markup_modal = null;
|
||||
function ensureModal(){
|
||||
if(__markup_modal) return __markup_modal;
|
||||
var backdrop = document.createElement('div'); backdrop.className = 'markup-modal-backdrop'; backdrop.style.display='none';
|
||||
var modal = document.createElement('div'); modal.className = 'markup-modal card'; modal.style.marginTop='1rem'; modal.style.maxHeight='80vh'; modal.style.overflow='auto';
|
||||
var header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center';
|
||||
var h = document.createElement('strong'); h.textContent = 'Detail';
|
||||
var closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.addEventListener('click', function(){ backdrop.style.display='none'; modal.querySelector('.card-body') && (modal.querySelector('.card-body').innerHTML=''); });
|
||||
header.appendChild(h); header.appendChild(closeBtn);
|
||||
var body = document.createElement('div'); body.className='card-body'; body.style.maxHeight='70vh'; body.style.overflow='auto';
|
||||
modal.appendChild(header); modal.appendChild(body); backdrop.appendChild(modal); document.body.appendChild(backdrop);
|
||||
__markup_modal = { backdrop: backdrop, modal: modal, body: body };
|
||||
return __markup_modal;
|
||||
}
|
||||
|
||||
function showModalForUrl(url, title){
|
||||
var m = ensureModal();
|
||||
m.backdrop.style.display = 'flex';
|
||||
if(title){ var h = m.modal.querySelector('strong'); if(h) h.textContent = title; }
|
||||
m.body.innerHTML = '<div class="p-3 text-muted">Loading…</div>';
|
||||
fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ m.body.innerHTML = html; }).catch(function(e){ m.body.innerHTML = '<div class="p-3 text-danger">Failed to load</div>'; console.error('modal fetch error', e); });
|
||||
}
|
||||
|
||||
function showPopupFor(element){
|
||||
// remove existing
|
||||
var existing = document.getElementById('markup-popup'); if(existing) existing.remove();
|
||||
var type = element.getAttribute('data-type'); var pk = element.getAttribute('data-pk'); var label = element.getAttribute('data-label') || element.textContent || '';
|
||||
var popup = document.createElement('div'); popup.id = 'markup-popup'; popup.className = 'markup-popup';
|
||||
popup.innerHTML = '<div style="font-weight:600;margin-bottom:.25rem;">'+ (label||type+':'+pk) +'</div>';
|
||||
var openBtn = document.createElement('button'); openBtn.type='button'; openBtn.className='btn btn-sm btn-outline-primary me-2'; openBtn.textContent='Open';
|
||||
openBtn.addEventListener('click', function(){
|
||||
var url = getDetailUrl(type, pk);
|
||||
try{
|
||||
// Prefer page-specific modal loaders if present so we reuse existing Bootstrap modals/viewers
|
||||
if(type === 'displayset'){
|
||||
if(typeof window.loadDisplaysetModal === 'function'){
|
||||
window.loadDisplaysetModal(url, pk);
|
||||
popup.remove();
|
||||
return;
|
||||
}
|
||||
var dsBtn = document.querySelector('.view-displayset-modal[data-ds-id="'+pk+'"]');
|
||||
if(dsBtn){ dsBtn.click(); popup.remove(); return; }
|
||||
}
|
||||
if(type === 'seriesfinding' || type === 'finding'){
|
||||
if(typeof window.loadFindingModal === 'function'){
|
||||
window.loadFindingModal(pk, url);
|
||||
popup.remove();
|
||||
return;
|
||||
}
|
||||
var fBtn = document.querySelector('.view-finding-modal[data-finding-id="'+pk+'"]');
|
||||
if(fBtn){ fBtn.click(); popup.remove(); return; }
|
||||
}
|
||||
}catch(e){ console.warn('error invoking page modal loader', e); }
|
||||
if(url) showModalForUrl(url, label);
|
||||
popup.remove();
|
||||
});
|
||||
var closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn btn-sm btn-outline-secondary'; closeBtn.textContent='Close'; closeBtn.addEventListener('click', function(){ popup.remove(); });
|
||||
popup.appendChild(openBtn); popup.appendChild(closeBtn);
|
||||
document.body.appendChild(popup);
|
||||
// position near element
|
||||
try{
|
||||
var r = element.getBoundingClientRect();
|
||||
popup.style.top = (window.scrollY + r.bottom + 6) + 'px';
|
||||
popup.style.left = Math.min(window.innerWidth - 240, window.scrollX + r.left) + 'px';
|
||||
popup.style.minWidth = '160px';
|
||||
}catch(e){}
|
||||
// clicking outside closes it
|
||||
setTimeout(function(){
|
||||
var onDoc = function(ev){ if(!popup.contains(ev.target) && ev.target !== element) { popup.remove(); document.removeEventListener('click', onDoc); } };
|
||||
document.addEventListener('click', onDoc);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
// Try to open a modal for a token (prefer page-specific modal loaders)
|
||||
function openModalForToken(element){
|
||||
var type = element.getAttribute('data-type'); var pk = element.getAttribute('data-pk'); var label = element.getAttribute('data-label') || element.textContent || '';
|
||||
var url = getDetailUrl(type, pk);
|
||||
try{
|
||||
if(type === 'displayset'){
|
||||
if(typeof window.loadDisplaysetModal === 'function'){
|
||||
window.loadDisplaysetModal(url, pk); return;
|
||||
}
|
||||
var dsBtn = document.querySelector('.view-displayset-modal[data-ds-id="'+pk+'"]');
|
||||
if(dsBtn){ dsBtn.click(); return; }
|
||||
}
|
||||
if(type === 'seriesfinding' || type === 'finding'){
|
||||
if(typeof window.loadFindingModal === 'function'){
|
||||
window.loadFindingModal(pk, url); return;
|
||||
}
|
||||
var fBtn = document.querySelector('.view-finding-modal[data-finding-id="'+pk+'"]');
|
||||
if(fBtn){ fBtn.click(); return; }
|
||||
}
|
||||
}catch(e){ console.warn('error invoking page modal loader', e); }
|
||||
if(url) showModalForUrl(url, label);
|
||||
}
|
||||
|
||||
// Replace tokens in text nodes with styled spans
|
||||
function renderMarkupTokens(root){
|
||||
injectMarkupStyles();
|
||||
root = root || document.body;
|
||||
// Convert server-rendered anchor tokens into our interactive spans
|
||||
try{
|
||||
if(root.querySelectorAll){
|
||||
root.querySelectorAll('a.markup-link').forEach(function(a){
|
||||
try{
|
||||
var type = a.getAttribute('data-type') || '';
|
||||
var args = [];
|
||||
try{ args = JSON.parse(a.getAttribute('data-args') || '[]'); }catch(e){ args = []; }
|
||||
var pk = (args && args.length) ? (args[0] + '') : (a.getAttribute('data-pk') || '');
|
||||
var label = a.textContent ? a.textContent.trim() : null;
|
||||
var span = document.createElement('span');
|
||||
span.className = 'markup-token';
|
||||
if(type) span.setAttribute('data-type', type);
|
||||
if(pk) span.setAttribute('data-pk', pk);
|
||||
if(label) span.setAttribute('data-label', label);
|
||||
if(type === 'case') span.className += ' badge bg-primary text-white';
|
||||
else if(type === 'displayset') span.className += ' badge bg-success text-white';
|
||||
else span.className += ' badge bg-info text-dark';
|
||||
span.textContent = label ? label : (type + ':' + pk);
|
||||
span.addEventListener('click', function(e){ e.stopPropagation(); openModalForToken(span); });
|
||||
a.parentNode.replaceChild(span, a);
|
||||
}catch(e){}
|
||||
});
|
||||
}
|
||||
}catch(e){}
|
||||
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
|
||||
var nodes = [];
|
||||
while(walker.nextNode()) nodes.push(walker.currentNode);
|
||||
var re = /\[\[(seriesfinding|finding|displayset|case):(\d+)(?:\|([^\]]+))?\]\]/gi;
|
||||
nodes.forEach(function(textNode){
|
||||
if(!textNode.nodeValue || !re.test(textNode.nodeValue)) return;
|
||||
var parentTag = textNode.parentElement && textNode.parentElement.tagName ? textNode.parentElement.tagName.toLowerCase() : '';
|
||||
if(['script','style','textarea','code','pre','a','button'].includes(parentTag)) return;
|
||||
// rebuild the fragment
|
||||
var frag = document.createDocumentFragment();
|
||||
var txt = textNode.nodeValue;
|
||||
var lastIndex = 0; var m;
|
||||
re.lastIndex = 0;
|
||||
while((m = re.exec(txt)) !== null){
|
||||
var before = txt.slice(lastIndex, m.index);
|
||||
if(before) frag.appendChild(document.createTextNode(before));
|
||||
var type = m[1].toLowerCase(); var pk = m[2]; var label = m[3] ? m[3] : null;
|
||||
var span = document.createElement('span'); span.className = 'markup-token'; span.setAttribute('data-type', type); span.setAttribute('data-pk', pk); if(label) span.setAttribute('data-label', label);
|
||||
// style by type
|
||||
if(type === 'case') span.className += ' badge bg-primary text-white';
|
||||
else if(type === 'displayset') span.className += ' badge bg-success text-white';
|
||||
else span.className += ' badge bg-info text-dark';
|
||||
span.textContent = label ? label : (type + ':' + pk);
|
||||
span.addEventListener('click', function(e){ e.stopPropagation(); openModalForToken(span); });
|
||||
frag.appendChild(span);
|
||||
lastIndex = re.lastIndex;
|
||||
}
|
||||
var rest = txt.slice(lastIndex); if(rest) frag.appendChild(document.createTextNode(rest));
|
||||
textNode.parentNode.replaceChild(frag, textNode);
|
||||
});
|
||||
|
||||
// Ensure any server-rendered span.markup-token elements have click handlers
|
||||
try{
|
||||
if(root.querySelectorAll){
|
||||
root.querySelectorAll('span.markup-token').forEach(function(span){
|
||||
try{
|
||||
if(span._markupBound) return;
|
||||
span._markupBound = true;
|
||||
// ensure data attributes are present
|
||||
var type = span.getAttribute('data-type') || '';
|
||||
var pk = span.getAttribute('data-pk') || '';
|
||||
var label = span.getAttribute('data-label') || span.textContent || '';
|
||||
span.addEventListener('click', function(e){ e.stopPropagation(); openModalForToken(span); });
|
||||
}catch(e){}
|
||||
});
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
// Run rendering on DOMContentLoaded and after HTMX swaps
|
||||
document.addEventListener('DOMContentLoaded', function(){ try{ renderMarkupTokens(document.body); }catch(e){console.error(e);} });
|
||||
document.addEventListener('htmx:afterSwap', function(evt){ try{ var target = evt.detail && evt.detail.target ? evt.detail.target : document; renderMarkupTokens(target); }catch(e){/*ignore*/} });
|
||||
|
||||
})(window);
|
||||
|
||||
+83
-19
@@ -14,10 +14,15 @@ from .models import (
|
||||
Structure,
|
||||
Finding,
|
||||
Subspecialty,
|
||||
Resource,
|
||||
Procedure,
|
||||
)
|
||||
|
||||
from django.utils.html import format_html
|
||||
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.template.defaultfilters import filesizeformat
|
||||
|
||||
from easy_thumbnails.files import get_thumbnailer
|
||||
|
||||
from easy_thumbnails.exceptions import InvalidImageFormatError
|
||||
@@ -82,12 +87,14 @@ class CaseTable(SelectionTable):
|
||||
|
||||
def get_view_cell(record):
|
||||
if record.open_access:
|
||||
return format_html(f"""<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">
|
||||
<path fill-opacity=".4" d="M12.995 8.195c0 .937-.312 1.912-.78 2.693l1.99 1.99c.976-1.327 1.6-2.966 1.6-4.683 0-1.795-.624-3.434-1.561-4.76l-2.068 2.028c.468.781.78 1.679.78 2.732h.04Z"/>
|
||||
<path d="M8 13.151a4.995 4.995 0 1 1 0-9.99c1.015 0 1.951.273 2.732.82l1.95-2.03a7.805 7.805 0 1 0 .04 12.449l-1.951-2.03a5.072 5.072 0 0 1-2.732.781z"/>
|
||||
</svg> View""")
|
||||
return mark_safe(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">'
|
||||
'<path fill-opacity=".4" d="M12.995 8.195c0 .937-.312 1.912-.78 2.693l1.99 1.99c.976-1.327 1.6-2.966 1.6-4.683 0-1.795-.624-3.434-1.561-4.76l-2.068 2.028c.468.781.78 1.679.78 2.732h.04Z"/>'
|
||||
'<path d="M8 13.151a4.995 4.995 0 1 1 0-9.99c1.015 0 1.951.273 2.732.82l1.95-2.03a7.805 7.805 0 1 0 .04 12.449l-1.951-2.03a5.072 5.072 0 0 1-2.732.781z"/>'
|
||||
'</svg> View'
|
||||
)
|
||||
else:
|
||||
return f'View'
|
||||
return 'View'
|
||||
|
||||
def render_author(self, value):
|
||||
max_length = 15 # Set the maximum length for truncating individual author names
|
||||
@@ -107,7 +114,7 @@ class CaseTable(SelectionTable):
|
||||
)
|
||||
)
|
||||
# Join all authors with commas
|
||||
return format_html(", ".join(authors))
|
||||
return mark_safe(", ".join(authors))
|
||||
|
||||
def render_associated_cases(self, record):
|
||||
links = []
|
||||
@@ -129,7 +136,7 @@ class CaseTable(SelectionTable):
|
||||
except Case.DoesNotExist:
|
||||
pass
|
||||
if links:
|
||||
return format_html(", ".join(links))
|
||||
return mark_safe(", ".join(links))
|
||||
return "No associated cases"
|
||||
|
||||
def render_collections(self, record):
|
||||
@@ -150,9 +157,10 @@ class CaseTable(SelectionTable):
|
||||
)
|
||||
for collection in collections
|
||||
]
|
||||
return format_html(", ".join(links))
|
||||
return mark_safe(", ".join(links))
|
||||
return "No collections"
|
||||
|
||||
|
||||
associated_cases = tables.Column(
|
||||
verbose_name="Associated Cases", accessor="pk", orderable=False
|
||||
)
|
||||
@@ -165,7 +173,7 @@ class CaseTable(SelectionTable):
|
||||
"atlas:case_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
view = tables.LinkColumn(
|
||||
"atlas:case_detail", text=get_view_cell, args=[A("pk")], orderable=False
|
||||
"atlas:case_detail", text=get_view_cell, args=[A("pk")], accessor="pk", orderable=False
|
||||
)
|
||||
clone = tables.LinkColumn(
|
||||
"atlas:case_clone", text="Clone", args=[A("pk")], orderable=False
|
||||
@@ -198,7 +206,7 @@ class PopupLinkColumn(tables.Column):
|
||||
obj = value.first()
|
||||
|
||||
if obj is None:
|
||||
return format_html("<span>No image<span>")
|
||||
return mark_safe("<span>No image</span>")
|
||||
|
||||
return obj.get_thumbnail()[0]
|
||||
|
||||
@@ -208,7 +216,7 @@ class SeriesTable(SelectionTable):
|
||||
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
view = tables.LinkColumn(
|
||||
"atlas:series_detail", text="View", args=[A("pk")], orderable=False
|
||||
"atlas:series_detail", text="View", args=[A("pk")], accessor="pk", orderable=False
|
||||
)
|
||||
popup = tables.Column(empty_values=(), orderable=False)
|
||||
|
||||
@@ -275,7 +283,7 @@ class ConditionTable(SelectionTable):
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Condition
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
||||
fields = ("subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
||||
sequence = ("name",)
|
||||
|
||||
|
||||
@@ -289,7 +297,7 @@ class ConditionTable(SelectionTable):
|
||||
)
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return mark_safe(record.get_synonym_link())
|
||||
return f"{record.get_synonym_link()}"
|
||||
|
||||
|
||||
@@ -311,12 +319,12 @@ class FindingTable(SelectionTable):
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Finding
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "primary")
|
||||
fields = ("name",)
|
||||
sequence = ("name",)
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return mark_safe(record.get_synonym_link())
|
||||
|
||||
|
||||
class StructureTable(SelectionTable):
|
||||
@@ -338,12 +346,12 @@ class StructureTable(SelectionTable):
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Structure
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "primary")
|
||||
fields = ("name",)
|
||||
sequence = ("name",)
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return mark_safe(record.get_synonym_link())
|
||||
|
||||
|
||||
class PresentationTable(SelectionTable):
|
||||
@@ -390,6 +398,26 @@ class PathologicalProcessTable(SelectionTable):
|
||||
sequence = ("name",)
|
||||
|
||||
|
||||
class ProcedureTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:procedure_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="Procedure",
|
||||
)
|
||||
|
||||
edit = tables.LinkColumn(
|
||||
"atlas:procedure_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
delete = tables.LinkColumn(
|
||||
"atlas:procedure_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Procedure
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name",)
|
||||
sequence = ("name",)
|
||||
|
||||
|
||||
class SubspecialtyTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:subspecialty_detail", {"pk": tables.A("pk")}),
|
||||
@@ -411,16 +439,31 @@ class SubspecialtyTable(SelectionTable):
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return mark_safe(record.get_synonym_link())
|
||||
return f"{record.get_synonym_link()}"
|
||||
|
||||
|
||||
class ResourceTable(SelectionTable):
|
||||
name = tables.Column(linkify=("atlas:resource_detail", {"pk": tables.A("pk")}), verbose_name="Resource")
|
||||
|
||||
edit = tables.LinkColumn("atlas:resource_update", text="Edit", args=[A("pk")], orderable=False)
|
||||
delete = tables.LinkColumn("atlas:resource_delete", text="Delete", args=[A("pk")], orderable=False)
|
||||
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Resource
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "description")
|
||||
|
||||
class CaseCollectionTable(SelectionTable):
|
||||
edit = tables.LinkColumn(
|
||||
"atlas:exam_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
view = tables.LinkColumn(
|
||||
"atlas:collection_detail", text="View", args=[A("pk")], orderable=False
|
||||
"atlas:collection_detail", text="View", args=[A("pk")], accessor="pk", orderable=False
|
||||
)
|
||||
|
||||
created = tables.DateTimeColumn(verbose_name="Created", accessor="created", orderable=True)
|
||||
updated = tables.DateTimeColumn(verbose_name="Updated", accessor="updated", orderable=True)
|
||||
#clone = tables.LinkColumn(
|
||||
# "atlas:case_clone", text="Clone", args=[A("pk")], orderable=False
|
||||
#)
|
||||
@@ -436,6 +479,8 @@ class CaseCollectionTable(SelectionTable):
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = (
|
||||
"name",
|
||||
"created",
|
||||
"updated",
|
||||
"collection_type",
|
||||
"publish_results",
|
||||
"active",
|
||||
@@ -453,6 +498,25 @@ class CaseCollectionTable(SelectionTable):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def render_created(self, value):
|
||||
if not value:
|
||||
return ""
|
||||
# compact date for display, full datetime in tooltip
|
||||
return format_html(
|
||||
'<span class="small text-muted" title="{}">{}</span>',
|
||||
value.strftime("%Y-%m-%d %H:%M"),
|
||||
value.strftime("%Y-%m-%d"),
|
||||
)
|
||||
|
||||
def render_updated(self, value):
|
||||
if not value:
|
||||
return ""
|
||||
return format_html(
|
||||
'<span class="small text-muted" title="{}">{}</span>',
|
||||
value.strftime("%Y-%m-%d %H:%M"),
|
||||
value.strftime("%Y-%m-%d"),
|
||||
)
|
||||
|
||||
class QuestionSchemaTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:question_schema_detail", {"pk": tables.A("pk")}),
|
||||
|
||||
+559
-3
@@ -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)
|
||||
@@ -50,3 +237,372 @@ def push_case_to_cimar_task(case_id):
|
||||
cimar_case.refresh_study()
|
||||
|
||||
return 10
|
||||
|
||||
|
||||
@task(takes_context=True)
|
||||
def series_reconstruct_task(
|
||||
context,
|
||||
series_id,
|
||||
user_id,
|
||||
recon_planes,
|
||||
slice_thickness_val=None,
|
||||
slice_spacing_val=None,
|
||||
recon_thickness_mode="mean",
|
||||
):
|
||||
"""Generate reconstructions asynchronously for a series with progress updates."""
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
from atlas import views as atlas_views
|
||||
|
||||
progress_key = f"series_reconstruct_progress:{context.task_result.id}"
|
||||
cache.set(
|
||||
progress_key,
|
||||
{"current": 0, "total": 0, "message": "Preparing reconstruction..."},
|
||||
timeout=60 * 60,
|
||||
)
|
||||
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
user = get_object_or_404(User, pk=user_id)
|
||||
|
||||
if not series.check_user_can_edit(user):
|
||||
raise PermissionError("Permission denied")
|
||||
|
||||
images = list(series.get_images())
|
||||
dicom_items = []
|
||||
for image in images:
|
||||
ds = atlas_views._read_series_image_dataset(image)
|
||||
if ds is None:
|
||||
continue
|
||||
try:
|
||||
arr = ds.pixel_array
|
||||
if arr.ndim != 2:
|
||||
continue
|
||||
dicom_items.append((image, ds, arr))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if len(dicom_items) < 2:
|
||||
raise ValueError("Need at least 2 valid DICOM images in series for reconstruction")
|
||||
|
||||
base_shape = dicom_items[0][2].shape
|
||||
dicom_items = [item for item in dicom_items if item[2].shape == base_shape]
|
||||
if len(dicom_items) < 2:
|
||||
raise ValueError("Not enough consistently-sized slices for reconstruction")
|
||||
|
||||
geom = atlas_views._extract_recon_geometry(dicom_items)
|
||||
sorted_items = geom["sorted_items"]
|
||||
volume = np.stack([item[2] for item in sorted_items], axis=0)
|
||||
|
||||
template_ds = sorted_items[0][1]
|
||||
origin_ipp = geom["origin_ipp"]
|
||||
row_dir = geom["row_dir"]
|
||||
col_dir = geom["col_dir"]
|
||||
normal_dir = geom["normal_dir"]
|
||||
native_row_spacing = geom["row_spacing"]
|
||||
native_col_spacing = geom["col_spacing"]
|
||||
native_z_spacing = geom["native_z_spacing"]
|
||||
|
||||
requested_slice_spacing = float(slice_spacing_val) if slice_spacing_val is not None else None
|
||||
requested_slab_thickness = float(slice_thickness_val) if slice_thickness_val is not None else None
|
||||
|
||||
# Determine source acquisition plane from normal vector.
|
||||
dominant_normal_axis = int(np.argmax(np.abs(normal_dir)))
|
||||
source_plane = {0: "sagittal", 1: "coronal", 2: "axial"}.get(dominant_normal_axis, "unknown")
|
||||
|
||||
# Reorient the reconstructed volume into patient axes order [z, y, x] so
|
||||
# generated plane names are anatomically correct regardless of source plane.
|
||||
# NOTE: DICOM stores ImageOrientationPatient as:
|
||||
# - first triplet: direction across columns (x-axis in pixel grid)
|
||||
# - second triplet: direction across rows (y-axis in pixel grid)
|
||||
# In _extract_recon_geometry names are historically row_dir/col_dir, so we
|
||||
# map axis spacing/indexing explicitly to avoid directional mixups.
|
||||
source_axis_dirs = [normal_dir, col_dir, row_dir]
|
||||
source_axis_spacings = [float(native_z_spacing), float(native_row_spacing), float(native_col_spacing)]
|
||||
source_axis_sizes = [int(volume.shape[0]), int(volume.shape[1]), int(volume.shape[2])]
|
||||
|
||||
source_for_patient_axis = {}
|
||||
sign_for_patient_axis = {}
|
||||
patient_axes_claimed = set()
|
||||
|
||||
for src_axis, vec in enumerate(source_axis_dirs):
|
||||
patient_axis = int(np.argmax(np.abs(vec)))
|
||||
if patient_axis in patient_axes_claimed:
|
||||
raise ValueError("Could not infer unique source orientation axes for reconstruction")
|
||||
patient_axes_claimed.add(patient_axis)
|
||||
source_for_patient_axis[patient_axis] = src_axis
|
||||
sign_for_patient_axis[patient_axis] = 1 if float(vec[patient_axis]) >= 0 else -1
|
||||
|
||||
src_x = source_for_patient_axis[0]
|
||||
src_y = source_for_patient_axis[1]
|
||||
src_z = source_for_patient_axis[2]
|
||||
|
||||
# Build patient-axis basis vectors expressed in source/world space.
|
||||
# This preserves source world-frame orientation (including obliquity/sign)
|
||||
# for derived series instead of hardcoding cardinal axes.
|
||||
patient_x_vec = np.asarray(source_axis_dirs[src_x], dtype=float) * float(sign_for_patient_axis[0])
|
||||
patient_y_vec = np.asarray(source_axis_dirs[src_y], dtype=float) * float(sign_for_patient_axis[1])
|
||||
patient_z_vec = np.asarray(source_axis_dirs[src_z], dtype=float) * float(sign_for_patient_axis[2])
|
||||
|
||||
def _norm_vec(vec):
|
||||
nrm = float(np.linalg.norm(vec))
|
||||
if nrm == 0:
|
||||
return np.asarray(vec, dtype=float)
|
||||
return np.asarray(vec, dtype=float) / nrm
|
||||
|
||||
patient_x_vec = _norm_vec(patient_x_vec)
|
||||
patient_y_vec = _norm_vec(patient_y_vec)
|
||||
patient_z_vec = _norm_vec(patient_z_vec)
|
||||
|
||||
def _orientation_pair(col_vec, row_vec, desired_normal):
|
||||
col = _norm_vec(col_vec)
|
||||
row = _norm_vec(row_vec)
|
||||
desired = _norm_vec(desired_normal)
|
||||
normal = np.cross(col, row)
|
||||
if float(np.dot(normal, desired)) < 0:
|
||||
row = -row
|
||||
return [float(col[0]), float(col[1]), float(col[2]), float(row[0]), float(row[1]), float(row[2])]
|
||||
|
||||
spacing_x = source_axis_spacings[src_x]
|
||||
spacing_y = source_axis_spacings[src_y]
|
||||
spacing_z = source_axis_spacings[src_z]
|
||||
|
||||
vol_zyx = np.transpose(volume, (src_z, src_y, src_x))
|
||||
|
||||
if sign_for_patient_axis[2] < 0:
|
||||
vol_zyx = np.flip(vol_zyx, axis=0)
|
||||
if sign_for_patient_axis[1] < 0:
|
||||
vol_zyx = np.flip(vol_zyx, axis=1)
|
||||
if sign_for_patient_axis[0] < 0:
|
||||
vol_zyx = np.flip(vol_zyx, axis=2)
|
||||
|
||||
def source_indices_from_patient_zyx(iz, iy, ix):
|
||||
src_indices = [0.0, 0.0, 0.0]
|
||||
|
||||
z_index = float(iz)
|
||||
y_index = float(iy)
|
||||
x_index = float(ix)
|
||||
|
||||
if sign_for_patient_axis[2] < 0:
|
||||
z_index = source_axis_sizes[src_z] - 1 - z_index
|
||||
if sign_for_patient_axis[1] < 0:
|
||||
y_index = source_axis_sizes[src_y] - 1 - y_index
|
||||
if sign_for_patient_axis[0] < 0:
|
||||
x_index = source_axis_sizes[src_x] - 1 - x_index
|
||||
|
||||
src_indices[src_z] = z_index
|
||||
src_indices[src_y] = y_index
|
||||
src_indices[src_x] = x_index
|
||||
return src_indices
|
||||
|
||||
def position_from_patient_zyx(iz, iy, ix):
|
||||
src_k, src_r, src_c = source_indices_from_patient_zyx(iz, iy, ix)
|
||||
pos = (
|
||||
np.asarray(origin_ipp, dtype=float)
|
||||
+ (np.asarray(normal_dir, dtype=float) * (float(src_k) * float(native_z_spacing)))
|
||||
+ (np.asarray(col_dir, dtype=float) * (float(src_r) * float(native_row_spacing)))
|
||||
+ (np.asarray(row_dir, dtype=float) * (float(src_c) * float(native_col_spacing)))
|
||||
)
|
||||
return [float(pos[0]), float(pos[1]), float(pos[2])]
|
||||
|
||||
def _reduce_slab(slab, reduce_axis=0):
|
||||
if recon_thickness_mode == "max":
|
||||
out = np.max(slab, axis=reduce_axis)
|
||||
elif recon_thickness_mode == "min":
|
||||
out = np.min(slab, axis=reduce_axis)
|
||||
else:
|
||||
out = np.mean(slab, axis=reduce_axis)
|
||||
|
||||
if np.issubdtype(volume.dtype, np.integer):
|
||||
info = np.iinfo(volume.dtype)
|
||||
out = np.clip(np.rint(out), info.min, info.max).astype(volume.dtype)
|
||||
else:
|
||||
out = out.astype(volume.dtype, copy=False)
|
||||
return out
|
||||
|
||||
def _slice_indices_for_axis(axis_length, axis_spacing, output_spacing, slab_thickness):
|
||||
if output_spacing <= 0:
|
||||
raise ValueError("Slice spacing must be greater than 0")
|
||||
if slab_thickness <= 0:
|
||||
raise ValueError("Slice thickness must be greater than 0")
|
||||
|
||||
axis_positions_mm = np.arange(axis_length, dtype=float) * float(axis_spacing)
|
||||
end_pos = axis_positions_mm[-1] if axis_positions_mm.size else 0.0
|
||||
center_positions_mm = np.arange(0.0, end_pos + 1e-6, output_spacing, dtype=float)
|
||||
if center_positions_mm.size == 0:
|
||||
center_positions_mm = np.array([0.0], dtype=float)
|
||||
|
||||
slabs = []
|
||||
centers_idx = []
|
||||
half_thickness = slab_thickness / 2.0
|
||||
for center_mm in center_positions_mm:
|
||||
mask = np.abs(axis_positions_mm - center_mm) <= (half_thickness + 1e-6)
|
||||
if not mask.any():
|
||||
nearest = int(np.argmin(np.abs(axis_positions_mm - center_mm)))
|
||||
idxs = np.array([nearest], dtype=int)
|
||||
else:
|
||||
idxs = np.where(mask)[0].astype(int)
|
||||
|
||||
slabs.append(idxs)
|
||||
centers_idx.append(float(center_mm / axis_spacing) if axis_spacing > 0 else float(idxs[0]))
|
||||
|
||||
return slabs, centers_idx
|
||||
|
||||
plane_builders = {}
|
||||
for plane in recon_planes:
|
||||
plane_norm = plane.lower()
|
||||
if plane_norm == "axial":
|
||||
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_z)
|
||||
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
|
||||
slab_indices, slab_centers_idx = _slice_indices_for_axis(
|
||||
int(vol_zyx.shape[0]), float(spacing_z), plane_spacing, plane_thickness
|
||||
)
|
||||
plane_builders[plane_norm] = {
|
||||
"count": int(len(slab_indices)),
|
||||
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[slabs[idx], :, :]),
|
||||
"pixel_spacing_out": [float(spacing_y), float(spacing_x)],
|
||||
"spacing_between_slices_out": float(plane_spacing),
|
||||
"slice_thickness_out": float(plane_thickness),
|
||||
"image_orientation_out": _orientation_pair(patient_x_vec, patient_y_vec, patient_z_vec),
|
||||
"plane_normal_out": [float(patient_z_vec[0]), float(patient_z_vec[1]), float(patient_z_vec[2])],
|
||||
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(centers[idx], 0.0, 0.0),
|
||||
}
|
||||
elif plane_norm == "coronal":
|
||||
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_y)
|
||||
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
|
||||
slab_indices, slab_centers_idx = _slice_indices_for_axis(
|
||||
int(vol_zyx.shape[1]), float(spacing_y), plane_spacing, plane_thickness
|
||||
)
|
||||
plane_builders[plane_norm] = {
|
||||
"count": int(len(slab_indices)),
|
||||
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[:, slabs[idx], :], reduce_axis=1),
|
||||
"pixel_spacing_out": [float(spacing_z), float(spacing_x)],
|
||||
"spacing_between_slices_out": float(plane_spacing),
|
||||
"slice_thickness_out": float(plane_thickness),
|
||||
"image_orientation_out": _orientation_pair(patient_x_vec, patient_z_vec, patient_y_vec),
|
||||
"plane_normal_out": [float(patient_y_vec[0]), float(patient_y_vec[1]), float(patient_y_vec[2])],
|
||||
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(0.0, centers[idx], 0.0),
|
||||
}
|
||||
elif plane_norm == "sagittal":
|
||||
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_x)
|
||||
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
|
||||
slab_indices, slab_centers_idx = _slice_indices_for_axis(
|
||||
int(vol_zyx.shape[2]), float(spacing_x), plane_spacing, plane_thickness
|
||||
)
|
||||
plane_builders[plane_norm] = {
|
||||
"count": int(len(slab_indices)),
|
||||
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[:, :, slabs[idx]], reduce_axis=2),
|
||||
"pixel_spacing_out": [float(spacing_z), float(spacing_y)],
|
||||
"spacing_between_slices_out": float(plane_spacing),
|
||||
"slice_thickness_out": float(plane_thickness),
|
||||
"image_orientation_out": _orientation_pair(patient_y_vec, patient_z_vec, patient_x_vec),
|
||||
"plane_normal_out": [float(patient_x_vec[0]), float(patient_x_vec[1]), float(patient_x_vec[2])],
|
||||
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(0.0, 0.0, centers[idx]),
|
||||
}
|
||||
|
||||
if not plane_builders:
|
||||
raise ValueError("No valid reconstruction planes selected")
|
||||
|
||||
total_slices = sum(v["count"] for v in plane_builders.values())
|
||||
cache.set(
|
||||
progress_key,
|
||||
{"current": 0, "total": total_slices, "message": "Reconstruction started"},
|
||||
timeout=60 * 60,
|
||||
)
|
||||
|
||||
processed = 0
|
||||
created_series = []
|
||||
|
||||
for plane_norm, cfg in plane_builders.items():
|
||||
recon_series = atlas_views._create_series_derivative(series, f"Recon {plane_norm.title()} ({source_plane.title()} src)")
|
||||
|
||||
for idx in range(int(cfg["count"])):
|
||||
arr2d = cfg["slice_fn"](idx)
|
||||
ds_new = copy.deepcopy(template_ds)
|
||||
arr2d = np.asarray(arr2d, dtype=dicom_items[0][2].dtype)
|
||||
|
||||
ds_new.Rows = int(arr2d.shape[0])
|
||||
ds_new.Columns = int(arr2d.shape[1])
|
||||
ds_new.InstanceNumber = idx + 1
|
||||
atlas_views._stamp_derived_dataset(
|
||||
ds_new,
|
||||
series_instance_uid=recon_series.series_instance_uid,
|
||||
source_series_instance_uid=series.series_instance_uid,
|
||||
series_description=recon_series.description or f"Recon {plane_norm.title()}",
|
||||
derivation_description=(
|
||||
f"{plane_norm.title()} reconstruction; mode={recon_thickness_mode}; "
|
||||
f"thickness={float(cfg['slice_thickness_out']):.3f}mm; spacing={float(cfg['spacing_between_slices_out']):.3f}mm"
|
||||
),
|
||||
image_type=["DERIVED", "SECONDARY", "MPR"],
|
||||
)
|
||||
ds_new.PixelData = arr2d.tobytes()
|
||||
ds_new.PixelSpacing = [float(cfg["pixel_spacing_out"][0]), float(cfg["pixel_spacing_out"][1])]
|
||||
ds_new.ImageOrientationPatient = cfg["image_orientation_out"]
|
||||
ipp = cfg["position_fn"](idx)
|
||||
ds_new.ImagePositionPatient = ipp
|
||||
ds_new.SliceThickness = float(cfg["slice_thickness_out"])
|
||||
ds_new.SpacingBetweenSlices = float(cfg["spacing_between_slices_out"])
|
||||
try:
|
||||
normal = np.asarray(cfg["plane_normal_out"], dtype=float)
|
||||
ds_new.SliceLocation = float(np.dot(np.asarray(ipp, dtype=float), normal))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
out_io = io.BytesIO()
|
||||
ds_new.save_as(out_io, write_like_original=False)
|
||||
out_io.seek(0)
|
||||
|
||||
recon_image = SeriesImage(
|
||||
series=recon_series,
|
||||
position=idx + 1,
|
||||
upload_filename=f"recon_{plane_norm}_{idx + 1}.dcm",
|
||||
)
|
||||
recon_image.image.save(
|
||||
f"recon_{plane_norm}_{recon_series.pk}_{idx + 1}.dcm",
|
||||
ContentFile(out_io.getvalue()),
|
||||
save=False,
|
||||
)
|
||||
recon_image.save()
|
||||
|
||||
processed += 1
|
||||
cache.set(
|
||||
progress_key,
|
||||
{
|
||||
"current": processed,
|
||||
"total": total_slices,
|
||||
"message": f"Generating {plane_norm} reconstruction ({processed}/{total_slices})",
|
||||
},
|
||||
timeout=60 * 60,
|
||||
)
|
||||
|
||||
created_series.append(
|
||||
{
|
||||
"id": recon_series.pk,
|
||||
"url": recon_series.get_absolute_url(),
|
||||
"description": recon_series.description or str(recon_series.pk),
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Reconstruction task complete for series {} with {} outputs",
|
||||
series.pk,
|
||||
len(created_series),
|
||||
)
|
||||
|
||||
cache.set(
|
||||
progress_key,
|
||||
{
|
||||
"current": total_slices,
|
||||
"total": total_slices,
|
||||
"message": "Reconstruction complete",
|
||||
},
|
||||
timeout=10 * 60,
|
||||
)
|
||||
|
||||
return {
|
||||
"series_id": series.pk,
|
||||
"created_series": created_series,
|
||||
"target_spacing": float(requested_slice_spacing) if requested_slice_spacing is not None else None,
|
||||
"slab_thickness": float(requested_slab_thickness) if requested_slab_thickness is not None else None,
|
||||
"mode": recon_thickness_mode,
|
||||
"source_plane": source_plane,
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
<a href="{% url 'atlas:subspecialty_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% elif group_name == 'presentations' %}
|
||||
<a href="{% url 'atlas:presentation_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% elif group_name == 'procedures' %}
|
||||
<a href="{% url 'atlas:procedure_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% else %}
|
||||
{{ item.name }}
|
||||
{% endif %}
|
||||
@@ -27,7 +29,7 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if not results.conditions.exists and not results.findings.exists and not results.structures.exists and not results.subspecialties.exists and not results.presentations.exists %}
|
||||
{% if not results.conditions.exists and not results.findings.exists and not results.structures.exists and not results.subspecialties.exists and not results.presentations.exists and not results.procedures.exists %}
|
||||
<p class="text-muted">No matches found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<span id="add-case-form">
|
||||
This form allows you to add an existing case to the collection. You may find it easier to do this from the individual case pages or the <a href="{% url 'atlas:case_view' %}">case overview view</a> which allows you to add multiple cases at once.
|
||||
<form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<label for="case">Select Case to add:</label>
|
||||
<select name="case" id="case">
|
||||
{% for case in cases %}
|
||||
<option value="{{ case.id }}">{{ case.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Add</button>
|
||||
<button _="on click remove #add-case-form">Cancel</button>
|
||||
</form>
|
||||
</span
|
||||
<div id="add-case-form" class="p-2 border rounded">
|
||||
<p class="mb-2">Add an existing case to this collection. You can also use the case overview view to add multiple cases at once: <a href="{% url 'atlas:case_view' %}">case overview</a>.</p>
|
||||
|
||||
{% load case_widgets %}
|
||||
{% url 'atlas:add_case_to_collection' collection.pk as add_case_url %}
|
||||
{% case_search_widget collection=collection cases=cases recent_cases=recent_cases selection_mode='multiple' action_url=add_case_url input_id='case-search-input-collection-'|add:collection.pk|stringformat:'s' target_id='case-search-results-collection-'|add:collection.pk|stringformat:'s' %}
|
||||
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-secondary" _="on click remove #add-case-form">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="container my-4">
|
||||
<h3>API Tokens</h3>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<input name="name" class="form-control" placeholder="Name (e.g. CLI)" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input name="scopes" class="form-control" placeholder="Scopes (space separated)" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input name="expires_days" type="number" min="0" class="form-control" placeholder="Days" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-primary w-100" type="submit" name="action" value="create">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if created_token %}
|
||||
<div class="alert alert-info">Created token. Save it now — it won't be shown again.
|
||||
<pre class="mt-2">{{ created_token }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">Your tokens</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>Name</th><th>Scopes</th><th>Created</th><th>Expires</th><th>Revoked</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in tokens %}
|
||||
<tr>
|
||||
<td>{{ t.name }}</td>
|
||||
<td>{{ t.scopes }}</td>
|
||||
<td>{{ t.created }}</td>
|
||||
<td>{{ t.expires }}</td>
|
||||
<td>{{ t.revoked }}</td>
|
||||
<td>
|
||||
{% if not t.revoked %}
|
||||
<form method="post" style="display:inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="revoke_id" value="{{ t.id }}" />
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit" name="action" value="revoke">Revoke</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6" class="text-muted">No tokens</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -46,6 +46,21 @@
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:case_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Case</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:normals_list' %}"><i class="bi bi-file-earmark-check me-1" aria-hidden="true"></i>Normals</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:case_search_page' %}"><i class="bi bi-search me-1" aria-hidden="true"></i>Search Cases</a>
|
||||
</li>
|
||||
{% if request.user.is_staff %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:case_admin_overview' %}"><i class="bi bi-speedometer2 me-1" aria-hidden="true"></i>Case Admin Overview</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:cases_by_size' %}"><i class="bi bi-hDD-stack me-1" aria-hidden="true"></i>Large cases</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
@@ -63,21 +78,79 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</a>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</a>
|
||||
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</a>
|
||||
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:categories_list' %}"><i class="bi bi-list-ul me-1" aria-hidden="true"></i>All categories</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:condition_view' %}">Conditions</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:finding_view' %}">Findings</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:structure_view' %}">Structures</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_view' %}">Subspecialties</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:presentation_view' %}">Presentations</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_view' %}">Pathological process</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:procedure_view' %}">Procedures</a></li>
|
||||
{% if request.user.is_staff %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li class="dropdown-item-text small text-muted px-3">Create</li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:condition_create' %}">New condition</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:finding_create' %}">New finding</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:structure_create' %}">New structure</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_create' %}">New subspecialty</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:presentation_create' %}">New presentation</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_create' %}">New pathological process</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:procedure_create' %}">New procedure</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a>
|
||||
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:resource_view' %}"><i class="bi bi-list-ul me-1" aria-hidden="true"></i>Manage Resources</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:resource_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Resource</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads"><i class="bi bi-upload me-1" aria-hidden="true"></i>Uploads</a>
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads"><i class="bi bi-upload me-1" aria-hidden="true"></i>Uploads</a>
|
||||
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle Uploads Menu</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:user_uploads' %}"><i class="bi bi-hourglass-split me-1" aria-hidden="true"></i>Awaiting Import</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:new_uploads' %}"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Web Upload</a>
|
||||
</li>
|
||||
{% if request.user.is_staff %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:uploads_hash_search' %}" title="Search uploads and imported images by hash, series UID or study UID"><i class="bi bi-fingerprint me-1" aria-hidden="true"></i>Upload Search</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas"><i class="bi bi-journal-text me-1" aria-hidden="true"></i>Question Schemas</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:help' %}" title="View the atlas help pages"><i class="bi bi-question-circle me-1" aria-hidden="true"></i>Help</a>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" title="Additional Features"><i class="bi bi-three-dots me-1" aria-hidden="true"></i>Other</a>
|
||||
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<ul class="dropdown-menu">
|
||||
<a class="dropdown-item" href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas"><i class="bi bi-journal-text me-1" aria-hidden="true"></i>Question Schemas</a>
|
||||
<a class="dropdown-item" href="{% url 'atlas:help' %}" title="View the atlas help pages"><i class="bi bi-question-circle me-1" aria-hidden="true"></i>Help</a>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE HTML>
|
||||
{% load static %}
|
||||
{% load django_htmx %}
|
||||
{% load crispy_forms_tags %}
|
||||
<html data-bs-theme="dark">
|
||||
<head>
|
||||
<title>Create Resource</title>
|
||||
<link href="{% static 'css/bootstrap.min.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'css/widgets.css' %}" type="text/css" media="all" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{% static 'css/bootstrap-icons.css' %}">
|
||||
<script src="{% static 'js/jquery-3.7.1.min.js' %}"></script>
|
||||
<script src="{% static 'js/bootstrap.bundle.min.js' %}"></script>
|
||||
{% block js %}
|
||||
{% endblock %}
|
||||
<link rel="stylesheet" href="{% static 'css/forms.css' %}">
|
||||
</head>
|
||||
<body class="p-3">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -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,19 +38,48 @@
|
||||
{% 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");
|
||||
|
||||
@@ -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" 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 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>
|
||||
|
||||
<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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user