Compare commits
761 Commits
8946e3eaf3
...
v1.0.1
Author | SHA1 | Date | |
---|---|---|---|
adead30a77 | |||
487f24732d | |||
3c06cebddf | |||
d1d52fa4b6 | |||
80dc8b3cff | |||
a9ccd64de2 | |||
f5b9758bb1 | |||
84570d5974 | |||
edb8241ad3 | |||
644a539ed9 | |||
f05568b0de | |||
da4a3e41c6 | |||
77f86f7102 | |||
358695977f | |||
ddfd75c1f8 | |||
f4642767ac | |||
2f729de40b | |||
d68beb938f | |||
ca667f7896 | |||
0cc00ef911 | |||
5ec2a86d08 | |||
cd57eca7d3 | |||
a46338fdcb | |||
40f1cebb7b | |||
2a6478f3cf | |||
b6e250a7cd | |||
bcee2eedd0 | |||
d9837246de | |||
62fac48904 | |||
2bf0eeb33d | |||
72f2af1df8 | |||
168b2b288a | |||
9a5f69f889 | |||
7d6f256392 | |||
866c9b10cf | |||
b8fd65d856 | |||
5490bd083f | |||
3cb78055ff | |||
f9d85a8028 | |||
4f193e7fa5 | |||
df3149abba | |||
7ab87c2966 | |||
f4f501def5 | |||
1c57950558 | |||
f132cdbeef | |||
0387c05055 | |||
552b2ffc47 | |||
a2e859af5d | |||
81b09190de | |||
ed100ee9e5 | |||
5dc6c4998d | |||
0d68233d41 | |||
4caac25b14 | |||
3defe020f5 | |||
f14085f4c1 | |||
be5343a4bd | |||
2da8eb7712 | |||
3a0abaac6a | |||
b15f76701e | |||
02290e968c | |||
294f1e42f7 | |||
070ce19fcc | |||
615e59fc6d | |||
68314a4ed2 | |||
b90761fd2c | |||
af03193217 | |||
730a75c44d | |||
70883db5ad | |||
7cefb487da | |||
2e1b01ec9b | |||
a7a5a03991 | |||
b36c6bfd18 | |||
a613b0006b | |||
d4db8692e7 | |||
37ad36da31 | |||
d140f93d25 | |||
26a6248a61 | |||
9f8ea16974 | |||
bc5ec44145 | |||
ff5b19fa0b | |||
6c50be49c6 | |||
8bfe028e2c | |||
519394a656 | |||
9e1c9caec6 | |||
ea850c9ae2 | |||
591b868920 | |||
91dc93758a | |||
5d27baee08 | |||
1254cf3698 | |||
efab086057 | |||
06db47c597 | |||
c04c824585 | |||
8eb7fb6869 | |||
db88b84ecb | |||
13c587b7da | |||
2b2a6ddd25 | |||
26a6b45d75 | |||
c6c62fc34c | |||
6bbdb8fced | |||
c633a474b5 | |||
5af99d85b5 | |||
1e7124262e | |||
2f509af1de | |||
3c8c1b5c16 | |||
3988559920 | |||
8988fee55d | |||
86d1522ca1 | |||
ed53b771ef | |||
bc3b811fc9 | |||
f314566591 | |||
4b6dbd4441 | |||
1ef34465c2 | |||
8b0ea1fec3 | |||
39acebb3a6 | |||
d9962f18ed | |||
d8044a7c76 | |||
3025e83b66 | |||
a02a58a8db | |||
de6910b4bf | |||
7bb93afacb | |||
2663d5e3b7 | |||
500beed4cc | |||
d83999aa43 | |||
6a09559b70 | |||
26227a66c5 | |||
d6836915bb | |||
49a7fb1007 | |||
90bc30757a | |||
fac3839ea3 | |||
d8d5e92453 | |||
12207d1159 | |||
ac02f4dee1 | |||
a050a1eccf | |||
8d91dd1d30 | |||
76fa1e1dd9 | |||
6d5f74bd62 | |||
2e00d503c8 | |||
43cc0a5652 | |||
4ce6536e33 | |||
1f60054d46 | |||
33bc7993fa | |||
418dfe7a70 | |||
645f69440f | |||
e1e279e939 | |||
c197f6cb76 | |||
7fe1afb348 | |||
bed186f6b5 | |||
516c2cdf81 | |||
8f9b78ac32 | |||
17b985d238 | |||
69a0791a6d | |||
4414d1720e | |||
43895bead0 | |||
067ef4fd7f | |||
73f31016fd | |||
25115a6fae | |||
6028ac2d3c | |||
225ef71518 | |||
fbae88eed1 | |||
647d156802 | |||
08a140a73b | |||
a8a01e17da | |||
3f59d1b1b7 | |||
5123365567 | |||
d0166f0901 | |||
f6231dc779 | |||
5c8435d39e | |||
e4e07c43b4 | |||
d202e83189 | |||
e264b808fc | |||
4b08c830a1 | |||
b9d45f94fe | |||
2ea778143e | |||
62160beab2 | |||
1a7983052f | |||
a1bee61679 | |||
126bf9203c | |||
a58f267586 | |||
22878b5398 | |||
52b44128fa | |||
8439d99949 | |||
66e7b2b9f8 | |||
9459b93c9b | |||
09e444344d | |||
767dcede54 | |||
4431564304 | |||
da821bcadb | |||
b58a23cf13 | |||
dc126459bc | |||
2c5ed21011 | |||
59281db9cb | |||
2a3927a140 | |||
9a225543c6 | |||
dd8685b103 | |||
625ef8883b | |||
f903f9d060 | |||
eac9ee7ab1 | |||
b27016aaf4 | |||
6992a75855 | |||
85ced0cc20 | |||
fcfde34c72 | |||
436c8e0e2d | |||
7af588da6c | |||
cfd750894a | |||
ede71f7d82 | |||
27706572ed | |||
08da6d71c4 | |||
c5a0bbb827 | |||
8680c73e86 | |||
ff74e92297 | |||
6b3b255cfd | |||
ecdb5df561 | |||
c5b4d948f5 | |||
c40ef7d070 | |||
b8081bc1c8 | |||
efec599225 | |||
614ad91e3d | |||
6605620d9c | |||
cd4d52692c | |||
2038965dcb | |||
b4c94a7ddb | |||
f144097c5d | |||
63f72e35d2 | |||
57ee0bf971 | |||
735cdec139 | |||
8591184da6 | |||
38d3420e4d | |||
7b5861ade6 | |||
f0437dceaa | |||
fa4640840b | |||
ca30b002ed | |||
05a564f41d | |||
7b2f155b14 | |||
f9628df8c7 | |||
a10bb0384f | |||
b5443c1331 | |||
fe83a47dae | |||
3d7e144d12 | |||
3c9fcae9f8 | |||
d093c4e636 | |||
1d5dfaa5ee | |||
57f233f20f | |||
a35d0ef7f1 | |||
4a5bc48889 | |||
0bdd50f432 | |||
f2fb52aeca | |||
52afd249b7 | |||
4a8080f0c8 | |||
443568f8ff | |||
5ab2e7e608 | |||
7b1ae3b354 | |||
bae8d1e6f8 | |||
36ed23564d | |||
4585b93136 | |||
14272ba0b8 | |||
0130f7412d | |||
8b4ca65122 | |||
f3f8ac955c | |||
8bfc8e119c | |||
0ccb62ce3c | |||
2507a1d00b | |||
fed4b6739f | |||
dd22b51fe1 | |||
f2b261f0b0 | |||
526d940c54 | |||
485e51f239 | |||
9f4e9637c9 | |||
1adb4867d5 | |||
55aa5496db | |||
b7ef513870 | |||
331e49a6bc | |||
2027e525e2 | |||
59fc703bcb | |||
c466f06384 | |||
8d80666ed8 | |||
3d9a3ecdff | |||
a8e938e802 | |||
4c4927df31 | |||
f8126b42fe | |||
407ee49bff | |||
b0bb600e12 | |||
0e8fbf148a | |||
0ef72ec338 | |||
721af501d1 | |||
e6f1338ee4 | |||
0e50e2c1b9 | |||
b0980b1871 | |||
ea9132542f | |||
b7fb30ce36 | |||
fe75fa1a49 | |||
f86fa6f4b5 | |||
6c293c2ce6 | |||
d3ed32183c | |||
e8090f30d7 | |||
176a0f069f | |||
302d8a933a | |||
c5587fcb73 | |||
a4b4bfe0ee | |||
0faef8651a | |||
4f925eae2f | |||
a9f5ba51c4 | |||
5b0fd0ced3 | |||
eca786d444 | |||
affb309ffc | |||
0e1db9d21d | |||
003d998b72 | |||
dccc85370e | |||
355a6bff5e | |||
98638e803a | |||
6c4ab2e1e3 | |||
e13069bed6 | |||
5b6f83c294 | |||
7295a2751c | |||
dd72da6ae6 | |||
36cdeb15ad | |||
eb6f5b876c | |||
14500434d7 | |||
35dffd358b | |||
fafb3fcc2e | |||
4131dd054a | |||
f370496780 | |||
667ad4ebc2 | |||
52e3ce4c93 | |||
ca0e6c82cb | |||
860c18c5fd | |||
46cef8cd1e | |||
421445d8d5 | |||
b0d3ff3fc1 | |||
68aef968e2 | |||
8d29944d5d | |||
8fbb52d366 | |||
1dbe4215ec | |||
101f6786f5 | |||
fe5cf189cc | |||
cefb5fe849 | |||
f0c7873257 | |||
0cb8ff9991 | |||
4d77021d58 | |||
fa05a17508 | |||
5960d0103d | |||
3535622380 | |||
86abae01c0 | |||
7c2adc9cac | |||
e119c344dd | |||
c7b54d2119 | |||
e6841b7744 | |||
6835232698 | |||
5392ff86ed | |||
328a78a923 | |||
9810577c5d | |||
2c93b0d3a7 | |||
343cb3f8b1 | |||
961e8629cb | |||
378e8eeae3 | |||
fe898aaf7d | |||
a010d7d290 | |||
8b962c53a9 | |||
bceb91b406 | |||
a14b7bf305 | |||
3622baf988 | |||
24545feea0 | |||
bb9233eeae | |||
60b8aad419 | |||
6e541c6a7b | |||
685b1b928d | |||
e0c2570515 | |||
5163914875 | |||
467b6d9ce7 | |||
e5aab6268d | |||
383ae11cd3 | |||
348ee95d1c | |||
9db80c9148 | |||
20b447adbb | |||
669bbd2f7b | |||
22b483b021 | |||
21ad8b2f94 | |||
a3a13d4eb6 | |||
a357ffe28d | |||
e00e2b17b0 | |||
65d679afbb | |||
891ec2fd38 | |||
4be21a2ca2 | |||
efd4dc440d | |||
935b465a19 | |||
05fa5bf274 | |||
1d1e2acf62 | |||
c742edb57c | |||
529504509e | |||
852b2664ce | |||
8b1b0162cc | |||
56e5d29416 | |||
ee50306370 | |||
559e5b96c4 | |||
4c2a6e7f74 | |||
daaf173ff6 | |||
05de6d716b | |||
f740ee7f1b | |||
c56c0dc822 | |||
0c446b9ae7 | |||
9ebec5000c | |||
ce32b33eaa | |||
45e0d37f81 | |||
d353a80269 | |||
8e7a09edca | |||
616bd3f578 | |||
108297cbfd | |||
9e03db595b | |||
3bfd08411b | |||
a4affa72a9 | |||
12c424be08 | |||
e00b4a9045 | |||
0ad7089722 | |||
707890ce3a | |||
7bdca9b895 | |||
bd1ac46942 | |||
11f965e20f | |||
ee99dd9038 | |||
65ec27b35b | |||
63ca5e33de | |||
1f228c7f1c | |||
56191f5e7a | |||
cbc8d276eb | |||
cd68a60001 | |||
dd7e3cad7a | |||
32908bde7d | |||
835c5e2aa6 | |||
6823c12b2d | |||
c7907dc24d | |||
e4d97869da | |||
dfbf10e2dd | |||
dbd25ddf38 | |||
11d839aada | |||
3980be3701 | |||
43cb31849a | |||
39cdafc847 | |||
bdeb026a7c | |||
73f4825bbe | |||
e1ecb5bcb6 | |||
1651f63577 | |||
a01d486d99 | |||
2b71c77c6c | |||
112c097d69 | |||
b6af6d5c15 | |||
6c4ca715f6 | |||
972673f5d1 | |||
cb1bc69f47 | |||
a4058c475b | |||
0004d2714f | |||
20efd4444c | |||
13465859ab | |||
53050f1358 | |||
f025eee4a6 | |||
506a6cf6c2 | |||
97db70abff | |||
1a1d763d67 | |||
598dfa45e8 | |||
ca36772f29 | |||
bd3205f06e | |||
ab7a25182f | |||
e3bb2895ae | |||
3e1e57a067 | |||
42f90c667d | |||
b02277f12f | |||
a9ad171249 | |||
bc42ae86d1 | |||
cc3410a1f6 | |||
953d3658a8 | |||
70f6875ac1 | |||
5da08d5c37 | |||
534247ece3 | |||
9525694e39 | |||
31903626f0 | |||
0111547676 | |||
e70592b276 | |||
22a0d58996 | |||
3d6a1dc7ba | |||
51d468fb44 | |||
164d43be8b | |||
cdf47e0b88 | |||
2427d55310 | |||
757cc94f33 | |||
0cfac25ed3 | |||
0443e348ac | |||
f2c0090aa3 | |||
ae75498edb | |||
7f3e251ac4 | |||
233e173735 | |||
c5686fbd40 | |||
94556d0731 | |||
ccab358464 | |||
79b0e83eba | |||
22e163f036 | |||
511eccac99 | |||
8ec0967f40 | |||
ae1380407c | |||
1e7222c781 | |||
b65b71df7a | |||
9a4820c725 | |||
6c327c7978 | |||
c730fca3eb | |||
ba106ff684 | |||
738f4eae86 | |||
d114b061b4 | |||
9b5b97eb1d | |||
52ab3af1f2 | |||
79ca8fc932 | |||
3a380c9f50 | |||
b9bff4812b | |||
dedd2d3449 | |||
bf7e0a2a18 | |||
d34aa82e86 | |||
af9b5210fa | |||
389fbf99aa | |||
1cafa04763 | |||
bc68089f87 | |||
9b7a3b3ec0 | |||
23136b7e40 | |||
2e4035d8a4 | |||
7063fe271e | |||
8d65b0c089 | |||
9988a989a6 | |||
20e418aeae | |||
9affa657c4 | |||
395ddbd460 | |||
93b8ac40df | |||
09f71fc5a7 | |||
e694119a58 | |||
67bbab0061 | |||
9992138bc4 | |||
f548221a10 | |||
4d883e8dce | |||
92e2462bb9 | |||
6ea02c28d4 | |||
05a8a78ed9 | |||
ac5d17fc66 | |||
37d7e5010f | |||
ce40568870 | |||
f4234f57b1 | |||
b8c652e78a | |||
9d760aafef | |||
4da025d50f | |||
787b741687 | |||
2aca8015af | |||
89ae75050b | |||
efa83d2bf8 | |||
388d89d95d | |||
8a368dbd16 | |||
4f842223cd | |||
81eac4b880 | |||
f03c92082e | |||
3a63c72bbb | |||
c3f6d45883 | |||
27cead22ad | |||
3a39ff6fc3 | |||
8ab0a5e164 | |||
c3c6e5084a | |||
ef7de71a5b | |||
1a1dff2c5d | |||
da6d380786 | |||
a1ed557dc2 | |||
3ffb4a68e1 | |||
12d9cd39be | |||
0fd7ac7f1f | |||
66d8fb7d93 | |||
cca2633f1a | |||
e1fcad3b42 | |||
4aad0c1213 | |||
ef1cad1995 | |||
ab2ca04ceb | |||
c88c142f7f | |||
ff6865c7ca | |||
488389057c | |||
186e83f92a | |||
da6ae3c826 | |||
23d6f833d7 | |||
17f9ef79b7 | |||
231f1d97bc | |||
dbc0c782c0 | |||
27bb07a942 | |||
0d63413835 | |||
a126d1f91d | |||
30e298aa02 | |||
cc8db3fea4 | |||
7c2b9df0d0 | |||
3b605c3340 | |||
d8e7bf6ae8 | |||
3c903424fb | |||
766487b669 | |||
0e52c12b35 | |||
3a1abe5157 | |||
9a2d738653 | |||
5c6f56f1c3 | |||
329538f7f5 | |||
cfdb4db0c3 | |||
5151b98f97 | |||
b102dc86aa | |||
d9dc2e209f | |||
86f8c12279 | |||
c71e91326f | |||
41d92b97a0 | |||
2f6ccd530a | |||
5d9dba0e3d | |||
ee159402d0 | |||
82ed0cf7cc | |||
66f2da31b6 | |||
cf39f83243 | |||
5bd04d8dc0 | |||
48624584fe | |||
fb7f9e328d | |||
c7ddf034a3 | |||
e001ccfa01 | |||
b6179430be | |||
8924232a93 | |||
ac36309527 | |||
7eddcabb7f | |||
f66d62db37 | |||
567b272161 | |||
2f04671ec5 | |||
c375576436 | |||
c536fb95b2 | |||
fbe3a59847 | |||
6472241dfc | |||
998ec597b1 | |||
3470f7422c | |||
9be3b1a487 | |||
c00ffd3ed0 | |||
f17ba4f6bf | |||
700850434a | |||
019622bd85 | |||
fe61456922 | |||
64f1da772a | |||
6b79fb8ebe | |||
8963e5461e | |||
a780b2330e | |||
a3a1c2ab2f | |||
dcd047a5ae | |||
268fa36507 | |||
f0ba8777e3 | |||
43989af1f1 | |||
0a6a14f8d0 | |||
5dfc3379fc | |||
c08e1c7010 | |||
2479fd193b | |||
a6ad184447 | |||
ff9ede6cce | |||
05b68fdd95 | |||
900929b875 | |||
8cf9629bf1 | |||
40926c1063 | |||
ba47f79d44 | |||
6f4353266c | |||
abfa7b21ba | |||
2536e595f0 | |||
bda9946859 | |||
a67ea9951b | |||
756af0a064 | |||
7caf54a5ba | |||
222b8e8a8b | |||
2875c59460 | |||
bb09930116 | |||
31736bfbaf | |||
b5625a5fb2 | |||
6103010169 | |||
283dfe8ecf | |||
faeaeb8b2c | |||
75db9fde3c | |||
91621625e6 | |||
d23d3ca6d1 | |||
8969505383 | |||
e9ff14d63e | |||
10b325ad29 | |||
a15844f52d | |||
e0cac3c800 | |||
be26a19f2e | |||
218090d1e5 | |||
f65e5b122f | |||
f3cb7deaf4 | |||
1745299e12 | |||
b17e04de71 | |||
b66b94fd83 | |||
2af61ca986 | |||
7269cec73d | |||
68a6507c1b | |||
e48ab4b58a | |||
f38e9df6b9 | |||
1f661a7038 | |||
66b4c50221 | |||
9f8a6e1a27 | |||
d9b72bce0c | |||
e829514e91 | |||
a1d19b4474 | |||
d29a5984f1 | |||
0b2a74ddd3 | |||
a1c3e79e90 | |||
7b1b789644 | |||
963453d2d6 | |||
46ab5d620b | |||
6593d372e0 | |||
cffafa82d9 | |||
dc432c4ac9 | |||
f0c4f237de | |||
99bd4df741 | |||
a866699f5d | |||
75b43f8993 | |||
e50ad9430e | |||
173b1e329b | |||
346238dab8 | |||
9913c9e084 | |||
ad16311941 | |||
493f71ac20 | |||
3f29b504b2 | |||
565486aef3 | |||
e5cecd6102 | |||
795545e8af | |||
b4f021bb8b | |||
dcafde1158 | |||
9b038dc8e4 | |||
4a201f3f9d | |||
a57f5476c0 | |||
240bcc6dd4 | |||
add2001ba3 | |||
70f362015c | |||
459c630db7 | |||
89bb802e45 | |||
475fdfcca7 | |||
db755334d0 | |||
1980363c12 | |||
07c8b62dc1 | |||
4c14c85a47 | |||
40119c9e9c | |||
8432884479 | |||
82b16ec9fb | |||
11a0dc3a4a | |||
2348c76ee8 | |||
6518458768 | |||
aab5325255 | |||
af8ea5ddc3 | |||
e730607c66 | |||
87f60e1826 | |||
0c3199515b | |||
7c5e3c1e43 | |||
274eb2d214 | |||
7aa5be57cd | |||
2e77b1a216 | |||
e3fdf08b2c | |||
2d1cdd5e94 | |||
af5e6172e9 | |||
88a4fc02d1 | |||
d6bc6df86b | |||
2fce2e0c80 | |||
bf1d53d07d | |||
2482242f20 | |||
0d7fa41261 | |||
2e9e15be95 | |||
08f2585def | |||
f8d05f2cec | |||
fd89626172 | |||
e1967bcd7e | |||
79193d897e | |||
2064ac508a | |||
66a950f757 |
33
.env.example
Normal file
33
.env.example
Normal file
@ -0,0 +1,33 @@
|
||||
SERVER_NAME= # URL where this will be hosted.
|
||||
FLASK_DEBUG=False
|
||||
|
||||
TZ=Europe/London # Time Zone
|
||||
|
||||
## App Configuration
|
||||
SECRET_KEY= # Long, secure, secret string.
|
||||
DATA=./data/
|
||||
DATABASE_TYPE=SQLite # SQLite or MySQL, defaults to SQLite
|
||||
DATABASE_HOST= # Required if MySQL. Must match name of Docker service, or provide host if database is on an external server. Defaults to localhost.
|
||||
DATABASE_PORT= # Required if MySQL. Defaults to 3306
|
||||
|
||||
## MySQL Database Configuration (Required if configured to MySQL Database.)
|
||||
# Note that if using the Docker service, these configuration values will also be used when creating the database in the mysql container.
|
||||
MYSQL_RANDOM_ROOT_PASSWORD=True
|
||||
MYSQL_DATABASE= # Required if MySQL.
|
||||
MYSQL_USER= # Required if MySQL
|
||||
MYSQL_PASSWORD= # Required if MySQL. Create secure password string. Note '@' character cannot be used.
|
||||
|
||||
## Flask Mail Configuration
|
||||
MAIL_SERVER=postfix # Must match name of the Docker service
|
||||
MAIL_PORT=25
|
||||
MAIL_USE_TLS=False
|
||||
MAIL_USE_SSL=False
|
||||
MAIL_USERNAME= # Username@domain, must match config values below
|
||||
MAIL_PASSWORD= # Must match config value below
|
||||
MAIL_DEFAULT_SENDER= # NoReply@domain or some such.
|
||||
MAIL_MAX_EMAILS=25
|
||||
MAIL_ASCII_ATTACHMENTS=True
|
||||
|
||||
# Postfix
|
||||
maildomain= # Domain must match the section of username above
|
||||
smtp_user= # username:password. Must match config values above.
|
15
.gitignore
vendored
15
.gitignore
vendored
@ -149,4 +149,17 @@ ref-test/testing.py
|
||||
database/data/
|
||||
|
||||
# Ignore Encryption Keyfile
|
||||
.encryption.key
|
||||
.encryption.key
|
||||
|
||||
# Ignore Data Dir
|
||||
**/data/*
|
||||
|
||||
# Ignore Logs Dir
|
||||
logs/*
|
||||
|
||||
# Ignore Certbot Dir
|
||||
certbot/*
|
||||
|
||||
# Ignore src dir (exception for robots.txt)
|
||||
src/html/*
|
||||
src/html/robots.txt
|
217
README.md
217
README.md
@ -10,31 +10,224 @@ The exam client is made with accessibility in mind, and has been designed to be
|
||||
|
||||
## Set Up and Installation
|
||||
|
||||
The clien is designed to work on a server.
|
||||
The app is designed to be hosted on a server.
|
||||
|
||||
### Pre-Requisites
|
||||
|
||||
Server
|
||||
Docker
|
||||
Docker-Compose
|
||||
Git
|
||||
- A Debian- or Ubuntu-based server, preferably the latest distribution.
|
||||
- Docker (specifically, Docker Engine)
|
||||
- Docker Compose
|
||||
- Git
|
||||
|
||||
### Installation
|
||||
|
||||
#### Install all the pre-requisites
|
||||
|
||||
The first step is to ensure all the prerequisites are available on the server.
|
||||
|
||||
To set up the server, consult some of the comprehensive guides on various hosting platforms like Linode or DigitalOcean.
|
||||
Here is a [good starting point on setting up a server](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu-22-04).
|
||||
|
||||
To install Docker and Docker Compose, consult the respective documentation:
|
||||
|
||||
- [Install on Ubuntu](https://docs.docker.com/engine/install/ubuntu/) or [Install on Debian](https://docs.docker.com/engine/install/debian/)
|
||||
- Docker Compose should be installed as part of that.
|
||||
|
||||
> At the time of writing, there has been an upgrade to Docker and Docker Compose, meaning the syntax below might be different between versions.
|
||||
|
||||
Check if Git is installed on your server using the `git --version` command.
|
||||
If it isn't installed, install it.
|
||||
This should normally come pre-packaged with your OS distribution.
|
||||
But if it doesn't, look up how to for whatever OS you use.
|
||||
If you are using Ubuntu or Debian, it should be as easy as using the command:
|
||||
|
||||
```$ sudo apt-get install git -y```
|
||||
|
||||
#### Preliminary Set-Up: Clone repos and Configure Values
|
||||
|
||||
#### Set Up Web Server
|
||||
Open a terminal and navigate to the folder where you want to install this app.
|
||||
I would suggest using a subfolder within your Home folder:
|
||||
|
||||
#### Incorporate SSL
|
||||
```$ cd ~ && mkdir ska-referee-test && cd ska-referee-test```
|
||||
|
||||
#### Set Up Auto-Renew
|
||||
That way, you will ensure you can read and write all the necessary files during installation.
|
||||
Once in the destination folder, clone all the relevant files you will need for the installation:
|
||||
|
||||
### Alterations
|
||||
```$ git clone https://git.vsnt.uk/viveksantayana/ska-referee-test.git .```
|
||||
|
||||
## Use
|
||||
(Remember to include the trailing dot at the end, as that indicates to Git to download the files in the current directory.)
|
||||
|
||||
## Compatibility
|
||||
#### Choose What Database Engine You Will Use
|
||||
|
||||
### iOS Limitations
|
||||
This app is designed to use an SQLite database by default.
|
||||
You can set it up to use a MySQL database by configuring the environment variables accordingly.
|
||||
If your database is being hosted remotely, make sure the MySQL database has the proper authentication for the user from a remote server.
|
||||
Alternatively, you can also use the second `docker-compose-mysql.yml` file which provides a MySQL database as part of the cluster.
|
||||
To use the second `docker-compose-mysql.yml` file, use the following command at the last step of the installation:
|
||||
|
||||
```sudo docker compose -f docker-compose-mysql.yml up```
|
||||
|
||||
#### Populate Environment Variables
|
||||
|
||||
Configuration values for the app are stored in the environment variables file.
|
||||
To set it up, make a copy of the example file and populate it with appropriate values.
|
||||
|
||||
```$ cp .env.example .env```
|
||||
|
||||
Make sure to use complex, secure strings for passwords.
|
||||
Also make sure that the various entries for usernames and passwords match.
|
||||
|
||||
#### Input Specific Values for Your Installation
|
||||
|
||||
There are some values in the following four files you will need to configure to reflect the domain you are installing this app.
|
||||
|
||||
```sh
|
||||
# .env
|
||||
|
||||
SERVER_NAME= # URL where this will be hosted.
|
||||
```
|
||||
|
||||
```sh
|
||||
# install-script.sh
|
||||
|
||||
domains=(example.org www.example.org)
|
||||
email="" # Adding a valid address is strongly recommended
|
||||
```
|
||||
|
||||
Substitute the domain name `domain_name` in the two file paths in the following file:
|
||||
|
||||
```sh
|
||||
# nginx/ssl.conf
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem;
|
||||
...
|
||||
```
|
||||
|
||||
And **six** locations in the following file, two for the regular version of the domain and four for the www version (remember to keep the www. prefix where present):
|
||||
|
||||
```nginx
|
||||
# nginx/conf.d/ref-test-app.conf
|
||||
|
||||
server {
|
||||
server_name domain_name;
|
||||
listen 80 default_server;
|
||||
...
|
||||
}
|
||||
|
||||
server {
|
||||
server_name domain_name;
|
||||
listen 443 ssl http2 default_server;
|
||||
...
|
||||
}
|
||||
|
||||
server {
|
||||
server_name www.domain_name;
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
# Redirect to non-www
|
||||
return 301 $scheme://domain_name$request_uri;
|
||||
...
|
||||
}
|
||||
|
||||
server {
|
||||
server_name www.domain_name;
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
...
|
||||
|
||||
# Redirect to non-www
|
||||
return 301 $scheme://domain_name$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
#### Installing SSL Certificates
|
||||
|
||||
The app will use SSL certificates to operate through a secure, `https` connection.
|
||||
This will be set up automatically.
|
||||
However, there is a specific chicken-and-egg problem as the web server, Nginx, won't run without certificates, Certbot, the certificate generator, won't run without the web server.
|
||||
So to solve this, there is an automation script we can run that will set up a dummy certificate and then issue the appropriate certificates for us.
|
||||
|
||||
```sh
|
||||
chmod +x install-script.sh
|
||||
sudo ./install-script.sh
|
||||
```
|
||||
|
||||
This will take a long time to run the first time because it will try and generate a fairly sizeable cypher.
|
||||
|
||||
When we later run the server, Certbot will check for renewals of the SSL certificates every 12 hours, and Nginx will reload the configurations every 6 hours, to make sure everything runs smoothly and stays live.
|
||||
|
||||
#### Run the Stack
|
||||
|
||||
Everything should be good to run on autopilot at this point.
|
||||
Navigate to the root folder of the app, the folder where you have `install-script.sh` and `docker-compose.yml`.
|
||||
Run the following command:
|
||||
|
||||
```sudo docker compose up -d```
|
||||
|
||||
And you should have the stack running.
|
||||
You can register in the app and begin using it.
|
||||
|
||||
### Fonts
|
||||
|
||||
The app uses [OpenDyslexic](https://opendyslexic.org/), which is available on-line.
|
||||
It also has the option of rendering in other system fonts, but this can vary depending on your operating system.
|
||||
Because these are proprietary fonts, they cannot be made available on-line in the same way as open source ones should your system not have them.
|
||||
Some fonts may not display correctly as a result.
|
||||
|
||||
## Updating the Installation
|
||||
|
||||
If the app is updated, you can update the version on your installation using the following method:
|
||||
|
||||
### Navigate to the root folder
|
||||
|
||||
This will be the root folder into which you cloned the git repository when you set the app up.
|
||||
|
||||
### Stash your local changes
|
||||
|
||||
When you update the code, there is a risk the changes you made to your configuration will be overwritten.
|
||||
To avoid this, use the following command:
|
||||
|
||||
```git stash```
|
||||
|
||||
This will stash the changes you made, and we can re-apply the changes once the new code has been downloaded.
|
||||
If you do not have any other changes stashed, the index number of these changes should be `0` in a later step.
|
||||
If there are other changes, make sure to note what the correct index number for the stashed changes is.
|
||||
|
||||
### Take down the Docker containers
|
||||
|
||||
We will need to stop the current containers with the following command:
|
||||
|
||||
```sudo docker compose down```
|
||||
|
||||
This may take a few seconds.
|
||||
|
||||
### Pull the updated code
|
||||
|
||||
Download the updated code from the Git repository:
|
||||
|
||||
```git pull```
|
||||
|
||||
This step might fail if you have any un-stashed local changed.
|
||||
|
||||
### Re-Apply your local configurations
|
||||
|
||||
Because we stashed our local configurations, we can re-apply them once again:
|
||||
|
||||
```git stash pop 0```
|
||||
|
||||
The index number (`0`) is assuming there were no other changes saved on your git repository.
|
||||
If you have a different index number for the relevant changes from the above step, change this accordingly.
|
||||
|
||||
### Re-build the docker image
|
||||
|
||||
Now that we have the base code downloaded, we just need to update the docker image:
|
||||
|
||||
```sudo docker compose build app```
|
||||
|
||||
### Re-build the containers
|
||||
|
||||
This is the same last step as running the containers in the last step of the installation:
|
||||
|
||||
```sudo docker compose up -d```
|
||||
|
@ -6,11 +6,6 @@
|
||||
|
||||
- [Docker Compose Reference](https://docs.docker.com/compose/compose-file/compose-file-v3/)
|
||||
|
||||
### MongoDB/PyMongo
|
||||
|
||||
- [MongoDB Shell Commands](https://docs.mongodb.com/manual/reference/)
|
||||
- [PyMongo Driver](https://pymongo.readthedocs.io/en/stable/)
|
||||
|
||||
## Source Code
|
||||
|
||||
- [MongoDB Docker Image entrypoint shell script](https://github.com/docker-library/mongo/blob/master/5.0/docker-entrypoint.sh) (Context: Tried to replicate the command to create a new user in the original entrypoint script in the custom initialisation script in this app.)
|
||||
@ -23,15 +18,6 @@
|
||||
- [Tables](https://www.blog.pythonlibrary.org/2017/12/14/flask-101-adding-editing-and-displaying-data/)
|
||||
- [Tables, but interactive](https://blog.miguelgrinberg.com/post/beautiful-interactive-tables-for-your-flask-templates)
|
||||
|
||||
## Stack Exchange/Overflow
|
||||
|
||||
### MongoDB
|
||||
|
||||
- [Creating MongoDB Database on Container Start](https://stackoverflow.com/questions/42912755/how-to-create-a-db-for-mongodb-container-on-start-up)
|
||||
- [Passing Environment Variables to Docker Container Entrypoint](https://stackoverflow.com/questions/64606674/how-can-i-pass-environment-variables-to-mongo-docker-entrypoint-initdb-d)
|
||||
- [Integrating Flask-Login with MongoDB](https://stackoverflow.com/questions/54992412/flask-login-usermixin-class-with-a-mongodb) (**This does not work with the app as is, and is possibly something that needs more research and development in the future**)
|
||||
- [Setting up a Postfix email notification system](https://medium.com/@vietgoeswest/a-simple-outbound-email-service-for-your-app-in-15-minutes-cc4da70a2af7)
|
||||
|
||||
## YouTube Tutorials
|
||||
|
||||
### General Flask Introduction
|
||||
@ -72,7 +58,7 @@ A much simpler and more rudimentary introduction to Flask and MongoDB.
|
||||
- [Build a User Login System with `flask-login`, `flask-wtforms`, `flask-bootstrap`, and `flask-sqlalchemy`](https://www.youtube.com/watch?v=8aTnmsDMldY)
|
||||
|
||||
A much more robust method that uses the various Flask modules to make a more powerful framework.
|
||||
Uses SQL rather than MongoDB.
|
||||
Uses SQL.
|
||||
|
||||
### Flask techniques
|
||||
|
||||
@ -80,4 +66,4 @@ Uses SQL rather than MongoDB.
|
||||
|
||||
### Flask handling file uploads
|
||||
|
||||
- [Handlin File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask)
|
||||
- [Handling File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask)
|
||||
|
2
certbot/.gitignore
vendored
2
certbot/.gitignore
vendored
@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
@ -1,14 +0,0 @@
|
||||
set -e
|
||||
mongo=( mongo --host 127.0.0.1 --port 27017 --quiet )
|
||||
|
||||
if [ "$MONGO_INITDB_ROOT_USERNAME" ] && [ "$MONGO_INITDB_ROOT_PASSWORD" ] && [ "$MONGO_INITDB_USERNAME" ] && [ "$MONGO_INITDB_PASSWORD" ]; then
|
||||
rootAuthDatabase='admin'
|
||||
|
||||
"${mongo[@]}" "$rootAuthDatabase" <<-EOJS
|
||||
db.createUser({
|
||||
user: $(_js_escape "$MONGO_INITDB_USERNAME"),
|
||||
pwd: $(_js_escape "$MONGO_INITDB_PASSWORD"),
|
||||
roles: [ { role: 'readWrite', db: $(_js_escape "$MONGO_INITDB_DATABASE") } ]
|
||||
})
|
||||
EOJS
|
||||
fi
|
90
docker-compose-mysql.yml
Normal file
90
docker-compose-mysql.yml
Normal file
@ -0,0 +1,90 @@
|
||||
version: '3.9'
|
||||
|
||||
volumes:
|
||||
app:
|
||||
mysql:
|
||||
|
||||
services:
|
||||
nginx:
|
||||
container_name: reftest_server
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
- ./certbot:/etc/letsencrypt:ro
|
||||
- ./nginx:/etc/nginx
|
||||
- ./src/html/certbot:/usr/share/nginx/html/certbot:ro
|
||||
- ./src/html/robots.txt:/usr/share/nginx/html/robots.txt:ro
|
||||
- ./ref-test/app/root:/usr/share/nginx/html/root:ro
|
||||
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static:ro
|
||||
- ./ref-test/app/editor/static:/usr/share/nginx/html/editor/static:ro
|
||||
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static:ro
|
||||
- ./ref-test/app/view/static:/usr/share/nginx/html/view/static:ro
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- frontend
|
||||
depends_on:
|
||||
- app
|
||||
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
||||
|
||||
app:
|
||||
container_name: reftest_app
|
||||
image: reftest
|
||||
build: ./ref-test
|
||||
env_file:
|
||||
- ./.env
|
||||
ports:
|
||||
- 5000
|
||||
volumes:
|
||||
- app:/ref-test/data
|
||||
- ./logs:/ref-test/data/logs
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
depends_on:
|
||||
postfix:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
||||
postfix:
|
||||
container_name: reftest_postfix
|
||||
image: catatnight/postfix:latest
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./.env
|
||||
ports:
|
||||
- 25
|
||||
networks:
|
||||
- backend
|
||||
|
||||
certbot:
|
||||
container_name: reftest_certbot
|
||||
image: certbot/certbot
|
||||
volumes:
|
||||
- ./certbot:/etc/letsencrypt
|
||||
- ./src/html/certbot:/var/www/html
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
|
||||
mysql:
|
||||
container_name: reftest_db
|
||||
image: mysql:8.0
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- mysql:/var/lib/mysql
|
||||
ports:
|
||||
- 3306
|
||||
networks:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
external: false
|
||||
backend:
|
||||
external: false
|
@ -1,15 +1,22 @@
|
||||
version: '3.9'
|
||||
|
||||
volumes:
|
||||
app:
|
||||
|
||||
services:
|
||||
ref_test_server:
|
||||
container_name: ref_test_server
|
||||
image: nginx:1.21.4-alpine
|
||||
nginx:
|
||||
container_name: reftest_server
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
- ./certbot:/etc/letsencrypt:ro
|
||||
- ./nginx:/etc/nginx
|
||||
- ./src/html:/usr/share/nginx/html/
|
||||
- ./ref-test/admin/static:/usr/share/nginx/html/admin/static
|
||||
- ./ref-test/quiz/static:/usr/share/nginx/html/quiz/static
|
||||
- ./src/html/certbot:/usr/share/nginx/html/certbot:ro
|
||||
- ./src/html/robots.txt:/usr/share/nginx/html/robots.txt:ro
|
||||
- ./ref-test/app/root:/usr/share/nginx/html/root:ro
|
||||
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static:ro
|
||||
- ./ref-test/app/editor/static:/usr/share/nginx/html/editor/static:ro
|
||||
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static:ro
|
||||
- ./ref-test/app/view/static:/usr/share/nginx/html/view/static:ro
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
@ -17,10 +24,11 @@ services:
|
||||
networks:
|
||||
- frontend
|
||||
depends_on:
|
||||
- ref_test_app
|
||||
- app
|
||||
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
||||
|
||||
ref_test_app:
|
||||
container_name: ref_test_app
|
||||
app:
|
||||
container_name: reftest_app
|
||||
image: reftest
|
||||
build: ./ref-test
|
||||
env_file:
|
||||
@ -28,32 +36,17 @@ services:
|
||||
ports:
|
||||
- 5000
|
||||
volumes:
|
||||
- ./.security:/ref-test/.security
|
||||
- ./ref-test/data:/ref-test/data
|
||||
- app:/ref-test/data
|
||||
- ./logs:/ref-test/data/logs
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
depends_on:
|
||||
- ref_test_db
|
||||
- ref_test_postfix
|
||||
- postfix
|
||||
|
||||
ref_test_db:
|
||||
container_name: ref_test_db
|
||||
image: mongo:5.0.4-focal
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./database/data:/data/db
|
||||
- ./database/initdb.d/:/docker-entrypoint-initdb.d/
|
||||
env_file:
|
||||
- ./.env
|
||||
ports:
|
||||
- 27017
|
||||
networks:
|
||||
- backend
|
||||
|
||||
ref_test_postfix:
|
||||
container_name: ref_test_postfix
|
||||
postfix:
|
||||
container_name: reftest_postfix
|
||||
image: catatnight/postfix:latest
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
@ -63,15 +56,13 @@ services:
|
||||
networks:
|
||||
- backend
|
||||
|
||||
ref_test_certbot:
|
||||
container_name: ref_test_certbot
|
||||
image: certbot/certbot:v1.21.0
|
||||
certbot:
|
||||
container_name: reftest_certbot
|
||||
image: certbot/certbot
|
||||
volumes:
|
||||
- ./certbot:/etc/letsencrypt
|
||||
- ./src/html:/var/www/html
|
||||
depends_on:
|
||||
- ref_test_server
|
||||
# command: certonly --webroot --webroot-path=/var/www/html --email (email) --agree-tos --no-eff-email -d (domain)
|
||||
- ./src/html/certbot:/var/www/html
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
|
90
install-script.sh
Normal file
90
install-script.sh
Normal file
@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Source https://pentacent.medium.com/nginx-and-lets-encrypt-with-docker-in-less-than-5-minutes-b4b8a60d3a71
|
||||
|
||||
if ! [ -x "$(command -v docker)" ]; then
|
||||
echo 'Error: docker is not installed.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [ -x "$(command -v compose)" ]; then
|
||||
echo 'Error: docker compose is not installed.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
domains=(example.org www.example.org)
|
||||
rsa_key_size=4096
|
||||
data_path="./certbot"
|
||||
email="" # Adding a valid address is strongly recommended
|
||||
staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits
|
||||
|
||||
if [ -d "$data_path" ]; then
|
||||
read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision
|
||||
if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
|
||||
exit
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -e "$data_path/ssl-dhparams.pem" ]; then
|
||||
echo "### Generating ssl-dhparams.pem ..."
|
||||
docker compose run --rm --entrypoint "\
|
||||
openssl dhparam -out /etc/letsencrypt/ssl-dhparams.pem 4096" certbot
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "### Creating dummy certificate for $domains ..."
|
||||
path="/etc/letsencrypt/live/$domains"
|
||||
mkdir -p "$data_path/live/$domains"
|
||||
docker compose run --rm --entrypoint "\
|
||||
openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
|
||||
-keyout '$path/privkey.pem' \
|
||||
-out '$path/fullchain.pem' \
|
||||
-subj '/CN=localhost'" certbot
|
||||
echo
|
||||
|
||||
if [ ! -e "$data_path/lets-encrypt-x3-cross-signed.pem" ]; then
|
||||
echo "### Downloading lets-encrypt-x3-cross-signed.pem ..."
|
||||
wget -O $data_path/lets-encrypt-x3-cross-signed.pem \
|
||||
"https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem"
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "### Starting nginx ..."
|
||||
docker compose up --force-recreate -d nginx
|
||||
echo
|
||||
|
||||
echo "### Deleting dummy certificate for $domains ..."
|
||||
docker compose run --rm --entrypoint "\
|
||||
rm -Rf /etc/letsencrypt/live/$domains && \
|
||||
rm -Rf /etc/letsencrypt/archive/$domains && \
|
||||
rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot
|
||||
echo
|
||||
|
||||
echo "### Requesting Let's Encrypt certificate for $domains ..."
|
||||
#Join $domains to -d args
|
||||
domain_args=""
|
||||
for domain in "${domains[@]}"; do
|
||||
domain_args="$domain_args -d $domain"
|
||||
done
|
||||
|
||||
# Select appropriate email arg
|
||||
case "$email" in
|
||||
"") email_arg="--register-unsafely-without-email" ;;
|
||||
*) email_arg="--email $email" ;;
|
||||
esac
|
||||
|
||||
# Enable staging mode if needed
|
||||
if [ $staging != "0" ]; then staging_arg="--staging"; fi
|
||||
|
||||
docker compose run --rm --entrypoint "\
|
||||
certbot certonly --non-interactive --webroot -w /var/www/html \
|
||||
$staging_arg \
|
||||
$email_arg \
|
||||
$domain_args \
|
||||
--rsa-key-size $rsa_key_size \
|
||||
--agree-tos \
|
||||
--force-renewal" certbot
|
||||
echo
|
||||
|
||||
echo "### Reloading nginx ..."
|
||||
docker compose exec nginx nginx -s reload
|
@ -1,6 +1,6 @@
|
||||
# Certbot Renewal
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /usr/share/nginx/html;
|
||||
root /usr/share/nginx/html/certbot;
|
||||
allow all;
|
||||
default_type "text/plain";
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
|
||||
listen 443 ssl http2 default_server;
|
||||
listen [::]:443 ssl http2 default_server;
|
||||
|
||||
access_log /var/log/nginx/host.access.log main;
|
||||
|
||||
# SSL configuration
|
||||
include /etc/nginx/ssl.conf;
|
||||
|
||||
# Add index.php to the list if you are using PHP
|
||||
index index.html index.htm index.nginx-debian.html;
|
||||
|
||||
# Default catch all to 404
|
||||
# Added from Serverfault support https://serverfault.com/questions/994141/nginx-redirecting-the-wrong-subdomains
|
||||
server_name _;
|
||||
server_name_in_redirect off;
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
|
||||
#error_page 404 /404.html;
|
||||
|
||||
# redirect server error pages to the static page /50x.html
|
||||
#
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
}
|
@ -1,25 +1,31 @@
|
||||
upstream reftest {
|
||||
server ref_test_app:5000;
|
||||
server app:5000;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name domain_name;
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
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;
|
||||
listen [::]:443 ssl http2;
|
||||
listen 443 ssl http2 default_server;
|
||||
listen [::]:443 ssl http2 default_server;
|
||||
|
||||
#SSL configuration
|
||||
# SSL configuration
|
||||
include /etc/nginx/ssl.conf;
|
||||
include /etc/nginx/certbot-challenge.conf;
|
||||
|
||||
location ^~ /static/ {
|
||||
# Define locations for static files to be served by Nginx
|
||||
location ^~ /root/ {
|
||||
include /etc/nginx/mime.types;
|
||||
alias /usr/share/nginx/html/root/;
|
||||
}
|
||||
|
||||
location ^~ /quiz/static/ {
|
||||
include /etc/nginx/mime.types;
|
||||
alias /usr/share/nginx/html/quiz/static/;
|
||||
}
|
||||
@ -29,8 +35,40 @@ server {
|
||||
alias /usr/share/nginx/html/admin/static/;
|
||||
}
|
||||
|
||||
location ^~ /admin/editor/static/ {
|
||||
include /etc/nginx/mime.types;
|
||||
alias /usr/share/nginx/html/editor/static/;
|
||||
}
|
||||
|
||||
location ^~ /admin/view/static/ {
|
||||
include /etc/nginx/mime.types;
|
||||
alias /usr/share/nginx/html/view/static/;
|
||||
}
|
||||
|
||||
# Proxy to the main app for all other requests
|
||||
location / {
|
||||
include /etc/nginx/conf.d/common-location.conf;
|
||||
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;
|
||||
}
|
@ -1,2 +1,13 @@
|
||||
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem;
|
||||
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
ssl_trusted_certificate /etc/letsencrypt/lets-encrypt-x3-cross-signed.pem;
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
ssl_session_cache shared:SSL:40m;
|
||||
ssl_session_timeout 4h;
|
||||
ssl_session_tickets on;
|
@ -1,2 +1,3 @@
|
||||
env/
|
||||
__pycache__/
|
||||
__pycache__/
|
||||
data/
|
@ -1,5 +1,8 @@
|
||||
FROM python:3.10-slim
|
||||
ARG DATA=./data/
|
||||
ENV DATA=$DATA
|
||||
WORKDIR /ref-test
|
||||
COPY . .
|
||||
RUN pip install --upgrade pip && pip install -r requirements.txt
|
||||
RUN chmod +x install.py reset.py && ./install.py
|
||||
CMD [ "gunicorn", "-b", "0.0.0.0:5000", "-w", "5", "wsgi:app" ]
|
@ -1,18 +1,16 @@
|
||||
from .modules import bootstrap, csrf, db, login_manager, mail
|
||||
from .config import DevelopmentConfig as Config
|
||||
from .config import Production as Config
|
||||
from .models import *
|
||||
from .extensions import bootstrap, csrf, db, login_manager, mail
|
||||
from .tools.logs import write
|
||||
|
||||
from flask import Flask
|
||||
from flask_wtf.csrf import CSRFError
|
||||
from flask import flash, Flask, render_template, request
|
||||
from flask.helpers import abort, url_for
|
||||
from flask.json import jsonify
|
||||
from flask_wtf.csrf import CSRFError
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from .admin.views import admin
|
||||
from .api.views import api
|
||||
from .views import views
|
||||
from .quiz.views import quiz
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config())
|
||||
@ -26,22 +24,45 @@ def create_app():
|
||||
|
||||
login_manager.login_view = 'admin._login'
|
||||
@login_manager.user_loader
|
||||
def _load_user(user_id):
|
||||
pass
|
||||
def _load_user(id):
|
||||
try: return User.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when loading user fo login manager: {exception}')
|
||||
return abort(500)
|
||||
|
||||
@app.before_request
|
||||
def _check_cookie_consent():
|
||||
if request.cookies.get('cookie_consent'):
|
||||
return
|
||||
if any([ request.path.startswith(x) for x in [ '/admin/static/', '/root/', '/quiz/static', '/cookies/', '/admin/editor/static', '/admin/view/static' ] ]):
|
||||
return
|
||||
flash(f'<strong>Cookie Consent</strong>: This web site only stores minimal, functional cookies. It does not store any tracking information. By using this site, you consent to this use of cookies. For more information, see our <a href="{url_for("views._privacy")}">privacy policy</a>.', 'cookie_alert')
|
||||
|
||||
@app.errorhandler(404)
|
||||
def _404_handler(error):
|
||||
return jsonify({'error':'404 — Not Found'}), 404
|
||||
def _404_handler(error): return render_template('404.html')
|
||||
@app.errorhandler(CSRFError)
|
||||
def _csrf_handler():
|
||||
return jsonify({'error':'Could not validate a secure connection.'}), 403
|
||||
def _csrf_handler(): return jsonify({'error':'Could not validate a secure connection.'}), 403
|
||||
@app.context_processor
|
||||
def _now():
|
||||
return {'now': datetime.utcnow()}
|
||||
def _now(): return {'now': datetime.now()}
|
||||
|
||||
from .admin.views import admin
|
||||
from .api.views import api
|
||||
from .quiz.views import quiz
|
||||
from .views import views
|
||||
from .editor.views import editor
|
||||
from .view.views import view
|
||||
|
||||
app.register_blueprint(admin, url_prefix='/admin')
|
||||
app.register_blueprint(api, url_prefix='/api')
|
||||
app.register_blueprint(views)
|
||||
app.register_blueprint(quiz)
|
||||
app.register_blueprint(editor, url_prefix='/admin/editor')
|
||||
app.register_blueprint(view, url_prefix='/admin/view')
|
||||
|
||||
return app
|
||||
"""Create Database Tables before First Request"""
|
||||
@app.before_first_request
|
||||
def _create_database_tables():
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
return app
|
260
ref-test/app/admin/static/css/style.css
Normal file
260
ref-test/app/admin/static/css/style.css
Normal file
@ -0,0 +1,260 @@
|
||||
body {
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
background-color: lightgray;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.site-footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
padding-top: 40px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.form-display {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 15px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.form-heading {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-label-group {
|
||||
position: relative;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-label-group input,
|
||||
.form-label-group label {
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
font-size: 16pt;
|
||||
}
|
||||
|
||||
.form-label-group label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0; /* Override default `<label>` margin */
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
cursor: text; /* Match the input under the label */
|
||||
border: 1px solid transparent;
|
||||
border-radius: .25rem;
|
||||
transition: all .1s ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.form-label-group input {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 0%;
|
||||
border-bottom: 2px solid #585858;
|
||||
}
|
||||
|
||||
.form-label-group input:active, .form-label-group input:focus {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-webkit-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input:-ms-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-ms-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-moz-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input:not(:placeholder-shown) {
|
||||
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
|
||||
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||
}
|
||||
|
||||
.form-label-group input:not(:placeholder-shown) ~ label {
|
||||
padding-top: calc(var(--input-padding-y) / 3);
|
||||
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.form-check {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
transform: scale(1.5);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.signin-forgot-password {
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
.form-submission-button {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-submission-button button, .form-submission-button a {
|
||||
margin: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.form-submission-button button span, .form-submission-button button svg, .form-submission-button a span, .form-submission-button a svg {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
table.dataTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dataTables_wrapper .dt-buttons {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
float:none;
|
||||
text-align:center;
|
||||
}
|
||||
|
||||
.row-actions button, .row-actions a {
|
||||
margin: 0px 5px;
|
||||
}
|
||||
|
||||
#cookie-alert {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
#dismiss-cookie-alert {
|
||||
margin-top: 16px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.alert-db-empty {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
font-size: 14pt;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
.form-date-input, .form-select-input {
|
||||
position: relative;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.form-date-input input,
|
||||
.form-date-input label, .form-select-input select, .form-select-input label {
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
font-size: 16pt;
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid #585858;
|
||||
}
|
||||
|
||||
.datepicker::-webkit-calendar-picker-indicator {
|
||||
border: 1px;
|
||||
border-color: gray;
|
||||
border-radius: 10%;
|
||||
}
|
||||
|
||||
.form-date-input label, .form-select-input label {
|
||||
/* position: absolute; */
|
||||
/* top: 0;
|
||||
left: 0; */
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0; /* Override default `<label>` margin */
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
cursor: text; /* Match the input under the label */
|
||||
border: 1px solid transparent;
|
||||
border-radius: .25rem;
|
||||
transition: all .1s ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.form-upload {
|
||||
margin: 2rem 0;
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
.result-action-buttons, .test-action {
|
||||
margin: 5px auto;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.accordion-item {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
/* Change Autocomplete styles in Chrome*/
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
textarea:-webkit-autofill,
|
||||
textarea:-webkit-autofill:hover,
|
||||
textarea:-webkit-autofill:focus,
|
||||
select:-webkit-autofill,
|
||||
select:-webkit-autofill:hover,
|
||||
select:-webkit-autofill:focus {
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
/* Fallback for Edge
|
||||
-------------------------------------------------- */
|
||||
@supports (-ms-ime-align: auto) {
|
||||
.form-label-group label {
|
||||
display: none;
|
||||
}
|
||||
.form-label-group input::-ms-input-placeholder {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback for IE
|
||||
-------------------------------------------------- */
|
||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||
.form-label-group label {
|
||||
display: none;
|
||||
}
|
||||
.form-label-group input:-ms-input-placeholder {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
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 |
2
ref-test/app/admin/static/js/jquery-3.6.0.min.js
vendored
Normal file
2
ref-test/app/admin/static/js/jquery-3.6.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
251
ref-test/app/admin/static/js/script.js
Normal file
251
ref-test/app/admin/static/js/script.js
Normal file
@ -0,0 +1,251 @@
|
||||
// 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}),
|
||||
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 id = $(this).data('id')
|
||||
var action = $(this).data('action')
|
||||
var disabled = $(this).hasClass('disabled')
|
||||
|
||||
if ( !disabled ) {
|
||||
if (action == 'delete') {
|
||||
$.ajax({
|
||||
url: `/admin/settings/questions/${action}/`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
'id': id,
|
||||
'action': action,
|
||||
}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
window.location.reload()
|
||||
},
|
||||
error: function(response){
|
||||
error_response(response)
|
||||
},
|
||||
})
|
||||
} else if (action == 'edit') {
|
||||
window.location.href = `/admin/editor/${id}/`
|
||||
} else if (action == 'view') {
|
||||
window.location.href = `/admin/view/${id}`
|
||||
} else if (action == 'download') {
|
||||
window.location.href = `/admin/settings/questions/download/${id}/`
|
||||
}
|
||||
}
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
function error_response(response) {
|
||||
|
||||
const $alert = $("#alert-box")
|
||||
$alert.html('')
|
||||
|
||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
||||
$alert.html(`
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||
${response.responseJSON.error}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`)
|
||||
} else if (response.responseJSON.error instanceof Array) {
|
||||
var output = ''
|
||||
for (let i = 0; i < response.responseJSON.error.length; i ++) {
|
||||
output += `
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||
${response.responseJSON.error[i]}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`
|
||||
$alert.html(output)
|
||||
}
|
||||
}
|
||||
|
||||
$alert.focus()
|
||||
}
|
||||
|
||||
// Dismiss Cookie Alert
|
||||
$('#dismiss-cookie-alert').click(function(event){
|
||||
|
||||
$.ajax({
|
||||
url: '/cookies/',
|
||||
type: 'POST',
|
||||
data: {
|
||||
time: Date.now()
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response){
|
||||
console.log(response)
|
||||
},
|
||||
error: function(response){
|
||||
console.log(response)
|
||||
}
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
// 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()
|
||||
})
|
||||
|
||||
// Detailed Results view questions
|
||||
$('.view-full-questions').click(function(event) {
|
||||
var dataset = $(this).data('dataset')
|
||||
window.open(`/admin/view/${dataset}`, '_blank')
|
||||
event.preventDefault()
|
||||
})
|
56
ref-test/app/admin/templates/admin/auth/account.html
Normal file
56
ref-test/app/admin/templates/admin/auth/account.html
Normal file
@ -0,0 +1,56 @@
|
||||
{% extends "admin/components/input-forms.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-update-account" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._home') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Update Your Account</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form-label-group">
|
||||
Please confirm <strong>your current password</strong> before making any changes to your user account.
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.password_confirm(class_="form-control", placeholder="Current Password", value = user.email, autofocus=true) }}
|
||||
{{ form.password_confirm.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
You can use this panel to update your email address or password.
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.email(class_="form-control", placeholder="Email Address", value = user.email) }}
|
||||
{{ form.email.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.password(class_="form-control", placeholder="Password") }}
|
||||
{{ form.password.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.password_reenter(class_="form-control", placeholder="Password") }}
|
||||
{{ form.password_reenter.label }}
|
||||
</div>
|
||||
{% include "admin/components/client-alerts.html" %}
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<a href="{{ url_for('admin._users') }}" class="btn btn-md btn-danger btn-block" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||
</svg>
|
||||
<span>
|
||||
Cancel
|
||||
</span>
|
||||
</a>
|
||||
<button class="btn btn-md btn-primary btn-block" type="submit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-person-lines-fill" viewBox="0 0 16 16">
|
||||
<path d="M6 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-5 6s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zM11 3.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5zm.5 2.5a.5.5 0 0 0 0 1h4a.5.5 0 0 0 0-1h-4zm2 3a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1h-2zm0 3a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1h-2z"/>
|
||||
</svg>
|
||||
<span>
|
||||
Update
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
32
ref-test/app/admin/templates/admin/auth/login.html
Normal file
32
ref-test/app/admin/templates/admin/auth/login.html
Normal file
@ -0,0 +1,32 @@
|
||||
{% extends "admin/components/input-forms.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-login" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ next or url_for('admin._home') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form">Log In</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form-label-group">
|
||||
{{ form.username(class_="form-control", autofocus=true, placeholder="Enter Username") }}
|
||||
{{ form.username.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.password(class_="form-control", placeholder="Enter Password") }}
|
||||
{{ form.password.label }}
|
||||
</div>
|
||||
<div class="form-check">
|
||||
{{ form.remember(class_="form-check-input") }}
|
||||
{{ form.remember.label }}
|
||||
</div>
|
||||
{% include "admin/components/client-alerts.html" %}
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<button class="btn btn-md btn-success btn-block" type="submit">Log In</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('admin._reset') }}" class="signin-forgot-password">Reset Password</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
43
ref-test/app/admin/templates/admin/auth/register.html
Normal file
43
ref-test/app/admin/templates/admin/auth/register.html
Normal file
@ -0,0 +1,43 @@
|
||||
{% extends "admin/components/input-forms.html" %}
|
||||
|
||||
{% block navbar %}
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-register" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Register an Account</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form-label-group">
|
||||
{{ form.username(class_="form-control", autofocus=true, placeholder="Username") }}
|
||||
{{ form.username.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.email(class_="form-control", placeholder="Email Address") }}
|
||||
{{ form.email.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.password(class_="form-control", placeholder="Password") }}
|
||||
{{ form.password.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.password_reenter(class_="form-control", placeholder="Password") }}
|
||||
{{ form.password_reenter.label }}
|
||||
</div>
|
||||
{% include "admin/components/client-alerts.html" %}
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<button class="btn btn-md btn-success btn-block" type="submit">Register</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
27
ref-test/app/admin/templates/admin/auth/reset.html
Normal file
27
ref-test/app/admin/templates/admin/auth/reset.html
Normal file
@ -0,0 +1,27 @@
|
||||
{% extends "admin/components/input-forms.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-reset" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Reset Password</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form-label-group">
|
||||
{{ form.username(class_="form-control", autofocus=true, placeholder="Enter Username") }}
|
||||
{{ form.username.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.email(class_="form-control", placeholder="Enter Email Address") }}
|
||||
{{ form.email.label }}
|
||||
</div>
|
||||
{% include "admin/components/client-alerts.html" %}
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<button class="btn btn-md btn-success btn-block" type="submit">Reset Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
29
ref-test/app/admin/templates/admin/auth/update-password.html
Normal file
29
ref-test/app/admin/templates/admin/auth/update-password.html
Normal file
@ -0,0 +1,29 @@
|
||||
{% extends "admin/components/input-forms.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-update-password" class="form-display form-post" action="{{ url_for('admin._update_password', **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Update Password</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form-label-group">
|
||||
{{ form.password(class_="form-control", placeholder="Password") }}
|
||||
{{ form.password.label }}
|
||||
{{ form.password.errors[0] }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.password_reenter(class_="form-control", placeholder="Password") }}
|
||||
{{ form.password_reenter.label }}
|
||||
{{ form.password_reenter.errors[0] }}
|
||||
</div>
|
||||
{% include "admin/components/client-alerts.html" %}
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<button class="btn btn-md btn-success btn-block" type="submit">Update Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
82
ref-test/app/admin/templates/admin/components/base.html
Normal file
82
ref-test/app/admin/templates/admin/components/base.html
Normal file
@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
|
||||
crossorigin="anonymous">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('.static', filename='css/style.css') }}"
|
||||
/>
|
||||
{% block datatable_css %}
|
||||
{% endblock %}
|
||||
<title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title>
|
||||
{% include "admin/components/og-meta.html" %}
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
||||
{% block navbar %}
|
||||
{% include "admin/components/navbar.html" %}
|
||||
{% endblock %}
|
||||
|
||||
<div class="container">
|
||||
{% block top_alerts %}
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer class="container site-footer mt-5">
|
||||
{% block footer %}
|
||||
{% include "admin/components/footer.html" %}
|
||||
{% endblock %}
|
||||
</footer>
|
||||
|
||||
<!-- JQuery, Popper, and Bootstrap js dependencies -->
|
||||
<script
|
||||
src="https://code.jquery.com/jquery-3.6.0.min.js"
|
||||
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script>
|
||||
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
|
||||
</script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
||||
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
|
||||
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<!-- Custom js -->
|
||||
<script type="text/javascript">
|
||||
var csrf_token = "{{ csrf_token() }}";
|
||||
|
||||
$.ajaxSetup({
|
||||
beforeSend: function(xhr, settings) {
|
||||
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
|
||||
xhr.setRequestHeader("X-CSRFToken", csrf_token);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="{{ url_for('.static', filename='js/script.js') }}"
|
||||
></script>
|
||||
{% block datatable_scripts %}
|
||||
{% endblock %}
|
||||
{% block custom_data_script %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</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.get_club() %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Club</h5>
|
||||
</div>
|
||||
{{ entry.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>
|
28
ref-test/app/admin/templates/admin/components/datatable.html
Normal file
28
ref-test/app/admin/templates/admin/components/datatable.html
Normal file
@ -0,0 +1,28 @@
|
||||
{% extends "admin/components/base.html" %}
|
||||
{% block datatable_css %}
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.11.3/css/dataTables.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/buttons/2.0.1/css/buttons.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/colreorder/1.5.5/css/colReorder.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/fixedheader/3.2.0/css/fixedHeader.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/keytable/2.6.4/css/keyTable.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/searchbuilder/1.3.0/css/searchBuilder.dataTables.min.css"/>
|
||||
{% endblock %}
|
||||
{% block datatable_scripts %}
|
||||
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jszip/2.5.0/jszip.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/pdfmake.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/vfs_fonts.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/jquery.dataTables.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/dataTables.bootstrap5.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/dataTables.buttons.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.bootstrap5.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.colVis.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.html5.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.print.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/colreorder/1.5.5/js/dataTables.colReorder.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/fixedheader/3.2.0/js/dataTables.fixedHeader.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/keytable/2.6.4/js/dataTables.keyTable.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/responsive.bootstrap5.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/searchbuilder/1.3.0/js/dataTables.searchBuilder.min.js"></script>
|
||||
{% endblock %}
|
@ -0,0 +1,2 @@
|
||||
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
@ -0,0 +1,4 @@
|
||||
{% extends "admin/components/base.html" %}
|
||||
{% import "bootstrap/wtf.html" as wtf %}
|
||||
{% block top_alerts %}
|
||||
{% endblock %}
|
117
ref-test/app/admin/templates/admin/components/navbar.html
Normal file
117
ref-test/app/admin/templates/admin/components/navbar.html
Normal file
@ -0,0 +1,117 @@
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbar"
|
||||
aria-controls="navbar"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle Navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navbar">
|
||||
<ul class="navbar-nav">
|
||||
{% if not current_user.is_authenticated %}
|
||||
<li class="nav-item" id="nav-login">
|
||||
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item" 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">Manage Questions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-account">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
role="button"
|
||||
href="{{ url_for('admin._update_user', id=current_user.id) }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Account
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-account"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
18
ref-test/app/admin/templates/admin/components/og-meta.html
Normal file
18
ref-test/app/admin/templates/admin/components/og-meta.html
Normal file
@ -0,0 +1,18 @@
|
||||
<meta name="description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta property="og:locale" content="en_UK" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta property="og:url" content="{{ url_for(request.endpoint, _external = True, **(request.view_args or {})) }}" />
|
||||
<meta property="og:site_name" content="Scottish Korfball Association Referee Theory Exam" />
|
||||
<meta property="og:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
|
||||
<meta property="og:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||
<meta property="og:image:width" content="512" />
|
||||
<meta property="og:image:height" content="512" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta name="twitter:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
|
||||
<meta name="twitter:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||
<meta name="twitter:creator" content="@viveksantayana" />
|
||||
<meta name="twitter:site" content="@viveksantayana" />
|
||||
<meta name="theme-color" content="#343a40" />
|
||||
<link rel="shortcut icon" href="{{ url_for('.static', filename='favicon.ico') }}">
|
@ -0,0 +1,23 @@
|
||||
<div class="navbar navbar-expand-sm navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<div class="expand navbar-expand justify-content-center" id="navbar_secondary">
|
||||
<ul class="nav nav-pills">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='active') }}">Active</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='scheduled') }}">Scheduled</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='expired') }}">Expired</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='all') }}">All</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='create') }}">Create</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,43 @@
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% set cookie_flash_flag = namespace(value=False) %}
|
||||
{% for category, message in messages %}
|
||||
{% if category == "error" %}
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Error" aria-title="Error"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% elif category == "success" %}
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-check2-circle" title="Success" aria-title="Success"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% elif category == "warning" %}
|
||||
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-info-circle-fill" aria-title="Warning" title="Warning"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% elif category == "cookie_alert" %}
|
||||
{% if not cookie_flash_flag.value %}
|
||||
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
|
||||
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
|
||||
{{ message|safe }}
|
||||
<div class="d-flex justify-content-center w-100">
|
||||
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
|
||||
</div>
|
||||
</div>
|
||||
{% set cookie_flash_flag.value = True %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-info-circle-fill" title="Alert"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
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 and is maintained by Vivek Santayana. If there are any issues with the app, any bugs you need to report, or any features you would like to request, please feel free to <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues">open an issue at the Git Repository</a>.</p>
|
||||
<a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues" class="btn btn-primary">Open an Issue</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
189
ref-test/app/admin/templates/admin/result-detail.html
Normal file
189
ref-test/app/admin/templates/admin/result-detail.html
Normal file
@ -0,0 +1,189 @@
|
||||
{% 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.get_club() %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Club</h5>
|
||||
</div>
|
||||
{{ entry.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">
|
||||
<a class="view-full-questions" data-dataset="{{ entry.test.dataset.id }}">View Questions</a>
|
||||
<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|int + 1 }}
|
||||
</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.get_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 %}
|
44
ref-test/app/admin/templates/admin/settings/delete_user.html
Normal file
44
ref-test/app/admin/templates/admin/settings/delete_user.html
Normal file
@ -0,0 +1,44 @@
|
||||
{% extends "admin/components/input-forms.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-delete-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._users') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Delete User ‘{{ user.get_username() }}’?</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<p>This action cannot be undone. Deleting an account will mean {{ user.get_username() }} will no longer be able to log in to the admin console.</p>
|
||||
<p>Are you sure you want to proceed?</p>
|
||||
<div class="form-label-group">
|
||||
{{ form.password(class_="form-control", placeholder="Confirm Your Password", autofocus=true) }}
|
||||
{{ form.password.label }}
|
||||
</div>
|
||||
<div class="form-check">
|
||||
{{ form.notify(class_="form-check-input") }}
|
||||
{{ form.notify.label }}
|
||||
</div>
|
||||
{% include "admin/components/client-alerts.html" %}
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<a href="{{ url_for('admin._users') }}" autofocus="true" class="btn btn-md btn-primary btn-block" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||
</svg>
|
||||
<span>
|
||||
Cancel
|
||||
</span>
|
||||
</a>
|
||||
<button class="btn btn-md btn-danger btn-block" type="submit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-person-x-fill" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M1 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm6.146-2.854a.5.5 0 0 1 .708 0L14 6.293l1.146-1.147a.5.5 0 0 1 .708.708L14.707 7l1.147 1.146a.5.5 0 0 1-.708.708L14 7.707l-1.146 1.147a.5.5 0 0 1-.708-.708L13.293 7l-1.147-1.146a.5.5 0 0 1 0-.708z"></path>
|
||||
</svg>
|
||||
<span>
|
||||
Delete
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
95
ref-test/app/admin/templates/admin/settings/index.html
Normal file
95
ref-test/app/admin/templates/admin/settings/index.html
Normal file
@ -0,0 +1,95 @@
|
||||
{% 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>
|
||||
Name
|
||||
</th>
|
||||
<th>
|
||||
Exams
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for dataset in datasets %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('editor._editor_console', id=dataset.id) }}">
|
||||
{{ dataset.get_name() }}
|
||||
</a>
|
||||
</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 %}
|
155
ref-test/app/admin/templates/admin/settings/questions.html
Normal file
155
ref-test/app/admin/templates/admin/settings/questions.html
Normal file
@ -0,0 +1,155 @@
|
||||
{% 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="1">
|
||||
Name
|
||||
</th>
|
||||
<th data-priority="2">
|
||||
Updated
|
||||
</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.get_name() }}
|
||||
</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="javascript:void(0)"
|
||||
class="btn btn-primary edit-question-dataset"
|
||||
data-id="{{ element.id }}"
|
||||
data-action="download"
|
||||
title="Download Questions"
|
||||
>
|
||||
<i class="bi bi-cloud-arrow-down-fill button-icon"></i>
|
||||
</a>
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
class="btn btn-primary view-question-dataset"
|
||||
data-id="{{ element.id }}"
|
||||
data-action="view"
|
||||
title="View Questions"
|
||||
>
|
||||
<i class="bi bi-file-earmark-text-fill button-icon"></i>
|
||||
</a>
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
class="btn btn-primary edit-question-dataset"
|
||||
data-id="{{ element.id }}"
|
||||
data-action="edit"
|
||||
title="Edit Questions"
|
||||
>
|
||||
<i class="bi bi-pencil-fill button-icon"></i>
|
||||
</a>
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
class="btn btn-danger edit-question-dataset {% if element.default %}disabled{% endif %}"
|
||||
data-id="{{ element.id }}"
|
||||
data-action="delete"
|
||||
title="Delete Questions"
|
||||
>
|
||||
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
||||
</a>
|
||||
</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 or create a new dataset using the editor console.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col text-center">
|
||||
<button title="Create New" class="btn btn-md btn-primary btn-block create-new-dataset">
|
||||
<i class="bi bi-cloud-plus-fill button-icon"></i>
|
||||
Create New Dataset
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-container">
|
||||
<form name="form-upload-questions" class="form-display" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="" enctype="multipart/form-data">
|
||||
<h2 class="form-heading">Upload Question Dataset</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form-label-group">
|
||||
{{ form.name(class_="form-control", autofocus=true, placeholder="Enter Name of Dataset") }}
|
||||
{{ form.name.label }}
|
||||
</div>
|
||||
<div class="form-upload">
|
||||
{{ form.data_file() }}
|
||||
</div>
|
||||
<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="Upload Dataset" class="btn btn-md btn-success btn-block" type="submit">
|
||||
<i class="bi bi-cloud-arrow-up-fill button-icon"></i>
|
||||
Upload Dataset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% if data %}
|
||||
{% block custom_data_script %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('#question-datasets-table').DataTable({
|
||||
'columnDefs': [
|
||||
{'sortable': false, 'targets': [0,5]},
|
||||
{'searchable': false, 'targets': [1,2,3]}
|
||||
],
|
||||
'order': [[1, 'asc'], [2, 'desc'], [3, 'asc']],
|
||||
'responsive': 'true',
|
||||
'fixedHeader': 'true',
|
||||
});
|
||||
} );
|
||||
$('#question-datasets-table').show();
|
||||
$(window).trigger('resize');
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endif %}
|
57
ref-test/app/admin/templates/admin/settings/update_user.html
Normal file
57
ref-test/app/admin/templates/admin/settings/update_user.html
Normal file
@ -0,0 +1,57 @@
|
||||
{% extends "admin/components/input-forms.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-update-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._users') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Update User ‘{{ user.get_username() }}’</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form-label-group">
|
||||
{{ form.email(class_="form-control", placeholder="Email Address", value = user.get_email()) }}
|
||||
{{ form.email.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.password(class_="form-control", placeholder="Password") }}
|
||||
{{ form.password.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.password_reenter(class_="form-control", placeholder="Password") }}
|
||||
{{ form.password_reenter.label }}
|
||||
</div>
|
||||
<div class="form-check">
|
||||
{{ form.notify(class_="form-check-input") }}
|
||||
{{ form.notify.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
Please confirm <strong>your current password</strong> before committing any changes to a user account.
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.confirm_password(class_="form-control", placeholder="Your Password", value = user.email) }}
|
||||
{{ form.confirm_password.label }}
|
||||
</div>
|
||||
{% include "admin/components/client-alerts.html" %}
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<a href="{{ url_for('admin._users') }}" class="btn btn-md btn-danger btn-block" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||
</svg>
|
||||
<span>
|
||||
Cancel
|
||||
</span>
|
||||
</a>
|
||||
<button class="btn btn-md btn-primary btn-block" type="submit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-person-lines-fill" viewBox="0 0 16 16">
|
||||
<path d="M6 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-5 6s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zM11 3.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5zm.5 2.5a.5.5 0 0 0 0 1h4a.5.5 0 0 0 0-1h-4zm2 3a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1h-2zm0 3a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1h-2z"/>
|
||||
</svg>
|
||||
<span>
|
||||
Update
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
125
ref-test/app/admin/templates/admin/settings/users.html
Normal file
125
ref-test/app/admin/templates/admin/settings/users.html
Normal file
@ -0,0 +1,125 @@
|
||||
{% extends "admin/components/datatable.html" %}
|
||||
{% block title %} SKA Referee Test | Manage Users {% endblock %}
|
||||
{% block content %}
|
||||
<h1>Manage Users</h1>
|
||||
<table id="user-table" class="table table-striped" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
|
||||
</th>
|
||||
<th data-priority="1">
|
||||
Username
|
||||
</th>
|
||||
<th>
|
||||
Email Address
|
||||
</th>
|
||||
<th data-priority="1">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr class="table-row">
|
||||
<td>
|
||||
{% if user == current_user %}
|
||||
<div class="text-success" title="Current User">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi success bi-caret-right-fill" viewBox="0 0 16 16">
|
||||
<path d="m12.14 8.753-5.482 4.796c-.646.566-1.658.106-1.658-.753V3.204a1 1 0 0 1 1.659-.753l5.48 4.796a1 1 0 0 1 0 1.506z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ user.get_username() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ user.get_email() }}
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<a
|
||||
href="
|
||||
{% if not user == current_user %}
|
||||
{{ url_for('admin._update_user', id = user.id ) }}
|
||||
{% else %}
|
||||
{{ url_for('admin._update_user', id=current_user.id) }}
|
||||
{% endif %}
|
||||
"
|
||||
class="btn btn-primary"
|
||||
title="Update User"
|
||||
>
|
||||
<i class="bi bi-person-lines-fill button-icon"></i>
|
||||
</a>
|
||||
<a
|
||||
href="
|
||||
{% if not user == current_user %}
|
||||
{{ url_for('admin._delete_user', id = user.id ) }}
|
||||
{% else %}
|
||||
#
|
||||
{% endif %}
|
||||
"
|
||||
class="btn btn-danger {% if user == current_user %} disabled {% endif %}"
|
||||
title="Delete User"
|
||||
{% if user == current_user %} onclick="return false" {% endif %}
|
||||
>
|
||||
<i class="bi bi-person-x-fill button-icon"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="form-container">
|
||||
<form name="form-create-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="">
|
||||
<h2 class="form-heading">Create User</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form-label-group">
|
||||
{{ form.username(class_="form-control", placeholder="Enter Username") }}
|
||||
{{ form.username.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.email(class_="form-control", placeholder="Enter Email") }}
|
||||
{{ form.email.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
If you do not enter a password, a random one will be generated.
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.password(class_="form-control", placeholder="Enter Password") }}
|
||||
{{ form.password.label }}
|
||||
</div>
|
||||
{% include "admin/components/client-alerts.html" %}
|
||||
<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-person-plus-fill button-icon"></i>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block custom_data_script %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('#user-table').DataTable({
|
||||
'columnDefs': [
|
||||
{'sortable': false, 'targets': [0,3]}
|
||||
],
|
||||
'order': [[1, 'asc'], [2, 'asc']],
|
||||
'buttons': [
|
||||
'copy', 'excel', 'pdf'
|
||||
],
|
||||
'responsive': 'true',
|
||||
'colReorder': 'true',
|
||||
'fixedHeader': 'true'
|
||||
});
|
||||
} );
|
||||
$('#user-table').show();
|
||||
$(window).trigger('resize');
|
||||
</script>
|
||||
{% endblock %}
|
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>
|
||||
<a href="{{ url_for('view._view_console', id=test.dataset.id) }}">{{ test.dataset.get_name() }}</a>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Created By</h5>
|
||||
</div>
|
||||
{{ test.creator.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 %}
|
167
ref-test/app/admin/templates/admin/tests.html
Normal file
167
ref-test/app/admin/templates/admin/tests.html
Normal file
@ -0,0 +1,167 @@
|
||||
{% extends "admin/components/datatable.html" %}
|
||||
{% block title %} SKA Referee Test | Manage Exams {% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/components/client-alerts.html" %}
|
||||
<h1>Manage Exams</h1>
|
||||
{% include "admin/components/secondary-navs/tests.html" %}
|
||||
<h2>{{ display_title }}</h2>
|
||||
{% if tests %}
|
||||
<table id="active-test-table" class="table table-striped" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-priority="1">
|
||||
Start Date
|
||||
</th>
|
||||
<th data-priority="1">
|
||||
Exam Code
|
||||
</th>
|
||||
<th data-priority="2">
|
||||
Expiry Date
|
||||
</th>
|
||||
<th data-priority="3">
|
||||
Time Limit
|
||||
</th>
|
||||
<th data-priority="4">
|
||||
Entries
|
||||
</th>
|
||||
<th data-priority="1">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for test in tests %}
|
||||
<tr class="table-row">
|
||||
<td>
|
||||
{{ test.start_date.strftime('%d %b %y %H:%M') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ test.get_code() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ test.end_date.strftime('%d %b %Y %H:%M') }}
|
||||
</td>
|
||||
<td>
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td>
|
||||
{{ test.entries|length }}
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<a
|
||||
href="#"
|
||||
class="btn btn-primary test-action"
|
||||
data-id="{{test.id}}"
|
||||
title="Edit Exam"
|
||||
data-action="edit"
|
||||
>
|
||||
<i class="bi bi-file-earmark-text-fill button-icon"></i>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="btn btn-danger test-action"
|
||||
data-id="{{test.id}}"
|
||||
title="Delete Exam"
|
||||
data-action="delete"
|
||||
>
|
||||
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% elif not filter == 'create' %}
|
||||
<div class="alert alert-primary alert-db-empty">
|
||||
<i class="bi bi-info-circle-fill" aria-title="Alert" title="Alert"></i>
|
||||
{{ error_none }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form %}
|
||||
<div class="form-container">
|
||||
<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-heading">Create Exam</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form-date-input">
|
||||
{{ form.start_date(placeholder="Enter Start Date", class_ = "datepicker") }}
|
||||
{{ form.start_date.label }}
|
||||
</div>
|
||||
<div class="form-date-input">
|
||||
{{ form.expiry_date(placeholder="Enter Expiry Date", class_ = "datepicker") }}
|
||||
{{ form.expiry_date.label }}
|
||||
</div>
|
||||
<div class="form-select-input">
|
||||
{{ form.time_limit(placeholder="Select Time Limit") }}
|
||||
{{ form.time_limit.label }}
|
||||
</div>
|
||||
<div class="form-select-input">
|
||||
{{ form.dataset(placeholder="Select Question Dataset") }}
|
||||
{{ form.dataset.label }}
|
||||
</div>
|
||||
{% include "admin/components/client-alerts.html" %}
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<button title="Create Exam" class="btn btn-md btn-success btn-block" type="submit">
|
||||
<i class="bi bi-file-earmark-plus-fill button-icon"></i>
|
||||
Create Exam
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% if tests %}
|
||||
{% block custom_data_script %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('#active-test-table').DataTable({
|
||||
'columnDefs': [
|
||||
{'sortable': false, 'targets': [1,3,5]},
|
||||
{'searchable': false, 'targets': [3,5]}
|
||||
],
|
||||
'order': [[0, 'desc'], [2, 'asc']],
|
||||
dom: 'lfBrtip',
|
||||
'buttons': [
|
||||
{
|
||||
extend: 'print',
|
||||
exportOptions: {
|
||||
columns: [0, 1, 2, 3]
|
||||
}
|
||||
},
|
||||
{
|
||||
extend: 'excel',
|
||||
exportOptions: {
|
||||
columns: [0, 1, 2, 3]
|
||||
}
|
||||
},
|
||||
{
|
||||
extend: 'pdf',
|
||||
exportOptions: {
|
||||
columns: [0, 1, 2, 3]
|
||||
}
|
||||
},
|
||||
],
|
||||
'responsive': 'true',
|
||||
'colReorder': 'true',
|
||||
'fixedHeader': 'true',
|
||||
});
|
||||
// $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') -->
|
||||
} );
|
||||
$('#active-test-table').show();
|
||||
$(window).trigger('resize');
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endif %}
|
456
ref-test/app/admin/views.py
Normal file
456
ref-test/app/admin/views.py
Normal file
@ -0,0 +1,456 @@
|
||||
from ..forms.admin import AddTimeAdjustment, CreateTest, CreateUser, DeleteUser, Login, Register, ResetPassword, UpdatePassword, UpdateUser, UploadData
|
||||
from ..models import Dataset, Entry, Test, User
|
||||
from ..tools.auth import disable_if_logged_in, require_account_creation
|
||||
from ..tools.data import check_dataset_exists, check_is_json, validate_json
|
||||
from ..tools.forms import get_dataset_choices, get_time_options, send_errors_to_client
|
||||
from ..tools.logs import write
|
||||
from ..tools.test import answer_options, get_correct_answers
|
||||
|
||||
from flask import abort, Blueprint, jsonify, render_template, request, send_file, session
|
||||
from flask.helpers import abort, flash, redirect, url_for
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from json import loads
|
||||
from os import path
|
||||
import secrets
|
||||
|
||||
admin = Blueprint(
|
||||
name='admin',
|
||||
import_name=__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static'
|
||||
)
|
||||
|
||||
@admin.route('/')
|
||||
@admin.route('/home/')
|
||||
@admin.route('/dashboard/')
|
||||
@login_required
|
||||
def _home():
|
||||
try:
|
||||
tests = Test.query.all()
|
||||
results = Entry.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
current_tests = [ test for test in tests if test.end_date >= datetime.now() and test.start_date.date() <= date.today() ]
|
||||
current_tests.sort(key= lambda x: x.end_date, 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():
|
||||
try:
|
||||
users = User.query.all()
|
||||
datasets = Dataset.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
return render_template('/admin/settings/index.html', users=users, datasets=datasets)
|
||||
|
||||
@admin.route('/login/', methods=['GET','POST'])
|
||||
@disable_if_logged_in
|
||||
@require_account_creation
|
||||
def _login():
|
||||
form = Login()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
try: users = User.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
user = None
|
||||
for _user in users:
|
||||
if _user.get_username() == request.form.get('username').lower():
|
||||
user = _user
|
||||
break
|
||||
if user:
|
||||
if user.verify_password(request.form.get('password')):
|
||||
user.login(remember=request.form.get('remember'))
|
||||
return jsonify({'success': f'Successfully logged in.'}), 200
|
||||
return jsonify({'error': f'The password you entered is incorrect.'}), 401
|
||||
return jsonify({'error': f'The username you entered does not exist.'}), 401
|
||||
return send_errors_to_client(form=form)
|
||||
if 'remembered_username' in session: form.username.data = session.pop('remembered_username')
|
||||
next = request.args.get('next')
|
||||
return render_template('/admin/auth/login.html', form=form, next=next)
|
||||
|
||||
@admin.route('/logout/')
|
||||
@login_required
|
||||
def _logout():
|
||||
current_user.logout()
|
||||
return redirect(url_for('admin._login'))
|
||||
|
||||
@admin.route('/register/', methods=['GET','POST'])
|
||||
@disable_if_logged_in
|
||||
def _register():
|
||||
from ..models.user import User
|
||||
form = Register()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
new_user = User()
|
||||
new_user.set_username(request.form.get('username').lower())
|
||||
new_user.set_email(request.form.get('email').lower())
|
||||
success, message = new_user.register(password=request.form.get('password'))
|
||||
if success:
|
||||
flash(message=f'{message} Please log in to continue.', category='success')
|
||||
session['remembered_username'] = request.form.get('username').lower()
|
||||
return jsonify({'success': message}), 200
|
||||
flash(message=message, category='error')
|
||||
return jsonify({'error': message}), 401
|
||||
return send_errors_to_client(form=form)
|
||||
return render_template('/admin/auth/register.html', form=form)
|
||||
|
||||
@admin.route('/reset/', methods=['GET','POST'])
|
||||
def _reset():
|
||||
form = ResetPassword()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
user = None
|
||||
try: users = User.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
for _user in users:
|
||||
if _user.get_username() == request.form.get('username'):
|
||||
user = _user
|
||||
break
|
||||
if not user: return jsonify({'error': 'The user account does not exist.'}), 400
|
||||
if not user.get_email() == request.form.get('email'): return jsonify({'error': 'The email address does not match the user account.'}), 400
|
||||
return user.reset_password()
|
||||
return send_errors_to_client(form=form)
|
||||
|
||||
token = request.args.get('token')
|
||||
if token:
|
||||
try: user = User.query.filter_by(reset_token=token).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not user: return redirect(url_for('admin._reset'))
|
||||
verification_token = user.verification_token
|
||||
user.clear_reset_tokens()
|
||||
if request.args.get('verification') == verification_token:
|
||||
form = UpdatePassword()
|
||||
session['user'] = user.id
|
||||
return render_template('/admin/auth/update-password.html', form=form)
|
||||
flash('The verification of your password reset request failed and the token has been invalidated. Please make a new reset password request.', 'error')
|
||||
|
||||
return render_template('/admin/auth/reset.html', form=form)
|
||||
|
||||
@admin.route('/update_password/', methods=['POST'])
|
||||
def _update_password():
|
||||
form = UpdatePassword()
|
||||
if form.validate_on_submit():
|
||||
user = session.pop('user')
|
||||
try: user = User.query.filter_by(id=user).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
user.update(password=request.form.get('password'))
|
||||
session['remembered_username'] = user.get_username()
|
||||
flash('Your password has been reset.', 'success')
|
||||
return jsonify({'success':'Your password has been reset'}), 200
|
||||
return send_errors_to_client(form=form)
|
||||
|
||||
@admin.route('/settings/users/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _users():
|
||||
form = CreateUser()
|
||||
try: users = User.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
password = request.form.get('password')
|
||||
password = secrets.token_hex(12) if not password else password
|
||||
new_user = User()
|
||||
new_user.set_username(request.form.get('username').lower())
|
||||
new_user.set_email(request.form.get('email'))
|
||||
success, message = new_user.register(notify=request.form.get('notify'), password=password)
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 401
|
||||
return send_errors_to_client(form=form)
|
||||
return render_template('/admin/settings/users.html', form = form, users = users)
|
||||
|
||||
@admin.route('/settings/users/delete/<string:id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _delete_user(id:str):
|
||||
try: user = User.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
form = DeleteUser()
|
||||
if request.method == 'POST':
|
||||
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
||||
if id == current_user.id: return jsonify({'error': 'Cannot delete your own account.'}), 400
|
||||
if form.validate_on_submit():
|
||||
password = request.form.get('password')
|
||||
if not current_user.verify_password(password): return jsonify({'error': 'The password you entered is incorrect.'}), 401
|
||||
success, message = user.delete(notify=request.form.get('notify'))
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
return send_errors_to_client(form=form)
|
||||
|
||||
if id == current_user.id:
|
||||
flash('Cannot delete your own user account.', 'error')
|
||||
return redirect(url_for('admin._users'))
|
||||
if not user:
|
||||
flash('User not found.', 'error')
|
||||
return redirect(url_for('admin._users'))
|
||||
return render_template('/admin/settings/delete_user.html', form=form, id = id, user = user)
|
||||
|
||||
@admin.route('/settings/users/update/<string:id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _update_user(id:str):
|
||||
try: user = User.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
form = UpdateUser()
|
||||
if request.method == 'POST':
|
||||
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
||||
if form.validate_on_submit():
|
||||
if not current_user.verify_password(request.form.get('confirm_password')): return jsonify({'error': 'Invalid password for your account.'}), 401
|
||||
success, message = user.update(
|
||||
password = request.form.get('password'),
|
||||
email = request.form.get('email'),
|
||||
notify = request.form.get('notify')
|
||||
)
|
||||
if success:
|
||||
flash(message, 'success')
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
return send_errors_to_client(form=form)
|
||||
if not user:
|
||||
flash('User not found.', 'error')
|
||||
return redirect(url_for('admin._users'))
|
||||
return render_template('/admin/settings/update_user.html', form=form, id = id, user = user)
|
||||
|
||||
@admin.route('/settings/questions/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _questions():
|
||||
form = UploadData()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
upload = form.data_file.data
|
||||
if not check_is_json(upload): return jsonify({'error': 'Invalid file. Please upload a JSON file.'}), 400
|
||||
upload.stream.seek(0)
|
||||
data = loads(upload.read())
|
||||
if not validate_json(data=data): return jsonify({'error': 'The data in the file is invalid.'}), 400
|
||||
new_dataset = Dataset()
|
||||
new_dataset.set_name(request.form.get('name'))
|
||||
success, message = new_dataset.create(
|
||||
data = data,
|
||||
default = request.form.get('default')
|
||||
)
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
return send_errors_to_client(form=form)
|
||||
|
||||
try: data = Dataset.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
return render_template('/admin/settings/questions.html', form=form, data=data)
|
||||
|
||||
@admin.route('/settings/questions/delete/', methods=['POST'])
|
||||
@login_required
|
||||
def _edit_questions():
|
||||
id = request.get_json()['id']
|
||||
action = request.get_json()['action']
|
||||
if not action == 'delete': return jsonify({'error': 'Invalid action.'}), 400
|
||||
try: dataset = Dataset.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if action == 'delete': success, message = dataset.delete()
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
|
||||
@admin.route('/settings/questions/download/<string:id>/')
|
||||
@login_required
|
||||
def _download(id:str):
|
||||
try: dataset = Dataset.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not dataset: return abort(404)
|
||||
data_path = path.abspath(dataset.get_file())
|
||||
return send_file(data_path, as_attachment=True, attachment_filename=f'{dataset.get_name()}.json')
|
||||
|
||||
@admin.route('/tests/<string:filter>/', methods=['GET'])
|
||||
@admin.route('/tests/', methods=['GET'])
|
||||
@login_required
|
||||
@check_dataset_exists
|
||||
def _tests(filter:str=None):
|
||||
tests = None
|
||||
try: _tests = Test.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
form = None
|
||||
now = datetime.now()
|
||||
if filter not in ['create','active','scheduled','expired','all']: return redirect(url_for('admin._tests', filter='active'))
|
||||
if filter == 'create':
|
||||
form = CreateTest()
|
||||
form.start_date.default = datetime.now()
|
||||
form.expiry_date.default = date.today() + timedelta(days=1)
|
||||
form.time_limit.choices = get_time_options()
|
||||
form.dataset.choices = get_dataset_choices()
|
||||
form.time_limit.default='none'
|
||||
form.process()
|
||||
display_title = ''
|
||||
error_none = ''
|
||||
if filter in [None, '', 'active']:
|
||||
tests = [ test for test in _tests if test.end_date >= now and test.start_date <= now ]
|
||||
display_title = 'Active Exams'
|
||||
error_none = 'There are no exams that are currently active. You can create one using the 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')
|
||||
try: new_test.dataset = Dataset.query.filter_by(id=dataset).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
success, message = new_test.create()
|
||||
if success:
|
||||
flash(message=message, category='success')
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
return send_errors_to_client(form=form)
|
||||
|
||||
@admin.route('/tests/edit/', methods=['POST'])
|
||||
@login_required
|
||||
def _edit_test():
|
||||
id = request.get_json()['id']
|
||||
action = request.get_json()['action']
|
||||
if action not in ['start', 'delete', 'end']: return jsonify({'error': 'Invalid action.'}), 400
|
||||
try: test = Test.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not test: return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
|
||||
if action == 'delete': success, message = test.delete()
|
||||
if action == 'start': success, message = test.start()
|
||||
if action == 'end': success, message = test.end()
|
||||
if success:
|
||||
flash(message=message, category='success')
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
|
||||
@admin.route('/test/<string:id>/', methods=['GET','POST'])
|
||||
@login_required
|
||||
def _view_test(id:str=None):
|
||||
form = AddTimeAdjustment()
|
||||
try: test = Test.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if request.method == 'POST':
|
||||
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
|
||||
if form.validate_on_submit():
|
||||
time = int(request.form.get('time'))
|
||||
success, message = test.add_adjustment(time)
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
return jsonify({'error': form.time.errors }), 400
|
||||
if not test:
|
||||
flash('Invalid test ID.', 'error')
|
||||
return redirect(url_for('admin._tests', filter='active'))
|
||||
return render_template('/admin/test.html', test = test, form = form)
|
||||
|
||||
@admin.route('/test/<string:id>/delete-adjustment/', methods=['POST'])
|
||||
@login_required
|
||||
def _delete_adjustment(id:str=None):
|
||||
try: test = Test.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
|
||||
user_code = request.get_json()['user_code'].lower()
|
||||
success, message = test.remove_adjustment(user_code)
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
|
||||
@admin.route('/results/')
|
||||
@login_required
|
||||
def _view_entries():
|
||||
try: entries = Entry.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
return render_template('/admin/results.html', entries = entries)
|
||||
|
||||
@admin.route('/results/<string:id>/', methods = ['GET', 'POST'])
|
||||
@login_required
|
||||
def _view_entry(id:str=None):
|
||||
try: entry = Entry.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if request.method == 'POST':
|
||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||
action = request.get_json()['action']
|
||||
if action not in ['validate', 'delete']: return jsonify({'error': 'Invalid action.'}), 400
|
||||
if action == 'validate':
|
||||
success, message = entry.validate()
|
||||
if action == 'delete':
|
||||
success, message = entry.delete()
|
||||
if success:
|
||||
flash(message, 'success')
|
||||
entry.notify_result()
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}),400
|
||||
if not entry:
|
||||
flash('Invalid entry ID.', 'error')
|
||||
return redirect(url_for('admin._view_entries'))
|
||||
test = entry.test
|
||||
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 ..extensions import db
|
||||
id = request.get_json()['id']
|
||||
try: entry = Entry.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||
return render_template('/admin/components/certificate.html', entry = entry)
|
@ -1,20 +0,0 @@
|
||||
from flask import Blueprint
|
||||
|
||||
admin = Blueprint(
|
||||
name='admin',
|
||||
import_name=__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static'
|
||||
)
|
||||
|
||||
@admin.route('/')
|
||||
@admin.route('/home/')
|
||||
@admin.route('/dashboard/')
|
||||
def _home():
|
||||
return 'Home Page'
|
||||
|
||||
@admin.route('/settings/')
|
||||
def _settings():
|
||||
return 'Settings Page'
|
||||
|
||||
from . import auth, questions, results, tests, users
|
@ -1,21 +0,0 @@
|
||||
from . import admin
|
||||
|
||||
@admin.route('/login/')
|
||||
def _login():
|
||||
return 'Login Page'
|
||||
|
||||
@admin.route('/logout/')
|
||||
def _logout():
|
||||
return 'Logout Command'
|
||||
|
||||
@admin.route('/register/')
|
||||
def _register():
|
||||
return 'Registration Page'
|
||||
|
||||
@admin.route('/reset/')
|
||||
def _reset():
|
||||
return 'Reset Page'
|
||||
|
||||
@admin.route('/update_password/', methods=['POST'])
|
||||
def _update_password():
|
||||
return 'Password Update'
|
@ -1,6 +0,0 @@
|
||||
from . import admin
|
||||
|
||||
@admin.route('/settings/users/')
|
||||
def _users():
|
||||
return 'Manage Users'
|
||||
|
115
ref-test/app/api/views.py
Normal file
115
ref-test/app/api/views.py
Normal file
@ -0,0 +1,115 @@
|
||||
from ..models import Dataset, Entry, User
|
||||
from ..tools.data import validate_json
|
||||
from ..tools.logs import write
|
||||
from ..tools.test import evaluate_answers, generate_questions
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask.helpers import abort, flash, url_for
|
||||
from flask_login import login_required
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from json import loads
|
||||
|
||||
api = Blueprint(
|
||||
name='api',
|
||||
import_name=__name__
|
||||
)
|
||||
|
||||
@api.route('/questions/', methods=['POST'])
|
||||
def _fetch_questions():
|
||||
id = request.get_json()['id']
|
||||
try: entry = Entry.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 400
|
||||
test = entry.test
|
||||
user_code = entry.user_code
|
||||
time_limit = test.time_limit
|
||||
time_adjustment = 0
|
||||
if time_limit:
|
||||
_time_limit = int(time_limit)
|
||||
if user_code:
|
||||
time_adjustment = test.adjustments[user_code]
|
||||
_time_limit += time_adjustment
|
||||
end_delta = timedelta(minutes=_time_limit)
|
||||
end_time = datetime.now() + end_delta
|
||||
else:
|
||||
end_time = None
|
||||
entry.start()
|
||||
dataset = test.dataset
|
||||
success, message = dataset.check_file()
|
||||
if not success: return jsonify({'error': message}), 500
|
||||
data_path = dataset.get_file()
|
||||
with open(data_path, 'r') as data_file:
|
||||
data = loads(data_file.read())
|
||||
questions = generate_questions(data)
|
||||
return jsonify({
|
||||
'time_limit': end_time,
|
||||
'questions': questions,
|
||||
'start_time': entry.start_time,
|
||||
'time_adjustment': time_adjustment
|
||||
}), 200
|
||||
|
||||
@api.route('/submit/', methods=['POST'])
|
||||
def _submit_quiz():
|
||||
id = request.get_json()['id']
|
||||
answers = request.get_json()['answers']
|
||||
try: entry = Entry.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not entry: return jsonify({'error': 'Unrecognised Entry.'}), 400
|
||||
test = entry.test
|
||||
dataset = test.dataset
|
||||
success, message = dataset.check_file()
|
||||
if not success: return jsonify({'error': message}), 500
|
||||
data_path = dataset.get_file()
|
||||
with open(data_path, 'r') as data_file:
|
||||
data = loads(data_file.read())
|
||||
result = evaluate_answers(answers=answers, key=data)
|
||||
entry.complete(answers=answers, result=result)
|
||||
return jsonify({
|
||||
'success': 'Your submission has been processed. Redirecting you to receive your results.',
|
||||
'id': id
|
||||
}), 200
|
||||
|
||||
@api.route('/editor/', methods=['POST'])
|
||||
@login_required
|
||||
def _editor(id:str=None):
|
||||
request_data = request.get_json()
|
||||
id = request_data['id']
|
||||
try: dataset = Dataset.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not dataset: return jsonify({'error': 'Invalid request. Dataset not found.'}), 404
|
||||
data_path = dataset.get_file()
|
||||
if request_data['action'] == 'fetch':
|
||||
with open(data_path, 'r') as data_file:
|
||||
data = loads(data_file.read())
|
||||
return jsonify({'success': 'Successfully downloaded dataset', 'data': data}), 200
|
||||
default = request_data['default']
|
||||
creator = request_data['creator']
|
||||
try: user = User.query.filter_by(id=creator).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
name = request_data['name']
|
||||
data = request_data['data']
|
||||
if not validate_json(data): return jsonify({'error': 'The data you submitted was invalid.'}), 400
|
||||
dataset.set_name(name)
|
||||
dataset.creator = user
|
||||
success, message = dataset.update(data=data, default=default)
|
||||
if not success: return jsonify({'error': message}), 400
|
||||
return jsonify({'success': message}), 200
|
||||
|
||||
@api.route('/editor/new/', methods=['POST'])
|
||||
@login_required
|
||||
def _editor_new():
|
||||
new_dataset = Dataset()
|
||||
new_dataset.set_name('New Dataset')
|
||||
success, message = new_dataset.create(data=[], default=False)
|
||||
if not success: return jsonify({'error':message}), 400
|
||||
flash(message, 'success')
|
||||
return jsonify({'success': message, 'redirect_to': url_for('editor._editor_console', id=new_dataset.id)}), 200
|
@ -1,14 +0,0 @@
|
||||
from flask import Blueprint
|
||||
|
||||
api = Blueprint(
|
||||
name='api',
|
||||
import_name=__name__
|
||||
)
|
||||
|
||||
@api.route('/questions/', methods=['POST'])
|
||||
def _fetch_questions():
|
||||
return 'Fetch Questions'
|
||||
|
||||
@api.route('/submit/', methods=['POST'])
|
||||
def _submit_quiz():
|
||||
return 'Submit Quiz'
|
@ -1,42 +1,57 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv('../.env')
|
||||
|
||||
class Config(object):
|
||||
"""Basic App Configuration"""
|
||||
APP_HOST = '0.0.0.0'
|
||||
DATA_FILE_DIRECTORY = os.getenv('DATA_FILE_DIRECTORY')
|
||||
DATA = './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_FILE_DIRECTORY)}/database.db'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
"""Email Engine Configuration"""
|
||||
MAIL_SERVER = os.getenv('MAIL_SERVER')
|
||||
MAIL_PORT = int(os.getenv('MAIL_PORT'))
|
||||
MAIL_PORT = int(os.getenv('MAIL_PORT') or 25)
|
||||
MAIL_USE_TLS = False
|
||||
MAIL_USE_SSL = False
|
||||
MAIL_DEBUG = False
|
||||
MAIL_USERNAME = os.getenv('MAIL_USERNAME')
|
||||
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
|
||||
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER')
|
||||
MAIL_MAX_EMAILS = int(os.getenv('MAIL_MAX_EMAILS'))
|
||||
MAIL_MAX_EMAILS = int(os.getenv('MAIL_MAX_EMAILS') or 25)
|
||||
MAIL_SUPPRESS_SEND = False
|
||||
MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS'))
|
||||
MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS') or True)
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""Database Driver Configuration"""
|
||||
DATABASE_TYPE = os.getenv('DATABASE_TYPE') or 'SQLite'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
if DATABASE_TYPE.lower() == 'mysql' and os.getenv('MYSQL_DATABASE') and os.getenv('MYSQL_USER') and os.getenv('MYSQL_PASSWORD'):
|
||||
DATABASE_HOST = os.getenv('DATABASE_HOST') or 'localhost'
|
||||
DATABASE_PORT = int(os.getenv('DATABASE_PORT') or 3306)
|
||||
MYSQL_DATABASE = os.getenv('MYSQL_DATABASE')
|
||||
MYSQL_USER = os.getenv('MYSQL_USER')
|
||||
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD')
|
||||
SQLALCHEMY_DATABASE_URI = f'mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{MYSQL_DATABASE}'
|
||||
else: SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(os.path.abspath(f"{DATA}/db.sqlite"))}'
|
||||
|
||||
class Production(Config):
|
||||
pass
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
class Development(Config):
|
||||
APP_HOST = '127.0.0.1'
|
||||
DEBUG = True
|
||||
SERVER_NAME = '127.0.0.1:5000'
|
||||
SESSION_COOKIE_SECURE = False
|
||||
MAIL_SERVER = 'localhost'
|
||||
MAIL_DEBUG = True
|
||||
MAIL_SUPPRESS_SEND = False
|
||||
|
||||
class TestingConfig(DevelopmentConfig):
|
||||
class Testing(Development):
|
||||
TESTING = True
|
||||
SESSION_COOKIE_SECURE = False
|
||||
MAIL_SERVER = os.getenv('MAIL_SERVER')
|
||||
|
@ -1,5 +0,0 @@
|
||||
from . import Config
|
||||
from os import path
|
||||
from pathlib import Path
|
||||
|
||||
data = Path(Config.DATA_FILE_DIRECTORY)
|
103
ref-test/app/editor/static/css/editor.css
Normal file
103
ref-test/app/editor/static/css/editor.css
Normal file
@ -0,0 +1,103 @@
|
||||
.accordion-button {
|
||||
color: inherit;
|
||||
background-color: inherit;
|
||||
display: block;
|
||||
border: 1px solid rgb(0 0 0 / .3);
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
width: fit-content;
|
||||
display: block;
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
.editor-controls a {
|
||||
margin: 10px 10px;
|
||||
}
|
||||
|
||||
.editor-controls a i {
|
||||
font-size: larger;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.option-controls, .block-controls {
|
||||
width: fit-content;
|
||||
display: block;
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
.option-controls a, .block-controls a {
|
||||
margin: 0 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.option-controls a i, .block-controls a i {
|
||||
font-size: larger;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.accordion-button div {
|
||||
margin: 0;
|
||||
position: relative;
|
||||
top: 50%;
|
||||
transform: translate(0, -50%);
|
||||
}
|
||||
|
||||
.accordion-button::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.accordion-error {
|
||||
background-color: #bb2d3b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.accordion-error:not(.collapsed) {
|
||||
background-color: #bb2d3b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.panel-button {
|
||||
padding: 6px;
|
||||
margin: 0px 2px;
|
||||
}
|
||||
|
||||
.panel-button i {
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
.editor-panel, .info-panel {
|
||||
margin: 30pt auto;
|
||||
}
|
||||
|
||||
.info-panel, .viewer-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
width:fit-content;
|
||||
}
|
||||
|
||||
#alert-box {
|
||||
margin: 30px auto;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.block {
|
||||
border: 2px solid black;
|
||||
border-radius: 10px;
|
||||
margin: 10px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.question-body, .question-block {
|
||||
padding: 0px 2em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding: 0px 2em;
|
||||
font-style: italic;
|
||||
}
|
260
ref-test/app/editor/static/css/style.css
Normal file
260
ref-test/app/editor/static/css/style.css
Normal file
@ -0,0 +1,260 @@
|
||||
body {
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
background-color: lightgray;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.site-footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
padding-top: 40px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.form-display {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 15px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.form-heading {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-label-group {
|
||||
position: relative;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-label-group input,
|
||||
.form-label-group label {
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
font-size: 16pt;
|
||||
}
|
||||
|
||||
.form-label-group label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0; /* Override default `<label>` margin */
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
cursor: text; /* Match the input under the label */
|
||||
border: 1px solid transparent;
|
||||
border-radius: .25rem;
|
||||
transition: all .1s ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.form-label-group input {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 0%;
|
||||
border-bottom: 2px solid #585858;
|
||||
}
|
||||
|
||||
.form-label-group input:active, .form-label-group input:focus {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-webkit-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input:-ms-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-ms-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-moz-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input:not(:placeholder-shown) {
|
||||
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
|
||||
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||
}
|
||||
|
||||
.form-label-group input:not(:placeholder-shown) ~ label {
|
||||
padding-top: calc(var(--input-padding-y) / 3);
|
||||
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.form-check {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
transform: scale(1.5);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.signin-forgot-password {
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
.form-submission-button {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-submission-button button, .form-submission-button a {
|
||||
margin: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.form-submission-button button span, .form-submission-button button svg, .form-submission-button a span, .form-submission-button a svg {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
table.dataTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dataTables_wrapper .dt-buttons {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
float:none;
|
||||
text-align:center;
|
||||
}
|
||||
|
||||
.row-actions button, .row-actions a {
|
||||
margin: 0px 5px;
|
||||
}
|
||||
|
||||
#cookie-alert {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
#dismiss-cookie-alert {
|
||||
margin-top: 16px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.alert-db-empty {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
font-size: 14pt;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
.form-date-input, .form-select-input {
|
||||
position: relative;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.form-date-input input,
|
||||
.form-date-input label, .form-select-input select, .form-select-input label {
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
font-size: 16pt;
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid #585858;
|
||||
}
|
||||
|
||||
.datepicker::-webkit-calendar-picker-indicator {
|
||||
border: 1px;
|
||||
border-color: gray;
|
||||
border-radius: 10%;
|
||||
}
|
||||
|
||||
.form-date-input label, .form-select-input label {
|
||||
/* position: absolute; */
|
||||
/* top: 0;
|
||||
left: 0; */
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0; /* Override default `<label>` margin */
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
cursor: text; /* Match the input under the label */
|
||||
border: 1px solid transparent;
|
||||
border-radius: .25rem;
|
||||
transition: all .1s ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.form-upload {
|
||||
margin: 2rem 0;
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
.result-action-buttons, .test-action {
|
||||
margin: 5px auto;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.accordion-item {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
/* Change Autocomplete styles in Chrome*/
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
textarea:-webkit-autofill,
|
||||
textarea:-webkit-autofill:hover,
|
||||
textarea:-webkit-autofill:focus,
|
||||
select:-webkit-autofill,
|
||||
select:-webkit-autofill:hover,
|
||||
select:-webkit-autofill:focus {
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
/* Fallback for Edge
|
||||
-------------------------------------------------- */
|
||||
@supports (-ms-ime-align: auto) {
|
||||
.form-label-group label {
|
||||
display: none;
|
||||
}
|
||||
.form-label-group input::-ms-input-placeholder {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback for IE
|
||||
-------------------------------------------------- */
|
||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||
.form-label-group label {
|
||||
display: none;
|
||||
}
|
||||
.form-label-group input:-ms-input-placeholder {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
BIN
ref-test/app/editor/static/favicon.ico
Normal file
BIN
ref-test/app/editor/static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
BIN
ref-test/app/editor/static/favicon.png
Normal file
BIN
ref-test/app/editor/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 84 KiB |
642
ref-test/app/editor/static/js/editor.js
Normal file
642
ref-test/app/editor/static/js/editor.js
Normal file
@ -0,0 +1,642 @@
|
||||
// Variable Declarations
|
||||
const $root = $('#editor-root')
|
||||
const target = $root.data('target')
|
||||
const id = $root.data('id')
|
||||
|
||||
const $control_panel = $('.control-panel')
|
||||
const $info_panel = $('.info-panel')
|
||||
const $viewer_panel = $('.viewer-panel')
|
||||
const $editor_panel = $('.editor-panel')
|
||||
|
||||
var toggle_info = false
|
||||
var toggle_viewer = false
|
||||
|
||||
var element_index = 0
|
||||
|
||||
// Initialise Sortable and trigger renumbering on end of drag
|
||||
Sortable.create($root.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
|
||||
|
||||
// Info and Viewer Button Listener
|
||||
$control_panel.find('button').click(function(event){
|
||||
var action = $(this).data('action');
|
||||
|
||||
if (action == 'info') {
|
||||
if ($info_panel.is(":hidden")) {
|
||||
if ($viewer_panel.is(":visible")) {
|
||||
toggle_viewer = true
|
||||
$viewer_panel.hide()
|
||||
}
|
||||
$editor_panel.hide()
|
||||
$info_panel.fadeIn()
|
||||
$(window).scrollTop(0)
|
||||
toggle_info = false
|
||||
$(this).addClass('active')
|
||||
} else {
|
||||
$info_panel.hide()
|
||||
if (toggle_viewer) {
|
||||
render_viewer()
|
||||
$(window).scrollTop(0)
|
||||
toggle_viewer = false
|
||||
} else {
|
||||
$editor_panel.fadeIn()
|
||||
$(window).scrollTop(0)
|
||||
}
|
||||
$(this).removeClass('active')
|
||||
}
|
||||
} else if (action == 'view') {
|
||||
if ($viewer_panel.is(":hidden")) {
|
||||
if ($info_panel.is(':visible')) {
|
||||
toggle_info = true
|
||||
$info_panel.hide()
|
||||
}
|
||||
$editor_panel.hide()
|
||||
render_viewer()
|
||||
$(window).scrollTop(0)
|
||||
toggle_viewer = false
|
||||
$(this).addClass('active')
|
||||
} else {
|
||||
$viewer_panel.hide()
|
||||
if (toggle_info) {
|
||||
$info_panel.fadeIn()
|
||||
$(window).scrollTop(0)
|
||||
toggle_info = false
|
||||
} else {
|
||||
$editor_panel.fadeIn()
|
||||
$(window).scrollTop(0)
|
||||
}
|
||||
$(this).removeClass('active')
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
// Control Button Listeners
|
||||
$root.on('click', '.block-controls > a', function(event){
|
||||
event.preventDefault()
|
||||
var action = $(this).data('action')
|
||||
var root_accordion = $(this).closest('div').siblings('.accordion')
|
||||
if (action == 'add-question') {
|
||||
var question = generate_single_question(root_container_id=`#${root_accordion.attr('id')}`)
|
||||
$(question).appendTo(root_accordion).hide().fadeIn()
|
||||
if (root_accordion.children().length > 1 ) {
|
||||
root_accordion.find('.panel-controls > a[data-action="delete"]').removeClass('disabled')
|
||||
} else {
|
||||
root_accordion.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
|
||||
}
|
||||
renumber_blocks()
|
||||
}
|
||||
})
|
||||
|
||||
$root.on('click', '.panel-controls > a', function(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
var action = $(this).data('action')
|
||||
var element = $(this).closest('.accordion-item')
|
||||
var root_container = $(this).closest('.accordion')
|
||||
if (action == 'delete') {
|
||||
element.fadeOut(function(){
|
||||
$(this).remove()
|
||||
renumber_blocks()
|
||||
if (root_container.get(0) != $root.get(0) && root_container.children().length < 2 ) {
|
||||
root_container.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
|
||||
}
|
||||
})
|
||||
} else if (action == 'add-question') {
|
||||
var question = generate_single_question(root_container_id=`#${root_container.attr('id')}`)
|
||||
$(question).insertBefore(element).hide().fadeIn()
|
||||
if (root_container.get(0) != $root.get(0) && root_container.children().length > 1 ) {
|
||||
root_container.find('.panel-controls > a[data-action="delete"]').removeClass('disabled')
|
||||
}
|
||||
} else if (action == 'add-block') {
|
||||
var block = generate_block(root_container_id=`#${root_container.attr('id')}`)
|
||||
$(block).insertBefore(element).hide().fadeIn()
|
||||
var block_container = $(`#element${element_index-1}`).children().find('.accordion')
|
||||
Sortable.create(block_container.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
|
||||
var question = generate_single_question(root_container_id=`#${block_container.attr('id')}`)
|
||||
block_container.append(question)
|
||||
block_container.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
|
||||
}
|
||||
renumber_blocks()
|
||||
})
|
||||
|
||||
$root.on('click', '.option-controls > a', function(event) {
|
||||
event.preventDefault()
|
||||
var action = $(this).data('action')
|
||||
var options = $(this).closest('div.option-controls').siblings('.options')
|
||||
var length = options.children().length
|
||||
var correct = $(this).closest('div.option-controls').siblings().find('.question-correct')
|
||||
if (action == 'delete') {
|
||||
if (length > 2) {
|
||||
options.children().last().fadeOut(function(){
|
||||
$(this).remove()
|
||||
length = options.children().length
|
||||
if (length <= 2) {
|
||||
options.siblings('div.option-controls').children('a[data-action="delete"]').addClass('disabled')
|
||||
} else {
|
||||
options.siblings('div.option-controls').children('a[data-action="delete"]').removeClass('disabled')
|
||||
}
|
||||
})
|
||||
correct.children().last().fadeOut(function(){
|
||||
$(this).remove()
|
||||
})
|
||||
}
|
||||
} else {
|
||||
var opt = `
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">${length}</span>
|
||||
<input type="text" class="form-control" value="Option ${length}">
|
||||
</div>
|
||||
`
|
||||
$(opt).appendTo(options).hide().fadeIn()
|
||||
var cor = `<option value="${length}">${length}</option>`
|
||||
correct.append(cor)
|
||||
}
|
||||
length = options.children().length
|
||||
if (length <= 2) {
|
||||
$(this).closest('div.option-controls').children('a[data-action="delete"]').addClass('disabled')
|
||||
} else {
|
||||
$(this).closest('div.option-controls').children('a[data-action="delete"]').removeClass('disabled')
|
||||
}
|
||||
})
|
||||
|
||||
$('.editor-controls > a').click(function(event){
|
||||
event.preventDefault()
|
||||
var action = $(this).data('action')
|
||||
var root_accordion = $(this).closest('div').siblings('.accordion')
|
||||
if (action == 'add-question') {
|
||||
var obj = generate_single_question(root_container_id=`#${root_accordion.attr('id')}`)
|
||||
$(obj).appendTo($root).hide().fadeIn()
|
||||
} else if (action == 'add-block') {
|
||||
var obj = generate_block(root_container_id=`#${root_accordion.attr('id')}`)
|
||||
$(obj).appendTo($root).hide().fadeIn()
|
||||
var block_container = $(`#element${element_index-1}`).children().find('.accordion')
|
||||
Sortable.create(block_container.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
|
||||
var question = generate_single_question(root_container_id=`#${block_container.attr('id')}`)
|
||||
block_container.append(question)
|
||||
block_container.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
|
||||
} else if (action == 'discard') {
|
||||
window.location.href = '/admin/settings/questions/'
|
||||
} else if (action == 'delete') {
|
||||
$.ajax({
|
||||
url: '/admin/settings/questions/delete/',
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
'id': id,
|
||||
'action': action
|
||||
}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
window.location.href = '/admin/settings/questions/'
|
||||
},
|
||||
error: function(response) {
|
||||
error_response(response)
|
||||
}
|
||||
})
|
||||
} else if (action == 'save') {
|
||||
var input = parse_input()
|
||||
var def = $('.dataset-default').is(':checked')
|
||||
var name = $('.dataset-name').val()
|
||||
var creator = $('.dataset-creator').val()
|
||||
console.log([def, name, creator])
|
||||
$.ajax({
|
||||
url: target,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
'id': id,
|
||||
'action': 'upload',
|
||||
'data': input,
|
||||
'default': def,
|
||||
'name': name,
|
||||
'creator': creator
|
||||
}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
window.location.href = '/admin/settings/questions/'
|
||||
},
|
||||
error: function(response) {
|
||||
error_response(response)
|
||||
}
|
||||
})
|
||||
}
|
||||
renumber_blocks()
|
||||
})
|
||||
|
||||
// Question Type Select Menu Listener
|
||||
$root.on('change', '.form-select.question-type', function(event) {
|
||||
event.preventDefault()
|
||||
var type = $(this).val()
|
||||
var options = $(this).closest('div.input-group').siblings('.options')
|
||||
var option_controls = $(this).closest('div.input-group').siblings('.option-controls')
|
||||
var correct = $(this).closest('div.input-group').siblings().find('.question-correct')
|
||||
if (type == 'Yes/No') {
|
||||
options.empty()
|
||||
correct.empty()
|
||||
var opt = `
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">0</span>
|
||||
<input type="text" class="form-control" value="Yes" disabled>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">1</span>
|
||||
<input type="text" class="form-control" value="No" disabled>
|
||||
</div>
|
||||
`
|
||||
$(opt).appendTo(options).hide().fadeIn()
|
||||
option_controls.children('a').addClass('disabled')
|
||||
var cor = `
|
||||
<option value ="0" default>0</option>
|
||||
<option value="1">1</option>
|
||||
`
|
||||
correct.append(cor)
|
||||
} else {
|
||||
option_controls.children('a').removeClass('disabled')
|
||||
options.find('input').removeAttr('disabled')
|
||||
if (options.children().length <= 2 ){
|
||||
option_controls.children('a[data-action="delete"]').addClass('disabled')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Data and Rendering Functions
|
||||
function renumber_blocks () {
|
||||
$( ".block-number" ).each(function(index) {
|
||||
$( this ).text($( this ).closest('.accordion-item').index() + 1)
|
||||
})
|
||||
}
|
||||
|
||||
function parse_input() {
|
||||
var data = []
|
||||
var element = {}
|
||||
var question = {}
|
||||
var block_container
|
||||
var q_no = 0
|
||||
$root.children().each(function(index) {
|
||||
element = {}
|
||||
if ($(this).data('type') == 'block') {
|
||||
element['type'] = 'block'
|
||||
element['question_header'] = $(this).find('.block-header-text').val()
|
||||
element['questions'] = []
|
||||
block_container = $(this).children().find('.accordion')
|
||||
block_container.children().each(function(index) {
|
||||
question = {}
|
||||
question['q_no'] = q_no
|
||||
question['text'] = $(this).find('.question-text').val()
|
||||
question['q_type'] = $(this).find('.question-type').val()
|
||||
question['correct'] = parseInt($(this).find('.question-correct').val())
|
||||
question['options'] = []
|
||||
$(this).find('.options').find('input').each(function(index) {
|
||||
question['options'].push($(this).val())
|
||||
})
|
||||
question['tags'] = $(this).find('.question-tags').val().split('\r\n')
|
||||
element['questions'].push(question)
|
||||
q_no ++
|
||||
})
|
||||
} else if ( $(this).data('type') == 'question') {
|
||||
element['type'] = 'question'
|
||||
element['q_no'] = q_no
|
||||
element['text'] = $(this).find('.question-text').val()
|
||||
element['q_type'] = $(this).find('.question-type').val()
|
||||
element['correct'] = parseInt($(this).find('.question-correct').val())
|
||||
element['options'] = []
|
||||
$(this).find('.options').find('input').each(function(index) {
|
||||
element['options'].push($(this).val())
|
||||
})
|
||||
element['tags'] = $(this).find('.question-tags').val().split('\r\n')
|
||||
q_no ++
|
||||
}
|
||||
data.push(element)
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
function parse_data(data) {
|
||||
var block, obj, new_block, block_container, question, _question, new_question, options, correct, opt, tags
|
||||
for (let c = 0; c < data.length; c++) {
|
||||
block = data[c]
|
||||
if (block['type'] == 'block') {
|
||||
obj = generate_block(root_container_id=`#${$root.attr('id')}`)
|
||||
$root.append(obj)
|
||||
new_block = $(`#element${element_index-1}`)
|
||||
new_block.find('.block-header-text').val(block['question_header']).trigger('change')
|
||||
block_container = $(`#element${element_index-1}`).children().find('.accordion')
|
||||
Sortable.create(block_container.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
|
||||
for (let _c = 0; _c < block['questions'].length; _c ++) {
|
||||
question = block['questions'][_c]
|
||||
_question = generate_single_question(root_container_id=`#${block_container.attr('id')}`)
|
||||
block_container.append(_question)
|
||||
if (block_container.children().length <= 1) {
|
||||
block_container.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
|
||||
} else {
|
||||
block_container.find('.panel-controls > a[data-action="delete"]').removeClass('disabled')
|
||||
}
|
||||
new_question = $(`#element${element_index-1}`)
|
||||
new_question.find('.question-text').val(question['text']).trigger('change')
|
||||
new_question.find('.question-type').val(question['q_type']).trigger('change')
|
||||
correct = new_question.find('.question-correct')
|
||||
if (question['q_type'] != 'Yes/No') {
|
||||
options = new_question.find('.options')
|
||||
options.empty()
|
||||
correct.empty()
|
||||
for ( var __c = 0; __c < question['options'].length; __c++) {
|
||||
option = question['options'][__c]
|
||||
opt = `
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">${__c}</span>
|
||||
<input type="text" class="form-control" value="${option}">
|
||||
</div>
|
||||
`
|
||||
options.append(opt)
|
||||
correct.append(`<option value="${__c}">${__c}</option>`)
|
||||
}
|
||||
}
|
||||
correct.val(String(question['correct']))
|
||||
tags = question['tags'].join('\r\n')
|
||||
new_question.find('.question-tags').val(tags)
|
||||
}
|
||||
} else {
|
||||
question = block
|
||||
obj = generate_single_question(root_container_id=`#${$root.attr('id')}`)
|
||||
$root.append(obj)
|
||||
new_question = $(`#element${element_index-1}`)
|
||||
new_question.find('.question-text').val(question['text']).trigger('change')
|
||||
new_question.find('.question-type').val(question['q_type']).trigger('change')
|
||||
correct = new_question.find('.question-correct')
|
||||
if (question['q_type'] != 'Yes/No') {
|
||||
options = new_question.find('.options')
|
||||
options.empty()
|
||||
correct.empty()
|
||||
for ( var _c = 0; _c < question['options'].length; _c++) {
|
||||
option = question['options'][_c]
|
||||
opt = `
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">${_c}</span>
|
||||
<input type="text" class="form-control" value="${option}">
|
||||
</div>
|
||||
`
|
||||
options.append(opt)
|
||||
correct.append(`<option value="${_c}">${_c}</option>`)
|
||||
}
|
||||
}
|
||||
correct.val(String(question['correct']))
|
||||
tags = question['tags'].join('\r\n')
|
||||
new_question.find('.question-tags').val(tags)
|
||||
}
|
||||
}
|
||||
renumber_blocks()
|
||||
}
|
||||
|
||||
// Content Generator Functions
|
||||
function generate_single_question(root_container_id) {
|
||||
if (root_container_id == `#${$root.attr('id')}`) {
|
||||
var block_button = `
|
||||
<a href="javascript:void(0)" class="btn btn-primary" data-action="add-block" title="Add Block Above" aria-title="Add Block Above" data-bs-toggle="collapse" data-bs-target>
|
||||
<i class="bi bi-folder-plus"></i>
|
||||
</a>
|
||||
`
|
||||
} else {
|
||||
var block_button = ''
|
||||
}
|
||||
var question = `
|
||||
<div class="accordion-item" id="element${element_index}" data-type="question">
|
||||
<h2 class="accordion-header" id="element${element_index}-header">
|
||||
<div class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#element${element_index}-content" aria-expanded="true" aria-controls="element${element_index}-content">
|
||||
<div class="float-start">
|
||||
<div class="accordion-caption">
|
||||
<span class="block-number"></span>.
|
||||
Question
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-controls float-end">
|
||||
<a href="javascript:void(0)" class="btn btn-success move-handle" data-action="move-question" title="Move Question" aria-title="Move Question" data-bs-toggle="collapse" data-bs-target>
|
||||
<i class="bi bi-arrows-move"></i>
|
||||
</a>
|
||||
${block_button}
|
||||
<a href="javascript:void(0)" class="btn btn-primary" data-action="add-question" title="Add Question Above" aria-title="Add Question Above" data-bs-toggle="collapse" data-bs-target>
|
||||
<i class="bi bi-file-plus-fill"></i>
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="btn btn-danger" data-action="delete" title="Delete Block" aria-title="Delete Block" data-bs-toggle="collapse" data-bs-target>
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div id="element${element_index}-content" class="accordion-collapse collapse" aria-labelledby="element${element_index}-header" data-bs-parent="${root_container_id}">
|
||||
<div class="accordion-body">
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Question</span>
|
||||
<textarea type="text" class="form-control question-text">Enter question here.</textarea>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Question Type</span>
|
||||
<select class="form-select question-type">
|
||||
<option value ="Multiple Choice" default>Multiple Choice</option>
|
||||
<option value="Yes/No">Yes/No</option>
|
||||
<option value="List">Ordered List</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="form-label">Options</label>
|
||||
<ul class="options">
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">0</span>
|
||||
<input type="text" class="form-control" value="Option 0">
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">1</span>
|
||||
<input type="text" class="form-control" value="Option 1">
|
||||
</div>
|
||||
</ul>
|
||||
<div class="option-controls">
|
||||
<a href="javascript:void(0)" class="btn btn-danger disabled" data-action="delete" title="Delete Question" aria-title="Delete Question">
|
||||
<i class="bi bi-patch-minus-fill"></i>
|
||||
Delete
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="btn btn-success" data-action="add" title="Add Question" aria-title="Add Question">
|
||||
<i class="bi bi-patch-plus-fill"></i>
|
||||
Add
|
||||
</a>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Correct</span>
|
||||
<select class="form-select question-correct">
|
||||
<option value ="0" default>0</option>
|
||||
<option value="1">1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Tags</span>
|
||||
<textarea type="text" class="form-control question-tags"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
element_index ++
|
||||
return question
|
||||
}
|
||||
|
||||
function generate_block(root_container_id) {
|
||||
var block = `
|
||||
<div class="accordion-item" id="element${element_index}" data-type="block">
|
||||
<h2 class="accordion-header" id="element${element_index}-header">
|
||||
<div class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#element${element_index}-content" aria-expanded="true" aria-controls="element${element_index}-content">
|
||||
<div class="float-start">
|
||||
<div class="accordion-caption">
|
||||
<span class="block-number"></span>.
|
||||
Block
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-controls float-end">
|
||||
<a href="javascript:void(0)" class="btn btn-success move-handle" data-action="move-question" title="Move Question" aria-title="Move Question" data-bs-toggle="collapse" data-bs-target>
|
||||
<i class="bi bi-arrows-move"></i>
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="btn btn-primary" data-action="add-block" title="Add Block Above" aria-title="Add Block Above" data-bs-toggle="collapse" data-bs-target>
|
||||
<i class="bi bi-folder-plus"></i>
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="btn btn-primary" data-action="add-question" title="Add Question Above" aria-title="Add Question Above" data-bs-toggle="collapse" data-bs-target>
|
||||
<i class="bi bi-file-plus-fill"></i>
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="btn btn-danger" data-action="delete" title="Delete Block" aria-title="Delete Block" data-bs-toggle="collapse" data-bs-target>
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div id="element${element_index}-content" class="accordion-collapse collapse" aria-labelledby="element${element_index}-header" data-bs-parent="${root_container_id}">
|
||||
<div class="accordion-body">
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Block Header</span>
|
||||
<textarea type="text" class="form-control block-header-text">Enter the header text for this block of questions.</textarea>
|
||||
</div>
|
||||
<div class="accordion" id="element${element_index}-questions">
|
||||
</div>
|
||||
<div class="block-controls">
|
||||
<a href="javascript:void(0)" class="btn btn-success" data-action="add-question" title="Add Question" aria-title="Add Question">
|
||||
<i class="bi bi-file-plus-fill"></i>
|
||||
Add Question
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
element_index ++
|
||||
return block
|
||||
}
|
||||
|
||||
// Fetch data once page finishes loading
|
||||
$(window).on('load', function() {
|
||||
$.ajax({
|
||||
url: target,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
'id': id,
|
||||
'action': 'fetch'
|
||||
}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
parse_data(response['data'])
|
||||
},
|
||||
error: function(response) {
|
||||
console.log(response)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Viewer Render Function
|
||||
function render_viewer() {
|
||||
$viewer_panel.fadeIn()
|
||||
$viewer_panel.empty()
|
||||
var heading = document.createElement('h3')
|
||||
heading.innerText = 'View Questions'
|
||||
$viewer_panel.append(heading)
|
||||
var data = parse_input()
|
||||
var block
|
||||
var obj
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
block = data[i]
|
||||
obj = document.createElement('div')
|
||||
obj.classList = 'block'
|
||||
if (block['type'] == 'question') {
|
||||
text = document.createElement('p')
|
||||
text.innerHTML = `<strong>Question ${block['q_no'] + 1}.</strong> ${block['text']}`
|
||||
obj.append(text)
|
||||
question_body = document.createElement('div')
|
||||
question_body.className ='question-body'
|
||||
type = document.createElement('p')
|
||||
type.innerHTML = `<strong>Question Type:</strong> ${block['q_type']}`
|
||||
question_body.append(type)
|
||||
options = document.createElement('p')
|
||||
options.innerHTML = '<strong>Options:</strong>'
|
||||
option_list = document.createElement('ul')
|
||||
for (let _i = 0; _i < block['options'].length; _i++) {
|
||||
option = document.createElement('li')
|
||||
option.innerHTML = block['options'][_i]
|
||||
if (block['correct'] == _i) {
|
||||
option.innerHTML += ' <span class="badge rounded-pill bg-success">Correct</span>'
|
||||
}
|
||||
option_list.append(option)
|
||||
}
|
||||
options.append(option_list)
|
||||
question_body.append(options)
|
||||
tags = document.createElement('p')
|
||||
tags.innerHTML = `<strong>Tags:</strong>`
|
||||
tag_list = document.createElement('ul')
|
||||
for (let _i = 0; _i < block['tags'].length; _i++) {
|
||||
tag = document.createElement('li')
|
||||
tag.innerHTML = block['tags'][_i]
|
||||
tag_list.append(tag)
|
||||
}
|
||||
tags.append(tag_list)
|
||||
question_body.append(tags)
|
||||
obj.append(question_body)
|
||||
} else if (block['type'] == 'block') {
|
||||
meta = document.createElement('p')
|
||||
meta.innerHTML = `<strong>Block ${i+1}.</strong> ${block['questions'].length} questions.`
|
||||
obj.append(meta)
|
||||
header = document.createElement('blockquote')
|
||||
header.innerText = block['question_header']
|
||||
obj.append(header)
|
||||
var block_question = document.createElement('div')
|
||||
var question
|
||||
block_question.className = 'question-block'
|
||||
for (let _i = 0; _i < block['questions'].length; _i++) {
|
||||
question = block['questions'][_i]
|
||||
text = document.createElement('p')
|
||||
text.innerHTML = `<strong>Question ${question['q_no'] + 1}.</strong> ${question['text']}`
|
||||
block_question.append(text)
|
||||
question_body = document.createElement('div')
|
||||
question_body.className ='question-body'
|
||||
type = document.createElement('p')
|
||||
type.innerHTML = `<strong>Question Type:</strong> ${question['q_type']}`
|
||||
question_body.append(type)
|
||||
options = document.createElement('p')
|
||||
options.innerHTML = '<strong>Options:</strong>'
|
||||
option_list = document.createElement('ul')
|
||||
for (let __i = 0; __i < question['options'].length; __i++) {
|
||||
option = document.createElement('li')
|
||||
option.innerHTML = question['options'][__i]
|
||||
if (question['correct'] == __i) {
|
||||
option.innerHTML += ' <span class="badge rounded-pill bg-success">Correct</span>'
|
||||
}
|
||||
option_list.append(option)
|
||||
}
|
||||
options.append(option_list)
|
||||
question_body.append(options)
|
||||
tags = document.createElement('p')
|
||||
tags.innerHTML = `<strong>Tags:</strong>`
|
||||
tag_list = document.createElement('ul')
|
||||
for (let __i = 0; __i < question['tags'].length; __i++) {
|
||||
tag = document.createElement('li')
|
||||
tag.innerHTML = question['tags'][__i]
|
||||
tag_list.append(tag)
|
||||
}
|
||||
tags.append(tag_list)
|
||||
question_body.append(tags)
|
||||
block_question.append(question_body)
|
||||
obj.append(block_question)
|
||||
}
|
||||
}
|
||||
$viewer_panel.append(obj)
|
||||
}
|
||||
}
|
2
ref-test/app/editor/static/js/jquery-3.6.0.min.js
vendored
Normal file
2
ref-test/app/editor/static/js/jquery-3.6.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
115
ref-test/app/editor/static/js/script.js
Normal file
115
ref-test/app/editor/static/js/script.js
Normal file
@ -0,0 +1,115 @@
|
||||
// Menu Highlight Scripts
|
||||
const menuItems = document.getElementsByClassName('nav-link');
|
||||
for(let i = 0; i < menuItems.length; i++) {
|
||||
if(menuItems[i].pathname == window.location.pathname) {
|
||||
menuItems[i].classList.add('active');
|
||||
}
|
||||
}
|
||||
const dropdownItems = document.getElementsByClassName('dropdown-item');
|
||||
for(let i = 0; i< dropdownItems.length; i++) {
|
||||
if(dropdownItems[i].pathname == window.location.pathname) {
|
||||
dropdownItems[i].classList.add('active');
|
||||
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active');
|
||||
}
|
||||
}
|
||||
|
||||
// General Post Method Form Processing Script
|
||||
$('form.form-post').submit(function(event) {
|
||||
|
||||
var $form = $(this);
|
||||
var data = $form.serialize();
|
||||
var url = $(this).prop('action');
|
||||
var rel_success = $(this).data('rel-success');
|
||||
|
||||
$.ajax({
|
||||
url: url,
|
||||
type: 'POST',
|
||||
data: data,
|
||||
dataType: 'json',
|
||||
success: function(response) {
|
||||
if (response.redirect_to) {
|
||||
window.location.href = response.redirect_to;
|
||||
}
|
||||
else {
|
||||
window.location.href = rel_success;
|
||||
}
|
||||
},
|
||||
error: function(response) {
|
||||
error_response(response);
|
||||
}
|
||||
});
|
||||
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
function error_response(response) {
|
||||
|
||||
const $alert = $("#alert-box");
|
||||
$alert.html('');
|
||||
|
||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
||||
$alert.html(`
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||
${response.responseJSON.error}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`);
|
||||
} else if (response.responseJSON.error instanceof Array) {
|
||||
var output = ''
|
||||
for (let i = 0; i < response.responseJSON.error.length; i ++) {
|
||||
output += `
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||
${response.responseJSON.error[i]}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`;
|
||||
$alert.html(output);
|
||||
}
|
||||
}
|
||||
|
||||
$alert.focus()
|
||||
}
|
||||
|
||||
// Dismiss Cookie Alert
|
||||
$('#dismiss-cookie-alert').click(function(event){
|
||||
|
||||
$.ajax({
|
||||
url: '/cookies/',
|
||||
type: 'POST',
|
||||
data: {
|
||||
time: Date.now()
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response){
|
||||
console.log(response);
|
||||
},
|
||||
error: function(response){
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
|
||||
event.preventDefault();
|
||||
})
|
||||
|
||||
// Create New Dataset
|
||||
$('.create-new-dataset').click(function(event){
|
||||
$.ajax({
|
||||
url: '/api/editor/new/',
|
||||
type: 'POST',
|
||||
data: {
|
||||
time: Date.now()
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response){
|
||||
if (response.redirect_to) {
|
||||
window.location.href = response.redirect_to;
|
||||
}
|
||||
},
|
||||
error: function(response){
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
event.preventDefault()
|
||||
})
|
81
ref-test/app/editor/templates/editor/components/base.html
Normal file
81
ref-test/app/editor/templates/editor/components/base.html
Normal file
@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
|
||||
crossorigin="anonymous">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('.static', filename='css/style.css') }}"
|
||||
/>
|
||||
{% block style %}
|
||||
{% endblock %}
|
||||
<title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title>
|
||||
{% include "editor/components/og-meta.html" %}
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
||||
{% block navbar %}
|
||||
{% include "editor/components/navbar.html" %}
|
||||
{% endblock %}
|
||||
|
||||
<div class="container">
|
||||
{% block top_alerts %}
|
||||
{% include "editor/components/server-alerts.html" %}
|
||||
{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer class="container site-footer mt-5">
|
||||
{% block footer %}
|
||||
{% include "editor/components/footer.html" %}
|
||||
{% endblock %}
|
||||
</footer>
|
||||
|
||||
<!-- JQuery, Popper, and Bootstrap js dependencies -->
|
||||
<script
|
||||
src="https://code.jquery.com/jquery-3.6.0.min.js"
|
||||
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script>
|
||||
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
||||
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
|
||||
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<!-- Custom js -->
|
||||
<script type="text/javascript">
|
||||
var csrf_token = "{{ csrf_token() }}";
|
||||
|
||||
$.ajaxSetup({
|
||||
beforeSend: function(xhr, settings) {
|
||||
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
|
||||
xhr.setRequestHeader("X-CSRFToken", csrf_token);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="{{ url_for('.static', filename='js/script.js') }}"
|
||||
></script>
|
||||
{% block script %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1 @@
|
||||
<div id="alert-box" tabindex="-1"></div>
|
@ -0,0 +1,28 @@
|
||||
{% extends "editor/components/base.html" %}
|
||||
{% block datatable_css %}
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.11.3/css/dataTables.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/buttons/2.0.1/css/buttons.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/colreorder/1.5.5/css/colReorder.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/fixedheader/3.2.0/css/fixedHeader.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/keytable/2.6.4/css/keyTable.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/searchbuilder/1.3.0/css/searchBuilder.dataTables.min.css"/>
|
||||
{% endblock %}
|
||||
{% block datatable_scripts %}
|
||||
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jszip/2.5.0/jszip.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/pdfmake.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/vfs_fonts.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/jquery.dataTables.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/dataTables.bootstrap5.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/dataTables.buttons.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.bootstrap5.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.colVis.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.html5.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.print.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/colreorder/1.5.5/js/dataTables.colReorder.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/fixedheader/3.2.0/js/dataTables.fixedHeader.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/keytable/2.6.4/js/dataTables.keyTable.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/responsive.bootstrap5.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/searchbuilder/1.3.0/js/dataTables.searchBuilder.min.js"></script>
|
||||
{% endblock %}
|
@ -0,0 +1,2 @@
|
||||
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
@ -0,0 +1,4 @@
|
||||
{% extends "admin/components/base.html" %}
|
||||
{% import "bootstrap/wtf.html" as wtf %}
|
||||
{% block top_alerts %}
|
||||
{% endblock %}
|
117
ref-test/app/editor/templates/editor/components/navbar.html
Normal file
117
ref-test/app/editor/templates/editor/components/navbar.html
Normal file
@ -0,0 +1,117 @@
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbar"
|
||||
aria-controls="navbar"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle Navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navbar">
|
||||
<ul class="navbar-nav">
|
||||
{% if not current_user.is_authenticated %}
|
||||
<li class="nav-item" id="nav-login">
|
||||
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item" 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">Manage Questions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-account">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
role="button"
|
||||
href="{{ url_for('admin._update_user', id=current_user.id) }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Account
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-account"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
18
ref-test/app/editor/templates/editor/components/og-meta.html
Normal file
18
ref-test/app/editor/templates/editor/components/og-meta.html
Normal file
@ -0,0 +1,18 @@
|
||||
<meta name="description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta property="og:locale" content="en_UK" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta property="og:url" content="{{ url_for(request.endpoint, _external = True, **(request.view_args or {})) }}" />
|
||||
<meta property="og:site_name" content="Scottish Korfball Association Referee Theory Exam" />
|
||||
<meta property="og:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
|
||||
<meta property="og:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||
<meta property="og:image:width" content="512" />
|
||||
<meta property="og:image:height" content="512" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta name="twitter:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
|
||||
<meta name="twitter:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||
<meta name="twitter:creator" content="@viveksantayana" />
|
||||
<meta name="twitter:site" content="@viveksantayana" />
|
||||
<meta name="theme-color" content="#343a40" />
|
||||
<link rel="shortcut icon" href="{{ url_for('.static', filename='favicon.ico') }}">
|
@ -0,0 +1,23 @@
|
||||
<div class="navbar navbar-expand-sm navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<div class="expand navbar-expand justify-content-center" id="navbar_secondary">
|
||||
<ul class="nav nav-pills">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='active') }}">Active</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='scheduled') }}">Scheduled</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='expired') }}">Expired</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='all') }}">All</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='create') }}">Create</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,43 @@
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% set cookie_flash_flag = namespace(value=False) %}
|
||||
{% for category, message in messages %}
|
||||
{% if category == "error" %}
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Error" aria-title="Error"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% elif category == "success" %}
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-check2-circle" title="Success" aria-title="Success"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% elif category == "warning" %}
|
||||
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-info-circle-fill" aria-title="Warning" title="Warning"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% elif category == "cookie_alert" %}
|
||||
{% if not cookie_flash_flag.value %}
|
||||
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
|
||||
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
|
||||
{{ message|safe }}
|
||||
<div class="d-flex justify-content-center w-100">
|
||||
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
|
||||
</div>
|
||||
</div>
|
||||
{% set cookie_flash_flag.value = True %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-info-circle-fill" title="Alert"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
150
ref-test/app/editor/templates/editor/console.html
Normal file
150
ref-test/app/editor/templates/editor/console.html
Normal file
@ -0,0 +1,150 @@
|
||||
{% extends "editor/components/base.html" %}
|
||||
|
||||
{% block style %}
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('.static', filename='css/editor.css') }}"
|
||||
/>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Editor</h1>
|
||||
<div class="container">
|
||||
<p class="lead">
|
||||
Use this console to edit the questions in this dataset. For more information on using the editor console, click on the the blue Information button. To preview the questions in the current dataset, click on the green View Questions button.
|
||||
</p>
|
||||
</div>
|
||||
<div class="container control-panel">
|
||||
<button class="btn btn-primary" aria-title="Information" title="Information" data-action="info"><i class="bi bi-info-circle-fill"></i></button>
|
||||
<button class="btn btn-success" aria-title="View Questions" title="View Questions" data-action="view"><i class="bi bi-book-fill"></i></button>
|
||||
</div>
|
||||
<div class="container info-panel">
|
||||
<h3>
|
||||
About the Editor Console
|
||||
</h3>
|
||||
<p>
|
||||
This console will allow you to edit the question data for the RefTest App.
|
||||
All of the questions will be visually displayed as blocks on the screen that you can minimise, expand, and rearrange.
|
||||
</p>
|
||||
<p>
|
||||
Blocks can be of two types: <strong>Blocks</strong> of multiple related questions, and <strong>Single Questions</strong> that are not part of a block.
|
||||
You can add, remove, or edit both Blockss and Questions through this editor.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Blocks</strong> are useful when you have a section of the test that contains multiple questions that are related to each other, for example if there is a scenario-based section where a series of questions are about the same situation.
|
||||
</p>
|
||||
<p>
|
||||
Blocks can contain any number of questions within them, but cannot contain nested blocks.
|
||||
</p>
|
||||
<p>
|
||||
When you set up a block, you can also add <strong>header text</strong> that will be displayed with each question.
|
||||
You can use this to provide common information for a scenario across a series of questions.
|
||||
</p>
|
||||
<p>
|
||||
Questions come in three types:
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Yes/No</strong> for when there is only a yes or no option,
|
||||
</li>
|
||||
<li>
|
||||
<strong>Multiple Choice</strong> for your regular multiple choice questions, and
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ordered List</strong> for multiple choice questions that will be displayed in the same order as listed here.
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p>
|
||||
Normally, multiple choice questions will have the order of the options randomised.
|
||||
</p>
|
||||
<p>
|
||||
Questions will be displayed to candidates in a randomised order.
|
||||
Blocks of questions will be kept together, but the order within the block will also be randomised.
|
||||
</p>
|
||||
<p><strong>Do not use language that will assume the flow of questions, such as saying ‘the previous question’, or ‘the next question’, etc. because of randomisation.</strong></p>
|
||||
<p>
|
||||
Each option will be referenced by an <strong>index number</strong>.
|
||||
Make sure to select which index number represents the <strong>correct option</strong>.
|
||||
</p>
|
||||
<p>
|
||||
You will also be able to define <strong>tags</strong> for each question.
|
||||
Separate multiple tags in <strong>new lines</strong>.
|
||||
Make sure to keep the spelling, capitalisation, and punctuation for tags consistent.
|
||||
</p>
|
||||
<p class="lead">
|
||||
Placeholder for Questions Remaining in a Block
|
||||
</p>
|
||||
<p>
|
||||
In order to show how many questions are remaining inside a block, e.g. to say ‘the next n questions are about a specific scenario’, use the placeholder <code><block_remaining_questions></code>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="container viewer-panel">
|
||||
</div>
|
||||
<div class="container editor-panel">
|
||||
<h3>
|
||||
Edit Questions
|
||||
</h3>
|
||||
<div class="container dataset-metadata">
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Dataset Name</span>
|
||||
<input type="text" class="form-control dataset-name" value="{{ dataset.get_name() }}">
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Author</span>
|
||||
<select class="form-select dataset-creator">
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {{default if dataset.user == user else None }}>{{ user.get_username() }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Last Updated</span>
|
||||
<span class="form-control">
|
||||
{{ dataset.date.strftime('%d %b %Y %H:%M') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">
|
||||
<input type="checkbox" aria-label="Default" class="dataset-default" {% if dataset.default %}checked{% endif %}>
|
||||
</span>
|
||||
<span class="form-control">
|
||||
Make Dataset the Default
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion" id="editor-root" data-target="{{ url_for('api._editor') }}" data-id="{{ dataset.id }}">
|
||||
</div>
|
||||
{% include "editor/components/client-alerts.html" %}
|
||||
<div class="editor-controls container">
|
||||
<a href="javascript:void(0);" class="btn btn-primary" data-action="add-block" title="Add Block" aria-title="Add Block">
|
||||
<i class="bi bi-folder-plus"></i>
|
||||
Add Block
|
||||
</a>
|
||||
<a href="javascript:void(0);" class="btn btn-primary" data-action="add-question" title="Add Question" aria-title="Add Question">
|
||||
<i class="bi bi-file-plus-fill"></i>
|
||||
Add Question
|
||||
</a>
|
||||
</div>
|
||||
<div class="editor-controls container">
|
||||
<a href="javascript:void(0);" class="btn btn-warning" data-action="discard" title="Discard Changes" aria-title="Discard Changes">
|
||||
<i class="bi bi-x-circle-fill"></i>
|
||||
Discard Changes
|
||||
</a>
|
||||
<a href="javascript:void(0);" class="btn btn-danger {% if datasets <=1 or dataset.default or dataset.tests|length > 0 %}disabled{% endif %}" data-action="delete" title="Delete" aria-title="Delete">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
Delete
|
||||
</a>
|
||||
<a href="javascript:void(0);" class="btn btn-success" data-action="save" title="Save" aria-title="Save">
|
||||
<i class="bi bi-cloud-arrow-up-fill"></i>
|
||||
Save Changes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="{{ url_for('.static', filename='js/editor.js') }}"
|
||||
></script>
|
||||
{% endblock %}
|
31
ref-test/app/editor/templates/editor/index.html
Normal file
31
ref-test/app/editor/templates/editor/index.html
Normal file
@ -0,0 +1,31 @@
|
||||
{% extends "editor/components/input-forms.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-editor" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for(request.endpoint, **request.view_args) }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form">Edit Questions</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form-select-input">
|
||||
{{ form.dataset(placeholder="Select Question Dataset") }}
|
||||
{{ form.dataset.label }}
|
||||
</div>
|
||||
{% include "admin/components/client-alerts.html" %}
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<button class="btn btn-md btn-success btn-block" type="submit">
|
||||
<i class="bi bi-pencil-fill button-icon"></i>
|
||||
Edit
|
||||
</button>
|
||||
<button title="New" class="btn btn-md btn-primary create-new-dataset">
|
||||
<i class="bi bi-cloud-plus-fill button-icon"></i>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
45
ref-test/app/editor/views.py
Normal file
45
ref-test/app/editor/views.py
Normal file
@ -0,0 +1,45 @@
|
||||
from ..forms.admin import EditDataset
|
||||
from ..models import Dataset, User
|
||||
from ..tools.data import check_dataset_exists
|
||||
from ..tools.forms import get_dataset_choices, send_errors_to_client
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask import Blueprint, jsonify, render_template
|
||||
from flask.helpers import abort, flash, redirect, request, url_for
|
||||
from flask_login import login_required
|
||||
|
||||
editor = Blueprint(
|
||||
name='editor',
|
||||
import_name=__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static'
|
||||
)
|
||||
|
||||
@editor.route('/', methods=['GET','POST'])
|
||||
@login_required
|
||||
def _editor():
|
||||
form = EditDataset()
|
||||
form.dataset.choices = get_dataset_choices()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
id = request.form.get('dataset')
|
||||
return jsonify({'success': 'Selected dataset', 'redirect_to': url_for('editor._editor_console', id=id)}),200
|
||||
return send_errors_to_client(form=form)
|
||||
form.process()
|
||||
return render_template('/editor/index.html', form=form)
|
||||
|
||||
@editor.route('/<string:id>/')
|
||||
@check_dataset_exists
|
||||
@login_required
|
||||
def _editor_console(id:str=None):
|
||||
try:
|
||||
dataset = Dataset.query.filter_by(id=id).first()
|
||||
datasets = Dataset.query.count()
|
||||
users = User.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not dataset:
|
||||
flash('Invalid dataset ID.', 'error')
|
||||
return redirect(url_for('admin._questions'))
|
||||
return render_template('/editor/console.html', dataset=dataset, datasets=datasets, users=users)
|
66
ref-test/app/forms/admin.py
Normal file
66
ref-test/app/forms/admin.py
Normal file
@ -0,0 +1,66 @@
|
||||
from ..tools.forms import value
|
||||
|
||||
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
|
||||
|
||||
class Login(FlaskForm):
|
||||
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
||||
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
|
||||
remember = BooleanField('Remember Log In', render_kw={'checked': True})
|
||||
|
||||
class Register(FlaskForm):
|
||||
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)])
|
||||
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
|
||||
password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')])
|
||||
|
||||
class ResetPassword(FlaskForm):
|
||||
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)])
|
||||
|
||||
class UpdatePassword(FlaskForm):
|
||||
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
|
||||
password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')])
|
||||
|
||||
class CreateUser(FlaskForm):
|
||||
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)])
|
||||
password = PasswordField('Password (Optional)', validators=[Optional(),Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
|
||||
notify = BooleanField('Notify accout creation by email', render_kw={'checked': True})
|
||||
|
||||
class DeleteUser(FlaskForm):
|
||||
password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
|
||||
notify = BooleanField('Notify deletion by email', render_kw={'checked': True})
|
||||
|
||||
class UpdateUser(FlaskForm):
|
||||
confirm_password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=20, 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)])
|
||||
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=20, 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.')])
|
||||
notify = BooleanField('Notify changes by email', render_kw={'checked': True})
|
||||
|
||||
class UpdateAccount(FlaskForm):
|
||||
confirm_password = PasswordField('Current Password', validators=[InputRequired(), Length(min=6, max=20, 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)])
|
||||
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=20, 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.')])
|
||||
|
||||
class CreateTest(FlaskForm):
|
||||
start_date = DateTimeLocalField('Start Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()] )
|
||||
expiry_date = DateTimeLocalField('Expiry Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()] )
|
||||
time_limit = SelectField('Time Limit')
|
||||
dataset = SelectField('Question Dataset')
|
||||
|
||||
class UploadData(FlaskForm):
|
||||
name = StringField('Name', validators=[InputRequired()])
|
||||
data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])])
|
||||
default = BooleanField('Make Default', render_kw={'checked': True})
|
||||
|
||||
class AddTimeAdjustment(FlaskForm):
|
||||
time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)])
|
||||
|
||||
class EditDataset(FlaskForm):
|
||||
dataset = SelectField('Question Dataset')
|
11
ref-test/app/forms/quiz.py
Normal file
11
ref-test/app/forms/quiz.py
Normal file
@ -0,0 +1,11 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField
|
||||
from wtforms.validators import InputRequired, Length, Email, Optional
|
||||
|
||||
class StartQuiz(FlaskForm):
|
||||
first_name = StringField('First Name(s)', validators=[InputRequired(), Length(max=30)])
|
||||
surname = StringField('Surname', validators=[InputRequired(), Length(max=30)])
|
||||
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||
club = StringField('Affiliated Club (Optional)', validators=[Optional(), Length(max=50)])
|
||||
test_code = StringField('Exam Code', validators=[InputRequired(), Length(min=14, max=14)])
|
||||
user_code = StringField('User Code (Optional)', validators=[Optional(), Length(min=6, max=6)])
|
@ -1,142 +1,4 @@
|
||||
from ..modules import db
|
||||
from ..tools.encryption import decrypt, encrypt
|
||||
from ..tools.logs import write
|
||||
|
||||
import secrets
|
||||
|
||||
from flask import flash, jsonify, session
|
||||
from flask.helpers import url_for
|
||||
from flask_login import UserMixin, login_user, logout_user
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
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)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<user {self.username}> was added with <id {self.id}>.'
|
||||
|
||||
@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):
|
||||
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.'
|
||||
elif user.get_email() == self.get_email():
|
||||
return False, f'Email address {self.get_email()} already in use.'
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
write('users.log', f'User \'{self.get_username()}\' was created with id \'{self.id}\'.')
|
||||
return True, f'User {self.get_username()} was created successfully.'
|
||||
|
||||
def login(self, remember:bool=False):
|
||||
self.authenticated = True
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
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):
|
||||
self.authenticated = False
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
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()
|
||||
print('Password', new_password)
|
||||
print('Reset Token', self.reset_token)
|
||||
print('Verification Token', self.verification_token)
|
||||
print('Reset Link', f'{url_for("auth._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):
|
||||
username = self.get_username()
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
write('users.log', f'User \'{username}\' was deleted.') # TODO add current user
|
||||
|
||||
class Device(db.Model):
|
||||
id = db.Column(db.String(36), primary_key=True)
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
mac_address = db.Column(db.String(128), nullable=False)
|
||||
ip_address = db.Column(db.String(128), nullable=False)
|
||||
description = db.Column(db.String(250), nullable=True)
|
||||
|
||||
@property
|
||||
def set_name(self): raise AttributeError('set_name is not a readable attribute.')
|
||||
|
||||
set_name.setter
|
||||
def set_name(self, name:str): self.name = encrypt(name)
|
||||
|
||||
def get_name(self): return decrypt(self.name)
|
||||
|
||||
@property
|
||||
def set_mac_address(self): raise AttributeError('set_mac_address is not a readable attribute.')
|
||||
|
||||
set_mac_address.setter
|
||||
def set_mac_address(self, mac_address:str): self.mac_address = encrypt(mac_address)
|
||||
|
||||
def get_mac_address(self): return decrypt(self.mac_address)
|
||||
|
||||
@property
|
||||
def set_ip_address(self): raise AttributeError('set_ip_address is not a readable attribute.')
|
||||
|
||||
set_ip_address.setter
|
||||
def set_ip_address(self, ip_address:str): self.ip_address = encrypt(ip_address)
|
||||
|
||||
def get_ip_address(self): return decrypt(self.ip_address)
|
||||
|
||||
def add(self):
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
write('commands.log', f'Device \'{self.get_name()}\' was added at the IP address \'{self.get_ip_address()}\' and the MAC address \'{self.get_mac_address()}\'.')
|
||||
return True, f'Device {self.get_name()} was added.'
|
||||
|
||||
def delete(self):
|
||||
name = self.get_name()
|
||||
ip_address = self.get_ip_address()
|
||||
mac_address = self.get_mac_address()
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
write('commands.log', f'Device \'{name}\' with the IP address {ip_address} and MAC address {mac_address} was deleted.')
|
||||
return True, f'Device {name} was deleted.'
|
||||
from .entry import Entry
|
||||
from .test import Test
|
||||
from .user import User
|
||||
from .dataset import Dataset
|
133
ref-test/app/models/dataset.py
Normal file
133
ref-test/app/models/dataset.py
Normal file
@ -0,0 +1,133 @@
|
||||
from ..extensions import db
|
||||
from ..tools.encryption import decrypt, encrypt
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask import current_app as app
|
||||
from flask.helpers import flash
|
||||
from flask_login import current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from datetime import datetime
|
||||
from json import dump
|
||||
from os import path, remove
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
class Dataset(db.Model):
|
||||
id = db.Column(db.String(36), index=True, primary_key=True)
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
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)
|
||||
accessed = db.Column(db.DateTime, nullable=True)
|
||||
locked = db.Column(db.Boolean, default=False, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Dataset {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_name(self): raise AttributeError('set_name is not a readable attribute.')
|
||||
|
||||
set_name.setter
|
||||
def set_name(self, name:str): self.name = encrypt(name)
|
||||
|
||||
def get_name(self): return decrypt(self.name)
|
||||
|
||||
def make_default(self):
|
||||
try:
|
||||
for dataset in Dataset.query.all(): dataset.default = False
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
|
||||
return False, f'Database error {exception}.'
|
||||
self.default = True
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
|
||||
return False, f'Database error {exception}.'
|
||||
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
|
||||
try:
|
||||
if Dataset.query.count() == 1:
|
||||
message = 'Cannot delete the only dataset.'
|
||||
flash(message, 'error')
|
||||
return False, message
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
|
||||
return False, f'Database error {exception}.'
|
||||
write('system.log', f'Dataset {self.id} deleted by {current_user.get_username()}.')
|
||||
filename = secure_filename('.'.join([self.id,'json']))
|
||||
data = Path(app.config.get('DATA'))
|
||||
file_path = path.join(data, 'questions', filename)
|
||||
try:
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when trying to delete dataset {self.id}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
remove(file_path)
|
||||
return True, 'Dataset deleted.'
|
||||
|
||||
def create(self, data:list, default:bool=False):
|
||||
self.generate_id()
|
||||
timestamp = datetime.now()
|
||||
file_path = self.get_file()
|
||||
with open(file_path, 'w') as file:
|
||||
dump(data, file, indent=2)
|
||||
self.date = timestamp
|
||||
self.creator = current_user
|
||||
if default: self.make_default()
|
||||
write('system.log', f'New dataset {self.get_name()} added by {current_user.get_username()}.')
|
||||
try:
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when trying to crreate dataset {self.id}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
return True, 'Dataset created.'
|
||||
|
||||
def check_file(self):
|
||||
filename = secure_filename('.'.join([self.id,'json']))
|
||||
data = Path(app.config.get('DATA'))
|
||||
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']))
|
||||
data = Path(app.config.get('DATA'))
|
||||
file_path = path.join(data, 'questions', filename)
|
||||
return file_path
|
||||
|
||||
def update(self, data:list=None, default:bool=False):
|
||||
self.date = datetime.now()
|
||||
if default: self.make_default()
|
||||
file_path = self.get_file()
|
||||
with open(file_path, 'w') as file:
|
||||
dump(data, file, indent=2)
|
||||
write('system.log', f'Dataset {self.id} edited by {current_user.get_username()}.')
|
||||
flash(f'Dataset {self.get_name()} successfully edited.', 'success')
|
||||
try:
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when trying to update dataset {self.id}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
return True, 'Dataset successfully edited.'
|
199
ref-test/app/models/entry.py
Normal file
199
ref-test/app/models/entry.py
Normal file
@ -0,0 +1,199 @@
|
||||
from ..extensions import db, mail
|
||||
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 sqlalchemy_json import MutableJson
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
class Entry(db.Model):
|
||||
id = db.Column(db.String(36), index=True, 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, index=True, nullable=True)
|
||||
end_time = db.Column(db.DateTime, index=True, nullable=True)
|
||||
status = db.Column(db.String(16), nullable=True)
|
||||
valid = db.Column(db.Boolean, default=True, nullable=True)
|
||||
answers = db.Column(MutableJson, nullable=True)
|
||||
result = db.Column(MutableJson, 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()
|
||||
try:
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when preparing new entry for {self.get_surname()}, {self.get_first_name()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
write('tests.log', f'New test ready for {self.get_surname()}, {self.get_first_name()} with id {self.id}.')
|
||||
return True, f'Test ready.'
|
||||
|
||||
def start(self):
|
||||
self.start_time = datetime.now()
|
||||
self.status = 'started'
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when starting test for {self.get_surname()}, {self.get_first_name()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
write('tests.log', f'Test started by {self.get_surname()}, {self.get_first_name()} with id {self.id}.')
|
||||
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
|
||||
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
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when submitting entry for {self.get_surname()}, {self.get_first_name()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
write('tests.log', f'Test completed by {self.get_surname()}, {self.get_first_name()} with id {self.id}.')
|
||||
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'
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when validating entry {self.id}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
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()}'
|
||||
try:
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when deleting entry {id}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
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>
|
||||
"""
|
||||
)
|
||||
try: mail.send(email)
|
||||
except Exception as exception: write('system.log', f'SMTP Error when trying to notify results to {self.get_surname()}, {self.get_first_name()} with error: {exception}')
|
137
ref-test/app/models/test.py
Normal file
137
ref-test/app/models/test.py
Normal file
@ -0,0 +1,137 @@
|
||||
from ..extensions import db
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask_login import current_user
|
||||
from sqlalchemy_json import MutableJson
|
||||
|
||||
from datetime import date, datetime
|
||||
import secrets
|
||||
from uuid import uuid4
|
||||
|
||||
class Test(db.Model):
|
||||
id = db.Column(db.String(36), index=True, primary_key=True)
|
||||
code = db.Column(db.String(36), index=True, nullable=False)
|
||||
start_date = db.Column(db.DateTime, index=True, 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(MutableJson, 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
|
||||
try:
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when creating test {self.get_code()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
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):
|
||||
if self.entries: return False, f'Cannot delete a test with submitted entries.'
|
||||
db.session.delete(self)
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when deleting test {self.get_code()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
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
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when launching test {self.get_code()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
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
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when closing test {self.get_code()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
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
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when adding adjustment to test {self.get_code()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
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
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when deleting adjustment from test {self.get_code()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
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
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when updating test {self.get_code()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
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.'
|
259
ref-test/app/models/user.py
Normal file
259
ref-test/app/models/user.py
Normal file
@ -0,0 +1,259 @@
|
||||
from ..extensions import db, mail
|
||||
from ..tools.encryption import decrypt, encrypt
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask import jsonify, session
|
||||
from flask.helpers import flash, 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), index=True, 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), index=True, 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()
|
||||
try: users = User.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
|
||||
return False, f'Database error {exception}.'
|
||||
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)
|
||||
try:
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when registering user {self.get_username()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
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>
|
||||
"""
|
||||
)
|
||||
try: mail.send(email)
|
||||
except Exception as exception: write('system.log', f'SMTP Error while trying to notify new user account creation to {self.get_username()} with error: {exception}')
|
||||
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)
|
||||
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>
|
||||
"""
|
||||
)
|
||||
try: mail.send(email)
|
||||
except Exception as exception:
|
||||
write('system.log', f'SMTP Error while trying to reset password for {self.get_username()} with error: {exception}')
|
||||
db.session.rollback()
|
||||
return jsonify({'error': f'SMTP Error: {exception}'}), 500
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when resetting password for user {self.get_username()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
return jsonify({'success': 'Your password reset link has been generated.'}), 200
|
||||
|
||||
def clear_reset_tokens(self):
|
||||
self.reset_token = self.verification_token = None
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when resetting clearing reset tokens for user {self.get_username()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
|
||||
def delete(self, notify:bool=False):
|
||||
username = self.get_username()
|
||||
email_address = self.get_email()
|
||||
try:
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when deleting user {self.get_username()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
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>
|
||||
"""
|
||||
)
|
||||
try: mail.send(email)
|
||||
except Exception as exception: write('system.log', f'SMTP Error when trying to delete account {username} with error: {exception}')
|
||||
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:
|
||||
try:
|
||||
for entry in User.query.all():
|
||||
if entry.get_email() == email and not entry == self: return False, f'The email address {email} is already in use.'
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
|
||||
return False, f'Database error {exception}.'
|
||||
self.set_email(email)
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when updating user {self.get_username()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
_current_user = 'command line' if not current_user else 'anonymous' if not current_user.is_authenticated else current_user.get_username()
|
||||
write('system.log', f'Information for user {self.get_username()} has been updated by {_current_user}.')
|
||||
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}.\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}.</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>
|
||||
"""
|
||||
)
|
||||
try: mail.send(message)
|
||||
except Exception as exception: write('system.log', f'SMTP Error when trying to update account {self.get_username()} with error: {exception}')
|
||||
return True, f'Account {self.get_username()} has been updated.'
|
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;
|
||||
}
|
||||
}
|
218
ref-test/app/quiz/static/css/style.css
Normal file
218
ref-test/app/quiz/static/css/style.css
Normal file
@ -0,0 +1,218 @@
|
||||
body {
|
||||
padding: 80px 0;
|
||||
line-height: 1.5;
|
||||
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 {
|
||||
background-color: lightgray;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.site-footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.quiz-container {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
padding-top: 40px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.form-quiz-start {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 15px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.form-heading {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-label-group {
|
||||
position: relative;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-label-group input,
|
||||
.form-label-group label {
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
font-size: 16pt;
|
||||
}
|
||||
|
||||
.form-label-group label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0; /* Override default `<label>` margin */
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
cursor: text; /* Match the input under the label */
|
||||
border: 1px solid transparent;
|
||||
border-radius: .25rem;
|
||||
transition: all .1s ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.form-label-group input {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 0%;
|
||||
border-bottom: 2px solid #585858;
|
||||
}
|
||||
|
||||
.form-label-group input:active, .form-label-group input:focus {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-webkit-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input:-ms-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-ms-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-moz-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input:not(:placeholder-shown) {
|
||||
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
|
||||
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||
}
|
||||
|
||||
.form-label-group input:not(:placeholder-shown) ~ label {
|
||||
padding-top: calc(var(--input-padding-y) / 3);
|
||||
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.form-check-margin {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
transform: scale(1.5);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.signin-forgot-password {
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
.form-submission-button {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-submission-button button, .form-submission-button a {
|
||||
margin: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.form-submission-button button span, .form-submission-button button svg, .form-submission-button a span, .form-submission-button a svg {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.results-name {
|
||||
margin: 3rem auto;
|
||||
}
|
||||
|
||||
.results-name .surname {
|
||||
font-variant: small-caps;
|
||||
font-size: 24pt;
|
||||
}
|
||||
|
||||
.results-score {
|
||||
margin: 2rem auto;
|
||||
width: fit-content;
|
||||
font-size: 36pt;
|
||||
}
|
||||
|
||||
.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
|
||||
-------------------------------------------------- */
|
||||
@supports (-ms-ime-align: auto) {
|
||||
.form-label-group label {
|
||||
display: none;
|
||||
}
|
||||
.form-label-group input::-ms-input-placeholder {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback for IE
|
||||
-------------------------------------------------- */
|
||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||
.form-label-group label {
|
||||
display: none;
|
||||
}
|
||||
.form-label-group input:-ms-input-placeholder {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
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) {
|
||||
$("#btn-start-quiz").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 (let i = 0; i < response.responseJSON.error.length; i ++) {
|
||||
output += `
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||
${response.responseJSON.error[i]}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`
|
||||
$alert.html(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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, team ball sport. The sport originated in the Netherlands. 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 %}
|
78
ref-test/app/quiz/templates/quiz/components/base.html
Normal file
78
ref-test/app/quiz/templates/quiz/components/base.html
Normal file
@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
|
||||
crossorigin="anonymous">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('.static', filename='css/style.css') }}"
|
||||
/>
|
||||
{% block style %}
|
||||
{% endblock %}
|
||||
<title>{% block title %} SKA Referee Test {% endblock %}</title>
|
||||
{% include "quiz/components/og-meta.html" %}
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
||||
{% block navbar %}
|
||||
{% include "quiz/components/navbar.html" %}
|
||||
{% endblock %}
|
||||
|
||||
<div class="container quiz-container">
|
||||
{% block top_alerts %}
|
||||
{% include "quiz/components/server-alerts.html" %}
|
||||
{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<footer class="container site-footer">
|
||||
{% include "quiz/components/footer.html" %}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- JQuery, Popper, and Bootstrap js dependencies -->
|
||||
<script
|
||||
src="https://code.jquery.com/jquery-3.6.0.min.js"
|
||||
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script>
|
||||
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
|
||||
</script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
||||
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
|
||||
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<!-- Custom js -->
|
||||
<script type="text/javascript">
|
||||
var csrf_token = "{{ csrf_token() }}";
|
||||
|
||||
$.ajaxSetup({
|
||||
beforeSend: function(xhr, settings) {
|
||||
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
|
||||
xhr.setRequestHeader("X-CSRFToken", csrf_token);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="{{ url_for('.static', filename='js/script.js') }}"
|
||||
></script>
|
||||
{% block script %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user