Compare commits
803 Commits
8946e3eaf3
...
master
Author | SHA1 | Date | |
---|---|---|---|
502e694a17 | |||
d28cd6daed | |||
58782f6db7 | |||
57b25cd214 | |||
666e12253e | |||
8013a776a9 | |||
aa1f46ee62 | |||
dbd8d6bbe3 | |||
fed46eaa1e | |||
79ad96a93f | |||
ba851cb7dc | |||
fcc4d55947 | |||
a56358b8dd | |||
179a608089 | |||
a1289da09c | |||
ea86fd9ae6 | |||
76d60546e2 | |||
9a02048199 | |||
c9ad8e87cd | |||
3714919ba5 | |||
1026cc71a9 | |||
07fb170656 | |||
1ea93994ab | |||
607b132996 | |||
7aa4d81e65 | |||
0ef39dcfbe | |||
e1517b89c0 | |||
d0ed228824 | |||
a2c52a4261 | |||
b2c9bdd7d2 | |||
7536c33a48 | |||
850c2b13b7 | |||
eb69979f59 | |||
95cea46a8f | |||
02a1129390 | |||
438e09f1ec | |||
9241e1c0f7 | |||
8deefb9035 | |||
4f2984deea | |||
70d2325579 | |||
36d840c752 | |||
4400446718 | |||
adead30a77 | |||
487f24732d | |||
3c06cebddf | |||
d1d52fa4b6 | |||
80dc8b3cff | |||
a9ccd64de2 | |||
f5b9758bb1 | |||
84570d5974 | |||
edb8241ad3 | |||
644a539ed9 | |||
f05568b0de | |||
da4a3e41c6 | |||
77f86f7102 | |||
358695977f | |||
ddfd75c1f8 | |||
f4642767ac | |||
2f729de40b | |||
d68beb938f | |||
ca667f7896 | |||
0cc00ef911 | |||
5ec2a86d08 | |||
cd57eca7d3 | |||
a46338fdcb | |||
40f1cebb7b | |||
2a6478f3cf | |||
b6e250a7cd | |||
bcee2eedd0 | |||
d9837246de | |||
62fac48904 | |||
2bf0eeb33d | |||
72f2af1df8 | |||
168b2b288a | |||
9a5f69f889 | |||
7d6f256392 | |||
866c9b10cf | |||
b8fd65d856 | |||
5490bd083f | |||
3cb78055ff | |||
f9d85a8028 | |||
4f193e7fa5 | |||
df3149abba | |||
7ab87c2966 | |||
f4f501def5 | |||
1c57950558 | |||
f132cdbeef | |||
0387c05055 | |||
552b2ffc47 | |||
a2e859af5d | |||
81b09190de | |||
ed100ee9e5 | |||
5dc6c4998d | |||
0d68233d41 | |||
4caac25b14 | |||
3defe020f5 | |||
f14085f4c1 | |||
be5343a4bd | |||
2da8eb7712 | |||
3a0abaac6a | |||
b15f76701e | |||
02290e968c | |||
294f1e42f7 | |||
070ce19fcc | |||
615e59fc6d | |||
68314a4ed2 | |||
b90761fd2c | |||
af03193217 | |||
730a75c44d | |||
70883db5ad | |||
7cefb487da | |||
2e1b01ec9b | |||
a7a5a03991 | |||
b36c6bfd18 | |||
a613b0006b | |||
d4db8692e7 | |||
37ad36da31 | |||
d140f93d25 | |||
26a6248a61 | |||
9f8ea16974 | |||
bc5ec44145 | |||
ff5b19fa0b | |||
6c50be49c6 | |||
8bfe028e2c | |||
519394a656 | |||
9e1c9caec6 | |||
ea850c9ae2 | |||
591b868920 | |||
91dc93758a | |||
5d27baee08 | |||
1254cf3698 | |||
efab086057 | |||
06db47c597 | |||
c04c824585 | |||
8eb7fb6869 | |||
db88b84ecb | |||
13c587b7da | |||
2b2a6ddd25 | |||
26a6b45d75 | |||
c6c62fc34c | |||
6bbdb8fced | |||
c633a474b5 | |||
5af99d85b5 | |||
1e7124262e | |||
2f509af1de | |||
3c8c1b5c16 | |||
3988559920 | |||
8988fee55d | |||
86d1522ca1 | |||
ed53b771ef | |||
bc3b811fc9 | |||
f314566591 | |||
4b6dbd4441 | |||
1ef34465c2 | |||
8b0ea1fec3 | |||
39acebb3a6 | |||
d9962f18ed | |||
d8044a7c76 | |||
3025e83b66 | |||
a02a58a8db | |||
de6910b4bf | |||
7bb93afacb | |||
2663d5e3b7 | |||
500beed4cc | |||
d83999aa43 | |||
6a09559b70 | |||
26227a66c5 | |||
d6836915bb | |||
49a7fb1007 | |||
90bc30757a | |||
fac3839ea3 | |||
d8d5e92453 | |||
12207d1159 | |||
ac02f4dee1 | |||
a050a1eccf | |||
8d91dd1d30 | |||
76fa1e1dd9 | |||
6d5f74bd62 | |||
2e00d503c8 | |||
43cc0a5652 | |||
4ce6536e33 | |||
1f60054d46 | |||
33bc7993fa | |||
418dfe7a70 | |||
645f69440f | |||
e1e279e939 | |||
c197f6cb76 | |||
7fe1afb348 | |||
bed186f6b5 | |||
516c2cdf81 | |||
8f9b78ac32 | |||
17b985d238 | |||
69a0791a6d | |||
4414d1720e | |||
43895bead0 | |||
067ef4fd7f | |||
73f31016fd | |||
25115a6fae | |||
6028ac2d3c | |||
225ef71518 | |||
fbae88eed1 | |||
647d156802 | |||
08a140a73b | |||
a8a01e17da | |||
3f59d1b1b7 | |||
5123365567 | |||
d0166f0901 | |||
f6231dc779 | |||
5c8435d39e | |||
e4e07c43b4 | |||
d202e83189 | |||
e264b808fc | |||
4b08c830a1 | |||
b9d45f94fe | |||
2ea778143e | |||
62160beab2 | |||
1a7983052f | |||
a1bee61679 | |||
126bf9203c | |||
a58f267586 | |||
22878b5398 | |||
52b44128fa | |||
8439d99949 | |||
66e7b2b9f8 | |||
9459b93c9b | |||
09e444344d | |||
767dcede54 | |||
4431564304 | |||
da821bcadb | |||
b58a23cf13 | |||
dc126459bc | |||
2c5ed21011 | |||
59281db9cb | |||
2a3927a140 | |||
9a225543c6 | |||
dd8685b103 | |||
625ef8883b | |||
f903f9d060 | |||
eac9ee7ab1 | |||
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/
|
database/data/
|
||||||
|
|
||||||
# Ignore Encryption Keyfile
|
# 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
|
## 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
|
### Pre-Requisites
|
||||||
|
|
||||||
Server
|
- A Debian- or Ubuntu-based server, preferably the latest distribution.
|
||||||
Docker
|
- Docker (specifically, Docker Engine)
|
||||||
Docker-Compose
|
- Docker Compose
|
||||||
Git
|
- Git
|
||||||
|
|
||||||
### Installation
|
### 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
|
#### 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
|
### 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/)
|
- [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
|
## 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.)
|
- [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](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)
|
- [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
|
## YouTube Tutorials
|
||||||
|
|
||||||
### General Flask Introduction
|
### 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)
|
- [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.
|
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
|
### Flask techniques
|
||||||
|
|
||||||
@ -80,4 +66,4 @@ Uses SQL rather than MongoDB.
|
|||||||
|
|
||||||
### Flask handling file uploads
|
### 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,23 @@
|
|||||||
version: '3.9'
|
version: '3.9'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ref_test_server:
|
nginx:
|
||||||
container_name: ref_test_server
|
container_name: reftest_server
|
||||||
image: nginx:1.21.4-alpine
|
image: nginx:alpine
|
||||||
volumes:
|
volumes:
|
||||||
- ./certbot:/etc/letsencrypt:ro
|
- ./certbot:/etc/letsencrypt:ro
|
||||||
- ./nginx:/etc/nginx
|
- ./nginx:/etc/nginx
|
||||||
- ./src/html:/usr/share/nginx/html/
|
- ./src/html/certbot:/usr/share/nginx/html/certbot:ro
|
||||||
- ./ref-test/admin/static:/usr/share/nginx/html/admin/static
|
- ./src/html/robots.txt:/usr/share/nginx/html/robots.txt:ro
|
||||||
- ./ref-test/quiz/static:/usr/share/nginx/html/quiz/static
|
- ./ref-test/app/root:/usr/share/nginx/html/root:ro
|
||||||
|
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static:ro
|
||||||
|
- ./ref-test/app/editor/static:/usr/share/nginx/html/editor/static:ro
|
||||||
|
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static:ro
|
||||||
|
- ./ref-test/app/view/static:/usr/share/nginx/html/view/static:ro
|
||||||
|
- ./ref-test/app/analysis/static:/usr/share/nginx/html/analysis/static:ro
|
||||||
ports:
|
ports:
|
||||||
- 80:80
|
- 80:80
|
||||||
- 443:443
|
- 443:443
|
||||||
@ -17,10 +25,11 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- frontend
|
- frontend
|
||||||
depends_on:
|
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:
|
app:
|
||||||
container_name: ref_test_app
|
container_name: reftest_app
|
||||||
image: reftest
|
image: reftest
|
||||||
build: ./ref-test
|
build: ./ref-test
|
||||||
env_file:
|
env_file:
|
||||||
@ -28,32 +37,17 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 5000
|
- 5000
|
||||||
volumes:
|
volumes:
|
||||||
- ./.security:/ref-test/.security
|
- app:/ref-test/data
|
||||||
- ./ref-test/data:/ref-test/data
|
- ./logs:/ref-test/data/logs
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- frontend
|
- frontend
|
||||||
- backend
|
- backend
|
||||||
depends_on:
|
depends_on:
|
||||||
- ref_test_db
|
- postfix
|
||||||
- ref_test_postfix
|
|
||||||
|
|
||||||
ref_test_db:
|
postfix:
|
||||||
container_name: ref_test_db
|
container_name: reftest_postfix
|
||||||
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
|
|
||||||
image: catatnight/postfix:latest
|
image: catatnight/postfix:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
@ -63,15 +57,13 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
ref_test_certbot:
|
certbot:
|
||||||
container_name: ref_test_certbot
|
container_name: reftest_certbot
|
||||||
image: certbot/certbot:v1.21.0
|
image: certbot/certbot
|
||||||
volumes:
|
volumes:
|
||||||
- ./certbot:/etc/letsencrypt
|
- ./certbot:/etc/letsencrypt
|
||||||
- ./src/html:/var/www/html
|
- ./src/html/certbot:/var/www/html
|
||||||
depends_on:
|
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||||
- ref_test_server
|
|
||||||
# command: certonly --webroot --webroot-path=/var/www/html --email (email) --agree-tos --no-eff-email -d (domain)
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
frontend:
|
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
|
# Certbot Renewal
|
||||||
location ^~ /.well-known/acme-challenge/ {
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html/certbot;
|
||||||
allow all;
|
allow all;
|
||||||
default_type "text/plain";
|
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 {
|
upstream reftest {
|
||||||
server ref_test_app:5000;
|
server app:5000;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
server_name domain_name;
|
server_name domain_name;
|
||||||
listen 80;
|
listen 80 default_server;
|
||||||
listen [::]:80;
|
listen [::]:80 default_server;
|
||||||
# Redirect to ssl
|
# Redirect to ssl
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
server_name domain_name;
|
server_name domain_name;
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2 default_server;
|
||||||
listen [::]:443 ssl http2;
|
listen [::]:443 ssl http2 default_server;
|
||||||
|
|
||||||
#SSL configuration
|
# SSL configuration
|
||||||
include /etc/nginx/ssl.conf;
|
include /etc/nginx/ssl.conf;
|
||||||
include /etc/nginx/certbot-challenge.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;
|
include /etc/nginx/mime.types;
|
||||||
alias /usr/share/nginx/html/quiz/static/;
|
alias /usr/share/nginx/html/quiz/static/;
|
||||||
}
|
}
|
||||||
@ -29,8 +35,45 @@ server {
|
|||||||
alias /usr/share/nginx/html/admin/static/;
|
alias /usr/share/nginx/html/admin/static/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ^~ /admin/editor/static/ {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
alias /usr/share/nginx/html/editor/static/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /admin/view/static/ {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
alias /usr/share/nginx/html/view/static/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /admin/analysis/static/ {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
alias /usr/share/nginx/html/analysis/static/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy to the main app for all other requests
|
||||||
location / {
|
location / {
|
||||||
include /etc/nginx/conf.d/common-location.conf;
|
include /etc/nginx/conf.d/proxy_headers.conf;
|
||||||
proxy_pass http://reftest;
|
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 /etc/letsencrypt/live/domain_name/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem; # managed by Certbot
|
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/
|
env/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
data/
|
@ -1,5 +1,8 @@
|
|||||||
FROM python:3.10-slim
|
FROM python:3.10-slim
|
||||||
|
ARG DATA=./data/
|
||||||
|
ENV DATA=$DATA
|
||||||
WORKDIR /ref-test
|
WORKDIR /ref-test
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pip install --upgrade pip && pip install -r requirements.txt
|
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" ]
|
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 Production as Config
|
||||||
from .config import DevelopmentConfig 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 import flash, Flask, render_template, request
|
||||||
from flask_wtf.csrf import CSRFError
|
from flask.helpers import abort, url_for
|
||||||
from flask.json import jsonify
|
from flask.json import jsonify
|
||||||
|
from flask_wtf.csrf import CSRFError
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
from datetime import datetime
|
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():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(Config())
|
app.config.from_object(Config())
|
||||||
@ -26,22 +24,47 @@ def create_app():
|
|||||||
|
|
||||||
login_manager.login_view = 'admin._login'
|
login_manager.login_view = 'admin._login'
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def _load_user(user_id):
|
def _load_user(id):
|
||||||
pass
|
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)
|
@app.errorhandler(404)
|
||||||
def _404_handler(error):
|
def _404_handler(error): return render_template('404.html')
|
||||||
return jsonify({'error':'404 — Not Found'}), 404
|
|
||||||
@app.errorhandler(CSRFError)
|
@app.errorhandler(CSRFError)
|
||||||
def _csrf_handler():
|
def _csrf_handler(): return jsonify({'error':'Could not validate a secure connection.'}), 403
|
||||||
return jsonify({'error':'Could not validate a secure connection.'}), 403
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def _now():
|
def _now(): return {'now': datetime.now()}
|
||||||
return {'now': datetime.utcnow()}
|
|
||||||
|
from .admin.views import admin
|
||||||
|
from .api.views import api
|
||||||
|
from .quiz.views import quiz
|
||||||
|
from .views import views
|
||||||
|
from .editor.views import editor
|
||||||
|
from .view.views import view
|
||||||
|
from .analysis.views import analysis
|
||||||
|
|
||||||
app.register_blueprint(admin, url_prefix='/admin')
|
app.register_blueprint(admin, url_prefix='/admin')
|
||||||
app.register_blueprint(api, url_prefix='/api')
|
app.register_blueprint(api, url_prefix='/api')
|
||||||
app.register_blueprint(views)
|
app.register_blueprint(views)
|
||||||
app.register_blueprint(quiz)
|
app.register_blueprint(quiz)
|
||||||
|
app.register_blueprint(editor, url_prefix='/admin/editor')
|
||||||
|
app.register_blueprint(view, url_prefix='/admin/view')
|
||||||
|
app.register_blueprint(analysis, url_prefix='/admin/analysis')
|
||||||
|
|
||||||
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
277
ref-test/app/admin/static/js/script.js
Normal file
277
ref-test/app/admin/static/js/script.js
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
// 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}/`
|
||||||
|
} else if (action == 'analyse') {
|
||||||
|
$.ajax({
|
||||||
|
url: `/admin/analysis/`,
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'id': id, 'class': 'test'}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
success: function(response) {
|
||||||
|
window.location.href = response
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
error_response(response)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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}/`
|
||||||
|
} else if (action == 'analyse') {
|
||||||
|
$.ajax({
|
||||||
|
url: `/admin/analysis/`,
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'id': id, 'class': 'dataset'}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
success: function(response) {
|
||||||
|
window.location.href = response
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
error_response(response)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
||||||
|
|
||||||
|
function error_response(response) {
|
||||||
|
|
||||||
|
const $alert = $("#alert-box")
|
||||||
|
$alert.html('')
|
||||||
|
|
||||||
|
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') if entry.start_time else None }}
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<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') if entry.end_time else None }}
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Score</h5>
|
||||||
|
</div>
|
||||||
|
{{ entry.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 %}
|
137
ref-test/app/admin/templates/admin/components/navbar.html
Normal file
137
ref-test/app/admin/templates/admin/components/navbar.html
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||||
|
<div class="container">
|
||||||
|
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||||
|
<button
|
||||||
|
class="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navbar"
|
||||||
|
aria-controls="navbar"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle Navigation"
|
||||||
|
>
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse justify-content-end" id="navbar">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{% if not current_user.is_authenticated %}
|
||||||
|
<li class="nav-item" id="nav-login">
|
||||||
|
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<li class="nav-item dropdown" id="nav-results">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-results"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._view_entries') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Results
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-results"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="nav-tests">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-tests"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._tests') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Exams
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-tests"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="nav-settings">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-settings"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._settings') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-settings"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="nav-account">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-account"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._update_user', id=current_user.id) }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Account
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-account"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
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 %}
|
152
ref-test/app/admin/templates/admin/index.html
Normal file
152
ref-test/app/admin/templates/admin/index.html
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
{% 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') if test.end_date else None }}
|
||||||
|
</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') if result.end_time else None }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if result.result %}
|
||||||
|
{{ (100*result.result['score']/result.result['max'])|round|int }}% ({{ result.result.grade }})
|
||||||
|
{% else %}
|
||||||
|
Incomplete
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('admin._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') if test.end_date else None }}
|
||||||
|
</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') if entry.start_time else None }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Submission Time</h5>
|
||||||
|
{% 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|safe }}
|
||||||
|
</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('%Y-%m-%d %H:%M') }}
|
||||||
|
{% 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 %}
|
164
ref-test/app/admin/templates/admin/settings/questions.html
Normal file
164
ref-test/app/admin/templates/admin/settings/questions.html
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
{% 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('%Y-%m-%d %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-success edit-question-dataset {% if not element.entries %} disabled {% endif %}"
|
||||||
|
data-id="{{ element.id }}"
|
||||||
|
data-action="analyse"
|
||||||
|
title="Analyse Answers"
|
||||||
|
>
|
||||||
|
<i class="bi bi-search button-icon"></i>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="javascript:void(0)"
|
||||||
|
class="btn btn-primary edit-question-dataset"
|
||||||
|
data-id="{{ element.id }}"
|
||||||
|
data-action="download"
|
||||||
|
title="Download Questions"
|
||||||
|
>
|
||||||
|
<i class="bi bi-cloud-arrow-down-fill button-icon"></i>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="javascript:void(0)"
|
||||||
|
class="btn btn-primary edit-question-dataset"
|
||||||
|
data-id="{{ element.id }}"
|
||||||
|
data-action="view"
|
||||||
|
title="View Questions"
|
||||||
|
>
|
||||||
|
<i class="bi bi-file-earmark-text-fill button-icon"></i>
|
||||||
|
</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 %}
|
196
ref-test/app/admin/templates/admin/test.html
Normal file
196
ref-test/app/admin/templates/admin/test.html
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
{% 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') if test.start_date else None }}
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">Expiry Date</h5>
|
||||||
|
</div>
|
||||||
|
{{ test.end_date.strftime('%d %b %Y %H:%M') if test.end_date else None }}
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<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.id) }}" >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="my-3 row">
|
||||||
|
{% if test.start_date <= now %}
|
||||||
|
<a href="#" class="btn btn-warning test-action {% if test.end_date < now %}disabled{% endif %}" data-action="end" data-id="{{ test.id }}">
|
||||||
|
<i class="bi bi-hourglass-bottom button-icon"></i>
|
||||||
|
Close Exam
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="#" class="btn btn-success test-action {% if test.start_date < now %}disabled{% endif %}" data-action="start" data-id="{{ test.id }}">
|
||||||
|
<i class="bi bi-hourglass-top button-icon"></i>
|
||||||
|
Start Exam
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn btn-success test-analyse test-action {% if not test.entries %} disabled {% endif %}"
|
||||||
|
data-id="{{test.id}}"
|
||||||
|
title="Analyse Exam"
|
||||||
|
data-action="analyse"
|
||||||
|
>
|
||||||
|
<i class="bi bi-search button-icon"></i>
|
||||||
|
Analyse Exam
|
||||||
|
</a>
|
||||||
|
<a href="#" class="btn btn-danger test-action" data-action="delete" data-id="{{ test.id }}">
|
||||||
|
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
||||||
|
Delete Exam
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
176
ref-test/app/admin/templates/admin/tests.html
Normal file
176
ref-test/app/admin/templates/admin/tests.html
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
{% 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('%Y-%m-%d %H:%M') }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ test.get_code() }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ test.end_date.strftime('%Y-%m-%d %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-success test-analyse test-action {% if not test.entries %} disabled {% endif %}"
|
||||||
|
data-id="{{test.id}}"
|
||||||
|
title="Analyse Exam"
|
||||||
|
data-action="analyse"
|
||||||
|
>
|
||||||
|
<i class="bi bi-search button-icon"></i>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn btn-primary test-action"
|
||||||
|
data-id="{{test.id}}"
|
||||||
|
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 %}
|
452
ref-test/app/admin/views.py
Normal file
452
ref-test/app/admin/views.py
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
from ..forms.admin import AddTimeAdjustment, CreateTest, CreateUser, DeleteUser, Login, Register, ResetPassword, UpdatePassword, UpdateUser, UploadData
|
||||||
|
from ..models import Dataset, Entry, Test, User
|
||||||
|
from ..tools.auth import disable_if_logged_in, require_account_creation
|
||||||
|
from ..tools.data import check_dataset_exists, check_is_json, validate_json
|
||||||
|
from ..tools.forms import get_dataset_choices, get_time_options, send_errors_to_client
|
||||||
|
from ..tools.logs import write
|
||||||
|
from ..tools.test import answer_options, get_correct_answers
|
||||||
|
|
||||||
|
from flask import abort, Blueprint, jsonify, render_template, request, send_file, session
|
||||||
|
from flask.helpers import abort, flash, redirect, url_for
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
|
||||||
|
from datetime import date, datetime, MINYEAR, timedelta
|
||||||
|
from json import loads
|
||||||
|
from os import path
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
admin = Blueprint(
|
||||||
|
name='admin',
|
||||||
|
import_name=__name__,
|
||||||
|
template_folder='templates',
|
||||||
|
static_folder='static'
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.route('/')
|
||||||
|
@admin.route('/home/')
|
||||||
|
@admin.route('/dashboard/')
|
||||||
|
@login_required
|
||||||
|
def _home():
|
||||||
|
try:
|
||||||
|
tests = Test.query.all()
|
||||||
|
results = Entry.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
current_tests = [ test for test in tests if test.end_date >= datetime.now() and test.start_date.date() <= date.today() ]
|
||||||
|
current_tests.sort(key= lambda x: x.end_date or datetime(MINYEAR,1,1), reverse=True)
|
||||||
|
upcoming_tests = [ test for test in tests if test.start_date.date() > datetime.now().date()]
|
||||||
|
upcoming_tests.sort(key= lambda x: x.start_date or datetime(MINYEAR,1,1))
|
||||||
|
recent_results = [result for result in results if not result.status == 'started' ]
|
||||||
|
recent_results.sort(key= lambda x: x.end_time or datetime(MINYEAR,1,1), reverse=True)
|
||||||
|
return render_template('/admin/index.html', current_tests = current_tests, upcomimg_tests = upcoming_tests, recent_results = recent_results)
|
||||||
|
|
||||||
|
@admin.route('/settings/')
|
||||||
|
@login_required
|
||||||
|
def _settings():
|
||||||
|
try:
|
||||||
|
users = User.query.all()
|
||||||
|
datasets = Dataset.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
return render_template('/admin/settings/index.html', users=users, datasets=datasets)
|
||||||
|
|
||||||
|
@admin.route('/login/', methods=['GET','POST'])
|
||||||
|
@disable_if_logged_in
|
||||||
|
@require_account_creation
|
||||||
|
def _login():
|
||||||
|
form = Login()
|
||||||
|
if request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
try: users = User.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
user = None
|
||||||
|
for _user in users:
|
||||||
|
if _user.get_username() == request.form.get('username').lower():
|
||||||
|
user = _user
|
||||||
|
break
|
||||||
|
if user:
|
||||||
|
if user.verify_password(request.form.get('password')):
|
||||||
|
user.login(remember=request.form.get('remember'))
|
||||||
|
return jsonify({'success': f'Successfully logged in.'}), 200
|
||||||
|
return jsonify({'error': f'The password you entered is incorrect.'}), 401
|
||||||
|
return jsonify({'error': f'The username you entered does not exist.'}), 401
|
||||||
|
return send_errors_to_client(form=form)
|
||||||
|
if 'remembered_username' in session: form.username.data = session.pop('remembered_username')
|
||||||
|
next = request.args.get('next')
|
||||||
|
return render_template('/admin/auth/login.html', form=form, next=next)
|
||||||
|
|
||||||
|
@admin.route('/logout/')
|
||||||
|
@login_required
|
||||||
|
def _logout():
|
||||||
|
current_user.logout()
|
||||||
|
return redirect(url_for('admin._login'))
|
||||||
|
|
||||||
|
@admin.route('/register/', methods=['GET','POST'])
|
||||||
|
@disable_if_logged_in
|
||||||
|
def _register():
|
||||||
|
from ..models.user import User
|
||||||
|
form = Register()
|
||||||
|
if request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
new_user = User()
|
||||||
|
new_user.set_username(request.form.get('username').lower())
|
||||||
|
new_user.set_email(request.form.get('email').lower())
|
||||||
|
success, message = new_user.register(password=request.form.get('password'))
|
||||||
|
if success:
|
||||||
|
flash(message=f'{message} Please log in to continue.', category='success')
|
||||||
|
session['remembered_username'] = request.form.get('username').lower()
|
||||||
|
return jsonify({'success': message}), 200
|
||||||
|
flash(message=message, category='error')
|
||||||
|
return jsonify({'error': message}), 401
|
||||||
|
return send_errors_to_client(form=form)
|
||||||
|
return render_template('/admin/auth/register.html', form=form)
|
||||||
|
|
||||||
|
@admin.route('/reset/', methods=['GET','POST'])
|
||||||
|
def _reset():
|
||||||
|
form = ResetPassword()
|
||||||
|
if request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
user = None
|
||||||
|
try: users = User.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
for _user in users:
|
||||||
|
if _user.get_username() == request.form.get('username'):
|
||||||
|
user = _user
|
||||||
|
break
|
||||||
|
if not user: return jsonify({'error': 'The user account does not exist.'}), 400
|
||||||
|
if not user.get_email() == request.form.get('email'): return jsonify({'error': 'The email address does not match the user account.'}), 400
|
||||||
|
return user.reset_password()
|
||||||
|
return send_errors_to_client(form=form)
|
||||||
|
|
||||||
|
token = request.args.get('token')
|
||||||
|
if token:
|
||||||
|
try: user = User.query.filter_by(reset_token=token).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not user: return redirect(url_for('admin._reset'))
|
||||||
|
verification_token = user.verification_token
|
||||||
|
user.clear_reset_tokens()
|
||||||
|
if request.args.get('verification') == verification_token:
|
||||||
|
form = UpdatePassword()
|
||||||
|
session['user'] = user.id
|
||||||
|
return render_template('/admin/auth/update-password.html', form=form)
|
||||||
|
flash('The verification of your password reset request failed and the token has been invalidated. Please make a new reset password request.', 'error')
|
||||||
|
|
||||||
|
return render_template('/admin/auth/reset.html', form=form)
|
||||||
|
|
||||||
|
@admin.route('/update_password/', methods=['POST'])
|
||||||
|
def _update_password():
|
||||||
|
form = UpdatePassword()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
user = session.pop('user')
|
||||||
|
try: user = User.query.filter_by(id=user).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
user.update(password=request.form.get('password'))
|
||||||
|
session['remembered_username'] = user.get_username()
|
||||||
|
flash('Your password has been reset.', 'success')
|
||||||
|
return jsonify({'success':'Your password has been reset'}), 200
|
||||||
|
return send_errors_to_client(form=form)
|
||||||
|
|
||||||
|
@admin.route('/settings/users/', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def _users():
|
||||||
|
form = CreateUser()
|
||||||
|
try: users = User.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
password = request.form.get('password')
|
||||||
|
password = secrets.token_hex(12) if not password else password
|
||||||
|
new_user = User()
|
||||||
|
new_user.set_username(request.form.get('username').lower())
|
||||||
|
new_user.set_email(request.form.get('email'))
|
||||||
|
success, message = new_user.register(notify=request.form.get('notify'), password=password)
|
||||||
|
if success: return jsonify({'success': message}), 200
|
||||||
|
return jsonify({'error': message}), 401
|
||||||
|
return send_errors_to_client(form=form)
|
||||||
|
return render_template('/admin/settings/users.html', form = form, users = users)
|
||||||
|
|
||||||
|
@admin.route('/settings/users/delete/<string:id>', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def _delete_user(id:str):
|
||||||
|
try: user = User.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
form = DeleteUser()
|
||||||
|
if request.method == 'POST':
|
||||||
|
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
||||||
|
if id == current_user.id: return jsonify({'error': 'Cannot delete your own account.'}), 400
|
||||||
|
if form.validate_on_submit():
|
||||||
|
password = request.form.get('password')
|
||||||
|
if not current_user.verify_password(password): return jsonify({'error': 'The password you entered is incorrect.'}), 401
|
||||||
|
success, message = user.delete(notify=request.form.get('notify'))
|
||||||
|
if success: return jsonify({'success': message}), 200
|
||||||
|
return jsonify({'error': message}), 400
|
||||||
|
return send_errors_to_client(form=form)
|
||||||
|
|
||||||
|
if id == current_user.id:
|
||||||
|
flash('Cannot delete your own user account.', 'error')
|
||||||
|
return redirect(url_for('admin._users'))
|
||||||
|
if not user:
|
||||||
|
flash('User not found.', 'error')
|
||||||
|
return redirect(url_for('admin._users'))
|
||||||
|
return render_template('/admin/settings/delete_user.html', form=form, id = id, user = user)
|
||||||
|
|
||||||
|
@admin.route('/settings/users/update/<string:id>', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def _update_user(id:str):
|
||||||
|
try: user = User.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
form = UpdateUser()
|
||||||
|
if request.method == 'POST':
|
||||||
|
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
||||||
|
if form.validate_on_submit():
|
||||||
|
if not current_user.verify_password(request.form.get('confirm_password')): return jsonify({'error': 'Invalid password for your account.'}), 401
|
||||||
|
success, message = user.update(
|
||||||
|
password = request.form.get('password'),
|
||||||
|
email = request.form.get('email'),
|
||||||
|
notify = request.form.get('notify')
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
flash(message, 'success')
|
||||||
|
return jsonify({'success': message}), 200
|
||||||
|
return jsonify({'error': message}), 400
|
||||||
|
return send_errors_to_client(form=form)
|
||||||
|
if not user:
|
||||||
|
flash('User not found.', 'error')
|
||||||
|
return redirect(url_for('admin._users'))
|
||||||
|
return render_template('/admin/settings/update_user.html', form=form, id = id, user = user)
|
||||||
|
|
||||||
|
@admin.route('/settings/questions/', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def _questions():
|
||||||
|
form = UploadData()
|
||||||
|
if request.method == 'POST':
|
||||||
|
if form.validate_on_submit():
|
||||||
|
upload = form.data_file.data
|
||||||
|
if not check_is_json(upload): return jsonify({'error': 'Invalid file. Please upload a JSON file.'}), 400
|
||||||
|
upload.stream.seek(0)
|
||||||
|
data = loads(upload.read())
|
||||||
|
if not validate_json(data=data): return jsonify({'error': 'The data in the file is invalid.'}), 400
|
||||||
|
new_dataset = Dataset()
|
||||||
|
new_dataset.set_name(request.form.get('name'))
|
||||||
|
success, message = new_dataset.create(
|
||||||
|
data = data,
|
||||||
|
default = request.form.get('default')
|
||||||
|
)
|
||||||
|
if success: return jsonify({'success': message}), 200
|
||||||
|
return jsonify({'error': message}), 400
|
||||||
|
return send_errors_to_client(form=form)
|
||||||
|
try: data = Dataset.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
return render_template('/admin/settings/questions.html', form=form, data=data)
|
||||||
|
|
||||||
|
@admin.route('/settings/questions/delete/', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def _edit_questions():
|
||||||
|
id = request.get_json()['id']
|
||||||
|
action = request.get_json()['action']
|
||||||
|
if not action == 'delete': return jsonify({'error': 'Invalid action.'}), 400
|
||||||
|
try: dataset = Dataset.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if action == 'delete': success, message = dataset.delete()
|
||||||
|
if success: return jsonify({'success': message}), 200
|
||||||
|
return jsonify({'error': message}), 400
|
||||||
|
|
||||||
|
@admin.route('/settings/questions/download/<string:id>/')
|
||||||
|
@login_required
|
||||||
|
def _download(id:str):
|
||||||
|
try: dataset = Dataset.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not dataset: return abort(404)
|
||||||
|
data_path = path.abspath(dataset.get_file())
|
||||||
|
return send_file(data_path, as_attachment=True, download_name=f'{dataset.get_name()}.json')
|
||||||
|
|
||||||
|
@admin.route('/tests/<string:filter>/', methods=['GET'])
|
||||||
|
@admin.route('/tests/', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
@check_dataset_exists
|
||||||
|
def _tests(filter:str=None):
|
||||||
|
tests = None
|
||||||
|
try: _tests = Test.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
form = None
|
||||||
|
now = datetime.now()
|
||||||
|
if filter not in ['create','active','scheduled','expired','all']: return redirect(url_for('admin._tests', filter='active'))
|
||||||
|
if filter == 'create':
|
||||||
|
form = CreateTest()
|
||||||
|
form.start_date.default = datetime.now()
|
||||||
|
form.expiry_date.default = date.today() + timedelta(days=1)
|
||||||
|
form.time_limit.choices = get_time_options()
|
||||||
|
form.dataset.choices = get_dataset_choices()
|
||||||
|
form.time_limit.default='none'
|
||||||
|
form.process()
|
||||||
|
display_title = ''
|
||||||
|
error_none = ''
|
||||||
|
if filter in [None, '', 'active']:
|
||||||
|
tests = [ test for test in _tests if test.end_date >= now and test.start_date <= now ]
|
||||||
|
display_title = 'Active Exams'
|
||||||
|
error_none = 'There are no exams that are currently active. You can create one using the Create Exam form.'
|
||||||
|
if filter == 'expired':
|
||||||
|
tests = [ test for test in _tests if test.end_date < now ]
|
||||||
|
display_title = 'Expired Exams'
|
||||||
|
error_none = 'There are no expired exams. Exams will appear in this category after their expiration date has passed.'
|
||||||
|
if filter == 'scheduled':
|
||||||
|
tests = [ test for test in _tests if test.start_date > now]
|
||||||
|
display_title = 'Scheduled Exams'
|
||||||
|
error_none = 'There are no scheduled exams pending. You can schedule an exam for the future using the Create Exam form.'
|
||||||
|
if filter == 'all':
|
||||||
|
tests = _tests
|
||||||
|
display_title = 'All Exams'
|
||||||
|
error_none = 'There are no exams set up. You can create one using the Create Exam form.'
|
||||||
|
return render_template('/admin/tests.html', form = form, tests=tests, display_title=display_title, error_none=error_none, filter=filter)
|
||||||
|
|
||||||
|
@admin.route('/tests/create/', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def _create_test():
|
||||||
|
form = CreateTest()
|
||||||
|
form.dataset.choices = get_dataset_choices()
|
||||||
|
form.time_limit.choices = get_time_options()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
new_test = Test()
|
||||||
|
new_test.start_date = request.form.get('start_date')
|
||||||
|
new_test.start_date = datetime.strptime(new_test.start_date, '%Y-%m-%dT%H:%M')
|
||||||
|
new_test.end_date = request.form.get('expiry_date')
|
||||||
|
new_test.end_date = datetime.strptime(new_test.end_date, '%Y-%m-%dT%H:%M')
|
||||||
|
new_test.time_limit = None if request.form.get('time_limit') == 'none' else int(request.form.get('time_limit'))
|
||||||
|
dataset = request.form.get('dataset')
|
||||||
|
try: new_test.dataset = Dataset.query.filter_by(id=dataset).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
success, message = new_test.create()
|
||||||
|
if success:
|
||||||
|
flash(message=message, category='success')
|
||||||
|
return jsonify({'success': message}), 200
|
||||||
|
return jsonify({'error': message}), 400
|
||||||
|
return send_errors_to_client(form=form)
|
||||||
|
|
||||||
|
@admin.route('/tests/edit/', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def _edit_test():
|
||||||
|
id = request.get_json()['id']
|
||||||
|
action = request.get_json()['action']
|
||||||
|
if action not in ['start', 'delete', 'end']: return jsonify({'error': 'Invalid action.'}), 400
|
||||||
|
try: test = Test.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not test: return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
|
||||||
|
if action == 'delete': success, message = test.delete()
|
||||||
|
if action == 'start': success, message = test.start()
|
||||||
|
if action == 'end': success, message = test.end()
|
||||||
|
if success:
|
||||||
|
flash(message=message, category='success')
|
||||||
|
return jsonify({'success': message}), 200
|
||||||
|
return jsonify({'error': message}), 400
|
||||||
|
|
||||||
|
@admin.route('/test/<string:id>/', methods=['GET','POST'])
|
||||||
|
@login_required
|
||||||
|
def _view_test(id:str=None):
|
||||||
|
form = AddTimeAdjustment()
|
||||||
|
try: test = Test.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
|
||||||
|
if form.validate_on_submit():
|
||||||
|
time = int(request.form.get('time'))
|
||||||
|
success, message = test.add_adjustment(time)
|
||||||
|
if success: return jsonify({'success': message}), 200
|
||||||
|
return jsonify({'error': message}), 400
|
||||||
|
return jsonify({'error': form.time.errors }), 400
|
||||||
|
if not test:
|
||||||
|
flash('Invalid test ID.', 'error')
|
||||||
|
return redirect(url_for('admin._tests', filter='active'))
|
||||||
|
return render_template('/admin/test.html', test = test, form = form)
|
||||||
|
|
||||||
|
@admin.route('/test/<string:id>/delete-adjustment/', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def _delete_adjustment(id:str=None):
|
||||||
|
try: test = Test.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
|
||||||
|
user_code = request.get_json()['user_code'].lower()
|
||||||
|
success, message = test.remove_adjustment(user_code)
|
||||||
|
if success: return jsonify({'success': message}), 200
|
||||||
|
return jsonify({'error': message}), 400
|
||||||
|
|
||||||
|
@admin.route('/results/')
|
||||||
|
@login_required
|
||||||
|
def _view_entries():
|
||||||
|
try: entries = Entry.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
return render_template('/admin/results.html', entries = entries)
|
||||||
|
|
||||||
|
@admin.route('/results/<string:id>/', methods = ['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def _view_entry(id:str=None):
|
||||||
|
try: entry = Entry.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||||
|
action = request.get_json()['action']
|
||||||
|
if action not in ['validate', 'delete']: return jsonify({'error': 'Invalid action.'}), 400
|
||||||
|
if action == 'validate':
|
||||||
|
success, message = entry.validate()
|
||||||
|
if action == 'delete':
|
||||||
|
success, message = entry.delete()
|
||||||
|
if success:
|
||||||
|
flash(message, 'success')
|
||||||
|
entry.notify_result()
|
||||||
|
return jsonify({'success': message}), 200
|
||||||
|
return jsonify({'error': message}),400
|
||||||
|
if not entry:
|
||||||
|
flash('Invalid entry ID.', 'error')
|
||||||
|
return redirect(url_for('admin._view_entries'))
|
||||||
|
test = entry.test
|
||||||
|
data = test.dataset.get_data()
|
||||||
|
correct = get_correct_answers(dataset=data)
|
||||||
|
answers = answer_options(dataset=data)
|
||||||
|
return render_template('/admin/result-detail.html', entry = entry, correct = correct, answers = answers)
|
||||||
|
|
||||||
|
@admin.route('/certificate/',methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def _generate_certificate():
|
||||||
|
from ..extensions import db
|
||||||
|
id = request.get_json()['id']
|
||||||
|
try: entry = Entry.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||||
|
return render_template('/admin/components/certificate.html', entry = entry)
|
@ -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'
|
|
||||||
|
|
8
ref-test/app/analysis/static/css/analysis.css
Normal file
8
ref-test/app/analysis/static/css/analysis.css
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
#alert-box {
|
||||||
|
margin: 30px auto;
|
||||||
|
max-width: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-percentage::after {
|
||||||
|
content: '%';
|
||||||
|
}
|
260
ref-test/app/analysis/static/css/style.css
Normal file
260
ref-test/app/analysis/static/css/style.css
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
body {
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
background-color: lightgray;
|
||||||
|
font-size: small;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
display: -ms-flexbox;
|
||||||
|
display: flex;
|
||||||
|
-ms-flex-align: center;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 40px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-display {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-heading {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input,
|
||||||
|
.form-label-group label {
|
||||||
|
padding: var(--input-padding-y) var(--input-padding-x);
|
||||||
|
font-size: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group label {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0; /* Override default `<label>` margin */
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #495057;
|
||||||
|
cursor: text; /* Match the input under the label */
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: .25rem;
|
||||||
|
transition: all .1s ease-in-out;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input {
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0%;
|
||||||
|
border-bottom: 2px solid #585858;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input:active, .form-label-group input:focus {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input::-webkit-input-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input:-ms-input-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input::-ms-input-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input::-moz-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input::placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input:not(:placeholder-shown) {
|
||||||
|
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
|
||||||
|
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-group input:not(:placeholder-shown) ~ label {
|
||||||
|
padding-top: calc(var(--input-padding-y) / 3);
|
||||||
|
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input {
|
||||||
|
transform: scale(1.5);
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-forgot-password {
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submission-button {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submission-button button, .form-submission-button a {
|
||||||
|
margin: 1rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submission-button button span, .form-submission-button button svg, .form-submission-button a span, .form-submission-button a svg {
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTables_wrapper .dt-buttons {
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
float:none;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions button, .row-actions a {
|
||||||
|
margin: 0px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cookie-alert {
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dismiss-cookie-alert {
|
||||||
|
margin-top: 16px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-db-empty {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
font-size: 14pt;
|
||||||
|
margin: 20px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-date-input, .form-select-input {
|
||||||
|
position: relative;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-date-input input,
|
||||||
|
.form-date-input label, .form-select-input select, .form-select-input label {
|
||||||
|
padding: var(--input-padding-y) var(--input-padding-x);
|
||||||
|
font-size: 16pt;
|
||||||
|
width: 100%;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid #585858;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker::-webkit-calendar-picker-indicator {
|
||||||
|
border: 1px;
|
||||||
|
border-color: gray;
|
||||||
|
border-radius: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-date-input label, .form-select-input label {
|
||||||
|
/* position: absolute; */
|
||||||
|
/* top: 0;
|
||||||
|
left: 0; */
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0; /* Override default `<label>` margin */
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #495057;
|
||||||
|
cursor: text; /* Match the input under the label */
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: .25rem;
|
||||||
|
transition: all .1s ease-in-out;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-upload {
|
||||||
|
margin: 2rem 0;
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-action-buttons, .test-action {
|
||||||
|
margin: 5px auto;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item {
|
||||||
|
background-color: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Change Autocomplete styles in Chrome*/
|
||||||
|
input:-webkit-autofill,
|
||||||
|
input:-webkit-autofill:hover,
|
||||||
|
input:-webkit-autofill:focus,
|
||||||
|
textarea:-webkit-autofill,
|
||||||
|
textarea:-webkit-autofill:hover,
|
||||||
|
textarea:-webkit-autofill:focus,
|
||||||
|
select:-webkit-autofill,
|
||||||
|
select:-webkit-autofill:hover,
|
||||||
|
select:-webkit-autofill:focus {
|
||||||
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback for Edge
|
||||||
|
-------------------------------------------------- */
|
||||||
|
@supports (-ms-ime-align: auto) {
|
||||||
|
.form-label-group label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.form-label-group input::-ms-input-placeholder {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback for IE
|
||||||
|
-------------------------------------------------- */
|
||||||
|
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||||
|
.form-label-group label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.form-label-group input:-ms-input-placeholder {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
27
ref-test/app/analysis/static/js/analysis.js
Normal file
27
ref-test/app/analysis/static/js/analysis.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
// Analyse Button Listener
|
||||||
|
$('.button-analyse').click(function(event) {
|
||||||
|
|
||||||
|
let buttonClass = $(this).data('class')
|
||||||
|
let id = null
|
||||||
|
|
||||||
|
if (buttonClass == 'test' ) {
|
||||||
|
id = $('#select-test').children('option:selected').val()
|
||||||
|
} else if (buttonClass == 'dataset' ) {
|
||||||
|
id = $('#select-dataset').children('option:selected').val()
|
||||||
|
}
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: `/admin/analysis/`,
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({'id': id, 'class': buttonClass}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
success: function(response) {
|
||||||
|
window.location.href = response
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
error_response(response)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
2
ref-test/app/analysis/static/js/jquery-3.6.0.min.js
vendored
Normal file
2
ref-test/app/analysis/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/analysis/static/js/script.js
Normal file
115
ref-test/app/analysis/static/js/script.js
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
// Menu Highlight Scripts
|
||||||
|
const menuItems = document.getElementsByClassName('nav-link')
|
||||||
|
for(let i = 0; i < menuItems.length; i++) {
|
||||||
|
if(menuItems[i].pathname == window.location.pathname) {
|
||||||
|
menuItems[i].classList.add('active')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dropdownItems = document.getElementsByClassName('dropdown-item')
|
||||||
|
for(let i = 0; i< dropdownItems.length; i++) {
|
||||||
|
if(dropdownItems[i].pathname == window.location.pathname) {
|
||||||
|
dropdownItems[i].classList.add('active')
|
||||||
|
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// General Post Method Form Processing Script
|
||||||
|
$('form.form-post').submit(function(event) {
|
||||||
|
|
||||||
|
var $form = $(this)
|
||||||
|
var data = $form.serialize()
|
||||||
|
var url = $(this).prop('action')
|
||||||
|
var rel_success = $(this).data('rel-success')
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: url,
|
||||||
|
type: 'POST',
|
||||||
|
data: data,
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(response) {
|
||||||
|
if (response.redirect_to) {
|
||||||
|
window.location.href = response.redirect_to
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
window.location.href = rel_success
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(response) {
|
||||||
|
error_response(response)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
||||||
|
|
||||||
|
function error_response(response) {
|
||||||
|
|
||||||
|
const $alert = $("#alert-box")
|
||||||
|
$alert.html('')
|
||||||
|
|
||||||
|
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
||||||
|
$alert.html(`
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||||
|
${response.responseJSON.error}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
} else if (response.responseJSON.error instanceof Array) {
|
||||||
|
var output = ''
|
||||||
|
for (let i = 0; i < response.responseJSON.error.length; i ++) {
|
||||||
|
output += `
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||||
|
${response.responseJSON.error[i]}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
$alert.html(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$alert.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss Cookie Alert
|
||||||
|
$('#dismiss-cookie-alert').click(function(event){
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/cookies/',
|
||||||
|
type: 'POST',
|
||||||
|
data: {
|
||||||
|
time: Date.now()
|
||||||
|
},
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(response){
|
||||||
|
console.log(response)
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
console.log(response)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create New Dataset
|
||||||
|
$('.create-new-dataset').click(function(event){
|
||||||
|
$.ajax({
|
||||||
|
url: '/api/editor/new/',
|
||||||
|
type: 'POST',
|
||||||
|
data: {
|
||||||
|
time: Date.now()
|
||||||
|
},
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(response){
|
||||||
|
if (response.redirect_to) {
|
||||||
|
window.location.href = response.redirect_to
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(response){
|
||||||
|
console.log(response)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
198
ref-test/app/analysis/templates/analysis/analysis.html
Normal file
198
ref-test/app/analysis/templates/analysis/analysis.html
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
{% extends "analysis/components/datatable.html" %}
|
||||||
|
|
||||||
|
{% block style %}
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('.static', filename='css/analysis.css') }}"
|
||||||
|
/>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Analysis by {{ type[0]|upper }}{{ type[1:] }}</h1>
|
||||||
|
<div class="container">
|
||||||
|
<p class="lead">
|
||||||
|
The analysis section displays statistics for all test results as well as answers to individual questions.
|
||||||
|
Analysis reports can be generated per exam or per question dataset to identify common mistakes or patterns in answers.
|
||||||
|
</p>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">
|
||||||
|
{% if type == 'exam' %}
|
||||||
|
Exam Code
|
||||||
|
{% elif type == 'dataset' %}
|
||||||
|
Dataset Name
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{{ subject }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">Total Entries</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{{ analysis.entries }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">Passed</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{{ analysis.grades.merit + analysis.grades.pass }} ({{ ((analysis.grades.merit + analysis.grades.pass)*100/analysis.entries)|round(2) }} %)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="badge rounded-pill progress-bar-striped bg-success">Merit: {{ analysis.grades.merit }}</span> <span class="badge rounded-pill bg-primary progress-bar-striped">Pass: {{ analysis.grades.pass }}</span> <span class="badge rounded-pill progress-bar-striped bg-danger">Fail: {{ analysis.grades.fail }}</span>
|
||||||
|
<div class="my-1 progress">
|
||||||
|
<div class="progress-bar progress-bar-striped bg-success" role="progressbar" style="width: {{ (analysis.grades.merit*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.merit }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.merit*100/analysis.entries)|round(2) }} %</div>
|
||||||
|
<div class="progress-bar progress-bar-striped" role="progressbar" style="width: {{ (analysis.grades.pass*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.pass }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.pass*100/analysis.entries)|round(2) }} %</div>
|
||||||
|
<div class="progress-bar progress-bar-striped bg-danger" role="progressbar" style="width: {{ (analysis.grades.fail*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.fail }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.fail*100/analysis.entries)|round(2) }} %</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">Mean Score</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{{ analysis.scores.mean|round(2) }} %
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">Standard Deviation</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{% if analysis.scores.stdev %}
|
||||||
|
{{ analysis.scores.stdev|round(2) }}
|
||||||
|
{% else %}
|
||||||
|
{{ None }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">Median Score</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{{ analysis.scores.median|round(2) }} %
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if type == 'exam' %}
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">Dataset Name</span>
|
||||||
|
<span class="form-control">
|
||||||
|
{{ dataset.get_name() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<table id="analysis-table" class="table table-striped" style="width:100%">
|
||||||
|
<thead>
|
||||||
|
<th data-priority="1">
|
||||||
|
Question
|
||||||
|
</th>
|
||||||
|
<th data-priority="1">
|
||||||
|
Percent Correct
|
||||||
|
</th>
|
||||||
|
<th data-priority="2">
|
||||||
|
Answers
|
||||||
|
</th>
|
||||||
|
<th data-priority="3">
|
||||||
|
Tags
|
||||||
|
</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for question in questions %}
|
||||||
|
<tr class="table-row">
|
||||||
|
<td>
|
||||||
|
{{ question.q_no + 1 }}
|
||||||
|
</td>
|
||||||
|
<td class="cell-percentage">
|
||||||
|
{{ ((analysis.answers[question.q_no][question.correct] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<table style="width:100%">
|
||||||
|
{% for option in question.options %}
|
||||||
|
<tr>
|
||||||
|
<td style="width:50%">
|
||||||
|
{{ option[1] }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if question.correct == option[0] %}
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar bg-success progress-bar-striped" role="progressbar" style="width: {{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}%;" aria-valuenow="{{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}" aria-valuemin="0" aria-valuemax="100">{{ ((analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}%</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar bg-danger progress-bar-striped" role="progressbar" style="width: {{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}%;" aria-valuenow="{{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}" aria-valuemin="0" aria-valuemax="100">{{ ((analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}%</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
{% for tag in question.tags %}
|
||||||
|
<li>{{ tag|safe }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
const target = "{{ url_for('api._editor') }}"
|
||||||
|
const id = "{{ dataset.id }}"
|
||||||
|
</script>
|
||||||
|
<script
|
||||||
|
type="text/javascript"
|
||||||
|
src="{{ url_for('.static', filename='js/analysis.js') }}"
|
||||||
|
></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block custom_data_script %}
|
||||||
|
<script>
|
||||||
|
console.log($('#analysis-table'))
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('#analysis-table').DataTable({
|
||||||
|
'searching': true,
|
||||||
|
'columnDefs': [
|
||||||
|
{'sortable': true, 'targets': [0,1]},
|
||||||
|
{'sortable': false, 'targets': [2,3]},
|
||||||
|
{'searchable': true, 'targets': [0,2,3]}
|
||||||
|
],
|
||||||
|
'order': [[0, 'asc'], [1, 'desc']],
|
||||||
|
'buttons': [
|
||||||
|
{
|
||||||
|
extend: 'print',
|
||||||
|
exportOptions: {
|
||||||
|
columns: [0, 1, 2, 3]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extend: 'excel',
|
||||||
|
exportOptions: {
|
||||||
|
columns: [0, 1, 2, 3]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extend: 'pdf',
|
||||||
|
exportOptions: {
|
||||||
|
columns: [0, 1, 2, 3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'responsive': 'true',
|
||||||
|
'colReorder': 'true',
|
||||||
|
'fixedHeader': 'true',
|
||||||
|
'searchBuilder': {
|
||||||
|
depthLimit: 2,
|
||||||
|
columns: [2, 3],
|
||||||
|
},
|
||||||
|
dom: 'BQlfrtip'
|
||||||
|
});
|
||||||
|
// $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') -->
|
||||||
|
} );
|
||||||
|
$('#analysis-table').show();
|
||||||
|
$(window).trigger('resize');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
@ -0,0 +1,92 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
|
||||||
|
crossorigin="anonymous">
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('.static', filename='css/style.css') }}"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('.static', filename='css/view.css') }}"
|
||||||
|
/>
|
||||||
|
{% block style %}
|
||||||
|
{% endblock %}
|
||||||
|
<title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title>
|
||||||
|
{% include "analysis/components/og-meta.html" %}
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
|
||||||
|
{% block navbar %}
|
||||||
|
{% include "analysis/components/navbar.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
{% block top_alerts %}
|
||||||
|
{% include "analysis/components/server-alerts.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="container site-footer mt-5">
|
||||||
|
{% block footer %}
|
||||||
|
{% include "analysis/components/footer.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- JQuery, Popper, and Bootstrap js dependencies -->
|
||||||
|
<script
|
||||||
|
src="https://code.jquery.com/jquery-3.6.0.min.js"
|
||||||
|
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
||||||
|
crossorigin="anonymous">
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
|
||||||
|
</script>
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
||||||
|
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
||||||
|
crossorigin="anonymous">
|
||||||
|
</script>
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
|
||||||
|
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
|
<!-- Custom js -->
|
||||||
|
<script type="text/javascript">
|
||||||
|
var csrf_token = "{{ csrf_token() }}";
|
||||||
|
|
||||||
|
$.ajaxSetup({
|
||||||
|
beforeSend: function(xhr, settings) {
|
||||||
|
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
|
||||||
|
xhr.setRequestHeader("X-CSRFToken", csrf_token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script
|
||||||
|
type="text/javascript"
|
||||||
|
src="{{ url_for('.static', filename='js/script.js') }}"
|
||||||
|
></script>
|
||||||
|
<script
|
||||||
|
type="text/javascript"
|
||||||
|
src="{{ url_for('.static', filename='js/analysis.js') }}"
|
||||||
|
></script>
|
||||||
|
{% block script %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block datatable_scripts %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block custom_data_script %}
|
||||||
|
{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -0,0 +1 @@
|
|||||||
|
<div id="alert-box" tabindex="-1"></div>
|
@ -0,0 +1,28 @@
|
|||||||
|
{% extends "analysis/components/base.html" %}
|
||||||
|
{% block datatable_css %}
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.11.3/css/dataTables.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/buttons/2.0.1/css/buttons.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/colreorder/1.5.5/css/colReorder.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/fixedheader/3.2.0/css/fixedHeader.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/keytable/2.6.4/css/keyTable.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.bootstrap5.min.css"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/searchbuilder/1.3.0/css/searchBuilder.dataTables.min.css"/>
|
||||||
|
{% endblock %}
|
||||||
|
{% block datatable_scripts %}
|
||||||
|
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jszip/2.5.0/jszip.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/pdfmake.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/vfs_fonts.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/jquery.dataTables.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/dataTables.bootstrap5.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/dataTables.buttons.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.bootstrap5.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.colVis.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.html5.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.print.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/colreorder/1.5.5/js/dataTables.colReorder.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/fixedheader/3.2.0/js/dataTables.fixedHeader.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/keytable/2.6.4/js/dataTables.keyTable.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/responsive.bootstrap5.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.datatables.net/searchbuilder/1.3.0/js/dataTables.searchBuilder.min.js"></script>
|
||||||
|
{% endblock %}
|
@ -0,0 +1,2 @@
|
|||||||
|
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||||
|
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
@ -0,0 +1,4 @@
|
|||||||
|
{% extends "analysis/components/base.html" %}
|
||||||
|
{% import "bootstrap/wtf.html" as wtf %}
|
||||||
|
{% block top_alerts %}
|
||||||
|
{% endblock %}
|
137
ref-test/app/analysis/templates/analysis/components/navbar.html
Normal file
137
ref-test/app/analysis/templates/analysis/components/navbar.html
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||||
|
<div class="container">
|
||||||
|
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||||
|
<button
|
||||||
|
class="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navbar"
|
||||||
|
aria-controls="navbar"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle Navigation"
|
||||||
|
>
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse justify-content-end" id="navbar">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{% if not current_user.is_authenticated %}
|
||||||
|
<li class="nav-item" id="nav-login">
|
||||||
|
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<li class="nav-item dropdown" id="nav-results">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-results"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._view_entries') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Results
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-results"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="nav-tests">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-tests"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._tests') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Exams
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-tests"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="nav-settings">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-settings"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._settings') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-settings"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="nav-account">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-account"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._update_user', id=current_user.id) }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Account
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-account"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
@ -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 %}
|
54
ref-test/app/analysis/templates/analysis/index.html
Normal file
54
ref-test/app/analysis/templates/analysis/index.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{% extends "analysis/components/input-forms.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Analysis</h1>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card m-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Exams</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="form-select-input">
|
||||||
|
<select name="select-test" id="select-test">
|
||||||
|
{% for test in tests %}
|
||||||
|
<option value="{{ test.id }}">{{ test.get_code() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="my-3">
|
||||||
|
<a href="{{ url_for('analysis._test') }}" class="btn btn-primary button-analyse" data-class="test">
|
||||||
|
<i class="bi bi-search button-icon"></i>
|
||||||
|
Analyse
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card m-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Datasets</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="form-select-input">
|
||||||
|
<select name="select-dataset" id="select-dataset">
|
||||||
|
{% for dataset in datasets %}
|
||||||
|
<option value="{{ dataset.id }}">{{ dataset.get_name() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="my-3">
|
||||||
|
<a href="{{ url_for('analysis._dataset') }}" class="btn btn-primary button-analyse" data-class="dataset">
|
||||||
|
<i class="bi bi-search button-icon"></i>
|
||||||
|
Analyse
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include "analysis/components/client-alerts.html" %}
|
||||||
|
{% endblock %}
|
85
ref-test/app/analysis/views.py
Normal file
85
ref-test/app/analysis/views.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
from ..models import Dataset, Test
|
||||||
|
from ..tools.data import analyse, check_dataset_exists, check_test_exists
|
||||||
|
from ..tools.logs import write
|
||||||
|
from ..tools.data import parse_questions
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, jsonify
|
||||||
|
from flask.helpers import abort, flash, redirect, url_for
|
||||||
|
from flask_login import login_required
|
||||||
|
|
||||||
|
analysis = Blueprint(
|
||||||
|
name='analysis',
|
||||||
|
import_name=__name__,
|
||||||
|
template_folder='templates',
|
||||||
|
static_folder='static'
|
||||||
|
)
|
||||||
|
|
||||||
|
@analysis.route('/', methods=['GET','POST'])
|
||||||
|
@login_required
|
||||||
|
@check_dataset_exists
|
||||||
|
@check_test_exists
|
||||||
|
def _analysis():
|
||||||
|
try:
|
||||||
|
_tests = Test.query.all()
|
||||||
|
_datasets = Dataset.query.all()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
tests = [ test for test in _tests if test.entries ]
|
||||||
|
datasets = [ dataset for dataset in _datasets if dataset.entries ]
|
||||||
|
if request.method == 'POST':
|
||||||
|
selection = request.get_json()
|
||||||
|
if selection['class'] == 'test':
|
||||||
|
try:
|
||||||
|
test = Test.query.filter_by(id=selection['id']).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not test: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||||
|
return url_for('analysis._test', id=selection['id']), 200
|
||||||
|
if selection['class'] == 'dataset':
|
||||||
|
try:
|
||||||
|
dataset = Dataset.query.filter_by(id=selection['id']).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not dataset: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||||
|
return url_for('analysis._dataset', id=selection['id']), 200
|
||||||
|
return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||||
|
return render_template('/analysis/index.html', tests=tests, datasets=datasets)
|
||||||
|
|
||||||
|
@analysis.route('/test/<string:id>')
|
||||||
|
@analysis.route('/test/')
|
||||||
|
@login_required
|
||||||
|
@check_test_exists
|
||||||
|
def _test(id:str=None):
|
||||||
|
if id in [None, '']:
|
||||||
|
flash(message='Please select a valid exam.', category='error')
|
||||||
|
return redirect(url_for('analysis._analysis'))
|
||||||
|
try:
|
||||||
|
test = Test.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not test:
|
||||||
|
flash('Invalid exam.', 'error')
|
||||||
|
return redirect(url_for('analysis._analysis'))
|
||||||
|
return render_template('/analysis/analysis.html', analysis=analyse(test), subject=test.get_code(), type='exam', dataset=test.dataset, questions=parse_questions(test.dataset.get_data()))
|
||||||
|
|
||||||
|
@analysis.route('/dataset/<string:id>')
|
||||||
|
@analysis.route('/dataset/')
|
||||||
|
@login_required
|
||||||
|
@check_dataset_exists
|
||||||
|
def _dataset(id:str=None):
|
||||||
|
if id in [None, '']:
|
||||||
|
flash(message='Please select a valid dataset.', category='error')
|
||||||
|
return redirect(url_for('analysis._analysis'))
|
||||||
|
try:
|
||||||
|
dataset = Dataset.query.filter_by(id=id).first()
|
||||||
|
except Exception as exception:
|
||||||
|
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||||
|
return abort(500)
|
||||||
|
if not dataset:
|
||||||
|
flash('Invalid dataset.', 'error')
|
||||||
|
return redirect(url_for('analysis._analysis'))
|
||||||
|
return render_template('/analysis/analysis.html', analysis=analyse(dataset), subject=dataset.get_name(), type='dataset', dataset=dataset, questions=parse_questions(dataset.get_data()))
|
115
ref-test/app/api/views.py
Normal file
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,58 @@
|
|||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
|
||||||
load_dotenv()
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv('../.env')
|
||||||
|
|
||||||
class Config(object):
|
class Config(object):
|
||||||
|
"""Basic App Configuration"""
|
||||||
APP_HOST = '0.0.0.0'
|
APP_HOST = '0.0.0.0'
|
||||||
DATA_FILE_DIRECTORY = os.getenv('DATA_FILE_DIRECTORY')
|
DATA = './data/'
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
TESTING = False
|
TESTING = False
|
||||||
SECRET_KEY = os.getenv('SECRET_KEY')
|
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||||
|
SERVER_NAME = os.getenv('SERVER_NAME')
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(DATA_FILE_DIRECTORY)}/database.db'
|
WTF_CSRF_TIME_LIMIT = None
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
|
||||||
|
|
||||||
|
"""Email Engine Configuration"""
|
||||||
MAIL_SERVER = os.getenv('MAIL_SERVER')
|
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_TLS = False
|
||||||
MAIL_USE_SSL = False
|
MAIL_USE_SSL = False
|
||||||
MAIL_DEBUG = False
|
MAIL_DEBUG = False
|
||||||
MAIL_USERNAME = os.getenv('MAIL_USERNAME')
|
MAIL_USERNAME = os.getenv('MAIL_USERNAME')
|
||||||
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
|
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
|
||||||
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER')
|
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_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
|
pass
|
||||||
|
|
||||||
class DevelopmentConfig(Config):
|
class Development(Config):
|
||||||
APP_HOST = '127.0.0.1'
|
APP_HOST = '127.0.0.1'
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
SERVER_NAME = '127.0.0.1:5000'
|
||||||
SESSION_COOKIE_SECURE = False
|
SESSION_COOKIE_SECURE = False
|
||||||
MAIL_SERVER = 'localhost'
|
MAIL_SERVER = 'localhost'
|
||||||
MAIL_DEBUG = True
|
MAIL_DEBUG = True
|
||||||
MAIL_SUPPRESS_SEND = False
|
MAIL_SUPPRESS_SEND = False
|
||||||
|
|
||||||
class TestingConfig(DevelopmentConfig):
|
class Testing(Development):
|
||||||
TESTING = True
|
TESTING = True
|
||||||
SESSION_COOKIE_SECURE = False
|
SESSION_COOKIE_SECURE = False
|
||||||
MAIL_SERVER = os.getenv('MAIL_SERVER')
|
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 %}
|
137
ref-test/app/editor/templates/editor/components/navbar.html
Normal file
137
ref-test/app/editor/templates/editor/components/navbar.html
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||||
|
<div class="container">
|
||||||
|
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||||
|
<button
|
||||||
|
class="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navbar"
|
||||||
|
aria-controls="navbar"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle Navigation"
|
||||||
|
>
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse justify-content-end" id="navbar">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{% if not current_user.is_authenticated %}
|
||||||
|
<li class="nav-item" id="nav-login">
|
||||||
|
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<li class="nav-item dropdown" id="nav-results">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-results"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._view_entries') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Results
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-results"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="nav-tests">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-tests"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._tests') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Exams
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-tests"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="nav-settings">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-settings"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._settings') }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-settings"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="nav-account">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="dropdown-account"
|
||||||
|
role="button"
|
||||||
|
href="{{ url_for('admin._update_user', id=current_user.id) }}"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Account
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu"
|
||||||
|
aria-labelledby="dropdown-account"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
18
ref-test/app/editor/templates/editor/components/og-meta.html
Normal file
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 .entry import Entry
|
||||||
from ..tools.encryption import decrypt, encrypt
|
from .test import Test
|
||||||
from ..tools.logs import write
|
from .user import User
|
||||||
|
from .dataset import Dataset
|
||||||
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.'
|
|
140
ref-test/app/models/dataset.py
Normal file
140
ref-test/app/models/dataset.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
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, loads
|
||||||
|
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')
|
||||||
|
entries = db.relationship('Entry', 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 get_data(self):
|
||||||
|
dataset_path = self.get_file()
|
||||||
|
with open(dataset_path, 'r') as _dataset:
|
||||||
|
data = loads(_dataset.read())
|
||||||
|
return data
|
||||||
|
|
||||||
|
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.'
|
201
ref-test/app/models/entry.py
Normal file
201
ref-test/app/models/entry.py
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
from ..extensions import db, mail
|
||||||
|
from ..tools.encryption import decrypt, encrypt
|
||||||
|
from ..tools.logs import write
|
||||||
|
from .test import Test
|
||||||
|
from .dataset import Dataset
|
||||||
|
|
||||||
|
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'))
|
||||||
|
dataset_id = db.Column(db.String(36), db.ForeignKey('dataset.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}')
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user