Compare commits
1199 Commits
70671adac8
...
v.0.2
Author | SHA1 | Date | |
---|---|---|---|
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 | |||
27a58acc73 | |||
c466f06384 | |||
ab3312d45c | |||
8d80666ed8 | |||
f867022207 | |||
3d9a3ecdff | |||
e311c66abc | |||
a8e938e802 | |||
ae31b2592e | |||
4c4927df31 | |||
0d8450f667 | |||
f8126b42fe | |||
b151de39df | |||
407ee49bff | |||
e7a82d2437 | |||
b0bb600e12 | |||
94c61f8d8a | |||
0e8fbf148a | |||
46aeee416a | |||
0ef72ec338 | |||
80391229a3 | |||
721af501d1 | |||
07395833e8 | |||
e6f1338ee4 | |||
3781e80fc5 | |||
0e50e2c1b9 | |||
a33ed7a94d | |||
b0980b1871 | |||
23ddf6601b | |||
ea9132542f | |||
19d12226f5 | |||
b7fb30ce36 | |||
1873772167 | |||
fe75fa1a49 | |||
8726d4335c | |||
f86fa6f4b5 | |||
a5f8cba71b | |||
6c293c2ce6 | |||
e1b2bd20f7 | |||
d3ed32183c | |||
957e6f02d6 | |||
e8090f30d7 | |||
3b3656209a | |||
176a0f069f | |||
58f40e221f | |||
302d8a933a | |||
7e9b5eada0 | |||
c5587fcb73 | |||
183aeac9ee | |||
a4b4bfe0ee | |||
c915ae1182 | |||
0faef8651a | |||
973bafcdb2 | |||
4f925eae2f | |||
b821d40e85 | |||
a9f5ba51c4 | |||
d5559c499d | |||
5b0fd0ced3 | |||
fba539f933 | |||
eca786d444 | |||
220a378c63 | |||
affb309ffc | |||
b013ffec47 | |||
0e1db9d21d | |||
e1a90d5f64 | |||
003d998b72 | |||
b84654d931 | |||
dccc85370e | |||
d3f116e1ca | |||
355a6bff5e | |||
9bd1f29aad | |||
98638e803a | |||
72cd18b76f | |||
6c4ab2e1e3 | |||
991313e8b2 | |||
e13069bed6 | |||
e2a0bc7b4e | |||
5b6f83c294 | |||
9a4c2962a5 | |||
7295a2751c | |||
7b2a2ce90c | |||
dd72da6ae6 | |||
54121a4fda | |||
36cdeb15ad | |||
0f1f79e237 | |||
eb6f5b876c | |||
d5f7ab0488 | |||
14500434d7 | |||
091fdbe891 | |||
35dffd358b | |||
cc7712ccec | |||
fafb3fcc2e | |||
ed6360e7a3 | |||
4131dd054a | |||
3b92dc3005 | |||
f370496780 | |||
cc45bf7acd | |||
667ad4ebc2 | |||
4c3805cfe7 | |||
52e3ce4c93 | |||
045f3aec0a | |||
ca0e6c82cb | |||
1f767da365 | |||
860c18c5fd | |||
eeaf676ee6 | |||
46cef8cd1e | |||
f069556afd | |||
421445d8d5 | |||
6d931bdf6c | |||
b0d3ff3fc1 | |||
0cb88390b8 | |||
68aef968e2 | |||
af9801ac24 | |||
8d29944d5d | |||
33314e2bc4 | |||
8fbb52d366 | |||
8749c6e590 | |||
1dbe4215ec | |||
c10c31c4b6 | |||
101f6786f5 | |||
df54ca7ff3 | |||
fe5cf189cc | |||
167bee38d6 | |||
cefb5fe849 | |||
bac083411c | |||
f0c7873257 | |||
6dcef4885a | |||
0cb8ff9991 | |||
536e1fe426 | |||
4d77021d58 | |||
5ab8be93ec | |||
fa05a17508 | |||
920287b7ae | |||
5960d0103d | |||
f44fbb24da | |||
3535622380 | |||
4c5aa66d8e | |||
86abae01c0 | |||
8328f6fb1a | |||
7c2adc9cac | |||
e9a5e72959 | |||
e119c344dd | |||
12480b32a5 | |||
c7b54d2119 | |||
96cca77b2f | |||
e6841b7744 | |||
28f944a6cf | |||
6835232698 | |||
00fb8e13fe | |||
5392ff86ed | |||
1b2b4f8dc6 | |||
328a78a923 | |||
9ea336b5c2 | |||
9810577c5d | |||
2d29e6ac97 | |||
2c93b0d3a7 | |||
c462b76cb7 | |||
343cb3f8b1 | |||
c2d95c1d52 | |||
961e8629cb | |||
673ccbcb9c | |||
378e8eeae3 | |||
28789c72f1 | |||
fe898aaf7d | |||
57e5a21ffa | |||
a010d7d290 | |||
13121b3037 | |||
8b962c53a9 | |||
a5dedc145c | |||
bceb91b406 | |||
b299f4ae55 | |||
a14b7bf305 | |||
e935524552 | |||
3622baf988 | |||
b8a7182f98 | |||
24545feea0 | |||
21641ce21f | |||
bb9233eeae | |||
3035ac6687 | |||
60b8aad419 | |||
8d2a84a071 | |||
6e541c6a7b | |||
a77faa7eed | |||
685b1b928d | |||
abe515b586 | |||
e0c2570515 | |||
b4e7efdbe5 | |||
5163914875 | |||
f61c12afd1 | |||
467b6d9ce7 | |||
8a0e93e3e5 | |||
e5aab6268d | |||
7874d3f99f | |||
383ae11cd3 | |||
37d6d3a003 | |||
348ee95d1c | |||
1b2209d97c | |||
9db80c9148 | |||
2e340cce00 | |||
20b447adbb | |||
8ccd34611e | |||
669bbd2f7b | |||
9cef9819fe | |||
22b483b021 | |||
9e4e874401 | |||
21ad8b2f94 | |||
47f996b19c | |||
a3a13d4eb6 | |||
d0232557bc | |||
a357ffe28d | |||
cdc19e69b8 | |||
e00e2b17b0 | |||
a2e05d39e6 | |||
65d679afbb | |||
72a068c975 | |||
891ec2fd38 | |||
663b976b3b | |||
4be21a2ca2 | |||
cfc62ee21f | |||
efd4dc440d | |||
7150e679c8 | |||
935b465a19 | |||
bce76d808d | |||
05fa5bf274 | |||
20f580d6a6 | |||
1d1e2acf62 | |||
700daa51ef | |||
c742edb57c | |||
cdd35117bf | |||
529504509e | |||
abcd4cbec5 | |||
852b2664ce | |||
2b6b5d8f73 | |||
8b1b0162cc | |||
715c7856fa | |||
56e5d29416 | |||
19521c3f23 | |||
ee50306370 | |||
d7ef628640 | |||
559e5b96c4 | |||
198e2cecb0 | |||
4c2a6e7f74 | |||
4ae587da12 | |||
daaf173ff6 | |||
fa43ab1879 | |||
05de6d716b | |||
a3101503d4 | |||
f740ee7f1b | |||
8df4583ee0 | |||
c56c0dc822 | |||
df94b9b486 | |||
0c446b9ae7 | |||
9a5f073170 | |||
9ebec5000c | |||
700c5ff39d | |||
ce32b33eaa | |||
2641b3e060 | |||
45e0d37f81 | |||
21565592cc | |||
d353a80269 | |||
d2cd8316da | |||
8e7a09edca | |||
c3fb5a9b0c | |||
616bd3f578 | |||
843ed247b6 | |||
108297cbfd | |||
5954c1a68e | |||
9e03db595b | |||
de969e0028 | |||
3bfd08411b | |||
19d16ab7d9 | |||
a4affa72a9 | |||
e7077cd193 | |||
12c424be08 | |||
06aee6fa6d | |||
e00b4a9045 | |||
2472323103 | |||
0ad7089722 | |||
5d334e4da5 | |||
707890ce3a | |||
d4f59769c6 | |||
7bdca9b895 | |||
848f39aa66 | |||
bd1ac46942 | |||
5fad0cda1e | |||
11f965e20f | |||
bfd8c8fa1e | |||
ee99dd9038 | |||
fbe19e43f7 | |||
65ec27b35b | |||
5a3aca732b | |||
63ca5e33de | |||
54653e82f2 | |||
1f228c7f1c | |||
fa290a2713 | |||
56191f5e7a | |||
9a5e24d362 | |||
cbc8d276eb | |||
396400e7c0 | |||
cd68a60001 | |||
cb39592bd3 | |||
dd7e3cad7a | |||
5dbf3be732 | |||
32908bde7d | |||
372333cd84 | |||
835c5e2aa6 | |||
672888c5d9 | |||
6823c12b2d | |||
cda7ac480f | |||
c7907dc24d | |||
8926960a18 | |||
e4d97869da | |||
352c89d69f | |||
dfbf10e2dd | |||
e499d1d54a | |||
dbd25ddf38 | |||
724ffbfdf4 | |||
11d839aada | |||
6d90ec4aa6 | |||
3980be3701 | |||
|
ca1a6efd57 | ||
|
43cb31849a | ||
|
59e7d3d112 | ||
|
39cdafc847 | ||
|
727779f054 | ||
|
bdeb026a7c | ||
|
b244fb34f7 | ||
|
73f4825bbe | ||
|
8675e78082 | ||
|
e1ecb5bcb6 | ||
|
da5e115bbc | ||
|
1651f63577 | ||
|
1edd25d3ea | ||
|
a01d486d99 | ||
|
3c64240842 | ||
|
2b71c77c6c | ||
cc466f4a20 | |||
112c097d69 | |||
79e015191a | |||
b6af6d5c15 | |||
529ac35bdb | |||
6c4ca715f6 | |||
8d6ca515dc | |||
972673f5d1 | |||
37ab26e72a | |||
cb1bc69f47 | |||
7550deab89 | |||
a4058c475b | |||
81b3e3a142 | |||
0004d2714f | |||
8527976007 | |||
20efd4444c | |||
|
039b58709e | ||
|
13465859ab | ||
|
0f3a84b54d | ||
|
53050f1358 | ||
|
c8ccd002fa | ||
|
f025eee4a6 | ||
|
6acf3fa204 | ||
|
506a6cf6c2 | ||
|
3dc7b1f74b | ||
|
97db70abff | ||
|
4d642cc1e9 | ||
|
1a1d763d67 | ||
|
fc765c0177 | ||
|
598dfa45e8 | ||
|
0d8200193d | ||
|
ca36772f29 | ||
eb9ca82cb3 | |||
bd3205f06e | |||
fa9d08b10e | |||
ab7a25182f | |||
720de1f2de | |||
e3bb2895ae | |||
bf64b78acc | |||
3e1e57a067 | |||
cd7005d713 | |||
42f90c667d | |||
5f06c9b624 | |||
b02277f12f | |||
7d90b6c7a2 | |||
a9ad171249 | |||
861b61c1ca | |||
bc42ae86d1 | |||
|
d25dc5ed45 | ||
|
cc3410a1f6 | ||
|
6c048a9c48 | ||
|
953d3658a8 | ||
|
14bc50165b | ||
|
70f6875ac1 | ||
|
4c40240346 | ||
|
5da08d5c37 | ||
|
9d0ae15f74 | ||
|
534247ece3 | ||
|
ba9ed0ca40 | ||
|
9525694e39 | ||
|
bcc9e9c609 | ||
|
31903626f0 | ||
|
c6adac8288 | ||
|
0111547676 | ||
50cff0245f | |||
e70592b276 | |||
b2a71bf51b | |||
22a0d58996 | |||
13b7249f2a | |||
3d6a1dc7ba | |||
bdffce21b7 | |||
51d468fb44 | |||
02e4e0dc1c | |||
164d43be8b | |||
7ae8cba851 | |||
cdf47e0b88 | |||
73c00ac333 | |||
2427d55310 | |||
b0f2d89956 | |||
757cc94f33 | |||
|
7f7a783c8a | ||
|
0cfac25ed3 | ||
|
33e8d8482f | ||
|
0443e348ac | ||
|
a7e3a5fe47 | ||
|
f2c0090aa3 | ||
|
4ff62d3b36 | ||
|
ae75498edb | ||
|
88836296b8 | ||
|
7f3e251ac4 | ||
|
da2ac4c0ae | ||
|
233e173735 | ||
|
36334ef186 | ||
|
c5686fbd40 | ||
|
40f3bb3b20 | ||
|
94556d0731 | ||
207f748c93 | |||
ccab358464 | |||
a0aaa6b035 | |||
79b0e83eba | |||
9b34fb8f73 | |||
22e163f036 | |||
c90a37a99b | |||
511eccac99 | |||
8503dc230d | |||
8ec0967f40 | |||
2f611d43cc | |||
ae1380407c | |||
1a290e3bd6 | |||
1e7222c781 | |||
39af68cf36 | |||
b65b71df7a | |||
2c0dcd8661 | |||
9a4820c725 | |||
1cc5b9cf57 | |||
6c327c7978 | |||
3d03fb79a7 | |||
c730fca3eb | |||
f4dbc55f88 | |||
ba106ff684 | |||
ec1de247c9 | |||
738f4eae86 | |||
1015148d4d | |||
d114b061b4 | |||
6db6baab50 | |||
9b5b97eb1d | |||
acfcca13b3 | |||
52ab3af1f2 | |||
05ec62994e | |||
79ca8fc932 | |||
10a524b7b8 | |||
3a380c9f50 | |||
41a7129959 | |||
b9bff4812b | |||
33f555f847 | |||
dedd2d3449 | |||
76d9031cb0 | |||
bf7e0a2a18 | |||
9bf39107fb | |||
d34aa82e86 | |||
349dd030d6 | |||
af9b5210fa | |||
03d419c3fc | |||
389fbf99aa | |||
e69c79df52 | |||
1cafa04763 | |||
ac21f571b4 | |||
bc68089f87 | |||
8cb4435517 | |||
9b7a3b3ec0 | |||
87c15070bd | |||
23136b7e40 | |||
8a4ae4cb91 | |||
2e4035d8a4 | |||
9a7758f208 | |||
7063fe271e | |||
ce31c3e691 | |||
8d65b0c089 | |||
8ead32c34d | |||
9988a989a6 | |||
ea4edf71ba | |||
20e418aeae | |||
0fce697095 | |||
9affa657c4 | |||
1a20f1ec67 | |||
395ddbd460 | |||
0a1a9b007d | |||
93b8ac40df | |||
e4ca12bc0f | |||
09f71fc5a7 | |||
e6d22f2a89 | |||
e694119a58 | |||
f242413911 | |||
67bbab0061 | |||
41a9892538 | |||
9992138bc4 | |||
ca25159830 | |||
f548221a10 | |||
347378d785 | |||
4d883e8dce | |||
db59e6c85c | |||
92e2462bb9 | |||
281575bbf7 | |||
6ea02c28d4 | |||
cb0e4ed4e6 | |||
05a8a78ed9 | |||
4b62ff6e80 | |||
ac5d17fc66 | |||
79c23471ee | |||
37d7e5010f | |||
350c67ab10 | |||
ce40568870 | |||
e53d7ef230 | |||
f4234f57b1 | |||
e715d07bf1 | |||
b8c652e78a | |||
8d76ecb78a | |||
9d760aafef | |||
f9d16b3608 | |||
4da025d50f | |||
e7da288904 | |||
787b741687 | |||
21f54c9789 | |||
2aca8015af | |||
c4f088f29c | |||
89ae75050b | |||
0c01bff022 | |||
efa83d2bf8 | |||
eb6395a793 | |||
388d89d95d | |||
3856d5fa84 | |||
8a368dbd16 | |||
0318ddbf21 | |||
4f842223cd | |||
78746b5e1a | |||
81eac4b880 | |||
cd98763937 | |||
f03c92082e | |||
aafde86012 | |||
3a63c72bbb | |||
31d7e978f4 | |||
c3f6d45883 | |||
3dd7739a16 | |||
27cead22ad | |||
dc934f10ee | |||
3a39ff6fc3 | |||
a0a33b81c4 | |||
8ab0a5e164 | |||
486aeb86a2 | |||
c3c6e5084a | |||
21a7eeea21 | |||
ef7de71a5b | |||
7c8308a294 | |||
1a1dff2c5d | |||
9b8f0f3d8e | |||
da6d380786 | |||
77267f944b | |||
a1ed557dc2 | |||
a3cdc42fab | |||
3ffb4a68e1 | |||
c00a465410 | |||
12d9cd39be | |||
9fa515cf9b | |||
0fd7ac7f1f | |||
e313df57d6 | |||
66d8fb7d93 | |||
0ad4ae38fa | |||
cca2633f1a | |||
9e6990e145 | |||
e1fcad3b42 | |||
031d18e922 | |||
4aad0c1213 | |||
85efd755d8 | |||
ef1cad1995 | |||
292c642e73 | |||
ab2ca04ceb | |||
81a4d5dbda | |||
c88c142f7f | |||
85460b7192 | |||
ff6865c7ca | |||
ecaa4fa95f | |||
488389057c | |||
6442c7f678 | |||
186e83f92a | |||
5160935096 | |||
da6ae3c826 | |||
efb89e7626 | |||
23d6f833d7 | |||
fdb5cc1cf9 | |||
17f9ef79b7 | |||
335c42f924 | |||
231f1d97bc | |||
2799190b97 | |||
dbc0c782c0 | |||
383f303127 | |||
27bb07a942 | |||
6331dda37b | |||
0d63413835 | |||
eb812a9ebb | |||
a126d1f91d | |||
eea99b9466 | |||
30e298aa02 | |||
196c4774a2 | |||
cc8db3fea4 | |||
727fc2d8c0 | |||
7c2b9df0d0 | |||
f0bfecaad3 | |||
3b605c3340 | |||
2ba8980dd8 | |||
d8e7bf6ae8 | |||
9ec9a5e80f | |||
3c903424fb | |||
8b2daf400a | |||
766487b669 | |||
cf5ac8b221 | |||
0e52c12b35 | |||
85c965bf92 | |||
3a1abe5157 | |||
eff7b25d71 | |||
9a2d738653 | |||
93bf9e94fb | |||
5c6f56f1c3 | |||
efb69efa10 | |||
329538f7f5 | |||
c7185f24d4 | |||
cfdb4db0c3 | |||
c46facdf8b | |||
5151b98f97 | |||
f97b2c7cbb | |||
b102dc86aa | |||
c8c93dc721 | |||
d9dc2e209f | |||
5b740768f4 | |||
86f8c12279 | |||
be63eed81d | |||
c71e91326f | |||
cca01a6c2f | |||
41d92b97a0 | |||
6855ddfdcb | |||
2f6ccd530a | |||
6b01841529 | |||
5d9dba0e3d | |||
0eda083bf2 | |||
ee159402d0 | |||
56b3e6a2f5 | |||
82ed0cf7cc | |||
49b0ea14f0 | |||
66f2da31b6 | |||
707398eae2 | |||
cf39f83243 | |||
8f8a12b609 | |||
5bd04d8dc0 | |||
4b603b70a0 | |||
48624584fe | |||
4902d40787 | |||
fb7f9e328d | |||
c7ca26202e | |||
c7ddf034a3 | |||
f47e22ccae | |||
e001ccfa01 | |||
121dd32bfb | |||
b6179430be | |||
4b671242ff | |||
8924232a93 | |||
b92b1c7c32 | |||
ac36309527 | |||
ba082d4ed7 | |||
7eddcabb7f | |||
d890a45f2b | |||
f66d62db37 | |||
b23d583bfb | |||
567b272161 | |||
048d06ca14 | |||
2f04671ec5 | |||
5f9b30cc01 | |||
c375576436 | |||
9fac4ebd82 | |||
c536fb95b2 | |||
018be71ed3 | |||
fbe3a59847 | |||
56a351bbb2 | |||
6472241dfc | |||
72aa7696ac | |||
998ec597b1 | |||
237aabf4ba | |||
3470f7422c | |||
37367cecc3 | |||
9be3b1a487 | |||
f086a6e32b | |||
c00ffd3ed0 | |||
a341974ebc | |||
f17ba4f6bf | |||
4d734dbbe8 | |||
700850434a | |||
6a4fe535e1 | |||
019622bd85 | |||
0a106cb952 | |||
fe61456922 | |||
989d6900df | |||
64f1da772a | |||
e69f60d813 | |||
6b79fb8ebe | |||
da35da2b76 | |||
8963e5461e | |||
34e82de922 | |||
a780b2330e | |||
f068c6c937 | |||
a3a1c2ab2f | |||
a0fc1653e7 | |||
dcd047a5ae | |||
3f5d0feedb | |||
268fa36507 | |||
40cd1de89f | |||
f0ba8777e3 | |||
1d778a6bdd | |||
43989af1f1 | |||
cf82a85070 | |||
0a6a14f8d0 | |||
4cfe7c2cba | |||
5dfc3379fc | |||
7ba1f22ad7 | |||
c08e1c7010 | |||
e84fb91452 | |||
2479fd193b | |||
4da20115d2 | |||
a6ad184447 | |||
01d1a35238 | |||
ff9ede6cce | |||
46604b755b | |||
05b68fdd95 | |||
0725b8b490 | |||
900929b875 | |||
7e65416f80 | |||
8cf9629bf1 | |||
ecc5780604 | |||
40926c1063 | |||
848aa88dac | |||
ba47f79d44 | |||
4d81172deb | |||
6f4353266c | |||
d1cf44fd18 | |||
abfa7b21ba | |||
23aee7abed | |||
2536e595f0 | |||
4d64f290ad | |||
bda9946859 | |||
93552023f6 | |||
a67ea9951b | |||
61ac4c1cb0 | |||
756af0a064 | |||
3907ede872 | |||
7caf54a5ba | |||
bcafc8f545 | |||
222b8e8a8b | |||
29d015cdb4 | |||
2875c59460 | |||
19054a9c67 | |||
bb09930116 | |||
e2a9d79484 | |||
31736bfbaf | |||
8d95b7d795 | |||
b5625a5fb2 | |||
aab1a2815e | |||
6103010169 | |||
e326729ddb | |||
283dfe8ecf | |||
ae4c418ed7 | |||
faeaeb8b2c | |||
3d274c8189 | |||
75db9fde3c | |||
bbc2af5962 | |||
91621625e6 | |||
fb19b12e7f | |||
d23d3ca6d1 | |||
516200d881 | |||
8969505383 | |||
9562bd6936 | |||
e9ff14d63e | |||
da251b57b0 | |||
10b325ad29 | |||
8a2d81ec23 | |||
a15844f52d | |||
6ad73aa3c9 | |||
e0cac3c800 | |||
2ba5df152a | |||
be26a19f2e | |||
b9ea8dffa3 | |||
218090d1e5 | |||
51f40311e0 | |||
f65e5b122f | |||
038a4e44ba | |||
f3cb7deaf4 | |||
f014d30a11 | |||
1745299e12 | |||
7904c52671 | |||
b17e04de71 | |||
779b06b4bf | |||
b66b94fd83 | |||
2672f9e45f | |||
2af61ca986 | |||
c57461f118 | |||
7269cec73d | |||
26350a7eb6 | |||
68a6507c1b | |||
f49b2f7df1 | |||
e48ab4b58a | |||
3c7b1a70ce | |||
f38e9df6b9 | |||
e85c910910 | |||
1f661a7038 | |||
ab4290a706 | |||
66b4c50221 | |||
a4a3b6de1b | |||
9f8a6e1a27 | |||
2ca0929fe8 | |||
d9b72bce0c | |||
3d5939ed9c | |||
e829514e91 | |||
6355afef59 | |||
a1d19b4474 | |||
636c1fdadb | |||
d29a5984f1 | |||
7df7465012 | |||
0b2a74ddd3 | |||
b1862e2a3f | |||
a1c3e79e90 | |||
7358c4440c | |||
7b1b789644 | |||
e7ca3ac0d7 | |||
963453d2d6 | |||
99f1a8d681 | |||
46ab5d620b | |||
4c0a1c8f3f | |||
6593d372e0 | |||
df503cf810 | |||
cffafa82d9 | |||
6495904cf1 | |||
dc432c4ac9 | |||
b6171637af | |||
f0c4f237de | |||
794c39ec41 | |||
99bd4df741 | |||
1d38d77c57 | |||
a866699f5d | |||
594354e459 | |||
75b43f8993 | |||
268d3a371e | |||
e50ad9430e | |||
cc0398f878 | |||
173b1e329b | |||
4eb29750ab | |||
346238dab8 | |||
6f57a4b24c | |||
9913c9e084 | |||
ae583bd2ef | |||
ad16311941 | |||
5023aaae75 | |||
493f71ac20 | |||
ae3d34e0f2 | |||
3f29b504b2 | |||
2e5c87f0b1 | |||
565486aef3 | |||
17ba7a8bdd | |||
e5cecd6102 | |||
c1068acbf7 | |||
795545e8af | |||
535fe31054 | |||
b4f021bb8b | |||
1f4848cc83 | |||
dcafde1158 | |||
288ecb60e1 | |||
9b038dc8e4 | |||
ac81dc2099 | |||
4a201f3f9d | |||
ee71044421 | |||
a57f5476c0 | |||
71b39d467d | |||
240bcc6dd4 | |||
2fc8523bfe | |||
add2001ba3 | |||
eca2165247 | |||
70f362015c | |||
8ad71d3a06 | |||
459c630db7 | |||
dc0e3bf11c | |||
89bb802e45 | |||
dd7df3080e | |||
475fdfcca7 | |||
213a0423d4 | |||
db755334d0 | |||
42f9cd9ea8 | |||
1980363c12 | |||
2bd07bf598 | |||
07c8b62dc1 | |||
d20a25f261 | |||
4c14c85a47 | |||
ef7c2f8271 | |||
40119c9e9c | |||
5b819c5e52 | |||
8432884479 | |||
9e9ceab81f | |||
82b16ec9fb | |||
97719badef | |||
11a0dc3a4a | |||
f934208082 | |||
2348c76ee8 | |||
59f10c789a | |||
6518458768 | |||
7c325e7c9e | |||
aab5325255 | |||
6a898bc2dc | |||
af8ea5ddc3 | |||
47e69da60b | |||
e730607c66 | |||
ac1ad771f0 | |||
87f60e1826 | |||
6285014938 | |||
0c3199515b | |||
272cd1441c | |||
7c5e3c1e43 | |||
d4dbaa4d48 | |||
274eb2d214 | |||
9fa553bd4a | |||
7aa5be57cd | |||
3cedfcaeaf | |||
2e77b1a216 | |||
2cf3329131 | |||
e3fdf08b2c | |||
96a8c8da6c | |||
2d1cdd5e94 | |||
e6b37ce453 | |||
af5e6172e9 | |||
fdc68079dc | |||
88a4fc02d1 | |||
f8d97314d3 | |||
d6bc6df86b | |||
9906d82261 | |||
2fce2e0c80 | |||
69139b9ac5 | |||
bf1d53d07d | |||
3797adfc95 | |||
2482242f20 | |||
6929136f90 | |||
0d7fa41261 | |||
60b6a462c8 | |||
2e9e15be95 | |||
e0eda9df49 | |||
08f2585def | |||
53cc25b4ce | |||
f8d05f2cec | |||
e37d287397 | |||
fd89626172 | |||
059dca4a40 | |||
e1967bcd7e | |||
6d5f8bc00c | |||
79193d897e | |||
c7252d0f7b | |||
2064ac508a | |||
35b0d30739 | |||
66a950f757 | |||
6e463ca588 | |||
0fff71b1fb | |||
3bde83cf92 | |||
e53373ab99 | |||
5b2e6dda67 | |||
408aa965fd | |||
39e80c64fa | |||
52019e61c1 | |||
857fa72feb | |||
c745e3c27c | |||
9b1d8fca71 | |||
6cecb49d50 | |||
2d0bb883bd | |||
d6d809a60e | |||
1d9d7853bd | |||
c0d79d1bc7 | |||
610b6a5766 | |||
f771c19d99 | |||
a862a0f03a | |||
f2943e4bc1 | |||
035b78a656 | |||
9f198ed133 | |||
43b5973dbe | |||
c6a6ed963e | |||
0002c15e94 |
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
SERVER_NAME= # URL where this will be hosted.
|
||||||
|
|
||||||
|
## Flask Configuration
|
||||||
|
SECRET_KEY= # Long, secure, secret string.
|
||||||
|
DATA=./data/
|
||||||
|
|
||||||
|
## 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.
|
6
.gitignore
vendored
6
.gitignore
vendored
@@ -145,8 +145,8 @@ dev/
|
|||||||
out/
|
out/
|
||||||
ref-test/testing.py
|
ref-test/testing.py
|
||||||
|
|
||||||
|
# Ignore Database
|
||||||
|
database/data/
|
||||||
|
|
||||||
# Ignore Encryption Keyfile
|
# Ignore Encryption Keyfile
|
||||||
.encryption.key
|
.encryption.key
|
||||||
|
|
||||||
# Ignore Font Binaries
|
|
||||||
**/fonts/
|
|
169
README.md
169
README.md
@@ -1,3 +1,168 @@
|
|||||||
# ska-referee-test
|
# SKA Referee Test App
|
||||||
|
|
||||||
An on-line version of a referee test for the Scottish Korfball Association.
|
## About
|
||||||
|
|
||||||
|
```A web app that digitises the theory exam for the Scottish Korfball Association referee qualification```
|
||||||
|
|
||||||
|
This web app provides an on-line platform through which to administer and take the SKA Refereeing theory exam.
|
||||||
|
The app includes a digital client to take the exam for candidates, as well as an admin console from which to manage tests, view results, and update questions.
|
||||||
|
The exam client is made with accessibility in mind, and has been designed to be adaptable to dyslexia and other learning needs or cognitive needs.
|
||||||
|
|
||||||
|
## Set Up and Installation
|
||||||
|
|
||||||
|
The clien is designed to work on a server.
|
||||||
|
|
||||||
|
### Pre-Requisites
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```$ cd ~ && mkdir ska-referee-test && cd ska-referee-test```
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```$ git clone https://git.vsnt.uk/viveksantayana/ska-referee-test.git .```
|
||||||
|
|
||||||
|
(Remember to include the trailing dot at the end, as that indicates to Git to download the files in the current directory.)
|
||||||
|
|
||||||
|
#### 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.
|
||||||
|
|
||||||
|
```
|
||||||
|
# .env
|
||||||
|
|
||||||
|
SERVER_NAME= # URL where this will be hosted.
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
# 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:
|
||||||
|
|
||||||
|
```
|
||||||
|
# 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 two for the www version:
|
||||||
|
|
||||||
|
```
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ 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.
|
||||||
|
@@ -77,3 +77,7 @@ Uses SQL rather than MongoDB.
|
|||||||
### Flask techniques
|
### Flask techniques
|
||||||
|
|
||||||
- [Create a `config.py` file](https://www.youtube.com/watch?v=GW_2O9CrnSU)
|
- [Create a `config.py` file](https://www.youtube.com/watch?v=GW_2O9CrnSU)
|
||||||
|
|
||||||
|
### Flask handling file uploads
|
||||||
|
|
||||||
|
- [Handlin File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask)
|
2
certbot/.gitignore
vendored
Normal file
2
certbot/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.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
|
|
@@ -1,31 +1,62 @@
|
|||||||
version: '3.9'
|
version: '3.9'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ref_test_db:
|
nginx:
|
||||||
container_name: ref_test_db
|
container_name: reftest_server
|
||||||
image: mongo:5.0.4-focal
|
image: nginx:alpine
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
volumes:
|
||||||
# - ./database/data:/data # Uncomment later when persistence is required.
|
- ./certbot:/etc/letsencrypt:ro
|
||||||
- ./database/initdb.d/:/docker-entrypoint-initdb.d/
|
- ./nginx:/etc/nginx
|
||||||
|
- ./src/html:/usr/share/nginx/html/
|
||||||
|
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static
|
||||||
|
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static
|
||||||
|
- ./ref-test/app/root:/usr/share/nginx/html/root
|
||||||
|
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_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
ports:
|
ports:
|
||||||
- 27017:27017
|
- 5000
|
||||||
|
volumes:
|
||||||
|
- ./ref-test/data:/ref-test/data
|
||||||
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
|
- frontend
|
||||||
- backend
|
- backend
|
||||||
|
depends_on:
|
||||||
|
- postfix
|
||||||
|
|
||||||
ref_test_postfix:
|
postfix:
|
||||||
container_name: ref_test_postfix
|
container_name: reftest_postfix
|
||||||
image: catatnight/postfix:latest
|
image: catatnight/postfix:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:25:25
|
- 25
|
||||||
networks:
|
networks:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
|
certbot:
|
||||||
|
container_name: reftest_certbot
|
||||||
|
image: certbot/certbot
|
||||||
|
volumes:
|
||||||
|
- ./certbot:/etc/letsencrypt
|
||||||
|
- ./src/html:/var/www/html
|
||||||
|
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
frontend:
|
frontend:
|
||||||
external: false
|
external: false
|
||||||
|
87
install-script.sh
Normal file
87
install-script.sh
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Source https://pentacent.medium.com/nginx-and-lets-encrypt-with-docker-in-less-than-5-minutes-b4b8a60d3a71
|
||||||
|
|
||||||
|
if ! [ -x "$(command -v docker 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"
|
||||||
|
docker compose run --rm --entrypoint "\
|
||||||
|
openssl dhparam -out /etc/letsencrypt/ssl-dhparams.pem 4096" certbot
|
||||||
|
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 --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
|
6
nginx/certbot-challenge.conf
Normal file
6
nginx/certbot-challenge.conf
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Certbot Renewal
|
||||||
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
allow all;
|
||||||
|
default_type "text/plain";
|
||||||
|
}
|
6
nginx/conf.d/proxy_headers.conf
Normal file
6
nginx/conf.d/proxy_headers.conf
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
57
nginx/conf.d/ref-test-app.conf
Normal file
57
nginx/conf.d/ref-test-app.conf
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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 / {
|
||||||
|
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;
|
||||||
|
}
|
26
nginx/fastcgi.conf
Normal file
26
nginx/fastcgi.conf
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
fastcgi_param QUERY_STRING $query_string;
|
||||||
|
fastcgi_param REQUEST_METHOD $request_method;
|
||||||
|
fastcgi_param CONTENT_TYPE $content_type;
|
||||||
|
fastcgi_param CONTENT_LENGTH $content_length;
|
||||||
|
|
||||||
|
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
|
||||||
|
fastcgi_param REQUEST_URI $request_uri;
|
||||||
|
fastcgi_param DOCUMENT_URI $document_uri;
|
||||||
|
fastcgi_param DOCUMENT_ROOT $document_root;
|
||||||
|
fastcgi_param SERVER_PROTOCOL $server_protocol;
|
||||||
|
fastcgi_param REQUEST_SCHEME $scheme;
|
||||||
|
fastcgi_param HTTPS $https if_not_empty;
|
||||||
|
|
||||||
|
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
|
||||||
|
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
|
||||||
|
|
||||||
|
fastcgi_param REMOTE_ADDR $remote_addr;
|
||||||
|
fastcgi_param REMOTE_PORT $remote_port;
|
||||||
|
fastcgi_param SERVER_ADDR $server_addr;
|
||||||
|
fastcgi_param SERVER_PORT $server_port;
|
||||||
|
fastcgi_param SERVER_NAME $server_name;
|
||||||
|
|
||||||
|
# PHP only, required if PHP was built with --enable-force-cgi-redirect
|
||||||
|
fastcgi_param REDIRECT_STATUS 200;
|
25
nginx/fastcgi_params
Normal file
25
nginx/fastcgi_params
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
fastcgi_param QUERY_STRING $query_string;
|
||||||
|
fastcgi_param REQUEST_METHOD $request_method;
|
||||||
|
fastcgi_param CONTENT_TYPE $content_type;
|
||||||
|
fastcgi_param CONTENT_LENGTH $content_length;
|
||||||
|
|
||||||
|
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
|
||||||
|
fastcgi_param REQUEST_URI $request_uri;
|
||||||
|
fastcgi_param DOCUMENT_URI $document_uri;
|
||||||
|
fastcgi_param DOCUMENT_ROOT $document_root;
|
||||||
|
fastcgi_param SERVER_PROTOCOL $server_protocol;
|
||||||
|
fastcgi_param REQUEST_SCHEME $scheme;
|
||||||
|
fastcgi_param HTTPS $https if_not_empty;
|
||||||
|
|
||||||
|
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
|
||||||
|
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
|
||||||
|
|
||||||
|
fastcgi_param REMOTE_ADDR $remote_addr;
|
||||||
|
fastcgi_param REMOTE_PORT $remote_port;
|
||||||
|
fastcgi_param SERVER_ADDR $server_addr;
|
||||||
|
fastcgi_param SERVER_PORT $server_port;
|
||||||
|
fastcgi_param SERVER_NAME $server_name;
|
||||||
|
|
||||||
|
# PHP only, required if PHP was built with --enable-force-cgi-redirect
|
||||||
|
fastcgi_param REDIRECT_STATUS 200;
|
98
nginx/mime.types
Normal file
98
nginx/mime.types
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
|
||||||
|
types {
|
||||||
|
text/html html htm shtml;
|
||||||
|
text/css css;
|
||||||
|
text/xml xml;
|
||||||
|
image/gif gif;
|
||||||
|
image/jpeg jpeg jpg;
|
||||||
|
application/javascript js;
|
||||||
|
application/atom+xml atom;
|
||||||
|
application/rss+xml rss;
|
||||||
|
|
||||||
|
text/mathml mml;
|
||||||
|
text/plain txt;
|
||||||
|
text/vnd.sun.j2me.app-descriptor jad;
|
||||||
|
text/vnd.wap.wml wml;
|
||||||
|
text/x-component htc;
|
||||||
|
|
||||||
|
image/png png;
|
||||||
|
image/svg+xml svg svgz;
|
||||||
|
image/tiff tif tiff;
|
||||||
|
image/vnd.wap.wbmp wbmp;
|
||||||
|
image/webp webp;
|
||||||
|
image/x-icon ico;
|
||||||
|
image/x-jng jng;
|
||||||
|
image/x-ms-bmp bmp;
|
||||||
|
|
||||||
|
font/woff woff;
|
||||||
|
font/woff2 woff2;
|
||||||
|
|
||||||
|
application/java-archive jar war ear;
|
||||||
|
application/json json;
|
||||||
|
application/mac-binhex40 hqx;
|
||||||
|
application/msword doc;
|
||||||
|
application/pdf pdf;
|
||||||
|
application/postscript ps eps ai;
|
||||||
|
application/rtf rtf;
|
||||||
|
application/vnd.apple.mpegurl m3u8;
|
||||||
|
application/vnd.google-earth.kml+xml kml;
|
||||||
|
application/vnd.google-earth.kmz kmz;
|
||||||
|
application/vnd.ms-excel xls;
|
||||||
|
application/vnd.ms-fontobject eot;
|
||||||
|
application/vnd.ms-powerpoint ppt;
|
||||||
|
application/vnd.oasis.opendocument.graphics odg;
|
||||||
|
application/vnd.oasis.opendocument.presentation odp;
|
||||||
|
application/vnd.oasis.opendocument.spreadsheet ods;
|
||||||
|
application/vnd.oasis.opendocument.text odt;
|
||||||
|
application/vnd.openxmlformats-officedocument.presentationml.presentation
|
||||||
|
pptx;
|
||||||
|
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||||
|
xlsx;
|
||||||
|
application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||||
|
docx;
|
||||||
|
application/vnd.wap.wmlc wmlc;
|
||||||
|
application/wasm wasm;
|
||||||
|
application/x-7z-compressed 7z;
|
||||||
|
application/x-cocoa cco;
|
||||||
|
application/x-java-archive-diff jardiff;
|
||||||
|
application/x-java-jnlp-file jnlp;
|
||||||
|
application/x-makeself run;
|
||||||
|
application/x-perl pl pm;
|
||||||
|
application/x-pilot prc pdb;
|
||||||
|
application/x-rar-compressed rar;
|
||||||
|
application/x-redhat-package-manager rpm;
|
||||||
|
application/x-sea sea;
|
||||||
|
application/x-shockwave-flash swf;
|
||||||
|
application/x-stuffit sit;
|
||||||
|
application/x-tcl tcl tk;
|
||||||
|
application/x-x509-ca-cert der pem crt;
|
||||||
|
application/x-xpinstall xpi;
|
||||||
|
application/xhtml+xml xhtml;
|
||||||
|
application/xspf+xml xspf;
|
||||||
|
application/zip zip;
|
||||||
|
|
||||||
|
application/octet-stream bin exe dll;
|
||||||
|
application/octet-stream deb;
|
||||||
|
application/octet-stream dmg;
|
||||||
|
application/octet-stream iso img;
|
||||||
|
application/octet-stream msi msp msm;
|
||||||
|
|
||||||
|
audio/midi mid midi kar;
|
||||||
|
audio/mpeg mp3;
|
||||||
|
audio/ogg ogg;
|
||||||
|
audio/x-m4a m4a;
|
||||||
|
audio/x-realaudio ra;
|
||||||
|
|
||||||
|
video/3gpp 3gpp 3gp;
|
||||||
|
video/mp2t ts;
|
||||||
|
video/mp4 mp4;
|
||||||
|
video/mpeg mpeg mpg;
|
||||||
|
video/quicktime mov;
|
||||||
|
video/webm webm;
|
||||||
|
video/x-flv flv;
|
||||||
|
video/x-m4v m4v;
|
||||||
|
video/x-mng mng;
|
||||||
|
video/x-ms-asf asx asf;
|
||||||
|
video/x-ms-wmv wmv;
|
||||||
|
video/x-msvideo avi;
|
||||||
|
}
|
33
nginx/nginx.conf
Normal file
33
nginx/nginx.conf
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
|
||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
error_log /var/log/nginx/error.log notice;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
#tcp_nopush on;
|
||||||
|
|
||||||
|
keepalive_timeout 65;
|
||||||
|
server_tokens off;
|
||||||
|
#gzip on;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
include /etc/nginx/conf.d/sites-enabled/*.conf;
|
||||||
|
}
|
17
nginx/scgi_params
Normal file
17
nginx/scgi_params
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
scgi_param REQUEST_METHOD $request_method;
|
||||||
|
scgi_param REQUEST_URI $request_uri;
|
||||||
|
scgi_param QUERY_STRING $query_string;
|
||||||
|
scgi_param CONTENT_TYPE $content_type;
|
||||||
|
|
||||||
|
scgi_param DOCUMENT_URI $document_uri;
|
||||||
|
scgi_param DOCUMENT_ROOT $document_root;
|
||||||
|
scgi_param SCGI 1;
|
||||||
|
scgi_param SERVER_PROTOCOL $server_protocol;
|
||||||
|
scgi_param REQUEST_SCHEME $scheme;
|
||||||
|
scgi_param HTTPS $https if_not_empty;
|
||||||
|
|
||||||
|
scgi_param REMOTE_ADDR $remote_addr;
|
||||||
|
scgi_param REMOTE_PORT $remote_port;
|
||||||
|
scgi_param SERVER_PORT $server_port;
|
||||||
|
scgi_param SERVER_NAME $server_name;
|
13
nginx/ssl.conf
Normal file
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;
|
17
nginx/uwsgi_params
Normal file
17
nginx/uwsgi_params
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
uwsgi_param QUERY_STRING $query_string;
|
||||||
|
uwsgi_param REQUEST_METHOD $request_method;
|
||||||
|
uwsgi_param CONTENT_TYPE $content_type;
|
||||||
|
uwsgi_param CONTENT_LENGTH $content_length;
|
||||||
|
|
||||||
|
uwsgi_param REQUEST_URI $request_uri;
|
||||||
|
uwsgi_param PATH_INFO $document_uri;
|
||||||
|
uwsgi_param DOCUMENT_ROOT $document_root;
|
||||||
|
uwsgi_param SERVER_PROTOCOL $server_protocol;
|
||||||
|
uwsgi_param REQUEST_SCHEME $scheme;
|
||||||
|
uwsgi_param HTTPS $https if_not_empty;
|
||||||
|
|
||||||
|
uwsgi_param REMOTE_ADDR $remote_addr;
|
||||||
|
uwsgi_param REMOTE_PORT $remote_port;
|
||||||
|
uwsgi_param SERVER_PORT $server_port;
|
||||||
|
uwsgi_param SERVER_NAME $server_name;
|
2
ref-test/.dockerignore
Normal file
2
ref-test/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
env/
|
||||||
|
__pycache__/
|
@@ -1,5 +1,5 @@
|
|||||||
FROM python:3.10-alpine
|
FROM python:3.10-slim
|
||||||
WORKDIR /app
|
WORKDIR /ref-test
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pip install -r requirements.txt
|
RUN pip install --upgrade pip && pip install -r requirements.txt
|
||||||
CMD [ "gunicorn", "-b", "0.0.0.0:5000", "app:app" ]
|
CMD [ "gunicorn", "-b", "0.0.0.0:5000", "-w", "5", "wsgi:app" ]
|
@@ -1,148 +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 security.database import decrypt_find_one, encrypted_update
|
|
||||||
from werkzeug.security import check_password_hash
|
|
||||||
|
|
||||||
from main import db
|
|
||||||
|
|
||||||
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
|
|
||||||
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,95 +0,0 @@
|
|||||||
import secrets
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
from flask import flash, jsonify
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
from main import db
|
|
||||||
from security import encrypt
|
|
||||||
|
|
||||||
class Test:
|
|
||||||
def __init__(self, _id=None, start_date=None, expiry_date=None, time_limit=None, creator=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 == '' else time_limit
|
|
||||||
self.creator = creator
|
|
||||||
|
|
||||||
def create(self):
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
if db.tests.insert_one(test):
|
|
||||||
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):
|
|
||||||
code = {
|
|
||||||
'_id': uuid4().hex,
|
|
||||||
'user_code': secrets.token_hex(2).upper(),
|
|
||||||
'time_adjustment': time_adjustment
|
|
||||||
}
|
|
||||||
if db.tests.find_one_and_update({'_id': self._id}, {'$push': {'time_adjustments': code}},upsert=False):
|
|
||||||
return jsonify({'success': code})
|
|
||||||
return jsonify({'error': 'Failed to add the time adjustment. An error occurred.'}), 400
|
|
||||||
|
|
||||||
def remove_time_adjustment(self, _id):
|
|
||||||
if db.tests.find_one_and_update({'_id': self._id}, {'$pull': {'time_adjustments': {'_id': _id} }}):
|
|
||||||
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):
|
|
||||||
if db.tests.delete_one({'_id': self._id}):
|
|
||||||
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):
|
|
||||||
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'] = 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,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,452 +0,0 @@
|
|||||||
// 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form Processing Scripts
|
|
||||||
$('form[name=form-register]').submit(function(event) {
|
|
||||||
|
|
||||||
var $form = $(this);
|
|
||||||
var alert = document.getElementById('alert-box');
|
|
||||||
var data = $form.serialize();
|
|
||||||
|
|
||||||
alert.innerHTML = ''
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: window.location.pathname,
|
|
||||||
type: 'POST',
|
|
||||||
data: data,
|
|
||||||
dataType: 'json',
|
|
||||||
success: function(response) {
|
|
||||||
window.location.href = "/admin/login/";
|
|
||||||
},
|
|
||||||
error: function(response) {
|
|
||||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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) {
|
|
||||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
$('form[name=form-login]').submit(function(event) {
|
|
||||||
|
|
||||||
var $form = $(this);
|
|
||||||
var alert = document.getElementById('alert-box');
|
|
||||||
var data = $form.serialize();
|
|
||||||
|
|
||||||
alert.innerHTML = ''
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: window.location.pathname,
|
|
||||||
type: 'POST',
|
|
||||||
data: data,
|
|
||||||
dataType: 'json',
|
|
||||||
success: function(response) {
|
|
||||||
window.location.href = "/admin/dashboard/";
|
|
||||||
},
|
|
||||||
error: function(response) {
|
|
||||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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) {
|
|
||||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
$('form[name=form-reset]').submit(function(event) {
|
|
||||||
|
|
||||||
var $form = $(this);
|
|
||||||
var alert = document.getElementById('alert-box');
|
|
||||||
var data = $form.serialize();
|
|
||||||
|
|
||||||
alert.innerHTML = ''
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: window.location.pathname,
|
|
||||||
type: 'POST',
|
|
||||||
data: data,
|
|
||||||
dataType: 'json',
|
|
||||||
success: function(response) {
|
|
||||||
window.location.href = "/admin/login/";
|
|
||||||
},
|
|
||||||
error: function(response) {
|
|
||||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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) {
|
|
||||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
$('form[name=form-update-password]').submit(function(event) {
|
|
||||||
|
|
||||||
var $form = $(this);
|
|
||||||
var alert = document.getElementById('alert-box');
|
|
||||||
var data = $form.serialize();
|
|
||||||
console.log(data)
|
|
||||||
alert.innerHTML = ''
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: window.location.pathname,
|
|
||||||
type: 'POST',
|
|
||||||
data: data,
|
|
||||||
dataType: 'json',
|
|
||||||
success: function(response) {
|
|
||||||
window.location.href = "/admin/login/";
|
|
||||||
},
|
|
||||||
error: function(response) {
|
|
||||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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) {
|
|
||||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
$('form[name=form-create-user]').submit(function(event) {
|
|
||||||
|
|
||||||
var $form = $(this);
|
|
||||||
var alert = document.getElementById('alert-box');
|
|
||||||
var data = $form.serialize();
|
|
||||||
|
|
||||||
alert.innerHTML = ''
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: window.location.pathname,
|
|
||||||
type: 'POST',
|
|
||||||
data: data,
|
|
||||||
dataType: 'json',
|
|
||||||
success: function(response) {
|
|
||||||
window.location.reload();
|
|
||||||
},
|
|
||||||
error: function(response) {
|
|
||||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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) {
|
|
||||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
$('form[name=form-delete-user]').submit(function(event) {
|
|
||||||
|
|
||||||
var $form = $(this);
|
|
||||||
var alert = document.getElementById('alert-box');
|
|
||||||
var data = $form.serialize();
|
|
||||||
|
|
||||||
alert.innerHTML = ''
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: window.location.pathname,
|
|
||||||
type: 'POST',
|
|
||||||
data: data,
|
|
||||||
dataType: 'json',
|
|
||||||
success: function(response) {
|
|
||||||
window.location.href = '/admin/settings/users/';
|
|
||||||
},
|
|
||||||
error: function(response) {
|
|
||||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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) {
|
|
||||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
$('form[name=form-update-user]').submit(function(event) {
|
|
||||||
|
|
||||||
var $form = $(this);
|
|
||||||
var alert = document.getElementById('alert-box');
|
|
||||||
var data = $form.serialize();
|
|
||||||
|
|
||||||
alert.innerHTML = ''
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: window.location.pathname,
|
|
||||||
type: 'POST',
|
|
||||||
data: data,
|
|
||||||
dataType: 'json',
|
|
||||||
success: function(response) {
|
|
||||||
window.location.href = '/admin/settings/users';
|
|
||||||
},
|
|
||||||
error: function(response) {
|
|
||||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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) {
|
|
||||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
$('form[name=form-update-account]').submit(function(event) {
|
|
||||||
|
|
||||||
var $form = $(this);
|
|
||||||
var alert = document.getElementById('alert-box');
|
|
||||||
var data = $form.serialize();
|
|
||||||
|
|
||||||
alert.innerHTML = ''
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: window.location.pathname,
|
|
||||||
type: 'POST',
|
|
||||||
data: data,
|
|
||||||
dataType: 'json',
|
|
||||||
success: function(response) {
|
|
||||||
window.location.href = '/admin/dashboard/';
|
|
||||||
},
|
|
||||||
error: function(response) {
|
|
||||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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) {
|
|
||||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
$('.delete-test').click(function(event) {
|
|
||||||
|
|
||||||
_id = $(this).data('_id')
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: `/admin/tests/delete/${_id}`,
|
|
||||||
type: 'GET',
|
|
||||||
success: function(response) {
|
|
||||||
window.location.href = '/admin/tests/';
|
|
||||||
},
|
|
||||||
error: function(response) {
|
|
||||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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) {
|
|
||||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Edit and Delete Test Button Handlers
|
|
||||||
|
|
||||||
$('form[name=form-create-test]').submit(function(event) {
|
|
||||||
|
|
||||||
var $form = $(this);
|
|
||||||
var alert = document.getElementById('alert-box');
|
|
||||||
var data = $form.serialize();
|
|
||||||
alert.innerHTML = ''
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: window.location.pathname,
|
|
||||||
type: 'POST',
|
|
||||||
data: data,
|
|
||||||
dataType: 'json',
|
|
||||||
success: function(response) {
|
|
||||||
window.location.href = '/admin/tests/';
|
|
||||||
},
|
|
||||||
error: function(response) {
|
|
||||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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) {
|
|
||||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
|
||||||
alert.innerHTML = alert.innerHTML + `
|
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Dismiss Cookie Alert
|
|
||||||
$('#dismiss-cookie-alert').click(function(event){
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: '/cookies/',
|
|
||||||
type: 'GET',
|
|
||||||
data: {
|
|
||||||
time: Date.now()
|
|
||||||
},
|
|
||||||
dataType: 'json',
|
|
||||||
success: function(response){
|
|
||||||
console.log(response)
|
|
||||||
},
|
|
||||||
error: function(response){
|
|
||||||
console.log(response)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
})
|
|
@@ -1 +0,0 @@
|
|||||||
<div id="alert-box"></div>
|
|
@@ -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 | 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 Tests</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">Manage Users</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{{ url_for('admin_views.questions') }}" id="link-questions" class="dropdown-item">Manage 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_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,5 +0,0 @@
|
|||||||
{% extends "admin/components/base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h1>Dashboard</h1>
|
|
||||||
{% endblock %}
|
|
@@ -1 +0,0 @@
|
|||||||
{% extends "admin/components/base.html" %}
|
|
@@ -1 +0,0 @@
|
|||||||
{% extends "admin/components/base.html" %}
|
|
@@ -1 +0,0 @@
|
|||||||
{% extends "admin/components/base.html" %}
|
|
@@ -1,321 +0,0 @@
|
|||||||
from flask import Blueprint, render_template, flash, redirect, request, jsonify, abort
|
|
||||||
from flask.helpers import url_for
|
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from werkzeug.security import check_password_hash
|
|
||||||
from security.database import decrypt_find, decrypt_find_one
|
|
||||||
from .models.users import User
|
|
||||||
from flask_mail import Message
|
|
||||||
from main import db
|
|
||||||
from uuid import uuid4
|
|
||||||
import secrets
|
|
||||||
from main import mail
|
|
||||||
from datetime import datetime, date, timedelta
|
|
||||||
from .models.tests import Test
|
|
||||||
|
|
||||||
views = Blueprint(
|
|
||||||
'admin_views',
|
|
||||||
__name__,
|
|
||||||
template_folder='templates',
|
|
||||||
static_folder='static'
|
|
||||||
)
|
|
||||||
|
|
||||||
def admin_account_required(function):
|
|
||||||
@wraps(function)
|
|
||||||
def decorated_function(*args, **kwargs):
|
|
||||||
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):
|
|
||||||
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):
|
|
||||||
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():
|
|
||||||
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():
|
|
||||||
return render_template('/admin/index.html')
|
|
||||||
|
|
||||||
@views.route('/settings/')
|
|
||||||
@admin_account_required
|
|
||||||
@login_required
|
|
||||||
def settings():
|
|
||||||
return render_template('/admin/settings/index.html')
|
|
||||||
|
|
||||||
@views.route('/settings/users/', methods=['GET','POST'])
|
|
||||||
@admin_account_required
|
|
||||||
@login_required
|
|
||||||
def users():
|
|
||||||
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):
|
|
||||||
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):
|
|
||||||
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/')
|
|
||||||
@admin_account_required
|
|
||||||
@login_required
|
|
||||||
def questions():
|
|
||||||
return render_template('/admin/settings/questions.html')
|
|
||||||
|
|
||||||
@views.route('/settings/questions/upload/')
|
|
||||||
@admin_account_required
|
|
||||||
@login_required
|
|
||||||
def upload_questions():
|
|
||||||
return render_template('/admin/settings/upload-questions.html')
|
|
||||||
|
|
||||||
@views.route('/tests/<filter>/', methods=['GET'])
|
|
||||||
@views.route('/tests/', methods=['GET'])
|
|
||||||
@admin_account_required
|
|
||||||
@login_required
|
|
||||||
def tests(filter=''):
|
|
||||||
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.default='none'
|
|
||||||
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'].date() >= date.today() 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'].date() < date.today()]
|
|
||||||
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 _tests():
|
|
||||||
from .models.forms import CreateTest
|
|
||||||
form = CreateTest()
|
|
||||||
form.time_limit.default='none'
|
|
||||||
form.process()
|
|
||||||
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')
|
|
||||||
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
|
|
||||||
)
|
|
||||||
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/<_id>/')
|
|
||||||
def delete_test(_id):
|
|
||||||
if db.tests.find_one({'_id': _id}):
|
|
||||||
return Test(_id = _id).delete()
|
|
||||||
return abort(404)
|
|
@@ -20,7 +20,7 @@ body {
|
|||||||
padding-bottom: 40px;
|
padding-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-signin {
|
.form-display {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
@@ -132,16 +132,11 @@ table.dataTable {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-table-row {
|
.table-row {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-row-actions {
|
.row-actions {
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-row-actions {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -153,8 +148,8 @@ table.dataTable {
|
|||||||
text-align:center;
|
text-align:center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-row-actions button {
|
.row-actions button, .row-actions a {
|
||||||
margin: 0px 10px;
|
margin: 0px 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#cookie-alert {
|
#cookie-alert {
|
||||||
@@ -163,7 +158,7 @@ table.dataTable {
|
|||||||
|
|
||||||
#dismiss-cookie-alert {
|
#dismiss-cookie-alert {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
width: 100%;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-db-empty {
|
.alert-db-empty {
|
||||||
@@ -214,6 +209,33 @@ table.dataTable {
|
|||||||
font-size: 20px;
|
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
|
/* Fallback for Edge
|
||||||
-------------------------------------------------- */
|
-------------------------------------------------- */
|
||||||
@supports (-ms-ime-align: auto) {
|
@supports (-ms-ime-align: auto) {
|
BIN
ref-test/app/admin/static/favicon.ico
Normal file
BIN
ref-test/app/admin/static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
BIN
ref-test/app/admin/static/favicon.png
Normal file
BIN
ref-test/app/admin/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 84 KiB |
233
ref-test/app/admin/static/js/script.js
Normal file
233
ref-test/app/admin/static/js/script.js
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
// 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 file = $('input[name=data_file]')[0].files[0]
|
||||||
|
data.append('file', file)
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: window.location.pathname,
|
||||||
|
type: 'POST',
|
||||||
|
data: data,
|
||||||
|
processData: false,
|
||||||
|
contentType: false,
|
||||||
|
success: function(response) {
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
error: function(response) {
|
||||||
|
error_response(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit and Delete Test Button Handlers
|
||||||
|
$('.test-action').click(function(event) {
|
||||||
|
|
||||||
|
let id = $(this).data('id');
|
||||||
|
let action = $(this).data('action');
|
||||||
|
|
||||||
|
if (action == 'delete' || action == 'start' || action == 'end') {
|
||||||
|
$.ajax({
|
||||||
|
url: `/admin/tests/edit/`,
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'id': id, 'action': action}), // TODO Change how CRUD operations work
|
||||||
|
contentType: 'application/json',
|
||||||
|
success: function(response) {
|
||||||
|
window.location.href = '/admin/tests/';
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
error_response(response);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (action == 'edit') {
|
||||||
|
window.location.href = `/admin/test/${id}/`
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (var 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();
|
||||||
|
})
|
||||||
|
|
||||||
|
// Script for Result Actions
|
||||||
|
$('.result-action-buttons').click(function(event){
|
||||||
|
|
||||||
|
var id = $(this).data('id');
|
||||||
|
|
||||||
|
if ($(this).data('result-action') == 'generate') {
|
||||||
|
$.ajax({
|
||||||
|
url: '/admin/certificate/',
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'id': id}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
dataType: 'html',
|
||||||
|
success: function(response) {
|
||||||
|
var display_window = window.open();
|
||||||
|
display_window.document.write(response);
|
||||||
|
},
|
||||||
|
error: function(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}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
success: function(response) {
|
||||||
|
if (action == 'delete') {
|
||||||
|
window.location.href = '/admin/results/';
|
||||||
|
} else window.location.reload();
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
error_response(response);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Script for Deleting Time Adjustment
|
||||||
|
$('.adjustment-delete').click(function(event){
|
||||||
|
|
||||||
|
var user_code = $(this).data('user_code');
|
||||||
|
var location = window.location.href;
|
||||||
|
location = location.replace('#', '')
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: location + 'delete-adjustment/',
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'user_code': user_code}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
success: function(response) {
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
error_response(response);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<form name="form-update-account" class="form-signin">
|
<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" %}
|
{% include "admin/components/server-alerts.html" %}
|
||||||
<h2 class="form-signin-heading">Update Your Account</h2>
|
<h2 class="form-heading">Update Your Account</h2>
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
<div class="form-label-group">
|
<div class="form-label-group">
|
||||||
Please confirm <strong>your current password</strong> before making any changes to your user account.
|
Please confirm <strong>your current password</strong> before making any changes to your user account.
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<div class="container form-submission-button">
|
<div class="container form-submission-button">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col text-center">
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<form name="form-login" class="form-signin">
|
<form name="form-login" 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" %}
|
{% include "admin/components/server-alerts.html" %}
|
||||||
<h2 class="form">Log In</h2>
|
<h2 class="form">Log In</h2>
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
@@ -3,16 +3,16 @@
|
|||||||
{% block navbar %}
|
{% block navbar %}
|
||||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||||
<div class="container">
|
<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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<form name="form-register" action="" method="" class="form-signin">
|
<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" %}
|
{% include "admin/components/server-alerts.html" %}
|
||||||
<h2 class="form-signin-heading">Register an Account</h2>
|
<h2 class="form-heading">Register an Account</h2>
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
<div class="form-label-group">
|
<div class="form-label-group">
|
||||||
{{ form.username(class_="form-control", autofocus=true, placeholder="Username") }}
|
{{ form.username(class_="form-control", autofocus=true, placeholder="Username") }}
|
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<form name="form-reset" class="form-signin">
|
<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" %}
|
{% include "admin/components/server-alerts.html" %}
|
||||||
<h2 class="form-signin-heading">Reset Password</h2>
|
<h2 class="form-heading">Reset Password</h2>
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
<div class="form-label-group">
|
<div class="form-label-group">
|
||||||
{{ form.username(class_="form-control", autofocus=true, placeholder="Enter Username") }}
|
{{ form.username(class_="form-control", autofocus=true, placeholder="Enter Username") }}
|
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<form name="form-update-password" class="form-signin">
|
<form name="form-update-password" 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" %}
|
{% include "admin/components/server-alerts.html" %}
|
||||||
<h2 class="form-signin-heading">Update Password</h2>
|
<h2 class="form-heading">Update Password</h2>
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
<div class="form-label-group">
|
<div class="form-label-group">
|
||||||
{{ form.password(class_="form-control", placeholder="Password") }}
|
{{ form.password(class_="form-control", placeholder="Password") }}
|
@@ -18,6 +18,7 @@
|
|||||||
{% block datatable_css %}
|
{% block datatable_css %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title>
|
<title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title>
|
||||||
|
{% include "admin/components/og-meta.html" %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-light">
|
<body class="bg-light">
|
||||||
|
|
||||||
@@ -32,8 +33,10 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="container site-footer">
|
<footer class="container site-footer mt-5">
|
||||||
{% include "admin/components/footer.html" %}
|
{% block footer %}
|
||||||
|
{% include "admin/components/footer.html" %}
|
||||||
|
{% endblock %}
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<!-- JQuery, Popper, and Bootstrap js dependencies -->
|
<!-- JQuery, Popper, and Bootstrap js dependencies -->
|
||||||
@@ -42,6 +45,9 @@
|
|||||||
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
||||||
crossorigin="anonymous">
|
crossorigin="anonymous">
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
|
||||||
|
</script>
|
||||||
<script
|
<script
|
||||||
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
||||||
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
||||||
@@ -53,11 +59,24 @@
|
|||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
></script>
|
></script>
|
||||||
<!-- Custom js -->
|
<!-- 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
|
<script
|
||||||
type="text/javascript"
|
type="text/javascript"
|
||||||
src="{{ url_for('.static', filename='js/script.js') }}"
|
src="{{ url_for('.static', filename='js/script.js') }}"
|
||||||
></script>
|
></script>
|
||||||
{% block datatable_scripts %}
|
{% block datatable_scripts %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% block custom_data_script %}
|
||||||
|
{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
@@ -0,0 +1,84 @@
|
|||||||
|
{% extends "admin/components/base.html" %}
|
||||||
|
{% block title %} SKA Referee Test | Detailed Results {% endblock %}
|
||||||
|
{% block navbar %}{% endblock %}
|
||||||
|
{% block top_alerts %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-center">
|
||||||
|
<h1 class="center">SKA Referee Theory Exam Results</h1>
|
||||||
|
</div>
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<ul class="list-group">
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Candidate</h5>
|
||||||
|
</div>
|
||||||
|
<h2>
|
||||||
|
{{ 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.get_email() }}
|
||||||
|
</li>
|
||||||
|
{% if entry.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.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>
|
||||||
|
{{ entry.test.get_code() }}
|
||||||
|
</li>
|
||||||
|
{% 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>
|
||||||
|
</div>
|
||||||
|
{{ 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>
|
||||||
|
<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>
|
||||||
|
{% if entry.status == 'late' %}
|
||||||
|
<span class="badge bg-danger">Late</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{{ entry.end_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||||
|
</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.result.score }}%
|
||||||
|
</li>
|
||||||
|
<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.result.grade[0]|upper }}{{ entry.result.grade[1:]}}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="site-footer mt-5">
|
||||||
|
These results were generated using the SKA RefTest web app on {{ now.strftime('%d %b %Y at %H:%M:%S') }}.
|
||||||
|
</div>
|
||||||
|
{% block footer %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@@ -0,0 +1 @@
|
|||||||
|
<div id="alert-box" tabindex="-1"></div>
|
@@ -6,6 +6,7 @@
|
|||||||
<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/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/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/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 %}
|
{% endblock %}
|
||||||
{% block datatable_scripts %}
|
{% 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/jszip/2.5.0/jszip.min.js"></script>
|
||||||
@@ -23,5 +24,5 @@
|
|||||||
<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/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/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/responsive/2.2.9/js/responsive.bootstrap5.js"></script>
|
||||||
{% block custom_data_script %}{% endblock %}
|
<script type="text/javascript" src="https://cdn.datatables.net/searchbuilder/1.3.0/js/dataTables.searchBuilder.min.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
@@ -1,2 +1,2 @@
|
|||||||
<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 the repository under an MIT License.</p>
|
<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>
|
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
111
ref-test/app/admin/templates/admin/components/navbar.html
Normal file
111
ref-test/app/admin/templates/admin/components/navbar.html
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<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 (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 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" id="nav-results">
|
||||||
|
<a href="{{ url_for('admin._view_entries') }}" id="link-results" class="nav-link">View Results</a>
|
||||||
|
</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-settings"
|
||||||
|
>
|
||||||
|
<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-account"
|
||||||
|
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">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._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>
|
17
ref-test/app/admin/templates/admin/components/og-meta.html
Normal file
17
ref-test/app/admin/templates/admin/components/og-meta.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<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) }}" />
|
||||||
|
<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" />
|
@@ -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">
|
<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>
|
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
|
||||||
{{ message|safe }}
|
{{ 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>
|
</div>
|
||||||
{% set cookie_flash_flag.value = True %}
|
{% set cookie_flash_flag.value = True %}
|
||||||
{% endif %}
|
{% endif %}
|
148
ref-test/app/admin/templates/admin/index.html
Normal file
148
ref-test/app/admin/templates/admin/index.html
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
{% extends "admin/components/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card m-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Current Exams</h5>
|
||||||
|
{% if current_tests %}
|
||||||
|
<div class="card-text">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Exam Code
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Expiry Date
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for test in current_tests %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ test.end_date.strftime('%d %b %Y') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<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._tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card m-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Recent Results</h5>
|
||||||
|
{% if recent_results %}
|
||||||
|
<div class="card-text">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Date Submitted
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Result
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for result in recent_results %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('admin._view_entry', id=result.id) }}">{{ result.get_surname() }}, {{ result.get_first_name() }}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ result.end_time.strftime('%d %b %Y %H:%M') }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ (100*result.result['score']/result.result['max'])|round|int }}% ({{ result.result.grade }})
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<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.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card m-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Upcoming Exams</h5>
|
||||||
|
{% if upcoming_tests %}
|
||||||
|
<div class="card-text">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Exam Code
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Expiry Date
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for test in upcoming_tests %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ test.end_date.strftime('%d %b %Y') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<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._tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<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>
|
||||||
|
<a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues" class="btn btn-primary">Open an Issue</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
188
ref-test/app/admin/templates/admin/result-detail.html
Normal file
188
ref-test/app/admin/templates/admin/result-detail.html
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
{% extends "admin/components/base.html" %}
|
||||||
|
{% block title %} SKA Referee Test | Detailed Results {% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% include "admin/components/client-alerts.html" %}
|
||||||
|
<h1>Exam Results</h1>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<ul class="list-group">
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Candidate</h5>
|
||||||
|
</div>
|
||||||
|
<h2>
|
||||||
|
{{ 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.get_email() }}
|
||||||
|
</li>
|
||||||
|
{% if entry.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.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>
|
||||||
|
{{ entry.test.get_code() }}
|
||||||
|
</li>
|
||||||
|
{% 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>
|
||||||
|
</div>
|
||||||
|
{{ entry.user_code }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% 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') }}
|
||||||
|
</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>
|
||||||
|
{% if entry.status == 'late' %}
|
||||||
|
<span class="badge bg-danger">Late</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if entry.end_time %}
|
||||||
|
{{ entry.end_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||||
|
{% else %}
|
||||||
|
Incomplete
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% 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.result.score }}%
|
||||||
|
</li>
|
||||||
|
<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.result.grade[0]|upper }}{{ entry.result.grade[1:]}}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% if entry.result %}
|
||||||
|
<div class="accordion" id="results-breakdown">
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header" id="by-category">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#by-category-breakdown" aria-expanded="false" aria-controls="by-category-breakdown">
|
||||||
|
Score By Categories
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="by-category-breakdown" class="accordion-collapse collapse" aria-labelledby="by-category" data-bs-parent="#results-breakdown">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Category
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Score
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Max
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for tag, scores in entry.result.tags.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ tag }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ scores.scored }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{scores.max}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header" id="by-question">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#by-question-breakdown" aria-expanded="false" aria-controls="by-question-breakdown">
|
||||||
|
View All Answers
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="by-question-breakdown" class="accordion-collapse collapse" aria-labelledby="by-question" data-bs-parent="#results-breakdown">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Question
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Answer
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for question, answer in entry.answers.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ question }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ answers[question|int][answer|int] }}
|
||||||
|
{% if not correct[question] == answer|int %}
|
||||||
|
<span class="badge badge-pill bg-danger badge-danger">Incorrect</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% 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 }}">
|
||||||
|
<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 }}">
|
||||||
|
<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 }}">
|
||||||
|
<i class="bi bi-trash-fill button-icon"></i>
|
||||||
|
Delete Result
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
138
ref-test/app/admin/templates/admin/results.html
Normal file
138
ref-test/app/admin/templates/admin/results.html
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
{% extends "admin/components/datatable.html" %}
|
||||||
|
{% block title %} SKA Referee Test | View Results {% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% include "admin/components/client-alerts.html" %}
|
||||||
|
<h1>View Results</h1>
|
||||||
|
{% if entries %}
|
||||||
|
<table id="results-table" class="table table-striped" style="width:100%">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-priority="1">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th data-priority="4">
|
||||||
|
Club
|
||||||
|
</th>
|
||||||
|
<th data-priority="5">
|
||||||
|
Exam Code
|
||||||
|
</th>
|
||||||
|
<th data-priority="3">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th data-priority="4">
|
||||||
|
Submitted
|
||||||
|
</th>
|
||||||
|
<th data-priority="2">
|
||||||
|
Result
|
||||||
|
</th>
|
||||||
|
<th data-priority="3">
|
||||||
|
Grade
|
||||||
|
</th>
|
||||||
|
<th data-priority="1">
|
||||||
|
Details
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in entries %}
|
||||||
|
<tr class="table-row">
|
||||||
|
<td>
|
||||||
|
{{ entry.get_surname() }}, {{ entry.get_first_name() }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.club %}
|
||||||
|
{{ entry.get_club() }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ entry.test.get_code() }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.status %}
|
||||||
|
{{ entry.status }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.end_time %}
|
||||||
|
{{ entry.end_time.strftime('%d %b %Y') }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.result %}
|
||||||
|
{{ entry.result.score }}%
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.result %}
|
||||||
|
{{ entry.result.grade }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="row-actions">
|
||||||
|
<a
|
||||||
|
href="{{ url_for('admin._view_entry', id = entry.id ) }}"
|
||||||
|
class="btn btn-primary entry-details"
|
||||||
|
data-id="{{entry.id}}"
|
||||||
|
title="View Details"
|
||||||
|
>
|
||||||
|
<i class="bi bi-file-medical-fill button-icon"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% 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 exam attempts to view.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% if entries %}
|
||||||
|
{% block custom_data_script %}
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('#results-table').DataTable({
|
||||||
|
'searching': false,
|
||||||
|
'columnDefs': [
|
||||||
|
{'sortable': false, 'targets': [7]},
|
||||||
|
{'searchable': false, 'targets': [7]}
|
||||||
|
],
|
||||||
|
'order': [[4, 'desc'], [0, 'asc']],
|
||||||
|
'buttons': [
|
||||||
|
{
|
||||||
|
extend: 'print',
|
||||||
|
exportOptions: {
|
||||||
|
columns: [0, 1, 3, 4, 5, 6]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extend: 'excel',
|
||||||
|
exportOptions: {
|
||||||
|
columns: [0, 1, 3, 4, 5, 6]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extend: 'pdf',
|
||||||
|
exportOptions: {
|
||||||
|
columns: [0, 1, 3, 4, 5, 6]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'responsive': 'true',
|
||||||
|
'colReorder': 'true',
|
||||||
|
'fixedHeader': 'true',
|
||||||
|
'searchBuilder': {
|
||||||
|
depthLimit: 2,
|
||||||
|
columns: [1, 5, 6],
|
||||||
|
},
|
||||||
|
dom: 'BQlfrtip'
|
||||||
|
});
|
||||||
|
// $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') -->
|
||||||
|
} );
|
||||||
|
$('#results-table').show();
|
||||||
|
$(window).trigger('resize');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
{% endif %}
|
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<form name="form-delete-user" class="form-signin">
|
<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" %}
|
{% include "admin/components/server-alerts.html" %}
|
||||||
<h2 class="form-signin-heading">Delete User ‘{{ user.username }}’?</h2>
|
<h2 class="form-heading">Delete User ‘{{ user.get_username() }}’?</h2>
|
||||||
{{ form.hidden_tag() }}
|
{{ 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>
|
<p>Are you sure you want to proceed?</p>
|
||||||
<div class="form-label-group">
|
<div class="form-label-group">
|
||||||
{{ form.password(class_="form-control", placeholder="Confirm Your Password", autofocus=true) }}
|
{{ form.password(class_="form-control", placeholder="Confirm Your Password", autofocus=true) }}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<div class="container form-submission-button">
|
<div class="container form-submission-button">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col text-center">
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
93
ref-test/app/admin/templates/admin/settings/index.html
Normal file
93
ref-test/app/admin/templates/admin/settings/index.html
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
{% extends "admin/components/base.html" %}
|
||||||
|
{% block title %}Settings — SKA Referee Test | Admin Console{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card m-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Admin Users</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Username
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Email Address
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="
|
||||||
|
{% if user == current_user %}
|
||||||
|
{{ url_for('admin._update_user', id=current_user.id) }}
|
||||||
|
{% else %}
|
||||||
|
{{ url_for('admin._update_user', id=user.id) }}
|
||||||
|
{% endif%}
|
||||||
|
">{{ user.get_username() }}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="mailto:{{ user.get_email() }}">{{ user.get_email() }}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('admin._users') }}" class="btn btn-primary">Manage Users</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card m-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Question Datasets</h5>
|
||||||
|
{% if datasets %}
|
||||||
|
<div class="card-text">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Uploaded
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Exams
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for dataset in datasets %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ dataset.date.strftime('%d %b %Y %H:%M') }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ dataset.tests|length }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<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._questions') }}" class="btn btn-primary">Upload Dataset</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
121
ref-test/app/admin/templates/admin/settings/questions.html
Normal file
121
ref-test/app/admin/templates/admin/settings/questions.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
{% extends "admin/components/datatable.html" %}
|
||||||
|
{% block title %} SKA Referee Test | Upload Questions {% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% include "admin/components/client-alerts.html" %}
|
||||||
|
<h1>Manage Question Datasets</h1>
|
||||||
|
{% if data %}
|
||||||
|
<table id="question-datasets-table" class="table table-striped" style="width:100%">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
|
||||||
|
</th>
|
||||||
|
<th data-priority="2">
|
||||||
|
Uploaded
|
||||||
|
</th>
|
||||||
|
<th data-priority="3">
|
||||||
|
Author
|
||||||
|
</th>
|
||||||
|
<th data-priority="3">
|
||||||
|
Exams
|
||||||
|
</th>
|
||||||
|
<th data-priority="1">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for element in data %}
|
||||||
|
<tr class="table-row">
|
||||||
|
<td>
|
||||||
|
{% 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"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ element.date.strftime('%d %b %Y %H:%M') }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ element.creator.get_username() }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ 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"
|
||||||
|
>
|
||||||
|
<i class="bi bi-file-earmark-text-fill button-icon"></i>
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn btn-danger edit-question-dataset {% if element.filename == default %}disabled{% endif %}"
|
||||||
|
data-filename="{{ element.filename }}"
|
||||||
|
data-action="delete"
|
||||||
|
title="Delete Dataset"
|
||||||
|
>
|
||||||
|
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% 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.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<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-upload">
|
||||||
|
{{ form.data_file() }}
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
{{ form.default(class_="form-check-input") }}
|
||||||
|
{{ form.default.label }}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
Upload Dataset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% if data %}
|
||||||
|
{% block custom_data_script %}
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('#question-datasets-table').DataTable({
|
||||||
|
'columnDefs': [
|
||||||
|
{'sortable': false, 'targets': [0,4]},
|
||||||
|
{'searchable': false, 'targets': [0,3,4]}
|
||||||
|
],
|
||||||
|
'order': [[1, 'desc'], [2, 'asc']],
|
||||||
|
'responsive': 'true',
|
||||||
|
'fixedHeader': 'true',
|
||||||
|
});
|
||||||
|
} );
|
||||||
|
$('#question-datasets-table').show();
|
||||||
|
$(window).trigger('resize');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
{% endif %}
|
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<form name="form-update-user" class="form-signin">
|
<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" %}
|
{% include "admin/components/server-alerts.html" %}
|
||||||
<h2 class="form-signin-heading">Update User ‘{{ user.username }}’</h2>
|
<h2 class="form-heading">Update User ‘{{ user.get_username() }}’</h2>
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
<div class="form-label-group">
|
<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 }}
|
{{ form.email.label }}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-label-group">
|
<div class="form-label-group">
|
||||||
@@ -23,17 +23,17 @@
|
|||||||
{{ form.notify.label }}
|
{{ form.notify.label }}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-label-group">
|
<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>
|
||||||
<div class="form-label-group">
|
<div class="form-label-group">
|
||||||
{{ form.user_password(class_="form-control", placeholder="Your Password", value = user.email) }}
|
{{ form.confirm_password(class_="form-control", placeholder="Your Password", value = user.email) }}
|
||||||
{{ form.user_password.label }}
|
{{ form.confirm_password.label }}
|
||||||
</div>
|
</div>
|
||||||
{% include "admin/components/client-alerts.html" %}
|
{% include "admin/components/client-alerts.html" %}
|
||||||
<div class="container form-submission-button">
|
<div class="container form-submission-button">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col text-center">
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
@@ -21,9 +21,9 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for user in users %}
|
{% for user in users %}
|
||||||
<tr class="user-table-row">
|
<tr class="table-row">
|
||||||
<td>
|
<td>
|
||||||
{% if user._id == get_id_from_cookie() %}
|
{% if user == current_user %}
|
||||||
<div class="text-success" title="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">
|
<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"/>
|
<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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ user.username }}
|
{{ user.get_username() }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ user.email }}
|
{{ user.get_email() }}
|
||||||
</td>
|
</td>
|
||||||
<td class="user-row-actions">
|
<td class="row-actions">
|
||||||
<a
|
<a
|
||||||
href="
|
href="
|
||||||
{% if not user._id == get_id_from_cookie() %}
|
{% if not user == current_user %}
|
||||||
{{ url_for('admin_views.update_user', _id = user._id ) }}
|
{{ url_for('admin._update_user', id = user.id ) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ url_for('admin_auth.account') }}
|
{{ url_for('admin._update_user', id=current_user.id) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
"
|
"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@@ -53,15 +53,15 @@
|
|||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="
|
href="
|
||||||
{% if not user._id == get_id_from_cookie() %}
|
{% if not user == current_user %}
|
||||||
{{ url_for('admin_views.delete_user', _id = user._id ) }}
|
{{ url_for('admin._delete_user', id = user.id ) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
#
|
#
|
||||||
{% endif %}
|
{% 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"
|
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>
|
<i class="bi bi-person-x-fill button-icon"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -71,8 +71,8 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<form name="form-create-user" class="form-signin">
|
<form name="form-create-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="">
|
||||||
<h2 class="form-signin-heading">Create User</h2>
|
<h2 class="form-heading">Create User</h2>
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
<div class="form-label-group">
|
<div class="form-label-group">
|
||||||
{{ form.username(class_="form-control", placeholder="Enter Username") }}
|
{{ form.username(class_="form-control", placeholder="Enter Username") }}
|
186
ref-test/app/admin/templates/admin/test.html
Normal file
186
ref-test/app/admin/templates/admin/test.html
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
{% extends "admin/components/base.html" %}
|
||||||
|
{% block title %} SKA Referee Test | Edit Exam {% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Edit Exam</h1>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<ul class="list-group">
|
||||||
|
<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>
|
||||||
|
<h2>
|
||||||
|
{{ 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.date.strftime('%Y%m%d%H%M%S') }}
|
||||||
|
</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.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 %H:%M') }}
|
||||||
|
</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.end_date.strftime('%d %b %Y %H:%M') }}
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Time Limit</h5>
|
||||||
|
</div>
|
||||||
|
{% if test.time_limit == None -%}
|
||||||
|
None
|
||||||
|
{% elif test.time_limit == 60 -%}
|
||||||
|
1 hour
|
||||||
|
{% elif test.time_limit == 90 -%}
|
||||||
|
1 hour 30 min
|
||||||
|
{% elif test.time_limit == 120 -%}
|
||||||
|
2 hours
|
||||||
|
{% else -%}
|
||||||
|
{{ test.time_limit }}
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
<div class="accordion" id="test-info-detail">
|
||||||
|
{% 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">
|
||||||
|
List Test Entries
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="test-entries-list" class="accordion-collapse collapse" aria-labelledby="test-entries" data-bs-parent="#test-info-detail">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<tbody>
|
||||||
|
{% for entry in test.entries %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('admin._view_entry', id=entry) }}" >Entry {{ loop.index }}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% 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">
|
||||||
|
List Time Adjustments
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="test-adjustments-list" class="accordion-collapse collapse" aria-labelledby="test-adjustments" data-bs-parent="#test-info-detail">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
User Code
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Adjustment (Minutes)
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Delete
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for key, value in test.adjustments.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ key.upper() }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ value }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="javascript::void(0);" class="btn btn-danger adjustment-delete" title="Delete Adjustment" data-user_code="{{ key }}">
|
||||||
|
<i class="bi bi-slash-circle-fill button-icon"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if not test.time_limit == None %}
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header" id="test-add-adjustments">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#test-adjustments-add" aria-expanded="false" aria-controls="test-adjustments-add">
|
||||||
|
Add Time Adjustments
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="test-adjustments-add" class="accordion-collapse collapse" aria-labelledby="test-add-adjustments" data-bs-parent="#test-info-detail">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<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 Time") }}
|
||||||
|
{{ form.time.label }}
|
||||||
|
</div>
|
||||||
|
<div class="container form-submission-button">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col text-center">
|
||||||
|
<button title="Add Time Adjustment" class="btn btn-md btn-success btn-block" type="submit">
|
||||||
|
<i class="bi bi-clock-history button-icon"></i>
|
||||||
|
Add Time Adjustment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</ul>
|
||||||
|
<div class="row">
|
||||||
|
{% include "admin/components/client-alerts.html" %}
|
||||||
|
</div>
|
||||||
|
<div class="container justify-content-center">
|
||||||
|
<div class="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-danger test-action" data-action="delete" data-id="{{ test.id }}">
|
||||||
|
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
||||||
|
Delete Exam
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@@ -1,6 +1,7 @@
|
|||||||
{% extends "admin/components/datatable.html" %}
|
{% extends "admin/components/datatable.html" %}
|
||||||
{% block title %} SKA Referee Test | Manage Exams {% endblock %}
|
{% block title %} SKA Referee Test | Manage Exams {% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include "admin/components/client-alerts.html" %}
|
||||||
<h1>Manage Exams</h1>
|
<h1>Manage Exams</h1>
|
||||||
{% include "admin/components/secondary-navs/tests.html" %}
|
{% include "admin/components/secondary-navs/tests.html" %}
|
||||||
<h2>{{ display_title }}</h2>
|
<h2>{{ display_title }}</h2>
|
||||||
@@ -30,46 +31,48 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for test in tests %}
|
{% for test in tests %}
|
||||||
<tr class="user-table-row">
|
<tr class="table-row">
|
||||||
<td>
|
<td>
|
||||||
{{ test.start_date.strftime('%d %b %Y') }}
|
{{ test.start_date.strftime('%d %b %y %H:%M') }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}
|
{{ test.get_code() }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ test.expiry_date.strftime('%d %b %Y') }}
|
{{ test.end_date.strftime('%d %b %Y %H:%M') }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if test.time_limit == None -%}
|
{% if test.time_limit == None -%}
|
||||||
None
|
None
|
||||||
{% elif test.time_limit == '60' -%}
|
{% elif test.time_limit == 60 -%}
|
||||||
1 hour
|
1 hour
|
||||||
{% elif test.time_limit == '90' -%}
|
{% elif test.time_limit == 90 -%}
|
||||||
1 hour 30 min
|
1 hour 30 min
|
||||||
{% elif test.time_limit == '120' -%}
|
{% elif test.time_limit == 120 -%}
|
||||||
2 hours
|
2 hours
|
||||||
{% else -%}
|
{% else -%}
|
||||||
{{ test.time_limit }}
|
{{ test.time_limit }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ test.attempts|length }}
|
{{ test.entries|length }}
|
||||||
</td>
|
</td>
|
||||||
<td class="test-row-actions">
|
<td class="row-actions">
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
class="btn btn-primary edit-test"
|
class="btn btn-primary test-action"
|
||||||
data-_id="{{test._id}}"
|
data-id="{{test.id}}"
|
||||||
title="Edit Exam"
|
title="Edit Exam"
|
||||||
|
data-action="edit"
|
||||||
>
|
>
|
||||||
<i class="bi bi-file-earmark-text-fill button-icon"></i>
|
<i class="bi bi-file-earmark-text-fill button-icon"></i>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
class="btn btn-danger delete-test"
|
class="btn btn-danger test-action"
|
||||||
data-_id="{{test._id}}"
|
data-id="{{test.id}}"
|
||||||
title="Delete Exam"
|
title="Delete Exam"
|
||||||
|
data-action="delete"
|
||||||
>
|
>
|
||||||
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -86,8 +89,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if form %}
|
{% if form %}
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<form name="form-create-test" class="form-signin">
|
<form name="form-create-test" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="/admin/tests/">
|
||||||
<h2 class="form-signin-heading">Create Exam</h2>
|
<h2 class="form-heading">Create Exam</h2>
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
<div class="form-date-input">
|
<div class="form-date-input">
|
||||||
{{ form.start_date(placeholder="Enter Start Date", class_ = "datepicker") }}
|
{{ form.start_date(placeholder="Enter Start Date", class_ = "datepicker") }}
|
||||||
@@ -101,6 +104,10 @@
|
|||||||
{{ form.time_limit(placeholder="Select Time Limit") }}
|
{{ form.time_limit(placeholder="Select Time Limit") }}
|
||||||
{{ form.time_limit.label }}
|
{{ form.time_limit.label }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-select-input">
|
||||||
|
{{ form.dataset(placeholder="Select Question Dataset") }}
|
||||||
|
{{ form.dataset.label }}
|
||||||
|
</div>
|
||||||
{% include "admin/components/client-alerts.html" %}
|
{% include "admin/components/client-alerts.html" %}
|
||||||
<div class="container form-submission-button">
|
<div class="container form-submission-button">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -153,7 +160,7 @@
|
|||||||
});
|
});
|
||||||
// $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') -->
|
// $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') -->
|
||||||
} );
|
} );
|
||||||
$('#test-table').show();
|
$('#active-test-table').show();
|
||||||
$(window).trigger('resize');
|
$(window).trigger('resize');
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
392
ref-test/app/admin/views.py
Normal file
392
ref-test/app/admin/views.py
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
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.forms import get_dataset_choices, get_time_options
|
||||||
|
from ..tools.data import check_is_json, validate_json
|
||||||
|
from ..tools.test import answer_options, get_correct_answers
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, render_template, redirect, request, session
|
||||||
|
from flask.helpers import flash, url_for
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from json import loads
|
||||||
|
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():
|
||||||
|
tests = Test.query.all()
|
||||||
|
results = Entry.query.all()
|
||||||
|
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, 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)
|
||||||
|
recent_results = [result for result in results if not result.status == 'started' ]
|
||||||
|
recent_results.sort(key= lambda x: x.end_time, 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():
|
||||||
|
users = User.query.all()
|
||||||
|
datasets = Dataset.query.all()
|
||||||
|
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():
|
||||||
|
users = User.query.all()
|
||||||
|
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
|
||||||
|
errors = [*form.username.errors, *form.password.errors]
|
||||||
|
return jsonify({ 'error': errors}), 400
|
||||||
|
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
|
||||||
|
errors = [*form.username.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
|
||||||
|
return jsonify({ 'error': errors}), 400
|
||||||
|
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
|
||||||
|
users = User.query.all()
|
||||||
|
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()
|
||||||
|
errors = [*form.username.errors, *form.email.errors]
|
||||||
|
return jsonify({ 'error': errors}), 400
|
||||||
|
|
||||||
|
token = request.args.get('token')
|
||||||
|
if token:
|
||||||
|
user = User.query.filter_by(reset_token=token).first()
|
||||||
|
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()
|
||||||
|
return render_template('/auth/update_password.html', form=form, user=user.id)
|
||||||
|
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 = request.form.get('user')
|
||||||
|
user = User.query.filter_by(id=user).first()
|
||||||
|
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
|
||||||
|
errors = [*form.password.errors, *form.password_reenter.errors]
|
||||||
|
return jsonify({ 'error': errors}), 401
|
||||||
|
|
||||||
|
@admin.route('/settings/users/', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def _users():
|
||||||
|
form = CreateUser()
|
||||||
|
users = User.query.all()
|
||||||
|
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
|
||||||
|
errors = [*form.username.errors, *form.email.errors, *form.password.errors]
|
||||||
|
return jsonify({ 'error': errors}), 401
|
||||||
|
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):
|
||||||
|
user = User.query.filter_by(id=id).first()
|
||||||
|
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
|
||||||
|
errors = form.password.errors
|
||||||
|
return jsonify({ 'error': errors}), 400
|
||||||
|
|
||||||
|
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):
|
||||||
|
user = User.query.filter_by(id=id).first()
|
||||||
|
form = UpdateUser()
|
||||||
|
if request.method == 'POST':
|
||||||
|
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
||||||
|
if form.validate_on_submit():
|
||||||
|
if not 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
|
||||||
|
errors = [*form.confirm_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
|
||||||
|
return jsonify({ 'error': errors}), 400
|
||||||
|
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
|
||||||
|
if not validate_json(upload): return jsonify({'error': 'The data in the file is invalid.'}), 400 # TODO Perhaps make a more complex validation script
|
||||||
|
new_dataset = Dataset()
|
||||||
|
success, message = new_dataset.create(
|
||||||
|
upload = upload,
|
||||||
|
default = request.form.get('default')
|
||||||
|
)
|
||||||
|
if success: return jsonify({'success': message}), 200
|
||||||
|
return jsonify({'error': message}), 400
|
||||||
|
errors = form.data_file.errors
|
||||||
|
return jsonify({ 'error': errors}), 400
|
||||||
|
|
||||||
|
data = Dataset.query.all()
|
||||||
|
return render_template('/admin/settings/questions.html', form=form, data=data)
|
||||||
|
|
||||||
|
@admin.route('/settings/questions/edit/', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def _edit_questions():
|
||||||
|
id = request.get_json()['id']
|
||||||
|
action = request.get_json()['action']
|
||||||
|
if action not in ['defailt', 'delete']: return jsonify({'error': 'Invalid action.'}), 400
|
||||||
|
dataset = Dataset.query.filter_by(id=id).first()
|
||||||
|
if action == 'delete': success, message = dataset.delete()
|
||||||
|
elif action == 'default': success, message = dataset.make_default()
|
||||||
|
if success: return jsonify({'success': message}), 200
|
||||||
|
return jsonify({'error': message}), 400
|
||||||
|
|
||||||
|
@admin.route('/tests/<string:filter>/', methods=['GET'])
|
||||||
|
@admin.route('/tests/', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def _tests(filter:str=None):
|
||||||
|
datasets = Dataset.query.all()
|
||||||
|
tests = None
|
||||||
|
_tests = Test.query.all()
|
||||||
|
form = None
|
||||||
|
now = datetime.now()
|
||||||
|
if not 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._questions'))
|
||||||
|
if filter not in ['create','active','scheduled','expired','all']: return redirect(url_for('admin._tests', filter='active'))
|
||||||
|
if filter == 'create':
|
||||||
|
form = CreateTest()
|
||||||
|
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 Creat 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')
|
||||||
|
new_test.dataset = Dataset.query.filter_by(id=dataset).first()
|
||||||
|
success, message = new_test.create()
|
||||||
|
if success:
|
||||||
|
flash(message=message, category='success')
|
||||||
|
return jsonify({'success': message}), 200
|
||||||
|
return jsonify({'error': message}), 400
|
||||||
|
else:
|
||||||
|
errors = [*form.start_date.errors, *form.expiry_date.errors, *form.time_limit.errors]
|
||||||
|
return jsonify({ 'error': errors}), 400
|
||||||
|
|
||||||
|
@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
|
||||||
|
test = Test.query.filter_by(id=id).first()
|
||||||
|
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()
|
||||||
|
test = Test.query.filter_by(id=id).first()
|
||||||
|
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):
|
||||||
|
test = Test.query.filter_by(id=id).first()
|
||||||
|
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():
|
||||||
|
entries = Entry.query.all()
|
||||||
|
return render_template('/admin/results.html', entries = entries)
|
||||||
|
|
||||||
|
@admin.route('/results/<string:id>/', methods = ['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def _view_entry(id:str=None):
|
||||||
|
entry = Entry.query.filter_by(id=id).first()
|
||||||
|
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
|
||||||
|
dataset = test.dataset
|
||||||
|
dataset_path = dataset.get_file()
|
||||||
|
with open(dataset_path, 'r') as _dataset:
|
||||||
|
data = loads(_dataset.read())
|
||||||
|
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 main import db
|
||||||
|
id = request.get_json()['id']
|
||||||
|
entry = Entry.query.filter_by(id=id).first()
|
||||||
|
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||||
|
return render_template('/admin/components/certificate.html', entry = entry)
|
66
ref-test/app/api/views.py
Normal file
66
ref-test/app/api/views.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from ..models import Dataset, Entry
|
||||||
|
from ..tools.test import evaluate_answers, generate_questions
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
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']
|
||||||
|
entry = Entry.query.filter_by(id=id).first()
|
||||||
|
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.utcnow() + 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']
|
||||||
|
entry = Entry.query.filter_by(id=id).first()
|
||||||
|
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
|
||||||
|
|
47
ref-test/app/config.py
Normal file
47
ref-test/app/config.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if not os.getenv('DATA'):
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv('../.env')
|
||||||
|
|
||||||
|
class Config(object):
|
||||||
|
APP_HOST = '0.0.0.0'
|
||||||
|
DATA = os.getenv('DATA')
|
||||||
|
DEBUG = False
|
||||||
|
TESTING = False
|
||||||
|
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||||
|
SERVER_NAME = os.getenv('SERVER_NAME')
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(DATA)}/database.db'
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
|
MAIL_SERVER = os.getenv('MAIL_SERVER')
|
||||||
|
MAIL_PORT = int(os.getenv('MAIL_PORT'))
|
||||||
|
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'))
|
||||||
|
MAIL_SUPPRESS_SEND = False
|
||||||
|
MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS'))
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DevelopmentConfig(Config):
|
||||||
|
APP_HOST = '127.0.0.1'
|
||||||
|
DEBUG = True
|
||||||
|
SESSION_COOKIE_SECURE = False
|
||||||
|
MAIL_SERVER = 'localhost'
|
||||||
|
MAIL_DEBUG = True
|
||||||
|
MAIL_SUPPRESS_SEND = False
|
||||||
|
|
||||||
|
class TestingConfig(DevelopmentConfig):
|
||||||
|
TESTING = True
|
||||||
|
SESSION_COOKIE_SECURE = False
|
||||||
|
MAIL_SERVER = os.getenv('MAIL_SERVER')
|
||||||
|
MAIL_DEBUG = True
|
||||||
|
MAIL_SUPPRESS_SEND = False
|
5
ref-test/app/data.py
Normal file
5
ref-test/app/data.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from config import Config
|
||||||
|
from os import path
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
data = Path(Config.DATA)
|
@@ -1,56 +1,64 @@
|
|||||||
from flask_wtf import FlaskForm
|
from ..tools.forms import value
|
||||||
from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField
|
|
||||||
from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional
|
|
||||||
from datetime import date, timedelta
|
|
||||||
|
|
||||||
class LoginForm(FlaskForm):
|
from flask_wtf import FlaskForm
|
||||||
|
from flask_wtf.file import FileAllowed, FileField, FileRequired
|
||||||
|
from wtforms import BooleanField, IntegerField, PasswordField, SelectField, StringField
|
||||||
|
from wtforms.fields import DateTimeLocalField
|
||||||
|
from wtforms.validators import InputRequired, Email, EqualTo, Length, Optional
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
class Login(FlaskForm):
|
||||||
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
||||||
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||||
remember = BooleanField('Remember Log In', render_kw={'checked': True})
|
remember = BooleanField('Remember Log In', render_kw={'checked': True})
|
||||||
|
|
||||||
class RegistrationForm(FlaskForm):
|
class Register(FlaskForm):
|
||||||
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
||||||
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||||
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||||
password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')])
|
password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')])
|
||||||
|
|
||||||
class ResetPasswordForm(FlaskForm):
|
class ResetPassword(FlaskForm):
|
||||||
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
||||||
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||||
|
|
||||||
class UpdatePasswordForm(FlaskForm):
|
class UpdatePassword(FlaskForm):
|
||||||
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||||
password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')])
|
password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')])
|
||||||
|
|
||||||
class CreateUserForm(FlaskForm):
|
class CreateUser(FlaskForm):
|
||||||
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
||||||
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||||
password = PasswordField('Password (Optional)', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
password = PasswordField('Password (Optional)', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||||
|
notify = BooleanField('Notify accout creation by email', render_kw={'checked': True})
|
||||||
|
|
||||||
class DeleteUserForm(FlaskForm):
|
class DeleteUser(FlaskForm):
|
||||||
password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||||
notify = BooleanField('Notify deletion by email', render_kw={'checked': True})
|
notify = BooleanField('Notify deletion by email', render_kw={'checked': True})
|
||||||
|
|
||||||
class UpdateUserForm(FlaskForm):
|
class UpdateUser(FlaskForm):
|
||||||
user_password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
confirm_password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||||
email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||||
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||||
password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')])
|
password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')])
|
||||||
notify = BooleanField('Notify changes by email', render_kw={'checked': True})
|
notify = BooleanField('Notify changes by email', render_kw={'checked': True})
|
||||||
|
|
||||||
class UpdateAccountForm(FlaskForm):
|
class UpdateAccount(FlaskForm):
|
||||||
password_confirm = PasswordField('Current Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
confirm_password = PasswordField('Current Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||||
email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||||
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||||
password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')])
|
password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')])
|
||||||
|
|
||||||
class CreateTest(FlaskForm):
|
class CreateTest(FlaskForm):
|
||||||
start_date = DateField('Start Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() )
|
start_date = DateTimeLocalField('Start Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()], default = datetime.now() )
|
||||||
time_options = [
|
expiry_date = DateTimeLocalField('Expiry Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()], default = date.today() + timedelta(days=1) )
|
||||||
('none', 'None'),
|
time_limit = SelectField('Time Limit')
|
||||||
('60', '1 hour'),
|
dataset = SelectField('Question Dataset')
|
||||||
('90', '1 hour 30 minutes'),
|
|
||||||
('120', '2 hours')
|
class UploadData(FlaskForm):
|
||||||
]
|
data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])])
|
||||||
expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) )
|
default = BooleanField('Make Default', render_kw={'checked': True})
|
||||||
time_limit = SelectField('Time Limit', choices=time_options)
|
|
||||||
|
class AddTimeAdjustment(FlaskForm):
|
||||||
|
time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)])
|
@@ -1,6 +1,6 @@
|
|||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, PasswordField, BooleanField
|
from wtforms import StringField
|
||||||
from wtforms.validators import InputRequired, Email, Length, Optional
|
from wtforms.validators import InputRequired, Length, Email, Optional
|
||||||
|
|
||||||
class StartQuiz(FlaskForm):
|
class StartQuiz(FlaskForm):
|
||||||
first_name = StringField('First Name(s)', validators=[InputRequired(), Length(max=30)])
|
first_name = StringField('First Name(s)', validators=[InputRequired(), Length(max=30)])
|
4
ref-test/app/models/__init__.py
Normal file
4
ref-test/app/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .entry import Entry
|
||||||
|
from .test import Test
|
||||||
|
from .user import User
|
||||||
|
from .dataset import Dataset
|
83
ref-test/app/models/dataset.py
Normal file
83
ref-test/app/models/dataset.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
from ..data import data
|
||||||
|
from ..modules import db
|
||||||
|
from ..tools.logs import write
|
||||||
|
|
||||||
|
from flask import flash
|
||||||
|
from flask_login import current_user
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from json import dump, loads
|
||||||
|
from os import path, remove
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
class Dataset(db.Model):
|
||||||
|
|
||||||
|
id = db.Column(db.String(36), primary_key=True)
|
||||||
|
tests = db.relationship('Test', backref='dataset')
|
||||||
|
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
|
||||||
|
date = db.Column(db.DateTime, nullable=False)
|
||||||
|
default = db.Column(db.Boolean, default=False, nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Dataset {self.id}> was added.'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
|
||||||
|
|
||||||
|
generate_id.setter
|
||||||
|
def generate_id(self): self.id = uuid4().hex
|
||||||
|
|
||||||
|
def make_default(self):
|
||||||
|
for dataset in Dataset.query.all():
|
||||||
|
dataset.default = False
|
||||||
|
self.default = True
|
||||||
|
db.session.commit()
|
||||||
|
write('system.log', f'Dataset {self.id} set as default by {current_user.get_username()}.')
|
||||||
|
flash(message='Dataset set as default.', category='success')
|
||||||
|
return True, f'Dataset set as default.'
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
if self.default:
|
||||||
|
message = 'Cannot delete the default dataset.'
|
||||||
|
flash(message, 'error')
|
||||||
|
return False, message
|
||||||
|
if Dataset.query.all().count() == 1:
|
||||||
|
message = 'Cannot delete the only dataset.'
|
||||||
|
flash(message, 'error')
|
||||||
|
return False, message
|
||||||
|
write('system.log', f'Dataset {self.id} deleted by {current_user.get_username()}.')
|
||||||
|
filename = secure_filename('.'.join([self.id,'json']))
|
||||||
|
file_path = path.join(data, 'questions', filename)
|
||||||
|
remove(file_path)
|
||||||
|
db.session.delete(self)
|
||||||
|
db.session.commit()
|
||||||
|
return True, 'Dataset deleted.'
|
||||||
|
|
||||||
|
def create(self, upload, default:bool=False):
|
||||||
|
self.generate_id()
|
||||||
|
timestamp = datetime.now()
|
||||||
|
filename = secure_filename('.'.join([self.id,'json']))
|
||||||
|
file_path = path.join(data, 'questions', filename)
|
||||||
|
upload.stream.seek(0)
|
||||||
|
questions = loads(upload.read())
|
||||||
|
with open(file_path, 'w') as file:
|
||||||
|
dump(questions, file, indent=2)
|
||||||
|
self.date = timestamp
|
||||||
|
self.creator = current_user
|
||||||
|
if default: self.make_default()
|
||||||
|
write('system.log', f'New dataset {self.id} added by {current_user.get_username()}.')
|
||||||
|
db.session.add(self)
|
||||||
|
db.session.commit()
|
||||||
|
return True, 'Dataset uploaded.'
|
||||||
|
|
||||||
|
def check_file(self):
|
||||||
|
filename = secure_filename('.'.join([self.id,'json']))
|
||||||
|
file_path = path.join(data, 'questions', filename)
|
||||||
|
if not path.isfile(file_path): return False, 'Data file is missing.'
|
||||||
|
return True, 'Data file found.'
|
||||||
|
|
||||||
|
def get_file(self):
|
||||||
|
filename = secure_filename('.'.join([self.id,'json']))
|
||||||
|
file_path = path.join(data, 'questions', filename)
|
||||||
|
return file_path
|
177
ref-test/app/models/entry.py
Normal file
177
ref-test/app/models/entry.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
from ..modules import db, mail
|
||||||
|
from ..tools.forms import JsonEncodedDict
|
||||||
|
from ..tools.encryption import decrypt, encrypt
|
||||||
|
from ..tools.logs import write
|
||||||
|
from .test import Test
|
||||||
|
|
||||||
|
from flask_login import current_user
|
||||||
|
from flask_mail import Message
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
class Entry(db.Model):
|
||||||
|
|
||||||
|
id = db.Column(db.String(36), primary_key=True)
|
||||||
|
first_name = db.Column(db.String(128), nullable=False)
|
||||||
|
surname = db.Column(db.String(128), nullable=False)
|
||||||
|
email = db.Column(db.String(128), nullable=False)
|
||||||
|
club = db.Column(db.String(128), nullable=True)
|
||||||
|
test_id = db.Column(db.String(36), db.ForeignKey('test.id'))
|
||||||
|
user_code = db.Column(db.String(6), nullable=True)
|
||||||
|
start_time = db.Column(db.DateTime, nullable=True)
|
||||||
|
end_time = db.Column(db.DateTime, nullable=True)
|
||||||
|
status = db.Column(db.String(16), nullable=True)
|
||||||
|
valid = db.Column(db.Boolean, default=True, nullable=True)
|
||||||
|
answers = db.Column(JsonEncodedDict, nullable=True)
|
||||||
|
result = db.Column(JsonEncodedDict, nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<New entry by {self.first_name} {self.surname}> was added with <id {self.id}>.'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
|
||||||
|
|
||||||
|
generate_id.setter
|
||||||
|
def generate_id(self): self.id = uuid4().hex
|
||||||
|
|
||||||
|
@property
|
||||||
|
def set_first_name(self): raise AttributeError('set_first_name is not a readable attribute.')
|
||||||
|
|
||||||
|
set_first_name.setter
|
||||||
|
def set_first_name(self, name:str): self.first_name = encrypt(name)
|
||||||
|
|
||||||
|
def get_first_name(self): return decrypt(self.first_name)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def set_surname(self): raise AttributeError('set_first_name is not a readable attribute.')
|
||||||
|
|
||||||
|
set_surname.setter
|
||||||
|
def set_surname(self, name:str): self.surname = encrypt(name)
|
||||||
|
|
||||||
|
def get_surname(self): return decrypt(self.surname)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def set_email(self): raise AttributeError('set_email is not a readable attribute.')
|
||||||
|
|
||||||
|
set_email.setter
|
||||||
|
def set_email(self, email:str): self.email = encrypt(email)
|
||||||
|
|
||||||
|
def get_email(self): return decrypt(self.email)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def set_club(self): raise AttributeError('set_club is not a readable attribute.')
|
||||||
|
|
||||||
|
set_club.setter
|
||||||
|
def set_club(self, club:str): self.club = encrypt(club)
|
||||||
|
|
||||||
|
def get_club(self): return decrypt(self.club)
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
self.generate_id()
|
||||||
|
db.session.add(self)
|
||||||
|
db.session.commit()
|
||||||
|
write('tests.log', f'New test ready for {self.get_first_name()} {self.get_surname()}.')
|
||||||
|
return True, f'Test ready.'
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.start_time = datetime.now()
|
||||||
|
self.status = 'started'
|
||||||
|
write('tests.log', f'Test started by {self.get_first_name()} {self.get_surname()}.')
|
||||||
|
db.session.commit()
|
||||||
|
return True, f'New test started with id {self.id}.'
|
||||||
|
|
||||||
|
def complete(self, answers:dict=None, result:dict=None):
|
||||||
|
self.end_time = datetime.now()
|
||||||
|
self.answers = answers
|
||||||
|
self.result = result
|
||||||
|
write('tests.log', f'Test completed by {self.get_first_name()} {self.get_surname()}.')
|
||||||
|
delta = timedelta(minutes=int(0 if self.test.time_limit is None else self.test.time_limit)+1)
|
||||||
|
if not self.test.time_limit or self.end_time <= self.start_time + delta:
|
||||||
|
self.status = 'completed'
|
||||||
|
self.valid = True
|
||||||
|
else:
|
||||||
|
self.status = 'late'
|
||||||
|
self.valid = False
|
||||||
|
db.session.commit()
|
||||||
|
return True, f'Test entry completed for id {self.id}.'
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
if self.valid: return False, f'The entry is already valid.'
|
||||||
|
if self.status == 'started': return False, 'The entry is still pending.'
|
||||||
|
self.valid = True
|
||||||
|
self.status = 'completed'
|
||||||
|
db.session.commit()
|
||||||
|
write('system.log', f'The entry {self.id} has been validated by {current_user.get_username()}.')
|
||||||
|
return True, f'The entry {self.id} has been validated.'
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
id = self.id
|
||||||
|
name = f'{self.get_first_name()} {self.get_surname()}'
|
||||||
|
db.session.delete(self)
|
||||||
|
db.session.commit()
|
||||||
|
write('system.log', f'The entry {id} by {name} has been deleted by {current_user.get_username()}.')
|
||||||
|
return True, 'Entry deleted.'
|
||||||
|
|
||||||
|
def notify_result(self):
|
||||||
|
score = round(100*self.result['score']/self.result['max'])
|
||||||
|
tags_low = { tag: tag_result['max'] - tag_result['scored'] for tag, tag_result in self.result['tags'].items() }
|
||||||
|
sorted_low_tags = sorted(tags_low.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
tag_output = [ tag[0] for tag in sorted_low_tags[0:3] if tag[1] > 3]
|
||||||
|
revision_plain = ''
|
||||||
|
revision_html = ''
|
||||||
|
if self.result['grade'] == 'pass':
|
||||||
|
flavour_text = """Well done on successfully completing the refereeing theory exam. We really appreciate members of our community taking the time to get qualified to referee.
|
||||||
|
"""
|
||||||
|
elif self.result['grade'] == 'merit':
|
||||||
|
flavour_text = """Congratulations on achieving a merit in the refereeing exam. We are delighted that members of our community work so hard with refereeing.
|
||||||
|
"""
|
||||||
|
elif self.result['grade'] == 'fail':
|
||||||
|
flavour_text = """Unfortunately, you were not successful in passing the theory exam in this attempt. We hope that this does not dissuade you, and that you try again in the future.
|
||||||
|
"""
|
||||||
|
revision_plain = f"""Based on your answers, we would also suggest you brush up on the following topics for your next attempt:\n\n
|
||||||
|
{','.join(tag_output)}\n\n
|
||||||
|
"""
|
||||||
|
revision_html = f"""<p>Based on your answers, we would also suggest you brush up on the following topics for your next attempt:</p>
|
||||||
|
<ul>
|
||||||
|
<li>{'</li><li>'.join(tag_output)}</li>
|
||||||
|
</ul>
|
||||||
|
"""
|
||||||
|
email = Message(
|
||||||
|
subject='RefTest | SKA Refereeing Theory Exam Results',
|
||||||
|
recipients=[self.get_email()],
|
||||||
|
body=f"""
|
||||||
|
SKA Refereeing Theory Exam
|
||||||
|
Candidate Results
|
||||||
|
Dear {self.get_first_name()},
|
||||||
|
This email is to confirm that you have taken the SKA Refereeing Theory Exam. Your submission has been evaluated and your results are as follows:
|
||||||
|
{self.get_surname()}, {self.get_first_name()}
|
||||||
|
Email Address: {self.get_email()}
|
||||||
|
{f'Club: {self.get_club()}' if self.club else ''}
|
||||||
|
Date of Exam: {self.end_time.strftime('%d %b %Y')}
|
||||||
|
Score: {score}%
|
||||||
|
Grade: {self.result['grade']}
|
||||||
|
{flavour_text}
|
||||||
|
{revision_plain}
|
||||||
|
Thank you for taking the time to become a qualified referee.
|
||||||
|
Best wishes,
|
||||||
|
SKA Refereeing
|
||||||
|
""",
|
||||||
|
html=f"""
|
||||||
|
<h1>SKA Refereeing Theory Exam</h1>
|
||||||
|
<h2>Candidate Results</h2>
|
||||||
|
<p>Dear {self.get_first_name()},</p>
|
||||||
|
<p>This email is to confirm that you have taken the SKA Refereeing Theory Exam. Your submission has been evaluated and your results are as follows:</p>
|
||||||
|
<h3>{self.get_surname()}, {self.get_first_name()}</h3>
|
||||||
|
<p><strong>Email Address</strong>: {self.get_email()}</p>
|
||||||
|
{f'<p><strong>Club</strong>: {self.get_club()}</p>' if self.club else ''}
|
||||||
|
<h1>{score}%</h1>
|
||||||
|
<h2>{self.result['grade']}</h2>
|
||||||
|
<p>{flavour_text}</p>
|
||||||
|
{revision_html}
|
||||||
|
<p>Thank you for taking the time to become a qualified referee.</p>
|
||||||
|
<p>Have a nice day!</p>
|
||||||
|
<p>Best wishes, <br/> SKA Refereeing</p>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
mail.send(email)
|
111
ref-test/app/models/test.py
Normal file
111
ref-test/app/models/test.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
from ..modules import db
|
||||||
|
from ..tools.encryption import decrypt, encrypt
|
||||||
|
from ..tools.forms import JsonEncodedDict
|
||||||
|
from ..tools.logs import write
|
||||||
|
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
import secrets
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
class Test(db.Model):
|
||||||
|
|
||||||
|
id = db.Column(db.String(36), primary_key=True)
|
||||||
|
code = db.Column(db.String(36), nullable=False)
|
||||||
|
start_date = db.Column(db.DateTime, nullable=True)
|
||||||
|
end_date = db.Column(db.DateTime, nullable=True)
|
||||||
|
time_limit = db.Column(db.Integer, nullable=True)
|
||||||
|
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
|
||||||
|
dataset_id = db.Column(db.String(36), db.ForeignKey('dataset.id'))
|
||||||
|
adjustments = db.Column(JsonEncodedDict, nullable=True)
|
||||||
|
entries = db.relationship('Entry', backref='test')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Test with code {self.get_code()} was created by {current_user.get_username()}.>'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
|
||||||
|
|
||||||
|
generate_id.setter
|
||||||
|
def generate_id(self): self.id = uuid4().hex
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generate_code(self): raise AttributeError('generate_code is not a readable attribute.')
|
||||||
|
|
||||||
|
generate_code.setter
|
||||||
|
def generate_code(self): self.code = secrets.token_hex(6).lower()
|
||||||
|
|
||||||
|
def get_code(self):
|
||||||
|
code = self.code.upper()
|
||||||
|
return '—'.join([code[:4], code[4:8], code[8:]])
|
||||||
|
|
||||||
|
def create(self):
|
||||||
|
self.generate_id()
|
||||||
|
self.generate_code()
|
||||||
|
self.creator = current_user
|
||||||
|
errors = []
|
||||||
|
if self.start_date.date() < date.today():
|
||||||
|
errors.append('The start date cannot be in the past.')
|
||||||
|
if self.end_date.date() < date.today():
|
||||||
|
errors.append('The expiry date cannot be in the past.')
|
||||||
|
if self.end_date < self.start_date:
|
||||||
|
errors.append('The expiry date cannot be before the start date.')
|
||||||
|
if errors:
|
||||||
|
return False, errors
|
||||||
|
db.session.add(self)
|
||||||
|
db.session.commit()
|
||||||
|
write('system.log', f'Test with code {self.get_code()} created by {current_user.get_username()}.')
|
||||||
|
return True, f'Test with code {self.get_code()} has been created.'
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
code = self.code
|
||||||
|
if self.entries: return False, f'Cannot delete a test with submitted entries.'
|
||||||
|
db.session.delete(self)
|
||||||
|
db.session.commit()
|
||||||
|
write('system.log', f'Test with code {self.get_code()} has been deleted by {current_user.get_username()}.')
|
||||||
|
return True, f'Test with code {self.get_code()} has been deleted.'
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
now = datetime.now()
|
||||||
|
if self.start_date.date() > now.date():
|
||||||
|
self.start_date = now
|
||||||
|
db.session.commit()
|
||||||
|
write('system.log', f'Test with code {self.get_code()} has been started by {current_user.get_username()}.')
|
||||||
|
return True, f'Test with code {self.get_code()} has been started.'
|
||||||
|
return False, f'Test with code {self.get_code()} has already started.'
|
||||||
|
|
||||||
|
def end(self):
|
||||||
|
now = datetime.now()
|
||||||
|
if self.end_date >= now:
|
||||||
|
self.end_date = now
|
||||||
|
db.session.commit()
|
||||||
|
write('system.log', f'Test with code {self.get_code()} ended by {current_user.get_username()}.')
|
||||||
|
return True, f'Test with code {self.get_code()} has been ended.'
|
||||||
|
return False, f'Test with code {self.get_code()} has already ended.'
|
||||||
|
|
||||||
|
def add_adjustment(self, time:int):
|
||||||
|
adjustments = self.adjustments if self.adjustments is not None else {}
|
||||||
|
code = secrets.token_hex(3).lower()
|
||||||
|
adjustments[code] = time
|
||||||
|
self.adjustments = adjustments
|
||||||
|
db.session.commit()
|
||||||
|
write('system.log', f'Time adjustment for {time} minutes with code {code} added to test {self.get_code()} by {current_user.get_username()}.')
|
||||||
|
return True, f'Time adjustment for {time} minutes added to test {self.get_code()}. This can be accessed using the user code {code.upper()}.'
|
||||||
|
|
||||||
|
def remove_adjustment(self, code:str):
|
||||||
|
if not self.adjustments: return False, f'There are no adjustments configured for test {self.get_code()}.'
|
||||||
|
self.adjustments.pop(code)
|
||||||
|
if not self.adjustments: self.adjustments = None
|
||||||
|
db.session.commit()
|
||||||
|
write('system.log', f'Time adjustment for with code {code} has been removed from test {self.get_code()} by {current_user.get_username()}.')
|
||||||
|
return True, f'Time adjustment for with code {code} has been removed from test {self.get_code()}.'
|
||||||
|
|
||||||
|
def update(self, start_date:datetime=None, end_date:datetime=None, time_limit:int=None):
|
||||||
|
if not start_date and not end_date and time_limit is None: return False, 'There were no changes requested.'
|
||||||
|
if start_date: self.start_date = start_date
|
||||||
|
if end_date: self.end_date = end_date
|
||||||
|
if time_limit is not None: self.time_limit = time_limit
|
||||||
|
db.session.commit()
|
||||||
|
write('system.log', f'Test with code {self.get_code()} has been updated by user {current_user.get_username()}.')
|
||||||
|
return True, f'Test with code {self.get_code()} has been updated by.'
|
223
ref-test/app/models/user.py
Normal file
223
ref-test/app/models/user.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
from ..modules import db, mail
|
||||||
|
from ..tools.encryption import decrypt, encrypt
|
||||||
|
from ..tools.logs import write
|
||||||
|
|
||||||
|
from flask import flash, jsonify, session
|
||||||
|
from flask.helpers import url_for
|
||||||
|
from flask_login import current_user, login_user, logout_user, UserMixin
|
||||||
|
from flask_mail import Message
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from uuid import uuid4
|
||||||
|
class User(UserMixin, db.Model):
|
||||||
|
id = db.Column(db.String(36), primary_key=True)
|
||||||
|
username = db.Column(db.String(128), nullable=False)
|
||||||
|
password = db.Column(db.String(128), nullable=False)
|
||||||
|
email = db.Column(db.String(128), nullable=False)
|
||||||
|
reset_token = db.Column(db.String(20), nullable=True)
|
||||||
|
verification_token = db.Column(db.String(20), nullable=True)
|
||||||
|
tests = db.relationship('Test', backref='creator')
|
||||||
|
datasets = db.relationship('Dataset', backref='creator')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<user {self.username}> was added with <id {self.id}>.'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
|
||||||
|
|
||||||
|
generate_id.setter
|
||||||
|
def generate_id(self): self.id = uuid4().hex
|
||||||
|
|
||||||
|
@property
|
||||||
|
def set_username(self): raise AttributeError('set_username is not a readable attribute.')
|
||||||
|
|
||||||
|
set_username.setter
|
||||||
|
def set_username(self, username:str): self.username = encrypt(username)
|
||||||
|
|
||||||
|
def get_username(self): return decrypt(self.username)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def set_password(self): raise AttributeError('set_password is not a readable attribute.')
|
||||||
|
|
||||||
|
set_password.setter
|
||||||
|
def set_password(self, password:str): self.password = generate_password_hash(password, method="sha256")
|
||||||
|
|
||||||
|
def verify_password(self, password:str): return check_password_hash(self.password, password)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def set_email(self): raise AttributeError('set_email is not a readable attribute.')
|
||||||
|
|
||||||
|
set_email.setter
|
||||||
|
def set_email(self, email:str): self.email = encrypt(email)
|
||||||
|
|
||||||
|
def get_email(self): return decrypt(self.email)
|
||||||
|
|
||||||
|
def register(self, notify:bool=False, password:str=None):
|
||||||
|
self.generate_id()
|
||||||
|
users = User.query.all()
|
||||||
|
for user in users:
|
||||||
|
if user.get_username() == self.get_username(): return False, f'Username {self.get_username()} already in use.'
|
||||||
|
if user.get_email() == self.get_email(): return False, f'Email address {self.get_email()} already in use.'
|
||||||
|
self.set_password(password=password)
|
||||||
|
db.session.add(self)
|
||||||
|
db.session.commit()
|
||||||
|
write('users.log', f'User \'{self.get_username()}\' was created with id \'{self.id}\'.')
|
||||||
|
if notify:
|
||||||
|
email = Message(
|
||||||
|
subject='RefTest | Registration Confirmation',
|
||||||
|
recipients=[self.email],
|
||||||
|
body=f"""
|
||||||
|
Hello {self.get_username()},\n\n
|
||||||
|
You have been registered as an administrator on the SKA RefTest App!\n\n
|
||||||
|
You can access your account using the username '{self.get_username()}'\n\n
|
||||||
|
Your password is as follows:\n\n
|
||||||
|
{password}\n\n
|
||||||
|
You can log in to the admin console via the following URL, where you can administer the test or change your password:\n\n
|
||||||
|
{url_for('admin._home', _external=True)}\n\n
|
||||||
|
Have a nice day!\n\n
|
||||||
|
SKA Refereeing
|
||||||
|
""",
|
||||||
|
html=f"""
|
||||||
|
<p>Hello {self.get_username()},</p>
|
||||||
|
<p>You have been registered as an administrator on the SKA RefTest App!</p>
|
||||||
|
<p>You can access your account using the username '{self.get_username()}'</p>
|
||||||
|
<p>Your password is as follows:</p>
|
||||||
|
<strong>{password}</strong>
|
||||||
|
<p>You can log in to the admin console via the following URL, where you can administer the test or change your password:</p>
|
||||||
|
<p><a href='{url_for('admin._home', _external=True)}'>{url_for('admin._home', _external=True)}</a></p>
|
||||||
|
<p>Have a nice day!</p>
|
||||||
|
<p>SKA Refereeing</p>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
mail.send(email)
|
||||||
|
return True, f'User {self.get_username()} was created successfully.'
|
||||||
|
|
||||||
|
def login(self, remember:bool=False):
|
||||||
|
login_user(self, remember = remember)
|
||||||
|
write('users.log', f'User \'{self.get_username()}\' has logged in.')
|
||||||
|
flash(message=f'Welcome {self.get_username()}', category='success')
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
session['remembered_username'] = self.get_username()
|
||||||
|
logout_user()
|
||||||
|
write('users.log', f'User \'{self.get_username()}\' has logged out.')
|
||||||
|
flash(message='You have successfully logged out.', category='success')
|
||||||
|
|
||||||
|
def reset_password(self):
|
||||||
|
new_password = secrets.token_hex(12)
|
||||||
|
self.set_password(new_password)
|
||||||
|
self.reset_token = secrets.token_urlsafe(16)
|
||||||
|
self.verification_token = secrets.token_urlsafe(16)
|
||||||
|
db.session.commit()
|
||||||
|
email = Message(
|
||||||
|
subject='RefTest | Password Reset',
|
||||||
|
recipients=[self.get_email()],
|
||||||
|
body=f"""
|
||||||
|
Hello {self.get_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
|
||||||
|
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._reset', token = self.reset_token, verification = self.verification_token, _external = True)}\n\n
|
||||||
|
Hopefully, this should enable access to your account once again.\n\n
|
||||||
|
Have a nice day!\n\n
|
||||||
|
SKA Refereeing
|
||||||
|
""",
|
||||||
|
html=f"""
|
||||||
|
<p>Hello {self.get_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>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. Copy and paste the following link in a web browser. Please note that this token is only valid once:</p>
|
||||||
|
<p><a href='{url_for('admin._reset', token = self.reset_token, verification = self.verification_token, _external = True)}'>{url_for('admin._reset', token = self.reset_token, verification = self.verification_token, _external = True)}</a></p>
|
||||||
|
<p>Hopefully, this should enable access to your account once again.</p>
|
||||||
|
<p>Have a nice day!</p>
|
||||||
|
<p>SKA Refereeing</p>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
mail.send(email)
|
||||||
|
print('Password', new_password)
|
||||||
|
print('Reset Token', self.reset_token)
|
||||||
|
print('Verification Token', self.verification_token)
|
||||||
|
print('Reset Link', f'{url_for("admin._reset", token=self.reset_token, verification=self.verification_token, _external=True)}')
|
||||||
|
return jsonify({'success': 'Your password reset link has been generated.'}), 200
|
||||||
|
|
||||||
|
def clear_reset_tokens(self):
|
||||||
|
self.reset_token = self.verification_token = None
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
def delete(self, notify:bool=False):
|
||||||
|
username = self.get_username()
|
||||||
|
email_address = self.get_email()
|
||||||
|
db.session.delete(self)
|
||||||
|
db.session.commit()
|
||||||
|
message = f'User \'{username}\' was deleted by \'{current_user.get_username()}\'.'
|
||||||
|
write('users.log', message)
|
||||||
|
if notify:
|
||||||
|
email = Message(
|
||||||
|
subject='RefTest | Account Deletion',
|
||||||
|
recipients=[email_address],
|
||||||
|
bcc=[current_user.get_email()],
|
||||||
|
body=f"""
|
||||||
|
Hello {username},\n\n
|
||||||
|
Your administrator account for the SKA RefTest App, as well as all data associated with the account, have been deleted by {current_user.get_username()}.\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!\n\n
|
||||||
|
SKA Refereeing
|
||||||
|
""",
|
||||||
|
html=f"""
|
||||||
|
<p>Hello {username},</p>
|
||||||
|
<p>Your administrator account for the SKA RefTest App, as well as all data associated with the account, have been deleted by {current_user.get_username()}.</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>
|
||||||
|
<p>SKA Refereeing</p>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
mail.send(email)
|
||||||
|
return True, message
|
||||||
|
|
||||||
|
def update(self, password:str=None, email:str=None, notify:bool=False):
|
||||||
|
if not password and not email: return False, 'There were no changes requested.'
|
||||||
|
if password: self.set_password(password)
|
||||||
|
old_email = self.get_email()
|
||||||
|
if email: self.set_email(email)
|
||||||
|
db.session.commit()
|
||||||
|
write('system.log', f'Information for user {self.get_username()} has been updated by {current_user.get_username()}.')
|
||||||
|
if notify:
|
||||||
|
message = Message(
|
||||||
|
subject='RefTest | Account Update',
|
||||||
|
recipients=[email],
|
||||||
|
bcc=[old_email,current_user.get_email()],
|
||||||
|
body=f"""
|
||||||
|
Hello {self.get_username()},\n\n
|
||||||
|
Your administrator account for the SKA RefTest App has been updated by {current_user.get_username()}.\n\n
|
||||||
|
Your new account details are as follows:\n\n
|
||||||
|
Email: {email}\n
|
||||||
|
Password: {password if password else '<same as old>'}\n\n
|
||||||
|
You can update your email address and password by logging in to the admin console using the following URL:\n\n
|
||||||
|
{url_for('admin._home', _external=True)}\n\n
|
||||||
|
Have a nice day!\n\n
|
||||||
|
SKA Refereeing
|
||||||
|
""",
|
||||||
|
html=f"""
|
||||||
|
<p>Hello {self.get_username()},</p>
|
||||||
|
<p>Your administrator account for the SKA RefTest App has been updated by {current_user.get_username()}.</p>
|
||||||
|
<p>Your new account details are as follows:</p>
|
||||||
|
<p>Email: {email} <br/> Password: <strong>{password if password else '<same as old>'}</strong></p>
|
||||||
|
<p>You can update your email address and password by logging in to the admin console using the following URL:</p>
|
||||||
|
<p><a href='{url_for('admin._home', _external=True)}'>{url_for('admin._home', _external=True)}</a></p>
|
||||||
|
<p>Have a nice day!</p>
|
||||||
|
<p>SKA Refereeing</p>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
mail.send(message)
|
||||||
|
return True, f'Account {self.get_username()} has been updated.'
|
10
ref-test/app/modules.py
Normal file
10
ref-test/app/modules.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from flask_bootstrap import Bootstrap
|
||||||
|
bootstrap = Bootstrap()
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
csrf = CSRFProtect()
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
db = SQLAlchemy()
|
||||||
|
from flask_login import LoginManager
|
||||||
|
login_manager = LoginManager()
|
||||||
|
from flask_mail import Mail
|
||||||
|
mail = Mail()
|
158
ref-test/app/quiz/static/css/quiz.css
Normal file
158
ref-test/app/quiz/static/css/quiz.css
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/*! Generated by Font Squirrel (https://www.fontsquirrel.com) on November 23, 2021 */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'opendyslexic3bold';
|
||||||
|
src: url('../fonts/opendyslexic3-bold-webfont.woff2') format('woff2'),
|
||||||
|
url('../fonts/opendyslexic3-bold-webfont.woff') format('woff');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'opendyslexic3regular';
|
||||||
|
src: url('../fonts/opendyslexic3-regular-webfont.woff2') format('woff2'),
|
||||||
|
url('../fonts/opendyslexic3-regular-webfont.woff') format('woff');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'opendyslexicmonoregular';
|
||||||
|
src: url('../fonts/opendyslexicmono-regular-webfont.woff2') format('woff2'),
|
||||||
|
url('../fonts/opendyslexicmono-regular-webfont.woff') format('woff');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Class Definitions */
|
||||||
|
|
||||||
|
.form-quiz-configure {
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-opendyslexic {
|
||||||
|
font-family: 'opendyslexic3bold';
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-comicsans {
|
||||||
|
font-family: 'Comic Sans MS', 'Comic Sans';
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-osdefault {
|
||||||
|
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-verdana {
|
||||||
|
font-family: Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-tahoma {
|
||||||
|
font-family: Tahoma, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-arial {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-12pt {
|
||||||
|
font-size: 12pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-14pt {
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-16pt {
|
||||||
|
font-size: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-f-18pt {
|
||||||
|
font-size: 18pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-settings-element {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-bg-light-1 {
|
||||||
|
background-color: beige;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-bg-light-2 {
|
||||||
|
background-color: #EBE3E1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sample-question {
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-container {
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 2 rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-title {
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-header {
|
||||||
|
margin: 1rem auto 3rem;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-quiz-control {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
#q-topbar a.btn {
|
||||||
|
padding: 2px 6px 0px 6px;
|
||||||
|
font-size: 14pt;
|
||||||
|
height: fit-content;
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-timer {
|
||||||
|
padding-top: 0px;
|
||||||
|
margin: 0px auto;
|
||||||
|
font-size: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-navigator-button {
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button-container {
|
||||||
|
width: fit-content;
|
||||||
|
margin: 2rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button-container a {
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigator-help {
|
||||||
|
margin: 4rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navigator-container {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout for Mobile Devices */
|
||||||
|
@media only screen and (max-width: 576px) {
|
||||||
|
body {
|
||||||
|
padding-top: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .container {
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,13 +1,27 @@
|
|||||||
.bg-light {
|
|
||||||
background-color: #EBE3E1!important;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-size: 14pt;
|
font-size: 14pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#cookie-alert {
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dismiss-cookie-alert {
|
||||||
|
margin-top: 16px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-container {
|
||||||
|
margin: 2rem auto;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-container {
|
||||||
|
margin: 2rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
.site-footer {
|
.site-footer {
|
||||||
background-color: lightgray;
|
background-color: lightgray;
|
||||||
font-size: small;
|
font-size: small;
|
||||||
@@ -111,7 +125,7 @@ body {
|
|||||||
color: #777;
|
color: #777;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-check {
|
.form-check-margin {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,33 +151,47 @@ body {
|
|||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*! Generated by Font Squirrel (https://www.fontsquirrel.com) on November 23, 2021 */
|
.results-name {
|
||||||
|
margin: 3rem auto;
|
||||||
@font-face {
|
|
||||||
font-family: 'opendyslexic3bold';
|
|
||||||
src: url('../fonts/opendyslexic3-bold-webfont.woff2') format('woff2'),
|
|
||||||
url('../fonts/opendyslexic3-bold-webfont.woff') format('woff');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
.results-name .surname {
|
||||||
font-family: 'opendyslexic3regular';
|
font-variant: small-caps;
|
||||||
src: url('../fonts/opendyslexic3-regular-webfont.woff2') format('woff2'),
|
font-size: 24pt;
|
||||||
url('../fonts/opendyslexic3-regular-webfont.woff') format('woff');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
.results-score {
|
||||||
font-family: 'opendyslexicmonoregular';
|
margin: 2rem auto;
|
||||||
src: url('../fonts/opendyslexicmono-regular-webfont.woff2') format('woff2'),
|
width: fit-content;
|
||||||
url('../fonts/opendyslexicmono-regular-webfont.woff') format('woff');
|
font-size: 36pt;
|
||||||
font-weight: normal;
|
}
|
||||||
font-style: normal;
|
|
||||||
|
|
||||||
|
.results-score::after {
|
||||||
|
content: '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-grade {
|
||||||
|
margin: 2rem auto;
|
||||||
|
width: fit-content;
|
||||||
|
font-size: 26pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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
|
/* Fallback for Edge
|
BIN
ref-test/app/quiz/static/favicon.ico
Normal file
BIN
ref-test/app/quiz/static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
BIN
ref-test/app/quiz/static/favicon.png
Normal file
BIN
ref-test/app/quiz/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 84 KiB |
BIN
ref-test/app/quiz/static/fonts/opendyslexic3-bold-webfont.woff
Normal file
BIN
ref-test/app/quiz/static/fonts/opendyslexic3-bold-webfont.woff
Normal file
Binary file not shown.
BIN
ref-test/app/quiz/static/fonts/opendyslexic3-bold-webfont.woff2
Normal file
BIN
ref-test/app/quiz/static/fonts/opendyslexic3-bold-webfont.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2
ref-test/app/quiz/static/js/jquery-3.6.0.min.js
vendored
Normal file
2
ref-test/app/quiz/static/js/jquery-3.6.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
650
ref-test/app/quiz/static/js/quiz.js
Normal file
650
ref-test/app/quiz/static/js/quiz.js
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
// Bind Listeners
|
||||||
|
|
||||||
|
$("input[name='font-select']").change(function(){
|
||||||
|
let $choice = $(this).val();
|
||||||
|
set_font($choice);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("input[name='font-size']").change(function(){
|
||||||
|
let $choice = $(this).val();
|
||||||
|
set_font_size($choice);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("input[name='bg-select']").change(function(){
|
||||||
|
let $choice = $(this).val();
|
||||||
|
set_bg_colour($choice);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".bg-select-area").click(function(event){
|
||||||
|
$(this).find("input[name='bg-select']").prop("checked", true).change();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#btn-toggle-navigator").click(function(event){
|
||||||
|
check_answered();
|
||||||
|
update_navigator();
|
||||||
|
if ($quiz_navigator.is(":hidden")) {
|
||||||
|
if ($quiz_settings.is(":visible")) {
|
||||||
|
toggle_settings = true;
|
||||||
|
$quiz_settings.fadeOut();
|
||||||
|
}
|
||||||
|
$quiz_render.fadeOut();
|
||||||
|
$quiz_navigator.fadeIn();
|
||||||
|
$(".navigator-text").fadeIn();
|
||||||
|
$(".review-text").fadeOut();
|
||||||
|
toggle_navigator = false;
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
} else {
|
||||||
|
$quiz_navigator.fadeOut();
|
||||||
|
if (toggle_settings) {
|
||||||
|
$quiz_settings.fadeIn();
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
toggle_settings = false;
|
||||||
|
} else {
|
||||||
|
$quiz_render.fadeIn();
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#btn-toggle-settings").click(function(event){
|
||||||
|
if (($quiz_settings).is(":hidden")) {
|
||||||
|
if ($quiz_navigator.is(":visible")) {
|
||||||
|
toggle_navigator = true;
|
||||||
|
$quiz_navigator.fadeOut();
|
||||||
|
}
|
||||||
|
$quiz_render.fadeOut();
|
||||||
|
$quiz_settings.fadeIn();
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
toggle_settings = false;
|
||||||
|
} else {
|
||||||
|
$quiz_settings.fadeOut();
|
||||||
|
if (toggle_navigator) {
|
||||||
|
$quiz_navigator.fadeIn();
|
||||||
|
toggle_navigator = false;
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
} else {
|
||||||
|
$quiz_render.fadeIn();
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".btn-quiz-return").click(function(event){
|
||||||
|
$quiz_navigator.fadeOut();
|
||||||
|
$quiz_settings.fadeOut();
|
||||||
|
$quiz_render.fadeIn();
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
toggle_settings = false;
|
||||||
|
toggle_navigator = false;
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".btn-dummy").click(function(event){
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#navigator-container").on("click", ".q-navigator-button", function(event){
|
||||||
|
check_answered();
|
||||||
|
update_navigator();
|
||||||
|
current_question = parseInt($(this).prop("name"));
|
||||||
|
$quiz_navigator.fadeOut();
|
||||||
|
$quiz_render.fadeIn();
|
||||||
|
$question_title.focus();
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
toggle_navigator = false;
|
||||||
|
toggle_settings = false;
|
||||||
|
render_question();
|
||||||
|
check_flag();
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".q-question-nav").click(function(event){
|
||||||
|
check_answered();
|
||||||
|
update_navigator();
|
||||||
|
if ($(this).prop("id") == "q-nav-next") {
|
||||||
|
if (current_question < questions.length) {
|
||||||
|
current_question ++;
|
||||||
|
}
|
||||||
|
} else if ($(this).prop("id") == "q-nav-prev") {
|
||||||
|
if (current_question > 0) {
|
||||||
|
current_question --;
|
||||||
|
}
|
||||||
|
} else if ($(this).hasClass("q-navigator-button")) {
|
||||||
|
current_question = $(this).prop("name");
|
||||||
|
$quiz_render.fadeIn();
|
||||||
|
$quiz_navigator.fadeOut();
|
||||||
|
toggle_navigator = false;
|
||||||
|
toggle_settings = false;
|
||||||
|
}
|
||||||
|
render_question();
|
||||||
|
check_flag();
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#q-nav-flag").click(function(event){
|
||||||
|
if (question_status[current_question] != 1) {
|
||||||
|
question_status[current_question] = 1;
|
||||||
|
$(this).removeClass().addClass("btn btn-warning");
|
||||||
|
$(this).prop("title", "Question Flagged for revision. Click to un-flag.");
|
||||||
|
} else {
|
||||||
|
question_status[current_question] = 0;
|
||||||
|
$(this).removeClass().addClass("btn btn-secondary");
|
||||||
|
$(this).prop("title", "Question Un-Flagged. Click to flag for revision.");
|
||||||
|
}
|
||||||
|
window.localStorage.setItem('question_status', JSON.stringify(question_status));
|
||||||
|
update_navigator();
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#btn-start-quiz").click(function(event){
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: `/api/questions/`,
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'id': id}),
|
||||||
|
contentType: "application/json",
|
||||||
|
success: function(response) {
|
||||||
|
$(this).fadeOut();
|
||||||
|
$(".btn-quiz-return").fadeIn();
|
||||||
|
$(".quiz-console").fadeIn();
|
||||||
|
$("#quiz-settings").fadeOut();
|
||||||
|
$("#quiz-navigator").fadeOut();
|
||||||
|
$(".quiz-start-text").fadeOut();
|
||||||
|
time_limit = response.time_limit;
|
||||||
|
start_time = response.start_time;
|
||||||
|
questions = response.questions;
|
||||||
|
total_questions = questions.length;
|
||||||
|
window.localStorage.setItem('questions', JSON.stringify(questions));
|
||||||
|
window.localStorage.setItem('start_time', JSON.stringify(start_time));
|
||||||
|
window.localStorage.setItem('time_limit', JSON.stringify(time_limit));
|
||||||
|
render_question();
|
||||||
|
build_navigator();
|
||||||
|
check_flag();
|
||||||
|
if (time_limit != 'null' && time_limit != null) {
|
||||||
|
$("#q-timer-widget").fadeIn();
|
||||||
|
time_remaining = get_time_remaining();
|
||||||
|
clock = setInterval(timer, 1000);
|
||||||
|
}
|
||||||
|
if (response.time_adjustment > 0) {
|
||||||
|
const $alert = $("#alert-box");
|
||||||
|
$alert.html(
|
||||||
|
`<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" title="Alert"></i>
|
||||||
|
User code validated. Extra time of ${response.time_adjustment} minutes added to the exam time limit.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
);
|
||||||
|
$alert.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(response) {
|
||||||
|
error_response(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#quiz-question-options").on("change", ".quiz-option", function(event){
|
||||||
|
$name = parseInt($(this).prop("name"));
|
||||||
|
$value = $(this).prop("value");
|
||||||
|
answers[$name] = $value;
|
||||||
|
window.localStorage.setItem('answers', JSON.stringify(answers));
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#q-review-answers").click(function(event){
|
||||||
|
check_answered();
|
||||||
|
update_navigator();
|
||||||
|
if ($quiz_navigator.is(":hidden")) {
|
||||||
|
if ($quiz_settings.is(":visible")) {
|
||||||
|
toggle_settings = true;
|
||||||
|
$quiz_settings.fadeOut();
|
||||||
|
}
|
||||||
|
$quiz_render.fadeOut();
|
||||||
|
$quiz_navigator.fadeIn();
|
||||||
|
$(".navigator-text").fadeOut();
|
||||||
|
$(".review-text").fadeIn();
|
||||||
|
toggle_navigator = false;
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
} else {
|
||||||
|
$quiz_navigator.fadeOut();
|
||||||
|
if (toggle_settings) {
|
||||||
|
$quiz_settings.fadeIn();
|
||||||
|
toggle_settings = false;
|
||||||
|
} else {
|
||||||
|
$quiz_render.fadeIn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".quiz-button-submit").click(function(event){
|
||||||
|
let submission = {
|
||||||
|
'id': id,
|
||||||
|
'answers': answers
|
||||||
|
}
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: `/api/submit/`,
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify(submission),
|
||||||
|
contentType: "application/json",
|
||||||
|
success: function(response) {
|
||||||
|
window.localStorage.clear();
|
||||||
|
window.location.href = `/result/`;
|
||||||
|
},
|
||||||
|
error: function(response) {
|
||||||
|
error_response(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Functions
|
||||||
|
|
||||||
|
function set_font(value = 'osdefault') {
|
||||||
|
let font_styles = ['arial', 'comicsans', 'opendyslexic', 'tahoma', 'verdana']
|
||||||
|
|
||||||
|
for (let i = 0; i < font_styles.length; i ++) {
|
||||||
|
if (font_styles[i] != value) {
|
||||||
|
$("body").removeClass( `q-f-${font_styles[i]}` );
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (value != 'osdefault') {
|
||||||
|
$("body").addClass(`q-f-${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
display_settings['font-select'] = value;
|
||||||
|
window.localStorage.setItem('display_settings', JSON.stringify(display_settings));
|
||||||
|
$('input[name="font-select"][value="' + value + '"]').prop('checked', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function set_font_size(value = '14pt') {
|
||||||
|
let font_sizes = ['12pt', '16pt', '18pt']
|
||||||
|
|
||||||
|
for (let i = 0; i < font_sizes.length; i ++) {
|
||||||
|
if (font_sizes[i] != value) {
|
||||||
|
$("body").removeClass( `q-f-${font_sizes[i]}` );
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (value != '14pt') {
|
||||||
|
$("body").addClass(`q-f-${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
display_settings['font-size'] = value;
|
||||||
|
window.localStorage.setItem('display_settings', JSON.stringify(display_settings));
|
||||||
|
$('input[name="font-size"][value="' + value + '"]').prop('checked', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function set_bg_colour(value = 'bg-light') {
|
||||||
|
let backgrounds = ['bg-light', 'q-bg-light-1', 'q-bg-light-2', 'alert-primary', 'alert-secondary', 'alert-dark', 'bg-dark']
|
||||||
|
|
||||||
|
for (let i = 0; i < backgrounds.length; i ++) {
|
||||||
|
if (backgrounds[i] != value) {
|
||||||
|
$("body").removeClass(backgrounds[i]);
|
||||||
|
if (backgrounds[i] == 'bg-dark') {
|
||||||
|
$("body").removeClass('text-light');
|
||||||
|
};
|
||||||
|
if (backgrounds[i] == 'alert-primary' || backgrounds[i] == 'alert-secondary' || backgrounds[i] == 'alert-dark') {
|
||||||
|
$("body").removeClass('text-dark');
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
$("body").addClass(value);
|
||||||
|
if (value == 'bg-dark') {
|
||||||
|
$("body").addClass('text-light');
|
||||||
|
};
|
||||||
|
if (value == 'alert-primary' || value == 'alert-secondary' || value == 'alert-dark') {
|
||||||
|
$("body").addClass('text-dark');
|
||||||
|
};
|
||||||
|
|
||||||
|
display_settings['bg-select'] = value;
|
||||||
|
window.localStorage.setItem('display_settings', JSON.stringify(display_settings));
|
||||||
|
$('input[name="bg-select"][value="' + value + '"]').prop('checked', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_settings_from_storage() {
|
||||||
|
let display_settings = window.localStorage.getItem('display_settings')
|
||||||
|
if (display_settings != null) {
|
||||||
|
return JSON.parse(display_settings);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
'font-select': 'osdefault',
|
||||||
|
'font-size': '14pt',
|
||||||
|
'bg-select': 'bg-light'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply_settings(settings) {
|
||||||
|
set_font(settings['font-select']);
|
||||||
|
set_font_size(settings['font-size']);
|
||||||
|
set_bg_colour(settings['bg-select']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_question() {
|
||||||
|
if (current_question == 0) {
|
||||||
|
$nav_prev.addClass('disabled');
|
||||||
|
}
|
||||||
|
if (current_question == questions.length - 1) {
|
||||||
|
$nav_next.addClass('disabled');
|
||||||
|
}
|
||||||
|
if ($nav_prev.hasClass('disabled') && current_question > 0) {
|
||||||
|
$nav_prev.removeClass('disabled');
|
||||||
|
}
|
||||||
|
if ($nav_next.hasClass('disabled') && current_question < questions.length - 1) {
|
||||||
|
$nav_next.removeClass('disabled');
|
||||||
|
}
|
||||||
|
var question = questions[current_question];
|
||||||
|
let header_text = question.question_header;
|
||||||
|
var block_length = 0;
|
||||||
|
if ('block_length' in question) {
|
||||||
|
block_length = question['block_length'];
|
||||||
|
};
|
||||||
|
var block_q_no = 0;
|
||||||
|
if ('block_q_no' in question) {
|
||||||
|
block_q_no = question['block_q_no'];
|
||||||
|
}
|
||||||
|
let remaining_qs = (block_length - block_q_no).toString();
|
||||||
|
if (block_length - block_q_no > 1) {
|
||||||
|
remaining_qs += ' questions';
|
||||||
|
} else {
|
||||||
|
remaining_qs += ' question';
|
||||||
|
}
|
||||||
|
header_text = header_text.replace('<block_remaining_questions>', remaining_qs);
|
||||||
|
$question_header.html(header_text);
|
||||||
|
$question_text.html(question.text);
|
||||||
|
$question_title.html(`Question ${current_question + 1} of ${ questions.length }.`);
|
||||||
|
|
||||||
|
var q_no = question['q_no'];
|
||||||
|
var options = question.options;
|
||||||
|
var options_output = '';
|
||||||
|
for (let i = 0; i < options.length; i ++) {
|
||||||
|
var add_checked = ''
|
||||||
|
if (q_no in answers) {
|
||||||
|
if (answers[q_no] == options[i][0]) {
|
||||||
|
add_checked = 'checked';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options_output += `<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input quiz-option" id="q${current_question}-${i}" name="${q_no}" value="${options[i][0]}" ${add_checked}>
|
||||||
|
<label for="q${current_question}-${i}" class="form-check-label">${options[i][1]}</label>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
$question_options.html(options_output);
|
||||||
|
let skipped = count_questions(-1);
|
||||||
|
let answered = count_questions(2);
|
||||||
|
let flagged = count_questions(1);
|
||||||
|
|
||||||
|
$progress_skipped.prop('title', `Skipped: ${skipped}`);
|
||||||
|
$progress_skipped.prop('aria-valuenow', skipped);
|
||||||
|
$progress_skipped.css('width', `${skipped}%`);
|
||||||
|
$skipped_count.text(`Skipped: ${skipped}`);
|
||||||
|
if (skipped < 1) {
|
||||||
|
$skipped_count.fadeOut()
|
||||||
|
} else {
|
||||||
|
$skipped_count.fadeIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
$progress_flagged.prop('title', `Flagged: ${flagged}`);
|
||||||
|
$progress_flagged.prop('aria-valuenow', flagged);
|
||||||
|
$progress_flagged.css('width', `${flagged}%`);
|
||||||
|
$flagged_count.text(`Flagged: ${flagged}`);
|
||||||
|
if (flagged < 1) {
|
||||||
|
$flagged_count.fadeOut()
|
||||||
|
} else {
|
||||||
|
$flagged_count.fadeIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
$progress_answered.prop('title', `Answered: ${answered}`);
|
||||||
|
$progress_answered.prop('aria-valuenow', answered);
|
||||||
|
$progress_answered.css('width', `${answered}%`);
|
||||||
|
$answered_count.text(`Answered: ${answered}`);
|
||||||
|
if (answered < 1) {
|
||||||
|
$answered_count.fadeOut()
|
||||||
|
} else {
|
||||||
|
$answered_count.fadeIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
$question_title.focus();
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function check_answered() {
|
||||||
|
var question = questions[current_question];
|
||||||
|
var name = question.q_no;
|
||||||
|
if (question_status[current_question] == 0 || question_status[current_question] == -1) {
|
||||||
|
if (!$(`input[name='${name}']:checked`).val()) {
|
||||||
|
question_status[current_question] = -1;
|
||||||
|
} else {
|
||||||
|
question_status[current_question] = 2;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem('question_status', JSON.stringify(question_status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function check_flag() {
|
||||||
|
if (!(current_question in question_status)) {
|
||||||
|
question_status[current_question] = 0;
|
||||||
|
window.localStorage.setItem('question_status', JSON.stringify(question_status));
|
||||||
|
}
|
||||||
|
switch (question_status[current_question]) {
|
||||||
|
case -1:
|
||||||
|
$nav_flag.removeClass().addClass('btn btn-danger progress-bar-striped');
|
||||||
|
$nav_flag.prop("title", "Question Incomplete. Click to flag for revision.");
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
$nav_flag.removeClass().addClass('btn btn-warning');
|
||||||
|
$nav_flag.prop("title", "Question Flagged for revision. Click to un-flag.");
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
$nav_flag.removeClass().addClass('btn btn-success');
|
||||||
|
$nav_flag.prop("title", "Question Answered. Click to flag for revision.");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$nav_flag.removeClass().addClass('btn btn-secondary');
|
||||||
|
$nav_flag.prop("title", "Question Un-Flagged. Click to flag for revision.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function build_navigator() {
|
||||||
|
$nav_container.html('')
|
||||||
|
var output = ''
|
||||||
|
for (let i = 0; i < questions.length; i ++) {
|
||||||
|
let add_class, add_href, add_status = '';
|
||||||
|
switch (question_status[i]) {
|
||||||
|
case -1:
|
||||||
|
add_class = 'btn-danger progress-bar-striped';
|
||||||
|
add_href = 'href="#"';
|
||||||
|
add_status = 'Incomplete';
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
add_class = 'btn-warning';
|
||||||
|
add_href = 'href="#"';
|
||||||
|
add_status = 'Flagged';
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
add_class = 'btn-success';
|
||||||
|
add_href = 'href="#"';
|
||||||
|
add_status = 'Answered';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
add_class = 'btn-secondary disabled';
|
||||||
|
add_href = '';
|
||||||
|
add_status = 'Unseen';
|
||||||
|
}
|
||||||
|
output += `<a ${add_href} class="q-navigator-button btn ${add_class}" name=${i} title="Question ${i+1}: ${add_status}">Q${i + 1}</a>`;
|
||||||
|
}
|
||||||
|
$nav_container.html(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_navigator() {
|
||||||
|
let button = $(`.q-navigator-button[name=${current_question}]`)
|
||||||
|
if (current_question in question_status) {
|
||||||
|
switch (question_status[current_question]) {
|
||||||
|
case -1:
|
||||||
|
button.removeClass().addClass("q-navigator-button btn btn-danger progress-bar-striped");
|
||||||
|
button.prop("title", `Question ${current_question + 1}: Incomplete`);
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
button.removeClass().addClass("q-navigator-button btn btn-warning");
|
||||||
|
button.prop("title", `Question ${current_question + 1}: Flagged`);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
button.removeClass().addClass("q-navigator-button btn btn-success");
|
||||||
|
button.prop("title", `Question ${current_question + 1}: Answered`);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
button.removeClass().addClass("q-navigator-button btn btn-secondary disabled");
|
||||||
|
button.prop("title", `Question ${current_question + 1}: Unseen`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
$("#btn-start-quiz").fadeOut();
|
||||||
|
$(".btn-quiz-return").fadeIn();
|
||||||
|
$(".quiz-console").fadeIn();
|
||||||
|
$("#quiz-settings").fadeOut();
|
||||||
|
$("#quiz-navigator").fadeOut();
|
||||||
|
$(".quiz-start-text").fadeOut();
|
||||||
|
|
||||||
|
questions = JSON.parse(window.localStorage.getItem('questions'));
|
||||||
|
total_questions = questions.length;
|
||||||
|
start_time = window.localStorage.getItem('start_time');
|
||||||
|
time_limit = window.localStorage.getItem('time_limit');
|
||||||
|
|
||||||
|
let get_answers = window.localStorage.getItem('answers');
|
||||||
|
if (get_answers != null) {
|
||||||
|
answers = JSON.parse(get_answers);
|
||||||
|
}
|
||||||
|
|
||||||
|
let get_status = window.localStorage.getItem('question_status');
|
||||||
|
if (get_status != null) {
|
||||||
|
question_status = JSON.parse(get_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
render_question();
|
||||||
|
build_navigator();
|
||||||
|
check_flag();
|
||||||
|
if (time_limit != 'null' && time_limit != null) {
|
||||||
|
$("#q-timer-widget").fadeIn();
|
||||||
|
time_remaining = get_time_remaining();
|
||||||
|
clock = setInterval(timer, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function check_started() {
|
||||||
|
let questions = window.localStorage.getItem('questions');
|
||||||
|
let time_limit = window.localStorage.getItem('time_limit');
|
||||||
|
let start_time = window.localStorage.getItem('start_time')
|
||||||
|
if (questions != null && start_time != null && time_limit != null) {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_time_remaining() {
|
||||||
|
var end_time = new Date(time_limit).getTime();
|
||||||
|
var _start_time = new Date().getTime();
|
||||||
|
return end_time - _start_time;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timer() {
|
||||||
|
var hours = Math.floor((time_remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||||
|
var minutes = Math.floor((time_remaining % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
var seconds = Math.floor((time_remaining % (1000 * 60)) / 1000);
|
||||||
|
|
||||||
|
if (time_remaining > 0) {
|
||||||
|
var timer_display = '';
|
||||||
|
if (hours > 0) {
|
||||||
|
timer_display = `${hours.toString()}:`;
|
||||||
|
}
|
||||||
|
if (minutes > 0 || hours > 0) {
|
||||||
|
if (minutes < 10) {
|
||||||
|
timer_display += `0${minutes.toString()}:`;
|
||||||
|
} else {
|
||||||
|
timer_display += `${minutes.toString()}:`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seconds < 10) {
|
||||||
|
timer_display += `0${seconds.toString()}`;
|
||||||
|
} else {
|
||||||
|
timer_display += seconds.toString();
|
||||||
|
}
|
||||||
|
$timer.html(timer_display);
|
||||||
|
time_remaining -= 1000
|
||||||
|
} else {
|
||||||
|
$timer.html('Expired');
|
||||||
|
clearInterval(clock);
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
$quiz_render.fadeOut();
|
||||||
|
$quiz_navigator.fadeOut();
|
||||||
|
$quiz_timeout.fadeIn();
|
||||||
|
$("#btn-toggle-navigator").addClass('disabled');
|
||||||
|
$("#btn-toggle-settings").addClass('disabled')
|
||||||
|
}
|
||||||
|
|
||||||
|
function count_questions(status) {
|
||||||
|
output = 0;
|
||||||
|
for (let i = 0; i < Object.keys(question_status).length; i++) {
|
||||||
|
key = Object.keys(question_status)[i];
|
||||||
|
if (question_status[key] == status){
|
||||||
|
output ++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variable Definitions
|
||||||
|
|
||||||
|
const id = window.localStorage.getItem('id');
|
||||||
|
|
||||||
|
var current_question = 0;
|
||||||
|
var total_questions = 0;
|
||||||
|
var question_status = {};
|
||||||
|
var answers = {};
|
||||||
|
var questions = [];
|
||||||
|
var time_limit, start_time, time_remaining;
|
||||||
|
|
||||||
|
var display_settings = get_settings_from_storage();
|
||||||
|
|
||||||
|
const $quiz_settings = $("#quiz-settings");
|
||||||
|
const $quiz_navigator = $("#quiz-navigator");
|
||||||
|
const $quiz_render = $("#quiz-render");
|
||||||
|
const $quiz_timeout = $("#quiz-timeout");
|
||||||
|
const $nav_flag = $("#q-nav-flag");
|
||||||
|
const $nav_next = $("#q-nav-next");
|
||||||
|
const $nav_prev = $("#q-nav-prev");
|
||||||
|
const $nav_container = $("#navigator-container");
|
||||||
|
const $timer = $("#q-timer-display");
|
||||||
|
var clock
|
||||||
|
|
||||||
|
var toggle_settings = false;
|
||||||
|
var toggle_navigator = false;
|
||||||
|
|
||||||
|
const $question_title = $("#quiz-question-title");
|
||||||
|
const $question_header = $("#quiz-question-header");
|
||||||
|
const $question_text = $("#quiz-question-text");
|
||||||
|
const $question_options = $("#quiz-question-options");
|
||||||
|
|
||||||
|
const $progress_skipped = $("#skipped-bar");
|
||||||
|
const $progress_answered = $("#answered-bar");
|
||||||
|
const $progress_flagged = $("#flagged-bar");
|
||||||
|
const $skipped_count = $("#skipped-count");
|
||||||
|
const $answered_count = $("#answered-count");
|
||||||
|
const $flagged_count = $("#flagged-count");
|
||||||
|
|
||||||
|
// Execution on Load
|
||||||
|
|
||||||
|
apply_settings(display_settings);
|
||||||
|
check_started();
|
86
ref-test/app/quiz/static/js/script.js
Normal file
86
ref-test/app/quiz/static/js/script.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
$(document).ready(function() {
|
||||||
|
$("#od-font-test").click(function(){
|
||||||
|
$("body").css("font-family", "opendyslexic3regular")
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.test-code-input').keyup(function() {
|
||||||
|
var input = $(this).val().split("-").join("").split("—").join("");
|
||||||
|
if (input.length > 0) {
|
||||||
|
input = input.match(new RegExp('.{1,4}', 'g')).join("—");
|
||||||
|
}
|
||||||
|
$(this).val(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$('form[name=form-quiz-start]').submit(function(event) {
|
||||||
|
|
||||||
|
var $form = $(this);
|
||||||
|
var data = $form.serialize();
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: window.location.pathname,
|
||||||
|
type: 'POST',
|
||||||
|
data: data,
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(response) {
|
||||||
|
var id = response.id
|
||||||
|
window.localStorage.setItem('id', id);
|
||||||
|
window.location.href = `/quiz/`;
|
||||||
|
},
|
||||||
|
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 (var 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
})
|
279
ref-test/app/quiz/templates/quiz/client.html
Normal file
279
ref-test/app/quiz/templates/quiz/client.html
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
{% extends "quiz/components/base.html" %}
|
||||||
|
|
||||||
|
{% block style %}
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('.static', filename='css/quiz.css') }}"
|
||||||
|
/>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div id="alert-box" tabindex="-1"></div>
|
||||||
|
<div class="container quiz-panel" id="quiz-settings" tabindex="-1">
|
||||||
|
<h1>Adjust Display Settings</h1>
|
||||||
|
<div class="container quiz-start-text">
|
||||||
|
You can use this panel to adjust the display settings for the exam. Please use the menu below to select the font face and font size. Below is a sample question so you can see how the exam will render with your chosen settings.
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-primary quiz-start-text" role="alert">
|
||||||
|
<strong>Note</strong>: Some fonts may not be available depending on your device and/or operating system.
|
||||||
|
</div>
|
||||||
|
<form action="#" name="quiz-configuration">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row gx-5 gy-5">
|
||||||
|
<div class="col">
|
||||||
|
<h5>
|
||||||
|
Select Font
|
||||||
|
</h5>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="osdefault" name="font-select" value="osdefault" checked>
|
||||||
|
<label for="osdefault" class="form-check-label q-f-osdefault">OS Default</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="arial" name="font-select" value="arial">
|
||||||
|
<label for="arial" class="form-check-label q-f-arial">Arial</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="comicsans" name="font-select" value="comicsans">
|
||||||
|
<label for="comicsans" class="form-check-label q-f-comicsans">Comic Sans</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="opendyslexic" name="font-select" value="opendyslexic">
|
||||||
|
<label for="opendyslexic" class="form-check-label q-f-opendyslexic">OpenDyslexic</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="tahoma" name="font-select" value="tahoma">
|
||||||
|
<label for="tahoma" class="form-check-label q-f-tahoma">Tahoma</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="verdana" name="font-select" value="verdana">
|
||||||
|
<label for="verdana" class="form-check-label q-f-verdana">Verdana</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<h5>
|
||||||
|
Select Font Size
|
||||||
|
</h5>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="12pt" name="font-size" value="12pt" checked>
|
||||||
|
<label for="12pt" class="form-check-label q-f-12pt">12pt</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="14pt" name="font-size" value="14pt" checked>
|
||||||
|
<label for="14pt" class="form-check-label q-f-14pt">14pt (Default)</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="16pt" name="font-size" value="16pt">
|
||||||
|
<label for="16pt" class="form-check-label q-f-16pt">16pt</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="18pt" name="font-size" value="18pt">
|
||||||
|
<label for="18pt" class="form-check-label q-f-18pt">18pt</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row gx-5 gy-5 mt-1">
|
||||||
|
<div class="col">
|
||||||
|
<h5>Select Background Colour</h5>
|
||||||
|
<div class="p-3 bg-light text-dark bg-select-area">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="bg-light" name="bg-select" value="bg-light" checked>
|
||||||
|
<label for="bg-light" class="form-check-label">Default Light</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 q-bg-light-1 text-dark bg-select-area">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="q-bg-light-1" name="bg-select" value="q-bg-light-1">
|
||||||
|
<label for="q-bg-light-1" class="form-check-label">Light Shade 1</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 q-bg-light-2 text-dark bg-select-area">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="q-bg-light-2" name="bg-select" value="q-bg-light-2">
|
||||||
|
<label for="q-bg-light-2" class="form-check-label">Light Shade 2</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 alert-primary text-dark bg-select-area">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="alert-primary" name="bg-select" value="alert-primary">
|
||||||
|
<label for="alert-primary" class="form-check-label">Blue</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 alert-secondary text-dark bg-select-area">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="alert-secondary" name="bg-select" value="alert-secondary">
|
||||||
|
<label for="alert-secondary" class="form-check-label">Grey 1</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 alert-dark text-dark bg-select-area">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="alert-dark" name="bg-select" value="alert-dark">
|
||||||
|
<label for="alert-dark" class="form-check-label">Grey 2</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-dark text-light bg-select-area">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="bg-dark" name="bg-select" value="bg-dark">
|
||||||
|
<label for="bg-dark" class="form-check-label">Dark</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="container question-container quiz-start-text">
|
||||||
|
<h4 class="question-title">Sample Question</h4>
|
||||||
|
<p class="question-header">
|
||||||
|
Korfball is a mixed-sex, controlled-contact, indoor, invasion ball sport. The sport originated in the Netherlands. It is a mixed-sex team sport. Its governing body is the International Korball Federation. There are numerous korfball leagues and associations around the world. A korfball match is officiated by a referee.
|
||||||
|
</p>
|
||||||
|
<p class="question-text">
|
||||||
|
In order to be a referee, what do you need to know?
|
||||||
|
</p>
|
||||||
|
<div class="options">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="sample0" name="sample" value="0">
|
||||||
|
<label for="sample0" class="form-check-label">The rules of korfball</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="sample1" name="sample" value="1">
|
||||||
|
<label for="sample1" class="form-check-label">The way of the Jedi Order</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="sample2" name="sample" value="2">
|
||||||
|
<label for="sample2" class="form-check-label">The <i>Dungeons & Dragons Fifth Edition Monster Manual</i>.</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="sample3" name="sample" value="2">
|
||||||
|
<label for="sample3" class="form-check-label">The Trade Union and Labour Relations (Consolidation) Act 1992.</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" id="sample4" name="sample" value="4" checked>
|
||||||
|
<label for="sample4" class="form-check-label">All of the above</i></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-3">
|
||||||
|
<p class="quiz-start-text">
|
||||||
|
When you are happy with the settings, click <strong>‘Start the Exam’</strong> below to proceed. You can change these settings at any time using the red gear <a class="btn btn-danger btn-dummy" tabindex="-1" aria-title="Settings" title="Settings"><i class="bi bi-gear-fill"></i></a> button on the exam console.
|
||||||
|
</p>
|
||||||
|
<div class="control-button-container">
|
||||||
|
<a href="#" class="btn btn-success btn-quiz-control" id="btn-start-quiz">Start the Exam</a>
|
||||||
|
<a href="#" class="btn btn-success btn-quiz-control btn-quiz-return" style="display: none;">Resume Exam</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container quiz-panel" style="display: none;" id="quiz-navigator" tabindex="-1">
|
||||||
|
<h1 class="navigator-text">
|
||||||
|
Question Grid
|
||||||
|
</h1>
|
||||||
|
<h1 class="review-text" style="display: none;">
|
||||||
|
Review Your Answers
|
||||||
|
</h1>
|
||||||
|
<div class="navigator-text">
|
||||||
|
This question grid displays the progress you have on the exam so far. Each question is represented by an icon below, and you can click on each icon to skip to that question.
|
||||||
|
|
||||||
|
The icons below are colour-coded to represent the status of each question.
|
||||||
|
</div>
|
||||||
|
<div class="review-text" style="display: none;">
|
||||||
|
You can use this panel to review your answers before you submit the exam. You will not be able to amend your answers after you submit.
|
||||||
|
|
||||||
|
Each question is represented by an icon below, and you can click on each icon to skip to that question. The icons below are colour-coded to represent the status of each question.
|
||||||
|
</div>
|
||||||
|
<table class="navigator-help">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="q-navigator-button btn btn-danger progress-bar-striped btn-dummy" title="Question: Incomplete">Q</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
A red and striped icon represents a question that you have skipped, and have not otherwise flagged for revision.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="q-navigator-button btn btn-warning btn-dummy" title="Question: Flagged">Q</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
A yellow icon represents a question that you have flagged for revision.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="q-navigator-button btn btn-success btn-dummy" title="Question: Answered">Q</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
A green icon represents a question that you have answered, and have not otherwise flagged for revision.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="q-navigator-button btn btn-secondary disabled btn-dummy" title="Question: Unseen">Q</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
A greyed-out icon represents a question that you have not yet seen.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div id="navigator-container">
|
||||||
|
</div>
|
||||||
|
<div class="control-button-container navigator-text">
|
||||||
|
<a href="#" class="btn btn-success btn-quiz-control btn-quiz-return">Resume Exam</a>
|
||||||
|
</div>
|
||||||
|
<div class="control-button-container review-text">
|
||||||
|
<a href="#" class="btn btn-danger btn-quiz-control btn-quiz-return">Back to Exam</a>
|
||||||
|
<a href="#" class="btn btn-success quiz-button-submit">Submit Exam</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container quiz-panel quiz-console" style="display: none" id="quiz-render">
|
||||||
|
<h1>
|
||||||
|
Exam Console
|
||||||
|
</h1>
|
||||||
|
<div class="container question-container">
|
||||||
|
<div class="progress">
|
||||||
|
<div id="answered-bar" class="progress-bar bg-success" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
<div id="flagged-bar" class="progress-bar bg-warning" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
<div id="skipped-bar" class="progress-bar progress-bar-striped bg-danger" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
<div class="counters">
|
||||||
|
<div id="answered-count" class="badge rounded-pill bg-success" style="display: none;">Answered: 0</div>
|
||||||
|
<div id="flagged-count" class="badge rounded-pill bg-warning"style="display: none;">Flagged: 0</div>
|
||||||
|
<div id="skipped-count" class="badge rounded-pill bg-danger progress-bar-striped"style="display: none;">Skipped: 0</div>
|
||||||
|
</div>
|
||||||
|
<h4 class="question-title" id="quiz-question-title" tabindex="-1">
|
||||||
|
Question x.
|
||||||
|
</h4>
|
||||||
|
<p class="question-header" id="quiz-question-header">
|
||||||
|
Question Header
|
||||||
|
</p>
|
||||||
|
<p class="question-text" id="quiz-question-text">
|
||||||
|
Question Text
|
||||||
|
</p>
|
||||||
|
<div class="options" id="quiz-question-options">
|
||||||
|
Options
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control-button-container">
|
||||||
|
<a href="#" class="btn btn-success q-question-nav" id="q-nav-prev" title="Previous Question"><i class="bi bi-caret-left-square-fill"></i> Back</a>
|
||||||
|
<a href="#" class="btn btn-secondary" id="q-nav-flag" title="Question Un-Flagged. Click to flag for revision."><i class="bi bi-flag-fill"></i></a>
|
||||||
|
<a href="#" class="btn btn-success q-question-nav" id="q-nav-next" title="Next Question">Next <i class="bi bi-caret-right-square-fill"></i></a>
|
||||||
|
</div>
|
||||||
|
<div class="control-button-container">
|
||||||
|
<a href="#" class="btn btn-primary" id="q-review-answers" title="Submit Answers">Submit Answers</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container quiz-panel quiz-timeout" style="display: none;" id="quiz-timeout">
|
||||||
|
<h1>
|
||||||
|
Time Limit Expired
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
The time limit set for this exam has expired. You must submit your answers immediately.
|
||||||
|
</p>
|
||||||
|
<div class="control-button-container">
|
||||||
|
<a href="#" class="btn btn-success quiz-button-submit" title="Submit Exam">Submit Exam</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block script %}
|
||||||
|
<script
|
||||||
|
type="text/javascript"
|
||||||
|
src="{{ url_for('.static', filename='js/quiz.js') }}"
|
||||||
|
></script>
|
||||||
|
{% endblock %}
|
@@ -15,7 +15,10 @@
|
|||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="{{ url_for('.static', filename='css/style.css') }}"
|
href="{{ url_for('.static', filename='css/style.css') }}"
|
||||||
/>
|
/>
|
||||||
<title>{% block title %} SKA Referee Test {% endblock %}</title>
|
{% block style %}
|
||||||
|
{% endblock %}
|
||||||
|
<title>{% block title %} SKA Referee Test Beta {% endblock %}</title>
|
||||||
|
{% include "quiz/components/og-meta.html" %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-light">
|
<body class="bg-light">
|
||||||
|
|
||||||
@@ -28,11 +31,11 @@
|
|||||||
{% include "quiz/components/server-alerts.html" %}
|
{% include "quiz/components/server-alerts.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="container site-footer">
|
<footer class="container site-footer">
|
||||||
{% include "quiz/components/footer.html" %}
|
{% include "quiz/components/footer.html" %}
|
||||||
</footer>
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- JQuery, Popper, and Bootstrap js dependencies -->
|
<!-- JQuery, Popper, and Bootstrap js dependencies -->
|
||||||
<script
|
<script
|
||||||
@@ -40,6 +43,9 @@
|
|||||||
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
||||||
crossorigin="anonymous">
|
crossorigin="anonymous">
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
|
||||||
|
</script>
|
||||||
<script
|
<script
|
||||||
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
||||||
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
||||||
@@ -51,9 +57,22 @@
|
|||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
></script>
|
></script>
|
||||||
<!-- Custom js -->
|
<!-- 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
|
<script
|
||||||
type="text/javascript"
|
type="text/javascript"
|
||||||
src="{{ url_for('.static', filename='js/script.js') }}"
|
src="{{ url_for('.static', filename='js/script.js') }}"
|
||||||
></script>
|
></script>
|
||||||
|
{% block script %}
|
||||||
|
{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
3
ref-test/app/quiz/templates/quiz/components/footer.html
Normal file
3
ref-test/app/quiz/templates/quiz/components/footer.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<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>
|
||||||
|
<p>OpenDyslexic 3 is an open source typeface created by Abbie Gonzalez, licensed under a <a href="https://scripts.sil.org/OFL">SIL-OFL</a>. More information about OpenDyslexic is available <a href="https://opendyslexic.org/">on the project web site</a>.</p>
|
14
ref-test/app/quiz/templates/quiz/components/navbar.html
Normal file
14
ref-test/app/quiz/templates/quiz/components/navbar.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark" id="primary-nav">
|
||||||
|
<div class="container">
|
||||||
|
<p class="navbar-brand mb-0 h1">SKA Refereeing Test (Beta)</p>
|
||||||
|
<div class="quiz-console w-100" style="display: none;" id="q-topbar">
|
||||||
|
<div class="d-flex justify-content align-middle">
|
||||||
|
<div class="container d-flex justify-content-center">
|
||||||
|
<span class="text-light q-timer" id="q-timer-widget" style="display: none;"><i class="bi bi-stopwatch-fill"></i> <span id="q-timer-display"></span></span>
|
||||||
|
</div>
|
||||||
|
<a href="#" class="btn btn-warning" aria-title="Question Grid" title="Question Grid" id="btn-toggle-navigator"><i class="bi bi-table"></i></a>
|
||||||
|
<a href="#" class="btn btn-danger" aria-title="Settings" title="Settings" id="btn-toggle-settings"><i class="bi bi-gear-fill"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
17
ref-test/app/quiz/templates/quiz/components/og-meta.html
Normal file
17
ref-test/app/quiz/templates/quiz/components/og-meta.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<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) }}" />
|
||||||
|
<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" />
|
@@ -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 %}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user