Compare commits
1327 Commits
27a58acc73
...
v1.2.3
Author | SHA1 | Date | |
---|---|---|---|
58782f6db7 | |||
57b25cd214 | |||
666e12253e | |||
8013a776a9 | |||
aa1f46ee62 | |||
dbd8d6bbe3 | |||
fed46eaa1e | |||
79ad96a93f | |||
ba851cb7dc | |||
fcc4d55947 | |||
a56358b8dd | |||
179a608089 | |||
a1289da09c | |||
ea86fd9ae6 | |||
76d60546e2 | |||
9a02048199 | |||
c9ad8e87cd | |||
3714919ba5 | |||
1026cc71a9 | |||
07fb170656 | |||
1ea93994ab | |||
607b132996 | |||
7aa4d81e65 | |||
0ef39dcfbe | |||
e1517b89c0 | |||
d0ed228824 | |||
a2c52a4261 | |||
b2c9bdd7d2 | |||
7536c33a48 | |||
850c2b13b7 | |||
eb69979f59 | |||
95cea46a8f | |||
02a1129390 | |||
438e09f1ec | |||
9241e1c0f7 | |||
8deefb9035 | |||
4f2984deea | |||
70d2325579 | |||
36d840c752 | |||
4400446718 | |||
adead30a77 | |||
487f24732d | |||
3c06cebddf | |||
d1d52fa4b6 | |||
80dc8b3cff | |||
a9ccd64de2 | |||
f5b9758bb1 | |||
84570d5974 | |||
edb8241ad3 | |||
644a539ed9 | |||
f05568b0de | |||
da4a3e41c6 | |||
77f86f7102 | |||
358695977f | |||
ddfd75c1f8 | |||
f4642767ac | |||
2f729de40b | |||
d68beb938f | |||
ca667f7896 | |||
0cc00ef911 | |||
5ec2a86d08 | |||
cd57eca7d3 | |||
a46338fdcb | |||
40f1cebb7b | |||
2a6478f3cf | |||
b6e250a7cd | |||
bcee2eedd0 | |||
d9837246de | |||
62fac48904 | |||
2bf0eeb33d | |||
72f2af1df8 | |||
168b2b288a | |||
9a5f69f889 | |||
7d6f256392 | |||
866c9b10cf | |||
b8fd65d856 | |||
5490bd083f | |||
3cb78055ff | |||
f9d85a8028 | |||
4f193e7fa5 | |||
df3149abba | |||
7ab87c2966 | |||
f4f501def5 | |||
1c57950558 | |||
f132cdbeef | |||
0387c05055 | |||
552b2ffc47 | |||
a2e859af5d | |||
81b09190de | |||
ed100ee9e5 | |||
5dc6c4998d | |||
0d68233d41 | |||
4caac25b14 | |||
3defe020f5 | |||
f14085f4c1 | |||
be5343a4bd | |||
2da8eb7712 | |||
3a0abaac6a | |||
b15f76701e | |||
02290e968c | |||
294f1e42f7 | |||
070ce19fcc | |||
615e59fc6d | |||
68314a4ed2 | |||
b90761fd2c | |||
af03193217 | |||
730a75c44d | |||
70883db5ad | |||
7cefb487da | |||
2e1b01ec9b | |||
a7a5a03991 | |||
b36c6bfd18 | |||
a613b0006b | |||
d4db8692e7 | |||
37ad36da31 | |||
d140f93d25 | |||
26a6248a61 | |||
9f8ea16974 | |||
bc5ec44145 | |||
ff5b19fa0b | |||
6c50be49c6 | |||
8bfe028e2c | |||
519394a656 | |||
9e1c9caec6 | |||
ea850c9ae2 | |||
591b868920 | |||
91dc93758a | |||
5d27baee08 | |||
1254cf3698 | |||
efab086057 | |||
06db47c597 | |||
c04c824585 | |||
8eb7fb6869 | |||
db88b84ecb | |||
13c587b7da | |||
2b2a6ddd25 | |||
26a6b45d75 | |||
c6c62fc34c | |||
6bbdb8fced | |||
c633a474b5 | |||
5af99d85b5 | |||
1e7124262e | |||
2f509af1de | |||
3c8c1b5c16 | |||
3988559920 | |||
8988fee55d | |||
86d1522ca1 | |||
ed53b771ef | |||
bc3b811fc9 | |||
f314566591 | |||
4b6dbd4441 | |||
1ef34465c2 | |||
8b0ea1fec3 | |||
39acebb3a6 | |||
d9962f18ed | |||
d8044a7c76 | |||
3025e83b66 | |||
a02a58a8db | |||
de6910b4bf | |||
7bb93afacb | |||
2663d5e3b7 | |||
500beed4cc | |||
d83999aa43 | |||
6a09559b70 | |||
26227a66c5 | |||
d6836915bb | |||
49a7fb1007 | |||
90bc30757a | |||
fac3839ea3 | |||
d8d5e92453 | |||
12207d1159 | |||
ac02f4dee1 | |||
a050a1eccf | |||
8d91dd1d30 | |||
76fa1e1dd9 | |||
6d5f74bd62 | |||
2e00d503c8 | |||
43cc0a5652 | |||
4ce6536e33 | |||
1f60054d46 | |||
33bc7993fa | |||
418dfe7a70 | |||
645f69440f | |||
e1e279e939 | |||
c197f6cb76 | |||
7fe1afb348 | |||
bed186f6b5 | |||
516c2cdf81 | |||
8f9b78ac32 | |||
17b985d238 | |||
69a0791a6d | |||
4414d1720e | |||
43895bead0 | |||
067ef4fd7f | |||
73f31016fd | |||
25115a6fae | |||
6028ac2d3c | |||
225ef71518 | |||
fbae88eed1 | |||
647d156802 | |||
08a140a73b | |||
a8a01e17da | |||
3f59d1b1b7 | |||
5123365567 | |||
d0166f0901 | |||
f6231dc779 | |||
5c8435d39e | |||
e4e07c43b4 | |||
d202e83189 | |||
e264b808fc | |||
4b08c830a1 | |||
b9d45f94fe | |||
2ea778143e | |||
62160beab2 | |||
1a7983052f | |||
a1bee61679 | |||
126bf9203c | |||
a58f267586 | |||
22878b5398 | |||
52b44128fa | |||
8439d99949 | |||
66e7b2b9f8 | |||
9459b93c9b | |||
09e444344d | |||
767dcede54 | |||
4431564304 | |||
da821bcadb | |||
b58a23cf13 | |||
dc126459bc | |||
2c5ed21011 | |||
59281db9cb | |||
2a3927a140 | |||
9a225543c6 | |||
dd8685b103 | |||
625ef8883b | |||
f903f9d060 | |||
eac9ee7ab1 | |||
8946e3eaf3 | |||
b27016aaf4 | |||
89788550fb | |||
6992a75855 | |||
9539ba22fe | |||
85ced0cc20 | |||
eac6cac7bc | |||
fcfde34c72 | |||
1b111727be | |||
436c8e0e2d | |||
9c0c7f6ba1 | |||
7af588da6c | |||
f170ff5e52 | |||
cfd750894a | |||
3bd16ae563 | |||
ede71f7d82 | |||
2f6de34051 | |||
27706572ed | |||
b9c4edeb48 | |||
08da6d71c4 | |||
587415c5db | |||
c5a0bbb827 | |||
c2d7dc7fe2 | |||
8680c73e86 | |||
0059ec5270 | |||
ff74e92297 | |||
ce07bdf8b2 | |||
6b3b255cfd | |||
7db0d055e5 | |||
ecdb5df561 | |||
7c70da4b5c | |||
c5b4d948f5 | |||
b3791dfcf8 | |||
c40ef7d070 | |||
cd5fd686e9 | |||
b8081bc1c8 | |||
6e1f7c6df1 | |||
efec599225 | |||
de0b24b042 | |||
614ad91e3d | |||
ab4496f06d | |||
6605620d9c | |||
150224c1d5 | |||
cd4d52692c | |||
a836e0c9e3 | |||
2038965dcb | |||
88a41de647 | |||
b4c94a7ddb | |||
fc099dbbf7 | |||
f144097c5d | |||
a84c1037a2 | |||
63f72e35d2 | |||
deab85289b | |||
57ee0bf971 | |||
866267dc5f | |||
735cdec139 | |||
d76c8a5fed | |||
8591184da6 | |||
b87f99e138 | |||
38d3420e4d | |||
ecf18a70a8 | |||
7b5861ade6 | |||
ce5be3a53e | |||
f0437dceaa | |||
7206ca6203 | |||
fa4640840b | |||
84cb483ee0 | |||
ca30b002ed | |||
b612b39e73 | |||
05a564f41d | |||
0b12b43621 | |||
7b2f155b14 | |||
9462d7aa7f | |||
f9628df8c7 | |||
0cbdfba45f | |||
a10bb0384f | |||
1d91e6d6ee | |||
b5443c1331 | |||
96c42f1ee1 | |||
fe83a47dae | |||
ec52ebffa5 | |||
3d7e144d12 | |||
9da654f235 | |||
3c9fcae9f8 | |||
d9f967811f | |||
d093c4e636 | |||
355c049937 | |||
1d5dfaa5ee | |||
7d287874cd | |||
57f233f20f | |||
55a11d80d2 | |||
a35d0ef7f1 | |||
6fb8b62e5b | |||
4a5bc48889 | |||
0453de9f62 | |||
0bdd50f432 | |||
78e4afdc71 | |||
f2fb52aeca | |||
eaf8197ae6 | |||
52afd249b7 | |||
79073f3d92 | |||
4a8080f0c8 | |||
99a4539559 | |||
443568f8ff | |||
11e740ea44 | |||
5ab2e7e608 | |||
cbf66d8768 | |||
7b1ae3b354 | |||
b9a83a436e | |||
bae8d1e6f8 | |||
0e28e3dc48 | |||
36ed23564d | |||
cc995925bb | |||
4585b93136 | |||
f15b7da648 | |||
14272ba0b8 | |||
706ba8409e | |||
0130f7412d | |||
a5f1177d8f | |||
8b4ca65122 | |||
757425494f | |||
f3f8ac955c | |||
b2e708cde0 | |||
8bfc8e119c | |||
103093c886 | |||
0ccb62ce3c | |||
440a5aad09 | |||
2507a1d00b | |||
6d887f1bfd | |||
fed4b6739f | |||
3fc9d4c3c1 | |||
dd22b51fe1 | |||
89e8267a29 | |||
f2b261f0b0 | |||
9c8c0d85a5 | |||
526d940c54 | |||
877b191a38 | |||
485e51f239 | |||
f74df18af1 | |||
9f4e9637c9 | |||
c85fa69713 | |||
1adb4867d5 | |||
ff6468cce3 | |||
55aa5496db | |||
30175355a6 | |||
b7ef513870 | |||
af07a152ad | |||
331e49a6bc | |||
9bdff0d729 | |||
2027e525e2 | |||
7fdc4bc26c | |||
59fc703bcb | |||
c466f06384 | |||
ab3312d45c | |||
8d80666ed8 | |||
f867022207 | |||
3d9a3ecdff | |||
e311c66abc | |||
a8e938e802 | |||
4c4927df31 | |||
0d8450f667 | |||
f8126b42fe | |||
b151de39df | |||
407ee49bff | |||
e7a82d2437 | |||
b0bb600e12 | |||
0e8fbf148a | |||
46aeee416a | |||
0ef72ec338 | |||
80391229a3 | |||
721af501d1 | |||
07395833e8 | |||
e6f1338ee4 | |||
0e50e2c1b9 | |||
a33ed7a94d | |||
b0980b1871 | |||
23ddf6601b | |||
ea9132542f | |||
19d12226f5 | |||
b7fb30ce36 | |||
1873772167 | |||
fe75fa1a49 | |||
8726d4335c | |||
f86fa6f4b5 | |||
a5f8cba71b | |||
6c293c2ce6 | |||
e1b2bd20f7 | |||
d3ed32183c | |||
e8090f30d7 | |||
3b3656209a | |||
176a0f069f | |||
58f40e221f | |||
302d8a933a | |||
7e9b5eada0 | |||
c5587fcb73 | |||
183aeac9ee | |||
a4b4bfe0ee | |||
c915ae1182 | |||
0faef8651a | |||
973bafcdb2 | |||
4f925eae2f | |||
b821d40e85 | |||
a9f5ba51c4 | |||
5b0fd0ced3 | |||
fba539f933 | |||
eca786d444 | |||
220a378c63 | |||
affb309ffc | |||
b013ffec47 | |||
0e1db9d21d | |||
e1a90d5f64 | |||
003d998b72 | |||
b84654d931 | |||
dccc85370e | |||
d3f116e1ca | |||
355a6bff5e | |||
9bd1f29aad | |||
98638e803a | |||
6c4ab2e1e3 | |||
991313e8b2 | |||
e13069bed6 | |||
e2a0bc7b4e | |||
5b6f83c294 | |||
9a4c2962a5 | |||
7295a2751c | |||
7b2a2ce90c | |||
dd72da6ae6 | |||
54121a4fda | |||
36cdeb15ad | |||
0f1f79e237 | |||
eb6f5b876c | |||
d5f7ab0488 | |||
14500434d7 | |||
35dffd358b | |||
cc7712ccec | |||
fafb3fcc2e | |||
ed6360e7a3 | |||
4131dd054a | |||
3b92dc3005 | |||
f370496780 | |||
cc45bf7acd | |||
667ad4ebc2 | |||
4c3805cfe7 | |||
52e3ce4c93 | |||
045f3aec0a | |||
ca0e6c82cb | |||
1f767da365 | |||
860c18c5fd | |||
46cef8cd1e | |||
f069556afd | |||
421445d8d5 | |||
6d931bdf6c | |||
b0d3ff3fc1 | |||
0cb88390b8 | |||
68aef968e2 | |||
af9801ac24 | |||
8d29944d5d | |||
33314e2bc4 | |||
8fbb52d366 | |||
8749c6e590 | |||
1dbe4215ec | |||
c10c31c4b6 | |||
101f6786f5 | |||
fe5cf189cc | |||
167bee38d6 | |||
cefb5fe849 | |||
bac083411c | |||
f0c7873257 | |||
6dcef4885a | |||
0cb8ff9991 | |||
536e1fe426 | |||
4d77021d58 | |||
5ab8be93ec | |||
fa05a17508 | |||
920287b7ae | |||
5960d0103d | |||
f44fbb24da | |||
3535622380 | |||
86abae01c0 | |||
8328f6fb1a | |||
7c2adc9cac | |||
e9a5e72959 | |||
e119c344dd | |||
12480b32a5 | |||
c7b54d2119 | |||
96cca77b2f | |||
e6841b7744 | |||
28f944a6cf | |||
6835232698 | |||
00fb8e13fe | |||
5392ff86ed | |||
1b2b4f8dc6 | |||
328a78a923 | |||
9810577c5d | |||
2d29e6ac97 | |||
2c93b0d3a7 | |||
c462b76cb7 | |||
343cb3f8b1 | |||
c2d95c1d52 | |||
961e8629cb | |||
673ccbcb9c | |||
378e8eeae3 | |||
28789c72f1 | |||
fe898aaf7d | |||
57e5a21ffa | |||
a010d7d290 | |||
13121b3037 | |||
8b962c53a9 | |||
bceb91b406 | |||
b299f4ae55 | |||
a14b7bf305 | |||
e935524552 | |||
3622baf988 | |||
b8a7182f98 | |||
24545feea0 | |||
21641ce21f | |||
bb9233eeae | |||
3035ac6687 | |||
60b8aad419 | |||
8d2a84a071 | |||
6e541c6a7b | |||
a77faa7eed | |||
685b1b928d | |||
e0c2570515 | |||
b4e7efdbe5 | |||
5163914875 | |||
f61c12afd1 | |||
467b6d9ce7 | |||
8a0e93e3e5 | |||
e5aab6268d | |||
7874d3f99f | |||
383ae11cd3 | |||
37d6d3a003 | |||
348ee95d1c | |||
1b2209d97c | |||
9db80c9148 | |||
2e340cce00 | |||
20b447adbb | |||
669bbd2f7b | |||
9cef9819fe | |||
22b483b021 | |||
9e4e874401 | |||
21ad8b2f94 | |||
47f996b19c | |||
a3a13d4eb6 | |||
d0232557bc | |||
a357ffe28d | |||
cdc19e69b8 | |||
e00e2b17b0 | |||
a2e05d39e6 | |||
65d679afbb | |||
72a068c975 | |||
891ec2fd38 | |||
4be21a2ca2 | |||
cfc62ee21f | |||
efd4dc440d | |||
7150e679c8 | |||
935b465a19 | |||
bce76d808d | |||
05fa5bf274 | |||
20f580d6a6 | |||
1d1e2acf62 | |||
700daa51ef | |||
c742edb57c | |||
cdd35117bf | |||
529504509e | |||
abcd4cbec5 | |||
852b2664ce | |||
8b1b0162cc | |||
715c7856fa | |||
56e5d29416 | |||
19521c3f23 | |||
ee50306370 | |||
d7ef628640 | |||
559e5b96c4 | |||
198e2cecb0 | |||
4c2a6e7f74 | |||
4ae587da12 | |||
daaf173ff6 | |||
fa43ab1879 | |||
05de6d716b | |||
a3101503d4 | |||
f740ee7f1b | |||
c56c0dc822 | |||
df94b9b486 | |||
0c446b9ae7 | |||
9a5f073170 | |||
9ebec5000c | |||
700c5ff39d | |||
ce32b33eaa | |||
2641b3e060 | |||
45e0d37f81 | |||
21565592cc | |||
d353a80269 | |||
d2cd8316da | |||
8e7a09edca | |||
c3fb5a9b0c | |||
616bd3f578 | |||
108297cbfd | |||
5954c1a68e | |||
9e03db595b | |||
de969e0028 | |||
3bfd08411b | |||
19d16ab7d9 | |||
a4affa72a9 | |||
e7077cd193 | |||
12c424be08 | |||
06aee6fa6d | |||
e00b4a9045 | |||
2472323103 | |||
0ad7089722 | |||
5d334e4da5 | |||
707890ce3a | |||
7bdca9b895 | |||
848f39aa66 | |||
bd1ac46942 | |||
5fad0cda1e | |||
11f965e20f | |||
bfd8c8fa1e | |||
ee99dd9038 | |||
fbe19e43f7 | |||
65ec27b35b | |||
5a3aca732b | |||
63ca5e33de | |||
54653e82f2 | |||
1f228c7f1c | |||
fa290a2713 | |||
56191f5e7a | |||
cbc8d276eb | |||
396400e7c0 | |||
cd68a60001 | |||
cb39592bd3 | |||
dd7e3cad7a | |||
5dbf3be732 | |||
32908bde7d | |||
372333cd84 | |||
835c5e2aa6 | |||
672888c5d9 | |||
6823c12b2d | |||
cda7ac480f | |||
c7907dc24d | |||
8926960a18 | |||
e4d97869da | |||
dfbf10e2dd | |||
e499d1d54a | |||
dbd25ddf38 | |||
724ffbfdf4 | |||
11d839aada | |||
6d90ec4aa6 | |||
3980be3701 | |||
ca1a6efd57 | |||
43cb31849a | |||
59e7d3d112 | |||
39cdafc847 | |||
727779f054 | |||
bdeb026a7c | |||
b244fb34f7 | |||
73f4825bbe | |||
e1ecb5bcb6 | |||
da5e115bbc | |||
1651f63577 | |||
1edd25d3ea | |||
a01d486d99 | |||
3c64240842 | |||
2b71c77c6c | |||
cc466f4a20 | |||
112c097d69 | |||
79e015191a | |||
b6af6d5c15 | |||
529ac35bdb | |||
6c4ca715f6 | |||
8d6ca515dc | |||
972673f5d1 | |||
cb1bc69f47 | |||
7550deab89 | |||
a4058c475b | |||
81b3e3a142 | |||
0004d2714f | |||
8527976007 | |||
20efd4444c | |||
039b58709e | |||
13465859ab | |||
0f3a84b54d | |||
53050f1358 | |||
c8ccd002fa | |||
f025eee4a6 | |||
6acf3fa204 | |||
506a6cf6c2 | |||
97db70abff | |||
4d642cc1e9 | |||
1a1d763d67 | |||
fc765c0177 | |||
598dfa45e8 | |||
0d8200193d | |||
ca36772f29 | |||
eb9ca82cb3 | |||
bd3205f06e | |||
fa9d08b10e | |||
ab7a25182f | |||
720de1f2de | |||
e3bb2895ae | |||
bf64b78acc | |||
3e1e57a067 | |||
42f90c667d | |||
5f06c9b624 | |||
b02277f12f | |||
7d90b6c7a2 | |||
a9ad171249 | |||
861b61c1ca | |||
bc42ae86d1 | |||
d25dc5ed45 | |||
cc3410a1f6 | |||
6c048a9c48 | |||
953d3658a8 | |||
14bc50165b | |||
70f6875ac1 | |||
4c40240346 | |||
5da08d5c37 | |||
534247ece3 | |||
ba9ed0ca40 | |||
9525694e39 | |||
bcc9e9c609 | |||
31903626f0 | |||
c6adac8288 | |||
0111547676 | |||
50cff0245f | |||
e70592b276 | |||
b2a71bf51b | |||
22a0d58996 | |||
13b7249f2a | |||
3d6a1dc7ba | |||
bdffce21b7 | |||
51d468fb44 | |||
164d43be8b | |||
7ae8cba851 | |||
cdf47e0b88 | |||
73c00ac333 | |||
2427d55310 | |||
b0f2d89956 | |||
757cc94f33 | |||
7f7a783c8a | |||
0cfac25ed3 | |||
33e8d8482f | |||
0443e348ac | |||
a7e3a5fe47 | |||
f2c0090aa3 | |||
4ff62d3b36 | |||
ae75498edb | |||
7f3e251ac4 | |||
da2ac4c0ae | |||
233e173735 | |||
36334ef186 | |||
c5686fbd40 | |||
40f3bb3b20 | |||
94556d0731 | |||
207f748c93 | |||
ccab358464 | |||
a0aaa6b035 | |||
79b0e83eba | |||
9b34fb8f73 | |||
22e163f036 | |||
c90a37a99b | |||
511eccac99 | |||
8ec0967f40 | |||
2f611d43cc | |||
ae1380407c | |||
1a290e3bd6 | |||
1e7222c781 | |||
39af68cf36 | |||
b65b71df7a | |||
2c0dcd8661 | |||
9a4820c725 | |||
1cc5b9cf57 | |||
6c327c7978 | |||
3d03fb79a7 | |||
c730fca3eb | |||
f4dbc55f88 | |||
ba106ff684 | |||
738f4eae86 | |||
1015148d4d | |||
d114b061b4 | |||
6db6baab50 | |||
9b5b97eb1d | |||
acfcca13b3 | |||
52ab3af1f2 | |||
05ec62994e | |||
79ca8fc932 | |||
10a524b7b8 | |||
3a380c9f50 | |||
41a7129959 | |||
b9bff4812b | |||
33f555f847 | |||
dedd2d3449 | |||
bf7e0a2a18 | |||
9bf39107fb | |||
d34aa82e86 | |||
349dd030d6 | |||
af9b5210fa | |||
03d419c3fc | |||
389fbf99aa | |||
e69c79df52 | |||
1cafa04763 | |||
ac21f571b4 | |||
bc68089f87 | |||
8cb4435517 | |||
9b7a3b3ec0 | |||
87c15070bd | |||
23136b7e40 | |||
2e4035d8a4 | |||
9a7758f208 | |||
7063fe271e | |||
ce31c3e691 | |||
8d65b0c089 | |||
8ead32c34d | |||
9988a989a6 | |||
ea4edf71ba | |||
20e418aeae | |||
0fce697095 | |||
9affa657c4 | |||
1a20f1ec67 | |||
395ddbd460 | |||
0a1a9b007d | |||
93b8ac40df | |||
09f71fc5a7 | |||
e6d22f2a89 | |||
e694119a58 | |||
f242413911 | |||
67bbab0061 | |||
41a9892538 | |||
9992138bc4 | |||
ca25159830 | |||
f548221a10 | |||
347378d785 | |||
4d883e8dce | |||
db59e6c85c | |||
92e2462bb9 | |||
281575bbf7 | |||
6ea02c28d4 | |||
05a8a78ed9 | |||
4b62ff6e80 | |||
ac5d17fc66 | |||
79c23471ee | |||
37d7e5010f | |||
350c67ab10 | |||
ce40568870 | |||
e53d7ef230 | |||
f4234f57b1 | |||
e715d07bf1 | |||
b8c652e78a | |||
8d76ecb78a | |||
9d760aafef | |||
f9d16b3608 | |||
4da025d50f | |||
787b741687 | |||
21f54c9789 | |||
2aca8015af | |||
c4f088f29c | |||
89ae75050b | |||
0c01bff022 | |||
efa83d2bf8 | |||
eb6395a793 | |||
388d89d95d | |||
3856d5fa84 | |||
8a368dbd16 | |||
0318ddbf21 | |||
4f842223cd | |||
78746b5e1a | |||
81eac4b880 | |||
f03c92082e | |||
aafde86012 | |||
3a63c72bbb | |||
31d7e978f4 | |||
c3f6d45883 | |||
3dd7739a16 | |||
27cead22ad | |||
dc934f10ee | |||
3a39ff6fc3 | |||
a0a33b81c4 | |||
8ab0a5e164 | |||
486aeb86a2 | |||
c3c6e5084a | |||
21a7eeea21 | |||
ef7de71a5b | |||
1a1dff2c5d | |||
9b8f0f3d8e | |||
da6d380786 | |||
77267f944b | |||
a1ed557dc2 | |||
a3cdc42fab | |||
3ffb4a68e1 | |||
c00a465410 | |||
12d9cd39be | |||
9fa515cf9b | |||
0fd7ac7f1f | |||
e313df57d6 | |||
66d8fb7d93 | |||
0ad4ae38fa | |||
cca2633f1a | |||
e1fcad3b42 | |||
031d18e922 | |||
4aad0c1213 | |||
85efd755d8 | |||
ef1cad1995 | |||
292c642e73 | |||
ab2ca04ceb | |||
81a4d5dbda | |||
c88c142f7f | |||
85460b7192 | |||
ff6865c7ca | |||
ecaa4fa95f | |||
488389057c | |||
6442c7f678 | |||
186e83f92a | |||
da6ae3c826 | |||
efb89e7626 | |||
23d6f833d7 | |||
fdb5cc1cf9 | |||
17f9ef79b7 | |||
335c42f924 | |||
231f1d97bc | |||
2799190b97 | |||
dbc0c782c0 | |||
383f303127 | |||
27bb07a942 | |||
6331dda37b | |||
0d63413835 | |||
eb812a9ebb | |||
a126d1f91d | |||
30e298aa02 | |||
196c4774a2 | |||
cc8db3fea4 | |||
727fc2d8c0 | |||
7c2b9df0d0 | |||
f0bfecaad3 | |||
3b605c3340 | |||
2ba8980dd8 | |||
d8e7bf6ae8 | |||
9ec9a5e80f | |||
3c903424fb | |||
8b2daf400a | |||
766487b669 | |||
cf5ac8b221 | |||
0e52c12b35 | |||
3a1abe5157 | |||
eff7b25d71 | |||
9a2d738653 | |||
93bf9e94fb | |||
5c6f56f1c3 | |||
efb69efa10 | |||
329538f7f5 | |||
c7185f24d4 | |||
cfdb4db0c3 | |||
c46facdf8b | |||
5151b98f97 | |||
f97b2c7cbb | |||
b102dc86aa | |||
c8c93dc721 | |||
d9dc2e209f | |||
86f8c12279 | |||
be63eed81d | |||
c71e91326f | |||
cca01a6c2f | |||
41d92b97a0 | |||
6855ddfdcb | |||
2f6ccd530a | |||
6b01841529 | |||
5d9dba0e3d | |||
0eda083bf2 | |||
ee159402d0 | |||
56b3e6a2f5 | |||
82ed0cf7cc | |||
49b0ea14f0 | |||
66f2da31b6 | |||
cf39f83243 | |||
8f8a12b609 | |||
5bd04d8dc0 | |||
4b603b70a0 | |||
48624584fe | |||
4902d40787 | |||
fb7f9e328d | |||
c7ca26202e | |||
c7ddf034a3 | |||
f47e22ccae | |||
e001ccfa01 | |||
121dd32bfb | |||
b6179430be | |||
4b671242ff | |||
8924232a93 | |||
ac36309527 | |||
ba082d4ed7 | |||
7eddcabb7f | |||
d890a45f2b | |||
f66d62db37 | |||
b23d583bfb | |||
567b272161 | |||
048d06ca14 | |||
2f04671ec5 | |||
5f9b30cc01 | |||
c375576436 | |||
9fac4ebd82 | |||
c536fb95b2 | |||
018be71ed3 | |||
fbe3a59847 | |||
6472241dfc | |||
72aa7696ac | |||
998ec597b1 | |||
237aabf4ba | |||
3470f7422c | |||
37367cecc3 | |||
9be3b1a487 | |||
f086a6e32b | |||
c00ffd3ed0 | |||
a341974ebc | |||
f17ba4f6bf | |||
4d734dbbe8 | |||
700850434a | |||
6a4fe535e1 | |||
019622bd85 | |||
fe61456922 | |||
989d6900df | |||
64f1da772a | |||
e69f60d813 | |||
6b79fb8ebe | |||
da35da2b76 | |||
8963e5461e | |||
34e82de922 | |||
a780b2330e | |||
f068c6c937 | |||
a3a1c2ab2f | |||
a0fc1653e7 | |||
dcd047a5ae | |||
3f5d0feedb | |||
268fa36507 | |||
f0ba8777e3 | |||
1d778a6bdd | |||
43989af1f1 | |||
cf82a85070 | |||
0a6a14f8d0 | |||
4cfe7c2cba | |||
5dfc3379fc | |||
7ba1f22ad7 | |||
c08e1c7010 | |||
e84fb91452 | |||
2479fd193b | |||
4da20115d2 | |||
a6ad184447 | |||
01d1a35238 | |||
ff9ede6cce | |||
05b68fdd95 | |||
0725b8b490 | |||
900929b875 | |||
7e65416f80 | |||
8cf9629bf1 | |||
ecc5780604 | |||
40926c1063 | |||
848aa88dac | |||
ba47f79d44 | |||
4d81172deb | |||
6f4353266c | |||
d1cf44fd18 | |||
abfa7b21ba | |||
23aee7abed | |||
2536e595f0 | |||
bda9946859 | |||
93552023f6 | |||
a67ea9951b | |||
61ac4c1cb0 | |||
756af0a064 | |||
3907ede872 | |||
7caf54a5ba | |||
bcafc8f545 | |||
222b8e8a8b | |||
29d015cdb4 | |||
2875c59460 | |||
19054a9c67 | |||
bb09930116 | |||
e2a9d79484 | |||
31736bfbaf | |||
b5625a5fb2 | |||
aab1a2815e | |||
6103010169 | |||
e326729ddb | |||
283dfe8ecf | |||
ae4c418ed7 | |||
faeaeb8b2c | |||
3d274c8189 | |||
75db9fde3c | |||
bbc2af5962 | |||
91621625e6 | |||
fb19b12e7f | |||
d23d3ca6d1 | |||
516200d881 | |||
8969505383 | |||
e9ff14d63e | |||
da251b57b0 | |||
10b325ad29 | |||
8a2d81ec23 | |||
a15844f52d | |||
6ad73aa3c9 | |||
e0cac3c800 | |||
2ba5df152a | |||
be26a19f2e | |||
b9ea8dffa3 | |||
218090d1e5 | |||
51f40311e0 | |||
f65e5b122f | |||
038a4e44ba | |||
f3cb7deaf4 | |||
1745299e12 | |||
7904c52671 | |||
b17e04de71 | |||
779b06b4bf | |||
b66b94fd83 | |||
2672f9e45f | |||
2af61ca986 | |||
c57461f118 | |||
7269cec73d | |||
26350a7eb6 | |||
68a6507c1b | |||
f49b2f7df1 | |||
e48ab4b58a | |||
3c7b1a70ce | |||
f38e9df6b9 | |||
1f661a7038 | |||
ab4290a706 | |||
66b4c50221 | |||
a4a3b6de1b | |||
9f8a6e1a27 | |||
2ca0929fe8 | |||
d9b72bce0c | |||
3d5939ed9c | |||
e829514e91 | |||
6355afef59 | |||
a1d19b4474 | |||
636c1fdadb | |||
d29a5984f1 | |||
7df7465012 | |||
0b2a74ddd3 | |||
a1c3e79e90 | |||
7358c4440c | |||
7b1b789644 | |||
e7ca3ac0d7 | |||
963453d2d6 | |||
99f1a8d681 | |||
46ab5d620b | |||
4c0a1c8f3f | |||
6593d372e0 | |||
df503cf810 | |||
cffafa82d9 | |||
6495904cf1 | |||
dc432c4ac9 | |||
b6171637af | |||
f0c4f237de | |||
99bd4df741 | |||
1d38d77c57 | |||
a866699f5d | |||
594354e459 | |||
75b43f8993 | |||
268d3a371e | |||
e50ad9430e | |||
cc0398f878 | |||
173b1e329b | |||
4eb29750ab | |||
346238dab8 | |||
6f57a4b24c | |||
9913c9e084 | |||
ae583bd2ef | |||
ad16311941 | |||
493f71ac20 | |||
ae3d34e0f2 | |||
3f29b504b2 | |||
2e5c87f0b1 | |||
565486aef3 | |||
17ba7a8bdd | |||
e5cecd6102 | |||
c1068acbf7 | |||
795545e8af | |||
535fe31054 | |||
b4f021bb8b | |||
1f4848cc83 | |||
dcafde1158 | |||
288ecb60e1 | |||
9b038dc8e4 | |||
4a201f3f9d | |||
ee71044421 | |||
a57f5476c0 | |||
71b39d467d | |||
240bcc6dd4 | |||
2fc8523bfe | |||
add2001ba3 | |||
eca2165247 | |||
70f362015c | |||
8ad71d3a06 | |||
459c630db7 | |||
dc0e3bf11c | |||
89bb802e45 | |||
dd7df3080e | |||
475fdfcca7 | |||
db755334d0 | |||
42f9cd9ea8 | |||
1980363c12 | |||
2bd07bf598 | |||
07c8b62dc1 | |||
d20a25f261 | |||
4c14c85a47 | |||
ef7c2f8271 | |||
40119c9e9c | |||
5b819c5e52 | |||
8432884479 | |||
9e9ceab81f | |||
82b16ec9fb | |||
97719badef | |||
11a0dc3a4a | |||
2348c76ee8 | |||
59f10c789a | |||
6518458768 | |||
7c325e7c9e | |||
aab5325255 | |||
6a898bc2dc | |||
af8ea5ddc3 | |||
47e69da60b | |||
e730607c66 | |||
ac1ad771f0 | |||
87f60e1826 | |||
6285014938 | |||
0c3199515b | |||
272cd1441c | |||
7c5e3c1e43 | |||
274eb2d214 | |||
9fa553bd4a | |||
7aa5be57cd | |||
3cedfcaeaf | |||
2e77b1a216 | |||
2cf3329131 | |||
e3fdf08b2c | |||
96a8c8da6c | |||
2d1cdd5e94 | |||
e6b37ce453 | |||
af5e6172e9 | |||
fdc68079dc | |||
88a4fc02d1 | |||
f8d97314d3 | |||
d6bc6df86b | |||
2fce2e0c80 | |||
69139b9ac5 | |||
bf1d53d07d | |||
3797adfc95 | |||
2482242f20 | |||
6929136f90 | |||
0d7fa41261 | |||
60b6a462c8 | |||
2e9e15be95 | |||
e0eda9df49 | |||
08f2585def | |||
53cc25b4ce | |||
f8d05f2cec | |||
e37d287397 | |||
fd89626172 | |||
e1967bcd7e | |||
6d5f8bc00c | |||
79193d897e | |||
c7252d0f7b | |||
2064ac508a | |||
35b0d30739 | |||
66a950f757 | |||
6e463ca588 | |||
95abec2b4b | |||
0fff71b1fb | |||
3bde83cf92 | |||
5b2e6dda67 | |||
408aa965fd | |||
39e80c64fa | |||
d3df5fe2d3 | |||
52019e61c1 | |||
c745e3c27c | |||
bf18944761 | |||
9b1d8fca71 | |||
2d0bb883bd | |||
f68571900e | |||
d6d809a60e | |||
c0d79d1bc7 | |||
2f45e58369 | |||
610b6a5766 | |||
a862a0f03a | |||
c3c6da1acc | |||
f2943e4bc1 | |||
9f198ed133 | |||
93367b6e70 | |||
43b5973dbe | |||
0002c15e94 |
33
.env.example
Normal file
@ -0,0 +1,33 @@
|
||||
SERVER_NAME= # URL where this will be hosted.
|
||||
FLASK_DEBUG=False
|
||||
|
||||
TZ=Europe/London # Time Zone
|
||||
|
||||
## App Configuration
|
||||
SECRET_KEY= # Long, secure, secret string.
|
||||
DATA=./data/
|
||||
DATABASE_TYPE=SQLite # SQLite or MySQL, defaults to SQLite
|
||||
DATABASE_HOST= # Required if MySQL. Must match name of Docker service, or provide host if database is on an external server. Defaults to localhost.
|
||||
DATABASE_PORT= # Required if MySQL. Defaults to 3306
|
||||
|
||||
## MySQL Database Configuration (Required if configured to MySQL Database.)
|
||||
# Note that if using the Docker service, these configuration values will also be used when creating the database in the mysql container.
|
||||
MYSQL_RANDOM_ROOT_PASSWORD=True
|
||||
MYSQL_DATABASE= # Required if MySQL.
|
||||
MYSQL_USER= # Required if MySQL
|
||||
MYSQL_PASSWORD= # Required if MySQL. Create secure password string. Note '@' character cannot be used.
|
||||
|
||||
## Flask Mail Configuration
|
||||
MAIL_SERVER=postfix # Must match name of the Docker service
|
||||
MAIL_PORT=25
|
||||
MAIL_USE_TLS=False
|
||||
MAIL_USE_SSL=False
|
||||
MAIL_USERNAME= # Username@domain, must match config values below
|
||||
MAIL_PASSWORD= # Must match config value below
|
||||
MAIL_DEFAULT_SENDER= # NoReply@domain or some such.
|
||||
MAIL_MAX_EMAILS=25
|
||||
MAIL_ASCII_ATTACHMENTS=True
|
||||
|
||||
# Postfix
|
||||
maildomain= # Domain must match the section of username above
|
||||
smtp_user= # username:password. Must match config values above.
|
18
.gitignore
vendored
@ -145,5 +145,21 @@ dev/
|
||||
out/
|
||||
ref-test/testing.py
|
||||
|
||||
# Ignore Database
|
||||
database/data/
|
||||
|
||||
# Ignore Encryption Keyfile
|
||||
.encryption.key
|
||||
.encryption.key
|
||||
|
||||
# Ignore Data Dir
|
||||
**/data/*
|
||||
|
||||
# Ignore Logs Dir
|
||||
logs/*
|
||||
|
||||
# Ignore Certbot Dir
|
||||
certbot/*
|
||||
|
||||
# Ignore src dir (exception for robots.txt)
|
||||
src/html/*
|
||||
src/html/robots.txt
|
217
README.md
@ -10,31 +10,224 @@ The exam client is made with accessibility in mind, and has been designed to be
|
||||
|
||||
## Set Up and Installation
|
||||
|
||||
The clien is designed to work on a server.
|
||||
The app is designed to be hosted on a server.
|
||||
|
||||
### Pre-Requisites
|
||||
|
||||
Server
|
||||
Docker
|
||||
Docker-Compose
|
||||
Git
|
||||
- A Debian- or Ubuntu-based server, preferably the latest distribution.
|
||||
- Docker (specifically, Docker Engine)
|
||||
- Docker Compose
|
||||
- Git
|
||||
|
||||
### Installation
|
||||
|
||||
#### Install all the pre-requisites
|
||||
|
||||
The first step is to ensure all the prerequisites are available on the server.
|
||||
|
||||
To set up the server, consult some of the comprehensive guides on various hosting platforms like Linode or DigitalOcean.
|
||||
Here is a [good starting point on setting up a server](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu-22-04).
|
||||
|
||||
To install Docker and Docker Compose, consult the respective documentation:
|
||||
|
||||
- [Install on Ubuntu](https://docs.docker.com/engine/install/ubuntu/) or [Install on Debian](https://docs.docker.com/engine/install/debian/)
|
||||
- Docker Compose should be installed as part of that.
|
||||
|
||||
> At the time of writing, there has been an upgrade to Docker and Docker Compose, meaning the syntax below might be different between versions.
|
||||
|
||||
Check if Git is installed on your server using the `git --version` command.
|
||||
If it isn't installed, install it.
|
||||
This should normally come pre-packaged with your OS distribution.
|
||||
But if it doesn't, look up how to for whatever OS you use.
|
||||
If you are using Ubuntu or Debian, it should be as easy as using the command:
|
||||
|
||||
```$ sudo apt-get install git -y```
|
||||
|
||||
#### Preliminary Set-Up: Clone repos and Configure Values
|
||||
|
||||
#### Set Up Web Server
|
||||
Open a terminal and navigate to the folder where you want to install this app.
|
||||
I would suggest using a subfolder within your Home folder:
|
||||
|
||||
#### Incorporate SSL
|
||||
```$ cd ~ && mkdir ska-referee-test && cd ska-referee-test```
|
||||
|
||||
#### Set Up Auto-Renew
|
||||
That way, you will ensure you can read and write all the necessary files during installation.
|
||||
Once in the destination folder, clone all the relevant files you will need for the installation:
|
||||
|
||||
### Alterations
|
||||
```$ git clone https://git.vsnt.uk/viveksantayana/ska-referee-test.git .```
|
||||
|
||||
## Use
|
||||
(Remember to include the trailing dot at the end, as that indicates to Git to download the files in the current directory.)
|
||||
|
||||
## Compatibility
|
||||
#### Choose What Database Engine You Will Use
|
||||
|
||||
### iOS Limitations
|
||||
This app is designed to use an SQLite database by default.
|
||||
You can set it up to use a MySQL database by configuring the environment variables accordingly.
|
||||
If your database is being hosted remotely, make sure the MySQL database has the proper authentication for the user from a remote server.
|
||||
Alternatively, you can also use the second `docker-compose-mysql.yml` file which provides a MySQL database as part of the cluster.
|
||||
To use the second `docker-compose-mysql.yml` file, use the following command at the last step of the installation:
|
||||
|
||||
```sudo docker compose -f docker-compose-mysql.yml up```
|
||||
|
||||
#### Populate Environment Variables
|
||||
|
||||
Configuration values for the app are stored in the environment variables file.
|
||||
To set it up, make a copy of the example file and populate it with appropriate values.
|
||||
|
||||
```$ cp .env.example .env```
|
||||
|
||||
Make sure to use complex, secure strings for passwords.
|
||||
Also make sure that the various entries for usernames and passwords match.
|
||||
|
||||
#### Input Specific Values for Your Installation
|
||||
|
||||
There are some values in the following four files you will need to configure to reflect the domain you are installing this app.
|
||||
|
||||
```sh
|
||||
# .env
|
||||
|
||||
SERVER_NAME= # URL where this will be hosted.
|
||||
```
|
||||
|
||||
```sh
|
||||
# install-script.sh
|
||||
|
||||
domains=(example.org www.example.org)
|
||||
email="" # Adding a valid address is strongly recommended
|
||||
```
|
||||
|
||||
Substitute the domain name `domain_name` in the two file paths in the following file:
|
||||
|
||||
```sh
|
||||
# nginx/ssl.conf
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem;
|
||||
...
|
||||
```
|
||||
|
||||
And **six** locations in the following file, two for the regular version of the domain and four for the www version (remember to keep the www. prefix where present):
|
||||
|
||||
```nginx
|
||||
# nginx/conf.d/ref-test-app.conf
|
||||
|
||||
server {
|
||||
server_name domain_name;
|
||||
listen 80 default_server;
|
||||
...
|
||||
}
|
||||
|
||||
server {
|
||||
server_name domain_name;
|
||||
listen 443 ssl http2 default_server;
|
||||
...
|
||||
}
|
||||
|
||||
server {
|
||||
server_name www.domain_name;
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
# Redirect to non-www
|
||||
return 301 $scheme://domain_name$request_uri;
|
||||
...
|
||||
}
|
||||
|
||||
server {
|
||||
server_name www.domain_name;
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
...
|
||||
|
||||
# Redirect to non-www
|
||||
return 301 $scheme://domain_name$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
#### Installing SSL Certificates
|
||||
|
||||
The app will use SSL certificates to operate through a secure, `https` connection.
|
||||
This will be set up automatically.
|
||||
However, there is a specific chicken-and-egg problem as the web server, Nginx, won't run without certificates, Certbot, the certificate generator, won't run without the web server.
|
||||
So to solve this, there is an automation script we can run that will set up a dummy certificate and then issue the appropriate certificates for us.
|
||||
|
||||
```sh
|
||||
chmod +x install-script.sh
|
||||
sudo ./install-script.sh
|
||||
```
|
||||
|
||||
This will take a long time to run the first time because it will try and generate a fairly sizeable cypher.
|
||||
|
||||
When we later run the server, Certbot will check for renewals of the SSL certificates every 12 hours, and Nginx will reload the configurations every 6 hours, to make sure everything runs smoothly and stays live.
|
||||
|
||||
#### Run the Stack
|
||||
|
||||
Everything should be good to run on autopilot at this point.
|
||||
Navigate to the root folder of the app, the folder where you have `install-script.sh` and `docker-compose.yml`.
|
||||
Run the following command:
|
||||
|
||||
```sudo docker compose up -d```
|
||||
|
||||
And you should have the stack running.
|
||||
You can register in the app and begin using it.
|
||||
|
||||
### Fonts
|
||||
|
||||
The app uses [OpenDyslexic](https://opendyslexic.org/), which is available on-line.
|
||||
It also has the option of rendering in other system fonts, but this can vary depending on your operating system.
|
||||
Because these are proprietary fonts, they cannot be made available on-line in the same way as open source ones should your system not have them.
|
||||
Some fonts may not display correctly as a result.
|
||||
|
||||
## Updating the Installation
|
||||
|
||||
If the app is updated, you can update the version on your installation using the following method:
|
||||
|
||||
### Navigate to the root folder
|
||||
|
||||
This will be the root folder into which you cloned the git repository when you set the app up.
|
||||
|
||||
### Stash your local changes
|
||||
|
||||
When you update the code, there is a risk the changes you made to your configuration will be overwritten.
|
||||
To avoid this, use the following command:
|
||||
|
||||
```git stash```
|
||||
|
||||
This will stash the changes you made, and we can re-apply the changes once the new code has been downloaded.
|
||||
If you do not have any other changes stashed, the index number of these changes should be `0` in a later step.
|
||||
If there are other changes, make sure to note what the correct index number for the stashed changes is.
|
||||
|
||||
### Take down the Docker containers
|
||||
|
||||
We will need to stop the current containers with the following command:
|
||||
|
||||
```sudo docker compose down```
|
||||
|
||||
This may take a few seconds.
|
||||
|
||||
### Pull the updated code
|
||||
|
||||
Download the updated code from the Git repository:
|
||||
|
||||
```git pull```
|
||||
|
||||
This step might fail if you have any un-stashed local changed.
|
||||
|
||||
### Re-Apply your local configurations
|
||||
|
||||
Because we stashed our local configurations, we can re-apply them once again:
|
||||
|
||||
```git stash pop 0```
|
||||
|
||||
The index number (`0`) is assuming there were no other changes saved on your git repository.
|
||||
If you have a different index number for the relevant changes from the above step, change this accordingly.
|
||||
|
||||
### Re-build the docker image
|
||||
|
||||
Now that we have the base code downloaded, we just need to update the docker image:
|
||||
|
||||
```sudo docker compose build app```
|
||||
|
||||
### Re-build the containers
|
||||
|
||||
This is the same last step as running the containers in the last step of the installation:
|
||||
|
||||
```sudo docker compose up -d```
|
||||
|
@ -6,11 +6,6 @@
|
||||
|
||||
- [Docker Compose Reference](https://docs.docker.com/compose/compose-file/compose-file-v3/)
|
||||
|
||||
### MongoDB/PyMongo
|
||||
|
||||
- [MongoDB Shell Commands](https://docs.mongodb.com/manual/reference/)
|
||||
- [PyMongo Driver](https://pymongo.readthedocs.io/en/stable/)
|
||||
|
||||
## Source Code
|
||||
|
||||
- [MongoDB Docker Image entrypoint shell script](https://github.com/docker-library/mongo/blob/master/5.0/docker-entrypoint.sh) (Context: Tried to replicate the command to create a new user in the original entrypoint script in the custom initialisation script in this app.)
|
||||
@ -23,15 +18,6 @@
|
||||
- [Tables](https://www.blog.pythonlibrary.org/2017/12/14/flask-101-adding-editing-and-displaying-data/)
|
||||
- [Tables, but interactive](https://blog.miguelgrinberg.com/post/beautiful-interactive-tables-for-your-flask-templates)
|
||||
|
||||
## Stack Exchange/Overflow
|
||||
|
||||
### MongoDB
|
||||
|
||||
- [Creating MongoDB Database on Container Start](https://stackoverflow.com/questions/42912755/how-to-create-a-db-for-mongodb-container-on-start-up)
|
||||
- [Passing Environment Variables to Docker Container Entrypoint](https://stackoverflow.com/questions/64606674/how-can-i-pass-environment-variables-to-mongo-docker-entrypoint-initdb-d)
|
||||
- [Integrating Flask-Login with MongoDB](https://stackoverflow.com/questions/54992412/flask-login-usermixin-class-with-a-mongodb) (**This does not work with the app as is, and is possibly something that needs more research and development in the future**)
|
||||
- [Setting up a Postfix email notification system](https://medium.com/@vietgoeswest/a-simple-outbound-email-service-for-your-app-in-15-minutes-cc4da70a2af7)
|
||||
|
||||
## YouTube Tutorials
|
||||
|
||||
### General Flask Introduction
|
||||
@ -72,7 +58,7 @@ A much simpler and more rudimentary introduction to Flask and MongoDB.
|
||||
- [Build a User Login System with `flask-login`, `flask-wtforms`, `flask-bootstrap`, and `flask-sqlalchemy`](https://www.youtube.com/watch?v=8aTnmsDMldY)
|
||||
|
||||
A much more robust method that uses the various Flask modules to make a more powerful framework.
|
||||
Uses SQL rather than MongoDB.
|
||||
Uses SQL.
|
||||
|
||||
### Flask techniques
|
||||
|
||||
@ -80,4 +66,4 @@ Uses SQL rather than MongoDB.
|
||||
|
||||
### Flask handling file uploads
|
||||
|
||||
- [Handlin File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask)
|
||||
- [Handling File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask)
|
||||
|
2
certbot/.gitignore
vendored
@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
@ -1,14 +0,0 @@
|
||||
set -e
|
||||
mongo=( mongo --host 127.0.0.1 --port 27017 --quiet )
|
||||
|
||||
if [ "$MONGO_INITDB_ROOT_USERNAME" ] && [ "$MONGO_INITDB_ROOT_PASSWORD" ] && [ "$MONGO_INITDB_USERNAME" ] && [ "$MONGO_INITDB_PASSWORD" ]; then
|
||||
rootAuthDatabase='admin'
|
||||
|
||||
"${mongo[@]}" "$rootAuthDatabase" <<-EOJS
|
||||
db.createUser({
|
||||
user: $(_js_escape "$MONGO_INITDB_USERNAME"),
|
||||
pwd: $(_js_escape "$MONGO_INITDB_PASSWORD"),
|
||||
roles: [ { role: 'readWrite', db: $(_js_escape "$MONGO_INITDB_DATABASE") } ]
|
||||
})
|
||||
EOJS
|
||||
fi
|
90
docker-compose-mysql.yml
Normal file
@ -0,0 +1,90 @@
|
||||
version: '3.9'
|
||||
|
||||
volumes:
|
||||
app:
|
||||
mysql:
|
||||
|
||||
services:
|
||||
nginx:
|
||||
container_name: reftest_server
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
- ./certbot:/etc/letsencrypt:ro
|
||||
- ./nginx:/etc/nginx
|
||||
- ./src/html/certbot:/usr/share/nginx/html/certbot:ro
|
||||
- ./src/html/robots.txt:/usr/share/nginx/html/robots.txt:ro
|
||||
- ./ref-test/app/root:/usr/share/nginx/html/root:ro
|
||||
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static:ro
|
||||
- ./ref-test/app/editor/static:/usr/share/nginx/html/editor/static:ro
|
||||
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static:ro
|
||||
- ./ref-test/app/view/static:/usr/share/nginx/html/view/static:ro
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- frontend
|
||||
depends_on:
|
||||
- app
|
||||
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
||||
|
||||
app:
|
||||
container_name: reftest_app
|
||||
image: reftest
|
||||
build: ./ref-test
|
||||
env_file:
|
||||
- ./.env
|
||||
ports:
|
||||
- 5000
|
||||
volumes:
|
||||
- app:/ref-test/data
|
||||
- ./logs:/ref-test/data/logs
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
depends_on:
|
||||
postfix:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
||||
postfix:
|
||||
container_name: reftest_postfix
|
||||
image: catatnight/postfix:latest
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./.env
|
||||
ports:
|
||||
- 25
|
||||
networks:
|
||||
- backend
|
||||
|
||||
certbot:
|
||||
container_name: reftest_certbot
|
||||
image: certbot/certbot
|
||||
volumes:
|
||||
- ./certbot:/etc/letsencrypt
|
||||
- ./src/html/certbot:/var/www/html
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
|
||||
mysql:
|
||||
container_name: reftest_db
|
||||
image: mysql:8.0
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- mysql:/var/lib/mysql
|
||||
ports:
|
||||
- 3306
|
||||
networks:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
external: false
|
||||
backend:
|
||||
external: false
|
@ -1,15 +1,23 @@
|
||||
version: '3.9'
|
||||
|
||||
volumes:
|
||||
app:
|
||||
|
||||
services:
|
||||
ref_test_server:
|
||||
container_name: ref_test_server
|
||||
image: nginx:1.21.4-alpine
|
||||
nginx:
|
||||
container_name: reftest_server
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
- ./certbot:/etc/letsencrypt:ro
|
||||
- ./nginx:/etc/nginx
|
||||
- ./src/html:/usr/share/nginx/html/
|
||||
- ./ref-test/admin/static:/usr/share/nginx/html/admin/static
|
||||
- ./ref-test/quiz/static:/usr/share/nginx/html/quiz/static
|
||||
- ./src/html/certbot:/usr/share/nginx/html/certbot:ro
|
||||
- ./src/html/robots.txt:/usr/share/nginx/html/robots.txt:ro
|
||||
- ./ref-test/app/root:/usr/share/nginx/html/root:ro
|
||||
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static:ro
|
||||
- ./ref-test/app/editor/static:/usr/share/nginx/html/editor/static:ro
|
||||
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static:ro
|
||||
- ./ref-test/app/view/static:/usr/share/nginx/html/view/static:ro
|
||||
- ./ref-test/app/analysis/static:/usr/share/nginx/html/analysis/static:ro
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
@ -17,41 +25,29 @@ services:
|
||||
networks:
|
||||
- frontend
|
||||
depends_on:
|
||||
- ref_test_app
|
||||
- app
|
||||
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
||||
|
||||
ref_test_app:
|
||||
container_name: ref_test_app
|
||||
app:
|
||||
container_name: reftest_app
|
||||
image: reftest
|
||||
build: ./ref-test
|
||||
volumes:
|
||||
- ./ref-test:/ref-test
|
||||
env_file:
|
||||
- ./.env
|
||||
ports:
|
||||
- 5000
|
||||
volumes:
|
||||
- app:/ref-test/data
|
||||
- ./logs:/ref-test/data/logs
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
depends_on:
|
||||
- ref_test_db
|
||||
- ref_test_postfix
|
||||
- postfix
|
||||
|
||||
ref_test_db:
|
||||
container_name: ref_test_db
|
||||
image: mongo:5.0.4-focal
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./database/data:/data
|
||||
- ./database/initdb.d/:/docker-entrypoint-initdb.d/
|
||||
env_file:
|
||||
- ./.env
|
||||
ports:
|
||||
- 27017
|
||||
networks:
|
||||
- backend
|
||||
|
||||
ref_test_postfix:
|
||||
container_name: ref_test_postfix
|
||||
postfix:
|
||||
container_name: reftest_postfix
|
||||
image: catatnight/postfix:latest
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
@ -61,15 +57,13 @@ services:
|
||||
networks:
|
||||
- backend
|
||||
|
||||
ref_test_certbot:
|
||||
container_name: ref_test_certbot
|
||||
image: certbot/certbot:v1.21.0
|
||||
certbot:
|
||||
container_name: reftest_certbot
|
||||
image: certbot/certbot
|
||||
volumes:
|
||||
- ./certbot:/etc/letsencrypt
|
||||
- ./src/html:/var/www/html
|
||||
depends_on:
|
||||
- ref_test_server
|
||||
# command: certonly --webroot --webroot-path=/var/www/html --email vsdomainmanager@gmail.com --agree-tos --no-eff-email -d reftest.vsnt.uk
|
||||
- ./src/html/certbot:/var/www/html
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
|
90
install-script.sh
Normal file
@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Source https://pentacent.medium.com/nginx-and-lets-encrypt-with-docker-in-less-than-5-minutes-b4b8a60d3a71
|
||||
|
||||
if ! [ -x "$(command -v docker)" ]; then
|
||||
echo 'Error: docker is not installed.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [ -x "$(command -v compose)" ]; then
|
||||
echo 'Error: docker compose is not installed.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
domains=(example.org www.example.org)
|
||||
rsa_key_size=4096
|
||||
data_path="./certbot"
|
||||
email="" # Adding a valid address is strongly recommended
|
||||
staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits
|
||||
|
||||
if [ -d "$data_path" ]; then
|
||||
read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision
|
||||
if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
|
||||
exit
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -e "$data_path/ssl-dhparams.pem" ]; then
|
||||
echo "### Generating ssl-dhparams.pem ..."
|
||||
docker compose run --rm --entrypoint "\
|
||||
openssl dhparam -out /etc/letsencrypt/ssl-dhparams.pem 4096" certbot
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "### Creating dummy certificate for $domains ..."
|
||||
path="/etc/letsencrypt/live/$domains"
|
||||
mkdir -p "$data_path/live/$domains"
|
||||
docker compose run --rm --entrypoint "\
|
||||
openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
|
||||
-keyout '$path/privkey.pem' \
|
||||
-out '$path/fullchain.pem' \
|
||||
-subj '/CN=localhost'" certbot
|
||||
echo
|
||||
|
||||
if [ ! -e "$data_path/lets-encrypt-x3-cross-signed.pem" ]; then
|
||||
echo "### Downloading lets-encrypt-x3-cross-signed.pem ..."
|
||||
wget -O $data_path/lets-encrypt-x3-cross-signed.pem \
|
||||
"https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem"
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "### Starting nginx ..."
|
||||
docker compose up --force-recreate -d nginx
|
||||
echo
|
||||
|
||||
echo "### Deleting dummy certificate for $domains ..."
|
||||
docker compose run --rm --entrypoint "\
|
||||
rm -Rf /etc/letsencrypt/live/$domains && \
|
||||
rm -Rf /etc/letsencrypt/archive/$domains && \
|
||||
rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot
|
||||
echo
|
||||
|
||||
echo "### Requesting Let's Encrypt certificate for $domains ..."
|
||||
#Join $domains to -d args
|
||||
domain_args=""
|
||||
for domain in "${domains[@]}"; do
|
||||
domain_args="$domain_args -d $domain"
|
||||
done
|
||||
|
||||
# Select appropriate email arg
|
||||
case "$email" in
|
||||
"") email_arg="--register-unsafely-without-email" ;;
|
||||
*) email_arg="--email $email" ;;
|
||||
esac
|
||||
|
||||
# Enable staging mode if needed
|
||||
if [ $staging != "0" ]; then staging_arg="--staging"; fi
|
||||
|
||||
docker compose run --rm --entrypoint "\
|
||||
certbot certonly --non-interactive --webroot -w /var/www/html \
|
||||
$staging_arg \
|
||||
$email_arg \
|
||||
$domain_args \
|
||||
--rsa-key-size $rsa_key_size \
|
||||
--agree-tos \
|
||||
--force-renewal" certbot
|
||||
echo
|
||||
|
||||
echo "### Reloading nginx ..."
|
||||
docker compose exec nginx nginx -s reload
|
@ -1,6 +1,6 @@
|
||||
# Certbot Renewal
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /usr/share/nginx/html;
|
||||
root /usr/share/nginx/html/certbot;
|
||||
allow all;
|
||||
default_type "text/plain";
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
|
||||
listen 443 ssl http2 default_server;
|
||||
listen [::]:443 ssl http2 default_server;
|
||||
|
||||
access_log /var/log/nginx/host.access.log main;
|
||||
|
||||
# SSL configuration
|
||||
include /etc/nginx/ssl.conf;
|
||||
|
||||
# Add index.php to the list if you are using PHP
|
||||
index index.html index.htm index.nginx-debian.html;
|
||||
|
||||
# Default catch all to 404
|
||||
# Added from Serverfault support https://serverfault.com/questions/994141/nginx-redirecting-the-wrong-subdomains
|
||||
server_name _;
|
||||
server_name_in_redirect off;
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
|
||||
#error_page 404 /404.html;
|
||||
|
||||
# redirect server error pages to the static page /50x.html
|
||||
#
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
}
|
79
nginx/conf.d/ref-test-app.conf
Normal file
@ -0,0 +1,79 @@
|
||||
upstream reftest {
|
||||
server app:5000;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name domain_name;
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
# Redirect to ssl
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name domain_name;
|
||||
listen 443 ssl http2 default_server;
|
||||
listen [::]:443 ssl http2 default_server;
|
||||
|
||||
# SSL configuration
|
||||
include /etc/nginx/ssl.conf;
|
||||
include /etc/nginx/certbot-challenge.conf;
|
||||
|
||||
# Define locations for static files to be served by Nginx
|
||||
location ^~ /root/ {
|
||||
include /etc/nginx/mime.types;
|
||||
alias /usr/share/nginx/html/root/;
|
||||
}
|
||||
|
||||
location ^~ /quiz/static/ {
|
||||
include /etc/nginx/mime.types;
|
||||
alias /usr/share/nginx/html/quiz/static/;
|
||||
}
|
||||
|
||||
location ^~ /admin/static/ {
|
||||
include /etc/nginx/mime.types;
|
||||
alias /usr/share/nginx/html/admin/static/;
|
||||
}
|
||||
|
||||
location ^~ /admin/editor/static/ {
|
||||
include /etc/nginx/mime.types;
|
||||
alias /usr/share/nginx/html/editor/static/;
|
||||
}
|
||||
|
||||
location ^~ /admin/view/static/ {
|
||||
include /etc/nginx/mime.types;
|
||||
alias /usr/share/nginx/html/view/static/;
|
||||
}
|
||||
|
||||
location ^~ /admin/analysis/static/ {
|
||||
include /etc/nginx/mime.types;
|
||||
alias /usr/share/nginx/html/analysis/static/;
|
||||
}
|
||||
|
||||
# Proxy to the main app for all other requests
|
||||
location / {
|
||||
include /etc/nginx/conf.d/proxy_headers.conf;
|
||||
proxy_pass http://reftest;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
server_name www.domain_name;
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
# Redirect to non-www
|
||||
return 301 $scheme://domain_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name www.domain_name;
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
# SSL configuration
|
||||
include /etc/nginx/ssl.conf;
|
||||
include /etc/nginx/certbot-challenge.conf;
|
||||
|
||||
# Redirect to non-www
|
||||
return 301 $scheme://domain_name$request_uri;
|
||||
}
|
13
nginx/ssl.conf
Normal file
@ -0,0 +1,13 @@
|
||||
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem;
|
||||
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
ssl_trusted_certificate /etc/letsencrypt/lets-encrypt-x3-cross-signed.pem;
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
ssl_session_cache shared:SSL:40m;
|
||||
ssl_session_timeout 4h;
|
||||
ssl_session_tickets on;
|
@ -1,2 +1,3 @@
|
||||
env/
|
||||
__pycache__/
|
||||
__pycache__/
|
||||
data/
|
@ -1,5 +1,8 @@
|
||||
FROM python:3.10-slim
|
||||
ARG DATA=./data/
|
||||
ENV DATA=$DATA
|
||||
WORKDIR /ref-test
|
||||
COPY . .
|
||||
RUN pip install --upgrade pip && pip install -r requirements.txt
|
||||
RUN chmod +x install.py reset.py && ./install.py
|
||||
CMD [ "gunicorn", "-b", "0.0.0.0:5000", "-w", "5", "wsgi:app" ]
|
@ -1,147 +0,0 @@
|
||||
from flask import Blueprint, render_template, request, session, redirect
|
||||
from flask.helpers import flash, url_for
|
||||
from flask.json import jsonify
|
||||
from .models.users import User
|
||||
from uuid import uuid4
|
||||
from common.security.database import decrypt_find_one, encrypted_update
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from .views import admin_account_required, disable_on_registration, login_required, disable_if_logged_in, get_id_from_cookie
|
||||
|
||||
auth = Blueprint(
|
||||
'admin_auth',
|
||||
__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static'
|
||||
)
|
||||
|
||||
@auth.route('/account/', methods = ['GET', 'POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def account():
|
||||
from .models.forms import UpdateAccountForm
|
||||
from main import db
|
||||
form = UpdateAccountForm()
|
||||
_id = get_id_from_cookie()
|
||||
user = decrypt_find_one(db.users, {'_id': _id})
|
||||
if request.method == 'GET':
|
||||
return render_template('/admin/auth/account.html', form = form, user = user)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
password_confirm = request.form.get('password_confirm')
|
||||
if not check_password_hash(user['password'], password_confirm):
|
||||
return jsonify({ 'error': 'The password you entered is incorrect.' }), 401
|
||||
entry = User(
|
||||
_id = _id,
|
||||
password = request.form.get('password'),
|
||||
email = request.form.get('email')
|
||||
)
|
||||
return entry.update()
|
||||
else:
|
||||
errors = [*form.password_confirm.errors, *form.password_reenter.errors, *form.password.errors, *form.email.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@auth.route('/login/', methods=['GET','POST'])
|
||||
@admin_account_required
|
||||
@disable_if_logged_in
|
||||
def login():
|
||||
from .models.forms import LoginForm
|
||||
form = LoginForm()
|
||||
if request.method == 'GET':
|
||||
return render_template('/admin/auth/login.html', form=form)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
entry = User(
|
||||
username = request.form.get('username').lower(),
|
||||
password = request.form.get('password'),
|
||||
remember = request.form.get('remember')
|
||||
)
|
||||
return entry.login()
|
||||
else:
|
||||
errors = [*form.username.errors, *form.password.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@auth.route('/logout/')
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def logout():
|
||||
_id = get_id_from_cookie()
|
||||
return User(_id=_id).logout()
|
||||
|
||||
@auth.route('/register/', methods=['GET','POST'])
|
||||
@disable_on_registration
|
||||
def register():
|
||||
from .models.forms import RegistrationForm
|
||||
form = RegistrationForm()
|
||||
if request.method == 'GET':
|
||||
return render_template('/admin/auth/register.html', form=form)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
entry = User(
|
||||
_id = uuid4().hex,
|
||||
username = request.form.get('username').lower(),
|
||||
email = request.form.get('email'),
|
||||
password = request.form.get('password'),
|
||||
)
|
||||
return entry.register()
|
||||
else:
|
||||
errors = [*form.username.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@auth.route('/reset/', methods = ['GET', 'POST'])
|
||||
@admin_account_required
|
||||
@disable_if_logged_in
|
||||
def reset():
|
||||
from .models.forms import ResetPasswordForm
|
||||
form = ResetPasswordForm()
|
||||
if request.method == 'GET':
|
||||
return render_template('/admin/auth/reset.html', form=form)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
entry = User(
|
||||
username = request.form.get('username').lower(),
|
||||
email = request.form.get('email'),
|
||||
)
|
||||
return entry.reset_password()
|
||||
else:
|
||||
errors = [*form.username.errors, *form.email.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@auth.route('/reset/<token1>/<token2>/', methods = ['GET'])
|
||||
@admin_account_required
|
||||
@disable_if_logged_in
|
||||
def reset_gateway(token1,token2):
|
||||
from main import db
|
||||
user = decrypt_find_one( db.users, {'reset_token' : token1} )
|
||||
if not user:
|
||||
return redirect(url_for('admin_auth.login'))
|
||||
encrypted_update( db.users, {'reset_token': token1}, {'$unset': {'reset_token' : '', 'verification_token': ''}})
|
||||
if not user['verification_token'] == token2:
|
||||
flash('The verification of your password reset request failed and the token has been invalidated. Please make a new reset password request.', 'error'), 401
|
||||
return redirect(url_for('admin_auth.reset'))
|
||||
session['_id'] = user['_id']
|
||||
session['reset_validated'] = True
|
||||
return redirect(url_for('admin_auth.update_password_'))
|
||||
|
||||
@auth.route('/reset/update/', methods = ['GET','POST'])
|
||||
@admin_account_required
|
||||
@disable_if_logged_in
|
||||
def update_password_():
|
||||
from .models.forms import UpdatePasswordForm
|
||||
form = UpdatePasswordForm()
|
||||
if request.method == 'GET':
|
||||
if 'reset_validated' not in session:
|
||||
return redirect(url_for('admin_auth.login'))
|
||||
session.pop('reset_validated')
|
||||
return render_template('/admin/auth/update-password.html', form=form)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
entry = User(
|
||||
_id = session['_id'],
|
||||
password = request.form.get('password')
|
||||
)
|
||||
session.pop('_id')
|
||||
return entry.update()
|
||||
else:
|
||||
errors = [*form.password.errors, *form.password_reenter.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
@ -1,11 +0,0 @@
|
||||
from wtforms.validators import ValidationError
|
||||
|
||||
def value(min=0, max=None):
|
||||
message = f'Value must be between {min} and {max}.'
|
||||
|
||||
def _length(form, field):
|
||||
value = field.data or 0
|
||||
if value < min or max != None and value > max:
|
||||
raise ValidationError(message)
|
||||
|
||||
return _length
|
@ -1,122 +0,0 @@
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
from flask import flash, jsonify
|
||||
import secrets
|
||||
import os
|
||||
from json import dump, loads
|
||||
|
||||
from common.security import encrypt
|
||||
|
||||
class Test:
|
||||
|
||||
def __init__(self, _id=None, start_date=None, expiry_date=None, time_limit=None, creator=None, dataset=None):
|
||||
self._id = _id
|
||||
self.start_date = start_date
|
||||
self.expiry_date = expiry_date
|
||||
self.time_limit = None if time_limit == 'none' or time_limit == '' or time_limit == None else int(time_limit)
|
||||
self.creator = creator
|
||||
self.dataset = dataset
|
||||
|
||||
def create(self):
|
||||
from main import app, db
|
||||
test = {
|
||||
'_id': self._id,
|
||||
'date_created': datetime.today(),
|
||||
'start_date': self.start_date,
|
||||
'expiry_date': self.expiry_date,
|
||||
'time_limit': self.time_limit,
|
||||
'creator': encrypt(self.creator),
|
||||
'test_code': secrets.token_hex(6).upper(),
|
||||
'dataset': self.dataset
|
||||
}
|
||||
if db.tests.insert_one(test):
|
||||
dataset_file_path = os.path.join(app.config["DATA_FILE_DIRECTORY"],self.dataset)
|
||||
with open(dataset_file_path, 'r') as dataset_file:
|
||||
data = loads(dataset_file.read())
|
||||
data['meta']['tests'].append(self._id)
|
||||
with open(dataset_file_path, 'w') as dataset_file:
|
||||
dump(data, dataset_file, indent=2)
|
||||
flash(f'Created a new exam with Exam Code <strong>{self.render_test_code(test["test_code"])}</strong>.', 'success')
|
||||
return jsonify({'success': test}), 200
|
||||
return jsonify({'error': f'Could not create exam. An error occurred.'}), 400
|
||||
|
||||
def add_time_adjustment(self, time_adjustment):
|
||||
from main import db
|
||||
user_code = secrets.token_hex(3).upper()
|
||||
adjustment = {
|
||||
user_code: time_adjustment
|
||||
}
|
||||
if db.tests.find_one_and_update({'_id': self._id}, {'$set': {'time_adjustments': adjustment}},upsert=False):
|
||||
flash(f'Time adjustment for {time_adjustment} minutes has been added. This can be enabled using the user code {user_code}.')
|
||||
return jsonify({'success': adjustment})
|
||||
return jsonify({'error': 'Failed to add the time adjustment. An error occurred.'}), 400
|
||||
|
||||
def remove_time_adjustment(self, user_code):
|
||||
from main import db
|
||||
if db.tests.find_one_and_update({'_id': self._id}, {'$unset': {f'time_adjustments.{user_code}': {}}}):
|
||||
message = 'Time adjustment has been deleted.'
|
||||
flash(message, 'success')
|
||||
return jsonify({'success': message})
|
||||
return jsonify({'error': 'Failed to delete the time adjustment. An error occurred.'}), 400
|
||||
|
||||
def render_test_code(self, test_code):
|
||||
return '—'.join([test_code[:4], test_code[4:8], test_code[8:]])
|
||||
|
||||
def parse_test_code(self, test_code):
|
||||
return test_code.replace('—', '')
|
||||
|
||||
def delete(self):
|
||||
from main import app, db
|
||||
test = db.tests.find_one({'_id': self._id})
|
||||
if 'entries' in test:
|
||||
if test['entries']:
|
||||
return jsonify({'error': 'Cannot delete an exam that has entries submitted to it.'}), 400
|
||||
if self.dataset is None:
|
||||
self.dataset = db.tests.find_one({'_id': self._id})['dataset']
|
||||
if db.tests.delete_one({'_id': self._id}):
|
||||
dataset_file_path = os.path.join(app.config["DATA_FILE_DIRECTORY"],self.dataset)
|
||||
with open(dataset_file_path, 'r') as dataset_file:
|
||||
data = loads(dataset_file.read())
|
||||
data['meta']['tests'].remove(self._id)
|
||||
with open(dataset_file_path, 'w') as dataset_file:
|
||||
dump(data, dataset_file, indent=2)
|
||||
message = 'Deleted exam.'
|
||||
flash(message, 'alert')
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': f'Could not create exam. An error occurred.'}), 400
|
||||
|
||||
def update(self):
|
||||
from main import db
|
||||
test = {}
|
||||
updated = []
|
||||
if not self.start_date == '' and self.start_date is not None:
|
||||
test['start_date'] = self.start_date
|
||||
updated.append('start date')
|
||||
if not self.expiry_date == '' and self.expiry_date is not None:
|
||||
test['expiry_date'] = self.expiry_date
|
||||
updated.append('expiry date')
|
||||
if not self.time_limit == '' and self.time_limit is not None:
|
||||
test['time_limit'] = int(self.time_limit)
|
||||
updated.append('time limit')
|
||||
output = ''
|
||||
if len(updated) == 0:
|
||||
flash(f'There were no changes requested for your account.', 'alert'), 200
|
||||
return jsonify({'success': 'There were no changes requested for your account.'}), 200
|
||||
elif len(updated) == 1:
|
||||
output = updated[0]
|
||||
elif len(updated) == 2:
|
||||
output = ' and '.join(updated)
|
||||
elif len(updated) > 2:
|
||||
output = updated[0]
|
||||
for index in range(1,len(updated)):
|
||||
if index < len(updated) - 2:
|
||||
output = ', '.join([output, updated[index]])
|
||||
elif index == len(updated) - 2:
|
||||
output = ', and '.join([output, updated[index]])
|
||||
else:
|
||||
output = ''.join([output, updated[index]])
|
||||
db.tests.find_one_and_update({'_id': self._id}, {'$set': test})
|
||||
_output = f'The {output} of the test {"has" if len(updated) == 1 else "have"} been updated.'
|
||||
flash(_output)
|
||||
return jsonify({'success': _output}), 200
|
@ -1,205 +0,0 @@
|
||||
from flask import flash, make_response, Response, session
|
||||
from flask.helpers import url_for
|
||||
from flask.json import jsonify
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from werkzeug.utils import redirect
|
||||
from flask_mail import Message
|
||||
import secrets
|
||||
|
||||
from common.security import encrypt, decrypt
|
||||
from common.security.database import decrypt_find_one, encrypted_update
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class User:
|
||||
|
||||
def __init__(self, _id=None, username=None, password=None, email=None, remember=False):
|
||||
self._id = _id
|
||||
self.username = username
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.remember = remember
|
||||
|
||||
def start_session(self, resp:Response):
|
||||
resp.set_cookie(
|
||||
key = '_id',
|
||||
value = self._id,
|
||||
max_age = timedelta(days=14) if self.remember else 'Session',
|
||||
path = '/',
|
||||
expires = datetime.utcnow() + timedelta(days=14) if self.remember else 'Session',
|
||||
domain = '.reftest.vsnt.uk',
|
||||
secure = True
|
||||
)
|
||||
if self.remember:
|
||||
resp.set_cookie (
|
||||
key = 'remember',
|
||||
value = 'True',
|
||||
max_age = timedelta(days=14),
|
||||
path = '/',
|
||||
expires = datetime.utcnow() + timedelta(days=14),
|
||||
domain = '.reftest.vsnt.uk',
|
||||
secure = True
|
||||
)
|
||||
|
||||
def register(self):
|
||||
from main import db
|
||||
from ..views import get_id_from_cookie
|
||||
user = {
|
||||
'_id': self._id,
|
||||
'email': encrypt(self.email),
|
||||
'password': generate_password_hash(self.password, method='sha256'),
|
||||
'username': encrypt(self.username)
|
||||
}
|
||||
if decrypt_find_one(db.users, { 'username': self.username }):
|
||||
return jsonify({ 'error': f'Username {self.username} is not available.' }), 400
|
||||
if db.users.insert_one(user):
|
||||
flash(f'User {self.username} has been created successfully. You may now use it to log in and administer the tests.', 'success')
|
||||
resp = make_response(jsonify(user), 200)
|
||||
if not get_id_from_cookie:
|
||||
self.start_session(resp)
|
||||
return resp
|
||||
return jsonify({ 'error': f'Registration failed. An error occurred.' }), 400
|
||||
|
||||
def login(self):
|
||||
from main import db
|
||||
user = decrypt_find_one( db.users, { 'username': self.username })
|
||||
if not user:
|
||||
return jsonify({ 'error': f'Username {self.username} does not exist.' }), 401
|
||||
if not check_password_hash( user['password'], self.password ):
|
||||
return jsonify({ 'error': f'The password you entered is incorrect.' }), 401
|
||||
response = {
|
||||
'success': f'Successfully logged in user {self.username}.'
|
||||
}
|
||||
if 'prev_page' in session:
|
||||
response['redirect_to'] = session['prev_page']
|
||||
session.pop('prev_page')
|
||||
resp = make_response(jsonify(response), 200)
|
||||
self._id = user['_id']
|
||||
self.start_session(resp)
|
||||
return resp
|
||||
|
||||
def logout(self):
|
||||
resp = make_response(redirect(url_for('admin_auth.login')))
|
||||
resp.set_cookie(
|
||||
key = '_id',
|
||||
value = '',
|
||||
max_age = timedelta(days=-1),
|
||||
path = '/',
|
||||
expires= datetime.utcnow() + timedelta(days=-1),
|
||||
domain = '.reftest.vsnt.uk',
|
||||
secure = True
|
||||
)
|
||||
resp.set_cookie (
|
||||
key = 'cookie_consent',
|
||||
value = 'True',
|
||||
max_age = 'Session',
|
||||
path = '/',
|
||||
expires = 'Session',
|
||||
domain = '.reftest.vsnt.uk',
|
||||
secure = True
|
||||
)
|
||||
resp.set_cookie (
|
||||
key = 'remember',
|
||||
value = 'True',
|
||||
max_age = timedelta(days=-1),
|
||||
path = '/',
|
||||
expires = datetime.utcnow() + timedelta(days=-1),
|
||||
domain = '.reftest.vsnt.uk',
|
||||
secure = True
|
||||
)
|
||||
flash('You have been logged out. All cookies pertaining to your account have been deleted. Have a nice day.', 'alert')
|
||||
return resp
|
||||
|
||||
def reset_password(self):
|
||||
from main import db, mail
|
||||
user = decrypt_find_one(db.users, { 'username': self.username })
|
||||
if not user:
|
||||
return jsonify({ 'error': f'Username {self.username} does not exist.' }), 401
|
||||
if not user['email'] == self.email:
|
||||
return jsonify({ 'error': f'The email address {self.email} does not match the user account {self.username}.' }), 401
|
||||
new_password = secrets.token_hex(12)
|
||||
reset_token = secrets.token_urlsafe(16)
|
||||
verification_token = secrets.token_urlsafe(16)
|
||||
user['password'] = generate_password_hash(new_password, method='sha256')
|
||||
if encrypted_update(db.users, { 'username': self.username }, { '$set': {'password': user['password'], 'reset_token': reset_token, 'verification_token': verification_token } } ):
|
||||
flash(f'Your password has been reset. Instructions to recover your account have been sent to {self.email}. Please be sure to check your spam folder in case you have not received the email.', 'alert')
|
||||
email = Message(
|
||||
subject = 'RefTest | Password Reset',
|
||||
recipients = [self.email],
|
||||
body = f"""
|
||||
Hello {user['username']}, \n\n
|
||||
This email was generated because we received a request to reset the password for your administrator account for the SKA RefTest app.\n\n
|
||||
If you did not make this request, please ignore this email.\n\n
|
||||
If you did make this request, then you have two options to recover your account.\n\n
|
||||
For the time being, your password has been reset to the following:\n\n
|
||||
{new_password}\n\n
|
||||
You may use this to log back in to your account, and subsequently change your password to something more suitable.\n\n
|
||||
Alternatively, you may visit the following private link using your unique token to override your password. Copy and paste the following link in a web browser. Please note that this token is only valid once:\n\n
|
||||
{url_for('admin_auth.reset_gateway', token1 = reset_token, token2 = verification_token, _external = True)}\n\n
|
||||
Have a nice day.
|
||||
""",
|
||||
html = f"""
|
||||
<p>Hello {user['username']},</p>
|
||||
<p>This email was generated because we received a request to reset the password for your administrator account for the SKA RefTest app. </p>
|
||||
<p>If you did not make this request, please ignore this email.</p>
|
||||
<p>If you did make this request, then you have two options to recover your account.</p>
|
||||
<p>For the time being, your password has been reset to the following:</p>
|
||||
<strong>{new_password}</strong>
|
||||
<p>You may use this to log back in to your account, and subsequently change your password to something more suitable.</p>
|
||||
<p>Alternatively, you may visit the following private link using your unique token to override your password. Click on the following link, or copy and paste it into a browser. <strong>Please note that this token is only valid once</strong>:</p>
|
||||
<p><a href='{url_for('admin_auth.reset_gateway', token1 = reset_token, token2 = verification_token, _external = True)}'>{url_for('admin_auth.reset_gateway', token1 = reset_token, token2 = verification_token, _external = True)}</a></p>
|
||||
<p>Have a nice day.</p>
|
||||
"""
|
||||
)
|
||||
mail.send(email)
|
||||
return jsonify({ 'success': 'Password reset request has been processed.'}), 200
|
||||
|
||||
def update(self):
|
||||
from main import db
|
||||
from ..views import get_id_from_cookie
|
||||
retrieved_user = decrypt_find_one(db.users, { '_id': self._id })
|
||||
if not retrieved_user:
|
||||
return jsonify({ 'error': f'User {retrieved_user["username"]} does not exist.' }), 401
|
||||
user = {}
|
||||
updated = []
|
||||
if not self.email == '' and self.email is not None:
|
||||
user['email'] = self.email
|
||||
updated.append('email')
|
||||
if not self.password == '' and self.password is not None:
|
||||
user['password'] = generate_password_hash(self.password, method='sha256')
|
||||
updated.append('password')
|
||||
output = ''
|
||||
if len(updated) == 0:
|
||||
flash(f'There were no changes requested for your account.', 'alert'), 200
|
||||
return jsonify({'success': 'There were no changes requested for your account.'}), 200
|
||||
elif len(updated) == 1:
|
||||
output = updated[0]
|
||||
elif len(updated) == 2:
|
||||
output = ' and '.join(updated)
|
||||
elif len(updated) > 2:
|
||||
output = updated[0]
|
||||
for index in range(1,len(updated)):
|
||||
if index < len(updated) - 2:
|
||||
output = ', '.join([output, updated[index]])
|
||||
elif index == len(updated) - 2:
|
||||
output = ', and '.join([output, updated[index]])
|
||||
else:
|
||||
output = ''.join([output, updated[index]])
|
||||
encrypted_update(db.users, {'_id': self._id}, { '$set': user })
|
||||
if self._id == get_id_from_cookie():
|
||||
_output = 'Your '
|
||||
elif retrieved_user['username'][-1] == 's':
|
||||
_output = '’'.join([retrieved_user['username'], ''])
|
||||
else:
|
||||
_output = '’'.join([retrieved_user['username'], 's'])
|
||||
_output = f'{_output} {output} {"has" if len(updated) == 1 else "have"} been updated.'
|
||||
flash(_output)
|
||||
return jsonify({'success': _output}), 200
|
||||
|
||||
def delete(self):
|
||||
from main import db
|
||||
retrieved_user = decrypt_find_one(db.users, { '_id': self._id })
|
||||
if not retrieved_user:
|
||||
return jsonify({ 'error': f'User does not exist.' }), 401
|
||||
db.users.find_one_and_delete({'_id': self._id})
|
||||
flash(f'User {retrieved_user["username"]} has been deleted.')
|
||||
return jsonify({'success': f'User {retrieved_user["username"]} has been deleted.'}), 200
|
@ -1,15 +0,0 @@
|
||||
from flask import Blueprint, render_template
|
||||
from .views import login_required, admin_account_required
|
||||
|
||||
results = Blueprint(
|
||||
'results',
|
||||
__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static'
|
||||
)
|
||||
|
||||
@results.route('/')
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def _results():
|
||||
return render_template('/admin/results.html')
|
@ -1,2 +0,0 @@
|
||||
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
@ -1,79 +0,0 @@
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin_views.home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbar"
|
||||
aria-controls="navbar"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle Navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navbar">
|
||||
<ul class="navbar-nav">
|
||||
{% if not check_login() %}
|
||||
<li class="nav-item" id="nav-login">
|
||||
<a href="{{ url_for('admin_auth.login') }}" id="link-login" class="nav-link">Log In</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if check_login() %}
|
||||
<li class="nav-item" id="nav-results">
|
||||
<a href="{{ url_for('results._results') }}" id="link-results" class="nav-link">View Results</a>
|
||||
</li>
|
||||
<li class="nav-item" id="nav-tests">
|
||||
<a href="{{ url_for('admin_views.tests') }}" id="link-tests" class="nav-link">Manage Exams</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-settings">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
role="button"
|
||||
href="{{ url_for('admin_views.settings') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-account"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin_views.users') }}" id="link-users" class="dropdown-item">Users</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin_views.questions') }}" id="link-questions" class="dropdown-item">Question Datasets</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-account">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
role="button"
|
||||
href="{{ url_for('admin_auth.account') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Account
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-account"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin_auth.account') }}" id="link-account" class="dropdown-item">Account Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin_auth.logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
@ -1,23 +0,0 @@
|
||||
<div class="navbar navbar-expand-sm navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<div class="expand navbar-expand justify-content-center" id="navbar_secondary">
|
||||
<ul class="nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='active') }}">Active</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='scheduled') }}">Scheduled</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='expired') }}">Expired</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='all') }}">All</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='create') }}">Create</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1 +0,0 @@
|
||||
{% extends "admin/components/base.html" %}
|
@ -1,509 +0,0 @@
|
||||
from flask import Blueprint, render_template, flash, redirect, request, jsonify, abort, session
|
||||
from flask.helpers import url_for
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import os
|
||||
from glob import glob
|
||||
from json import loads
|
||||
from werkzeug.security import check_password_hash
|
||||
from common.security.database import decrypt_find, decrypt_find_one
|
||||
from .models.users import User
|
||||
from flask_mail import Message
|
||||
from uuid import uuid4
|
||||
import secrets
|
||||
from datetime import datetime, date
|
||||
from .models.tests import Test
|
||||
from common.data_tools import get_default_dataset, get_time_options, available_datasets, get_datasets, get_correct_answers
|
||||
|
||||
views = Blueprint(
|
||||
'admin_views',
|
||||
__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static'
|
||||
)
|
||||
|
||||
def admin_account_required(function):
|
||||
@wraps(function)
|
||||
def decorated_function(*args, **kwargs):
|
||||
from main import db
|
||||
from main import db
|
||||
if not db.users.find_one({}):
|
||||
flash('No administrator accounts have been registered. Please register an administrator account.', 'alert')
|
||||
return redirect(url_for('admin_auth.register'))
|
||||
return function(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def disable_on_registration(function):
|
||||
@wraps(function)
|
||||
def decorated_function(*args, **kwargs):
|
||||
from main import db
|
||||
if db.users.find_one({}):
|
||||
return abort(404)
|
||||
return function(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def get_id_from_cookie():
|
||||
return request.cookies.get('_id')
|
||||
|
||||
def get_user_from_db(_id):
|
||||
from main import db
|
||||
return db.users.find_one({'_id': _id})
|
||||
|
||||
def check_login():
|
||||
_id = get_id_from_cookie()
|
||||
return True if get_user_from_db(_id) else False
|
||||
|
||||
def login_required(function):
|
||||
@wraps(function)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not check_login():
|
||||
session['prev_page'] = request.url
|
||||
flash('Please log in to view this page.', 'alert')
|
||||
return redirect(url_for('admin_auth.login'))
|
||||
return function(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def disable_if_logged_in(function):
|
||||
@wraps(function)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if check_login():
|
||||
return abort(404)
|
||||
return function(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@views.route('/')
|
||||
@views.route('/home/')
|
||||
@views.route('/dashboard/')
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def home():
|
||||
from main import db
|
||||
tests = db.tests.find()
|
||||
results = decrypt_find(db.entries, {})
|
||||
current_tests = [ test for test in tests if test['expiry_date'] >= datetime.utcnow() and test['start_date'].date() <= date.today() ]
|
||||
current_tests.sort(key= lambda x: x['expiry_date'], reverse=True)
|
||||
upcoming_tests = [ test for test in tests if test['start_date'] > datetime.utcnow()]
|
||||
upcoming_tests.sort(key= lambda x: x['start_date'])
|
||||
recent_results = [result for result in results if 'submission_time' in result ]
|
||||
recent_results.sort(key= lambda x: x['submission_time'], reverse=True)
|
||||
for result in recent_results:
|
||||
result['percent'] = round(100*result['results']['score']/result['results']['max'])
|
||||
return render_template('/admin/index.html', current_tests = current_tests[:5], upcomimg_tests = upcoming_tests[:5], recent_results = recent_results[:5])
|
||||
|
||||
@views.route('/settings/')
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def settings():
|
||||
from main import db
|
||||
users = decrypt_find(db.users, {})
|
||||
users.sort(key= lambda x: x['username'])
|
||||
datasets = get_datasets()
|
||||
return render_template('/admin/settings/index.html', users=users[:5], datasets=datasets[:5])
|
||||
|
||||
@views.route('/settings/users/', methods=['GET','POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def users():
|
||||
from main import db, mail
|
||||
from .models.forms import CreateUserForm
|
||||
form = CreateUserForm()
|
||||
if request.method == 'GET':
|
||||
users_list = decrypt_find(db.users, {})
|
||||
return render_template('/admin/settings/users.html', users = users_list, form = form)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
entry = User(
|
||||
_id = uuid4().hex,
|
||||
username = request.form.get('username').lower(),
|
||||
email = request.form.get('email'),
|
||||
password = request.form.get('password') if not request.form.get('password') == '' else secrets.token_hex(12),
|
||||
)
|
||||
email = Message(
|
||||
subject = 'RefTest | Registration Confirmation',
|
||||
recipients = [entry.email],
|
||||
body = f"""
|
||||
Hello {entry.username}, \n\n
|
||||
You have been registered as an administrator for the SKA RefTest App!\n\n
|
||||
You can access your account using the username '{entry.username}'.\n\n
|
||||
Your password is as follows:\n\n
|
||||
{entry.password}\n\n
|
||||
You can change your password by logging in to the admin console by copying the following URL into a web browser:\n\n
|
||||
{url_for('admin_views.home', _external = True)}\n\n
|
||||
Have a nice day.
|
||||
""",
|
||||
html = f"""
|
||||
<p>Hello {entry.username},</p>
|
||||
<p>You have been registered as an administrator for the SKA RefTest App!</p>
|
||||
<p>You can access your account using the username '{entry.username}'.</p>
|
||||
<p>Your password is as follows:</p>
|
||||
<strong>{entry.password}</strong>
|
||||
<p>You can change your password by logging in to the admin console at the link below:</p>
|
||||
<p><a href='{url_for('admin_views.home', _external = True)}'>{url_for('admin_views.home', _external = True)}</a></p>
|
||||
<p>Have a nice day.</p>
|
||||
"""
|
||||
)
|
||||
mail.send(email)
|
||||
return entry.register()
|
||||
else:
|
||||
errors = [*form.username.errors, *form.email.errors, *form.password.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@views.route('/settings/users/delete/<string:_id>', methods = ['GET', 'POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def delete_user(_id:str):
|
||||
from main import db, mail
|
||||
if _id == get_id_from_cookie():
|
||||
flash('Cannot delete your own user account.', 'error')
|
||||
return redirect(url_for('admin_views.users'))
|
||||
from .models.forms import DeleteUserForm
|
||||
form = DeleteUserForm()
|
||||
user = decrypt_find_one(db.users, {'_id': _id})
|
||||
if request.method == 'GET':
|
||||
if not user:
|
||||
return abort(404)
|
||||
return render_template('/admin/settings/delete-user.html', form = form, _id = _id, user = user)
|
||||
if request.method == 'POST':
|
||||
if not user:
|
||||
return jsonify({ 'error': 'User does not exist.' }), 404
|
||||
if form.validate_on_submit():
|
||||
_user = decrypt_find_one(db.users, {'_id': get_id_from_cookie()})
|
||||
password = request.form.get('password')
|
||||
if not check_password_hash(_user['password'], password):
|
||||
return jsonify({ 'error': 'The password you entered is incorrect.' }), 401
|
||||
if request.form.get('notify'):
|
||||
email = Message(
|
||||
subject = 'RefTest | Account Deletion',
|
||||
recipients = [user['email']],
|
||||
bcc = [_user['email']],
|
||||
body = f"""
|
||||
Hello {user['username']}, \n\n
|
||||
Your administrator account for the SKA RefTest App has been deleted by {_user['username']}. All data about your account has been deleted.\n\n
|
||||
If you believe this was done in error, please contact them immediately.\n\n
|
||||
If you would like to register to administer the app, please ask an existing administrator to create a new account.\n\n
|
||||
Have a nice day.
|
||||
""",
|
||||
html = f"""
|
||||
<p>Hello {user['username']},</p>
|
||||
<p>Your administrator account for the SKA RefTest App has been deleted by {_user['username']}. All data about your account has been deleted.</p>
|
||||
<p>If you believe this was done in error, please contact them immediately.</p>
|
||||
<p>If you would like to register to administer the app, please ask an existing administrator to create a new account.</p>
|
||||
<p>Have a nice day.</p>
|
||||
"""
|
||||
)
|
||||
mail.send(email)
|
||||
user = User(
|
||||
_id = user['_id']
|
||||
)
|
||||
return user.delete()
|
||||
else: return abort(400)
|
||||
|
||||
@views.route('/settings/users/update/<string:_id>', methods = ['GET', 'POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def update_user(_id:str):
|
||||
from main import db, mail
|
||||
if _id == get_id_from_cookie():
|
||||
flash('Cannot delete your own user account.', 'error')
|
||||
return redirect(url_for('admin_views.users'))
|
||||
from .models.forms import UpdateUserForm
|
||||
form = UpdateUserForm()
|
||||
user = decrypt_find_one( db.users, {'_id': _id})
|
||||
if request.method == 'GET':
|
||||
if not user:
|
||||
return abort(404)
|
||||
return render_template('/admin/settings/update-user.html', form = form, _id = _id, user = user)
|
||||
if request.method == 'POST':
|
||||
if not user:
|
||||
return jsonify({ 'error': 'User does not exist.' }), 404
|
||||
if form.validate_on_submit():
|
||||
_user = decrypt_find_one(db.users, {'_id': get_id_from_cookie()})
|
||||
password = request.form.get('password')
|
||||
if not check_password_hash(_user['password'], password):
|
||||
return jsonify({ 'error': 'The password you entered is incorrect.' }), 401
|
||||
if request.form.get('notify'):
|
||||
recipient = request.form.get('email') if not request.form.get('email') == '' else user['email']
|
||||
email = Message(
|
||||
subject = 'RefTest | Account Update',
|
||||
recipients = [recipient],
|
||||
bcc = [_user['email']],
|
||||
body = f"""
|
||||
Hello {user['username']}, \n\n
|
||||
Your administrator account for the SKA RefTest App has been updated by {_user['username']}.\n\n
|
||||
Your new account details are as follows:\n\n
|
||||
Email: {recipient}\n
|
||||
Password: {request.form.get('password')}\n\n
|
||||
You can update your email and password by logging in to the app.\n\n
|
||||
Have a nice day.
|
||||
""",
|
||||
html = f"""
|
||||
<p>Hello {user['username']},</p>
|
||||
<p>Your administrator account for the SKA RefTest App has been updated by {_user['username']}.</p>
|
||||
<p>Your new account details are as follows:</p>
|
||||
<p>Email: {recipient} <br/>Password: {request.form.get('password')}</p>
|
||||
<p>You can update your email and password by logging in to the app.</p>
|
||||
<p>Have a nice day.</p>
|
||||
"""
|
||||
)
|
||||
mail.send(email)
|
||||
entry = User(
|
||||
_id = _id,
|
||||
email = request.form.get('email'),
|
||||
password = request.form.get('password')
|
||||
)
|
||||
return entry.update()
|
||||
else:
|
||||
errors = [*form.user_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@views.route('/settings/questions/', methods=['GET', 'POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def questions():
|
||||
from .models.forms import UploadDataForm
|
||||
from common.data_tools import check_json_format, validate_json_contents, store_data_file
|
||||
form = UploadDataForm()
|
||||
if request.method == 'GET':
|
||||
data = get_datasets()
|
||||
default = get_default_dataset()
|
||||
return render_template('/admin/settings/questions.html', form=form, data=data, default=default)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
upload = form.data_file.data
|
||||
default = True if request.form.get('default') else False
|
||||
if not check_json_format(upload):
|
||||
return jsonify({ 'error': 'Invalid file selected. Please upload a JSON file.'}), 400
|
||||
if not validate_json_contents(upload):
|
||||
return jsonify({'error': 'The data in the file is invalid.'}), 400
|
||||
filename = store_data_file(upload, default=default)
|
||||
flash(f'Dataset {form.data_file.data.filename} has been uploaded as {filename}.', 'success')
|
||||
return jsonify({ 'success': f'Dataset {form.data_file.data.filename} has been uploaded as {filename}.'}), 200
|
||||
errors = [*form.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@views.route('/settings/questions/delete/', methods=['POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def delete_questions():
|
||||
from main import db, app
|
||||
filename = request.get_json()['filename']
|
||||
data_files = glob(os.path.join(app.config["DATA_FILE_DIRECTORY"],'*.json'))
|
||||
if any(filename in file for file in data_files):
|
||||
default = get_default_dataset()
|
||||
if default == filename:
|
||||
return jsonify({'error': 'Cannot delete the default question dataset.'}), 400
|
||||
data_file = os.path.join(app.config["DATA_FILE_DIRECTORY"],filename)
|
||||
with open(data_file, 'r') as _data_file:
|
||||
data = loads(_data_file.read())
|
||||
if data['meta']['tests']:
|
||||
return jsonify({'error': 'Cannot delete a dataset that is in use by an exam.'}), 400
|
||||
if len(data_files) == 1:
|
||||
return jsonify({'error': 'Cannot delete the only question dataset.'}), 400
|
||||
os.remove(data_file)
|
||||
flash(f'Question dataset {filename} has been deleted.', 'success')
|
||||
return jsonify({'success': f'Question dataset {filename} has been deleted.'}), 200
|
||||
return abort(404)
|
||||
|
||||
@views.route('/settings/questions/default/', methods=['POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def make_default_questions():
|
||||
from main import app
|
||||
filename = request.get_json()['filename']
|
||||
data_files = glob(os.path.join(app.config["DATA_FILE_DIRECTORY"],'*.json'))
|
||||
default_file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], '.default.txt')
|
||||
if any(filename in file for file in data_files):
|
||||
with open(default_file_path, 'r') as default_file:
|
||||
default = default_file.read()
|
||||
if default == filename:
|
||||
return jsonify({'error': 'Cannot delete default question dataset.'}), 400
|
||||
with open(default_file_path, 'w') as default_file:
|
||||
default_file.write(filename)
|
||||
flash(f'Set dataset f{filename} as the default.', 'success')
|
||||
return jsonify({'success': f'Set dataset {filename} as the default.'})
|
||||
return abort(404)
|
||||
|
||||
@views.route('/tests/<filter>/', methods=['GET'])
|
||||
@views.route('/tests/', methods=['GET'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def tests(filter=''):
|
||||
from main import db
|
||||
if not available_datasets():
|
||||
flash('There are no available question datasets. Please upload a question dataset in order to set up an exam.', 'error')
|
||||
return redirect(url_for('admin_views.questions'))
|
||||
if filter not in ['', 'create', 'active', 'scheduled', 'expired', 'all']:
|
||||
return abort(404)
|
||||
if filter == 'create':
|
||||
from .models.forms import CreateTest
|
||||
form = CreateTest()
|
||||
form.time_limit.choices = get_time_options()
|
||||
form.dataset.choices = available_datasets()
|
||||
form.time_limit.default='none'
|
||||
form.dataset.default=get_default_dataset()
|
||||
form.process()
|
||||
display_title = ''
|
||||
error_none = ''
|
||||
return render_template('/admin/tests.html', form = form, display_title=display_title, error_none=error_none, filter=filter)
|
||||
_tests = db.tests.find({})
|
||||
if filter == 'active' or filter == '':
|
||||
tests = [ test for test in _tests if test['expiry_date'] >= datetime.utcnow() and test['start_date'].date() <= date.today() ]
|
||||
display_title = 'Active Exams'
|
||||
error_none = 'There are no exams that are currently active. You can create one using the Creat Exam form.'
|
||||
if filter == 'expired':
|
||||
tests = [ test for test in _tests if test['expiry_date'] < datetime.utcnow()]
|
||||
display_title = 'Expired Exams'
|
||||
error_none = 'There are no expired exams. Exams will appear in this category after their expiration date has passed.'
|
||||
if filter == 'scheduled':
|
||||
tests = [ test for test in _tests if test['start_date'].date() > date.today()]
|
||||
display_title = 'Scheduled Exams'
|
||||
error_none = 'There are no scheduled exams pending. You can schedule an exam for the future using the Create Exam form.'
|
||||
if filter == 'all':
|
||||
tests = _tests
|
||||
display_title = 'All Exams'
|
||||
error_none = 'There are no exams set up. You can create one using the Create Exam form.'
|
||||
return render_template('/admin/tests.html', tests = tests, display_title=display_title, error_none=error_none, filter=filter)
|
||||
|
||||
@views.route('/tests/create/', methods=['POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def create_test():
|
||||
from main import db
|
||||
from .models.forms import CreateTest
|
||||
form = CreateTest()
|
||||
form.dataset.choices = available_datasets()
|
||||
form.time_limit.choices = get_time_options()
|
||||
if form.validate_on_submit():
|
||||
start_date = request.form.get('start_date')
|
||||
start_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||
expiry_date = request.form.get('expiry_date')
|
||||
expiry_date = datetime.strptime(expiry_date, '%Y-%m-%d') + timedelta(days= 1) - timedelta(milliseconds = 1)
|
||||
dataset = request.form.get('dataset')
|
||||
errors = []
|
||||
if start_date.date() < date.today():
|
||||
errors.append('The start date cannot be in the past.')
|
||||
if expiry_date.date() < date.today():
|
||||
errors.append('The expiry date cannot be in the past.')
|
||||
if expiry_date < start_date:
|
||||
errors.append('The expiry date cannot be before the start date.')
|
||||
if errors:
|
||||
return jsonify({'error': errors}), 400
|
||||
creator_id = get_id_from_cookie()
|
||||
creator = decrypt_find_one(db.users, { '_id': creator_id } )['username']
|
||||
test = Test(
|
||||
_id = uuid4().hex,
|
||||
start_date = start_date,
|
||||
expiry_date = expiry_date,
|
||||
time_limit = request.form.get('time_limit'),
|
||||
creator = creator,
|
||||
dataset = dataset
|
||||
)
|
||||
test.create()
|
||||
return jsonify({'success': 'New exam created.'}), 200
|
||||
else:
|
||||
errors = [*form.expiry.errors, *form.time_limit.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@views.route('/tests/delete/', methods=['POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def delete_test():
|
||||
from main import db
|
||||
_id = request.get_json()['_id']
|
||||
if db.tests.find_one({'_id': _id}):
|
||||
return Test(_id = _id).delete()
|
||||
return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
|
||||
|
||||
@views.route('/tests/close/', methods=['POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def close_test():
|
||||
from main import db
|
||||
_id = request.get_json()['_id']
|
||||
if db.tests.find_one({'_id': _id}):
|
||||
return Test(_id = _id, expiry_date= datetime.utcnow()).update()
|
||||
return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
|
||||
|
||||
@views.route('/test/<_id>/', methods=['GET','POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def view_test(_id):
|
||||
from main import db
|
||||
from .models.forms import AddTimeAdjustment
|
||||
form = AddTimeAdjustment()
|
||||
test = decrypt_find_one(db.tests, {'_id': _id})
|
||||
if request.method == 'GET':
|
||||
if not test:
|
||||
return abort(404)
|
||||
return render_template('/admin/test.html', test = test, form = form)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
time = int(request.form.get('time'))
|
||||
return Test(_id=_id).add_time_adjustment(time)
|
||||
return jsonify({'error': form.time.errors }), 400
|
||||
|
||||
@views.route('/test/<_id>/delete-adjustment/', methods = ['POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def delete_adjustment(_id):
|
||||
user_code = request.get_json()['user_code']
|
||||
return Test(_id=_id).remove_time_adjustment(user_code)
|
||||
|
||||
@views.route('/results/')
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def view_entries():
|
||||
from main import db
|
||||
entries = decrypt_find(db.entries, {})
|
||||
return render_template('/admin/results.html', entries = entries)
|
||||
|
||||
@views.route('/results/<_id>/', methods = ['GET', 'POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def view_entry(_id=''):
|
||||
from main import app, db
|
||||
entry = decrypt_find_one(db.entries, {'_id': _id})
|
||||
if request.method == 'GET':
|
||||
if not entry:
|
||||
return abort(404)
|
||||
test_code = entry['test_code']
|
||||
test = db.tests.find_one({'test_code' : test_code})
|
||||
dataset = test['dataset']
|
||||
dataset_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], dataset)
|
||||
with open(dataset_path, 'r') as _dataset:
|
||||
data = loads(_dataset.read())
|
||||
correct = get_correct_answers(dataset=data)
|
||||
print(correct.values())
|
||||
return render_template('/admin/result-detail.html', entry = entry, correct = correct)
|
||||
if request.method == 'POST':
|
||||
if not entry:
|
||||
return jsonify({'error': 'A valid entry could no be found.'}), 404
|
||||
action = request.get_json()['action']
|
||||
if action == 'override':
|
||||
late_ignore = db.entries.find_one_and_update({'_id': _id}, {'$set': {'status': 'late (allowed)'}})
|
||||
if late_ignore:
|
||||
flash('Late status for the entry has been allowed.', 'success')
|
||||
return jsonify({'success': 'Late status allowed.'}), 200
|
||||
return jsonify({'error': 'An error occurred.'}), 400
|
||||
if action == 'delete':
|
||||
test_code = entry['test_code']
|
||||
test = db.tests.find_one_and_update({'test_code': test_code}, {'$pull': {'entries': _id}})
|
||||
if not test:
|
||||
return jsonify({'error': 'A valid exam could not be found.'}), 404
|
||||
delete = db.entries.delete_one({'_id': _id})
|
||||
if delete:
|
||||
flash('Entry has been deleted.', 'success')
|
||||
return jsonify({'success': 'Entry has been deleted.'}), 200
|
||||
return jsonify({'error': 'An error occurred.'}), 400
|
||||
|
||||
@views.route('/certificate/', methods=['POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def generate_certificate():
|
||||
from main import db
|
||||
_id = request.get_json()['_id']
|
||||
entry = decrypt_find_one(db.entries, {'_id': _id})
|
||||
if not entry:
|
||||
return abort(404)
|
||||
return render_template('/admin/components/certificate.html', entry = entry)
|
70
ref-test/app/__init__.py
Normal file
@ -0,0 +1,70 @@
|
||||
from .config import Production as Config
|
||||
from .models import *
|
||||
from .extensions import bootstrap, csrf, db, login_manager, mail
|
||||
from .tools.logs import write
|
||||
|
||||
from flask import flash, Flask, render_template, request
|
||||
from flask.helpers import abort, url_for
|
||||
from flask.json import jsonify
|
||||
from flask_wtf.csrf import CSRFError
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config())
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto= 1, x_host= 1)
|
||||
|
||||
bootstrap.init_app(app)
|
||||
csrf.init_app(app)
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
mail.init_app(app)
|
||||
|
||||
login_manager.login_view = 'admin._login'
|
||||
@login_manager.user_loader
|
||||
def _load_user(id):
|
||||
try: return User.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when loading user fo login manager: {exception}')
|
||||
return abort(500)
|
||||
|
||||
@app.before_request
|
||||
def _check_cookie_consent():
|
||||
if request.cookies.get('cookie_consent'):
|
||||
return
|
||||
if any([ request.path.startswith(x) for x in [ '/admin/static/', '/root/', '/quiz/static', '/cookies/', '/admin/editor/static', '/admin/view/static' ] ]):
|
||||
return
|
||||
flash(f'<strong>Cookie Consent</strong>: This web site only stores minimal, functional cookies. It does not store any tracking information. By using this site, you consent to this use of cookies. For more information, see our <a href="{url_for("views._privacy")}">privacy policy</a>.', 'cookie_alert')
|
||||
|
||||
@app.errorhandler(404)
|
||||
def _404_handler(error): return render_template('404.html')
|
||||
@app.errorhandler(CSRFError)
|
||||
def _csrf_handler(): return jsonify({'error':'Could not validate a secure connection.'}), 403
|
||||
@app.context_processor
|
||||
def _now(): return {'now': datetime.now()}
|
||||
|
||||
from .admin.views import admin
|
||||
from .api.views import api
|
||||
from .quiz.views import quiz
|
||||
from .views import views
|
||||
from .editor.views import editor
|
||||
from .view.views import view
|
||||
from .analysis.views import analysis
|
||||
|
||||
app.register_blueprint(admin, url_prefix='/admin')
|
||||
app.register_blueprint(api, url_prefix='/api')
|
||||
app.register_blueprint(views)
|
||||
app.register_blueprint(quiz)
|
||||
app.register_blueprint(editor, url_prefix='/admin/editor')
|
||||
app.register_blueprint(view, url_prefix='/admin/view')
|
||||
app.register_blueprint(analysis, url_prefix='/admin/analysis')
|
||||
|
||||
"""Create Database Tables before First Request"""
|
||||
@app.before_first_request
|
||||
def _create_database_tables():
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
return app
|
@ -158,7 +158,7 @@ table.dataTable {
|
||||
|
||||
#dismiss-cookie-alert {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.alert-db-empty {
|
||||
@ -223,6 +223,19 @@ table.dataTable {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
/* Change Autocomplete styles in Chrome*/
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
textarea:-webkit-autofill,
|
||||
textarea:-webkit-autofill:hover,
|
||||
textarea:-webkit-autofill:focus,
|
||||
select:-webkit-autofill,
|
||||
select:-webkit-autofill:hover,
|
||||
select:-webkit-autofill:focus {
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
/* Fallback for Edge
|
||||
-------------------------------------------------- */
|
||||
@supports (-ms-ime-align: auto) {
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
@ -1,25 +1,25 @@
|
||||
// Menu Highlight Scripts
|
||||
const menuItems = document.getElementsByClassName('nav-link');
|
||||
const menuItems = document.getElementsByClassName('nav-link')
|
||||
for(let i = 0; i < menuItems.length; i++) {
|
||||
if(menuItems[i].pathname == window.location.pathname) {
|
||||
menuItems[i].classList.add('active');
|
||||
menuItems[i].classList.add('active')
|
||||
}
|
||||
}
|
||||
const dropdownItems = document.getElementsByClassName('dropdown-item');
|
||||
const dropdownItems = document.getElementsByClassName('dropdown-item')
|
||||
for(let i = 0; i< dropdownItems.length; i++) {
|
||||
if(dropdownItems[i].pathname == window.location.pathname) {
|
||||
dropdownItems[i].classList.add('active');
|
||||
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active');
|
||||
dropdownItems[i].classList.add('active')
|
||||
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active')
|
||||
}
|
||||
}
|
||||
|
||||
// General Post Method Form Processing Script
|
||||
$('form.form-post').submit(function(event) {
|
||||
|
||||
var $form = $(this);
|
||||
var data = $form.serialize();
|
||||
var url = $(this).attr('action');
|
||||
var rel_success = $(this).data('rel-success');
|
||||
var $form = $(this)
|
||||
var data = $form.serialize()
|
||||
var url = $(this).prop('action')
|
||||
var rel_success = $(this).data('rel-success')
|
||||
|
||||
$.ajax({
|
||||
url: url,
|
||||
@ -28,25 +28,25 @@ $('form.form-post').submit(function(event) {
|
||||
dataType: 'json',
|
||||
success: function(response) {
|
||||
if (response.redirect_to) {
|
||||
window.location.href = response.redirect_to;
|
||||
window.location.href = response.redirect_to
|
||||
}
|
||||
else {
|
||||
window.location.href = rel_success;
|
||||
window.location.href = rel_success
|
||||
}
|
||||
},
|
||||
error: function(response) {
|
||||
error_response(response);
|
||||
error_response(response)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
event.preventDefault();
|
||||
});
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
// Form Upload Questions - Special case, needs to handle files.
|
||||
$('form[name=form-upload-questions]').submit(function(event) {
|
||||
|
||||
var $form = $(this);
|
||||
var data = new FormData($form[0]);
|
||||
var $form = $(this)
|
||||
var data = new FormData($form[0])
|
||||
var file = $('input[name=data_file]')[0].files[0]
|
||||
data.append('file', file)
|
||||
|
||||
@ -57,84 +57,107 @@ $('form[name=form-upload-questions]').submit(function(event) {
|
||||
processData: false,
|
||||
contentType: false,
|
||||
success: function(response) {
|
||||
window.location.reload();
|
||||
window.location.reload()
|
||||
},
|
||||
error: function(response) {
|
||||
error_response(response);
|
||||
error_response(response)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
event.preventDefault();
|
||||
});
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
// Edit and Delete Test Button Handlers
|
||||
$('.test-action').click(function(event) {
|
||||
|
||||
let _id = $(this).data('_id');
|
||||
let action = $(this).data('action');
|
||||
let id = $(this).data('id')
|
||||
let action = $(this).data('action')
|
||||
|
||||
if (action == 'delete') {
|
||||
if (action == 'delete' || action == 'start' || action == 'end') {
|
||||
$.ajax({
|
||||
url: `/admin/tests/delete/`,
|
||||
url: `/admin/tests/edit/`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({'_id': _id}),
|
||||
data: JSON.stringify({'id': id, 'action': action}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
window.location.href = '/admin/tests/';
|
||||
window.location.href = '/admin/tests/'
|
||||
},
|
||||
error: function(response){
|
||||
error_response(response);
|
||||
error_response(response)
|
||||
},
|
||||
});
|
||||
})
|
||||
} else if (action == 'edit') {
|
||||
window.location.href = `/admin/test/${_id}/`
|
||||
} else if (action == 'close'){
|
||||
window.location.href = `/admin/test/${id}/`
|
||||
} else if (action == 'analyse') {
|
||||
$.ajax({
|
||||
url: `/admin/tests/close/`,
|
||||
url: `/admin/analysis/`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({'_id': _id}),
|
||||
data: JSON.stringify({'id': id, 'class': 'test'}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
$(window).scrollTop(0);
|
||||
window.location.reload();
|
||||
window.location.href = response
|
||||
},
|
||||
error: function(response){
|
||||
error_response(response);
|
||||
error_response(response)
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
});
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
// Edit Dataset Button Handlers
|
||||
$('.edit-question-dataset').click(function(event) {
|
||||
|
||||
var filename = $(this).data('filename');
|
||||
var action = $(this).data('action');
|
||||
var disabled = $(this).hasClass('disabled');
|
||||
var id = $(this).data('id')
|
||||
var action = $(this).data('action')
|
||||
var disabled = $(this).hasClass('disabled')
|
||||
|
||||
if ( !disabled ) {
|
||||
$.ajax({
|
||||
url: `/admin/settings/questions/${action}/`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({'filename': filename}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
window.location.reload();
|
||||
},
|
||||
error: function(response){
|
||||
error_response(response);
|
||||
},
|
||||
});
|
||||
};
|
||||
event.preventDefault();
|
||||
});
|
||||
if (action == 'delete') {
|
||||
$.ajax({
|
||||
url: `/admin/settings/questions/${action}/`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
'id': id,
|
||||
'action': action,
|
||||
}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
window.location.reload()
|
||||
},
|
||||
error: function(response){
|
||||
error_response(response)
|
||||
},
|
||||
})
|
||||
} else if (action == 'edit') {
|
||||
window.location.href = `/admin/editor/${id}/`
|
||||
} else if (action == 'view') {
|
||||
window.location.href = `/admin/view/${id}`
|
||||
} else if (action == 'download') {
|
||||
window.location.href = `/admin/settings/questions/download/${id}/`
|
||||
} else if (action == 'analyse') {
|
||||
$.ajax({
|
||||
url: `/admin/analysis/`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({'id': id, 'class': 'dataset'}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
window.location.href = response
|
||||
},
|
||||
error: function(response){
|
||||
error_response(response)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
function error_response(response) {
|
||||
|
||||
const $alert = $("#alert-box");
|
||||
$alert.html('');
|
||||
const $alert = $("#alert-box")
|
||||
$alert.html('')
|
||||
|
||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
||||
$alert.html(`
|
||||
@ -143,18 +166,18 @@ function error_response(response) {
|
||||
${response.responseJSON.error}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`);
|
||||
`)
|
||||
} else if (response.responseJSON.error instanceof Array) {
|
||||
var output = ''
|
||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
||||
for (let i = 0; i < response.responseJSON.error.length; i ++) {
|
||||
output += `
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||
${response.responseJSON.error[i]}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`;
|
||||
$alert.html(output);
|
||||
`
|
||||
$alert.html(output)
|
||||
}
|
||||
}
|
||||
|
||||
@ -166,68 +189,68 @@ $('#dismiss-cookie-alert').click(function(event){
|
||||
|
||||
$.ajax({
|
||||
url: '/cookies/',
|
||||
type: 'GET',
|
||||
type: 'POST',
|
||||
data: {
|
||||
time: Date.now()
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response){
|
||||
console.log(response);
|
||||
console.log(response)
|
||||
},
|
||||
error: function(response){
|
||||
console.log(response);
|
||||
console.log(response)
|
||||
}
|
||||
})
|
||||
|
||||
event.preventDefault();
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
// Script for Result Actions
|
||||
$('.result-action-buttons').click(function(event){
|
||||
|
||||
var _id = $(this).data('_id');
|
||||
var id = $(this).data('id')
|
||||
|
||||
if ($(this).data('result-action') == 'generate') {
|
||||
$.ajax({
|
||||
url: '/admin/certificate/',
|
||||
type: 'POST',
|
||||
data: JSON.stringify({'_id': _id}),
|
||||
data: JSON.stringify({'id': id}),
|
||||
contentType: 'application/json',
|
||||
dataType: 'html',
|
||||
success: function(response) {
|
||||
var display_window = window.open();
|
||||
display_window.document.write(response);
|
||||
var display_window = window.open()
|
||||
display_window.document.write(response)
|
||||
},
|
||||
error: function(response){
|
||||
error_response(response);
|
||||
error_response(response)
|
||||
},
|
||||
});
|
||||
})
|
||||
} else {
|
||||
var action = $(this).data('result-action')
|
||||
$.ajax({
|
||||
url: window.location.href,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({'_id': _id, 'action': action}),
|
||||
data: JSON.stringify({'id': id, 'action': action}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
if (action == 'delete') {
|
||||
window.location.href = '/admin/results/';
|
||||
} else window.location.reload();
|
||||
window.location.href = '/admin/results/'
|
||||
} else window.location.reload()
|
||||
},
|
||||
error: function(response){
|
||||
error_response(response);
|
||||
error_response(response)
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
});
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
// Script for Deleting Time Adjustment
|
||||
$('.adjustment-delete').click(function(event){
|
||||
|
||||
var user_code = $(this).data('user_code');
|
||||
var location = window.location.href;
|
||||
var user_code = $(this).data('user_code')
|
||||
var location = window.location.href
|
||||
location = location.replace('#', '')
|
||||
|
||||
$.ajax({
|
||||
@ -236,12 +259,19 @@ $('.adjustment-delete').click(function(event){
|
||||
data: JSON.stringify({'user_code': user_code}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
window.location.reload();
|
||||
window.location.reload()
|
||||
},
|
||||
error: function(response){
|
||||
error_response(response);
|
||||
error_response(response)
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
event.preventDefault();
|
||||
});
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
// Detailed Results view questions
|
||||
$('.view-full-questions').click(function(event) {
|
||||
var dataset = $(this).data('dataset')
|
||||
window.open(`/admin/view/${dataset}`, '_blank')
|
||||
event.preventDefault()
|
||||
})
|
@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-update-account" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.home') }}">
|
||||
<form name="form-update-account" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._home') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Update Your Account</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
@ -32,7 +32,7 @@
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<a href="{{ url_for('admin_views.users') }}" class="btn btn-md btn-danger btn-block" type="button">
|
||||
<a href="{{ url_for('admin._users') }}" class="btn btn-md btn-danger btn-block" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||
</svg>
|
@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-login" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.home') }}">
|
||||
<form name="form-login" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ next or url_for('admin._home') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form">Log In</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
@ -26,7 +26,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('admin_auth.reset') }}" class="signin-forgot-password">Reset Password</a>
|
||||
<a href="{{ url_for('admin._reset') }}" class="signin-forgot-password">Reset Password</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
@ -3,14 +3,14 @@
|
||||
{% block navbar %}
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin_views.home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-register" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_auth.login') }}">
|
||||
<form name="form-register" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Register an Account</h2>
|
||||
{{ form.hidden_tag() }}
|
@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-reset" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_auth.login') }}">
|
||||
<form name="form-reset" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Reset Password</h2>
|
||||
{{ form.hidden_tag() }}
|
@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-update-password" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_auth.login') }}">
|
||||
<form name="form-update-password" class="form-display form-post" action="{{ url_for('admin._update_password', **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Update Password</h2>
|
||||
{{ form.hidden_tag() }}
|
@ -45,6 +45,9 @@
|
||||
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script>
|
||||
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
|
||||
</script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
||||
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
@ -15,30 +15,30 @@
|
||||
<h5 class="mb-1">Candidate</h5>
|
||||
</div>
|
||||
<h2>
|
||||
{{ entry.name.surname}}, {{ entry.name.first_name }}
|
||||
{{ entry.get_surname()}}, {{ entry.get_first_name() }}
|
||||
</h2>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Email Address</h5>
|
||||
</div>
|
||||
{{ entry.email }}
|
||||
{{ entry.get_email() }}
|
||||
</li>
|
||||
{% if entry['club'] %}
|
||||
{% if entry.get_club() %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Club</h5>
|
||||
</div>
|
||||
{{ entry.club }}
|
||||
{{ entry.get_club() }}
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Exam Code</h5>
|
||||
</div>
|
||||
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
|
||||
{{ entry.test.get_code() }}
|
||||
</li>
|
||||
{% if entry['user_code'] %}
|
||||
{% if entry.user_code %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">User Code</h5>
|
||||
@ -50,7 +50,7 @@
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Start Time</h5>
|
||||
</div>
|
||||
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') if entry.start_time else None }}
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
@ -59,19 +59,19 @@
|
||||
<span class="badge bg-danger">Late</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{{ entry.submission_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||
{{ entry.end_time.strftime('%d %b %Y %H:%M:%S') if entry.end_time else None }}
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Score</h5>
|
||||
</div>
|
||||
{{ entry.results.score }}%
|
||||
{{ entry.result.score }}%
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action {% if entry.results.grade == 'fail' %}list-group-item-danger {% elif entry.results.grade == 'merit' %} list-group-item-success {% endif %}">
|
||||
<li class="list-group-item list-group-item-action {% if entry.result.grade == 'fail' %}list-group-item-danger {% elif entry.result.grade == 'merit' %} list-group-item-success {% endif %}">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Grade</h5>
|
||||
</div>
|
||||
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:]}}
|
||||
{{ entry.result.grade[0]|upper }}{{ entry.result.grade[1:]}}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="site-footer mt-5">
|
@ -0,0 +1,2 @@
|
||||
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
137
ref-test/app/admin/templates/admin/components/navbar.html
Normal file
@ -0,0 +1,137 @@
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbar"
|
||||
aria-controls="navbar"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle Navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navbar">
|
||||
<ul class="navbar-nav">
|
||||
{% if not current_user.is_authenticated %}
|
||||
<li class="nav-item" id="nav-login">
|
||||
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item dropdown" id="nav-results">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-results"
|
||||
role="button"
|
||||
href="{{ url_for('admin._view_entries') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Results
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-results"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-tests">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-tests"
|
||||
role="button"
|
||||
href="{{ url_for('admin._tests') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Exams
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-tests"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-settings">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-settings"
|
||||
role="button"
|
||||
href="{{ url_for('admin._settings') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-settings"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-account">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
role="button"
|
||||
href="{{ url_for('admin._update_user', id=current_user.id) }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Account
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-account"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
@ -2,16 +2,17 @@
|
||||
<meta property="og:locale" content="en_UK" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta property="og:url" content="{{ url_for(request.endpoint, _external = True, **request.view_args) }}" />
|
||||
<meta property="og:url" content="{{ url_for(request.endpoint, _external = True, **(request.view_args or {})) }}" />
|
||||
<meta property="og:site_name" content="Scottish Korfball Association Referee Theory Exam" />
|
||||
<meta property="og:image" content="{{ url_for('static', filename='favicon.png', _external = True) }}" />
|
||||
<meta property="og:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
|
||||
<meta property="og:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||
<meta property="og:image:width" content="512" />
|
||||
<meta property="og:image:height" content="512" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta name="twitter:image" content="{{ url_for('static', filename='favicon.png', _external = True) }}" />
|
||||
<meta name="twitter:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
|
||||
<meta name="twitter:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||
<meta name="twitter:creator" content="@viveksantayana" />
|
||||
<meta name="twitter:site" content="@viveksantayana" />
|
||||
<meta name="theme-color" content="#343a40" />
|
||||
<meta name="theme-color" content="#343a40" />
|
||||
<link rel="shortcut icon" href="{{ url_for('.static', filename='favicon.ico') }}">
|
@ -0,0 +1,23 @@
|
||||
<div class="navbar navbar-expand-sm navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<div class="expand navbar-expand justify-content-center" id="navbar_secondary">
|
||||
<ul class="nav nav-pills">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='active') }}">Active</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='scheduled') }}">Scheduled</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='expired') }}">Expired</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='all') }}">All</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='create') }}">Create</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -25,7 +25,9 @@
|
||||
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
|
||||
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
|
||||
<div class="d-flex justify-content-center w-100">
|
||||
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
|
||||
</div>
|
||||
</div>
|
||||
{% set cookie_flash_flag.value = True %}
|
||||
{% endif %}
|
@ -25,22 +25,22 @@
|
||||
{% for test in current_tests %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('admin_views.view_test', _id=test._id) }}">{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}</a>
|
||||
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ test.expiry_date.strftime('%d %b %Y') }}
|
||||
{{ test.end_date.strftime('%d %b %Y') if test.end_date else None }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a href="{{ url_for('admin_views.tests', filter='active') }}" class="btn btn-primary">View Exams</a>
|
||||
<a href="{{ url_for('admin._tests', filter='active') }}" class="btn btn-primary">View Exams</a>
|
||||
{% else %}
|
||||
<div class="alert alert-primary">
|
||||
There are currently no active exams.
|
||||
</div>
|
||||
<a href="{{ url_for('admin_views.tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
|
||||
<a href="{{ url_for('admin._tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@ -69,20 +69,24 @@
|
||||
{% for result in recent_results %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('admin_views.view_entry', _id=result._id) }}">{{ result.name.surname }}, {{ result.name.first_name }}</a>
|
||||
<a href="{{ url_for('admin._view_entry', id=result.id) }}">{{ result.get_surname() }}, {{ result.get_first_name() }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ result.submission_time.strftime('%d %b %Y %H:%M') }}
|
||||
{{ result.end_time.strftime('%d %b %Y %H:%M') if result.end_time else None }}
|
||||
</td>
|
||||
<td>
|
||||
{{ result.percent }}% ({{ result.results.grade }})
|
||||
{% if result.result %}
|
||||
{{ (100*result.result['score']/result.result['max'])|round|int }}% ({{ result.result.grade }})
|
||||
{% else %}
|
||||
Incomplete
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a href="{{ url_for('admin_views.view_entries') }}" class="btn btn-primary">View Results</a>
|
||||
<a href="{{ url_for('admin._view_entries') }}" class="btn btn-primary">View Results</a>
|
||||
{% else %}
|
||||
<div class="alert alert-primary">
|
||||
There are currently no exam results to preview.
|
||||
@ -114,22 +118,22 @@
|
||||
{% for test in upcoming_tests %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('admin_views.view_test', _id=test._id) }}">{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}</a>
|
||||
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ test.expiry_date.strftime('%d %b %Y') }}
|
||||
{{ test.end_date.strftime('%d %b %Y') if test.end_date else None }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a href="{{ url_for('admin_views.tests', filter='scheduled') }}" class="btn btn-primary">View Exams</a>
|
||||
<a href="{{ url_for('admin._tests', filter='scheduled') }}" class="btn btn-primary">View Exams</a>
|
||||
{% else %}
|
||||
<div class="alert alert-primary">
|
||||
There are currently no upcoming exams.
|
||||
</div>
|
||||
<a href="{{ url_for('admin_views.tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
|
||||
<a href="{{ url_for('admin._tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@ -138,7 +142,7 @@
|
||||
<div class="card m-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Help</h5>
|
||||
<p class="card-text">This web app was developed by Vivek Santayana. If there are any issues with the app, any bugs you need to report, or any features you would like to request, please feel free to <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues">open an issue at the Git Repository</a>.</p>
|
||||
<p class="card-text">This web app was developed and is maintained by Vivek Santayana. If there are any issues with the app, any bugs you need to report, or any features you would like to request, please feel free to <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues">open an issue at the Git Repository</a>.</p>
|
||||
<a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues" class="btn btn-primary">Open an Issue</a>
|
||||
</div>
|
||||
</div>
|
@ -13,30 +13,30 @@
|
||||
<h5 class="mb-1">Candidate</h5>
|
||||
</div>
|
||||
<h2>
|
||||
{{ entry.name.surname }}, {{ entry.name.first_name }}
|
||||
{{ entry.get_surname() }}, {{ entry.get_first_name() }}
|
||||
</h2>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Email Address</h5>
|
||||
</div>
|
||||
{{ entry.email }}
|
||||
{{ entry.get_email() }}
|
||||
</li>
|
||||
{% if entry['club'] %}
|
||||
{% if entry.get_club() %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Club</h5>
|
||||
</div>
|
||||
{{ entry.club }}
|
||||
{{ entry.get_club() }}
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Exam Code</h5>
|
||||
</div>
|
||||
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
|
||||
{{ entry.test.get_code() }}
|
||||
</li>
|
||||
{% if entry['user_code'] %}
|
||||
{% if entry.user_code %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">User Code</h5>
|
||||
@ -44,12 +44,14 @@
|
||||
{{ entry.user_code }}
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Start Time</h5>
|
||||
</div>
|
||||
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||
</li>
|
||||
{% if entry.start_time %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Start Time</h5>
|
||||
</div>
|
||||
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') if entry.start_time else None }}
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Submission Time</h5>
|
||||
@ -57,28 +59,28 @@
|
||||
<span class="badge bg-danger">Late</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if 'submission_time' in entry %}
|
||||
{{ entry.submission_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||
{% if entry.end_time %}
|
||||
{{ entry.end_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||
{% else %}
|
||||
Incomplete
|
||||
{% endif %}
|
||||
</li>
|
||||
{% if 'results' in entry %}
|
||||
{% if entry.result %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Score</h5>
|
||||
</div>
|
||||
{{ entry.results.score }}%
|
||||
{{ entry.result.score }}%
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action {% if entry.results.grade == 'fail' %}list-group-item-danger {% elif entry.results.grade == 'merit' %} list-group-item-success {% endif %}">
|
||||
<li class="list-group-item list-group-item-action {% if entry.result.grade == 'fail' %}list-group-item-danger {% elif entry.result.grade == 'merit' %} list-group-item-success {% endif %}">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Grade</h5>
|
||||
</div>
|
||||
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:]}}
|
||||
{{ entry.result.grade[0]|upper }}{{ entry.result.grade[1:]}}
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% if 'results' in entry %}
|
||||
{% if entry.result %}
|
||||
<div class="accordion" id="results-breakdown">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="by-category">
|
||||
@ -103,16 +105,16 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag, scores in entry.results.tags.items() %}
|
||||
{% for tag, scores in entry.result.tags.items() %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ tag }}
|
||||
{{ tag|safe }}
|
||||
</td>
|
||||
<td>
|
||||
{{ scores.scored }}
|
||||
</td>
|
||||
<td>
|
||||
{{scores.max}}
|
||||
{{ scores.max }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -129,6 +131,7 @@
|
||||
</h2>
|
||||
<div id="by-question-breakdown" class="accordion-collapse collapse" aria-labelledby="by-question" data-bs-parent="#results-breakdown">
|
||||
<div class="accordion-body">
|
||||
<a class="view-full-questions" data-dataset="{{ entry.test.dataset.id }}">View Questions</a>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -144,11 +147,11 @@
|
||||
{% for question, answer in entry.answers.items() %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ question }}
|
||||
{{ question|int + 1 }}
|
||||
</td>
|
||||
<td>
|
||||
{{ answer }}
|
||||
{% if not correct[question] == answer %}
|
||||
{{ answers[question|int][answer|int] }}
|
||||
{% if not correct[question] == answer|int %}
|
||||
<span class="badge badge-pill bg-danger badge-danger">Incorrect</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
@ -162,19 +165,19 @@
|
||||
{% endif %}
|
||||
<div class="container justify-content-center">
|
||||
<div class="row">
|
||||
<a href="#" class="btn btn-primary result-action-buttons" data-result-action="generate" data-_id="{{ entry._id }}">
|
||||
<a href="#" class="btn btn-primary result-action-buttons" data-result-action="generate" data-id="{{ entry.id }}">
|
||||
<i class="bi bi-printer-fill button-icon"></i>
|
||||
Printable Version
|
||||
</a>
|
||||
</div>
|
||||
<div class="row">
|
||||
{% if entry.status == 'late' %}
|
||||
<a href="#" class="btn btn-warning result-action-buttons" data-result-action="override" data-_id="{{ entry._id }}">
|
||||
<a href="#" class="btn btn-warning result-action-buttons" data-result-action="override" data-id="{{ entry.id }}">
|
||||
<i class="bi bi-clock-history button-icon"></i>
|
||||
Allow Late Entry
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="#" class="btn btn-danger result-action-buttons" data-result-action="delete" data-_id="{{ entry._id }}">
|
||||
<a href="#" class="btn btn-danger result-action-buttons" data-result-action="delete" data-id="{{ entry.id }}">
|
||||
<i class="bi bi-trash-fill button-icon"></i>
|
||||
Delete Result
|
||||
</a>
|
@ -37,41 +37,41 @@
|
||||
{% for entry in entries %}
|
||||
<tr class="table-row">
|
||||
<td>
|
||||
{{ entry.name.surname }}, {{ entry.name.first_name }}
|
||||
{{ entry.get_surname() }}, {{ entry.get_first_name() }}
|
||||
</td>
|
||||
<td>
|
||||
{% if 'club' in entry %}
|
||||
{{ entry.club }}
|
||||
{% if entry.get_club() %}
|
||||
{{ entry.get_club() }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
|
||||
{{ entry.test.get_code() }}
|
||||
</td>
|
||||
<td>
|
||||
{% if 'status' in entry %}
|
||||
{% if entry.status %}
|
||||
{{ entry.status }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if 'submission_time' in entry %}
|
||||
{{ entry.submission_time.strftime('%d %b %Y') }}
|
||||
{% if entry.end_time %}
|
||||
{{ entry.end_time.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if 'results' in entry %}
|
||||
{{ entry.results.score }}%
|
||||
{% if entry.result %}
|
||||
{{ entry.result.score }}%
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if 'results' in entry %}
|
||||
{{ entry.results.grade }}
|
||||
{% if entry.result %}
|
||||
{{ entry.result.grade }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<a
|
||||
href="{{ url_for('admin_views.view_entry', _id = entry._id ) }}"
|
||||
href="{{ url_for('admin._view_entry', id = entry.id ) }}"
|
||||
class="btn btn-primary entry-details"
|
||||
data-_id="{{entry._id}}"
|
||||
data-id="{{entry.id}}"
|
||||
title="View Details"
|
||||
>
|
||||
<i class="bi bi-file-medical-fill button-icon"></i>
|
@ -2,11 +2,11 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-delete-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.users') }}">
|
||||
<form name="form-delete-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._users') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Delete User ‘{{ user.username }}’?</h2>
|
||||
<h2 class="form-heading">Delete User ‘{{ user.get_username() }}’?</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<p>This action cannot be undone. Deleting an account will mean {{ user.username }} will no longer be able to log in to the admin console.</p>
|
||||
<p>This action cannot be undone. Deleting an account will mean {{ user.get_username() }} will no longer be able to log in to the admin console.</p>
|
||||
<p>Are you sure you want to proceed?</p>
|
||||
<div class="form-label-group">
|
||||
{{ form.password(class_="form-control", placeholder="Confirm Your Password", autofocus=true) }}
|
||||
@ -20,7 +20,7 @@
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<a href="{{ url_for('admin_views.users') }}" autofocus="true" class="btn btn-md btn-primary btn-block" type="button">
|
||||
<a href="{{ url_for('admin._users') }}" autofocus="true" class="btn btn-md btn-primary btn-block" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||
</svg>
|
@ -28,22 +28,22 @@
|
||||
<tr>
|
||||
<td>
|
||||
<a href="
|
||||
{% if user._id == get_id_from_cookie() %}
|
||||
{{ url_for('admin_auth.account') }}
|
||||
{% if user == current_user %}
|
||||
{{ url_for('admin._update_user', id=current_user.id) }}
|
||||
{% else %}
|
||||
{{ url_for('admin_views.update_user', _id=user._id) }}
|
||||
{{ url_for('admin._update_user', id=user.id) }}
|
||||
{% endif%}
|
||||
">{{ user.username }}</a>
|
||||
">{{ user.get_username() }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="mailto:{{ user.email }}">{{ user.email }}</a>
|
||||
<a href="mailto:{{ user.get_email() }}">{{ user.get_email() }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a href="{{ url_for('admin_views.users') }}" class="btn btn-primary">Manage Users</a>
|
||||
<a href="{{ url_for('admin._users') }}" class="btn btn-primary">Manage Users</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -57,7 +57,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
File Name
|
||||
Name
|
||||
</th>
|
||||
<th>
|
||||
Exams
|
||||
@ -68,22 +68,24 @@
|
||||
{% for dataset in datasets %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ dataset.filename }}
|
||||
<a href="{{ url_for('editor._editor_console', id=dataset.id) }}">
|
||||
{{ dataset.get_name() }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ dataset.use }}
|
||||
{{ dataset.tests|length }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a href="{{ url_for('admin_views.questions') }}" class="btn btn-primary">Manage Datasets</a>
|
||||
<a href="{{ url_for('admin._questions') }}" class="btn btn-primary">Manage Datasets</a>
|
||||
{% else %}
|
||||
<div class="alert alert-primary">
|
||||
There are currently no question datasets uploaded.
|
||||
</div>
|
||||
<a href="{{ url_for('admin_views.questions') }}" class="btn btn-primary">Upload Dataset</a>
|
||||
<a href="{{ url_for('admin._questions') }}" class="btn btn-primary">Upload Dataset</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
@ -11,10 +11,10 @@
|
||||
|
||||
</th>
|
||||
<th data-priority="1">
|
||||
File Name
|
||||
Name
|
||||
</th>
|
||||
<th data-priority="2">
|
||||
Uploaded
|
||||
Updated
|
||||
</th>
|
||||
<th data-priority="3">
|
||||
Author
|
||||
@ -31,7 +31,7 @@
|
||||
{% for element in data %}
|
||||
<tr class="table-row">
|
||||
<td>
|
||||
{% if element.filename == default %}
|
||||
{% if element.default %}
|
||||
<div class="text-success" title="Default Dataset">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi success bi-caret-right-fill" viewBox="0 0 16 16">
|
||||
<path d="m12.14 8.753-5.482 4.796c-.646.566-1.658.106-1.658-.753V3.204a1 1 0 0 1 1.659-.753l5.48 4.796a1 1 0 0 1 0 1.506z"/>
|
||||
@ -40,36 +40,63 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ element.filename }}
|
||||
{{ element.get_name() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ element.timestamp.strftime('%d %b %Y') }}
|
||||
{{ element.date.strftime('%Y-%m-%d %H:%M') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ element.author }}
|
||||
{{ element.creator.get_username() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ element.use }}
|
||||
{{ element.tests|length }}
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<a
|
||||
href="#"
|
||||
class="btn btn-primary edit-question-dataset {% if element.filename == default %}disabled{% endif %}"
|
||||
data-filename="{{ element.filename }}"
|
||||
data-action="default"
|
||||
title="Make Default"
|
||||
href="javascript:void(0)"
|
||||
class="btn btn-success edit-question-dataset {% if not element.entries %} disabled {% endif %}"
|
||||
data-id="{{ element.id }}"
|
||||
data-action="analyse"
|
||||
title="Analyse Answers"
|
||||
>
|
||||
<i class="bi bi-search button-icon"></i>
|
||||
</a>
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
class="btn btn-primary edit-question-dataset"
|
||||
data-id="{{ element.id }}"
|
||||
data-action="download"
|
||||
title="Download Questions"
|
||||
>
|
||||
<i class="bi bi-cloud-arrow-down-fill button-icon"></i>
|
||||
</a>
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
class="btn btn-primary edit-question-dataset"
|
||||
data-id="{{ element.id }}"
|
||||
data-action="view"
|
||||
title="View Questions"
|
||||
>
|
||||
<i class="bi bi-file-earmark-text-fill button-icon"></i>
|
||||
</button>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="btn btn-danger edit-question-dataset {% if element.filename == default %}disabled{% endif %}"
|
||||
data-filename="{{ element.filename }}"
|
||||
href="javascript:void(0)"
|
||||
class="btn btn-primary edit-question-dataset"
|
||||
data-id="{{ element.id }}"
|
||||
data-action="edit"
|
||||
title="Edit Questions"
|
||||
>
|
||||
<i class="bi bi-pencil-fill button-icon"></i>
|
||||
</a>
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
class="btn btn-danger edit-question-dataset {% if element.default %}disabled{% endif %}"
|
||||
data-id="{{ element.id }}"
|
||||
data-action="delete"
|
||||
title="Delete Dataset"
|
||||
title="Delete Questions"
|
||||
>
|
||||
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
||||
</button>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -78,13 +105,23 @@
|
||||
{% else %}
|
||||
<div class="alert alert-primary alert-db-empty">
|
||||
<i class="bi bi-info-circle-fill" aria-title="Alert" title="Alert"></i>
|
||||
There are no question datasets uploaded. Please use the panel below to upload a new question dataset.
|
||||
There are no question datasets uploaded. Please use the panel below to upload a new question dataset or create a new dataset using the editor console.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col text-center">
|
||||
<button title="Create New" class="btn btn-md btn-primary btn-block create-new-dataset">
|
||||
<i class="bi bi-cloud-plus-fill button-icon"></i>
|
||||
Create New Dataset
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-container">
|
||||
<form name="form-upload-questions" class="form-display" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="" enctype="multipart/form-data">
|
||||
<h2 class="form-heading">Upload Question Dataset</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form-label-group">
|
||||
{{ form.name(class_="form-control", autofocus=true, placeholder="Enter Name of Dataset") }}
|
||||
{{ form.name.label }}
|
||||
</div>
|
||||
<div class="form-upload">
|
||||
{{ form.data_file() }}
|
||||
</div>
|
||||
@ -95,8 +132,8 @@
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<button title="Create User" class="btn btn-md btn-success btn-block" type="submit">
|
||||
<i class="bi bi-file-earmark-arrow-up-fill button-icon"></i>
|
||||
<button title="Upload Dataset" class="btn btn-md btn-success btn-block" type="submit">
|
||||
<i class="bi bi-cloud-arrow-up-fill button-icon"></i>
|
||||
Upload Dataset
|
||||
</button>
|
||||
</div>
|
||||
@ -113,9 +150,9 @@
|
||||
$('#question-datasets-table').DataTable({
|
||||
'columnDefs': [
|
||||
{'sortable': false, 'targets': [0,5]},
|
||||
{'searchable': false, 'targets': [0,4,5]}
|
||||
{'searchable': false, 'targets': [1,2,3]}
|
||||
],
|
||||
'order': [[2, 'desc'], [3, 'asc']],
|
||||
'order': [[1, 'asc'], [2, 'desc'], [3, 'asc']],
|
||||
'responsive': 'true',
|
||||
'fixedHeader': 'true',
|
||||
});
|
@ -2,12 +2,12 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-update-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.users') }}">
|
||||
<form name="form-update-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._users') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Update User ‘{{ user.username }}’</h2>
|
||||
<h2 class="form-heading">Update User ‘{{ user.get_username() }}’</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form-label-group">
|
||||
{{ form.email(class_="form-control", placeholder="Email Address", value = user.email) }}
|
||||
{{ form.email(class_="form-control", placeholder="Email Address", value = user.get_email()) }}
|
||||
{{ form.email.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
@ -23,17 +23,17 @@
|
||||
{{ form.notify.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
Please confirm <strong>your password</strong> before committing any changes to a user account.
|
||||
Please confirm <strong>your current password</strong> before committing any changes to a user account.
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.user_password(class_="form-control", placeholder="Your Password", value = user.email) }}
|
||||
{{ form.user_password.label }}
|
||||
{{ form.confirm_password(class_="form-control", placeholder="Your Password", value = user.email) }}
|
||||
{{ form.confirm_password.label }}
|
||||
</div>
|
||||
{% include "admin/components/client-alerts.html" %}
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<a href="{{ url_for('admin_views.users') }}" class="btn btn-md btn-danger btn-block" type="button">
|
||||
<a href="{{ url_for('admin._users') }}" class="btn btn-md btn-danger btn-block" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||
</svg>
|
@ -23,7 +23,7 @@
|
||||
{% for user in users %}
|
||||
<tr class="table-row">
|
||||
<td>
|
||||
{% if user._id == get_id_from_cookie() %}
|
||||
{% if user == current_user %}
|
||||
<div class="text-success" title="Current User">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi success bi-caret-right-fill" viewBox="0 0 16 16">
|
||||
<path d="m12.14 8.753-5.482 4.796c-.646.566-1.658.106-1.658-.753V3.204a1 1 0 0 1 1.659-.753l5.48 4.796a1 1 0 0 1 0 1.506z"/>
|
||||
@ -32,18 +32,18 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ user.username }}
|
||||
{{ user.get_username() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ user.email }}
|
||||
{{ user.get_email() }}
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<a
|
||||
href="
|
||||
{% if not user._id == get_id_from_cookie() %}
|
||||
{{ url_for('admin_views.update_user', _id = user._id ) }}
|
||||
{% if not user == current_user %}
|
||||
{{ url_for('admin._update_user', id = user.id ) }}
|
||||
{% else %}
|
||||
{{ url_for('admin_auth.account') }}
|
||||
{{ url_for('admin._update_user', id=current_user.id) }}
|
||||
{% endif %}
|
||||
"
|
||||
class="btn btn-primary"
|
||||
@ -53,15 +53,15 @@
|
||||
</a>
|
||||
<a
|
||||
href="
|
||||
{% if not user._id == get_id_from_cookie() %}
|
||||
{{ url_for('admin_views.delete_user', _id = user._id ) }}
|
||||
{% if not user == current_user %}
|
||||
{{ url_for('admin._delete_user', id = user.id ) }}
|
||||
{% else %}
|
||||
#
|
||||
{% endif %}
|
||||
"
|
||||
class="btn btn-danger {% if user._id == get_id_from_cookie() %} disabled {% endif %}"
|
||||
class="btn btn-danger {% if user == current_user %} disabled {% endif %}"
|
||||
title="Delete User"
|
||||
{% if user._id == get_id_from_cookie() %} onclick="return false" {% endif %}
|
||||
{% if user == current_user %} onclick="return false" {% endif %}
|
||||
>
|
||||
<i class="bi bi-person-x-fill button-icon"></i>
|
||||
</button>
|
@ -12,38 +12,33 @@
|
||||
<h5 class="mb-1">Exam Code</h5>
|
||||
</div>
|
||||
<h2>
|
||||
{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}
|
||||
{{ test.get_code() }}
|
||||
</h2>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Dataset</h5>
|
||||
</div>
|
||||
{{ test.dataset }}
|
||||
<a href="{{ url_for('view._view_console', id=test.dataset.id) }}">{{ test.dataset.get_name() }}</a>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Created By</h5>
|
||||
</div>
|
||||
{{ test.creator }}
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Date Created</h5>
|
||||
</div>
|
||||
{{ test.date_created.strftime('%d %b %Y') }}
|
||||
{{ test.creator.get_username() }}
|
||||
</li>
|
||||
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Start Date</h5>
|
||||
</div>
|
||||
{{ test.start_date.strftime('%d %b %Y') }}
|
||||
{{ test.start_date.strftime('%d %b %Y %H:%M') if test.start_date else None }}
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Expiry Date</h5>
|
||||
</div>
|
||||
{{ test.expiry_date.strftime('%d %b %Y') }}
|
||||
{{ test.end_date.strftime('%d %b %Y %H:%M') if test.end_date else None }}
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
@ -62,7 +57,7 @@
|
||||
{% endif %}
|
||||
</li>
|
||||
<div class="accordion" id="test-info-detail">
|
||||
{% if 'entries' in test and test.entries|length > 0 %}
|
||||
{% if test.entries|length > 0 %}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="test-entries">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#test-entries-list" aria-expanded="false" aria-controls="test-entries-list">
|
||||
@ -76,7 +71,7 @@
|
||||
{% for entry in test.entries %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('admin_views.view_entry', _id=entry) }}" >Entry {{ loop.index }}</a>
|
||||
<a href="{{ url_for('admin._view_entry', id=entry.id) }}" >Entry {{ loop.index }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -86,7 +81,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if 'time_adjustments' in test and test.time_adjustments|length > 0 %}
|
||||
{% if test.adjustments %}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="test-adjustments">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#test-adjustments-list" aria-expanded="false" aria-controls="test-adjustments-list">
|
||||
@ -110,10 +105,10 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, value in test.time_adjustments.items() %}
|
||||
{% for key, value in test.adjustments.items() %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ key }}
|
||||
{{ key.upper() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ value }}
|
||||
@ -143,7 +138,7 @@
|
||||
<form name="form-add-adjustment" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form-label-group">
|
||||
{{ form.time(class_="form-control", placeholder="Enter Username") }}
|
||||
{{ form.time(class_="form-control", placeholder="Enter Time") }}
|
||||
{{ form.time.label }}
|
||||
</div>
|
||||
<div class="container form-submission-button">
|
||||
@ -167,12 +162,29 @@
|
||||
{% include "admin/components/client-alerts.html" %}
|
||||
</div>
|
||||
<div class="container justify-content-center">
|
||||
<div class="row">
|
||||
<a href="#" class="btn btn-warning test-action" data-action="close" data-_id="{{ test._id }}">
|
||||
<i class="bi bi-hourglass button-icon"></i>
|
||||
Close Exam
|
||||
<div class="my-3 row">
|
||||
{% if test.start_date <= now %}
|
||||
<a href="#" class="btn btn-warning test-action {% if test.end_date < now %}disabled{% endif %}" data-action="end" data-id="{{ test.id }}">
|
||||
<i class="bi bi-hourglass-bottom button-icon"></i>
|
||||
Close Exam
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-success test-action {% if test.start_date < now %}disabled{% endif %}" data-action="start" data-id="{{ test.id }}">
|
||||
<i class="bi bi-hourglass-top button-icon"></i>
|
||||
Start Exam
|
||||
</a>
|
||||
{% endif %}
|
||||
<a
|
||||
href="#"
|
||||
class="btn btn-success test-analyse test-action {% if not test.entries %} disabled {% endif %}"
|
||||
data-id="{{test.id}}"
|
||||
title="Analyse Exam"
|
||||
data-action="analyse"
|
||||
>
|
||||
<i class="bi bi-search button-icon"></i>
|
||||
Analyse Exam
|
||||
</a>
|
||||
<a href="#" class="btn btn-danger test-action" data-action="delete" data-_id="{{ test._id }}">
|
||||
<a href="#" class="btn btn-danger test-action" data-action="delete" data-id="{{ test.id }}">
|
||||
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
||||
Delete Exam
|
||||
</a>
|
@ -33,13 +33,13 @@
|
||||
{% for test in tests %}
|
||||
<tr class="table-row">
|
||||
<td>
|
||||
{{ test.start_date.strftime('%d %b %Y') }}
|
||||
{{ test.start_date.strftime('%Y-%m-%d %H:%M') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}
|
||||
{{ test.get_code() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ test.expiry_date.strftime('%d %b %Y') }}
|
||||
{{ test.end_date.strftime('%Y-%m-%d %H:%M') }}
|
||||
</td>
|
||||
<td>
|
||||
{% if test.time_limit == None -%}
|
||||
@ -58,10 +58,19 @@
|
||||
{{ test.entries|length }}
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<a
|
||||
href="#"
|
||||
class="btn btn-success test-analyse test-action {% if not test.entries %} disabled {% endif %}"
|
||||
data-id="{{test.id}}"
|
||||
title="Analyse Exam"
|
||||
data-action="analyse"
|
||||
>
|
||||
<i class="bi bi-search button-icon"></i>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="btn btn-primary test-action"
|
||||
data-_id="{{test._id}}"
|
||||
data-id="{{test.id}}"
|
||||
title="Edit Exam"
|
||||
data-action="edit"
|
||||
>
|
||||
@ -70,7 +79,7 @@
|
||||
<a
|
||||
href="#"
|
||||
class="btn btn-danger test-action"
|
||||
data-_id="{{test._id}}"
|
||||
data-id="{{test.id}}"
|
||||
title="Delete Exam"
|
||||
data-action="delete"
|
||||
>
|
452
ref-test/app/admin/views.py
Normal file
@ -0,0 +1,452 @@
|
||||
from ..forms.admin import AddTimeAdjustment, CreateTest, CreateUser, DeleteUser, Login, Register, ResetPassword, UpdatePassword, UpdateUser, UploadData
|
||||
from ..models import Dataset, Entry, Test, User
|
||||
from ..tools.auth import disable_if_logged_in, require_account_creation
|
||||
from ..tools.data import check_dataset_exists, check_is_json, validate_json
|
||||
from ..tools.forms import get_dataset_choices, get_time_options, send_errors_to_client
|
||||
from ..tools.logs import write
|
||||
from ..tools.test import answer_options, get_correct_answers
|
||||
|
||||
from flask import abort, Blueprint, jsonify, render_template, request, send_file, session
|
||||
from flask.helpers import abort, flash, redirect, url_for
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from datetime import date, datetime, MINYEAR, timedelta
|
||||
from json import loads
|
||||
from os import path
|
||||
import secrets
|
||||
|
||||
admin = Blueprint(
|
||||
name='admin',
|
||||
import_name=__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static'
|
||||
)
|
||||
|
||||
@admin.route('/')
|
||||
@admin.route('/home/')
|
||||
@admin.route('/dashboard/')
|
||||
@login_required
|
||||
def _home():
|
||||
try:
|
||||
tests = Test.query.all()
|
||||
results = Entry.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
current_tests = [ test for test in tests if test.end_date >= datetime.now() and test.start_date.date() <= date.today() ]
|
||||
current_tests.sort(key= lambda x: x.end_date or datetime(MINYEAR,1,1), reverse=True)
|
||||
upcoming_tests = [ test for test in tests if test.start_date.date() > datetime.now().date()]
|
||||
upcoming_tests.sort(key= lambda x: x.start_date or datetime(MINYEAR,1,1))
|
||||
recent_results = [result for result in results if not result.status == 'started' ]
|
||||
recent_results.sort(key= lambda x: x.end_time or datetime(MINYEAR,1,1), reverse=True)
|
||||
return render_template('/admin/index.html', current_tests = current_tests, upcomimg_tests = upcoming_tests, recent_results = recent_results)
|
||||
|
||||
@admin.route('/settings/')
|
||||
@login_required
|
||||
def _settings():
|
||||
try:
|
||||
users = User.query.all()
|
||||
datasets = Dataset.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
return render_template('/admin/settings/index.html', users=users, datasets=datasets)
|
||||
|
||||
@admin.route('/login/', methods=['GET','POST'])
|
||||
@disable_if_logged_in
|
||||
@require_account_creation
|
||||
def _login():
|
||||
form = Login()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
try: users = User.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
user = None
|
||||
for _user in users:
|
||||
if _user.get_username() == request.form.get('username').lower():
|
||||
user = _user
|
||||
break
|
||||
if user:
|
||||
if user.verify_password(request.form.get('password')):
|
||||
user.login(remember=request.form.get('remember'))
|
||||
return jsonify({'success': f'Successfully logged in.'}), 200
|
||||
return jsonify({'error': f'The password you entered is incorrect.'}), 401
|
||||
return jsonify({'error': f'The username you entered does not exist.'}), 401
|
||||
return send_errors_to_client(form=form)
|
||||
if 'remembered_username' in session: form.username.data = session.pop('remembered_username')
|
||||
next = request.args.get('next')
|
||||
return render_template('/admin/auth/login.html', form=form, next=next)
|
||||
|
||||
@admin.route('/logout/')
|
||||
@login_required
|
||||
def _logout():
|
||||
current_user.logout()
|
||||
return redirect(url_for('admin._login'))
|
||||
|
||||
@admin.route('/register/', methods=['GET','POST'])
|
||||
@disable_if_logged_in
|
||||
def _register():
|
||||
from ..models.user import User
|
||||
form = Register()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
new_user = User()
|
||||
new_user.set_username(request.form.get('username').lower())
|
||||
new_user.set_email(request.form.get('email').lower())
|
||||
success, message = new_user.register(password=request.form.get('password'))
|
||||
if success:
|
||||
flash(message=f'{message} Please log in to continue.', category='success')
|
||||
session['remembered_username'] = request.form.get('username').lower()
|
||||
return jsonify({'success': message}), 200
|
||||
flash(message=message, category='error')
|
||||
return jsonify({'error': message}), 401
|
||||
return send_errors_to_client(form=form)
|
||||
return render_template('/admin/auth/register.html', form=form)
|
||||
|
||||
@admin.route('/reset/', methods=['GET','POST'])
|
||||
def _reset():
|
||||
form = ResetPassword()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
user = None
|
||||
try: users = User.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
for _user in users:
|
||||
if _user.get_username() == request.form.get('username'):
|
||||
user = _user
|
||||
break
|
||||
if not user: return jsonify({'error': 'The user account does not exist.'}), 400
|
||||
if not user.get_email() == request.form.get('email'): return jsonify({'error': 'The email address does not match the user account.'}), 400
|
||||
return user.reset_password()
|
||||
return send_errors_to_client(form=form)
|
||||
|
||||
token = request.args.get('token')
|
||||
if token:
|
||||
try: user = User.query.filter_by(reset_token=token).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not user: return redirect(url_for('admin._reset'))
|
||||
verification_token = user.verification_token
|
||||
user.clear_reset_tokens()
|
||||
if request.args.get('verification') == verification_token:
|
||||
form = UpdatePassword()
|
||||
session['user'] = user.id
|
||||
return render_template('/admin/auth/update-password.html', form=form)
|
||||
flash('The verification of your password reset request failed and the token has been invalidated. Please make a new reset password request.', 'error')
|
||||
|
||||
return render_template('/admin/auth/reset.html', form=form)
|
||||
|
||||
@admin.route('/update_password/', methods=['POST'])
|
||||
def _update_password():
|
||||
form = UpdatePassword()
|
||||
if form.validate_on_submit():
|
||||
user = session.pop('user')
|
||||
try: user = User.query.filter_by(id=user).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
user.update(password=request.form.get('password'))
|
||||
session['remembered_username'] = user.get_username()
|
||||
flash('Your password has been reset.', 'success')
|
||||
return jsonify({'success':'Your password has been reset'}), 200
|
||||
return send_errors_to_client(form=form)
|
||||
|
||||
@admin.route('/settings/users/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _users():
|
||||
form = CreateUser()
|
||||
try: users = User.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
password = request.form.get('password')
|
||||
password = secrets.token_hex(12) if not password else password
|
||||
new_user = User()
|
||||
new_user.set_username(request.form.get('username').lower())
|
||||
new_user.set_email(request.form.get('email'))
|
||||
success, message = new_user.register(notify=request.form.get('notify'), password=password)
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 401
|
||||
return send_errors_to_client(form=form)
|
||||
return render_template('/admin/settings/users.html', form = form, users = users)
|
||||
|
||||
@admin.route('/settings/users/delete/<string:id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _delete_user(id:str):
|
||||
try: user = User.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
form = DeleteUser()
|
||||
if request.method == 'POST':
|
||||
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
||||
if id == current_user.id: return jsonify({'error': 'Cannot delete your own account.'}), 400
|
||||
if form.validate_on_submit():
|
||||
password = request.form.get('password')
|
||||
if not current_user.verify_password(password): return jsonify({'error': 'The password you entered is incorrect.'}), 401
|
||||
success, message = user.delete(notify=request.form.get('notify'))
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
return send_errors_to_client(form=form)
|
||||
|
||||
if id == current_user.id:
|
||||
flash('Cannot delete your own user account.', 'error')
|
||||
return redirect(url_for('admin._users'))
|
||||
if not user:
|
||||
flash('User not found.', 'error')
|
||||
return redirect(url_for('admin._users'))
|
||||
return render_template('/admin/settings/delete_user.html', form=form, id = id, user = user)
|
||||
|
||||
@admin.route('/settings/users/update/<string:id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _update_user(id:str):
|
||||
try: user = User.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
form = UpdateUser()
|
||||
if request.method == 'POST':
|
||||
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
||||
if form.validate_on_submit():
|
||||
if not current_user.verify_password(request.form.get('confirm_password')): return jsonify({'error': 'Invalid password for your account.'}), 401
|
||||
success, message = user.update(
|
||||
password = request.form.get('password'),
|
||||
email = request.form.get('email'),
|
||||
notify = request.form.get('notify')
|
||||
)
|
||||
if success:
|
||||
flash(message, 'success')
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
return send_errors_to_client(form=form)
|
||||
if not user:
|
||||
flash('User not found.', 'error')
|
||||
return redirect(url_for('admin._users'))
|
||||
return render_template('/admin/settings/update_user.html', form=form, id = id, user = user)
|
||||
|
||||
@admin.route('/settings/questions/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _questions():
|
||||
form = UploadData()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
upload = form.data_file.data
|
||||
if not check_is_json(upload): return jsonify({'error': 'Invalid file. Please upload a JSON file.'}), 400
|
||||
upload.stream.seek(0)
|
||||
data = loads(upload.read())
|
||||
if not validate_json(data=data): return jsonify({'error': 'The data in the file is invalid.'}), 400
|
||||
new_dataset = Dataset()
|
||||
new_dataset.set_name(request.form.get('name'))
|
||||
success, message = new_dataset.create(
|
||||
data = data,
|
||||
default = request.form.get('default')
|
||||
)
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
return send_errors_to_client(form=form)
|
||||
try: data = Dataset.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
return render_template('/admin/settings/questions.html', form=form, data=data)
|
||||
|
||||
@admin.route('/settings/questions/delete/', methods=['POST'])
|
||||
@login_required
|
||||
def _edit_questions():
|
||||
id = request.get_json()['id']
|
||||
action = request.get_json()['action']
|
||||
if not action == 'delete': return jsonify({'error': 'Invalid action.'}), 400
|
||||
try: dataset = Dataset.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if action == 'delete': success, message = dataset.delete()
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
|
||||
@admin.route('/settings/questions/download/<string:id>/')
|
||||
@login_required
|
||||
def _download(id:str):
|
||||
try: dataset = Dataset.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not dataset: return abort(404)
|
||||
data_path = path.abspath(dataset.get_file())
|
||||
return send_file(data_path, as_attachment=True, download_name=f'{dataset.get_name()}.json')
|
||||
|
||||
@admin.route('/tests/<string:filter>/', methods=['GET'])
|
||||
@admin.route('/tests/', methods=['GET'])
|
||||
@login_required
|
||||
@check_dataset_exists
|
||||
def _tests(filter:str=None):
|
||||
tests = None
|
||||
try: _tests = Test.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
form = None
|
||||
now = datetime.now()
|
||||
if filter not in ['create','active','scheduled','expired','all']: return redirect(url_for('admin._tests', filter='active'))
|
||||
if filter == 'create':
|
||||
form = CreateTest()
|
||||
form.start_date.default = datetime.now()
|
||||
form.expiry_date.default = date.today() + timedelta(days=1)
|
||||
form.time_limit.choices = get_time_options()
|
||||
form.dataset.choices = get_dataset_choices()
|
||||
form.time_limit.default='none'
|
||||
form.process()
|
||||
display_title = ''
|
||||
error_none = ''
|
||||
if filter in [None, '', 'active']:
|
||||
tests = [ test for test in _tests if test.end_date >= now and test.start_date <= now ]
|
||||
display_title = 'Active Exams'
|
||||
error_none = 'There are no exams that are currently active. You can create one using the Create Exam form.'
|
||||
if filter == 'expired':
|
||||
tests = [ test for test in _tests if test.end_date < now ]
|
||||
display_title = 'Expired Exams'
|
||||
error_none = 'There are no expired exams. Exams will appear in this category after their expiration date has passed.'
|
||||
if filter == 'scheduled':
|
||||
tests = [ test for test in _tests if test.start_date > now]
|
||||
display_title = 'Scheduled Exams'
|
||||
error_none = 'There are no scheduled exams pending. You can schedule an exam for the future using the Create Exam form.'
|
||||
if filter == 'all':
|
||||
tests = _tests
|
||||
display_title = 'All Exams'
|
||||
error_none = 'There are no exams set up. You can create one using the Create Exam form.'
|
||||
return render_template('/admin/tests.html', form = form, tests=tests, display_title=display_title, error_none=error_none, filter=filter)
|
||||
|
||||
@admin.route('/tests/create/', methods=['POST'])
|
||||
@login_required
|
||||
def _create_test():
|
||||
form = CreateTest()
|
||||
form.dataset.choices = get_dataset_choices()
|
||||
form.time_limit.choices = get_time_options()
|
||||
if form.validate_on_submit():
|
||||
new_test = Test()
|
||||
new_test.start_date = request.form.get('start_date')
|
||||
new_test.start_date = datetime.strptime(new_test.start_date, '%Y-%m-%dT%H:%M')
|
||||
new_test.end_date = request.form.get('expiry_date')
|
||||
new_test.end_date = datetime.strptime(new_test.end_date, '%Y-%m-%dT%H:%M')
|
||||
new_test.time_limit = None if request.form.get('time_limit') == 'none' else int(request.form.get('time_limit'))
|
||||
dataset = request.form.get('dataset')
|
||||
try: new_test.dataset = Dataset.query.filter_by(id=dataset).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
success, message = new_test.create()
|
||||
if success:
|
||||
flash(message=message, category='success')
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
return send_errors_to_client(form=form)
|
||||
|
||||
@admin.route('/tests/edit/', methods=['POST'])
|
||||
@login_required
|
||||
def _edit_test():
|
||||
id = request.get_json()['id']
|
||||
action = request.get_json()['action']
|
||||
if action not in ['start', 'delete', 'end']: return jsonify({'error': 'Invalid action.'}), 400
|
||||
try: test = Test.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not test: return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
|
||||
if action == 'delete': success, message = test.delete()
|
||||
if action == 'start': success, message = test.start()
|
||||
if action == 'end': success, message = test.end()
|
||||
if success:
|
||||
flash(message=message, category='success')
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
|
||||
@admin.route('/test/<string:id>/', methods=['GET','POST'])
|
||||
@login_required
|
||||
def _view_test(id:str=None):
|
||||
form = AddTimeAdjustment()
|
||||
try: test = Test.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if request.method == 'POST':
|
||||
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
|
||||
if form.validate_on_submit():
|
||||
time = int(request.form.get('time'))
|
||||
success, message = test.add_adjustment(time)
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
return jsonify({'error': form.time.errors }), 400
|
||||
if not test:
|
||||
flash('Invalid test ID.', 'error')
|
||||
return redirect(url_for('admin._tests', filter='active'))
|
||||
return render_template('/admin/test.html', test = test, form = form)
|
||||
|
||||
@admin.route('/test/<string:id>/delete-adjustment/', methods=['POST'])
|
||||
@login_required
|
||||
def _delete_adjustment(id:str=None):
|
||||
try: test = Test.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
|
||||
user_code = request.get_json()['user_code'].lower()
|
||||
success, message = test.remove_adjustment(user_code)
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
|
||||
@admin.route('/results/')
|
||||
@login_required
|
||||
def _view_entries():
|
||||
try: entries = Entry.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
return render_template('/admin/results.html', entries = entries)
|
||||
|
||||
@admin.route('/results/<string:id>/', methods = ['GET', 'POST'])
|
||||
@login_required
|
||||
def _view_entry(id:str=None):
|
||||
try: entry = Entry.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if request.method == 'POST':
|
||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||
action = request.get_json()['action']
|
||||
if action not in ['validate', 'delete']: return jsonify({'error': 'Invalid action.'}), 400
|
||||
if action == 'validate':
|
||||
success, message = entry.validate()
|
||||
if action == 'delete':
|
||||
success, message = entry.delete()
|
||||
if success:
|
||||
flash(message, 'success')
|
||||
entry.notify_result()
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}),400
|
||||
if not entry:
|
||||
flash('Invalid entry ID.', 'error')
|
||||
return redirect(url_for('admin._view_entries'))
|
||||
test = entry.test
|
||||
data = test.dataset.get_data()
|
||||
correct = get_correct_answers(dataset=data)
|
||||
answers = answer_options(dataset=data)
|
||||
return render_template('/admin/result-detail.html', entry = entry, correct = correct, answers = answers)
|
||||
|
||||
@admin.route('/certificate/',methods=['POST'])
|
||||
@login_required
|
||||
def _generate_certificate():
|
||||
from ..extensions import db
|
||||
id = request.get_json()['id']
|
||||
try: entry = Entry.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||
return render_template('/admin/components/certificate.html', entry = entry)
|
8
ref-test/app/analysis/static/css/analysis.css
Normal file
@ -0,0 +1,8 @@
|
||||
#alert-box {
|
||||
margin: 30px auto;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.cell-percentage::after {
|
||||
content: '%';
|
||||
}
|
260
ref-test/app/analysis/static/css/style.css
Normal file
@ -0,0 +1,260 @@
|
||||
body {
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
background-color: lightgray;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.site-footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
padding-top: 40px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.form-display {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 15px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.form-heading {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-label-group {
|
||||
position: relative;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-label-group input,
|
||||
.form-label-group label {
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
font-size: 16pt;
|
||||
}
|
||||
|
||||
.form-label-group label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0; /* Override default `<label>` margin */
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
cursor: text; /* Match the input under the label */
|
||||
border: 1px solid transparent;
|
||||
border-radius: .25rem;
|
||||
transition: all .1s ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.form-label-group input {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 0%;
|
||||
border-bottom: 2px solid #585858;
|
||||
}
|
||||
|
||||
.form-label-group input:active, .form-label-group input:focus {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-webkit-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input:-ms-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-ms-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-moz-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input:not(:placeholder-shown) {
|
||||
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
|
||||
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||
}
|
||||
|
||||
.form-label-group input:not(:placeholder-shown) ~ label {
|
||||
padding-top: calc(var(--input-padding-y) / 3);
|
||||
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.form-check {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
transform: scale(1.5);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.signin-forgot-password {
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
.form-submission-button {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-submission-button button, .form-submission-button a {
|
||||
margin: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.form-submission-button button span, .form-submission-button button svg, .form-submission-button a span, .form-submission-button a svg {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
table.dataTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dataTables_wrapper .dt-buttons {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
float:none;
|
||||
text-align:center;
|
||||
}
|
||||
|
||||
.row-actions button, .row-actions a {
|
||||
margin: 0px 5px;
|
||||
}
|
||||
|
||||
#cookie-alert {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
#dismiss-cookie-alert {
|
||||
margin-top: 16px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.alert-db-empty {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
font-size: 14pt;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
.form-date-input, .form-select-input {
|
||||
position: relative;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.form-date-input input,
|
||||
.form-date-input label, .form-select-input select, .form-select-input label {
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
font-size: 16pt;
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid #585858;
|
||||
}
|
||||
|
||||
.datepicker::-webkit-calendar-picker-indicator {
|
||||
border: 1px;
|
||||
border-color: gray;
|
||||
border-radius: 10%;
|
||||
}
|
||||
|
||||
.form-date-input label, .form-select-input label {
|
||||
/* position: absolute; */
|
||||
/* top: 0;
|
||||
left: 0; */
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0; /* Override default `<label>` margin */
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
cursor: text; /* Match the input under the label */
|
||||
border: 1px solid transparent;
|
||||
border-radius: .25rem;
|
||||
transition: all .1s ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.form-upload {
|
||||
margin: 2rem 0;
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
.result-action-buttons, .test-action {
|
||||
margin: 5px auto;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.accordion-item {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
/* Change Autocomplete styles in Chrome*/
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
textarea:-webkit-autofill,
|
||||
textarea:-webkit-autofill:hover,
|
||||
textarea:-webkit-autofill:focus,
|
||||
select:-webkit-autofill,
|
||||
select:-webkit-autofill:hover,
|
||||
select:-webkit-autofill:focus {
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
/* Fallback for Edge
|
||||
-------------------------------------------------- */
|
||||
@supports (-ms-ime-align: auto) {
|
||||
.form-label-group label {
|
||||
display: none;
|
||||
}
|
||||
.form-label-group input::-ms-input-placeholder {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback for IE
|
||||
-------------------------------------------------- */
|
||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||
.form-label-group label {
|
||||
display: none;
|
||||
}
|
||||
.form-label-group input:-ms-input-placeholder {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
27
ref-test/app/analysis/static/js/analysis.js
Normal file
@ -0,0 +1,27 @@
|
||||
// Analyse Button Listener
|
||||
$('.button-analyse').click(function(event) {
|
||||
|
||||
let buttonClass = $(this).data('class')
|
||||
let id = null
|
||||
|
||||
if (buttonClass == 'test' ) {
|
||||
id = $('#select-test').children('option:selected').val()
|
||||
} else if (buttonClass == 'dataset' ) {
|
||||
id = $('#select-dataset').children('option:selected').val()
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: `/admin/analysis/`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({'id': id, 'class': buttonClass}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
window.location.href = response
|
||||
},
|
||||
error: function(response){
|
||||
error_response(response)
|
||||
},
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
})
|
2
ref-test/app/analysis/static/js/jquery-3.6.0.min.js
vendored
Normal file
115
ref-test/app/analysis/static/js/script.js
Normal file
@ -0,0 +1,115 @@
|
||||
// Menu Highlight Scripts
|
||||
const menuItems = document.getElementsByClassName('nav-link')
|
||||
for(let i = 0; i < menuItems.length; i++) {
|
||||
if(menuItems[i].pathname == window.location.pathname) {
|
||||
menuItems[i].classList.add('active')
|
||||
}
|
||||
}
|
||||
const dropdownItems = document.getElementsByClassName('dropdown-item')
|
||||
for(let i = 0; i< dropdownItems.length; i++) {
|
||||
if(dropdownItems[i].pathname == window.location.pathname) {
|
||||
dropdownItems[i].classList.add('active')
|
||||
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active')
|
||||
}
|
||||
}
|
||||
|
||||
// General Post Method Form Processing Script
|
||||
$('form.form-post').submit(function(event) {
|
||||
|
||||
var $form = $(this)
|
||||
var data = $form.serialize()
|
||||
var url = $(this).prop('action')
|
||||
var rel_success = $(this).data('rel-success')
|
||||
|
||||
$.ajax({
|
||||
url: url,
|
||||
type: 'POST',
|
||||
data: data,
|
||||
dataType: 'json',
|
||||
success: function(response) {
|
||||
if (response.redirect_to) {
|
||||
window.location.href = response.redirect_to
|
||||
}
|
||||
else {
|
||||
window.location.href = rel_success
|
||||
}
|
||||
},
|
||||
error: function(response) {
|
||||
error_response(response)
|
||||
}
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
function error_response(response) {
|
||||
|
||||
const $alert = $("#alert-box")
|
||||
$alert.html('')
|
||||
|
||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
||||
$alert.html(`
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||
${response.responseJSON.error}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`)
|
||||
} else if (response.responseJSON.error instanceof Array) {
|
||||
var output = ''
|
||||
for (let i = 0; i < response.responseJSON.error.length; i ++) {
|
||||
output += `
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||
${response.responseJSON.error[i]}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`
|
||||
$alert.html(output)
|
||||
}
|
||||
}
|
||||
|
||||
$alert.focus()
|
||||
}
|
||||
|
||||
// Dismiss Cookie Alert
|
||||
$('#dismiss-cookie-alert').click(function(event){
|
||||
|
||||
$.ajax({
|
||||
url: '/cookies/',
|
||||
type: 'POST',
|
||||
data: {
|
||||
time: Date.now()
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response){
|
||||
console.log(response)
|
||||
},
|
||||
error: function(response){
|
||||
console.log(response)
|
||||
}
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
// Create New Dataset
|
||||
$('.create-new-dataset').click(function(event){
|
||||
$.ajax({
|
||||
url: '/api/editor/new/',
|
||||
type: 'POST',
|
||||
data: {
|
||||
time: Date.now()
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response){
|
||||
if (response.redirect_to) {
|
||||
window.location.href = response.redirect_to
|
||||
}
|
||||
},
|
||||
error: function(response){
|
||||
console.log(response)
|
||||
}
|
||||
})
|
||||
event.preventDefault()
|
||||
})
|
198
ref-test/app/analysis/templates/analysis/analysis.html
Normal file
@ -0,0 +1,198 @@
|
||||
{% extends "analysis/components/datatable.html" %}
|
||||
|
||||
{% block style %}
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('.static', filename='css/analysis.css') }}"
|
||||
/>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Analysis by {{ type[0]|upper }}{{ type[1:] }}</h1>
|
||||
<div class="container">
|
||||
<p class="lead">
|
||||
The analysis section displays statistics for all test results as well as answers to individual questions.
|
||||
Analysis reports can be generated per exam or per question dataset to identify common mistakes or patterns in answers.
|
||||
</p>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">
|
||||
{% if type == 'exam' %}
|
||||
Exam Code
|
||||
{% elif type == 'dataset' %}
|
||||
Dataset Name
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="form-control">
|
||||
{{ subject }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Total Entries</span>
|
||||
<span class="form-control">
|
||||
{{ analysis.entries }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Passed</span>
|
||||
<span class="form-control">
|
||||
{{ analysis.grades.merit + analysis.grades.pass }} ({{ ((analysis.grades.merit + analysis.grades.pass)*100/analysis.entries)|round(2) }} %)
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<span class="badge rounded-pill progress-bar-striped bg-success">Merit: {{ analysis.grades.merit }}</span> <span class="badge rounded-pill bg-primary progress-bar-striped">Pass: {{ analysis.grades.pass }}</span> <span class="badge rounded-pill progress-bar-striped bg-danger">Fail: {{ analysis.grades.fail }}</span>
|
||||
<div class="my-1 progress">
|
||||
<div class="progress-bar progress-bar-striped bg-success" role="progressbar" style="width: {{ (analysis.grades.merit*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.merit }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.merit*100/analysis.entries)|round(2) }} %</div>
|
||||
<div class="progress-bar progress-bar-striped" role="progressbar" style="width: {{ (analysis.grades.pass*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.pass }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.pass*100/analysis.entries)|round(2) }} %</div>
|
||||
<div class="progress-bar progress-bar-striped bg-danger" role="progressbar" style="width: {{ (analysis.grades.fail*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.fail }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.fail*100/analysis.entries)|round(2) }} %</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Mean Score</span>
|
||||
<span class="form-control">
|
||||
{{ analysis.scores.mean|round(2) }} %
|
||||
</span>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Standard Deviation</span>
|
||||
<span class="form-control">
|
||||
{% if analysis.scores.stdev %}
|
||||
{{ analysis.scores.stdev|round(2) }}
|
||||
{% else %}
|
||||
{{ None }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Median Score</span>
|
||||
<span class="form-control">
|
||||
{{ analysis.scores.median|round(2) }} %
|
||||
</span>
|
||||
</div>
|
||||
{% if type == 'exam' %}
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Dataset Name</span>
|
||||
<span class="form-control">
|
||||
{{ dataset.get_name() }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="container">
|
||||
<table id="analysis-table" class="table table-striped" style="width:100%">
|
||||
<thead>
|
||||
<th data-priority="1">
|
||||
Question
|
||||
</th>
|
||||
<th data-priority="1">
|
||||
Percent Correct
|
||||
</th>
|
||||
<th data-priority="2">
|
||||
Answers
|
||||
</th>
|
||||
<th data-priority="3">
|
||||
Tags
|
||||
</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for question in questions %}
|
||||
<tr class="table-row">
|
||||
<td>
|
||||
{{ question.q_no + 1 }}
|
||||
</td>
|
||||
<td class="cell-percentage">
|
||||
{{ ((analysis.answers[question.q_no][question.correct] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}
|
||||
</td>
|
||||
<td>
|
||||
<table style="width:100%">
|
||||
{% for option in question.options %}
|
||||
<tr>
|
||||
<td style="width:50%">
|
||||
{{ option[1] }}
|
||||
</td>
|
||||
<td>
|
||||
{% if question.correct == option[0] %}
|
||||
<div class="progress">
|
||||
<div class="progress-bar bg-success progress-bar-striped" role="progressbar" style="width: {{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}%;" aria-valuenow="{{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}" aria-valuemin="0" aria-valuemax="100">{{ ((analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}%</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="progress">
|
||||
<div class="progress-bar bg-danger progress-bar-striped" role="progressbar" style="width: {{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}%;" aria-valuenow="{{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}" aria-valuemin="0" aria-valuemax="100">{{ ((analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}%</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</td>
|
||||
<td>
|
||||
<ul>
|
||||
{% for tag in question.tags %}
|
||||
<li>{{ tag|safe }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const target = "{{ url_for('api._editor') }}"
|
||||
const id = "{{ dataset.id }}"
|
||||
</script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="{{ url_for('.static', filename='js/analysis.js') }}"
|
||||
></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block custom_data_script %}
|
||||
<script>
|
||||
console.log($('#analysis-table'))
|
||||
$(document).ready(function() {
|
||||
$('#analysis-table').DataTable({
|
||||
'searching': true,
|
||||
'columnDefs': [
|
||||
{'sortable': true, 'targets': [0,1]},
|
||||
{'sortable': false, 'targets': [2,3]},
|
||||
{'searchable': true, 'targets': [0,2,3]}
|
||||
],
|
||||
'order': [[0, 'asc'], [1, 'desc']],
|
||||
'buttons': [
|
||||
{
|
||||
extend: 'print',
|
||||
exportOptions: {
|
||||
columns: [0, 1, 2, 3]
|
||||
}
|
||||
},
|
||||
{
|
||||
extend: 'excel',
|
||||
exportOptions: {
|
||||
columns: [0, 1, 2, 3]
|
||||
}
|
||||
},
|
||||
{
|
||||
extend: 'pdf',
|
||||
exportOptions: {
|
||||
columns: [0, 1, 2, 3]
|
||||
}
|
||||
}
|
||||
],
|
||||
'responsive': 'true',
|
||||
'colReorder': 'true',
|
||||
'fixedHeader': 'true',
|
||||
'searchBuilder': {
|
||||
depthLimit: 2,
|
||||
columns: [2, 3],
|
||||
},
|
||||
dom: 'BQlfrtip'
|
||||
});
|
||||
// $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') -->
|
||||
} );
|
||||
$('#analysis-table').show();
|
||||
$(window).trigger('resize');
|
||||
</script>
|
||||
{% endblock %}
|
@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
|
||||
crossorigin="anonymous">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('.static', filename='css/style.css') }}"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('.static', filename='css/view.css') }}"
|
||||
/>
|
||||
{% block style %}
|
||||
{% endblock %}
|
||||
<title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title>
|
||||
{% include "analysis/components/og-meta.html" %}
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
||||
{% block navbar %}
|
||||
{% include "analysis/components/navbar.html" %}
|
||||
{% endblock %}
|
||||
|
||||
<div class="container">
|
||||
{% block top_alerts %}
|
||||
{% include "analysis/components/server-alerts.html" %}
|
||||
{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer class="container site-footer mt-5">
|
||||
{% block footer %}
|
||||
{% include "analysis/components/footer.html" %}
|
||||
{% endblock %}
|
||||
</footer>
|
||||
|
||||
<!-- JQuery, Popper, and Bootstrap js dependencies -->
|
||||
<script
|
||||
src="https://code.jquery.com/jquery-3.6.0.min.js"
|
||||
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script>
|
||||
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
|
||||
</script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
||||
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
|
||||
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<!-- Custom js -->
|
||||
<script type="text/javascript">
|
||||
var csrf_token = "{{ csrf_token() }}";
|
||||
|
||||
$.ajaxSetup({
|
||||
beforeSend: function(xhr, settings) {
|
||||
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
|
||||
xhr.setRequestHeader("X-CSRFToken", csrf_token);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="{{ url_for('.static', filename='js/script.js') }}"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="{{ url_for('.static', filename='js/analysis.js') }}"
|
||||
></script>
|
||||
{% block script %}
|
||||
{% endblock %}
|
||||
{% block datatable_scripts %}
|
||||
{% endblock %}
|
||||
{% block custom_data_script %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1 @@
|
||||
<div id="alert-box" tabindex="-1"></div>
|
@ -0,0 +1,28 @@
|
||||
{% extends "analysis/components/base.html" %}
|
||||
{% block datatable_css %}
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.11.3/css/dataTables.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/buttons/2.0.1/css/buttons.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/colreorder/1.5.5/css/colReorder.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/fixedheader/3.2.0/css/fixedHeader.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/keytable/2.6.4/css/keyTable.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/searchbuilder/1.3.0/css/searchBuilder.dataTables.min.css"/>
|
||||
{% endblock %}
|
||||
{% block datatable_scripts %}
|
||||
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jszip/2.5.0/jszip.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/pdfmake.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/vfs_fonts.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/jquery.dataTables.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/dataTables.bootstrap5.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/dataTables.buttons.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.bootstrap5.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.colVis.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.html5.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.print.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/colreorder/1.5.5/js/dataTables.colReorder.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/fixedheader/3.2.0/js/dataTables.fixedHeader.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/keytable/2.6.4/js/dataTables.keyTable.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/responsive.bootstrap5.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/searchbuilder/1.3.0/js/dataTables.searchBuilder.min.js"></script>
|
||||
{% endblock %}
|
@ -0,0 +1,2 @@
|
||||
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
@ -0,0 +1,4 @@
|
||||
{% extends "analysis/components/base.html" %}
|
||||
{% import "bootstrap/wtf.html" as wtf %}
|
||||
{% block top_alerts %}
|
||||
{% endblock %}
|
137
ref-test/app/analysis/templates/analysis/components/navbar.html
Normal file
@ -0,0 +1,137 @@
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbar"
|
||||
aria-controls="navbar"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle Navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navbar">
|
||||
<ul class="navbar-nav">
|
||||
{% if not current_user.is_authenticated %}
|
||||
<li class="nav-item" id="nav-login">
|
||||
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item dropdown" id="nav-results">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-results"
|
||||
role="button"
|
||||
href="{{ url_for('admin._view_entries') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Results
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-results"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-tests">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-tests"
|
||||
role="button"
|
||||
href="{{ url_for('admin._tests') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Exams
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-tests"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-settings">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-settings"
|
||||
role="button"
|
||||
href="{{ url_for('admin._settings') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-settings"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-account">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
role="button"
|
||||
href="{{ url_for('admin._update_user', id=current_user.id) }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Account
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-account"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
@ -2,16 +2,17 @@
|
||||
<meta property="og:locale" content="en_UK" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta property="og:url" content="{{ url_for(request.endpoint, _external = True, **request.view_args) }}" />
|
||||
<meta property="og:url" content="{{ url_for(request.endpoint, _external = True, **(request.view_args or {})) }}" />
|
||||
<meta property="og:site_name" content="Scottish Korfball Association Referee Theory Exam" />
|
||||
<meta property="og:image" content="{{ url_for('static', filename='favicon.png', _external = True) }}" />
|
||||
<meta property="og:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
|
||||
<meta property="og:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||
<meta property="og:image:width" content="512" />
|
||||
<meta property="og:image:height" content="512" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta name="twitter:image" content="{{ url_for('static', filename='favicon.png', _external = True) }}" />
|
||||
<meta name="twitter:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
|
||||
<meta name="twitter:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||
<meta name="twitter:creator" content="@viveksantayana" />
|
||||
<meta name="twitter:site" content="@viveksantayana" />
|
||||
<meta name="theme-color" content="#343a40" />
|
||||
<meta name="theme-color" content="#343a40" />
|
||||
<link rel="shortcut icon" href="{{ url_for('.static', filename='favicon.ico') }}">
|
@ -0,0 +1,23 @@
|
||||
<div class="navbar navbar-expand-sm navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<div class="expand navbar-expand justify-content-center" id="navbar_secondary">
|
||||
<ul class="nav nav-pills">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='active') }}">Active</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='scheduled') }}">Scheduled</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='expired') }}">Expired</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='all') }}">All</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='create') }}">Create</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -25,7 +25,9 @@
|
||||
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
|
||||
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
|
||||
<div class="d-flex justify-content-center w-100">
|
||||
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
|
||||
</div>
|
||||
</div>
|
||||
{% set cookie_flash_flag.value = True %}
|
||||
{% endif %}
|
54
ref-test/app/analysis/templates/analysis/index.html
Normal file
@ -0,0 +1,54 @@
|
||||
{% extends "analysis/components/input-forms.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Analysis</h1>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="card m-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Exams</h5>
|
||||
<div class="card-text">
|
||||
<div class="form-select-input">
|
||||
<select name="select-test" id="select-test">
|
||||
{% for test in tests %}
|
||||
<option value="{{ test.id }}">{{ test.get_code() }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="my-3">
|
||||
<a href="{{ url_for('analysis._test') }}" class="btn btn-primary button-analyse" data-class="test">
|
||||
<i class="bi bi-search button-icon"></i>
|
||||
Analyse
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="card m-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Datasets</h5>
|
||||
<div class="card-text">
|
||||
<div class="form-select-input">
|
||||
<select name="select-dataset" id="select-dataset">
|
||||
{% for dataset in datasets %}
|
||||
<option value="{{ dataset.id }}">{{ dataset.get_name() }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="my-3">
|
||||
<a href="{{ url_for('analysis._dataset') }}" class="btn btn-primary button-analyse" data-class="dataset">
|
||||
<i class="bi bi-search button-icon"></i>
|
||||
Analyse
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "analysis/components/client-alerts.html" %}
|
||||
{% endblock %}
|
85
ref-test/app/analysis/views.py
Normal file
@ -0,0 +1,85 @@
|
||||
from ..models import Dataset, Test
|
||||
from ..tools.data import analyse, check_dataset_exists, check_test_exists
|
||||
from ..tools.logs import write
|
||||
from ..tools.data import parse_questions
|
||||
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
from flask.helpers import abort, flash, redirect, url_for
|
||||
from flask_login import login_required
|
||||
|
||||
analysis = Blueprint(
|
||||
name='analysis',
|
||||
import_name=__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static'
|
||||
)
|
||||
|
||||
@analysis.route('/', methods=['GET','POST'])
|
||||
@login_required
|
||||
@check_dataset_exists
|
||||
@check_test_exists
|
||||
def _analysis():
|
||||
try:
|
||||
_tests = Test.query.all()
|
||||
_datasets = Dataset.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
tests = [ test for test in _tests if test.entries ]
|
||||
datasets = [ dataset for dataset in _datasets if dataset.entries ]
|
||||
if request.method == 'POST':
|
||||
selection = request.get_json()
|
||||
if selection['class'] == 'test':
|
||||
try:
|
||||
test = Test.query.filter_by(id=selection['id']).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not test: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||
return url_for('analysis._test', id=selection['id']), 200
|
||||
if selection['class'] == 'dataset':
|
||||
try:
|
||||
dataset = Dataset.query.filter_by(id=selection['id']).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not dataset: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||
return url_for('analysis._dataset', id=selection['id']), 200
|
||||
return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||
return render_template('/analysis/index.html', tests=tests, datasets=datasets)
|
||||
|
||||
@analysis.route('/test/<string:id>')
|
||||
@analysis.route('/test/')
|
||||
@login_required
|
||||
@check_test_exists
|
||||
def _test(id:str=None):
|
||||
if id in [None, '']:
|
||||
flash(message='Please select a valid exam.', category='error')
|
||||
return redirect(url_for('analysis._analysis'))
|
||||
try:
|
||||
test = Test.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not test:
|
||||
flash('Invalid exam.', 'error')
|
||||
return redirect(url_for('analysis._analysis'))
|
||||
return render_template('/analysis/analysis.html', analysis=analyse(test), subject=test.get_code(), type='exam', dataset=test.dataset, questions=parse_questions(test.dataset.get_data()))
|
||||
|
||||
@analysis.route('/dataset/<string:id>')
|
||||
@analysis.route('/dataset/')
|
||||
@login_required
|
||||
@check_dataset_exists
|
||||
def _dataset(id:str=None):
|
||||
if id in [None, '']:
|
||||
flash(message='Please select a valid dataset.', category='error')
|
||||
return redirect(url_for('analysis._analysis'))
|
||||
try:
|
||||
dataset = Dataset.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not dataset:
|
||||
flash('Invalid dataset.', 'error')
|
||||
return redirect(url_for('analysis._analysis'))
|
||||
return render_template('/analysis/analysis.html', analysis=analyse(dataset), subject=dataset.get_name(), type='dataset', dataset=dataset, questions=parse_questions(dataset.get_data()))
|
115
ref-test/app/api/views.py
Normal file
@ -0,0 +1,115 @@
|
||||
from ..models import Dataset, Entry, User
|
||||
from ..tools.data import validate_json
|
||||
from ..tools.logs import write
|
||||
from ..tools.test import evaluate_answers, generate_questions
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask.helpers import abort, flash, url_for
|
||||
from flask_login import login_required
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from json import loads
|
||||
|
||||
api = Blueprint(
|
||||
name='api',
|
||||
import_name=__name__
|
||||
)
|
||||
|
||||
@api.route('/questions/', methods=['POST'])
|
||||
def _fetch_questions():
|
||||
id = request.get_json()['id']
|
||||
try: entry = Entry.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 400
|
||||
test = entry.test
|
||||
user_code = entry.user_code
|
||||
time_limit = test.time_limit
|
||||
time_adjustment = 0
|
||||
if time_limit:
|
||||
_time_limit = int(time_limit)
|
||||
if user_code:
|
||||
time_adjustment = test.adjustments[user_code]
|
||||
_time_limit += time_adjustment
|
||||
end_delta = timedelta(minutes=_time_limit)
|
||||
end_time = datetime.now() + end_delta
|
||||
else:
|
||||
end_time = None
|
||||
entry.start()
|
||||
dataset = test.dataset
|
||||
success, message = dataset.check_file()
|
||||
if not success: return jsonify({'error': message}), 500
|
||||
data_path = dataset.get_file()
|
||||
with open(data_path, 'r') as data_file:
|
||||
data = loads(data_file.read())
|
||||
questions = generate_questions(data)
|
||||
return jsonify({
|
||||
'time_limit': end_time,
|
||||
'questions': questions,
|
||||
'start_time': entry.start_time,
|
||||
'time_adjustment': time_adjustment
|
||||
}), 200
|
||||
|
||||
@api.route('/submit/', methods=['POST'])
|
||||
def _submit_quiz():
|
||||
id = request.get_json()['id']
|
||||
answers = request.get_json()['answers']
|
||||
try: entry = Entry.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not entry: return jsonify({'error': 'Unrecognised Entry.'}), 400
|
||||
test = entry.test
|
||||
dataset = test.dataset
|
||||
success, message = dataset.check_file()
|
||||
if not success: return jsonify({'error': message}), 500
|
||||
data_path = dataset.get_file()
|
||||
with open(data_path, 'r') as data_file:
|
||||
data = loads(data_file.read())
|
||||
result = evaluate_answers(answers=answers, key=data)
|
||||
entry.complete(answers=answers, result=result)
|
||||
return jsonify({
|
||||
'success': 'Your submission has been processed. Redirecting you to receive your results.',
|
||||
'id': id
|
||||
}), 200
|
||||
|
||||
@api.route('/editor/', methods=['POST'])
|
||||
@login_required
|
||||
def _editor(id:str=None):
|
||||
request_data = request.get_json()
|
||||
id = request_data['id']
|
||||
try: dataset = Dataset.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not dataset: return jsonify({'error': 'Invalid request. Dataset not found.'}), 404
|
||||
data_path = dataset.get_file()
|
||||
if request_data['action'] == 'fetch':
|
||||
with open(data_path, 'r') as data_file:
|
||||
data = loads(data_file.read())
|
||||
return jsonify({'success': 'Successfully downloaded dataset', 'data': data}), 200
|
||||
default = request_data['default']
|
||||
creator = request_data['creator']
|
||||
try: user = User.query.filter_by(id=creator).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
name = request_data['name']
|
||||
data = request_data['data']
|
||||
if not validate_json(data): return jsonify({'error': 'The data you submitted was invalid.'}), 400
|
||||
dataset.set_name(name)
|
||||
dataset.creator = user
|
||||
success, message = dataset.update(data=data, default=default)
|
||||
if not success: return jsonify({'error': message}), 400
|
||||
return jsonify({'success': message}), 200
|
||||
|
||||
@api.route('/editor/new/', methods=['POST'])
|
||||
@login_required
|
||||
def _editor_new():
|
||||
new_dataset = Dataset()
|
||||
new_dataset.set_name('New Dataset')
|
||||
success, message = new_dataset.create(data=[], default=False)
|
||||
if not success: return jsonify({'error':message}), 400
|
||||
flash(message, 'success')
|
||||
return jsonify({'success': message, 'redirect_to': url_for('editor._editor_console', id=new_dataset.id)}), 200
|
60
ref-test/app/config.py
Normal file
@ -0,0 +1,60 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv('../.env')
|
||||
|
||||
class Config(object):
|
||||
"""Basic App Configuration"""
|
||||
APP_HOST = '0.0.0.0'
|
||||
DATA = './data/'
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||
SERVER_NAME = os.getenv('SERVER_NAME')
|
||||
SESSION_COOKIE_SECURE = True
|
||||
WTF_CSRF_TIME_LIMIT = None
|
||||
|
||||
"""Email Engine Configuration"""
|
||||
MAIL_SERVER = os.getenv('MAIL_SERVER')
|
||||
MAIL_PORT = int(os.getenv('MAIL_PORT') or 25)
|
||||
MAIL_USE_TLS = False
|
||||
MAIL_USE_SSL = False
|
||||
MAIL_DEBUG = False
|
||||
MAIL_USERNAME = os.getenv('MAIL_USERNAME')
|
||||
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
|
||||
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER')
|
||||
MAIL_MAX_EMAILS = int(os.getenv('MAIL_MAX_EMAILS') or 25)
|
||||
MAIL_SUPPRESS_SEND = False
|
||||
MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS') or True)
|
||||
|
||||
"""Database Driver Configuration"""
|
||||
DATABASE_TYPE = os.getenv('DATABASE_TYPE') or 'SQLite'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
if DATABASE_TYPE.lower() == 'mysql' and os.getenv('MYSQL_DATABASE') and os.getenv('MYSQL_USER') and os.getenv('MYSQL_PASSWORD'):
|
||||
DATABASE_HOST = os.getenv('DATABASE_HOST') or 'localhost'
|
||||
DATABASE_PORT = int(os.getenv('DATABASE_PORT') or 3306)
|
||||
MYSQL_DATABASE = os.getenv('MYSQL_DATABASE')
|
||||
MYSQL_USER = os.getenv('MYSQL_USER')
|
||||
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD')
|
||||
SQLALCHEMY_DATABASE_URI = f'mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{MYSQL_DATABASE}'
|
||||
else: SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(os.path.abspath(f"{DATA}/db.sqlite"))}'
|
||||
|
||||
class Production(Config):
|
||||
pass
|
||||
|
||||
class Development(Config):
|
||||
APP_HOST = '127.0.0.1'
|
||||
DEBUG = True
|
||||
SERVER_NAME = '127.0.0.1:5000'
|
||||
SESSION_COOKIE_SECURE = False
|
||||
MAIL_SERVER = 'localhost'
|
||||
MAIL_DEBUG = True
|
||||
MAIL_SUPPRESS_SEND = False
|
||||
|
||||
class Testing(Development):
|
||||
TESTING = True
|
||||
SESSION_COOKIE_SECURE = False
|
||||
MAIL_SERVER = os.getenv('MAIL_SERVER')
|
||||
MAIL_DEBUG = True
|
||||
MAIL_SUPPRESS_SEND = False
|
103
ref-test/app/editor/static/css/editor.css
Normal file
@ -0,0 +1,103 @@
|
||||
.accordion-button {
|
||||
color: inherit;
|
||||
background-color: inherit;
|
||||
display: block;
|
||||
border: 1px solid rgb(0 0 0 / .3);
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
width: fit-content;
|
||||
display: block;
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
.editor-controls a {
|
||||
margin: 10px 10px;
|
||||
}
|
||||
|
||||
.editor-controls a i {
|
||||
font-size: larger;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.option-controls, .block-controls {
|
||||
width: fit-content;
|
||||
display: block;
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
.option-controls a, .block-controls a {
|
||||
margin: 0 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.option-controls a i, .block-controls a i {
|
||||
font-size: larger;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.accordion-button div {
|
||||
margin: 0;
|
||||
position: relative;
|
||||
top: 50%;
|
||||
transform: translate(0, -50%);
|
||||
}
|
||||
|
||||
.accordion-button::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.accordion-error {
|
||||
background-color: #bb2d3b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.accordion-error:not(.collapsed) {
|
||||
background-color: #bb2d3b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.panel-button {
|
||||
padding: 6px;
|
||||
margin: 0px 2px;
|
||||
}
|
||||
|
||||
.panel-button i {
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
.editor-panel, .info-panel {
|
||||
margin: 30pt auto;
|
||||
}
|
||||
|
||||
.info-panel, .viewer-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
width:fit-content;
|
||||
}
|
||||
|
||||
#alert-box {
|
||||
margin: 30px auto;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.block {
|
||||
border: 2px solid black;
|
||||
border-radius: 10px;
|
||||
margin: 10px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.question-body, .question-block {
|
||||
padding: 0px 2em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding: 0px 2em;
|
||||
font-style: italic;
|
||||
}
|
260
ref-test/app/editor/static/css/style.css
Normal file
@ -0,0 +1,260 @@
|
||||
body {
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
background-color: lightgray;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.site-footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
padding-top: 40px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.form-display {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 15px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.form-heading {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-label-group {
|
||||
position: relative;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-label-group input,
|
||||
.form-label-group label {
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
font-size: 16pt;
|
||||
}
|
||||
|
||||
.form-label-group label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0; /* Override default `<label>` margin */
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
cursor: text; /* Match the input under the label */
|
||||
border: 1px solid transparent;
|
||||
border-radius: .25rem;
|
||||
transition: all .1s ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.form-label-group input {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 0%;
|
||||
border-bottom: 2px solid #585858;
|
||||
}
|
||||
|
||||
.form-label-group input:active, .form-label-group input:focus {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-webkit-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input:-ms-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-ms-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-moz-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input:not(:placeholder-shown) {
|
||||
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
|
||||
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||
}
|
||||
|
||||
.form-label-group input:not(:placeholder-shown) ~ label {
|
||||
padding-top: calc(var(--input-padding-y) / 3);
|
||||
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.form-check {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
transform: scale(1.5);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.signin-forgot-password {
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
.form-submission-button {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-submission-button button, .form-submission-button a {
|
||||
margin: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.form-submission-button button span, .form-submission-button button svg, .form-submission-button a span, .form-submission-button a svg {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
table.dataTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dataTables_wrapper .dt-buttons {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
float:none;
|
||||
text-align:center;
|
||||
}
|
||||
|
||||
.row-actions button, .row-actions a {
|
||||
margin: 0px 5px;
|
||||
}
|
||||
|
||||
#cookie-alert {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
#dismiss-cookie-alert {
|
||||
margin-top: 16px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.alert-db-empty {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
font-size: 14pt;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
.form-date-input, .form-select-input {
|
||||
position: relative;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.form-date-input input,
|
||||
.form-date-input label, .form-select-input select, .form-select-input label {
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
font-size: 16pt;
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid #585858;
|
||||
}
|
||||
|
||||
.datepicker::-webkit-calendar-picker-indicator {
|
||||
border: 1px;
|
||||
border-color: gray;
|
||||
border-radius: 10%;
|
||||
}
|
||||
|
||||
.form-date-input label, .form-select-input label {
|
||||
/* position: absolute; */
|
||||
/* top: 0;
|
||||
left: 0; */
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0; /* Override default `<label>` margin */
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
cursor: text; /* Match the input under the label */
|
||||
border: 1px solid transparent;
|
||||
border-radius: .25rem;
|
||||
transition: all .1s ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.form-upload {
|
||||
margin: 2rem 0;
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
.result-action-buttons, .test-action {
|
||||
margin: 5px auto;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.accordion-item {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
/* Change Autocomplete styles in Chrome*/
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
textarea:-webkit-autofill,
|
||||
textarea:-webkit-autofill:hover,
|
||||
textarea:-webkit-autofill:focus,
|
||||
select:-webkit-autofill,
|
||||
select:-webkit-autofill:hover,
|
||||
select:-webkit-autofill:focus {
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
/* Fallback for Edge
|
||||
-------------------------------------------------- */
|
||||
@supports (-ms-ime-align: auto) {
|
||||
.form-label-group label {
|
||||
display: none;
|
||||
}
|
||||
.form-label-group input::-ms-input-placeholder {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback for IE
|
||||
-------------------------------------------------- */
|
||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||
.form-label-group label {
|
||||
display: none;
|
||||
}
|
||||
.form-label-group input:-ms-input-placeholder {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
642
ref-test/app/editor/static/js/editor.js
Normal file
@ -0,0 +1,642 @@
|
||||
// Variable Declarations
|
||||
const $root = $('#editor-root')
|
||||
const target = $root.data('target')
|
||||
const id = $root.data('id')
|
||||
|
||||
const $control_panel = $('.control-panel')
|
||||
const $info_panel = $('.info-panel')
|
||||
const $viewer_panel = $('.viewer-panel')
|
||||
const $editor_panel = $('.editor-panel')
|
||||
|
||||
var toggle_info = false
|
||||
var toggle_viewer = false
|
||||
|
||||
var element_index = 0
|
||||
|
||||
// Initialise Sortable and trigger renumbering on end of drag
|
||||
Sortable.create($root.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
|
||||
|
||||
// Info and Viewer Button Listener
|
||||
$control_panel.find('button').click(function(event){
|
||||
var action = $(this).data('action');
|
||||
|
||||
if (action == 'info') {
|
||||
if ($info_panel.is(":hidden")) {
|
||||
if ($viewer_panel.is(":visible")) {
|
||||
toggle_viewer = true
|
||||
$viewer_panel.hide()
|
||||
}
|
||||
$editor_panel.hide()
|
||||
$info_panel.fadeIn()
|
||||
$(window).scrollTop(0)
|
||||
toggle_info = false
|
||||
$(this).addClass('active')
|
||||
} else {
|
||||
$info_panel.hide()
|
||||
if (toggle_viewer) {
|
||||
render_viewer()
|
||||
$(window).scrollTop(0)
|
||||
toggle_viewer = false
|
||||
} else {
|
||||
$editor_panel.fadeIn()
|
||||
$(window).scrollTop(0)
|
||||
}
|
||||
$(this).removeClass('active')
|
||||
}
|
||||
} else if (action == 'view') {
|
||||
if ($viewer_panel.is(":hidden")) {
|
||||
if ($info_panel.is(':visible')) {
|
||||
toggle_info = true
|
||||
$info_panel.hide()
|
||||
}
|
||||
$editor_panel.hide()
|
||||
render_viewer()
|
||||
$(window).scrollTop(0)
|
||||
toggle_viewer = false
|
||||
$(this).addClass('active')
|
||||
} else {
|
||||
$viewer_panel.hide()
|
||||
if (toggle_info) {
|
||||
$info_panel.fadeIn()
|
||||
$(window).scrollTop(0)
|
||||
toggle_info = false
|
||||
} else {
|
||||
$editor_panel.fadeIn()
|
||||
$(window).scrollTop(0)
|
||||
}
|
||||
$(this).removeClass('active')
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
// Control Button Listeners
|
||||
$root.on('click', '.block-controls > a', function(event){
|
||||
event.preventDefault()
|
||||
var action = $(this).data('action')
|
||||
var root_accordion = $(this).closest('div').siblings('.accordion')
|
||||
if (action == 'add-question') {
|
||||
var question = generate_single_question(root_container_id=`#${root_accordion.attr('id')}`)
|
||||
$(question).appendTo(root_accordion).hide().fadeIn()
|
||||
if (root_accordion.children().length > 1 ) {
|
||||
root_accordion.find('.panel-controls > a[data-action="delete"]').removeClass('disabled')
|
||||
} else {
|
||||
root_accordion.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
|
||||
}
|
||||
renumber_blocks()
|
||||
}
|
||||
})
|
||||
|
||||
$root.on('click', '.panel-controls > a', function(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
var action = $(this).data('action')
|
||||
var element = $(this).closest('.accordion-item')
|
||||
var root_container = $(this).closest('.accordion')
|
||||
if (action == 'delete') {
|
||||
element.fadeOut(function(){
|
||||
$(this).remove()
|
||||
renumber_blocks()
|
||||
if (root_container.get(0) != $root.get(0) && root_container.children().length < 2 ) {
|
||||
root_container.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
|
||||
}
|
||||
})
|
||||
} else if (action == 'add-question') {
|
||||
var question = generate_single_question(root_container_id=`#${root_container.attr('id')}`)
|
||||
$(question).insertBefore(element).hide().fadeIn()
|
||||
if (root_container.get(0) != $root.get(0) && root_container.children().length > 1 ) {
|
||||
root_container.find('.panel-controls > a[data-action="delete"]').removeClass('disabled')
|
||||
}
|
||||
} else if (action == 'add-block') {
|
||||
var block = generate_block(root_container_id=`#${root_container.attr('id')}`)
|
||||
$(block).insertBefore(element).hide().fadeIn()
|
||||
var block_container = $(`#element${element_index-1}`).children().find('.accordion')
|
||||
Sortable.create(block_container.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
|
||||
var question = generate_single_question(root_container_id=`#${block_container.attr('id')}`)
|
||||
block_container.append(question)
|
||||
block_container.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
|
||||
}
|
||||
renumber_blocks()
|
||||
})
|
||||
|
||||
$root.on('click', '.option-controls > a', function(event) {
|
||||
event.preventDefault()
|
||||
var action = $(this).data('action')
|
||||
var options = $(this).closest('div.option-controls').siblings('.options')
|
||||
var length = options.children().length
|
||||
var correct = $(this).closest('div.option-controls').siblings().find('.question-correct')
|
||||
if (action == 'delete') {
|
||||
if (length > 2) {
|
||||
options.children().last().fadeOut(function(){
|
||||
$(this).remove()
|
||||
length = options.children().length
|
||||
if (length <= 2) {
|
||||
options.siblings('div.option-controls').children('a[data-action="delete"]').addClass('disabled')
|
||||
} else {
|
||||
options.siblings('div.option-controls').children('a[data-action="delete"]').removeClass('disabled')
|
||||
}
|
||||
})
|
||||
correct.children().last().fadeOut(function(){
|
||||
$(this).remove()
|
||||
})
|
||||
}
|
||||
} else {
|
||||
var opt = `
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">${length}</span>
|
||||
<input type="text" class="form-control" value="Option ${length}">
|
||||
</div>
|
||||
`
|
||||
$(opt).appendTo(options).hide().fadeIn()
|
||||
var cor = `<option value="${length}">${length}</option>`
|
||||
correct.append(cor)
|
||||
}
|
||||
length = options.children().length
|
||||
if (length <= 2) {
|
||||
$(this).closest('div.option-controls').children('a[data-action="delete"]').addClass('disabled')
|
||||
} else {
|
||||
$(this).closest('div.option-controls').children('a[data-action="delete"]').removeClass('disabled')
|
||||
}
|
||||
})
|
||||
|
||||
$('.editor-controls > a').click(function(event){
|
||||
event.preventDefault()
|
||||
var action = $(this).data('action')
|
||||
var root_accordion = $(this).closest('div').siblings('.accordion')
|
||||
if (action == 'add-question') {
|
||||
var obj = generate_single_question(root_container_id=`#${root_accordion.attr('id')}`)
|
||||
$(obj).appendTo($root).hide().fadeIn()
|
||||
} else if (action == 'add-block') {
|
||||
var obj = generate_block(root_container_id=`#${root_accordion.attr('id')}`)
|
||||
$(obj).appendTo($root).hide().fadeIn()
|
||||
var block_container = $(`#element${element_index-1}`).children().find('.accordion')
|
||||
Sortable.create(block_container.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
|
||||
var question = generate_single_question(root_container_id=`#${block_container.attr('id')}`)
|
||||
block_container.append(question)
|
||||
block_container.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
|
||||
} else if (action == 'discard') {
|
||||
window.location.href = '/admin/settings/questions/'
|
||||
} else if (action == 'delete') {
|
||||
$.ajax({
|
||||
url: '/admin/settings/questions/delete/',
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
'id': id,
|
||||
'action': action
|
||||
}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
window.location.href = '/admin/settings/questions/'
|
||||
},
|
||||
error: function(response) {
|
||||
error_response(response)
|
||||
}
|
||||
})
|
||||
} else if (action == 'save') {
|
||||
var input = parse_input()
|
||||
var def = $('.dataset-default').is(':checked')
|
||||
var name = $('.dataset-name').val()
|
||||
var creator = $('.dataset-creator').val()
|
||||
console.log([def, name, creator])
|
||||
$.ajax({
|
||||
url: target,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
'id': id,
|
||||
'action': 'upload',
|
||||
'data': input,
|
||||
'default': def,
|
||||
'name': name,
|
||||
'creator': creator
|
||||
}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
window.location.href = '/admin/settings/questions/'
|
||||
},
|
||||
error: function(response) {
|
||||
error_response(response)
|
||||
}
|
||||
})
|
||||
}
|
||||
renumber_blocks()
|
||||
})
|
||||
|
||||
// Question Type Select Menu Listener
|
||||
$root.on('change', '.form-select.question-type', function(event) {
|
||||
event.preventDefault()
|
||||
var type = $(this).val()
|
||||
var options = $(this).closest('div.input-group').siblings('.options')
|
||||
var option_controls = $(this).closest('div.input-group').siblings('.option-controls')
|
||||
var correct = $(this).closest('div.input-group').siblings().find('.question-correct')
|
||||
if (type == 'Yes/No') {
|
||||
options.empty()
|
||||
correct.empty()
|
||||
var opt = `
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">0</span>
|
||||
<input type="text" class="form-control" value="Yes" disabled>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">1</span>
|
||||
<input type="text" class="form-control" value="No" disabled>
|
||||
</div>
|
||||
`
|
||||
$(opt).appendTo(options).hide().fadeIn()
|
||||
option_controls.children('a').addClass('disabled')
|
||||
var cor = `
|
||||
<option value ="0" default>0</option>
|
||||
<option value="1">1</option>
|
||||
`
|
||||
correct.append(cor)
|
||||
} else {
|
||||
option_controls.children('a').removeClass('disabled')
|
||||
options.find('input').removeAttr('disabled')
|
||||
if (options.children().length <= 2 ){
|
||||
option_controls.children('a[data-action="delete"]').addClass('disabled')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Data and Rendering Functions
|
||||
function renumber_blocks () {
|
||||
$( ".block-number" ).each(function(index) {
|
||||
$( this ).text($( this ).closest('.accordion-item').index() + 1)
|
||||
})
|
||||
}
|
||||
|
||||
function parse_input() {
|
||||
var data = []
|
||||
var element = {}
|
||||
var question = {}
|
||||
var block_container
|
||||
var q_no = 0
|
||||
$root.children().each(function(index) {
|
||||
element = {}
|
||||
if ($(this).data('type') == 'block') {
|
||||
element['type'] = 'block'
|
||||
element['question_header'] = $(this).find('.block-header-text').val()
|
||||
element['questions'] = []
|
||||
block_container = $(this).children().find('.accordion')
|
||||
block_container.children().each(function(index) {
|
||||
question = {}
|
||||
question['q_no'] = q_no
|
||||
question['text'] = $(this).find('.question-text').val()
|
||||
question['q_type'] = $(this).find('.question-type').val()
|
||||
question['correct'] = parseInt($(this).find('.question-correct').val())
|
||||
question['options'] = []
|
||||
$(this).find('.options').find('input').each(function(index) {
|
||||
question['options'].push($(this).val())
|
||||
})
|
||||
question['tags'] = $(this).find('.question-tags').val().split('\r\n')
|
||||
element['questions'].push(question)
|
||||
q_no ++
|
||||
})
|
||||
} else if ( $(this).data('type') == 'question') {
|
||||
element['type'] = 'question'
|
||||
element['q_no'] = q_no
|
||||
element['text'] = $(this).find('.question-text').val()
|
||||
element['q_type'] = $(this).find('.question-type').val()
|
||||
element['correct'] = parseInt($(this).find('.question-correct').val())
|
||||
element['options'] = []
|
||||
$(this).find('.options').find('input').each(function(index) {
|
||||
element['options'].push($(this).val())
|
||||
})
|
||||
element['tags'] = $(this).find('.question-tags').val().split('\r\n')
|
||||
q_no ++
|
||||
}
|
||||
data.push(element)
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
function parse_data(data) {
|
||||
var block, obj, new_block, block_container, question, _question, new_question, options, correct, opt, tags
|
||||
for (let c = 0; c < data.length; c++) {
|
||||
block = data[c]
|
||||
if (block['type'] == 'block') {
|
||||
obj = generate_block(root_container_id=`#${$root.attr('id')}`)
|
||||
$root.append(obj)
|
||||
new_block = $(`#element${element_index-1}`)
|
||||
new_block.find('.block-header-text').val(block['question_header']).trigger('change')
|
||||
block_container = $(`#element${element_index-1}`).children().find('.accordion')
|
||||
Sortable.create(block_container.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
|
||||
for (let _c = 0; _c < block['questions'].length; _c ++) {
|
||||
question = block['questions'][_c]
|
||||
_question = generate_single_question(root_container_id=`#${block_container.attr('id')}`)
|
||||
block_container.append(_question)
|
||||
if (block_container.children().length <= 1) {
|
||||
block_container.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
|
||||
} else {
|
||||
block_container.find('.panel-controls > a[data-action="delete"]').removeClass('disabled')
|
||||
}
|
||||
new_question = $(`#element${element_index-1}`)
|
||||
new_question.find('.question-text').val(question['text']).trigger('change')
|
||||
new_question.find('.question-type').val(question['q_type']).trigger('change')
|
||||
correct = new_question.find('.question-correct')
|
||||
if (question['q_type'] != 'Yes/No') {
|
||||
options = new_question.find('.options')
|
||||
options.empty()
|
||||
correct.empty()
|
||||
for ( var __c = 0; __c < question['options'].length; __c++) {
|
||||
option = question['options'][__c]
|
||||
opt = `
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">${__c}</span>
|
||||
<input type="text" class="form-control" value="${option}">
|
||||
</div>
|
||||
`
|
||||
options.append(opt)
|
||||
correct.append(`<option value="${__c}">${__c}</option>`)
|
||||
}
|
||||
}
|
||||
correct.val(String(question['correct']))
|
||||
tags = question['tags'].join('\r\n')
|
||||
new_question.find('.question-tags').val(tags)
|
||||
}
|
||||
} else {
|
||||
question = block
|
||||
obj = generate_single_question(root_container_id=`#${$root.attr('id')}`)
|
||||
$root.append(obj)
|
||||
new_question = $(`#element${element_index-1}`)
|
||||
new_question.find('.question-text').val(question['text']).trigger('change')
|
||||
new_question.find('.question-type').val(question['q_type']).trigger('change')
|
||||
correct = new_question.find('.question-correct')
|
||||
if (question['q_type'] != 'Yes/No') {
|
||||
options = new_question.find('.options')
|
||||
options.empty()
|
||||
correct.empty()
|
||||
for ( var _c = 0; _c < question['options'].length; _c++) {
|
||||
option = question['options'][_c]
|
||||
opt = `
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">${_c}</span>
|
||||
<input type="text" class="form-control" value="${option}">
|
||||
</div>
|
||||
`
|
||||
options.append(opt)
|
||||
correct.append(`<option value="${_c}">${_c}</option>`)
|
||||
}
|
||||
}
|
||||
correct.val(String(question['correct']))
|
||||
tags = question['tags'].join('\r\n')
|
||||
new_question.find('.question-tags').val(tags)
|
||||
}
|
||||
}
|
||||
renumber_blocks()
|
||||
}
|
||||
|
||||
// Content Generator Functions
|
||||
function generate_single_question(root_container_id) {
|
||||
if (root_container_id == `#${$root.attr('id')}`) {
|
||||
var block_button = `
|
||||
<a href="javascript:void(0)" class="btn btn-primary" data-action="add-block" title="Add Block Above" aria-title="Add Block Above" data-bs-toggle="collapse" data-bs-target>
|
||||
<i class="bi bi-folder-plus"></i>
|
||||
</a>
|
||||
`
|
||||
} else {
|
||||
var block_button = ''
|
||||
}
|
||||
var question = `
|
||||
<div class="accordion-item" id="element${element_index}" data-type="question">
|
||||
<h2 class="accordion-header" id="element${element_index}-header">
|
||||
<div class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#element${element_index}-content" aria-expanded="true" aria-controls="element${element_index}-content">
|
||||
<div class="float-start">
|
||||
<div class="accordion-caption">
|
||||
<span class="block-number"></span>.
|
||||
Question
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-controls float-end">
|
||||
<a href="javascript:void(0)" class="btn btn-success move-handle" data-action="move-question" title="Move Question" aria-title="Move Question" data-bs-toggle="collapse" data-bs-target>
|
||||
<i class="bi bi-arrows-move"></i>
|
||||
</a>
|
||||
${block_button}
|
||||
<a href="javascript:void(0)" class="btn btn-primary" data-action="add-question" title="Add Question Above" aria-title="Add Question Above" data-bs-toggle="collapse" data-bs-target>
|
||||
<i class="bi bi-file-plus-fill"></i>
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="btn btn-danger" data-action="delete" title="Delete Block" aria-title="Delete Block" data-bs-toggle="collapse" data-bs-target>
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div id="element${element_index}-content" class="accordion-collapse collapse" aria-labelledby="element${element_index}-header" data-bs-parent="${root_container_id}">
|
||||
<div class="accordion-body">
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Question</span>
|
||||
<textarea type="text" class="form-control question-text">Enter question here.</textarea>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Question Type</span>
|
||||
<select class="form-select question-type">
|
||||
<option value ="Multiple Choice" default>Multiple Choice</option>
|
||||
<option value="Yes/No">Yes/No</option>
|
||||
<option value="List">Ordered List</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="form-label">Options</label>
|
||||
<ul class="options">
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">0</span>
|
||||
<input type="text" class="form-control" value="Option 0">
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">1</span>
|
||||
<input type="text" class="form-control" value="Option 1">
|
||||
</div>
|
||||
</ul>
|
||||
<div class="option-controls">
|
||||
<a href="javascript:void(0)" class="btn btn-danger disabled" data-action="delete" title="Delete Question" aria-title="Delete Question">
|
||||
<i class="bi bi-patch-minus-fill"></i>
|
||||
Delete
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="btn btn-success" data-action="add" title="Add Question" aria-title="Add Question">
|
||||
<i class="bi bi-patch-plus-fill"></i>
|
||||
Add
|
||||
</a>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Correct</span>
|
||||
<select class="form-select question-correct">
|
||||
<option value ="0" default>0</option>
|
||||
<option value="1">1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Tags</span>
|
||||
<textarea type="text" class="form-control question-tags"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
element_index ++
|
||||
return question
|
||||
}
|
||||
|
||||
function generate_block(root_container_id) {
|
||||
var block = `
|
||||
<div class="accordion-item" id="element${element_index}" data-type="block">
|
||||
<h2 class="accordion-header" id="element${element_index}-header">
|
||||
<div class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#element${element_index}-content" aria-expanded="true" aria-controls="element${element_index}-content">
|
||||
<div class="float-start">
|
||||
<div class="accordion-caption">
|
||||
<span class="block-number"></span>.
|
||||
Block
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-controls float-end">
|
||||
<a href="javascript:void(0)" class="btn btn-success move-handle" data-action="move-question" title="Move Question" aria-title="Move Question" data-bs-toggle="collapse" data-bs-target>
|
||||
<i class="bi bi-arrows-move"></i>
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="btn btn-primary" data-action="add-block" title="Add Block Above" aria-title="Add Block Above" data-bs-toggle="collapse" data-bs-target>
|
||||
<i class="bi bi-folder-plus"></i>
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="btn btn-primary" data-action="add-question" title="Add Question Above" aria-title="Add Question Above" data-bs-toggle="collapse" data-bs-target>
|
||||
<i class="bi bi-file-plus-fill"></i>
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="btn btn-danger" data-action="delete" title="Delete Block" aria-title="Delete Block" data-bs-toggle="collapse" data-bs-target>
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div id="element${element_index}-content" class="accordion-collapse collapse" aria-labelledby="element${element_index}-header" data-bs-parent="${root_container_id}">
|
||||
<div class="accordion-body">
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Block Header</span>
|
||||
<textarea type="text" class="form-control block-header-text">Enter the header text for this block of questions.</textarea>
|
||||
</div>
|
||||
<div class="accordion" id="element${element_index}-questions">
|
||||
</div>
|
||||
<div class="block-controls">
|
||||
<a href="javascript:void(0)" class="btn btn-success" data-action="add-question" title="Add Question" aria-title="Add Question">
|
||||
<i class="bi bi-file-plus-fill"></i>
|
||||
Add Question
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
element_index ++
|
||||
return block
|
||||
}
|
||||
|
||||
// Fetch data once page finishes loading
|
||||
$(window).on('load', function() {
|
||||
$.ajax({
|
||||
url: target,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
'id': id,
|
||||
'action': 'fetch'
|
||||
}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
parse_data(response['data'])
|
||||
},
|
||||
error: function(response) {
|
||||
console.log(response)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Viewer Render Function
|
||||
function render_viewer() {
|
||||
$viewer_panel.fadeIn()
|
||||
$viewer_panel.empty()
|
||||
var heading = document.createElement('h3')
|
||||
heading.innerText = 'View Questions'
|
||||
$viewer_panel.append(heading)
|
||||
var data = parse_input()
|
||||
var block
|
||||
var obj
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
block = data[i]
|
||||
obj = document.createElement('div')
|
||||
obj.classList = 'block'
|
||||
if (block['type'] == 'question') {
|
||||
text = document.createElement('p')
|
||||
text.innerHTML = `<strong>Question ${block['q_no'] + 1}.</strong> ${block['text']}`
|
||||
obj.append(text)
|
||||
question_body = document.createElement('div')
|
||||
question_body.className ='question-body'
|
||||
type = document.createElement('p')
|
||||
type.innerHTML = `<strong>Question Type:</strong> ${block['q_type']}`
|
||||
question_body.append(type)
|
||||
options = document.createElement('p')
|
||||
options.innerHTML = '<strong>Options:</strong>'
|
||||
option_list = document.createElement('ul')
|
||||
for (let _i = 0; _i < block['options'].length; _i++) {
|
||||
option = document.createElement('li')
|
||||
option.innerHTML = block['options'][_i]
|
||||
if (block['correct'] == _i) {
|
||||
option.innerHTML += ' <span class="badge rounded-pill bg-success">Correct</span>'
|
||||
}
|
||||
option_list.append(option)
|
||||
}
|
||||
options.append(option_list)
|
||||
question_body.append(options)
|
||||
tags = document.createElement('p')
|
||||
tags.innerHTML = `<strong>Tags:</strong>`
|
||||
tag_list = document.createElement('ul')
|
||||
for (let _i = 0; _i < block['tags'].length; _i++) {
|
||||
tag = document.createElement('li')
|
||||
tag.innerHTML = block['tags'][_i]
|
||||
tag_list.append(tag)
|
||||
}
|
||||
tags.append(tag_list)
|
||||
question_body.append(tags)
|
||||
obj.append(question_body)
|
||||
} else if (block['type'] == 'block') {
|
||||
meta = document.createElement('p')
|
||||
meta.innerHTML = `<strong>Block ${i+1}.</strong> ${block['questions'].length} questions.`
|
||||
obj.append(meta)
|
||||
header = document.createElement('blockquote')
|
||||
header.innerText = block['question_header']
|
||||
obj.append(header)
|
||||
var block_question = document.createElement('div')
|
||||
var question
|
||||
block_question.className = 'question-block'
|
||||
for (let _i = 0; _i < block['questions'].length; _i++) {
|
||||
question = block['questions'][_i]
|
||||
text = document.createElement('p')
|
||||
text.innerHTML = `<strong>Question ${question['q_no'] + 1}.</strong> ${question['text']}`
|
||||
block_question.append(text)
|
||||
question_body = document.createElement('div')
|
||||
question_body.className ='question-body'
|
||||
type = document.createElement('p')
|
||||
type.innerHTML = `<strong>Question Type:</strong> ${question['q_type']}`
|
||||
question_body.append(type)
|
||||
options = document.createElement('p')
|
||||
options.innerHTML = '<strong>Options:</strong>'
|
||||
option_list = document.createElement('ul')
|
||||
for (let __i = 0; __i < question['options'].length; __i++) {
|
||||
option = document.createElement('li')
|
||||
option.innerHTML = question['options'][__i]
|
||||
if (question['correct'] == __i) {
|
||||
option.innerHTML += ' <span class="badge rounded-pill bg-success">Correct</span>'
|
||||
}
|
||||
option_list.append(option)
|
||||
}
|
||||
options.append(option_list)
|
||||
question_body.append(options)
|
||||
tags = document.createElement('p')
|
||||
tags.innerHTML = `<strong>Tags:</strong>`
|
||||
tag_list = document.createElement('ul')
|
||||
for (let __i = 0; __i < question['tags'].length; __i++) {
|
||||
tag = document.createElement('li')
|
||||
tag.innerHTML = question['tags'][__i]
|
||||
tag_list.append(tag)
|
||||
}
|
||||
tags.append(tag_list)
|
||||
question_body.append(tags)
|
||||
block_question.append(question_body)
|
||||
obj.append(block_question)
|
||||
}
|
||||
}
|
||||
$viewer_panel.append(obj)
|
||||
}
|
||||
}
|
2
ref-test/app/editor/static/js/jquery-3.6.0.min.js
vendored
Normal file
115
ref-test/app/editor/static/js/script.js
Normal file
@ -0,0 +1,115 @@
|
||||
// Menu Highlight Scripts
|
||||
const menuItems = document.getElementsByClassName('nav-link');
|
||||
for(let i = 0; i < menuItems.length; i++) {
|
||||
if(menuItems[i].pathname == window.location.pathname) {
|
||||
menuItems[i].classList.add('active');
|
||||
}
|
||||
}
|
||||
const dropdownItems = document.getElementsByClassName('dropdown-item');
|
||||
for(let i = 0; i< dropdownItems.length; i++) {
|
||||
if(dropdownItems[i].pathname == window.location.pathname) {
|
||||
dropdownItems[i].classList.add('active');
|
||||
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active');
|
||||
}
|
||||
}
|
||||
|
||||
// General Post Method Form Processing Script
|
||||
$('form.form-post').submit(function(event) {
|
||||
|
||||
var $form = $(this);
|
||||
var data = $form.serialize();
|
||||
var url = $(this).prop('action');
|
||||
var rel_success = $(this).data('rel-success');
|
||||
|
||||
$.ajax({
|
||||
url: url,
|
||||
type: 'POST',
|
||||
data: data,
|
||||
dataType: 'json',
|
||||
success: function(response) {
|
||||
if (response.redirect_to) {
|
||||
window.location.href = response.redirect_to;
|
||||
}
|
||||
else {
|
||||
window.location.href = rel_success;
|
||||
}
|
||||
},
|
||||
error: function(response) {
|
||||
error_response(response);
|
||||
}
|
||||
});
|
||||
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
function error_response(response) {
|
||||
|
||||
const $alert = $("#alert-box");
|
||||
$alert.html('');
|
||||
|
||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
||||
$alert.html(`
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||
${response.responseJSON.error}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`);
|
||||
} else if (response.responseJSON.error instanceof Array) {
|
||||
var output = ''
|
||||
for (let i = 0; i < response.responseJSON.error.length; i ++) {
|
||||
output += `
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||
${response.responseJSON.error[i]}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`;
|
||||
$alert.html(output);
|
||||
}
|
||||
}
|
||||
|
||||
$alert.focus()
|
||||
}
|
||||
|
||||
// Dismiss Cookie Alert
|
||||
$('#dismiss-cookie-alert').click(function(event){
|
||||
|
||||
$.ajax({
|
||||
url: '/cookies/',
|
||||
type: 'POST',
|
||||
data: {
|
||||
time: Date.now()
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response){
|
||||
console.log(response);
|
||||
},
|
||||
error: function(response){
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
|
||||
event.preventDefault();
|
||||
})
|
||||
|
||||
// Create New Dataset
|
||||
$('.create-new-dataset').click(function(event){
|
||||
$.ajax({
|
||||
url: '/api/editor/new/',
|
||||
type: 'POST',
|
||||
data: {
|
||||
time: Date.now()
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response){
|
||||
if (response.redirect_to) {
|
||||
window.location.href = response.redirect_to;
|
||||
}
|
||||
},
|
||||
error: function(response){
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
event.preventDefault()
|
||||
})
|
81
ref-test/app/editor/templates/editor/components/base.html
Normal file
@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
|
||||
crossorigin="anonymous">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('.static', filename='css/style.css') }}"
|
||||
/>
|
||||
{% block style %}
|
||||
{% endblock %}
|
||||
<title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title>
|
||||
{% include "editor/components/og-meta.html" %}
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
||||
{% block navbar %}
|
||||
{% include "editor/components/navbar.html" %}
|
||||
{% endblock %}
|
||||
|
||||
<div class="container">
|
||||
{% block top_alerts %}
|
||||
{% include "editor/components/server-alerts.html" %}
|
||||
{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer class="container site-footer mt-5">
|
||||
{% block footer %}
|
||||
{% include "editor/components/footer.html" %}
|
||||
{% endblock %}
|
||||
</footer>
|
||||
|
||||
<!-- JQuery, Popper, and Bootstrap js dependencies -->
|
||||
<script
|
||||
src="https://code.jquery.com/jquery-3.6.0.min.js"
|
||||
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script>
|
||||
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
||||
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
|
||||
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<!-- Custom js -->
|
||||
<script type="text/javascript">
|
||||
var csrf_token = "{{ csrf_token() }}";
|
||||
|
||||
$.ajaxSetup({
|
||||
beforeSend: function(xhr, settings) {
|
||||
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
|
||||
xhr.setRequestHeader("X-CSRFToken", csrf_token);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="{{ url_for('.static', filename='js/script.js') }}"
|
||||
></script>
|
||||
{% block script %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1 @@
|
||||
<div id="alert-box" tabindex="-1"></div>
|
@ -0,0 +1,28 @@
|
||||
{% extends "editor/components/base.html" %}
|
||||
{% block datatable_css %}
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.11.3/css/dataTables.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/buttons/2.0.1/css/buttons.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/colreorder/1.5.5/css/colReorder.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/fixedheader/3.2.0/css/fixedHeader.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/keytable/2.6.4/css/keyTable.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/searchbuilder/1.3.0/css/searchBuilder.dataTables.min.css"/>
|
||||
{% endblock %}
|
||||
{% block datatable_scripts %}
|
||||
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jszip/2.5.0/jszip.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/pdfmake.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/vfs_fonts.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/jquery.dataTables.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/dataTables.bootstrap5.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/dataTables.buttons.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.bootstrap5.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.colVis.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.html5.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.print.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/colreorder/1.5.5/js/dataTables.colReorder.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/fixedheader/3.2.0/js/dataTables.fixedHeader.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/keytable/2.6.4/js/dataTables.keyTable.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/responsive.bootstrap5.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/searchbuilder/1.3.0/js/dataTables.searchBuilder.min.js"></script>
|
||||
{% endblock %}
|
@ -0,0 +1,2 @@
|
||||
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
@ -0,0 +1,4 @@
|
||||
{% extends "admin/components/base.html" %}
|
||||
{% import "bootstrap/wtf.html" as wtf %}
|
||||
{% block top_alerts %}
|
||||
{% endblock %}
|
137
ref-test/app/editor/templates/editor/components/navbar.html
Normal file
@ -0,0 +1,137 @@
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbar"
|
||||
aria-controls="navbar"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle Navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navbar">
|
||||
<ul class="navbar-nav">
|
||||
{% if not current_user.is_authenticated %}
|
||||
<li class="nav-item" id="nav-login">
|
||||
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item dropdown" id="nav-results">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-results"
|
||||
role="button"
|
||||
href="{{ url_for('admin._view_entries') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Results
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-results"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-tests">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-tests"
|
||||
role="button"
|
||||
href="{{ url_for('admin._tests') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Exams
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-tests"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-settings">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-settings"
|
||||
role="button"
|
||||
href="{{ url_for('admin._settings') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-settings"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-account">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
role="button"
|
||||
href="{{ url_for('admin._update_user', id=current_user.id) }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Account
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-account"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
18
ref-test/app/editor/templates/editor/components/og-meta.html
Normal file
@ -0,0 +1,18 @@
|
||||
<meta name="description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta property="og:locale" content="en_UK" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta property="og:url" content="{{ url_for(request.endpoint, _external = True, **(request.view_args or {})) }}" />
|
||||
<meta property="og:site_name" content="Scottish Korfball Association Referee Theory Exam" />
|
||||
<meta property="og:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
|
||||
<meta property="og:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||
<meta property="og:image:width" content="512" />
|
||||
<meta property="og:image:height" content="512" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta name="twitter:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
|
||||
<meta name="twitter:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||
<meta name="twitter:creator" content="@viveksantayana" />
|
||||
<meta name="twitter:site" content="@viveksantayana" />
|
||||
<meta name="theme-color" content="#343a40" />
|
||||
<link rel="shortcut icon" href="{{ url_for('.static', filename='favicon.ico') }}">
|
@ -0,0 +1,23 @@
|
||||
<div class="navbar navbar-expand-sm navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<div class="expand navbar-expand justify-content-center" id="navbar_secondary">
|
||||
<ul class="nav nav-pills">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='active') }}">Active</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='scheduled') }}">Scheduled</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='expired') }}">Expired</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='all') }}">All</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='create') }}">Create</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,43 @@
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% set cookie_flash_flag = namespace(value=False) %}
|
||||
{% for category, message in messages %}
|
||||
{% if category == "error" %}
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Error" aria-title="Error"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% elif category == "success" %}
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-check2-circle" title="Success" aria-title="Success"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% elif category == "warning" %}
|
||||
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-info-circle-fill" aria-title="Warning" title="Warning"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% elif category == "cookie_alert" %}
|
||||
{% if not cookie_flash_flag.value %}
|
||||
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
|
||||
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
|
||||
{{ message|safe }}
|
||||
<div class="d-flex justify-content-center w-100">
|
||||
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
|
||||
</div>
|
||||
</div>
|
||||
{% set cookie_flash_flag.value = True %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-info-circle-fill" title="Alert"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
150
ref-test/app/editor/templates/editor/console.html
Normal file
@ -0,0 +1,150 @@
|
||||
{% extends "editor/components/base.html" %}
|
||||
|
||||
{% block style %}
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('.static', filename='css/editor.css') }}"
|
||||
/>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Editor</h1>
|
||||
<div class="container">
|
||||
<p class="lead">
|
||||
Use this console to edit the questions in this dataset. For more information on using the editor console, click on the the blue Information button. To preview the questions in the current dataset, click on the green View Questions button.
|
||||
</p>
|
||||
</div>
|
||||
<div class="container control-panel">
|
||||
<button class="btn btn-primary" aria-title="Information" title="Information" data-action="info"><i class="bi bi-info-circle-fill"></i></button>
|
||||
<button class="btn btn-success" aria-title="View Questions" title="View Questions" data-action="view"><i class="bi bi-book-fill"></i></button>
|
||||
</div>
|
||||
<div class="container info-panel">
|
||||
<h3>
|
||||
About the Editor Console
|
||||
</h3>
|
||||
<p>
|
||||
This console will allow you to edit the question data for the RefTest App.
|
||||
All of the questions will be visually displayed as blocks on the screen that you can minimise, expand, and rearrange.
|
||||
</p>
|
||||
<p>
|
||||
Blocks can be of two types: <strong>Blocks</strong> of multiple related questions, and <strong>Single Questions</strong> that are not part of a block.
|
||||
You can add, remove, or edit both Blockss and Questions through this editor.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Blocks</strong> are useful when you have a section of the test that contains multiple questions that are related to each other, for example if there is a scenario-based section where a series of questions are about the same situation.
|
||||
</p>
|
||||
<p>
|
||||
Blocks can contain any number of questions within them, but cannot contain nested blocks.
|
||||
</p>
|
||||
<p>
|
||||
When you set up a block, you can also add <strong>header text</strong> that will be displayed with each question.
|
||||
You can use this to provide common information for a scenario across a series of questions.
|
||||
</p>
|
||||
<p>
|
||||
Questions come in three types:
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Yes/No</strong> for when there is only a yes or no option,
|
||||
</li>
|
||||
<li>
|
||||
<strong>Multiple Choice</strong> for your regular multiple choice questions, and
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ordered List</strong> for multiple choice questions that will be displayed in the same order as listed here.
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p>
|
||||
Normally, multiple choice questions will have the order of the options randomised.
|
||||
</p>
|
||||
<p>
|
||||
Questions will be displayed to candidates in a randomised order.
|
||||
Blocks of questions will be kept together, but the order within the block will also be randomised.
|
||||
</p>
|
||||
<p><strong>Do not use language that will assume the flow of questions, such as saying ‘the previous question’, or ‘the next question’, etc. because of randomisation.</strong></p>
|
||||
<p>
|
||||
Each option will be referenced by an <strong>index number</strong>.
|
||||
Make sure to select which index number represents the <strong>correct option</strong>.
|
||||
</p>
|
||||
<p>
|
||||
You will also be able to define <strong>tags</strong> for each question.
|
||||
Separate multiple tags in <strong>new lines</strong>.
|
||||
Make sure to keep the spelling, capitalisation, and punctuation for tags consistent.
|
||||
</p>
|
||||
<p class="lead">
|
||||
Placeholder for Questions Remaining in a Block
|
||||
</p>
|
||||
<p>
|
||||
In order to show how many questions are remaining inside a block, e.g. to say ‘the next n questions are about a specific scenario’, use the placeholder <code><block_remaining_questions></code>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="container viewer-panel">
|
||||
</div>
|
||||
<div class="container editor-panel">
|
||||
<h3>
|
||||
Edit Questions
|
||||
</h3>
|
||||
<div class="container dataset-metadata">
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Dataset Name</span>
|
||||
<input type="text" class="form-control dataset-name" value="{{ dataset.get_name() }}">
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Author</span>
|
||||
<select class="form-select dataset-creator">
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {{default if dataset.user == user else None }}>{{ user.get_username() }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Last Updated</span>
|
||||
<span class="form-control">
|
||||
{{ dataset.date.strftime('%d %b %Y %H:%M') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">
|
||||
<input type="checkbox" aria-label="Default" class="dataset-default" {% if dataset.default %}checked{% endif %}>
|
||||
</span>
|
||||
<span class="form-control">
|
||||
Make Dataset the Default
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion" id="editor-root" data-target="{{ url_for('api._editor') }}" data-id="{{ dataset.id }}">
|
||||
</div>
|
||||
{% include "editor/components/client-alerts.html" %}
|
||||
<div class="editor-controls container">
|
||||
<a href="javascript:void(0);" class="btn btn-primary" data-action="add-block" title="Add Block" aria-title="Add Block">
|
||||
<i class="bi bi-folder-plus"></i>
|
||||
Add Block
|
||||
</a>
|
||||
<a href="javascript:void(0);" class="btn btn-primary" data-action="add-question" title="Add Question" aria-title="Add Question">
|
||||
<i class="bi bi-file-plus-fill"></i>
|
||||
Add Question
|
||||
</a>
|
||||
</div>
|
||||
<div class="editor-controls container">
|
||||
<a href="javascript:void(0);" class="btn btn-warning" data-action="discard" title="Discard Changes" aria-title="Discard Changes">
|
||||
<i class="bi bi-x-circle-fill"></i>
|
||||
Discard Changes
|
||||
</a>
|
||||
<a href="javascript:void(0);" class="btn btn-danger {% if datasets <=1 or dataset.default or dataset.tests|length > 0 %}disabled{% endif %}" data-action="delete" title="Delete" aria-title="Delete">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
Delete
|
||||
</a>
|
||||
<a href="javascript:void(0);" class="btn btn-success" data-action="save" title="Save" aria-title="Save">
|
||||
<i class="bi bi-cloud-arrow-up-fill"></i>
|
||||
Save Changes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="{{ url_for('.static', filename='js/editor.js') }}"
|
||||
></script>
|
||||
{% endblock %}
|
31
ref-test/app/editor/templates/editor/index.html
Normal file
@ -0,0 +1,31 @@
|
||||
{% extends "editor/components/input-forms.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-editor" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for(request.endpoint, **request.view_args) }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form">Edit Questions</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form-select-input">
|
||||
{{ form.dataset(placeholder="Select Question Dataset") }}
|
||||
{{ form.dataset.label }}
|
||||
</div>
|
||||
{% include "admin/components/client-alerts.html" %}
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<button class="btn btn-md btn-success btn-block" type="submit">
|
||||
<i class="bi bi-pencil-fill button-icon"></i>
|
||||
Edit
|
||||
</button>
|
||||
<button title="New" class="btn btn-md btn-primary create-new-dataset">
|
||||
<i class="bi bi-cloud-plus-fill button-icon"></i>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
45
ref-test/app/editor/views.py
Normal file
@ -0,0 +1,45 @@
|
||||
from ..forms.admin import EditDataset
|
||||
from ..models import Dataset, User
|
||||
from ..tools.data import check_dataset_exists
|
||||
from ..tools.forms import get_dataset_choices, send_errors_to_client
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask import Blueprint, jsonify, render_template
|
||||
from flask.helpers import abort, flash, redirect, request, url_for
|
||||
from flask_login import login_required
|
||||
|
||||
editor = Blueprint(
|
||||
name='editor',
|
||||
import_name=__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static'
|
||||
)
|
||||
|
||||
@editor.route('/', methods=['GET','POST'])
|
||||
@login_required
|
||||
def _editor():
|
||||
form = EditDataset()
|
||||
form.dataset.choices = get_dataset_choices()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
id = request.form.get('dataset')
|
||||
return jsonify({'success': 'Selected dataset', 'redirect_to': url_for('editor._editor_console', id=id)}),200
|
||||
return send_errors_to_client(form=form)
|
||||
form.process()
|
||||
return render_template('/editor/index.html', form=form)
|
||||
|
||||
@editor.route('/<string:id>/')
|
||||
@check_dataset_exists
|
||||
@login_required
|
||||
def _editor_console(id:str=None):
|
||||
try:
|
||||
dataset = Dataset.query.filter_by(id=id).first()
|
||||
datasets = Dataset.query.count()
|
||||
users = User.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not dataset:
|
||||
flash('Invalid dataset ID.', 'error')
|
||||
return redirect(url_for('admin._questions'))
|
||||
return render_template('/editor/console.html', dataset=dataset, datasets=datasets, users=users)
|