Compare commits

...

296 Commits

Author SHA1 Message Date
70671adac8 Cookie bugfix, removed 'session' string from expiry/age 2021-12-08 11:26:18 +00:00
6be20568a8 Debug form error handlers 2021-12-07 16:17:59 +00:00
7ff786c6e2 Named image 2021-12-07 16:03:56 +00:00
f5061c9ec1 Bug fix and data persistence 2021-12-07 15:52:58 +00:00
ad33251e9d Bugfix: security key location 2021-12-07 15:25:22 +00:00
df33b3889a Bugfix: encryption lockout 2021-12-07 15:15:16 +00:00
37b8b3e0a1 Bug fix: evaluating question 97 2021-12-07 15:03:21 +00:00
5878001862 Merge 2021-12-07 13:37:12 +00:00
05740d5aa5 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 13:34:02 +00:00
7c1370e95c Typo 2021-12-07 13:33:31 +00:00
96d8e48f3a Some more minor fixes to containerising and ignore 2021-12-07 13:29:25 +00:00
bab3674d31 Re-wrote compose and conf removing personal info 2021-12-07 13:26:24 +00:00
32d6268c95 Finesse log in form css 2021-12-07 12:39:29 +00:00
4f79ca6ec4 Finesse log in form css 2021-12-07 12:39:29 +00:00
9088e35157 Finessed remaining block question counter 2021-12-07 12:27:06 +00:00
8c9f0ef5b3 Finessed remaining block question counter 2021-12-07 12:27:06 +00:00
206f50b775 Correcting an error 2021-12-07 07:24:39 +00:00
075094b935 Correcting an error 2021-12-07 07:24:39 +00:00
325ced0cd2 Finesse cookie dismiss button 2021-12-07 07:22:11 +00:00
c23e5aa68a Finesse cookie dismiss button 2021-12-07 07:22:11 +00:00
130cfcf41f Finesse cookie consent display 2021-12-07 07:09:28 +00:00
27a62b5611 Finesse cookie consent display 2021-12-07 07:09:28 +00:00
ab4fb55aa1 removed fake link 2021-12-07 07:04:58 +00:00
a6a85492de removed fake link 2021-12-07 07:04:58 +00:00
bb661a2523 Fixed subject-verb disagreement 2021-12-07 07:00:44 +00:00
480c7f46b2 Fixed subject-verb disagreement 2021-12-07 07:00:44 +00:00
8d1893be01 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 06:55:50 +00:00
c07012c007 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 06:55:50 +00:00
800112a006 Technical issues help email 2021-12-07 06:46:34 +00:00
e69f3ee5e8 Technical issues help email 2021-12-07 06:46:34 +00:00
2f6208369b Remove personal data from document 2021-12-07 06:42:46 +00:00
8c46a16fce Remove personal data from document 2021-12-07 06:42:46 +00:00
4c8e3f8c53 Removed email address 2021-12-07 06:40:57 +00:00
ff4bf5c475 Removed email address 2021-12-07 06:40:57 +00:00
314ede846b Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test
Sync
2021-12-07 06:38:54 +00:00
4ea675c938 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test
Sync
2021-12-07 06:38:54 +00:00
4ae180ac82 Started drafting documentation 2021-12-07 06:38:43 +00:00
077afe42ee Started drafting documentation 2021-12-07 06:38:43 +00:00
d01352658c Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
53d95add63 Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
1d42d94ef2 Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
166e98a40e Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
babea6871a Updated test expiry 2021-12-06 23:37:16 +00:00
326e529ffb Updated test expiry 2021-12-06 23:37:16 +00:00
bfe1f33019 Updated test expiry 2021-12-06 23:37:16 +00:00
31b3d5b170 Updated test expiry 2021-12-06 23:37:16 +00:00
d62fe1daec Corrected bug in exam display 2021-12-06 23:24:57 +00:00
b5d5f7f0e1 Corrected bug in exam display 2021-12-06 23:24:57 +00:00
0f1237b6fb Corrected bug in exam display 2021-12-06 23:24:57 +00:00
ede2ff5355 Corrected bug in exam display 2021-12-06 23:24:57 +00:00
aa9a0272d0 I am bad at debugging. 2021-12-06 23:19:13 +00:00
9e22aa5f98 I am bad at debugging. 2021-12-06 23:19:13 +00:00
89fdbd9607 I am bad at debugging. 2021-12-06 23:19:13 +00:00
ebf6005335 I am bad at debugging. 2021-12-06 23:19:13 +00:00
a8d7d35c12 Close Quiz function 2021-12-06 23:16:33 +00:00
67b424da4d Close Quiz function 2021-12-06 23:16:33 +00:00
5f58c1096c Close Quiz function 2021-12-06 23:16:33 +00:00
8c96bbe1e3 Close Quiz function 2021-12-06 23:16:33 +00:00
792053e886 Remove redundant file 2021-12-06 22:54:40 +00:00
3e07f2f763 Remove redundant file 2021-12-06 22:54:40 +00:00
730d2c1397 Remove redundant file 2021-12-06 22:54:40 +00:00
27ca68bad1 Remove redundant file 2021-12-06 22:54:40 +00:00
f5c964951d Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
e0284ddd17 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
16ae0bcebf Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
d405e57803 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
e177a1351d This fixes it, hopefully 2021-12-06 22:47:54 +00:00
eca9e789c1 This fixes it, hopefully 2021-12-06 22:47:54 +00:00
fc8982baa7 This fixes it, hopefully 2021-12-06 22:47:54 +00:00
5c471179b8 This fixes it, hopefully 2021-12-06 22:47:54 +00:00
38806309c3 And again 2021-12-06 22:26:48 +00:00
45a1d3f704 And again 2021-12-06 22:26:48 +00:00
97b2986da1 And again 2021-12-06 22:26:48 +00:00
84d91fff82 And again 2021-12-06 22:26:48 +00:00
cb7ecd9c04 Trying to fix it again 2021-12-06 22:24:34 +00:00
d6fd252f48 Trying to fix it again 2021-12-06 22:24:34 +00:00
d041079900 Trying to fix it again 2021-12-06 22:24:34 +00:00
15c3198b33 Trying to fix it again 2021-12-06 22:24:34 +00:00
c9350eac60 I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
b6a203a76c I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
ca3cb323de I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
4ff56b83a3 I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
a3d6a37ebc More Bug Fixes 2021-12-06 22:17:52 +00:00
6fdbad0c09 More Bug Fixes 2021-12-06 22:17:52 +00:00
e534c4423e More Bug Fixes 2021-12-06 22:17:52 +00:00
a6ec5eb5cc More Bug Fixes 2021-12-06 22:17:52 +00:00
80f48ca964 Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
f1934f6131 Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
6a09576762 Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
f4fef63475 Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
05fe5f711e OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
0be4bbb62d OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
598eb12970 OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
f08f872d0f OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
46050dda85 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
686a456dbb Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
6c886af6d1 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
150c592e64 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
b136b7235e OG and Cookie settings 2021-12-06 21:51:29 +00:00
32a8fc9e40 OG and Cookie settings 2021-12-06 21:51:29 +00:00
9a9f72dd21 OG and Cookie settings 2021-12-06 21:51:29 +00:00
a6b4bccd51 OG and Cookie settings 2021-12-06 21:51:29 +00:00
fa4111424a Bugfixes: case sensitivity, percent sign rendering
Temporary work-around: disabled client navbar home link to prevent exit
2021-12-06 21:47:02 +00:00
1af2385b1c Bugfixes: case sensitivity, percent sign rendering
Temporary work-around: disabled client navbar home link to prevent exit
2021-12-06 21:47:02 +00:00
8a52c1e4ec Bugfixes: case sensitivity, percent sign rendering
Temporary work-around: disabled client navbar home link to prevent exit
2021-12-06 21:47:02 +00:00
c7b009ed3d Bugfixes: case sensitivity, percent sign rendering
Temporary work-around: disabled client navbar home link to prevent exit
2021-12-06 21:47:02 +00:00
95299d42db Proxy Fix 2021-12-06 20:10:27 +00:00
e26fa2348f Proxy Fix 2021-12-06 20:10:27 +00:00
4ea85bcdc8 Proxy Fix 2021-12-06 20:10:27 +00:00
41a32e98c5 Proxy Fix 2021-12-06 20:10:27 +00:00
Vivek Santayana
f21daa2f11 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
83c34416ce Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
c4451d565d Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
6fdc9b9ae6 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
25e0f26b95 Updated config 2021-12-06 19:21:45 +00:00
62bb44b0f9 Updated config 2021-12-06 19:21:45 +00:00
3db7c41ae4 Updated config 2021-12-06 19:21:45 +00:00
c1d4dd2b90 Updated config 2021-12-06 19:21:45 +00:00
Vivek Santayana
66eb06cfa0 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
0b20b0f9c6 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
a0a418080b Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
0ac73d8a94 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
8ce575bdd6 Favicons and OG Meta 2021-12-06 18:58:42 +00:00
2e0d3cf7a3 Favicons and OG Meta 2021-12-06 18:58:42 +00:00
c6a4fd5045 Favicons and OG Meta 2021-12-06 18:58:42 +00:00
231ca2b824 Favicons and OG Meta 2021-12-06 18:58:42 +00:00
Vivek Santayana
61c5043f5d Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Download fonts and remove private files
2021-12-06 18:06:47 +00:00
Vivek Santayana
d2560b3ec8 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Download fonts and remove private files
2021-12-06 18:06:47 +00:00
Vivek Santayana
37c3cd1a8a Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Download fonts and remove private files
2021-12-06 18:06:47 +00:00
Vivek Santayana
bece36013c Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Download fonts and remove private files
2021-12-06 18:06:47 +00:00
6fb91e1b77 Uploading Fonts 2021-12-06 18:06:11 +00:00
e5c5c32ff0 Uploading Fonts 2021-12-06 18:06:11 +00:00
ab1ad4b00d Uploading Fonts 2021-12-06 18:06:11 +00:00
3ed1a8fb1b Uploading Fonts 2021-12-06 18:06:11 +00:00
Vivek Santayana
c22c1e2f9b Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
b8c78fa10a Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
48745ee21b Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
e20cc1359b Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
03e1010253 Correct error 2021-12-06 16:56:54 +00:00
ad032efe4e Correct error 2021-12-06 16:56:54 +00:00
ed32aa1fc7 Correct error 2021-12-06 16:56:54 +00:00
02b892eb6d Correct error 2021-12-06 16:56:54 +00:00
72493d622e Added correct answer view 2021-12-06 13:44:40 +00:00
2e96349c15 Added correct answer view 2021-12-06 13:44:40 +00:00
bfe74e24a6 Added correct answer view 2021-12-06 13:44:40 +00:00
f2700ecd90 Added correct answer view 2021-12-06 13:44:40 +00:00
2a692e48b7 Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
eccf0820f2 Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
285b3c5241 Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
b80a274626 Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
11f2f523d8 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
2b57979466 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
c9366e405e Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
6fe251909b Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
7323bd2885 Nginx Server 2021-12-06 13:29:20 +00:00
fbf8b12c8b Nginx Server 2021-12-06 13:29:20 +00:00
3b4cd4b9a4 Nginx Server 2021-12-06 13:29:20 +00:00
beae39d628 Nginx Server 2021-12-06 13:29:20 +00:00
1a1ea9d76b Dockerised. Restructured to remove circular import
Moved most of app definitions out of guard function to use wsgi
Updated configuration files and referencing of .env values.
Local version needs dotenv or exporting of env variables.
Dockerised version works fine without load_dotenv.
Ready to test now!
2021-12-05 03:49:31 +00:00
691c2e4bae Dockerised. Restructured to remove circular import
Moved most of app definitions out of guard function to use wsgi
Updated configuration files and referencing of .env values.
Local version needs dotenv or exporting of env variables.
Dockerised version works fine without load_dotenv.
Ready to test now!
2021-12-05 03:49:31 +00:00
1f8e4508c3 Dockerised. Restructured to remove circular import
Moved most of app definitions out of guard function to use wsgi
Updated configuration files and referencing of .env values.
Local version needs dotenv or exporting of env variables.
Dockerised version works fine without load_dotenv.
Ready to test now!
2021-12-05 03:49:31 +00:00
e25d37fecb Dockerised. Restructured to remove circular import
Moved most of app definitions out of guard function to use wsgi
Updated configuration files and referencing of .env values.
Local version needs dotenv or exporting of env variables.
Dockerised version works fine without load_dotenv.
Ready to test now!
2021-12-05 03:49:31 +00:00
0bb18ce75f dockerise 2021-12-05 00:17:54 +00:00
f57229f470 dockerise 2021-12-05 00:17:54 +00:00
452c214c15 dockerise 2021-12-05 00:17:54 +00:00
821c0b115a dockerise 2021-12-05 00:17:54 +00:00
c319a3307c Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
12b5f53096 Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
2f77e86450 Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
05fecdf293 Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
438d53abc5 Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
1dac71da45 Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
d9145b15f7 Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
524b2d8f9f Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
12fa8b9275 Finished making dashboards 2021-12-04 20:47:43 +00:00
a00edb86c0 Finished making dashboards 2021-12-04 20:47:43 +00:00
f6763b85fd Finished making dashboards 2021-12-04 20:47:43 +00:00
896afa7b2d Finished making dashboards 2021-12-04 20:47:43 +00:00
be78862c72 Added question progress bar 2021-12-04 18:50:09 +00:00
360e3c5930 Added question progress bar 2021-12-04 18:50:09 +00:00
1312729836 Added question progress bar 2021-12-04 18:50:09 +00:00
6cd30c8c0f Added question progress bar 2021-12-04 18:50:09 +00:00
b21a83e28b Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
8d7796ab9f Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
63180c6488 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
79afc8e1c5 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
ecefc1900d Completed client side time adjustment handling
Corrected error display bug
Removed redundant auth and models in quiz client app
2021-12-04 17:14:28 +00:00
b6f1ee0c8b Completed client side time adjustment handling
Corrected error display bug
Removed redundant auth and models in quiz client app
2021-12-04 17:14:28 +00:00
c0cdc5ca50 Completed client side time adjustment handling
Corrected error display bug
Removed redundant auth and models in quiz client app
2021-12-04 17:14:28 +00:00
e8ca0768ab Completed client side time adjustment handling
Corrected error display bug
Removed redundant auth and models in quiz client app
2021-12-04 17:14:28 +00:00
e735553cb3 Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
1387ebd49e Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
52908a548c Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
ead24674b6 Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
d2a100d827 Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
79d7a6fec8 Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
239c29b1f5 Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
1962c46b11 Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
7940e7c46f Typo correction 2021-12-04 12:48:01 +00:00
a45684c0e4 Typo correction 2021-12-04 12:48:01 +00:00
bcbb38d0d9 Typo correction 2021-12-04 12:48:01 +00:00
3630dc1c14 Typo correction 2021-12-04 12:48:01 +00:00
2d599629f4 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
2ce03b3e2b Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
f222b289c0 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
b83b07b8d8 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
f83f173233 Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
1ffd20b68f Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
bb7b93fc8c Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
ccc94b5bbe Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
c4582503b9 Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
95419c7467 Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
5b7e5e72d8 Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
8ac49e4db3 Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
44985084d2 Added result page. 2021-12-01 00:48:47 +00:00
88e86c59ba Added result page. 2021-12-01 00:48:47 +00:00
e8081a9b30 Added result page. 2021-12-01 00:48:47 +00:00
dc1951745b Added result page. 2021-12-01 00:48:47 +00:00
ace56eed24 Finessing of client. 2021-12-01 00:48:38 +00:00
89eb4ed854 Finessing of client. 2021-12-01 00:48:38 +00:00
d80cd40207 Finessing of client. 2021-12-01 00:48:38 +00:00
ba997ddf5b Finessing of client. 2021-12-01 00:48:38 +00:00
89bf76759b Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
7e3a8a5aaa Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
ee53c7acda Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
73845486b3 Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
b110b880b2 Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
6add11e846 Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
197bbe1150 Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
e0f898ef72 Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
c9c991f898 Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
5740c9ce16 Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
bd55d19248 Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
b05a5c70a3 Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
0eaf842ca8 Added automated email notification of results. 2021-12-01 00:46:21 +00:00
cabc57ea90 Added automated email notification of results. 2021-12-01 00:46:21 +00:00
29730e9987 Added automated email notification of results. 2021-12-01 00:46:21 +00:00
4c004085b1 Added automated email notification of results. 2021-12-01 00:46:21 +00:00
3e013a8e8c Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
76eaef6f05 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
41b54f3264 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
1dfd06a331 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
e7ca0671f3 Corrected doubled import 2021-12-01 00:45:20 +00:00
e587a6d515 Corrected doubled import 2021-12-01 00:45:20 +00:00
bbbdcee77a Corrected doubled import 2021-12-01 00:45:20 +00:00
4acb3157f1 Corrected doubled import 2021-12-01 00:45:20 +00:00
86b8390acb Exam Code Time Controls 2021-11-30 18:16:52 +00:00
d439ec2dd8 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
46ad21e9c1 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
2cab80bda4 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
de0db1b6af Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
2463375398 Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
5dc64507a7 Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
09aa30694e Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
ffbdd91d9b Built client interface 2021-11-30 03:11:28 +00:00
10c75845a9 Built client interface 2021-11-30 03:11:28 +00:00
61d2cc3a6d Built client interface 2021-11-30 03:11:28 +00:00
1e953fe21c Built client interface 2021-11-30 03:11:28 +00:00
94541d1813 Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
979fee8253 Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
b24f340d91 Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
1088dc76e4 Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
37ea8d0191 Added question generating API 2021-11-28 18:17:50 +00:00
c5ca461e3d Added question generating API 2021-11-28 18:17:50 +00:00
6469274f14 Added question generating API 2021-11-28 18:17:50 +00:00
a763ba212a Added question generating API 2021-11-28 18:17:50 +00:00
43acda716b Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
e5234122c9 Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
6ff5d159cb Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
d1d9b1bd1f Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
81d95bb4b3 Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
f361d25728 Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
0ab06af8be Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
dd085582cc Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
95abec2b4b Refactor to have all models in the models package. 2021-11-25 23:21:48 +00:00
3bde83cf92 Refactor to have all models in the models package. 2021-11-25 23:21:48 +00:00
5b2e6dda67 Refactor to have all models in the models package. 2021-11-25 23:21:48 +00:00
39e80c64fa Refactor to have all models in the models package. 2021-11-25 23:21:48 +00:00
52019e61c1 Quiz registration form 2021-11-25 23:12:20 +00:00
857fa72feb Quiz registration form 2021-11-25 23:12:20 +00:00
c745e3c27c Quiz registration form 2021-11-25 23:12:20 +00:00
9b1d8fca71 Finished delete and data table fiew for tests 2021-11-25 20:12:50 +00:00
6cecb49d50 Finished delete and data table fiew for tests 2021-11-25 20:12:50 +00:00
2d0bb883bd Finished delete and data table fiew for tests 2021-11-25 20:12:50 +00:00
d6d809a60e Working version of test tables 2021-11-25 10:34:01 +00:00
1d9d7853bd Working version of test tables 2021-11-25 10:34:01 +00:00
c0d79d1bc7 Working version of test tables 2021-11-25 10:34:01 +00:00
610b6a5766 Building new test form
Added CRUD for tests
2021-11-24 17:17:56 +00:00
f771c19d99 Building new test form
Added CRUD for tests
2021-11-24 17:17:56 +00:00
a862a0f03a Building new test form
Added CRUD for tests
2021-11-24 17:17:56 +00:00
f2943e4bc1 Deleted user views registration 2021-11-23 14:53:58 +00:00
035b78a656 Deleted user views registration 2021-11-23 14:53:58 +00:00
9f198ed133 Deleted user views registration 2021-11-23 14:53:58 +00:00
43b5973dbe Finished most of admin console
Basic CRUD operations for managing registered admin users
Encrypted personal information
Still missing sections on managing tests and results
Also missing dashboards/index/category landing pages
2021-11-23 13:00:03 +00:00
c6a6ed963e Finished most of admin console
Basic CRUD operations for managing registered admin users
Encrypted personal information
Still missing sections on managing tests and results
Also missing dashboards/index/category landing pages
2021-11-23 13:00:03 +00:00
0002c15e94 Finished most of admin console
Basic CRUD operations for managing registered admin users
Encrypted personal information
Still missing sections on managing tests and results
Also missing dashboards/index/category landing pages
2021-11-23 13:00:03 +00:00
90 changed files with 3911 additions and 862 deletions

6
.gitignore vendored
View File

@ -145,8 +145,8 @@ dev/
out/ out/
ref-test/testing.py ref-test/testing.py
# Ignore Database
database/data/
# Ignore Encryption Keyfile # Ignore Encryption Keyfile
.encryption.key .encryption.key
# Ignore Font Binaries
**/fonts/

View File

@ -1,3 +1,40 @@
# ska-referee-test # SKA Referee Test App
An on-line version of a referee test for the Scottish Korfball Association. ## About
```A web app that digitises the theory exam for the Scottish Korfball Association referee qualification```
This web app provides an on-line platform through which to administer and take the SKA Refereeing theory exam.
The app includes a digital client to take the exam for candidates, as well as an admin console from which to manage tests, view results, and update questions.
The exam client is made with accessibility in mind, and has been designed to be adaptable to dyslexia and other learning needs or cognitive needs.
## Set Up and Installation
The clien is designed to work on a server.
### Pre-Requisites
Server
Docker
Docker-Compose
Git
### Installation
#### Preliminary Set-Up: Clone repos and Configure Values
#### Set Up Web Server
#### Incorporate SSL
#### Set Up Auto-Renew
### Alterations
## Use
## Compatibility
### iOS Limitations
### Fonts

View File

@ -77,3 +77,7 @@ Uses SQL rather than MongoDB.
### Flask techniques ### Flask techniques
- [Create a `config.py` file](https://www.youtube.com/watch?v=GW_2O9CrnSU) - [Create a `config.py` file](https://www.youtube.com/watch?v=GW_2O9CrnSU)
### Flask handling file uploads
- [Handlin File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask)

2
certbot/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -1,17 +1,54 @@
version: '3.9' version: '3.9'
services: services:
ref_test_server:
container_name: ref_test_server
image: nginx:1.21.4-alpine
volumes:
- ./certbot:/etc/letsencrypt:ro
- ./nginx:/etc/nginx
- ./src/html:/usr/share/nginx/html/
- ./ref-test/admin/static:/usr/share/nginx/html/admin/static
- ./ref-test/quiz/static:/usr/share/nginx/html/quiz/static
ports:
- 80:80
- 443:443
restart: unless-stopped
networks:
- frontend
depends_on:
- ref_test_app
ref_test_app:
container_name: ref_test_app
image: reftest
build: ./ref-test
env_file:
- ./.env
ports:
- 5000
volumes:
- ./.security:/ref-test/.security
- ./ref-test/data:/ref-test/data
restart: unless-stopped
networks:
- frontend
- backend
depends_on:
- ref_test_db
- ref_test_postfix
ref_test_db: ref_test_db:
container_name: ref_test_db container_name: ref_test_db
image: mongo:5.0.4-focal image: mongo:5.0.4-focal
restart: unless-stopped restart: unless-stopped
volumes: volumes:
# - ./database/data:/data # Uncomment later when persistence is required. - ./database/data:/data/db
- ./database/initdb.d/:/docker-entrypoint-initdb.d/ - ./database/initdb.d/:/docker-entrypoint-initdb.d/
env_file: env_file:
- ./.env - ./.env
ports: ports:
- 27017:27017 - 27017
networks: networks:
- backend - backend
@ -22,10 +59,20 @@ services:
env_file: env_file:
- ./.env - ./.env
ports: ports:
- 127.0.0.1:25:25 - 25
networks: networks:
- backend - backend
ref_test_certbot:
container_name: ref_test_certbot
image: certbot/certbot:v1.21.0
volumes:
- ./certbot:/etc/letsencrypt
- ./src/html:/var/www/html
depends_on:
- ref_test_server
# command: certonly --webroot --webroot-path=/var/www/html --email (email) --agree-tos --no-eff-email -d (domain)
networks: networks:
frontend: frontend:
external: false external: false

View File

@ -0,0 +1,6 @@
# Certbot Renewal
location ^~ /.well-known/acme-challenge/ {
root /usr/share/nginx/html;
allow all;
default_type "text/plain";
}

View File

@ -0,0 +1,6 @@
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;

33
nginx/conf.d/default.conf Normal file
View File

@ -0,0 +1,33 @@
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;
}
}

View File

@ -0,0 +1,36 @@
upstream reftest {
server ref_test_app:5000;
}
server {
server_name domain_name;
listen 80;
listen [::]:80;
# Redirect to ssl
return 301 https://$host$request_uri;
}
server {
server_name domain_name;
listen 443 ssl http2;
listen [::]:443 ssl http2;
#SSL configuration
include /etc/nginx/ssl.conf;
include /etc/nginx/certbot-challenge.conf;
location ^~ /static/ {
include /etc/nginx/mime.types;
alias /usr/share/nginx/html/quiz/static/;
}
location ^~ /admin/static/ {
include /etc/nginx/mime.types;
alias /usr/share/nginx/html/admin/static/;
}
location / {
include /etc/nginx/conf.d/common-location.conf;
proxy_pass http://reftest;
}
}

26
nginx/fastcgi.conf Normal file
View File

@ -0,0 +1,26 @@
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param QUERY_STRING $query_string;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param DOCUMENT_URI $document_uri;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param SERVER_PROTOCOL $server_protocol;
fastcgi_param REQUEST_SCHEME $scheme;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param REMOTE_PORT $remote_port;
fastcgi_param SERVER_ADDR $server_addr;
fastcgi_param SERVER_PORT $server_port;
fastcgi_param SERVER_NAME $server_name;
# PHP only, required if PHP was built with --enable-force-cgi-redirect
fastcgi_param REDIRECT_STATUS 200;

25
nginx/fastcgi_params Normal file
View File

@ -0,0 +1,25 @@
fastcgi_param QUERY_STRING $query_string;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param DOCUMENT_URI $document_uri;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param SERVER_PROTOCOL $server_protocol;
fastcgi_param REQUEST_SCHEME $scheme;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param REMOTE_PORT $remote_port;
fastcgi_param SERVER_ADDR $server_addr;
fastcgi_param SERVER_PORT $server_port;
fastcgi_param SERVER_NAME $server_name;
# PHP only, required if PHP was built with --enable-force-cgi-redirect
fastcgi_param REDIRECT_STATUS 200;

98
nginx/mime.types Normal file
View File

@ -0,0 +1,98 @@
types {
text/html html htm shtml;
text/css css;
text/xml xml;
image/gif gif;
image/jpeg jpeg jpg;
application/javascript js;
application/atom+xml atom;
application/rss+xml rss;
text/mathml mml;
text/plain txt;
text/vnd.sun.j2me.app-descriptor jad;
text/vnd.wap.wml wml;
text/x-component htc;
image/png png;
image/svg+xml svg svgz;
image/tiff tif tiff;
image/vnd.wap.wbmp wbmp;
image/webp webp;
image/x-icon ico;
image/x-jng jng;
image/x-ms-bmp bmp;
font/woff woff;
font/woff2 woff2;
application/java-archive jar war ear;
application/json json;
application/mac-binhex40 hqx;
application/msword doc;
application/pdf pdf;
application/postscript ps eps ai;
application/rtf rtf;
application/vnd.apple.mpegurl m3u8;
application/vnd.google-earth.kml+xml kml;
application/vnd.google-earth.kmz kmz;
application/vnd.ms-excel xls;
application/vnd.ms-fontobject eot;
application/vnd.ms-powerpoint ppt;
application/vnd.oasis.opendocument.graphics odg;
application/vnd.oasis.opendocument.presentation odp;
application/vnd.oasis.opendocument.spreadsheet ods;
application/vnd.oasis.opendocument.text odt;
application/vnd.openxmlformats-officedocument.presentationml.presentation
pptx;
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
xlsx;
application/vnd.openxmlformats-officedocument.wordprocessingml.document
docx;
application/vnd.wap.wmlc wmlc;
application/wasm wasm;
application/x-7z-compressed 7z;
application/x-cocoa cco;
application/x-java-archive-diff jardiff;
application/x-java-jnlp-file jnlp;
application/x-makeself run;
application/x-perl pl pm;
application/x-pilot prc pdb;
application/x-rar-compressed rar;
application/x-redhat-package-manager rpm;
application/x-sea sea;
application/x-shockwave-flash swf;
application/x-stuffit sit;
application/x-tcl tcl tk;
application/x-x509-ca-cert der pem crt;
application/x-xpinstall xpi;
application/xhtml+xml xhtml;
application/xspf+xml xspf;
application/zip zip;
application/octet-stream bin exe dll;
application/octet-stream deb;
application/octet-stream dmg;
application/octet-stream iso img;
application/octet-stream msi msp msm;
audio/midi mid midi kar;
audio/mpeg mp3;
audio/ogg ogg;
audio/x-m4a m4a;
audio/x-realaudio ra;
video/3gpp 3gpp 3gp;
video/mp2t ts;
video/mp4 mp4;
video/mpeg mpeg mpg;
video/quicktime mov;
video/webm webm;
video/x-flv flv;
video/x-m4v m4v;
video/x-mng mng;
video/x-ms-asf asx asf;
video/x-ms-wmv wmv;
video/x-msvideo avi;
}

33
nginx/nginx.conf Normal file
View File

@ -0,0 +1,33 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
server_tokens off;
#gzip on;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/conf.d/sites-enabled/*.conf;
}

17
nginx/scgi_params Normal file
View File

@ -0,0 +1,17 @@
scgi_param REQUEST_METHOD $request_method;
scgi_param REQUEST_URI $request_uri;
scgi_param QUERY_STRING $query_string;
scgi_param CONTENT_TYPE $content_type;
scgi_param DOCUMENT_URI $document_uri;
scgi_param DOCUMENT_ROOT $document_root;
scgi_param SCGI 1;
scgi_param SERVER_PROTOCOL $server_protocol;
scgi_param REQUEST_SCHEME $scheme;
scgi_param HTTPS $https if_not_empty;
scgi_param REMOTE_ADDR $remote_addr;
scgi_param REMOTE_PORT $remote_port;
scgi_param SERVER_PORT $server_port;
scgi_param SERVER_NAME $server_name;

2
nginx/ssl.conf Normal file
View File

@ -0,0 +1,2 @@
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem; # managed by Certbot

17
nginx/uwsgi_params Normal file
View File

@ -0,0 +1,17 @@
uwsgi_param QUERY_STRING $query_string;
uwsgi_param REQUEST_METHOD $request_method;
uwsgi_param CONTENT_TYPE $content_type;
uwsgi_param CONTENT_LENGTH $content_length;
uwsgi_param REQUEST_URI $request_uri;
uwsgi_param PATH_INFO $document_uri;
uwsgi_param DOCUMENT_ROOT $document_root;
uwsgi_param SERVER_PROTOCOL $server_protocol;
uwsgi_param REQUEST_SCHEME $scheme;
uwsgi_param HTTPS $https if_not_empty;
uwsgi_param REMOTE_ADDR $remote_addr;
uwsgi_param REMOTE_PORT $remote_port;
uwsgi_param SERVER_PORT $server_port;
uwsgi_param SERVER_NAME $server_name;

2
ref-test/.dockerignore Normal file
View File

@ -0,0 +1,2 @@
env/
__pycache__/

View File

@ -1,5 +1,5 @@
FROM python:3.10-alpine FROM python:3.10-slim
WORKDIR /app WORKDIR /ref-test
COPY . . COPY . .
RUN pip install -r requirements.txt RUN pip install --upgrade pip && pip install -r requirements.txt
CMD [ "gunicorn", "-b", "0.0.0.0:5000", "app:app" ] CMD [ "gunicorn", "-b", "0.0.0.0:5000", "-w", "5", "wsgi:app" ]

View File

@ -1,13 +1,11 @@
from flask import Blueprint, render_template, request, session, redirect from flask import Blueprint, render_template, request, session, redirect
from flask.helpers import flash, url_for from flask.helpers import flash, url_for
from flask.json import jsonify from flask.json import jsonify
from .user.models import User from .models.users import User
from uuid import uuid4 from uuid import uuid4
from security.database import decrypt_find_one, encrypted_update from common.security.database import decrypt_find_one, encrypted_update
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from main import db
from .views import admin_account_required, disable_on_registration, login_required, disable_if_logged_in, get_id_from_cookie from .views import admin_account_required, disable_on_registration, login_required, disable_if_logged_in, get_id_from_cookie
auth = Blueprint( auth = Blueprint(
@ -21,7 +19,8 @@ auth = Blueprint(
@admin_account_required @admin_account_required
@login_required @login_required
def account(): def account():
from .forms import UpdateAccountForm from .models.forms import UpdateAccountForm
from main import db
form = UpdateAccountForm() form = UpdateAccountForm()
_id = get_id_from_cookie() _id = get_id_from_cookie()
user = decrypt_find_one(db.users, {'_id': _id}) user = decrypt_find_one(db.users, {'_id': _id})
@ -46,7 +45,7 @@ def account():
@admin_account_required @admin_account_required
@disable_if_logged_in @disable_if_logged_in
def login(): def login():
from .forms import LoginForm from .models.forms import LoginForm
form = LoginForm() form = LoginForm()
if request.method == 'GET': if request.method == 'GET':
return render_template('/admin/auth/login.html', form=form) return render_template('/admin/auth/login.html', form=form)
@ -72,7 +71,7 @@ def logout():
@auth.route('/register/', methods=['GET','POST']) @auth.route('/register/', methods=['GET','POST'])
@disable_on_registration @disable_on_registration
def register(): def register():
from .forms import RegistrationForm from .models.forms import RegistrationForm
form = RegistrationForm() form = RegistrationForm()
if request.method == 'GET': if request.method == 'GET':
return render_template('/admin/auth/register.html', form=form) return render_template('/admin/auth/register.html', form=form)
@ -93,7 +92,7 @@ def register():
@admin_account_required @admin_account_required
@disable_if_logged_in @disable_if_logged_in
def reset(): def reset():
from .forms import ResetPasswordForm from .models.forms import ResetPasswordForm
form = ResetPasswordForm() form = ResetPasswordForm()
if request.method == 'GET': if request.method == 'GET':
return render_template('/admin/auth/reset.html', form=form) return render_template('/admin/auth/reset.html', form=form)
@ -128,7 +127,7 @@ def reset_gateway(token1,token2):
@admin_account_required @admin_account_required
@disable_if_logged_in @disable_if_logged_in
def update_password_(): def update_password_():
from .forms import UpdatePasswordForm from .models.forms import UpdatePasswordForm
form = UpdatePasswordForm() form = UpdatePasswordForm()
if request.method == 'GET': if request.method == 'GET':
if 'reset_validated' not in session: if 'reset_validated' not in session:

View File

@ -1,8 +1,11 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField from flask_wtf.file import FileField, FileRequired, FileAllowed
from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField, IntegerField
from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional
from datetime import date, timedelta from datetime import date, timedelta
from .validators import value
class LoginForm(FlaskForm): class LoginForm(FlaskForm):
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)]) username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
@ -46,11 +49,14 @@ class UpdateAccountForm(FlaskForm):
class CreateTest(FlaskForm): class CreateTest(FlaskForm):
start_date = DateField('Start Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() ) start_date = DateField('Start Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() )
time_options = [
('none', 'None'),
('60', '1 hour'),
('90', '1 hour 30 minutes'),
('120', '2 hours')
]
expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) ) expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) )
time_limit = SelectField('Time Limit', choices=time_options) time_limit = SelectField('Time Limit')
dataset = SelectField('Question Dataset')
class UploadDataForm(FlaskForm):
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)])

View File

@ -0,0 +1,11 @@
from wtforms.validators import ValidationError
def value(min=0, max=None):
message = f'Value must be between {min} and {max}.'
def _length(form, field):
value = field.data or 0
if value < min or max != None and value > max:
raise ValidationError(message)
return _length

View File

@ -3,19 +3,23 @@ from datetime import datetime
from uuid import uuid4 from uuid import uuid4
from flask import flash, jsonify from flask import flash, jsonify
import secrets import secrets
import os
from json import dump, loads
from main import db from common.security import encrypt
from security import encrypt
class Test: class Test:
def __init__(self, _id=None, start_date=None, expiry_date=None, time_limit=None, creator=None):
def __init__(self, _id=None, start_date=None, expiry_date=None, time_limit=None, creator=None, dataset=None):
self._id = _id self._id = _id
self.start_date = start_date self.start_date = start_date
self.expiry_date = expiry_date self.expiry_date = expiry_date
self.time_limit = None if time_limit == 'none' or time_limit == '' else time_limit self.time_limit = None if time_limit == 'none' or time_limit == '' or time_limit == None else int(time_limit)
self.creator = creator self.creator = creator
self.dataset = dataset
def create(self): def create(self):
from main import app, db
test = { test = {
'_id': self._id, '_id': self._id,
'date_created': datetime.today(), 'date_created': datetime.today(),
@ -23,25 +27,34 @@ class Test:
'expiry_date': self.expiry_date, 'expiry_date': self.expiry_date,
'time_limit': self.time_limit, 'time_limit': self.time_limit,
'creator': encrypt(self.creator), 'creator': encrypt(self.creator),
'test_code': secrets.token_hex(6).upper() 'test_code': secrets.token_hex(6).upper(),
'dataset': self.dataset
} }
if db.tests.insert_one(test): if db.tests.insert_one(test):
dataset_file_path = os.path.join(app.config["DATA_FILE_DIRECTORY"],self.dataset)
with open(dataset_file_path, 'r') as dataset_file:
data = loads(dataset_file.read())
data['meta']['tests'].append(self._id)
with open(dataset_file_path, 'w') as dataset_file:
dump(data, dataset_file, indent=2)
flash(f'Created a new exam with Exam Code <strong>{self.render_test_code(test["test_code"])}</strong>.', 'success') flash(f'Created a new exam with Exam Code <strong>{self.render_test_code(test["test_code"])}</strong>.', 'success')
return jsonify({'success': test}), 200 return jsonify({'success': test}), 200
return jsonify({'error': f'Could not create exam. An error occurred.'}), 400 return jsonify({'error': f'Could not create exam. An error occurred.'}), 400
def add_time_adjustment(self, time_adjustment): def add_time_adjustment(self, time_adjustment):
code = { from main import db
'_id': uuid4().hex, user_code = secrets.token_hex(3).upper()
'user_code': secrets.token_hex(2).upper(), adjustment = {
'time_adjustment': time_adjustment user_code: time_adjustment
} }
if db.tests.find_one_and_update({'_id': self._id}, {'$push': {'time_adjustments': code}},upsert=False): if db.tests.find_one_and_update({'_id': self._id}, {'$set': {'time_adjustments': adjustment}},upsert=False):
return jsonify({'success': code}) flash(f'Time adjustment for {time_adjustment} minutes has been added. This can be enabled using the user code {user_code}.')
return jsonify({'success': adjustment})
return jsonify({'error': 'Failed to add the time adjustment. An error occurred.'}), 400 return jsonify({'error': 'Failed to add the time adjustment. An error occurred.'}), 400
def remove_time_adjustment(self, _id): def remove_time_adjustment(self, user_code):
if db.tests.find_one_and_update({'_id': self._id}, {'$pull': {'time_adjustments': {'_id': _id} }}): from main import db
if db.tests.find_one_and_update({'_id': self._id}, {'$unset': {f'time_adjustments.{user_code}': {}}}):
message = 'Time adjustment has been deleted.' message = 'Time adjustment has been deleted.'
flash(message, 'success') flash(message, 'success')
return jsonify({'success': message}) return jsonify({'success': message})
@ -54,13 +67,27 @@ class Test:
return test_code.replace('', '') return test_code.replace('', '')
def delete(self): def delete(self):
from main import app, db
test = db.tests.find_one({'_id': self._id})
if 'entries' in test:
if test['entries']:
return jsonify({'error': 'Cannot delete an exam that has entries submitted to it.'}), 400
if self.dataset is None:
self.dataset = db.tests.find_one({'_id': self._id})['dataset']
if db.tests.delete_one({'_id': self._id}): if db.tests.delete_one({'_id': self._id}):
dataset_file_path = os.path.join(app.config["DATA_FILE_DIRECTORY"],self.dataset)
with open(dataset_file_path, 'r') as dataset_file:
data = loads(dataset_file.read())
data['meta']['tests'].remove(self._id)
with open(dataset_file_path, 'w') as dataset_file:
dump(data, dataset_file, indent=2)
message = 'Deleted exam.' message = 'Deleted exam.'
flash(message, 'alert') flash(message, 'alert')
return jsonify({'success': message}), 200 return jsonify({'success': message}), 200
return jsonify({'error': f'Could not create exam. An error occurred.'}), 400 return jsonify({'error': f'Could not create exam. An error occurred.'}), 400
def update(self): def update(self):
from main import db
test = {} test = {}
updated = [] updated = []
if not self.start_date == '' and self.start_date is not None: if not self.start_date == '' and self.start_date is not None:
@ -70,7 +97,7 @@ class Test:
test['expiry_date'] = self.expiry_date test['expiry_date'] = self.expiry_date
updated.append('expiry date') updated.append('expiry date')
if not self.time_limit == '' and self.time_limit is not None: if not self.time_limit == '' and self.time_limit is not None:
test['time_limit'] = self.time_limit test['time_limit'] = int(self.time_limit)
updated.append('time limit') updated.append('time limit')
output = '' output = ''
if len(updated) == 0: if len(updated) == 0:

View File

@ -20,7 +20,7 @@ body {
padding-bottom: 40px; padding-bottom: 40px;
} }
.form-signin { .form-display {
width: 100%; width: 100%;
max-width: 420px; max-width: 420px;
padding: 15px; padding: 15px;
@ -132,16 +132,11 @@ table.dataTable {
width: 100%; width: 100%;
} }
.user-table-row { .table-row {
vertical-align: middle; vertical-align: middle;
} }
.user-row-actions { .row-actions {
text-align: center;
white-space: nowrap;
}
.test-row-actions {
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
} }
@ -153,8 +148,8 @@ table.dataTable {
text-align:center; text-align:center;
} }
.user-row-actions button { .row-actions button, .row-actions a {
margin: 0px 10px; margin: 0px 5px;
} }
#cookie-alert { #cookie-alert {
@ -163,7 +158,7 @@ table.dataTable {
#dismiss-cookie-alert { #dismiss-cookie-alert {
margin-top: 16px; margin-top: 16px;
width: 100%; width: fit-content;
} }
.alert-db-empty { .alert-db-empty {
@ -214,6 +209,33 @@ table.dataTable {
font-size: 20px; font-size: 20px;
} }
.form-upload {
margin: 2rem 0;
font-size: 14pt;
}
.result-action-buttons, .test-action {
margin: 5px auto;
width: fit-content;
}
.accordion-item {
background-color: unset;
}
/* Change Autocomplete styles in Chrome*/
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
transition: background-color 5000s ease-in-out 0s;
}
/* Fallback for Edge /* Fallback for Edge
-------------------------------------------------- */ -------------------------------------------------- */
@supports (-ms-ime-align: auto) { @supports (-ms-ime-align: auto) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -13,373 +13,54 @@ for(let i = 0; i< dropdownItems.length; i++) {
} }
} }
// Form Processing Scripts // General Post Method Form Processing Script
$('form[name=form-register]').submit(function(event) { $('form.form-post').submit(function(event) {
var $form = $(this); var $form = $(this);
var alert = document.getElementById('alert-box');
var data = $form.serialize(); var data = $form.serialize();
var url = $(this).attr('action');
alert.innerHTML = '' var rel_success = $(this).data('rel-success');
$.ajax({ $.ajax({
url: window.location.pathname, url: url,
type: 'POST', type: 'POST',
data: data, data: data,
dataType: 'json', dataType: 'json',
success: function(response) { success: function(response) {
window.location.href = "/admin/login/"; if (response.redirect_to) {
window.location.href = response.redirect_to;
}
else {
window.location.href = rel_success;
}
}, },
error: function(response) { error: function(response) {
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) { error_response(response);
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
} else if (response.responseJSON.error instanceof Array) {
for (var i = 0; i < response.responseJSON.error.length; i ++) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
}
}
} }
}); });
event.preventDefault(); event.preventDefault();
}); });
$('form[name=form-login]').submit(function(event) { // Form Upload Questions - Special case, needs to handle files.
$('form[name=form-upload-questions]').submit(function(event) {
var $form = $(this); var $form = $(this);
var alert = document.getElementById('alert-box'); var data = new FormData($form[0]);
var data = $form.serialize(); var file = $('input[name=data_file]')[0].files[0]
data.append('file', file)
alert.innerHTML = ''
$.ajax({ $.ajax({
url: window.location.pathname, url: window.location.pathname,
type: 'POST', type: 'POST',
data: data, data: data,
dataType: 'json', processData: false,
success: function(response) { contentType: false,
window.location.href = "/admin/dashboard/";
},
error: function(response) {
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
} else if (response.responseJSON.error instanceof Array) {
for (var i = 0; i < response.responseJSON.error.length; i ++) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
}
}
}
});
event.preventDefault();
});
$('form[name=form-reset]').submit(function(event) {
var $form = $(this);
var alert = document.getElementById('alert-box');
var data = $form.serialize();
alert.innerHTML = ''
$.ajax({
url: window.location.pathname,
type: 'POST',
data: data,
dataType: 'json',
success: function(response) {
window.location.href = "/admin/login/";
},
error: function(response) {
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
} else if (response.responseJSON.error instanceof Array) {
for (var i = 0; i < response.responseJSON.error.length; i ++) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
}
}
}
});
event.preventDefault();
});
$('form[name=form-update-password]').submit(function(event) {
var $form = $(this);
var alert = document.getElementById('alert-box');
var data = $form.serialize();
console.log(data)
alert.innerHTML = ''
$.ajax({
url: window.location.pathname,
type: 'POST',
data: data,
dataType: 'json',
success: function(response) {
window.location.href = "/admin/login/";
},
error: function(response) {
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
} else if (response.responseJSON.error instanceof Array) {
for (var i = 0; i < response.responseJSON.error.length; i ++) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
}
}
}
});
event.preventDefault();
});
$('form[name=form-create-user]').submit(function(event) {
var $form = $(this);
var alert = document.getElementById('alert-box');
var data = $form.serialize();
alert.innerHTML = ''
$.ajax({
url: window.location.pathname,
type: 'POST',
data: data,
dataType: 'json',
success: function(response) { success: function(response) {
window.location.reload(); window.location.reload();
}, },
error: function(response) { error: function(response) {
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) { error_response(response);
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
} else if (response.responseJSON.error instanceof Array) {
for (var i = 0; i < response.responseJSON.error.length; i ++) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
}
}
}
});
event.preventDefault();
});
$('form[name=form-delete-user]').submit(function(event) {
var $form = $(this);
var alert = document.getElementById('alert-box');
var data = $form.serialize();
alert.innerHTML = ''
$.ajax({
url: window.location.pathname,
type: 'POST',
data: data,
dataType: 'json',
success: function(response) {
window.location.href = '/admin/settings/users/';
},
error: function(response) {
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
} else if (response.responseJSON.error instanceof Array) {
for (var i = 0; i < response.responseJSON.error.length; i ++) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
}
}
}
});
event.preventDefault();
});
$('form[name=form-update-user]').submit(function(event) {
var $form = $(this);
var alert = document.getElementById('alert-box');
var data = $form.serialize();
alert.innerHTML = ''
$.ajax({
url: window.location.pathname,
type: 'POST',
data: data,
dataType: 'json',
success: function(response) {
window.location.href = '/admin/settings/users';
},
error: function(response) {
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
} else if (response.responseJSON.error instanceof Array) {
for (var i = 0; i < response.responseJSON.error.length; i ++) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
}
}
}
});
event.preventDefault();
});
$('form[name=form-update-account]').submit(function(event) {
var $form = $(this);
var alert = document.getElementById('alert-box');
var data = $form.serialize();
alert.innerHTML = ''
$.ajax({
url: window.location.pathname,
type: 'POST',
data: data,
dataType: 'json',
success: function(response) {
window.location.href = '/admin/dashboard/';
},
error: function(response) {
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
} else if (response.responseJSON.error instanceof Array) {
for (var i = 0; i < response.responseJSON.error.length; i ++) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
}
}
}
});
event.preventDefault();
});
$('.delete-test').click(function(event) {
_id = $(this).data('_id')
$.ajax({
url: `/admin/tests/delete/${_id}`,
type: 'GET',
success: function(response) {
window.location.href = '/admin/tests/';
},
error: function(response) {
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
} else if (response.responseJSON.error instanceof Array) {
for (var i = 0; i < response.responseJSON.error.length; i ++) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
}
}
} }
}); });
@ -387,48 +68,99 @@ $('.delete-test').click(function(event) {
}); });
// Edit and Delete Test Button Handlers // Edit and Delete Test Button Handlers
$('.test-action').click(function(event) {
$('form[name=form-create-test]').submit(function(event) { let _id = $(this).data('_id');
let action = $(this).data('action');
var $form = $(this); if (action == 'delete') {
var alert = document.getElementById('alert-box'); $.ajax({
var data = $form.serialize(); url: `/admin/tests/delete/`,
alert.innerHTML = '' type: 'POST',
data: JSON.stringify({'_id': _id}),
$.ajax({ contentType: 'application/json',
url: window.location.pathname, success: function(response) {
type: 'POST', window.location.href = '/admin/tests/';
data: data, },
dataType: 'json', error: function(response){
success: function(response) { error_response(response);
window.location.href = '/admin/tests/'; },
}, });
error: function(response) { } else if (action == 'edit') {
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) { window.location.href = `/admin/test/${_id}/`
alert.innerHTML = alert.innerHTML + ` } else if (action == 'close'){
<div class="alert alert-danger alert-dismissible fade show" role="alert"> $.ajax({
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i> url: `/admin/tests/close/`,
${response.responseJSON.error} type: 'POST',
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> data: JSON.stringify({'_id': _id}),
</div> contentType: 'application/json',
`; success: function(response) {
} else if (response.responseJSON.error instanceof Array) { $(window).scrollTop(0);
for (var i = 0; i < response.responseJSON.error.length; i ++) { window.location.reload();
alert.innerHTML = alert.innerHTML + ` },
<div class="alert alert-danger alert-dismissible fade show" role="alert"> error: function(response){
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i> error_response(response);
${response.responseJSON.error[i]} },
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> });
</div> }
`;
}
}
}
});
event.preventDefault(); event.preventDefault();
}); });
// Edit Dataset Button Handlers
$('.edit-question-dataset').click(function(event) {
var filename = $(this).data('filename');
var action = $(this).data('action');
var disabled = $(this).hasClass('disabled');
if ( !disabled ) {
$.ajax({
url: `/admin/settings/questions/${action}/`,
type: 'POST',
data: JSON.stringify({'filename': filename}),
contentType: 'application/json',
success: function(response) {
window.location.reload();
},
error: function(response){
error_response(response);
},
});
};
event.preventDefault();
});
function error_response(response) {
const $alert = $("#alert-box");
$alert.html('');
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
$alert.html(`
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`);
} else if (response.responseJSON.error instanceof Array) {
var output = ''
for (var i = 0; i < response.responseJSON.error.length; i ++) {
output += `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
$alert.html(output);
}
}
$alert.focus()
}
// Dismiss Cookie Alert // Dismiss Cookie Alert
$('#dismiss-cookie-alert').click(function(event){ $('#dismiss-cookie-alert').click(function(event){
@ -440,13 +172,76 @@ $('#dismiss-cookie-alert').click(function(event){
}, },
dataType: 'json', dataType: 'json',
success: function(response){ success: function(response){
console.log(response) console.log(response);
}, },
error: function(response){ error: function(response){
console.log(response) console.log(response);
} }
}) })
event.preventDefault() event.preventDefault();
}) })
// Script for Result Actions
$('.result-action-buttons').click(function(event){
var _id = $(this).data('_id');
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();
});

View File

@ -2,9 +2,9 @@
{% block content %} {% block content %}
<div class="form-container"> <div class="form-container">
<form name="form-update-account" class="form-signin"> <form name="form-update-account" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.home') }}">
{% include "admin/components/server-alerts.html" %} {% include "admin/components/server-alerts.html" %}
<h2 class="form-signin-heading">Update Your Account</h2> <h2 class="form-heading">Update Your Account</h2>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="form-label-group"> <div class="form-label-group">
Please confirm <strong>your current password</strong> before making any changes to your user account. Please confirm <strong>your current password</strong> before making any changes to your user account.

View File

@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="form-container"> <div class="form-container">
<form name="form-login" class="form-signin"> <form name="form-login" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.home') }}">
{% include "admin/components/server-alerts.html" %} {% include "admin/components/server-alerts.html" %}
<h2 class="form">Log In</h2> <h2 class="form">Log In</h2>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}

View File

@ -10,9 +10,9 @@
{% block content %} {% block content %}
<div class="form-container"> <div class="form-container">
<form name="form-register" action="" method="" class="form-signin"> <form name="form-register" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_auth.login') }}">
{% include "admin/components/server-alerts.html" %} {% include "admin/components/server-alerts.html" %}
<h2 class="form-signin-heading">Register an Account</h2> <h2 class="form-heading">Register an Account</h2>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="form-label-group"> <div class="form-label-group">
{{ form.username(class_="form-control", autofocus=true, placeholder="Username") }} {{ form.username(class_="form-control", autofocus=true, placeholder="Username") }}

View File

@ -2,9 +2,9 @@
{% block content %} {% block content %}
<div class="form-container"> <div class="form-container">
<form name="form-reset" class="form-signin"> <form name="form-reset" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_auth.login') }}">
{% include "admin/components/server-alerts.html" %} {% include "admin/components/server-alerts.html" %}
<h2 class="form-signin-heading">Reset Password</h2> <h2 class="form-heading">Reset Password</h2>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="form-label-group"> <div class="form-label-group">
{{ form.username(class_="form-control", autofocus=true, placeholder="Enter Username") }} {{ form.username(class_="form-control", autofocus=true, placeholder="Enter Username") }}

View File

@ -2,9 +2,9 @@
{% block content %} {% block content %}
<div class="form-container"> <div class="form-container">
<form name="form-update-password" class="form-signin"> <form name="form-update-password" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_auth.login') }}">
{% include "admin/components/server-alerts.html" %} {% include "admin/components/server-alerts.html" %}
<h2 class="form-signin-heading">Update Password</h2> <h2 class="form-heading">Update Password</h2>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="form-label-group"> <div class="form-label-group">
{{ form.password(class_="form-control", placeholder="Password") }} {{ form.password(class_="form-control", placeholder="Password") }}

View File

@ -18,6 +18,7 @@
{% block datatable_css %} {% block datatable_css %}
{% endblock %} {% endblock %}
<title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title> <title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title>
{% include "admin/components/og-meta.html" %}
</head> </head>
<body class="bg-light"> <body class="bg-light">
@ -32,8 +33,10 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
<footer class="container site-footer"> <footer class="container site-footer mt-5">
{% include "admin/components/footer.html" %} {% block footer %}
{% include "admin/components/footer.html" %}
{% endblock %}
</footer> </footer>
<!-- JQuery, Popper, and Bootstrap js dependencies --> <!-- JQuery, Popper, and Bootstrap js dependencies -->
@ -53,11 +56,24 @@
crossorigin="anonymous" crossorigin="anonymous"
></script> ></script>
<!-- Custom js --> <!-- Custom js -->
<script type="text/javascript">
var csrf_token = "{{ csrf_token() }}";
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
xhr.setRequestHeader("X-CSRFToken", csrf_token);
}
}
});
</script>
<script <script
type="text/javascript" type="text/javascript"
src="{{ url_for('.static', filename='js/script.js') }}" src="{{ url_for('.static', filename='js/script.js') }}"
></script> ></script>
{% block datatable_scripts %} {% block datatable_scripts %}
{% endblock %} {% endblock %}
{% block custom_data_script %}
{% endblock %}
</body> </body>
</html> </html>

View File

@ -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.name.surname}}, {{ entry.name.first_name }}
</h2>
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Email Address</h5>
</div>
{{ entry.email }}
</li>
{% if entry['club'] %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Club</h5>
</div>
{{ entry.club }}
</li>
{% endif %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Exam Code</h5>
</div>
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
</li>
{% if entry['user_code'] %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">User Code</h5>
</div>
{{ entry.user_code }}
</li>
{% endif %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Start Time</h5>
</div>
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') }}
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Submission Time</h5>
{% if entry.status == 'late' %}
<span class="badge bg-danger">Late</span>
{% endif %}
</div>
{{ entry.submission_time.strftime('%d %b %Y %H:%M:%S') }}
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Score</h5>
</div>
{{ entry.results.score }}&percnt;
</li>
<li class="list-group-item list-group-item-action {% if entry.results.grade == 'fail' %}list-group-item-danger {% elif entry.results.grade == 'merit' %} list-group-item-success {% endif %}">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Grade</h5>
</div>
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:]}}
</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 %}

View File

@ -1 +1 @@
<div id="alert-box"></div> <div id="alert-box" tabindex="-1"></div>

View File

@ -6,6 +6,7 @@
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/fixedheader/3.2.0/css/fixedHeader.bootstrap5.min.css"/> <link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/fixedheader/3.2.0/css/fixedHeader.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/keytable/2.6.4/css/keyTable.bootstrap5.min.css"/> <link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/keytable/2.6.4/css/keyTable.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.bootstrap5.min.css"/> <link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/searchbuilder/1.3.0/css/searchBuilder.dataTables.min.css"/>
{% endblock %} {% endblock %}
{% block datatable_scripts %} {% block datatable_scripts %}
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jszip/2.5.0/jszip.min.js"></script> <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jszip/2.5.0/jszip.min.js"></script>
@ -23,5 +24,5 @@
<script type="text/javascript" src="https://cdn.datatables.net/keytable/2.6.4/js/dataTables.keyTable.min.js"></script> <script type="text/javascript" src="https://cdn.datatables.net/keytable/2.6.4/js/dataTables.keyTable.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script> <script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/responsive.bootstrap5.js"></script> <script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/responsive.bootstrap5.js"></script>
{% block custom_data_script %}{% endblock %} <script type="text/javascript" src="https://cdn.datatables.net/searchbuilder/1.3.0/js/dataTables.searchBuilder.min.js"></script>
{% endblock %} {% endblock %}

View File

@ -1,2 +1,2 @@
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at the repository under an MIT License.</p> <p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&rsquo;s personal GIT repository</a> under an MIT License.</p>
<p>All questions in the test are &copy; The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p> <p>All questions in the test are &copy; The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>

View File

@ -1,6 +1,6 @@
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark"> <nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
<div class="container"> <div class="container">
<a href="{{ url_for('admin_views.home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a> <a href="{{ url_for('admin_views.home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a>
<button <button
class="navbar-toggler" class="navbar-toggler"
type="button" type="button"
@ -24,7 +24,7 @@
<a href="{{ url_for('results._results') }}" id="link-results" class="nav-link">View Results</a> <a href="{{ url_for('results._results') }}" id="link-results" class="nav-link">View Results</a>
</li> </li>
<li class="nav-item" id="nav-tests"> <li class="nav-item" id="nav-tests">
<a href="{{ url_for('admin_views.tests') }}" id="link-tests" class="nav-link">Manage Tests</a> <a href="{{ url_for('admin_views.tests') }}" id="link-tests" class="nav-link">Manage Exams</a>
</li> </li>
<li class="nav-item dropdown" id="nav-settings"> <li class="nav-item dropdown" id="nav-settings">
<a <a
@ -42,10 +42,10 @@
aria-labelledby="dropdown-account" aria-labelledby="dropdown-account"
> >
<li> <li>
<a href="{{ url_for('admin_views.users') }}" id="link-users" class="dropdown-item">Manage Users</a> <a href="{{ url_for('admin_views.users') }}" id="link-users" class="dropdown-item">Users</a>
</li> </li>
<li> <li>
<a href="{{ url_for('admin_views.questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a> <a href="{{ url_for('admin_views.questions') }}" id="link-questions" class="dropdown-item">Question Datasets</a>
</li> </li>
</ul> </ul>
</li> </li>

View File

@ -0,0 +1,17 @@
<meta name="description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
<meta property="og:locale" content="en_UK" />
<meta property="og:type" content="website" />
<meta property="og:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
<meta property="og:url" content="{{ url_for(request.endpoint, _external = True, **request.view_args) }}" />
<meta property="og:site_name" content="Scottish Korfball Association Referee Theory Exam" />
<meta property="og:image" content="{{ url_for('static', filename='favicon.png', _external = True) }}" />
<meta property="og:image:alt" content="Logo of the SKA Refereeing Exam App" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
<meta name="twitter:image" content="{{ url_for('static', filename='favicon.png', _external = True) }}" />
<meta name="twitter:image:alt" content="Logo of the SKA Refereeing Exam App" />
<meta name="twitter:creator" content="@viveksantayana" />
<meta name="twitter:site" content="@viveksantayana" />
<meta name="theme-color" content="#343a40" />

View File

@ -25,7 +25,9 @@
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert"> <div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i> <i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
{{ message|safe }} {{ message|safe }}
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button> <div class="d-flex justify-content-center w-100">
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
</div>
</div> </div>
{% set cookie_flash_flag.value = True %} {% set cookie_flash_flag.value = True %}
{% endif %} {% endif %}

View File

@ -2,4 +2,147 @@
{% block content %} {% block content %}
<h1>Dashboard</h1> <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_views.view_test', _id=test._id) }}">{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}</a>
</td>
<td>
{{ test.expiry_date.strftime('%d %b %Y') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('admin_views.tests', filter='active') }}" class="btn btn-primary">View Exams</a>
{% else %}
<div class="alert alert-primary">
There are currently no active exams.
</div>
<a href="{{ url_for('admin_views.tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
{% 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_views.view_entry', _id=result._id) }}">{{ result.name.surname }}, {{ result.name.first_name }}</a>
</td>
<td>
{{ result.submission_time.strftime('%d %b %Y %H:%M') }}
</td>
<td>
{{ result.percent }}&percnt; ({{ result.results.grade }})
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('admin_views.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_views.view_test', _id=test._id) }}">{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}</a>
</td>
<td>
{{ test.expiry_date.strftime('%d %b %Y') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('admin_views.tests', filter='scheduled') }}" class="btn btn-primary">View Exams</a>
{% else %}
<div class="alert alert-primary">
There are currently no upcoming exams.
</div>
<a href="{{ url_for('admin_views.tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
{% endif %}
</div>
</div>
</div>
<div class="col-sm">
<div class="card m-3">
<div class="card-body">
<h5 class="card-title">Help</h5>
<p class="card-text">This web app was developed by Vivek Santayana. If there are any issues with the app, any bugs you need to report, or any features you would like to request, please feel free to <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues">open an issue at the Git Repository</a>.</p>
<a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues" class="btn btn-primary">Open an Issue</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,188 @@
{% extends "admin/components/base.html" %}
{% block title %} SKA Referee Test | Detailed Results {% endblock %}
{% block content %}
{% include "admin/components/client-alerts.html" %}
<h1>Exam Results</h1>
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-6">
<ul class="list-group">
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Candidate</h5>
</div>
<h2>
{{ entry.name.surname }}, {{ entry.name.first_name }}
</h2>
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Email Address</h5>
</div>
{{ entry.email }}
</li>
{% if entry['club'] %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Club</h5>
</div>
{{ entry.club }}
</li>
{% endif %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Exam Code</h5>
</div>
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
</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 'start_time' in entry %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Start Time</h5>
</div>
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') }}
</li>
{% endif %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Submission Time</h5>
{% if entry.status == 'late' %}
<span class="badge bg-danger">Late</span>
{% endif %}
</div>
{% if 'submission_time' in entry %}
{{ entry.submission_time.strftime('%d %b %Y %H:%M:%S') }}
{% else %}
Incomplete
{% endif %}
</li>
{% if 'results' in entry %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Score</h5>
</div>
{{ entry.results.score }}&percnt;
</li>
<li class="list-group-item list-group-item-action {% if entry.results.grade == 'fail' %}list-group-item-danger {% elif entry.results.grade == 'merit' %} list-group-item-success {% endif %}">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Grade</h5>
</div>
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:]}}
</li>
{% endif %}
</ul>
{% if 'results' in entry %}
<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.results.tags.items() %}
<tr>
<td>
{{ tag }}
</td>
<td>
{{ scores.scored }}
</td>
<td>
{{scores.max}}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="by-question">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#by-question-breakdown" aria-expanded="false" aria-controls="by-question-breakdown">
View All Answers
</button>
</h2>
<div id="by-question-breakdown" class="accordion-collapse collapse" aria-labelledby="by-question" data-bs-parent="#results-breakdown">
<div class="accordion-body">
<table class="table table-striped">
<thead>
<tr>
<th>
Question
</th>
<th>
Answer
</th>
</tr>
</thead>
<tbody>
{% for question, answer in entry.answers.items() %}
<tr>
<td>
{{ question }}
</td>
<td>
{{ answer }}
{% if not correct[question] == answer %}
<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 %}

View File

@ -1 +1,138 @@
{% extends "admin/components/base.html" %} {% 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.name.surname }}, {{ entry.name.first_name }}
</td>
<td>
{% if 'club' in entry %}
{{ entry.club }}
{% endif %}
</td>
<td>
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
</td>
<td>
{% if 'status' in entry %}
{{ entry.status }}
{% endif %}
</td>
<td>
{% if 'submission_time' in entry %}
{{ entry.submission_time.strftime('%d %b %Y') }}
{% endif %}
</td>
<td>
{% if 'results' in entry %}
{{ entry.results.score }}&percnt;
{% endif %}
</td>
<td>
{% if 'results' in entry %}
{{ entry.results.grade }}
{% endif %}
</td>
<td class="row-actions">
<a
href="{{ url_for('admin_views.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 %}

View File

@ -2,9 +2,9 @@
{% block content %} {% block content %}
<div class="form-container"> <div class="form-container">
<form name="form-delete-user" class="form-signin"> <form name="form-delete-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.users') }}">
{% include "admin/components/server-alerts.html" %} {% include "admin/components/server-alerts.html" %}
<h2 class="form-signin-heading">Delete User &lsquo;{{ user.username }}&rsquo;?</h2> <h2 class="form-heading">Delete User &lsquo;{{ user.username }}&rsquo;?</h2>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<p>This action cannot be undone. Deleting an account will mean {{ user.username }} will no longer be able to log in to the admin console.</p> <p>This action cannot be undone. Deleting an account will mean {{ user.username }} will no longer be able to log in to the admin console.</p>
<p>Are you sure you want to proceed?</p> <p>Are you sure you want to proceed?</p>

View File

@ -1 +1,93 @@
{% extends "admin/components/base.html" %} {% 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._id == get_id_from_cookie() %}
{{ url_for('admin_auth.account') }}
{% else %}
{{ url_for('admin_views.update_user', _id=user._id) }}
{% endif%}
">{{ user.username }}</a>
</td>
<td>
<a href="mailto:{{ user.email }}">{{ user.email }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('admin_views.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>
File Name
</th>
<th>
Exams
</th>
</tr>
</thead>
<tbody>
{% for dataset in datasets %}
<tr>
<td>
{{ dataset.filename }}
</td>
<td>
{{ dataset.use }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('admin_views.questions') }}" class="btn btn-primary">Manage Datasets</a>
{% else %}
<div class="alert alert-primary">
There are currently no question datasets uploaded.
</div>
<a href="{{ url_for('admin_views.questions') }}" class="btn btn-primary">Upload Dataset</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1 +1,127 @@
{% extends "admin/components/base.html" %} {% 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">
File Name
</th>
<th data-priority="2">
Uploaded
</th>
<th data-priority="3">
Author
</th>
<th data-priority="3">
Exams
</th>
<th data-priority="1">
Actions
</th>
</tr>
</thead>
<tbody>
{% for element in data %}
<tr class="table-row">
<td>
{% if element.filename == 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.filename }}
</td>
<td>
{{ element.timestamp.strftime('%d %b %Y') }}
</td>
<td>
{{ element.author }}
</td>
<td>
{{ element.use }}
</td>
<td class="row-actions">
<a
href="#"
class="btn btn-primary edit-question-dataset {% if element.filename == default %}disabled{% endif %}"
data-filename="{{ element.filename }}"
data-action="default"
title="Make Default"
>
<i class="bi bi-file-earmark-text-fill button-icon"></i>
</button>
<a
href="#"
class="btn btn-danger edit-question-dataset {% if element.filename == default %}disabled{% endif %}"
data-filename="{{ element.filename }}"
data-action="delete"
title="Delete Dataset"
>
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-primary alert-db-empty">
<i class="bi bi-info-circle-fill" aria-title="Alert" title="Alert"></i>
There are no question datasets uploaded. Please use the panel below to upload a new question dataset.
</div>
{% endif %}
<div class="form-container">
<form name="form-upload-questions" class="form-display" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="" enctype="multipart/form-data">
<h2 class="form-heading">Upload Question Dataset</h2>
{{ form.hidden_tag() }}
<div class="form-upload">
{{ form.data_file() }}
</div>
<div class="form-check">
{{ form.default(class_="form-check-input") }}
{{ form.default.label }}
</div>
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<button title="Create User" class="btn btn-md btn-success btn-block" type="submit">
<i class="bi bi-file-earmark-arrow-up-fill button-icon"></i>
Upload Dataset
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}
{% if data %}
{% block custom_data_script %}
<script>
$(document).ready(function() {
$('#question-datasets-table').DataTable({
'columnDefs': [
{'sortable': false, 'targets': [0,5]},
{'searchable': false, 'targets': [0,4,5]}
],
'order': [[2, 'desc'], [3, 'asc']],
'responsive': 'true',
'fixedHeader': 'true',
});
} );
$('#question-datasets-table').show();
$(window).trigger('resize');
</script>
{% endblock %}
{% endif %}

View File

@ -2,9 +2,9 @@
{% block content %} {% block content %}
<div class="form-container"> <div class="form-container">
<form name="form-update-user" class="form-signin"> <form name="form-update-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.users') }}">
{% include "admin/components/server-alerts.html" %} {% include "admin/components/server-alerts.html" %}
<h2 class="form-signin-heading">Update User &lsquo;{{ user.username }}&rsquo;</h2> <h2 class="form-heading">Update User &lsquo;{{ user.username }}&rsquo;</h2>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="form-label-group"> <div class="form-label-group">
{{ form.email(class_="form-control", placeholder="Email Address", value = user.email) }} {{ form.email(class_="form-control", placeholder="Email Address", value = user.email) }}

View File

@ -21,7 +21,7 @@
</thead> </thead>
<tbody> <tbody>
{% for user in users %} {% for user in users %}
<tr class="user-table-row"> <tr class="table-row">
<td> <td>
{% if user._id == get_id_from_cookie() %} {% if user._id == get_id_from_cookie() %}
<div class="text-success" title="Current User"> <div class="text-success" title="Current User">
@ -37,7 +37,7 @@
<td> <td>
{{ user.email }} {{ user.email }}
</td> </td>
<td class="user-row-actions"> <td class="row-actions">
<a <a
href=" href="
{% if not user._id == get_id_from_cookie() %} {% if not user._id == get_id_from_cookie() %}
@ -71,8 +71,8 @@
</tbody> </tbody>
</table> </table>
<div class="form-container"> <div class="form-container">
<form name="form-create-user" class="form-signin"> <form name="form-create-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="">
<h2 class="form-signin-heading">Create User</h2> <h2 class="form-heading">Create User</h2>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="form-label-group"> <div class="form-label-group">
{{ form.username(class_="form-control", placeholder="Enter Username") }} {{ form.username(class_="form-control", placeholder="Enter Username") }}

View File

@ -0,0 +1,184 @@
{% 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>
{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}
</h2>
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Dataset</h5>
</div>
{{ test.dataset }}
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Created By</h5>
</div>
{{ test.creator }}
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Date Created</h5>
</div>
{{ test.date_created.strftime('%d %b %Y') }}
</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') }}
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Expiry Date</h5>
</div>
{{ test.expiry_date.strftime('%d %b %Y') }}
</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 'entries' in test and 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_views.view_entry', _id=entry) }}" >Entry {{ loop.index }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{% if 'time_adjustments' in test and test.time_adjustments|length > 0 %}
<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.time_adjustments.items() %}
<tr>
<td>
{{ key }}
</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 Username") }}
{{ form.time.label }}
</div>
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<button title="Add Time Adjustment" class="btn btn-md btn-success btn-block" type="submit">
<i class="bi bi-clock-history button-icon"></i>
Add Time Adjustment
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
{% endif %}
</div>
</ul>
<div class="row">
{% include "admin/components/client-alerts.html" %}
</div>
<div class="container justify-content-center">
<div class="row">
<a href="#" class="btn btn-warning test-action" data-action="close" data-_id="{{ test._id }}">
<i class="bi bi-hourglass button-icon"></i>
Close Exam
</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 %}

View File

@ -1,6 +1,7 @@
{% extends "admin/components/datatable.html" %} {% extends "admin/components/datatable.html" %}
{% block title %} SKA Referee Test | Manage Exams {% endblock %} {% block title %} SKA Referee Test | Manage Exams {% endblock %}
{% block content %} {% block content %}
{% include "admin/components/client-alerts.html" %}
<h1>Manage Exams</h1> <h1>Manage Exams</h1>
{% include "admin/components/secondary-navs/tests.html" %} {% include "admin/components/secondary-navs/tests.html" %}
<h2>{{ display_title }}</h2> <h2>{{ display_title }}</h2>
@ -30,7 +31,7 @@
</thead> </thead>
<tbody> <tbody>
{% for test in tests %} {% for test in tests %}
<tr class="user-table-row"> <tr class="table-row">
<td> <td>
{{ test.start_date.strftime('%d %b %Y') }} {{ test.start_date.strftime('%d %b %Y') }}
</td> </td>
@ -43,33 +44,35 @@
<td> <td>
{% if test.time_limit == None -%} {% if test.time_limit == None -%}
None None
{% elif test.time_limit == '60' -%} {% elif test.time_limit == 60 -%}
1 hour 1 hour
{% elif test.time_limit == '90' -%} {% elif test.time_limit == 90 -%}
1 hour 30 min 1 hour 30 min
{% elif test.time_limit == '120' -%} {% elif test.time_limit == 120 -%}
2 hours 2 hours
{% else -%} {% else -%}
{{ test.time_limit }} {{ test.time_limit }}
{% endif %} {% endif %}
</td> </td>
<td> <td>
{{ test.attempts|length }} {{ test.entries|length }}
</td> </td>
<td class="test-row-actions"> <td class="row-actions">
<a <a
href="#" href="#"
class="btn btn-primary edit-test" class="btn btn-primary test-action"
data-_id="{{test._id}}" data-_id="{{test._id}}"
title="Edit Exam" title="Edit Exam"
data-action="edit"
> >
<i class="bi bi-file-earmark-text-fill button-icon"></i> <i class="bi bi-file-earmark-text-fill button-icon"></i>
</a> </a>
<a <a
href="#" href="#"
class="btn btn-danger delete-test" class="btn btn-danger test-action"
data-_id="{{test._id}}" data-_id="{{test._id}}"
title="Delete Exam" title="Delete Exam"
data-action="delete"
> >
<i class="bi bi-file-earmark-excel-fill button-icon"></i> <i class="bi bi-file-earmark-excel-fill button-icon"></i>
</button> </button>
@ -86,8 +89,8 @@
{% endif %} {% endif %}
{% if form %} {% if form %}
<div class="form-container"> <div class="form-container">
<form name="form-create-test" class="form-signin"> <form name="form-create-test" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="/admin/tests/">
<h2 class="form-signin-heading">Create Exam</h2> <h2 class="form-heading">Create Exam</h2>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="form-date-input"> <div class="form-date-input">
{{ form.start_date(placeholder="Enter Start Date", class_ = "datepicker") }} {{ form.start_date(placeholder="Enter Start Date", class_ = "datepicker") }}
@ -101,6 +104,10 @@
{{ form.time_limit(placeholder="Select Time Limit") }} {{ form.time_limit(placeholder="Select Time Limit") }}
{{ form.time_limit.label }} {{ form.time_limit.label }}
</div> </div>
<div class="form-select-input">
{{ form.dataset(placeholder="Select Question Dataset") }}
{{ form.dataset.label }}
</div>
{% include "admin/components/client-alerts.html" %} {% include "admin/components/client-alerts.html" %}
<div class="container form-submission-button"> <div class="container form-submission-button">
<div class="row"> <div class="row">
@ -153,7 +160,7 @@
}); });
// $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') --> // $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') -->
} ); } );
$('#test-table').show(); $('#active-test-table').show();
$(window).trigger('resize'); $(window).trigger('resize');
</script> </script>
{% endblock %} {% endblock %}

View File

@ -1,185 +0,0 @@
from flask import flash, make_response, Response
from flask.helpers import url_for
from flask.json import jsonify
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import redirect
from flask_mail import Message
import secrets
from security import encrypt, decrypt
from security.database import decrypt_find_one, encrypted_update
from datetime import datetime, timedelta
from main import db, mail
class User:
def __init__(self, _id=None, username=None, password=None, email=None, remember=False):
self._id = _id
self.username = username
self.email = email
self.password = password
self.remember = remember
def start_session(self, resp:Response):
resp.set_cookie(
key = '_id',
value = self._id,
max_age = timedelta(days=14) if self.remember else 'Session',
path = '/',
expires = datetime.utcnow() + timedelta(days=14) if self.remember else 'Session'
)
if self.remember:
resp.set_cookie (
key = 'remember',
value = 'True',
max_age = timedelta(days=14),
path = '/',
expires = datetime.utcnow() + timedelta(days=14)
)
def register(self):
from ..views import get_id_from_cookie
user = {
'_id': self._id,
'email': encrypt(self.email),
'password': generate_password_hash(self.password, method='sha256'),
'username': encrypt(self.username)
}
if decrypt_find_one(db.users, { 'username': self.username }):
return jsonify({ 'error': f'Username {self.username} is not available.' }), 400
if db.users.insert_one(user):
flash(f'User {self.username} has been created successfully. You may now use it to log in and administer the tests.', 'success')
resp = make_response(jsonify(user), 200)
if not get_id_from_cookie:
self.start_session(resp)
return resp
return jsonify({ 'error': f'Registration failed. An error occurred.' }), 400
def login(self):
user = decrypt_find_one( db.users, { 'username': self.username })
if not user:
return jsonify({ 'error': f'Username {self.username} does not exist.' }), 401
if not check_password_hash( user['password'], self.password ):
return jsonify({ 'error': f'The password you entered is incorrect.' }), 401
resp = make_response(jsonify({ 'success': f'Successfully logged in user {self.username}.' }), 200)
self._id = user['_id']
self.start_session(resp)
return resp
def logout(self):
resp = make_response(redirect(url_for('admin_auth.login')))
resp.set_cookie(
key = '_id',
value = '',
max_age = timedelta(days=-1),
path = '/',
expires= datetime.utcnow() + timedelta(days=-1)
)
resp.set_cookie (
key = 'cookie_consent',
value = 'True',
max_age = 'Session',
path = '/',
expires = 'Session'
)
resp.set_cookie (
key = 'remember',
value = 'True',
max_age = timedelta(days=-1),
path = '/',
expires = datetime.utcnow() + timedelta(days=-1)
)
flash('You have been logged out. All cookies pertaining to your account have been deleted. Have a nice day.', 'alert')
return resp
def reset_password(self):
user = decrypt_find_one(db.users, { 'username': self.username })
if not user:
return jsonify({ 'error': f'Username {self.username} does not exist.' }), 401
if not decrypt(user['email']) == self.email:
return jsonify({ 'error': f'The email address {self.email} does not match the user account {self.username}.' }), 401
new_password = secrets.token_hex(12)
reset_token = secrets.token_urlsafe(16)
verification_token = secrets.token_urlsafe(16)
user['password'] = generate_password_hash(new_password, method='sha256')
if encrypted_update( { 'username': self.username }, { '$set': {'password': user['password'], 'reset_token': reset_token, 'verification_token': verification_token } } ):
flash(f'Your password has been reset. Instructions to recover your account have been sent to {self.email}. Please be sure to check your spam folder in case you have not received the email.', 'alert')
email = Message(
subject = 'RefTest | Password Reset',
recipients = [self.email],
body = f"""
Hello {user['username']}, \n\n
This email was generated because we received a request to reset the password for your administrator account for the SKA RefTest app.\n\n
If you did not make this request, please ignore this email.\n\n
If you did make this request, then you have two options to recover your account.\n\n
For the time being, your password has been reset to the following:\n\n
{new_password}\n\n
You may use this to log back in to your account, and subsequently change your password to something more suitable.\n\n
Alternatively, you may visit the following private link using your unique token to override your password. Copy and paste the following link in a web browser. Please note that this token is only valid once:\n\n
{url_for('admin_auth.reset_gateway', token1 = reset_token, token2 = verification_token, _external = True)}\n\n
Have a nice day.
""",
html = f"""
<p>Hello {user['username']},</p>
<p>This email was generated because we received a request to reset the password for your administrator account for the SKA RefTest app. </p>
<p>If you did not make this request, please ignore this email.</p>
<p>If you did make this request, then you have two options to recover your account.</p>
<p>For the time being, your password has been reset to the following:</p>
<strong>{new_password}</strong>
<p>You may use this to log back in to your account, and subsequently change your password to something more suitable.</p>
<p>Alternatively, you may visit the following private link using your unique token to override your password. Click on the following link, or copy and paste it into a browser. <strong>Please note that this token is only valid once</strong>:</p>
<p><a href='{url_for('admin_auth.reset_gateway', token1 = reset_token, token2 = verification_token, _external = True)}'>{url_for('admin_auth.reset_gateway', token1 = reset_token, token2 = verification_token, _external = True)}</a></p>
<p>Have a nice day.</p>
"""
)
mail.send(email)
return jsonify({ 'success': 'Password reset request has been processed.'}), 200
def update(self):
from ..views import get_id_from_cookie
retrieved_user = decrypt_find_one(db.users, { '_id': self._id })
if not retrieved_user:
return jsonify({ 'error': f'User {retrieved_user["username"]} does not exist.' }), 401
user = {}
updated = []
if not self.email == '' and self.email is not None:
user['email'] = self.email
updated.append('email')
if not self.password == '' and self.password is not None:
user['password'] = generate_password_hash(self.password, method='sha256')
updated.append('password')
output = ''
if len(updated) == 0:
flash(f'There were no changes requested for your account.', 'alert'), 200
return jsonify({'success': 'There were no changes requested for your account.'}), 200
elif len(updated) == 1:
output = updated[0]
elif len(updated) == 2:
output = ' and '.join(updated)
elif len(updated) > 2:
output = updated[0]
for index in range(1,len(updated)):
if index < len(updated) - 2:
output = ', '.join([output, updated[index]])
elif index == len(updated) - 2:
output = ', and '.join([output, updated[index]])
else:
output = ''.join([output, updated[index]])
encrypted_update(db.users, {'_id': self._id}, { '$set': user })
if self._id == get_id_from_cookie():
_output = 'Your '
elif retrieved_user['username'][-1] == 's':
_output = '&rsquo;'.join([retrieved_user['username'], ''])
else:
_output = '&rsquo;'.join([retrieved_user['username'], 's'])
_output = f'{_output} {output} {"has" if len(updated) == 1 else "have"} been updated.'
flash(_output)
return jsonify({'success': _output}), 200
def delete(self):
retrieved_user = decrypt_find_one(db.users, { '_id': self._id })
if not retrieved_user:
return jsonify({ 'error': f'User does not exist.' }), 401
db.users.find_one_and_delete({'_id': self._id})
flash(f'User {retrieved_user["username"]} has been deleted.')
return jsonify({'success': f'User {retrieved_user["username"]} has been deleted.'}), 200

View File

@ -1,17 +1,20 @@
from flask import Blueprint, render_template, flash, redirect, request, jsonify, abort from flask import Blueprint, render_template, flash, redirect, request, jsonify, abort, session
from flask.helpers import url_for from flask.helpers import url_for
from functools import wraps from functools import wraps
from datetime import datetime, timedelta
import os
from glob import glob
from json import loads
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from security.database import decrypt_find, decrypt_find_one from common.security.database import decrypt_find, decrypt_find_one
from .user.models import User from .models.users import User
from flask_mail import Message from flask_mail import Message
from main import db
from uuid import uuid4 from uuid import uuid4
import secrets import secrets
from main import mail from datetime import datetime, date
from datetime import datetime, date, timedelta from .models.tests import Test
from .models import Test from common.data_tools import get_default_dataset, get_time_options, available_datasets, get_datasets, get_correct_answers
views = Blueprint( views = Blueprint(
'admin_views', 'admin_views',
@ -23,6 +26,8 @@ views = Blueprint(
def admin_account_required(function): def admin_account_required(function):
@wraps(function) @wraps(function)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
from main import db
from main import db
if not db.users.find_one({}): if not db.users.find_one({}):
flash('No administrator accounts have been registered. Please register an administrator account.', 'alert') flash('No administrator accounts have been registered. Please register an administrator account.', 'alert')
return redirect(url_for('admin_auth.register')) return redirect(url_for('admin_auth.register'))
@ -32,6 +37,7 @@ def admin_account_required(function):
def disable_on_registration(function): def disable_on_registration(function):
@wraps(function) @wraps(function)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
from main import db
if db.users.find_one({}): if db.users.find_one({}):
return abort(404) return abort(404)
return function(*args, **kwargs) return function(*args, **kwargs)
@ -41,6 +47,7 @@ def get_id_from_cookie():
return request.cookies.get('_id') return request.cookies.get('_id')
def get_user_from_db(_id): def get_user_from_db(_id):
from main import db
return db.users.find_one({'_id': _id}) return db.users.find_one({'_id': _id})
def check_login(): def check_login():
@ -51,6 +58,7 @@ def login_required(function):
@wraps(function) @wraps(function)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
if not check_login(): if not check_login():
session['prev_page'] = request.url
flash('Please log in to view this page.', 'alert') flash('Please log in to view this page.', 'alert')
return redirect(url_for('admin_auth.login')) return redirect(url_for('admin_auth.login'))
return function(*args, **kwargs) return function(*args, **kwargs)
@ -70,19 +78,35 @@ def disable_if_logged_in(function):
@admin_account_required @admin_account_required
@login_required @login_required
def home(): def home():
return render_template('/admin/index.html') from main import db
tests = db.tests.find()
results = decrypt_find(db.entries, {})
current_tests = [ test for test in tests if test['expiry_date'] >= datetime.utcnow() and test['start_date'].date() <= date.today() ]
current_tests.sort(key= lambda x: x['expiry_date'], reverse=True)
upcoming_tests = [ test for test in tests if test['start_date'] > datetime.utcnow()]
upcoming_tests.sort(key= lambda x: x['start_date'])
recent_results = [result for result in results if 'submission_time' in result ]
recent_results.sort(key= lambda x: x['submission_time'], reverse=True)
for result in recent_results:
result['percent'] = round(100*result['results']['score']/result['results']['max'])
return render_template('/admin/index.html', current_tests = current_tests[:5], upcomimg_tests = upcoming_tests[:5], recent_results = recent_results[:5])
@views.route('/settings/') @views.route('/settings/')
@admin_account_required @admin_account_required
@login_required @login_required
def settings(): def settings():
return render_template('/admin/settings/index.html') from main import db
users = decrypt_find(db.users, {})
users.sort(key= lambda x: x['username'])
datasets = get_datasets()
return render_template('/admin/settings/index.html', users=users[:5], datasets=datasets[:5])
@views.route('/settings/users/', methods=['GET','POST']) @views.route('/settings/users/', methods=['GET','POST'])
@admin_account_required @admin_account_required
@login_required @login_required
def users(): def users():
from .forms import CreateUserForm from main import db, mail
from .models.forms import CreateUserForm
form = CreateUserForm() form = CreateUserForm()
if request.method == 'GET': if request.method == 'GET':
users_list = decrypt_find(db.users, {}) users_list = decrypt_find(db.users, {})
@ -129,10 +153,11 @@ def users():
@admin_account_required @admin_account_required
@login_required @login_required
def delete_user(_id:str): def delete_user(_id:str):
from main import db, mail
if _id == get_id_from_cookie(): if _id == get_id_from_cookie():
flash('Cannot delete your own user account.', 'error') flash('Cannot delete your own user account.', 'error')
return redirect(url_for('admin_views.users')) return redirect(url_for('admin_views.users'))
from .forms import DeleteUserForm from .models.forms import DeleteUserForm
form = DeleteUserForm() form = DeleteUserForm()
user = decrypt_find_one(db.users, {'_id': _id}) user = decrypt_find_one(db.users, {'_id': _id})
if request.method == 'GET': if request.method == 'GET':
@ -178,10 +203,11 @@ def delete_user(_id:str):
@admin_account_required @admin_account_required
@login_required @login_required
def update_user(_id:str): def update_user(_id:str):
from main import db, mail
if _id == get_id_from_cookie(): if _id == get_id_from_cookie():
flash('Cannot delete your own user account.', 'error') flash('Cannot delete your own user account.', 'error')
return redirect(url_for('admin_views.users')) return redirect(url_for('admin_views.users'))
from .forms import UpdateUserForm from .models.forms import UpdateUserForm
form = UpdateUserForm() form = UpdateUserForm()
user = decrypt_find_one( db.users, {'_id': _id}) user = decrypt_find_one( db.users, {'_id': _id})
if request.method == 'GET': if request.method == 'GET':
@ -231,40 +257,102 @@ def update_user(_id:str):
errors = [*form.user_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors] errors = [*form.user_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
return jsonify({ 'error': errors}), 400 return jsonify({ 'error': errors}), 400
@views.route('/settings/questions/') @views.route('/settings/questions/', methods=['GET', 'POST'])
@admin_account_required @admin_account_required
@login_required @login_required
def questions(): def questions():
return render_template('/admin/settings/questions.html') from .models.forms import UploadDataForm
from common.data_tools import check_json_format, validate_json_contents, store_data_file
form = UploadDataForm()
if request.method == 'GET':
data = get_datasets()
default = get_default_dataset()
return render_template('/admin/settings/questions.html', form=form, data=data, default=default)
if request.method == 'POST':
if form.validate_on_submit():
upload = form.data_file.data
default = True if request.form.get('default') else False
if not check_json_format(upload):
return jsonify({ 'error': 'Invalid file selected. Please upload a JSON file.'}), 400
if not validate_json_contents(upload):
return jsonify({'error': 'The data in the file is invalid.'}), 400
filename = store_data_file(upload, default=default)
flash(f'Dataset {form.data_file.data.filename} has been uploaded as {filename}.', 'success')
return jsonify({ 'success': f'Dataset {form.data_file.data.filename} has been uploaded as {filename}.'}), 200
errors = [*form.data_file.errors]
return jsonify({ 'error': errors}), 400
@views.route('/settings/questions/upload/') @views.route('/settings/questions/delete/', methods=['POST'])
@admin_account_required @admin_account_required
@login_required @login_required
def upload_questions(): def delete_questions():
return render_template('/admin/settings/upload-questions.html') from main import db, app
filename = request.get_json()['filename']
data_files = glob(os.path.join(app.config["DATA_FILE_DIRECTORY"],'*.json'))
if any(filename in file for file in data_files):
default = get_default_dataset()
if default == filename:
return jsonify({'error': 'Cannot delete the default question dataset.'}), 400
data_file = os.path.join(app.config["DATA_FILE_DIRECTORY"],filename)
with open(data_file, 'r') as _data_file:
data = loads(_data_file.read())
if data['meta']['tests']:
return jsonify({'error': 'Cannot delete a dataset that is in use by an exam.'}), 400
if len(data_files) == 1:
return jsonify({'error': 'Cannot delete the only question dataset.'}), 400
os.remove(data_file)
flash(f'Question dataset {filename} has been deleted.', 'success')
return jsonify({'success': f'Question dataset {filename} has been deleted.'}), 200
return abort(404)
@views.route('/settings/questions/default/', methods=['POST'])
@admin_account_required
@login_required
def make_default_questions():
from main import app
filename = request.get_json()['filename']
data_files = glob(os.path.join(app.config["DATA_FILE_DIRECTORY"],'*.json'))
default_file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], '.default.txt')
if any(filename in file for file in data_files):
with open(default_file_path, 'r') as default_file:
default = default_file.read()
if default == filename:
return jsonify({'error': 'Cannot delete default question dataset.'}), 400
with open(default_file_path, 'w') as default_file:
default_file.write(filename)
flash(f'Set dataset f{filename} as the default.', 'success')
return jsonify({'success': f'Set dataset {filename} as the default.'})
return abort(404)
@views.route('/tests/<filter>/', methods=['GET']) @views.route('/tests/<filter>/', methods=['GET'])
@views.route('/tests/', methods=['GET']) @views.route('/tests/', methods=['GET'])
@admin_account_required @admin_account_required
@login_required @login_required
def tests(filter=''): def tests(filter=''):
from main import db
if not available_datasets():
flash('There are no available question datasets. Please upload a question dataset in order to set up an exam.', 'error')
return redirect(url_for('admin_views.questions'))
if filter not in ['', 'create', 'active', 'scheduled', 'expired', 'all']: if filter not in ['', 'create', 'active', 'scheduled', 'expired', 'all']:
return abort(404) return abort(404)
if filter == 'create': if filter == 'create':
from .forms import CreateTest from .models.forms import CreateTest
form = CreateTest() form = CreateTest()
form.time_limit.choices = get_time_options()
form.dataset.choices = available_datasets()
form.time_limit.default='none' form.time_limit.default='none'
form.dataset.default=get_default_dataset()
form.process() form.process()
display_title = '' display_title = ''
error_none = '' error_none = ''
return render_template('/admin/tests.html', form = form, display_title=display_title, error_none=error_none, filter=filter) return render_template('/admin/tests.html', form = form, display_title=display_title, error_none=error_none, filter=filter)
_tests = db.tests.find({}) _tests = db.tests.find({})
if filter == 'active' or filter == '': if filter == 'active' or filter == '':
tests = [ test for test in _tests if test['expiry_date'].date() >= date.today() and test['start_date'].date() <= date.today() ] tests = [ test for test in _tests if test['expiry_date'] >= datetime.utcnow() and test['start_date'].date() <= date.today() ]
display_title = 'Active Exams' display_title = 'Active Exams'
error_none = 'There are no exams that are currently active. You can create one using the Creat Exam form.' error_none = 'There are no exams that are currently active. You can create one using the Creat Exam form.'
if filter == 'expired': if filter == 'expired':
tests = [ test for test in _tests if test['expiry_date'].date() < date.today()] tests = [ test for test in _tests if test['expiry_date'] < datetime.utcnow()]
display_title = 'Expired Exams' display_title = 'Expired Exams'
error_none = 'There are no expired exams. Exams will appear in this category after their expiration date has passed.' error_none = 'There are no expired exams. Exams will appear in this category after their expiration date has passed.'
if filter == 'scheduled': if filter == 'scheduled':
@ -280,16 +368,18 @@ def tests(filter=''):
@views.route('/tests/create/', methods=['POST']) @views.route('/tests/create/', methods=['POST'])
@admin_account_required @admin_account_required
@login_required @login_required
def _tests(): def create_test():
from .forms import CreateTest from main import db
from .models.forms import CreateTest
form = CreateTest() form = CreateTest()
form.time_limit.default='none' form.dataset.choices = available_datasets()
form.process() form.time_limit.choices = get_time_options()
if form.validate_on_submit(): if form.validate_on_submit():
start_date = request.form.get('start_date') start_date = request.form.get('start_date')
start_date = datetime.strptime(start_date, '%Y-%m-%d') start_date = datetime.strptime(start_date, '%Y-%m-%d')
expiry_date = request.form.get('expiry_date') expiry_date = request.form.get('expiry_date')
expiry_date = datetime.strptime(expiry_date, '%Y-%m-%d') expiry_date = datetime.strptime(expiry_date, '%Y-%m-%d') + timedelta(days= 1) - timedelta(milliseconds = 1)
dataset = request.form.get('dataset')
errors = [] errors = []
if start_date.date() < date.today(): if start_date.date() < date.today():
errors.append('The start date cannot be in the past.') errors.append('The start date cannot be in the past.')
@ -306,7 +396,8 @@ def _tests():
start_date = start_date, start_date = start_date,
expiry_date = expiry_date, expiry_date = expiry_date,
time_limit = request.form.get('time_limit'), time_limit = request.form.get('time_limit'),
creator = creator creator = creator,
dataset = dataset
) )
test.create() test.create()
return jsonify({'success': 'New exam created.'}), 200 return jsonify({'success': 'New exam created.'}), 200
@ -314,8 +405,105 @@ def _tests():
errors = [*form.expiry.errors, *form.time_limit.errors] errors = [*form.expiry.errors, *form.time_limit.errors]
return jsonify({ 'error': errors}), 400 return jsonify({ 'error': errors}), 400
@views.route('/tests/delete/<_id>/') @views.route('/tests/delete/', methods=['POST'])
def delete_test(_id): @admin_account_required
@login_required
def delete_test():
from main import db
_id = request.get_json()['_id']
if db.tests.find_one({'_id': _id}): if db.tests.find_one({'_id': _id}):
return Test(_id = _id).delete() return Test(_id = _id).delete()
return abort(404) return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
@views.route('/tests/close/', methods=['POST'])
@admin_account_required
@login_required
def close_test():
from main import db
_id = request.get_json()['_id']
if db.tests.find_one({'_id': _id}):
return Test(_id = _id, expiry_date= datetime.utcnow()).update()
return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
@views.route('/test/<_id>/', methods=['GET','POST'])
@admin_account_required
@login_required
def view_test(_id):
from main import db
from .models.forms import AddTimeAdjustment
form = AddTimeAdjustment()
test = decrypt_find_one(db.tests, {'_id': _id})
if request.method == 'GET':
if not test:
return abort(404)
return render_template('/admin/test.html', test = test, form = form)
if request.method == 'POST':
if form.validate_on_submit():
time = int(request.form.get('time'))
return Test(_id=_id).add_time_adjustment(time)
return jsonify({'error': form.time.errors }), 400
@views.route('/test/<_id>/delete-adjustment/', methods = ['POST'])
@admin_account_required
@login_required
def delete_adjustment(_id):
user_code = request.get_json()['user_code']
return Test(_id=_id).remove_time_adjustment(user_code)
@views.route('/results/')
@admin_account_required
@login_required
def view_entries():
from main import db
entries = decrypt_find(db.entries, {})
return render_template('/admin/results.html', entries = entries)
@views.route('/results/<_id>/', methods = ['GET', 'POST'])
@admin_account_required
@login_required
def view_entry(_id=''):
from main import app, db
entry = decrypt_find_one(db.entries, {'_id': _id})
if request.method == 'GET':
if not entry:
return abort(404)
test_code = entry['test_code']
test = db.tests.find_one({'test_code' : test_code})
dataset = test['dataset']
dataset_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], dataset)
with open(dataset_path, 'r') as _dataset:
data = loads(_dataset.read())
correct = get_correct_answers(dataset=data)
print(correct.values())
return render_template('/admin/result-detail.html', entry = entry, correct = correct)
if request.method == 'POST':
if not entry:
return jsonify({'error': 'A valid entry could no be found.'}), 404
action = request.get_json()['action']
if action == 'override':
late_ignore = db.entries.find_one_and_update({'_id': _id}, {'$set': {'status': 'late (allowed)'}})
if late_ignore:
flash('Late status for the entry has been allowed.', 'success')
return jsonify({'success': 'Late status allowed.'}), 200
return jsonify({'error': 'An error occurred.'}), 400
if action == 'delete':
test_code = entry['test_code']
test = db.tests.find_one_and_update({'test_code': test_code}, {'$pull': {'entries': _id}})
if not test:
return jsonify({'error': 'A valid exam could not be found.'}), 404
delete = db.entries.delete_one({'_id': _id})
if delete:
flash('Entry has been deleted.', 'success')
return jsonify({'success': 'Entry has been deleted.'}), 200
return jsonify({'error': 'An error occurred.'}), 400
@views.route('/certificate/', methods=['POST'])
@admin_account_required
@login_required
def generate_certificate():
from main import db
_id = request.get_json()['_id']
entry = decrypt_find_one(db.entries, {'_id': _id})
if not entry:
return abort(404)
return render_template('/admin/components/certificate.html', entry = entry)

View File

@ -1,18 +0,0 @@
from datetime import datetime, timedelta
from flask import Blueprint, redirect, request
cookie_consent = Blueprint(
'cookie_consent',
__name__
)
@cookie_consent.route('/')
def _cookies():
resp = redirect('/')
resp.set_cookie(
key = 'cookie_consent',
value = 'True',
max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else 'Session',
path = '/',
expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else 'Session'
)
return resp

View File

@ -0,0 +1,21 @@
from datetime import datetime, timedelta
from flask import Blueprint, redirect, request
cookie_consent = Blueprint(
'cookie_consent',
__name__
)
@cookie_consent.route('/')
def _cookies():
from main import app
resp = redirect('/')
resp.set_cookie(
key = 'cookie_consent',
value = 'True',
max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else None,
path = '/',
expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else None,
domain = f'.{app.config["SERVER_NAME"]}',
secure = True
)
return resp

View File

@ -0,0 +1,238 @@
import os
import pathlib
from json import dump, loads
from datetime import datetime, timedelta
from glob import glob
from random import shuffle
from werkzeug.utils import secure_filename
from .security.database import decrypt_find_one
def check_data_folder_exists():
from main import app
if not os.path.exists(app.config['DATA_FILE_DIRECTORY']):
pathlib.Path(app.config['DATA_FILE_DIRECTORY']).mkdir(parents='True', exist_ok='True')
def check_default_indicator():
from main import app
if not os.path.isfile(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.default.txt')):
open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.default.txt'),'w').close()
def get_default_dataset():
check_default_indicator()
from main import app
default_file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], '.default.txt')
with open(default_file_path, 'r') as default_file:
default = default_file.read()
return default
def available_datasets():
from main import app
files = glob(os.path.join(app.config["DATA_FILE_DIRECTORY"],'*.json'))
default = get_default_dataset()
output = []
for file in files:
filename = file.rsplit('/')[-1]
label = f'{filename[:-5]} (Default)' if filename == default else filename[:-5]
element = (filename, label)
output.append(element)
output.reverse()
return output
def check_json_format(file):
if not '.' in file.filename:
return False
if not file.filename.rsplit('.', 1)[-1] == 'json':
return False
return True
def validate_json_contents(file):
file.stream.seek(0)
data = loads(file.read())
if not type(data) is dict:
return False
elif not all( key in data for key in ['meta', 'questions']):
return False
elif not type(data['meta']) is dict:
return False
elif not type(data['questions']) is list:
return False
return True
def store_data_file(file, default:bool=None):
from admin.views import get_id_from_cookie
from main import app
check_default_indicator()
timestamp = datetime.utcnow()
filename = '.'.join([timestamp.strftime('%Y%m%d%H%M%S'),'json'])
filename = secure_filename(filename)
file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], filename)
file.stream.seek(0)
data = loads(file.read())
data['meta']['timestamp'] = timestamp.strftime('%Y-%m-%d %H%M%S')
data['meta']['author'] = get_id_from_cookie()
data['meta']['tests'] = []
with open(file_path, 'w') as _file:
dump(data, _file, indent=2)
if default:
with open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.default.txt'), 'w') as _file:
_file.write(filename)
return filename
def randomise_list(list:list):
_list = list.copy()
shuffle(_list)
return(_list)
def generate_questions(dataset:dict):
questions_list = dataset['questions']
output = []
for block in randomise_list(questions_list):
if block['type'] == 'question':
question = {
'type': 'question',
'q_no': block['q_no'],
'question_header': '',
'text': block['text']
}
if block['q_type'] == 'Multiple Choice':
question['options'] = randomise_list(block['options'])
else:
question['options'] = block['options'].copy()
output.append(question)
if block['type'] == 'block':
for key, _question in enumerate(randomise_list(block['questions'])):
question = {
'type': 'block',
'q_no': _question['q_no'],
'question_header': block['question_header'] if 'question_header' in block else '',
'block_length': len(block['questions']),
'block_q_no': key,
'text': _question['text']
}
if _question['q_type'] == 'Multiple Choice':
question['options'] = randomise_list(_question['options'])
else:
question['options'] = _question['options'].copy()
output.append(question)
return output
def evaluate_answers(dataset: dict, answers: dict):
score = 0
max = 0
tags = {}
for block in dataset['questions']:
if block['type'] == 'question':
max += 1
q_no = block['q_no']
if str(q_no) in answers:
correct = block['correct']
correct_answer = block['options'][correct]
submitted_answer = answers[str(q_no)]
submitted_answer = submitted_answer.replace('', '&lsquo;').replace('', '&rsquo;').replace('', '&mdash;')
if submitted_answer == correct_answer:
score += 1
for tag in block['tags']:
if tag not in tags:
tags[tag] = {
'scored': 1,
'max': 1
}
else:
tags[tag]['scored'] += 1
tags[tag]['max'] += 1
else:
for tag in block['tags']:
if tag not in tags:
tags[tag] = {
'scored': 0,
'max': 1
}
else:
tags[tag]['max'] += 1
if block['type'] == 'block':
for question in block['questions']:
max += 1
q_no = question['q_no']
if str(q_no) in answers:
correct = question['correct']
correct_answer = question['options'][correct]
if answers[str(q_no)] == correct_answer:
score += 1
for tag in question['tags']:
if tag not in tags:
tags[tag] = {
'scored': 1,
'max': 1
}
else:
tags[tag]['scored'] += 1
tags[tag]['max'] += 1
else:
for tag in question['tags']:
if tag not in tags:
tags[tag] = {
'scored': 0,
'max': 1
}
else:
tags[tag]['max'] += 1
grade = 'merit' if score/max >= .85 else 'pass' if score/max >= .70 else 'fail'
return {
'grade': grade,
'tags': tags,
'score': score,
'max': max
}
def get_tags_list(dataset:dict):
output = []
blocks = dataset['questions']
for block in blocks:
if block['type'] == 'question':
output = list(set(output) | set(block['tags']))
if block['type'] == 'block':
for question in block['questions']:
output = list(set(output) | set(question['tags']))
return output
def get_time_options():
time_options = [
('none', 'None'),
('60', '1 hour'),
('90', '1 hour 30 minutes'),
('120', '2 hours')
]
return time_options
def get_datasets():
from main import app, db
files = glob(os.path.join(app.config["DATA_FILE_DIRECTORY"],'*.json'))
data = []
if files:
for file in files:
filename = file.rsplit('/')[-1]
with open(file) as _file:
load = loads(_file.read())
_author = load['meta']['author']
author = decrypt_find_one(db.users, {'_id': _author})['username']
data_element = {
'filename': filename,
'timestamp': datetime.strptime(load['meta']['timestamp'], '%Y-%m-%d %H%M%S'),
'author': author,
'use': len(load['meta']['tests'])
}
data.append(data_element)
return data
def get_correct_answers(dataset:dict):
output = {}
blocks = dataset['questions']
for block in blocks:
if block['type'] == 'question':
output[str(block['q_no'])] = block['options'][block['correct']]
if block['type'] == 'block':
for question in block['questions']:
output[str(question['q_no'])] = question['options'][question['correct']]
return output

View File

@ -2,17 +2,17 @@ from os import environ, path
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
def generate_keyfile(): def generate_keyfile():
with open('./security/.encryption.key', 'wb') as keyfile: with open('./.security/.encryption.key', 'wb') as keyfile:
key = Fernet.generate_key() key = Fernet.generate_key()
keyfile.write(key) keyfile.write(key)
def load_key(): def load_key():
with open('./security/.encryption.key', 'rb') as keyfile: with open('./.security/.encryption.key', 'rb') as keyfile:
key = keyfile.read() key = keyfile.read()
return key return key
def check_keyfile_exists(): def check_keyfile_exists():
return path.isfile('./security/.encryption.key') return path.isfile('./.security/.encryption.key')
def encrypt(input): def encrypt(input):
if not check_keyfile_exists(): if not check_keyfile_exists():
@ -37,11 +37,22 @@ def encrypt(input):
def decrypt(input): def decrypt(input):
if not check_keyfile_exists(): if not check_keyfile_exists():
raise EncryptionKeyMissing raise EncryptionKeyMissing
input = input.encode()
_encryption_key = load_key() _encryption_key = load_key()
fernet = Fernet(_encryption_key) fernet = Fernet(_encryption_key)
output = fernet.decrypt(input) if type(input) == str:
return output.decode() input = input.encode()
output = fernet.decrypt(input)
return output.decode()
if type(input) == dict:
output = {}
for key, value in input.items():
if type(value) == dict:
output[key] = decrypt(value)
else:
value = value.encode()
output[key] = fernet.decrypt(value)
output[key] = output[key].decode()
return output
class EncryptionKeyMissing(Exception): class EncryptionKeyMissing(Exception):
def __init__(self, message='There is no encryption keyfile.'): def __init__(self, message='There is no encryption keyfile.'):

View File

@ -15,7 +15,7 @@ def decrypt_find(collection:collection, query:dict):
if not query: if not query:
output_list.append(decrypted_document) output_list.append(decrypted_document)
else: else:
if set(query.items()).issubset(set(decrypted_document.items())): if query.items() <= decrypted_document.items():
output_list.append(decrypted_document) output_list.append(decrypted_document)
return output_list return output_list

View File

@ -4,9 +4,8 @@ class Config(object):
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')
from dotenv import load_dotenv
load_dotenv()
MONGO_INITDB_DATABASE = os.getenv('MONGO_INITDB_DATABASE') MONGO_INITDB_DATABASE = os.getenv('MONGO_INITDB_DATABASE')
from urllib import parse from urllib import parse
MONGO_URI = f'mongodb://{os.getenv("MONGO_INITDB_USERNAME")}:{parse.quote_plus(os.getenv("MONGO_INITDB_PASSWORD"))}@{os.getenv("MONGO_DB_HOST_ALIAS")}:{os.getenv("MONGO_PORT")}/' MONGO_URI = f'mongodb://{os.getenv("MONGO_INITDB_USERNAME")}:{parse.quote_plus(os.getenv("MONGO_INITDB_PASSWORD"))}@{os.getenv("MONGO_DB_HOST_ALIAS")}:{os.getenv("MONGO_PORT")}/'
@ -26,26 +25,27 @@ class Config(object):
MAIL_MAX_EMAILS = int(os.getenv("MAIL_MAX_EMAILS")) MAIL_MAX_EMAILS = int(os.getenv("MAIL_MAX_EMAILS"))
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"))
DATA_FILE_DIRECTORY = os.getenv("DATA_FILE_DIRECTORY")
class ProductionConfig(Config): class ProductionConfig(Config):
pass pass
class DevelopmentConfig(Config): class DevelopmentConfig(Config):
from dotenv import load_dotenv
load_dotenv()
DEBUG = True DEBUG = True
SESSION_COOKIE_SECURE = False SESSION_COOKIE_SECURE = False
MONGO_INITDB_DATABASE = os.getenv('MONGO_INITDB_DATABASE') MONGO_INITDB_DATABASE = os.getenv('MONGO_INITDB_DATABASE')
from urllib import parse from urllib import parse
MONGO_URI = f'mongodb://{os.getenv("MONGO_INITDB_USERNAME")}:{parse.quote_plus(os.getenv("MONGO_INITDB_PASSWORD"))}@localhost:{os.getenv("MONGO_PORT")}/' MONGO_URI = f'mongodb://{os.getenv("MONGO_INITDB_USERNAME")}:{parse.quote_plus(os.getenv("MONGO_INITDB_PASSWORD"))}@localhost:{os.getenv("MONGO_PORT")}/'
APP_HOST = '127.0.0.1' APP_HOST = '127.0.0.1'
MAIL_SERVER = 'localhost'
MAIL_DEBUG = True MAIL_DEBUG = True
MAIL_SUPPRESS_SEND = False MAIL_SUPPRESS_SEND = False
class TestingConfig(Config): class TestingConfig(DevelopmentConfig):
from dotenv import load_dotenv
load_dotenv()
TESTING = True TESTING = True
SESSION_COOKIE_SECURE = False SESSION_COOKIE_SECURE = False
MAIL_SERVER = os.getenv("MAIL_SERVER")
MAIL_DEBUG = True MAIL_DEBUG = True
MAIL_SUPPRESS_SEND = False MAIL_SUPPRESS_SEND = False
from urllib import parse
MONGO_URI = f'mongodb://{os.getenv("MONGO_INITDB_USERNAME")}:{parse.quote_plus(os.getenv("MONGO_INITDB_PASSWORD"))}@{os.getenv("MONGO_DB_HOST_ALIAS")}:{os.getenv("MONGO_PORT")}/'

2
ref-test/data/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from flask import Flask, flash, request from flask import Flask, flash, request, render_template
from flask.helpers import url_for from flask.helpers import url_for
from flask.json import jsonify from flask.json import jsonify
from flask_bootstrap import Bootstrap from flask_bootstrap import Bootstrap
@ -8,45 +8,23 @@ from pymongo import MongoClient
from pymongo.errors import ConnectionFailure from pymongo.errors import ConnectionFailure
from flask_wtf.csrf import CSRFProtect, CSRFError from flask_wtf.csrf import CSRFProtect, CSRFError
from flask_mail import Mail from flask_mail import Mail
from werkzeug.middleware.proxy_fix import ProxyFix
from security import check_keyfile_exists, generate_keyfile from common.security import check_keyfile_exists, generate_keyfile
import config
app = Flask(__name__) def create_app():
app.config.from_object('config.DevelopmentConfig') app = Flask(__name__)
app.config.from_object(config.ProductionConfig())
Bootstrap(app) from common.blueprints import cookie_consent
csrf = CSRFProtect(app)
@app.errorhandler(CSRFError)
def csrf_error_handler(error):
return jsonify({ 'error': 'Could not validate a secure connection.'} ), 400
try:
mongo = MongoClient(app.config['MONGO_URI'])
db = mongo[app.config['MONGO_INITDB_DATABASE']]
except ConnectionFailure as error:
print(error)
try:
mail = Mail(app)
except Exception as error:
print(error)
if __name__ == '__main__':
if not check_keyfile_exists():
generate_keyfile()
from common import cookie_consent
from admin.views import views as admin_views from admin.views import views as admin_views
from admin.auth import auth as admin_auth from admin.auth import auth as admin_auth
from admin.results import results from admin.results import results
from quiz.views import views as quiz_views from quiz.views import views as quiz_views
from quiz.auth import auth as quiz_auth
app.register_blueprint(quiz_views, url_prefix = '/') app.register_blueprint(quiz_views, url_prefix = '/')
app.register_blueprint(quiz_auth, url_prefix = '/')
app.register_blueprint(admin_views, url_prefix = '/admin/') app.register_blueprint(admin_views, url_prefix = '/admin/')
app.register_blueprint(admin_auth, url_prefix = '/admin/') app.register_blueprint(admin_auth, url_prefix = '/admin/')
app.register_blueprint(results, url_prefix = '/admin/results/') app.register_blueprint(results, url_prefix = '/admin/results/')
@ -79,4 +57,28 @@ if __name__ == '__main__':
def _get_id_from_cookie(): def _get_id_from_cookie():
return dict(get_id_from_cookie = get_id_from_cookie) return dict(get_id_from_cookie = get_id_from_cookie)
@app.errorhandler(404)
def _404_handler(e):
return render_template('/quiz/404.html'), 404
@app.errorhandler(CSRFError)
def csrf_error_handler(error):
return jsonify({ 'error': 'Could not validate a secure connection.'} ), 400
if not check_keyfile_exists():
generate_keyfile()
Bootstrap(app)
csrf = CSRFProtect(app)
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
return app
app = create_app()
mongo = MongoClient(app.config['MONGO_URI'])
db = mongo[app.config['MONGO_INITDB_DATABASE']]
mail = Mail(app)
if __name__ == '__main__':
app.run(host=app.config['APP_HOST']) app.run(host=app.config['APP_HOST'])

View File

@ -1,6 +0,0 @@
from flask import Blueprint
auth = Blueprint(
'quiz_auth',
__name__,
)

View File

@ -0,0 +1,158 @@
/*! Generated by Font Squirrel (https://www.fontsquirrel.com) on November 23, 2021 */
@font-face {
font-family: 'opendyslexic3bold';
src: url('../fonts/opendyslexic3-bold-webfont.woff2') format('woff2'),
url('../fonts/opendyslexic3-bold-webfont.woff') format('woff');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'opendyslexic3regular';
src: url('../fonts/opendyslexic3-regular-webfont.woff2') format('woff2'),
url('../fonts/opendyslexic3-regular-webfont.woff') format('woff');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'opendyslexicmonoregular';
src: url('../fonts/opendyslexicmono-regular-webfont.woff2') format('woff2'),
url('../fonts/opendyslexicmono-regular-webfont.woff') format('woff');
font-weight: normal;
font-style: normal;
}
/* Class Definitions */
.form-quiz-configure {
width: 100%;
padding: 15px;
margin: auto;
}
.q-f-opendyslexic {
font-family: 'opendyslexic3bold';
}
.q-f-comicsans {
font-family: 'Comic Sans MS', 'Comic Sans';
}
.q-f-osdefault {
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.q-f-verdana {
font-family: Verdana, sans-serif;
}
.q-f-tahoma {
font-family: Tahoma, sans-serif;
}
.q-f-arial {
font-family: Arial, Helvetica, sans-serif;
}
.q-f-12pt {
font-size: 12pt;
}
.q-f-14pt {
font-size: 14pt;
}
.q-f-16pt {
font-size: 16pt;
}
.q-f-18pt {
font-size: 18pt;
}
.q-settings-element {
margin-bottom: 2rem;
}
.q-bg-light-1 {
background-color: beige;
}
.q-bg-light-2 {
background-color: #EBE3E1;
}
#sample-question {
margin: 2rem auto;
padding: 2rem;
}
.question-container {
margin: 2rem auto;
padding: 2 rem;
}
.question-title {
margin: 2rem 0;
}
.question-header {
margin: 1rem auto 3rem;
width: 90%;
}
.btn-quiz-control {
width: fit-content;
}
#q-topbar a.btn {
padding: 2px 6px 0px 6px;
font-size: 14pt;
height: fit-content;
width: fit-content;
margin: 0px 4px;
}
.q-timer {
padding-top: 0px;
margin: 0px auto;
font-size: 16pt;
}
.q-navigator-button {
margin: 5px;
}
.control-button-container {
width: fit-content;
margin: 2rem auto;
}
.control-button-container a {
width: fit-content;
margin: 0 5px;
}
.navigator-help {
margin: 4rem auto;
}
#navigator-container {
margin-bottom: 2rem;
}
/* Layout for Mobile Devices */
@media only screen and (max-width: 576px) {
body {
padding-top: 140px;
}
.navbar .container {
justify-content: space-evenly;
}
}

View File

@ -1,13 +1,27 @@
.bg-light {
background-color: #EBE3E1!important;
}
body { body {
padding: 80px 0; padding: 80px 0;
line-height: 1.5; line-height: 1.5;
font-size: 14pt; font-size: 14pt;
} }
#cookie-alert {
padding-right: 16px;
}
#dismiss-cookie-alert {
margin-top: 16px;
width: fit-content;
}
.button-container {
margin: 2rem auto;
width: fit-content;
}
.instruction-container {
margin: 2rem auto;
}
.site-footer { .site-footer {
background-color: lightgray; background-color: lightgray;
font-size: small; font-size: small;
@ -111,7 +125,7 @@ body {
color: #777; color: #777;
} }
.form-check { .form-check-margin {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@ -137,33 +151,47 @@ body {
margin: 0 2px; margin: 0 2px;
} }
/*! Generated by Font Squirrel (https://www.fontsquirrel.com) on November 23, 2021 */ .results-name {
margin: 3rem auto;
@font-face {
font-family: 'opendyslexic3bold';
src: url('../fonts/opendyslexic3-bold-webfont.woff2') format('woff2'),
url('../fonts/opendyslexic3-bold-webfont.woff') format('woff');
font-weight: normal;
font-style: normal;
} }
@font-face { .results-name .surname {
font-family: 'opendyslexic3regular'; font-variant: small-caps;
src: url('../fonts/opendyslexic3-regular-webfont.woff2') format('woff2'), font-size: 24pt;
url('../fonts/opendyslexic3-regular-webfont.woff') format('woff');
font-weight: normal;
font-style: normal;
} }
@font-face { .results-score {
font-family: 'opendyslexicmonoregular'; margin: 2rem auto;
src: url('../fonts/opendyslexicmono-regular-webfont.woff2') format('woff2'), width: fit-content;
url('../fonts/opendyslexicmono-regular-webfont.woff') format('woff'); font-size: 36pt;
font-weight: normal; }
font-style: normal;
.results-score::after {
content: '%';
}
.results-grade {
margin: 2rem auto;
width: fit-content;
font-size: 26pt;
}
.button-icon {
font-size: 20px;
margin-right: 2px;
}
/* Change Autocomplete styles in Chrome*/
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
transition: background-color 5000s ease-in-out 0s;
} }
/* Fallback for Edge /* Fallback for Edge

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -0,0 +1,646 @@
// Bind Listeners
$("input[name='font-select']").change(function(){
let $choice = $(this).val();
set_font($choice);
});
$("input[name='font-size']").change(function(){
let $choice = $(this).val();
set_font_size($choice);
});
$("input[name='bg-select']").change(function(){
let $choice = $(this).val();
set_bg_colour($choice);
});
$("#btn-toggle-navigator").click(function(event){
check_answered();
update_navigator();
if ($quiz_navigator.is(":hidden")) {
if ($quiz_settings.is(":visible")) {
toggle_settings = true;
$quiz_settings.fadeOut();
}
$quiz_render.fadeOut();
$quiz_navigator.fadeIn();
$(".navigator-text").fadeIn();
$(".review-text").fadeOut();
toggle_navigator = false;
$(window).scrollTop(0);
} else {
$quiz_navigator.fadeOut();
if (toggle_settings) {
$quiz_settings.fadeIn();
$(window).scrollTop(0);
toggle_settings = false;
} else {
$quiz_render.fadeIn();
$(window).scrollTop(0);
}
}
event.preventDefault();
});
$("#btn-toggle-settings").click(function(event){
if (($quiz_settings).is(":hidden")) {
if ($quiz_navigator.is(":visible")) {
toggle_navigator = true;
$quiz_navigator.fadeOut();
}
$quiz_render.fadeOut();
$quiz_settings.fadeIn();
$(window).scrollTop(0);
toggle_settings = false;
} else {
$quiz_settings.fadeOut();
if (toggle_navigator) {
$quiz_navigator.fadeIn();
toggle_navigator = false;
$(window).scrollTop(0);
} else {
$quiz_render.fadeIn();
$(window).scrollTop(0);
}
}
event.preventDefault();
});
$(".btn-quiz-return").click(function(event){
$quiz_navigator.fadeOut();
$quiz_settings.fadeOut();
$quiz_render.fadeIn();
$(window).scrollTop(0);
toggle_settings = false;
toggle_navigator = false;
event.preventDefault();
});
$(".btn-dummy").click(function(event){
event.preventDefault();
});
$("#navigator-container").on("click", ".q-navigator-button", function(event){
check_answered();
update_navigator();
current_question = parseInt($(this).attr("name"));
$quiz_navigator.fadeOut();
$quiz_render.fadeIn();
$question_title.focus();
$(window).scrollTop(0);
toggle_navigator = false;
toggle_settings = false;
render_question();
check_flag();
event.preventDefault();
});
$(".q-question-nav").click(function(event){
check_answered();
update_navigator();
if ($(this).attr("id") == "q-nav-next") {
if (current_question < questions.length) {
current_question ++;
}
} else if ($(this).attr("id") == "q-nav-prev") {
if (current_question > 0) {
current_question --;
}
} else if ($(this).hasClass("q-navigator-button")) {
current_question = $(this).attr("name");
$quiz_render.fadeIn();
$quiz_navigator.fadeOut();
toggle_navigator = false;
toggle_settings = false;
}
render_question();
check_flag();
event.preventDefault();
});
$("#q-nav-flag").click(function(event){
if (question_status[current_question] != 1) {
question_status[current_question] = 1;
$(this).removeClass().addClass("btn btn-warning");
$(this).attr("title", "Question Flagged for revision. Click to un-flag.");
} else {
question_status[current_question] = 0;
$(this).removeClass().addClass("btn btn-secondary");
$(this).attr("title", "Question Un-Flagged. Click to flag for revision.");
}
window.localStorage.setItem('question_status', JSON.stringify(question_status));
update_navigator();
event.preventDefault();
});
$("#btn-start-quiz").click(function(event){
$.ajax({
url: `/api/questions/`,
type: 'POST',
data: JSON.stringify({'_id': _id}),
contentType: "application/json",
success: function(response) {
$(this).fadeOut();
$(".btn-quiz-return").fadeIn();
$(".quiz-console").fadeIn();
$("#quiz-settings").fadeOut();
$("#quiz-navigator").fadeOut();
$(".quiz-start-text").fadeOut();
time_limit = response.time_limit;
start_time = response.start_time;
questions = response.questions;
total_questions = questions.length;
window.localStorage.setItem('questions', JSON.stringify(questions));
window.localStorage.setItem('start_time', JSON.stringify(start_time));
window.localStorage.setItem('time_limit', JSON.stringify(time_limit));
render_question();
build_navigator();
check_flag();
if (time_limit != 'null' && time_limit != null) {
$("#q-timer-widget").fadeIn();
time_remaining = get_time_remaining();
clock = setInterval(timer, 1000);
}
if (response.time_adjustment > 0) {
const $alert = $("#alert-box");
$alert.html(
`<div class="alert alert-primary alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Alert"></i>
User code validated. Extra time of ${response.time_adjustment} minutes added to the exam time limit.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`
);
$alert.focus();
}
},
error: function(response) {
error_response(response);
}
});
event.preventDefault();
});
$("#quiz-question-options").on("change", ".quiz-option", function(event){
$name = parseInt($(this).attr("name"));
$value = $(this).attr("value");
answers[$name] = $value;
window.localStorage.setItem('answers', JSON.stringify(answers));
});
$("#q-review-answers").click(function(event){
check_answered();
update_navigator();
if ($quiz_navigator.is(":hidden")) {
if ($quiz_settings.is(":visible")) {
toggle_settings = true;
$quiz_settings.fadeOut();
}
$quiz_render.fadeOut();
$quiz_navigator.fadeIn();
$(".navigator-text").fadeOut();
$(".review-text").fadeIn();
toggle_navigator = false;
$(window).scrollTop(0);
} else {
$quiz_navigator.fadeOut();
if (toggle_settings) {
$quiz_settings.fadeIn();
toggle_settings = false;
} else {
$quiz_render.fadeIn();
}
}
event.preventDefault();
});
$(".quiz-button-submit").click(function(event){
let submission = {
'_id': _id,
'answers': answers
}
$.ajax({
url: `/api/submit/`,
type: 'POST',
data: JSON.stringify(submission),
contentType: "application/json",
success: function(response) {
window.localStorage.clear();
window.location.href = `/result/`;
},
error: function(response) {
error_response(response);
}
});
event.preventDefault();
});
// Functions
function set_font(value = 'osdefault') {
let font_styles = ['arial', 'comicsans', 'opendyslexic', 'tahoma', 'verdana']
for (let i = 0; i < font_styles.length; i ++) {
if (font_styles[i] != value) {
$("body").removeClass( `q-f-${font_styles[i]}` );
};
};
if (value != 'osdefault') {
$("body").addClass(`q-f-${value}`);
};
display_settings['font-select'] = value;
window.localStorage.setItem('display_settings', JSON.stringify(display_settings));
$('input[name="font-select"][value="' + value + '"]').prop('checked', true);
}
function set_font_size(value = '14pt') {
let font_sizes = ['12pt', '16pt', '18pt']
for (let i = 0; i < font_sizes.length; i ++) {
if (font_sizes[i] != value) {
$("body").removeClass( `q-f-${font_sizes[i]}` );
};
};
if (value != '14pt') {
$("body").addClass(`q-f-${value}`);
};
display_settings['font-size'] = value;
window.localStorage.setItem('display_settings', JSON.stringify(display_settings));
$('input[name="font-size"][value="' + value + '"]').prop('checked', true);
}
function set_bg_colour(value = 'bg-light') {
let backgrounds = ['bg-light', 'q-bg-light-1', 'q-bg-light-2', 'alert-primary', 'alert-secondary', 'alert-dark', 'bg-dark']
for (let i = 0; i < backgrounds.length; i ++) {
if (backgrounds[i] != value) {
$("body").removeClass(backgrounds[i]);
if (backgrounds[i] == 'bg-dark') {
$("body").removeClass('text-light');
};
if (backgrounds[i] == 'alert-primary' || backgrounds[i] == 'alert-secondary' || backgrounds[i] == 'alert-dark') {
$("body").removeClass('text-dark');
};
};
};
$("body").addClass(value);
if (value == 'bg-dark') {
$("body").addClass('text-light');
};
if (value == 'alert-primary' || value == 'alert-secondary' || value == 'alert-dark') {
$("body").addClass('text-dark');
};
display_settings['bg-select'] = value;
window.localStorage.setItem('display_settings', JSON.stringify(display_settings));
$('input[name="bg-select"][value="' + value + '"]').prop('checked', true);
}
function get_settings_from_storage() {
let display_settings = window.localStorage.getItem('display_settings')
if (display_settings != null) {
return JSON.parse(display_settings);
};
return {
'font-select': 'osdefault',
'font-size': '14pt',
'bg-select': 'bg-light'
}
}
function apply_settings(settings) {
set_font(settings['font-select']);
set_font_size(settings['font-size']);
set_bg_colour(settings['bg-select']);
}
function render_question() {
if (current_question == 0) {
$nav_prev.addClass('disabled');
}
if (current_question == questions.length - 1) {
$nav_next.addClass('disabled');
}
if ($nav_prev.hasClass('disabled') && current_question > 0) {
$nav_prev.removeClass('disabled');
}
if ($nav_next.hasClass('disabled') && current_question < questions.length - 1) {
$nav_next.removeClass('disabled');
}
var question = questions[current_question];
let header_text = question.question_header;
var block_length = 0;
if ('block_length' in question) {
block_length = question['block_length'];
};
var block_q_no = 0;
if ('block_q_no' in question) {
block_q_no = question['block_q_no'];
}
let remaining_qs = (block_length - block_q_no).toString();
if (block_length - block_q_no > 1) {
remaining_qs += ' questions';
} else {
remaining_qs += ' question';
}
header_text = header_text.replace('<block_remaining_questions>', remaining_qs);
$question_header.html(header_text);
$question_text.html(question.text);
$question_title.html(`Question ${current_question + 1} of ${ questions.length }.`);
var q_no = question['q_no'];
var options = question.options;
var options_output = '';
for (let i = 0; i < options.length; i ++) {
var add_checked = ''
if (q_no in answers) {
if (answers[q_no] == options[i]) {
add_checked = 'checked';
}
}
options_output += `<div class="form-check">
<input type="radio" class="form-check-input quiz-option" id="q${current_question}-${i}" name="${q_no}" value="${options[i]}" ${add_checked}>
<label for="q${current_question}-${i}" class="form-check-label">${options[i]}</label>
</div>`;
}
$question_options.html(options_output);
let skipped = count_questions(-1);
let answered = count_questions(2);
let flagged = count_questions(1);
$progress_skipped.attr('title', `Skipped: ${skipped}`);
$progress_skipped.attr('aria-valuenow', skipped);
$progress_skipped.css('width', `${skipped}%`);
$skipped_count.text(`Skipped: ${skipped}`);
if (skipped < 1) {
$skipped_count.fadeOut()
} else {
$skipped_count.fadeIn()
}
$progress_flagged.attr('title', `Flagged: ${flagged}`);
$progress_flagged.attr('aria-valuenow', flagged);
$progress_flagged.css('width', `${flagged}%`);
$flagged_count.text(`Flagged: ${flagged}`);
if (flagged < 1) {
$flagged_count.fadeOut()
} else {
$flagged_count.fadeIn()
}
$progress_answered.attr('title', `Answered: ${answered}`);
$progress_answered.attr('aria-valuenow', answered);
$progress_answered.css('width', `${answered}%`);
$answered_count.text(`Answered: ${answered}`);
if (answered < 1) {
$answered_count.fadeOut()
} else {
$answered_count.fadeIn()
}
$question_title.focus();
$(window).scrollTop(0);
}
function check_answered() {
var question = questions[current_question];
var name = question.q_no;
if (question_status[current_question] == 0 || question_status[current_question] == -1) {
if (!$(`input[name='${name}']:checked`).val()) {
question_status[current_question] = -1;
} else {
question_status[current_question] = 2;
}
window.localStorage.setItem('question_status', JSON.stringify(question_status));
}
}
function check_flag() {
if (!(current_question in question_status)) {
question_status[current_question] = 0;
window.localStorage.setItem('question_status', JSON.stringify(question_status));
}
switch (question_status[current_question]) {
case -1:
$nav_flag.removeClass().addClass('btn btn-danger progress-bar-striped');
$nav_flag.attr("title", "Question Incomplete. Click to flag for revision.");
break;
case 1:
$nav_flag.removeClass().addClass('btn btn-warning');
$nav_flag.attr("title", "Question Flagged for revision. Click to un-flag.");
break;
case 2:
$nav_flag.removeClass().addClass('btn btn-success');
$nav_flag.attr("title", "Question Answered. Click to flag for revision.");
break;
default:
$nav_flag.removeClass().addClass('btn btn-secondary');
$nav_flag.attr("title", "Question Un-Flagged. Click to flag for revision.");
}
}
function build_navigator() {
$nav_container.html('')
var output = ''
for (let i = 0; i < questions.length; i ++) {
let add_class, add_href, add_status = '';
switch (question_status[i]) {
case -1:
add_class = 'btn-danger progress-bar-striped';
add_href = 'href="#"';
add_status = 'Incomplete';
break;
case 1:
add_class = 'btn-warning';
add_href = 'href="#"';
add_status = 'Flagged';
break;
case 2:
add_class = 'btn-success';
add_href = 'href="#"';
add_status = 'Answered';
break;
default:
add_class = 'btn-secondary disabled';
add_href = '';
add_status = 'Unseen';
}
output += `<a ${add_href} class="q-navigator-button btn ${add_class}" name=${i} title="Question ${i+1}: ${add_status}">Q${i + 1}</a>`;
}
$nav_container.html(output);
}
function update_navigator() {
let button = $(`.q-navigator-button[name=${current_question}]`)
if (current_question in question_status) {
switch (question_status[current_question]) {
case -1:
button.removeClass().addClass("q-navigator-button btn btn-danger progress-bar-striped");
button.attr("title", `Question ${current_question + 1}: Incomplete`);
break;
case 1:
button.removeClass().addClass("q-navigator-button btn btn-warning");
button.attr("title", `Question ${current_question + 1}: Flagged`);
break;
case 2:
button.removeClass().addClass("q-navigator-button btn btn-success");
button.attr("title", `Question ${current_question + 1}: Answered`);
break;
default:
button.removeClass().addClass("q-navigator-button btn btn-secondary disabled");
button.attr("title", `Question ${current_question + 1}: Unseen`);
}
}
}
function start() {
$("#btn-start-quiz").fadeOut();
$(".btn-quiz-return").fadeIn();
$(".quiz-console").fadeIn();
$("#quiz-settings").fadeOut();
$("#quiz-navigator").fadeOut();
$(".quiz-start-text").fadeOut();
questions = JSON.parse(window.localStorage.getItem('questions'));
total_questions = questions.length;
start_time = window.localStorage.getItem('start_time');
time_limit = window.localStorage.getItem('time_limit');
let get_answers = window.localStorage.getItem('answers');
if (get_answers != null) {
answers = JSON.parse(get_answers);
}
let get_status = window.localStorage.getItem('question_status');
if (get_status != null) {
question_status = JSON.parse(get_status);
}
render_question();
build_navigator();
check_flag();
if (time_limit != 'null' && time_limit != null) {
$("#q-timer-widget").fadeIn();
time_remaining = get_time_remaining();
clock = setInterval(timer, 1000);
}
}
function check_started() {
let questions = window.localStorage.getItem('questions');
let time_limit = window.localStorage.getItem('time_limit');
let start_time = window.localStorage.getItem('start_time')
if (questions != null && start_time != null && time_limit != null) {
start();
}
}
function get_time_remaining() {
var end_time = new Date(time_limit).getTime();
var _start_time = new Date().getTime();
return end_time - _start_time;
}
function timer() {
var hours = Math.floor((time_remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
var minutes = Math.floor((time_remaining % (1000 * 60 * 60)) / (1000 * 60));
var seconds = Math.floor((time_remaining % (1000 * 60)) / 1000);
if (time_remaining > 0) {
var timer_display = '';
if (hours > 0) {
timer_display = `${hours.toString()}:`;
}
if (minutes > 0 || hours > 0) {
if (minutes < 10) {
timer_display += `0${minutes.toString()}:`;
} else {
timer_display += `${minutes.toString()}:`;
}
}
if (seconds < 10) {
timer_display += `0${seconds.toString()}`;
} else {
timer_display += seconds.toString();
}
$timer.html(timer_display);
time_remaining -= 1000
} else {
$timer.html('Expired');
clearInterval(clock);
stop()
}
}
function stop() {
$quiz_render.fadeOut();
$quiz_navigator.fadeOut();
$quiz_timeout.fadeIn();
$("#btn-toggle-navigator").addClass('disabled');
$("#btn-toggle-settings").addClass('disabled')
}
function count_questions(status) {
output = 0;
for (let i = 0; i < Object.keys(question_status).length; i++) {
key = Object.keys(question_status)[i];
if (question_status[key] == status){
output ++;
}
}
return output;
}
// Variable Definitions
const _id = window.localStorage.getItem('_id');
var current_question = 0;
var total_questions = 0;
var question_status = {};
var answers = {};
var questions = [];
var time_limit, start_time, time_remaining;
var display_settings = get_settings_from_storage();
const $quiz_settings = $("#quiz-settings");
const $quiz_navigator = $("#quiz-navigator");
const $quiz_render = $("#quiz-render");
const $quiz_timeout = $("#quiz-timeout");
const $nav_flag = $("#q-nav-flag");
const $nav_next = $("#q-nav-next");
const $nav_prev = $("#q-nav-prev");
const $nav_container = $("#navigator-container");
const $timer = $("#q-timer-display");
var clock
var toggle_settings = false;
var toggle_navigator = false;
const $question_title = $("#quiz-question-title");
const $question_header = $("#quiz-question-header");
const $question_text = $("#quiz-question-text");
const $question_options = $("#quiz-question-options");
const $progress_skipped = $("#skipped-bar");
const $progress_answered = $("#answered-bar");
const $progress_flagged = $("#flagged-bar");
const $skipped_count = $("#skipped-count");
const $answered_count = $("#answered-count");
const $flagged_count = $("#flagged-count");
// Execution on Load
apply_settings(display_settings);
check_started();

View File

@ -4,7 +4,7 @@ $(document).ready(function() {
}); });
$('.test-code-input').keyup(function() { $('.test-code-input').keyup(function() {
var input = $(this).val().split("-").join("").split("—").join(""); // remove hyphens and mdashes var input = $(this).val().split("-").join("").split("—").join("");
if (input.length > 0) { if (input.length > 0) {
input = input.match(new RegExp('.{1,4}', 'g')).join("—"); input = input.match(new RegExp('.{1,4}', 'g')).join("—");
} }
@ -15,41 +15,72 @@ $(document).ready(function() {
$('form[name=form-quiz-start]').submit(function(event) { $('form[name=form-quiz-start]').submit(function(event) {
var $form = $(this); var $form = $(this);
var alert = document.getElementById('alert-box');
var data = $form.serialize(); var data = $form.serialize();
alert.innerHTML = ''
$.ajax({ $.ajax({
url: window.location.pathname, url: window.location.pathname,
type: 'POST', type: 'POST',
data: data, data: data,
dataType: 'json', dataType: 'json',
success: function(response) { success: function(response) {
window.location.href = "/admin/login/"; var _id = response._id
window.localStorage.setItem('_id', _id);
window.location.href = `/test/`;
}, },
error: function(response) { error: function(response) {
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) { error_response(response);
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
} else if (response.responseJSON.error instanceof Array) {
for (var i = 0; i < response.responseJSON.error.length; i ++) {
alert.innerHTML = alert.innerHTML + `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
}
}
} }
}); });
event.preventDefault(); event.preventDefault();
}); });
function error_response(response) {
const $alert = $("#alert-box");
$alert.html('');
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
$alert.html(`
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`);
} else if (response.responseJSON.error instanceof Array) {
var output = ''
for (var i = 0; i < response.responseJSON.error.length; i ++) {
output += `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
$alert.html(output);
}
}
}
// Dismiss Cookie Alert
$('#dismiss-cookie-alert').click(function(event){
$.ajax({
url: '/cookies/',
type: 'GET',
data: {
time: Date.now()
},
dataType: 'json',
success: function(response){
console.log(response);
},
error: function(response){
console.log(response);
}
})
event.preventDefault();
})

View File

@ -0,0 +1,8 @@
{% extends "quiz/components/base.html" %}
{% block content %}
<h1>Page Not Found</h1>
<p>
The page you were looking for does not exist. Try going back and navigating to the desired destination correctly.
</p>
{% endblock %}

View File

@ -0,0 +1,279 @@
{% extends "quiz/components/base.html" %}
{% block style %}
<link
rel="stylesheet"
href="{{ url_for('.static', filename='css/quiz.css') }}"
/>
{% endblock %}
{% block content %}
<div id="alert-box" tabindex="-1"></div>
<div class="container quiz-panel" id="quiz-settings" tabindex="-1">
<h1>Adjust Display Settings</h1>
<div class="container quiz-start-text">
You can use this panel to adjust the display settings for the exam. Please use the menu below to select the font face and font size. Below is a sample question so you can see how the exam will render with your chosen settings.
</div>
<div class="alert alert-primary quiz-start-text" role="alert">
<strong>Note</strong>: Some fonts may not be available depending on your device and/or operating system.
</div>
<form action="#" name="quiz-configuration">
<div class="container">
<div class="row gx-5 gy-5">
<div class="col">
<h5>
Select Font
</h5>
<div class="form-check">
<input type="radio" class="form-check-input" id="osdefault" name="font-select" value="osdefault" checked>
<label for="osdefault" class="form-check-label q-f-osdefault">OS Default</label>
</div>
<div class="form-check">
<input type="radio" class="form-check-input" id="arial" name="font-select" value="arial">
<label for="arial" class="form-check-label q-f-arial">Arial</label>
</div>
<div class="form-check">
<input type="radio" class="form-check-input" id="comicsans" name="font-select" value="comicsans">
<label for="comicsans" class="form-check-label q-f-comicsans">Comic Sans</label>
</div>
<div class="form-check">
<input type="radio" class="form-check-input" id="opendyslexic" name="font-select" value="opendyslexic">
<label for="opendyslexic" class="form-check-label q-f-opendyslexic">OpenDyslexic</label>
</div>
<div class="form-check">
<input type="radio" class="form-check-input" id="tahoma" name="font-select" value="tahoma">
<label for="tahoma" class="form-check-label q-f-tahoma">Tahoma</label>
</div>
<div class="form-check">
<input type="radio" class="form-check-input" id="verdana" name="font-select" value="verdana">
<label for="verdana" class="form-check-label q-f-verdana">Verdana</label>
</div>
</div>
<div class="col">
<h5>
Select Font Size
</h5>
<div class="form-check">
<input type="radio" class="form-check-input" id="12pt" name="font-size" value="12pt" checked>
<label for="12pt" class="form-check-label q-f-12pt">12pt</label>
</div>
<div class="form-check">
<input type="radio" class="form-check-input" id="14pt" name="font-size" value="14pt" checked>
<label for="14pt" class="form-check-label q-f-14pt">14pt (Default)</label>
</div>
<div class="form-check">
<input type="radio" class="form-check-input" id="16pt" name="font-size" value="16pt">
<label for="16pt" class="form-check-label q-f-16pt">16pt</label>
</div>
<div class="form-check">
<input type="radio" class="form-check-input" id="18pt" name="font-size" value="18pt">
<label for="18pt" class="form-check-label q-f-18pt">18pt</label>
</div>
</div>
</div>
<div class="row gx-5 gy-5 mt-1">
<div class="col">
<h5>Select Background Colour</h5>
<div class="p-3 bg-light text-dark">
<div class="form-check">
<input type="radio" class="form-check-input" id="bg-light" name="bg-select" value="bg-light" checked>
<label for="bg-light" class="form-check-label">Default Light</label>
</div>
</div>
<div class="p-3 q-bg-light-1 text-dark">
<div class="form-check">
<input type="radio" class="form-check-input" id="q-bg-light-1" name="bg-select" value="q-bg-light-1">
<label for="q-bg-light-1" class="form-check-label">Light Shade 1</label>
</div>
</div>
<div class="p-3 q-bg-light-2 text-dark">
<div class="form-check">
<input type="radio" class="form-check-input" id="q-bg-light-2" name="bg-select" value="q-bg-light-2">
<label for="q-bg-light-2" class="form-check-label">Light Shade 2</label>
</div>
</div>
<div class="p-3 alert-primary text-dark">
<div class="form-check">
<input type="radio" class="form-check-input" id="alert-primary" name="bg-select" value="alert-primary">
<label for="alert-primary" class="form-check-label">Blue</label>
</div>
</div>
<div class="p-3 alert-secondary text-dark">
<div class="form-check">
<input type="radio" class="form-check-input" id="alert-secondary" name="bg-select" value="alert-secondary">
<label for="alert-secondary" class="form-check-label">Grey 1</label>
</div>
</div>
<div class="p-3 alert-dark text-dark">
<div class="form-check">
<input type="radio" class="form-check-input" id="alert-dark" name="bg-select" value="alert-dark">
<label for="alert-dark" class="form-check-label">Grey 2</label>
</div>
</div>
<div class="p-3 bg-dark text-light">
<div class="form-check">
<input type="radio" class="form-check-input" id="bg-dark" name="bg-select" value="bg-dark">
<label for="bg-dark" class="form-check-label">Dark</label>
</div>
</div>
</div>
</div>
</div>
</form>
<div class="container question-container quiz-start-text">
<h4 class="question-title">Sample Question</h4>
<p class="question-header">
Korfball is a mixed-sex, controlled-contact, indoor, invasion ball sport. The sport originated in the Netherlands. It is a mixed-sex team sport. Its governing body is the International Korball Federation. There are numerous korfball leagues and associations around the world. A korfball match is officiated by a referee.
</p>
<p class="question-text">
In order to be a referee, what do you need to know?
</p>
<div class="options">
<div class="form-check">
<input type="radio" class="form-check-input" id="sample0" name="sample" value="0">
<label for="sample0" class="form-check-label">The rules of korfball</label>
</div>
<div class="form-check">
<input type="radio" class="form-check-input" id="sample1" name="sample" value="1">
<label for="sample1" class="form-check-label">The way of the Jedi Order</label>
</div>
<div class="form-check">
<input type="radio" class="form-check-input" id="sample2" name="sample" value="2">
<label for="sample2" class="form-check-label">The <i>Dungeons &amp; Dragons Fifth Edition Monster Manual</i>.</label>
</div>
<div class="form-check">
<input type="radio" class="form-check-input" id="sample3" name="sample" value="2">
<label for="sample3" class="form-check-label">The Trade Union and Labour Relations (Consolidation) Act 1992.</label>
</div>
<div class="form-check">
<input type="radio" class="form-check-input" id="sample4" name="sample" value="4" checked>
<label for="sample4" class="form-check-label">All of the above</i></label>
</div>
</div>
</div>
<div class="row mt-3">
<p class="quiz-start-text">
When you are happy with the settings, click <strong>&lsquo;Start the Exam&rsquo;</strong> below to proceed. You can change these settings at any time using the red gear <a class="btn btn-danger btn-dummy" tabindex="-1" aria-title="Settings" title="Settings"><i class="bi bi-gear-fill"></i></a> button on the exam console.
</p>
<div class="control-button-container">
<a href="#" class="btn btn-success btn-quiz-control" id="btn-start-quiz">Start the Exam</a>
<a href="#" class="btn btn-success btn-quiz-control btn-quiz-return" style="display: none;">Resume Exam</a>
</div>
</div>
</div>
<div class="container quiz-panel" style="display: none;" id="quiz-navigator" tabindex="-1">
<h1 class="navigator-text">
Question Grid
</h1>
<h1 class="review-text" style="display: none;">
Review Your Answers
</h1>
<div class="navigator-text">
This question grid displays the progress you have on the exam so far. Each question is represented by an icon below, and you can click on each icon to skip to that question.
The icons below are colour-coded to represent the status of each question.
</div>
<div class="review-text" style="display: none;">
You can use this panel to review your answers before you submit the exam. You will not be able to amend your answers after you submit.
Each question is represented by an icon below, and you can click on each icon to skip to that question. The icons below are colour-coded to represent the status of each question.
</div>
<table class="navigator-help">
<tr>
<td>
<a class="q-navigator-button btn btn-danger progress-bar-striped btn-dummy" title="Question: Incomplete">Q</a>
</td>
<td>
A red and striped icon represents a question that you have skipped, and have not otherwise flagged for revision.
</td>
</tr>
<tr>
<td>
<a class="q-navigator-button btn btn-warning btn-dummy" title="Question: Flagged">Q</a>
</td>
<td>
A yellow icon represents a question that you have flagged for revision.
</td>
</tr>
<tr>
<td>
<a class="q-navigator-button btn btn-success btn-dummy" title="Question: Answered">Q</a>
</td>
<td>
A green icon represents a question that you have answered, and have not otherwise flagged for revision.
</td>
</tr>
<tr>
<td>
<a class="q-navigator-button btn btn-secondary disabled btn-dummy" title="Question: Unseen">Q</a>
</td>
<td>
A greyed-out icon represents a question that you have not yet seen.
</td>
</tr>
</table>
<div id="navigator-container">
</div>
<div class="control-button-container navigator-text">
<a href="#" class="btn btn-success btn-quiz-control btn-quiz-return">Resume Exam</a>
</div>
<div class="control-button-container review-text">
<a href="#" class="btn btn-danger btn-quiz-control btn-quiz-return">Back to Exam</a>
<a href="#" class="btn btn-success quiz-button-submit">Submit Exam</a>
</div>
</div>
<div class="container quiz-panel quiz-console" style="display: none" id="quiz-render">
<h1>
Exam Console
</h1>
<div class="container question-container">
<div class="progress">
<div id="answered-bar" class="progress-bar bg-success" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
<div id="flagged-bar" class="progress-bar bg-warning" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
<div id="skipped-bar" class="progress-bar progress-bar-striped bg-danger" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<div class="counters">
<div id="answered-count" class="badge rounded-pill bg-success" style="display: none;">Answered: 0</div>
<div id="flagged-count" class="badge rounded-pill bg-warning"style="display: none;">Flagged: 0</div>
<div id="skipped-count" class="badge rounded-pill bg-danger progress-bar-striped"style="display: none;">Skipped: 0</div>
</div>
<h4 class="question-title" id="quiz-question-title" tabindex="-1">
Question x.
</h4>
<p class="question-header" id="quiz-question-header">
Question Header
</p>
<p class="question-text" id="quiz-question-text">
Question Text
</p>
<div class="options" id="quiz-question-options">
Options
</div>
</div>
<div class="control-button-container">
<a href="#" class="btn btn-success q-question-nav" id="q-nav-prev" title="Previous Question"><i class="bi bi-caret-left-square-fill"></i>&nbsp;Back</a>
<a href="#" class="btn btn-secondary" id="q-nav-flag" title="Question Un-Flagged. Click to flag for revision."><i class="bi bi-flag-fill"></i></a>
<a href="#" class="btn btn-success q-question-nav" id="q-nav-next" title="Next Question">Next&nbsp;<i class="bi bi-caret-right-square-fill"></i></a>
</div>
<div class="control-button-container">
<a href="#" class="btn btn-primary" id="q-review-answers" title="Submit Answers">Submit Answers</a>
</div>
</div>
<div class="container quiz-panel quiz-timeout" style="display: none;" id="quiz-timeout">
<h1>
Time Limit Expired
</h1>
<p>
The time limit set for this exam has expired. You must submit your answers immediately.
</p>
<div class="control-button-container">
<a href="#" class="btn btn-success quiz-button-submit" title="Submit Exam">Submit Exam</a>
</div>
</div>
{% endblock %}
{% block script %}
<script
type="text/javascript"
src="{{ url_for('.static', filename='js/quiz.js') }}"
></script>
{% endblock %}

View File

@ -15,7 +15,10 @@
rel="stylesheet" rel="stylesheet"
href="{{ url_for('.static', filename='css/style.css') }}" href="{{ url_for('.static', filename='css/style.css') }}"
/> />
<title>{% block title %} SKA Referee Test {% endblock %}</title> {% block style %}
{% endblock %}
<title>{% block title %} SKA Referee Test Beta {% endblock %}</title>
{% include "quiz/components/og-meta.html" %}
</head> </head>
<body class="bg-light"> <body class="bg-light">
@ -28,11 +31,11 @@
{% include "quiz/components/server-alerts.html" %} {% include "quiz/components/server-alerts.html" %}
{% endblock %} {% endblock %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div>
<footer class="container site-footer"> <footer class="container site-footer">
{% include "quiz/components/footer.html" %} {% include "quiz/components/footer.html" %}
</footer> </footer>
</div>
<!-- JQuery, Popper, and Bootstrap js dependencies --> <!-- JQuery, Popper, and Bootstrap js dependencies -->
<script <script
@ -51,9 +54,22 @@
crossorigin="anonymous" crossorigin="anonymous"
></script> ></script>
<!-- Custom js --> <!-- Custom js -->
<script type="text/javascript">
var csrf_token = "{{ csrf_token() }}";
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
xhr.setRequestHeader("X-CSRFToken", csrf_token);
}
}
});
</script>
<script <script
type="text/javascript" type="text/javascript"
src="{{ url_for('.static', filename='js/script.js') }}" src="{{ url_for('.static', filename='js/script.js') }}"
></script> ></script>
{% block script %}
{% endblock %}
</body> </body>
</html> </html>

View File

@ -0,0 +1,3 @@
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&rsquo;s personal GIT repository</a> under an MIT License.</p>
<p>All questions in the test are &copy; The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
<p>OpenDyslexic 3 is an open source typeface created by Abbie Gonzalez, licensed under a <a href="https://scripts.sil.org/OFL">SIL-OFL</a>. More information about OpenDyslexic is available <a href="https://opendyslexic.org/">on the project web site</a>.</p>

View File

@ -1,5 +1,14 @@
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark"> <nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark" id="primary-nav">
<div class="container"> <div class="container">
<a href="/" class="navbar-brand mb-0 h1">SKA Refereeing Test </a> <p class="navbar-brand mb-0 h1">SKA Refereeing Test (Beta)</p>
<div class="quiz-console w-100" style="display: none;" id="q-topbar">
<div class="d-flex justify-content align-middle">
<div class="container d-flex justify-content-center">
<span class="text-light q-timer" id="q-timer-widget" style="display: none;"><i class="bi bi-stopwatch-fill"></i>&nbsp;<span id="q-timer-display"></span></span>
</div>
<a href="#" class="btn btn-warning" aria-title="Question Grid" title="Question Grid" id="btn-toggle-navigator"><i class="bi bi-table"></i></a>
<a href="#" class="btn btn-danger" aria-title="Settings" title="Settings" id="btn-toggle-settings"><i class="bi bi-gear-fill"></i></a>
</div>
</div>
</div> </div>
</nav> </nav>

View File

@ -0,0 +1,17 @@
<meta name="description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
<meta property="og:locale" content="en_UK" />
<meta property="og:type" content="website" />
<meta property="og:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
<meta property="og:url" content="{{ url_for(request.endpoint, _external = True, **request.view_args) }}" />
<meta property="og:site_name" content="Scottish Korfball Association Referee Theory Exam" />
<meta property="og:image" content="{{ url_for('static', filename='favicon.png', _external = True) }}" />
<meta property="og:image:alt" content="Logo of the SKA Refereeing Exam App" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
<meta name="twitter:image" content="{{ url_for('static', filename='favicon.png', _external = True) }}" />
<meta name="twitter:image:alt" content="Logo of the SKA Refereeing Exam App" />
<meta name="twitter:creator" content="@viveksantayana" />
<meta name="twitter:site" content="@viveksantayana" />
<meta name="theme-color" content="#343a40" />

View File

@ -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 %}

View File

@ -4,21 +4,18 @@
<h1>SKA Refereeing Theory Exam</h1> <h1>SKA Refereeing Theory Exam</h1>
<p> <p>
This app will allow you to take the Exam on-line. This app should also allow you to adjust the way the quiz is rendered to suit your access needs. This could include using a screen reader, changing the display font size or typeface, or navigating questions and answers via the keyboard. This app will enable you to take the SKA Refereeing Exam on-line. The app will further allow you to change the display settings &mdash; such as the font size, typeface, and background colour &mdash; to a layout that you may find more suitable.
</p> </p>
<p> <p>
Instructions We designed this app to prioritise accessibility for the exam, and to ensure that it could be presented in a manner that does not put people with any specific needs at a disadvantage.
</p> </p>
<p> <p>
Other Info The presentation of the questions is just a start, and we acknowledge we still have a long way to go. We welcome any feedback on how we can further improve this test.
</p> </p>
<div class="button-container">
<p> <a href="{{ url_for('quiz_views.instructions') }}" class="btn btn-success">
When you are ready to begin the quiz, click the following button. <i class="bi bi-book-fill button-icon"></i>
</p> Read the Instructions
</a>
<a href="{{ url_for('quiz_views.start') }}" class="btn btn-success">Take the Quiz</a> </div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,61 @@
{% extends "quiz/components/base.html" %}
{% block content %}
<div class="instruction-container">
<h3>Instructions</h3>
<ul>
<li>
The exam comprises 100 multiple-choice questions.
</li>
<li>
For each question, answer what decision you would give as a referee unless the question instructs otherwise.
</li>
<li>
You will be able to customise the display settings of the exam from the settings panel by clicking on the red gear button <a class="btn btn-danger" aria-title="Settings" title="Settings" onclick="return false;"><i class="bi bi-gear-fill"></i></a>.
</li>
<li>
You can view your progress at a glance, as well as navigate to any question in the quiz, using the question grid, accessed via the yellow grid button <a class="btn btn-warning" aria-title="Question Grid" title="Question Grid" onclick="return false;"><i class="bi bi-table"></i></a>.
</li>
<li>
If you are unsure of the answer to a question or would like to revise a question, you can flag the question to review it later on using the flag button button <a class="btn btn-secondary" id="q-nav-flag" title="Flag Button." onclick="return false;"><i class="bi bi-flag-fill"></i></a>.
</li>
</ul>
</div>
<div class="instruction-container">
<h3>
Technical Details
</h3>
<ul>
<li>
To ensure compatibility, make sure you use the latest version of Firefox, Chrome, Safari, or other fairly modern browser. Make sure JavaScript is enabled.
</li>
<li>
Once you start the exam, your answers are stored locally on your browser until you submit your final results to the server.
</li>
<li>
Do not close the window, refresh the page, or navigate to a different page as you could risk losing your progress.
</li>
<li>
<strong>If you have any technical issues while taking the exam, please report them immediately to <a href="mailto:refereeing@scotlandkorfball.co.uk">refereeing@scotlandkorfball.co.uk</a></strong>.
</li>
</ul>
</div>
<div class="instruction-container">
<h4>
Results
</h4>
<p>
The results of your exam will be processed immediately and sent to the SKA Refereeing Coordinator. You will also be emailed a copy of your results.
</p>
<p>
When you are ready to begin the quiz, click the following button.
</p>
</div>
<div class="button-container">
<a href="{{ url_for('quiz_views.start') }}" class="btn btn-success">
<i class="bi bi-pencil-fill button-icon"></i>
Take the Exam
</a>
</div>
{% endblock %}

View File

@ -3,23 +3,24 @@
{% block content %} {% block content %}
<h1>Privacy Policy</h1> <h1>Privacy Policy</h1>
This web app stores data using cookies. The web site only stores the minimum information it needs to function.
<h5>Site Administrators</h5>
<ul> <ul>
<li>This web app stores data using cookies. The web site only stores the minimum information it needs to function.</li> <li>For site administrators, this web site uses encrypted cookies to store data from your log-in session.</li>
<li>Site Administrators</li> <li>User information for administrators is encrypted and stored in a secure database, and are expunged when an account is deleted.</li>
<ul> </ul>
<li>For site administrators, this web site uses encrypted cookies to store data from your log-in session,</li>
<li>User information for administrators is encrypted and stored in a secure database, and are expunged when an account is deleted.</li> <h5>Test Candidates</h5>
</ul> <ul>
<li>Test Candidates</li> <li>The web site will not be trackin your log in, and all information about your test attempt will be stored on your device until you submit it to the server.</li>
<ul> <li>Data from your test, including identifying information such as your name and email address, will be recorded by the Scottish Korfball Association in order to oversee the training and qualification of referees.</li>
<li>The web site will not be trackin your log in, and all information about your test attempt will be stored on your device until you submit it to the server.</li> <li>These records will be kept for three years or until the expiration of the theory exam qualification (whichever is later), and will be expunged securely thereafter.</li>
<li>Data from your test, including identifying information such as your name and email address, will be recorded by the Scottish Korfball Association in order to oversee the training and qualification of referees.</li> <li>All identifying information about candidates will be encrypted and stored in a secure database.</li>
<li>These records will be kept for HOW MANY?? years and will be expunged securely thereafter.</li> </ul>
<li>All identifying information about candidates will be encrypted and stored in a secure database.</li>
</ul> <h5>Requests to Delete Data</h5>
<li>Requests to Delete Data</li> <ul>
<ul> <li>You can request to have any of your data that is held here deleted by emailing <a href="mailto:refereeing@scotlandkorfball.co.uk">refereeing@scotlandkorfball.co.uk</a>.</li>
<li>You can request to have any of your data that is held here deleted by emailing WHOM?</li>
</ul>
</ul> </ul>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,44 @@
{% extends "quiz/components/base.html" %}
{% block content %}
<h1>SKA Refereeing Theory Exam</h1>
<h2>Candidate Results</h2>
<h3 class="results-name">
<span class="surname">{{ entry.name.surname }}</span>, {{ entry.name.first_name }}
</h3>
<strong class="results-details">Email Address</strong>: {{ entry.email }} <br />
{% if entry.club %}
<strong class="results-details">Club</strong>: {{ entry.club }} <br />
{% endif%}
{% if entry.status == 'late' %}
Your results are invalid because you did not submit your exam in time. Please contact the SKA Refereeing Coordinator.
{% else %}
<div class="results-score">
{{ score }}
</div>
<div class="results-grade">
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:] }}
</div>
{% if entry.results.grade == 'fail' %}
Unfortunately, you have not passed the theory exam in this attempt. For your next attempt, it might help to revise the following topics:
<ul>
{% for tag in tag_output %}
<li>{{ tag }}</li>
{% endfor %}
</ul>
{% endif %}
A copy of these results will be sent to you via email.
{% endif %}
{% endblock %}

View File

@ -34,7 +34,10 @@
<div class="container form-submission-button"> <div class="container form-submission-button">
<div class="row"> <div class="row">
<div class="col text-center"> <div class="col text-center">
<button class="btn btn-md btn-success btn-block" type="submit">Start Quiz</button> <button class="btn btn-md btn-success btn-block" type="submit">
<i class="bi bi-pencil-fill button-icon"></i>
Get Ready
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,9 +1,16 @@
from flask import Blueprint, render_template, request, redirect, jsonify from flask import Blueprint, render_template, request, redirect, jsonify, session, abort, flash
from datetime import datetime from flask.helpers import url_for
from datetime import datetime, timedelta
from uuid import uuid4 from uuid import uuid4
import os
from json import loads
from flask_mail import Message
from main import db from pymongo.collection import ReturnDocument
from security import encrypt
from common.security import encrypt
from common.data_tools import generate_questions, evaluate_answers
from common.security.database import decrypt_find_one
views = Blueprint( views = Blueprint(
'quiz_views', 'quiz_views',
@ -16,13 +23,29 @@ views = Blueprint(
@views.route('/') @views.route('/')
@views.route('/home/') @views.route('/home/')
def home(): def home():
from main import db
_id = session.get('_id')
if _id and db.entries.find_one({'_id': _id}):
return redirect(url_for('quiz_views.start_quiz'))
return render_template('/quiz/index.html') return render_template('/quiz/index.html')
@views.route('/instructions/')
def instructions():
from main import db
_id = session.get('_id')
if _id and db.entries.find_one({'_id': _id}):
return redirect(url_for('quiz_views.start_quiz'))
return render_template('/quiz/instructions.html')
@views.route('/start/', methods = ['GET', 'POST']) @views.route('/start/', methods = ['GET', 'POST'])
def start(): def start():
from main import db
from .forms import StartQuiz from .forms import StartQuiz
form = StartQuiz() form = StartQuiz()
if request.method == 'GET': if request.method == 'GET':
_id = session.get('_id')
if _id and db.entries.find_one({'_id': _id}):
return redirect(url_for('quiz_views.start_quiz'))
return render_template('/quiz/start-quiz.html', form=form) return render_template('/quiz/start-quiz.html', form=form)
if request.method == 'POST': if request.method == 'POST':
if form.validate_on_submit(): if form.validate_on_submit():
@ -32,27 +55,179 @@ def start():
} }
email = request.form.get('email') email = request.form.get('email')
club = request.form.get('club') club = request.form.get('club')
test_code = request.form.get('test_code').replace('', '') test_code = request.form.get('test_code').replace('', '').upper()
user_code = request.form.get('user_code') user_code = request.form.get('user_code')
user_code = None if user_code == '' else user_code user_code = None if user_code == '' else user_code.upper()
if not db.tests.find_one({'test_code': test_code}): test = db.tests.find_one({'test_code': test_code})
if not test:
return jsonify({'error': 'The exam code you entered is invalid.'}), 400 return jsonify({'error': 'The exam code you entered is invalid.'}), 400
attempt = { if user_code and user_code not in test['time_adjustments']:
return jsonify({'error': f'The user code you entered is not valid.'}), 400
if test['expiry_date'] < datetime.utcnow():
return jsonify({'error': f'The exam code you entered expired on {test["expiry_date"].strftime("%d %b %Y")} UTC.'}), 400
if test['start_date'] > datetime.utcnow():
return jsonify({'error': f'The exam has not yet opened. Your exam code will be valid from {test["start_date"].strftime("%d %b %Y %H:%M")} UTC.'}), 400
entry = {
'_id': uuid4().hex, '_id': uuid4().hex,
'name': encrypt(name), 'name': encrypt(name),
'email': encrypt(email), 'email': encrypt(email),
'club': encrypt(club), 'club': encrypt(club),
'test-code': test_code, 'test_code': test_code,
'user_code': user_code, 'user_code': user_code
'start_time': datetime.utcnow(),
'status': 'started'
} }
if db.results.insert(attempt): if db.entries.insert_one(entry):
return jsonify({ 'success': f'Exam started at started {attempt["start_time"].strftime("%H:%M:%S")}.' }) session['_id'] = entry['_id']
return jsonify({
'success': 'Received and validated test and/or user code. Redirecting to test client.',
'_id': entry['_id']
}), 200
else: else:
errors = [*form.errors] errors = [*form.test_code.errors, *form.user_code.errors, *form.first_name.errors, *form.surname.errors, *form.email.errors, *form.club.errors]
return jsonify({ 'error': errors}), 400 return jsonify({ 'error': errors}), 400
@views.route('/api/questions/', methods=['POST'])
def fetch_questions():
from main import app, db
_id = request.get_json()['_id']
entry = db.entries.find_one({'_id': _id})
if not entry:
return jsonify({'error': 'The data that the client sent to the server is invalid. This is possibly because you have already submitted your exam and have tried to access the page again.'}), 400
test_code = entry['test_code']
user_code = entry['user_code']
test = db.tests.find_one({'test_code' : test_code})
time_limit = test['time_limit']
time_adjustment = 0
if time_limit:
_time_limit = int(time_limit)
if user_code:
time_adjustment = test['time_adjustments'][user_code]
_time_limit += time_adjustment
end_delta = timedelta(minutes=_time_limit)
end_time = datetime.utcnow() + end_delta
else:
end_time = None
update = {
'start_time': datetime.utcnow(),
'status': 'started',
'end_time': end_time
}
entry = db.entries.find_one_and_update({'_id': _id}, {'$set': update}, upsert=False, return_document=ReturnDocument.AFTER)
db.tests.find_one_and_update({'_id': test['_id']}, {'$push': {'entries': _id}})
dataset = test['dataset']
dataset_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], dataset)
with open(dataset_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
})
@views.route('/test/')
def start_quiz():
from main import db
_id = session.get('_id')
if not _id or not db.entries.find_one({'_id': _id}):
flash('Your log in was not recognised. Please sign in to the quiz again.', 'error')
return redirect(url_for('quiz_views.start'))
return render_template('quiz/client.html')
@views.route('/api/submit/', methods=['POST'])
def submit_quiz():
from main import app, db
_id = request.get_json()['_id']
answers = request.get_json()['answers']
entry = db.entries.find_one({'_id': _id})
if not entry:
return jsonify('Unrecognised ID', 'error'), 400
status = 'submitted'
if 'end_time' in entry:
if entry['end_time']:
if datetime.utcnow() > entry['end_time'] + timedelta(minutes=2):
status = 'late'
test_code = entry['test_code']
test = db.tests.find_one({'test_code' : test_code})
dataset = test['dataset']
dataset_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], dataset)
with open(dataset_path, 'r') as _dataset:
data = loads(_dataset.read())
results = evaluate_answers(data, answers)
entry = db.entries.find_one_and_update({'_id': _id}, {'$set': {
'status': status,
'submission_time': datetime.utcnow(),
'results': results,
'answers': answers
}})
return jsonify({
'success': 'Your submission has been processed. Redirecting you to receive your results.',
'_id': _id
}), 200
@views.route('/result/')
def result():
from main import db, mail
_id = session.get('_id')
entry = decrypt_find_one(db.entries, {'_id': _id})
if not entry:
return abort(404)
score = round(100*entry['results']['score']/entry['results']['max'])
tags_low = { tag: tag_result['max'] - tag_result['scored'] for tag, tag_result in entry['results']['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]
if entry['results']['grade'] == 'pass':
flavour_text_plain = """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 entry['results']['grade'] == 'merit':
flavour_text_plain = """Congratulations on achieving a merit in the refereeing exam. We are delighted that members of our community work so hard with refereeing.
"""
elif entry['results']['grade'] == 'fail':
flavour_text_plain = """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.
"""
if not entry['status'] == 'late':
email = Message(
subject="SKA Refereeing Theory Exam Results",
recipients=[entry['email']],
body=f"""SKA Refereeing Theory Exam\n\n
Candidate Results\n\n
Dear {entry['name']['first_name']},\n\n
This email is to confirm that you have took the SKA Refereeing Theory Exam. Your test has been evaluated and your results have been generated.\n\n
{entry['name']['surname']}, {entry['name']['first_name']}\n\n
Email Address: {entry['email']}\n
{f"Club: {entry['club']}" if entry['club'] else ''}\n
Date of Test: {entry['submission_time'].strftime('%d %b %Y')}\n
Score: {score}%\n
Grade: {entry['results']['grade']}\n\n
{flavour_text_plain}\n\n
Based on your answers, we would also suggest you brush up on the following topics as you continue refereeing:\n\n
{','.join(tag_output)}\n\n
Thank you for taking the time to get qualified as a referee.\n\n
Best wishes,\n
SKA Refereeing
""",
html=f"""<h1>SKA Refereeing Theory Exam</h1>
<h2>Candidate Results</h2>
<p>Dear {entry['name']['first_name']},</p>
<p>This email is to confirm that you have took the SKA Refereeing Theory Exam. Your test has been evaluated and your results have been generated.</p>
<h3>{entry['name']['surname']}, {entry['name']['first_name']}</h3>
<p><strong>Email Address</strong>: {entry['email']}</p>
{f"<p><strong>Club</strong>: {entry['club']}</p>" if entry['club'] else ''}
<p><strong>Date of Test</strong>: {entry['submission_time'].strftime('%d %b %Y')}</p>
<h1>{score}%</h1>
<h2>{entry['results']['grade']}</h2>
<p>{flavour_text_plain}</p>
<p>Based on your answers, we would also suggest you revise the following topics as you continue refereeing:</p>
<ul>
<li>{'</li><li>'.join(tag_output)}</li>
</ul>
<p>Thank you for taking the time to get qualified as a referee.</p>
<p>Best wishes,<br />
SKA Refereeing</p>
""",
)
mail.send(email)
return render_template('/quiz/result.html', entry=entry, score=score, tag_output=tag_output)
@views.route('/privacy/') @views.route('/privacy/')
def privacy(): def privacy():

23
ref-test/requirements.txt Normal file
View File

@ -0,0 +1,23 @@
blinker==1.4
cffi==1.15.0
click==8.0.3
cryptography==36.0.0
dnspython==2.1.0
dominate==2.6.0
email-validator==1.1.3
Flask==2.0.2
Flask-Bootstrap==3.3.7.1
Flask-Mail==0.9.1
Flask-WTF==1.0.0
gunicorn==20.1.0
idna==3.3
itsdangerous==2.0.1
Jinja2==3.0.3
MarkupSafe==2.0.1
pip-autoremove==0.10.0
pycparser==2.21
pymongo==4.0
python-dotenv==0.19.2
visitor==0.1.3
Werkzeug==2.0.2
WTForms==3.0.0

4
ref-test/wsgi.py Normal file
View File

@ -0,0 +1,4 @@
from main import app
if __name__ == '__main__':
app.run()

2
src/html/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Allow: /