Compare commits

...

615 Commits

Author SHA1 Message Date
43895bead0 Renamed containers 2022-06-17 13:01:27 +01:00
067ef4fd7f Production debug 2022-06-17 12:58:46 +01:00
73f31016fd Updated to newest Docker version syntax 2022-06-17 10:28:34 +01:00
25115a6fae Wrote installation instructions in the Readme 2022-06-17 02:01:06 +01:00
6028ac2d3c Renamed services
Made configs and scripts consistent
2022-06-17 02:00:37 +01:00
225ef71518 Added conditional env loading.
Making it work in both dev and production without changing .env files.
2022-06-17 01:10:11 +01:00
fbae88eed1 Production Ready 2022-06-17 01:09:15 +01:00
647d156802 Dotenv production setting 2022-06-16 15:20:54 +01:00
08a140a73b Finished common section of app 2022-06-16 15:19:26 +01:00
a8a01e17da Updated wsgi 2022-06-16 14:19:04 +01:00
3f59d1b1b7 Debug time limit handling 2022-06-16 14:15:18 +01:00
5123365567 Debug password reset methods 2022-06-16 14:14:21 +01:00
d0166f0901 Debug html formatting 2022-06-16 14:13:25 +01:00
f6231dc779 Debug password reset url 2022-06-16 14:13:07 +01:00
5c8435d39e Added cookie consent 2022-06-16 13:22:06 +01:00
e4e07c43b4 Updated nginx configs 2022-06-16 13:21:27 +01:00
d202e83189 Updated server files 2022-06-16 12:52:55 +01:00
e264b808fc Added email notifications 2022-06-16 12:46:03 +01:00
4b08c830a1 Finished quiz and debugging 2022-06-16 10:44:48 +01:00
b9d45f94fe Finished Quiz Console 2022-06-16 01:03:06 +01:00
2ea778143e Finished admin console 2022-06-15 23:54:44 +01:00
62160beab2 Restored static and template files 2022-06-15 11:43:04 +01:00
1a7983052f Finished common views 2022-06-15 11:33:09 +01:00
a1bee61679 Completed admin views
Corrected model method return values
2022-06-15 11:23:38 +01:00
126bf9203c Added a whole lot of views.
Finished quiz API views
Finished question generator and answer eval
2022-06-14 22:55:11 +01:00
a58f267586 Added more views 2022-06-12 22:48:13 +01:00
22878b5398 Added relationships between database models 2022-06-12 21:20:09 +01:00
52b44128fa Tool function to parse test codes 2022-06-12 21:04:21 +01:00
8439d99949 Added models and views 2022-06-12 21:03:51 +01:00
66e7b2b9f8 Views into module 2022-06-12 21:02:22 +01:00
9459b93c9b Re-organised admin views into single module 2022-06-12 21:01:03 +01:00
09e444344d Importing models to create database 2022-06-12 20:53:03 +01:00
767dcede54 Changed location of views to avoid circular import 2022-06-12 20:51:36 +01:00
4431564304 Created config module to avoid circular import 2022-06-12 20:50:57 +01:00
da821bcadb moved user model 2022-06-11 18:26:53 +01:00
b58a23cf13 Added new models 2022-06-11 18:26:39 +01:00
dc126459bc Made db.create_all() conditional 2022-06-11 18:19:03 +01:00
2c5ed21011 Fixed the weird database issue 2022-06-11 18:08:24 +01:00
59281db9cb merged 2022-06-11 15:39:53 +01:00
2a3927a140 Merge branch 'sqlite-ground-up' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test into sqlite-ground-up 2022-06-11 15:38:24 +01:00
9a225543c6 db create all the time 2022-06-11 15:27:27 +01:00
dd8685b103 Fixed database connection issue 2022-06-11 15:16:35 +01:00
625ef8883b Fixed database connection issue 2022-06-11 15:16:35 +01:00
f903f9d060 Update reqs 2022-06-11 13:30:28 +01:00
eac9ee7ab1 Update reqs 2022-06-11 13:30:28 +01:00
8946e3eaf3 Progress
Problems with database access and subdirectories still
2022-06-11 13:26:50 +01:00
b27016aaf4 Progress
Problems with database access and subdirectories still
2022-06-11 13:26:50 +01:00
89788550fb Name correction 2022-06-11 11:33:06 +01:00
6992a75855 Name correction 2022-06-11 11:33:06 +01:00
9539ba22fe Progress 2022-06-11 11:29:15 +01:00
85ced0cc20 Progress 2022-06-11 11:29:15 +01:00
eac6cac7bc Restore 2022-06-11 02:56:38 +01:00
fcfde34c72 Restore 2022-06-11 02:56:38 +01:00
1b111727be Started from scratch and failed
Issue with register_blueprint
2022-06-11 02:39:47 +01:00
436c8e0e2d Started from scratch and failed
Issue with register_blueprint
2022-06-11 02:39:47 +01:00
9c0c7f6ba1 Added new files 2022-06-10 22:11:29 +01:00
7af588da6c Added new files 2022-06-10 22:11:29 +01:00
cfd750894a Whitespace corrections 2022-04-17 18:42:40 +01:00
ede71f7d82 Bugfix change event not triggering 2021-12-08 13:25:50 +00:00
27706572ed Added click area to select background colour 2021-12-08 13:21:18 +00:00
08da6d71c4 update function call from attr to prop 2021-12-08 13:20:40 +00:00
c5a0bbb827 Local jQuery library fallback 2021-12-08 13:20:07 +00:00
8680c73e86 Changed answer option object semantics to indices
Evaluating answer should no longer require string matching
Answers evaluated based on matching index value integers
2021-12-08 12:49:32 +00:00
ff74e92297 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-08 12:46:49 +00:00
6b3b255cfd Only show topics to revise if failed 2021-12-08 12:46:33 +00:00
ecdb5df561 Merge 2021-12-08 11:33:27 +00:00
c5b4d948f5 Removed personal information 2021-12-08 11:29:22 +00:00
c40ef7d070 Removed personal information 2021-12-08 11:27:54 +00:00
b8081bc1c8 Cookie bugfix, removed 'session' string from expiry/age 2021-12-08 11:26:18 +00:00
efec599225 Debug form error handlers 2021-12-07 16:17:59 +00:00
614ad91e3d Debug form error handlers 2021-12-07 16:17:59 +00:00
6605620d9c Named image 2021-12-07 16:03:56 +00:00
cd4d52692c Named image 2021-12-07 16:03:56 +00:00
2038965dcb Bug fix and data persistence 2021-12-07 15:52:58 +00:00
b4c94a7ddb Bug fix and data persistence 2021-12-07 15:52:58 +00:00
f144097c5d Bugfix: security key location 2021-12-07 15:25:22 +00:00
63f72e35d2 Bugfix: security key location 2021-12-07 15:25:22 +00:00
57ee0bf971 Bugfix: encryption lockout 2021-12-07 15:15:16 +00:00
735cdec139 Bugfix: encryption lockout 2021-12-07 15:15:16 +00:00
8591184da6 Bug fix: evaluating question 97 2021-12-07 15:03:21 +00:00
38d3420e4d Bug fix: evaluating question 97 2021-12-07 15:03:21 +00:00
7b5861ade6 Merge 2021-12-07 13:37:12 +00:00
f0437dceaa Merge 2021-12-07 13:37:12 +00:00
fa4640840b Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 13:34:02 +00:00
ca30b002ed Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 13:34:02 +00:00
05a564f41d Typo 2021-12-07 13:33:31 +00:00
7b2f155b14 Typo 2021-12-07 13:33:31 +00:00
f9628df8c7 Some more minor fixes to containerising and ignore 2021-12-07 13:29:25 +00:00
a10bb0384f Some more minor fixes to containerising and ignore 2021-12-07 13:29:25 +00:00
b5443c1331 Re-wrote compose and conf removing personal info 2021-12-07 13:26:24 +00:00
fe83a47dae Re-wrote compose and conf removing personal info 2021-12-07 13:26:24 +00:00
3d7e144d12 Finesse log in form css 2021-12-07 12:39:29 +00:00
3c9fcae9f8 Finesse log in form css 2021-12-07 12:39:29 +00:00
d093c4e636 Finesse log in form css 2021-12-07 12:39:29 +00:00
1d5dfaa5ee Finesse log in form css 2021-12-07 12:39:29 +00:00
57f233f20f Finessed remaining block question counter 2021-12-07 12:27:06 +00:00
a35d0ef7f1 Finessed remaining block question counter 2021-12-07 12:27:06 +00:00
4a5bc48889 Finessed remaining block question counter 2021-12-07 12:27:06 +00:00
0bdd50f432 Finessed remaining block question counter 2021-12-07 12:27:06 +00:00
f2fb52aeca Correcting an error 2021-12-07 07:24:39 +00:00
52afd249b7 Correcting an error 2021-12-07 07:24:39 +00:00
4a8080f0c8 Correcting an error 2021-12-07 07:24:39 +00:00
443568f8ff Correcting an error 2021-12-07 07:24:39 +00:00
5ab2e7e608 Finesse cookie dismiss button 2021-12-07 07:22:11 +00:00
7b1ae3b354 Finesse cookie dismiss button 2021-12-07 07:22:11 +00:00
bae8d1e6f8 Finesse cookie dismiss button 2021-12-07 07:22:11 +00:00
36ed23564d Finesse cookie dismiss button 2021-12-07 07:22:11 +00:00
4585b93136 Finesse cookie consent display 2021-12-07 07:09:28 +00:00
14272ba0b8 Finesse cookie consent display 2021-12-07 07:09:28 +00:00
0130f7412d Finesse cookie consent display 2021-12-07 07:09:28 +00:00
8b4ca65122 Finesse cookie consent display 2021-12-07 07:09:28 +00:00
f3f8ac955c removed fake link 2021-12-07 07:04:58 +00:00
8bfc8e119c removed fake link 2021-12-07 07:04:58 +00:00
0ccb62ce3c removed fake link 2021-12-07 07:04:58 +00:00
2507a1d00b removed fake link 2021-12-07 07:04:58 +00:00
fed4b6739f Fixed subject-verb disagreement 2021-12-07 07:00:44 +00:00
dd22b51fe1 Fixed subject-verb disagreement 2021-12-07 07:00:44 +00:00
f2b261f0b0 Fixed subject-verb disagreement 2021-12-07 07:00:44 +00:00
526d940c54 Fixed subject-verb disagreement 2021-12-07 07:00:44 +00:00
485e51f239 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 06:55:50 +00:00
9f4e9637c9 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 06:55:50 +00:00
1adb4867d5 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 06:55:50 +00:00
55aa5496db Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 06:55:50 +00:00
b7ef513870 Technical issues help email 2021-12-07 06:46:34 +00:00
331e49a6bc Technical issues help email 2021-12-07 06:46:34 +00:00
2027e525e2 Technical issues help email 2021-12-07 06:46:34 +00:00
59fc703bcb Technical issues help email 2021-12-07 06:46:34 +00:00
c466f06384 Remove personal data from document 2021-12-07 06:42:46 +00:00
8d80666ed8 Remove personal data from document 2021-12-07 06:42:46 +00:00
3d9a3ecdff Remove personal data from document 2021-12-07 06:42:46 +00:00
a8e938e802 Remove personal data from document 2021-12-07 06:42:46 +00:00
4c4927df31 Removed email address 2021-12-07 06:40:57 +00:00
f8126b42fe Removed email address 2021-12-07 06:40:57 +00:00
407ee49bff Removed email address 2021-12-07 06:40:57 +00:00
b0bb600e12 Removed email address 2021-12-07 06:40:57 +00:00
0e8fbf148a Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test
Sync
2021-12-07 06:38:54 +00:00
0ef72ec338 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test
Sync
2021-12-07 06:38:54 +00:00
721af501d1 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test
Sync
2021-12-07 06:38:54 +00:00
e6f1338ee4 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test
Sync
2021-12-07 06:38:54 +00:00
0e50e2c1b9 Started drafting documentation 2021-12-07 06:38:43 +00:00
b0980b1871 Started drafting documentation 2021-12-07 06:38:43 +00:00
ea9132542f Started drafting documentation 2021-12-07 06:38:43 +00:00
b7fb30ce36 Started drafting documentation 2021-12-07 06:38:43 +00:00
fe75fa1a49 Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
f86fa6f4b5 Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
6c293c2ce6 Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
d3ed32183c Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
e8090f30d7 Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
176a0f069f Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
302d8a933a Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
c5587fcb73 Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
a4b4bfe0ee Updated test expiry 2021-12-06 23:37:16 +00:00
0faef8651a Updated test expiry 2021-12-06 23:37:16 +00:00
4f925eae2f Updated test expiry 2021-12-06 23:37:16 +00:00
a9f5ba51c4 Updated test expiry 2021-12-06 23:37:16 +00:00
5b0fd0ced3 Updated test expiry 2021-12-06 23:37:16 +00:00
eca786d444 Updated test expiry 2021-12-06 23:37:16 +00:00
affb309ffc Updated test expiry 2021-12-06 23:37:16 +00:00
0e1db9d21d Updated test expiry 2021-12-06 23:37:16 +00:00
003d998b72 Corrected bug in exam display 2021-12-06 23:24:57 +00:00
dccc85370e Corrected bug in exam display 2021-12-06 23:24:57 +00:00
355a6bff5e Corrected bug in exam display 2021-12-06 23:24:57 +00:00
98638e803a Corrected bug in exam display 2021-12-06 23:24:57 +00:00
6c4ab2e1e3 Corrected bug in exam display 2021-12-06 23:24:57 +00:00
e13069bed6 Corrected bug in exam display 2021-12-06 23:24:57 +00:00
5b6f83c294 Corrected bug in exam display 2021-12-06 23:24:57 +00:00
7295a2751c Corrected bug in exam display 2021-12-06 23:24:57 +00:00
dd72da6ae6 I am bad at debugging. 2021-12-06 23:19:13 +00:00
36cdeb15ad I am bad at debugging. 2021-12-06 23:19:13 +00:00
eb6f5b876c I am bad at debugging. 2021-12-06 23:19:13 +00:00
14500434d7 I am bad at debugging. 2021-12-06 23:19:13 +00:00
35dffd358b I am bad at debugging. 2021-12-06 23:19:13 +00:00
fafb3fcc2e I am bad at debugging. 2021-12-06 23:19:13 +00:00
4131dd054a I am bad at debugging. 2021-12-06 23:19:13 +00:00
f370496780 I am bad at debugging. 2021-12-06 23:19:13 +00:00
667ad4ebc2 Close Quiz function 2021-12-06 23:16:33 +00:00
52e3ce4c93 Close Quiz function 2021-12-06 23:16:33 +00:00
ca0e6c82cb Close Quiz function 2021-12-06 23:16:33 +00:00
860c18c5fd Close Quiz function 2021-12-06 23:16:33 +00:00
46cef8cd1e Close Quiz function 2021-12-06 23:16:33 +00:00
421445d8d5 Close Quiz function 2021-12-06 23:16:33 +00:00
b0d3ff3fc1 Close Quiz function 2021-12-06 23:16:33 +00:00
68aef968e2 Close Quiz function 2021-12-06 23:16:33 +00:00
8d29944d5d Remove redundant file 2021-12-06 22:54:40 +00:00
8fbb52d366 Remove redundant file 2021-12-06 22:54:40 +00:00
1dbe4215ec Remove redundant file 2021-12-06 22:54:40 +00:00
101f6786f5 Remove redundant file 2021-12-06 22:54:40 +00:00
fe5cf189cc Remove redundant file 2021-12-06 22:54:40 +00:00
cefb5fe849 Remove redundant file 2021-12-06 22:54:40 +00:00
f0c7873257 Remove redundant file 2021-12-06 22:54:40 +00:00
0cb8ff9991 Remove redundant file 2021-12-06 22:54:40 +00:00
4d77021d58 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
fa05a17508 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
5960d0103d Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
3535622380 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
86abae01c0 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
7c2adc9cac Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
e119c344dd Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
c7b54d2119 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
e6841b7744 This fixes it, hopefully 2021-12-06 22:47:54 +00:00
6835232698 This fixes it, hopefully 2021-12-06 22:47:54 +00:00
5392ff86ed This fixes it, hopefully 2021-12-06 22:47:54 +00:00
328a78a923 This fixes it, hopefully 2021-12-06 22:47:54 +00:00
9810577c5d This fixes it, hopefully 2021-12-06 22:47:54 +00:00
2c93b0d3a7 This fixes it, hopefully 2021-12-06 22:47:54 +00:00
343cb3f8b1 This fixes it, hopefully 2021-12-06 22:47:54 +00:00
961e8629cb This fixes it, hopefully 2021-12-06 22:47:54 +00:00
378e8eeae3 And again 2021-12-06 22:26:48 +00:00
fe898aaf7d And again 2021-12-06 22:26:48 +00:00
a010d7d290 And again 2021-12-06 22:26:48 +00:00
8b962c53a9 And again 2021-12-06 22:26:48 +00:00
bceb91b406 And again 2021-12-06 22:26:48 +00:00
a14b7bf305 And again 2021-12-06 22:26:48 +00:00
3622baf988 And again 2021-12-06 22:26:48 +00:00
24545feea0 And again 2021-12-06 22:26:48 +00:00
bb9233eeae Trying to fix it again 2021-12-06 22:24:34 +00:00
60b8aad419 Trying to fix it again 2021-12-06 22:24:34 +00:00
6e541c6a7b Trying to fix it again 2021-12-06 22:24:34 +00:00
685b1b928d Trying to fix it again 2021-12-06 22:24:34 +00:00
e0c2570515 Trying to fix it again 2021-12-06 22:24:34 +00:00
5163914875 Trying to fix it again 2021-12-06 22:24:34 +00:00
467b6d9ce7 Trying to fix it again 2021-12-06 22:24:34 +00:00
e5aab6268d Trying to fix it again 2021-12-06 22:24:34 +00:00
383ae11cd3 I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
348ee95d1c I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
9db80c9148 I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
20b447adbb I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
669bbd2f7b I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
22b483b021 I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
21ad8b2f94 I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
a3a13d4eb6 I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
a357ffe28d More Bug Fixes 2021-12-06 22:17:52 +00:00
e00e2b17b0 More Bug Fixes 2021-12-06 22:17:52 +00:00
65d679afbb More Bug Fixes 2021-12-06 22:17:52 +00:00
891ec2fd38 More Bug Fixes 2021-12-06 22:17:52 +00:00
4be21a2ca2 More Bug Fixes 2021-12-06 22:17:52 +00:00
efd4dc440d More Bug Fixes 2021-12-06 22:17:52 +00:00
935b465a19 More Bug Fixes 2021-12-06 22:17:52 +00:00
05fa5bf274 More Bug Fixes 2021-12-06 22:17:52 +00:00
1d1e2acf62 Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
c742edb57c Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
529504509e Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
852b2664ce Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
8b1b0162cc Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
56e5d29416 Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
ee50306370 Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
559e5b96c4 Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
4c2a6e7f74 OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
daaf173ff6 OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
05de6d716b OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
f740ee7f1b OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
c56c0dc822 OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
0c446b9ae7 OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
9ebec5000c OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
ce32b33eaa OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
45e0d37f81 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
d353a80269 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
8e7a09edca Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
616bd3f578 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
108297cbfd Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
9e03db595b Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
3bfd08411b Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
a4affa72a9 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
12c424be08 OG and Cookie settings 2021-12-06 21:51:29 +00:00
e00b4a9045 OG and Cookie settings 2021-12-06 21:51:29 +00:00
0ad7089722 OG and Cookie settings 2021-12-06 21:51:29 +00:00
707890ce3a OG and Cookie settings 2021-12-06 21:51:29 +00:00
7bdca9b895 OG and Cookie settings 2021-12-06 21:51:29 +00:00
bd1ac46942 OG and Cookie settings 2021-12-06 21:51:29 +00:00
11f965e20f OG and Cookie settings 2021-12-06 21:51:29 +00:00
ee99dd9038 OG and Cookie settings 2021-12-06 21:51:29 +00:00
65ec27b35b 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
63ca5e33de 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
1f228c7f1c 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
56191f5e7a 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
cbc8d276eb 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
cd68a60001 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
dd7e3cad7a 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
32908bde7d 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
835c5e2aa6 Proxy Fix 2021-12-06 20:10:27 +00:00
6823c12b2d Proxy Fix 2021-12-06 20:10:27 +00:00
c7907dc24d Proxy Fix 2021-12-06 20:10:27 +00:00
e4d97869da Proxy Fix 2021-12-06 20:10:27 +00:00
dfbf10e2dd Proxy Fix 2021-12-06 20:10:27 +00:00
dbd25ddf38 Proxy Fix 2021-12-06 20:10:27 +00:00
11d839aada Proxy Fix 2021-12-06 20:10:27 +00:00
3980be3701 Proxy Fix 2021-12-06 20:10:27 +00:00
Vivek Santayana
43cb31849a Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
39cdafc847 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
bdeb026a7c Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
73f4825bbe Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
e1ecb5bcb6 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
1651f63577 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
a01d486d99 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
2b71c77c6c Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
112c097d69 Updated config 2021-12-06 19:21:45 +00:00
b6af6d5c15 Updated config 2021-12-06 19:21:45 +00:00
6c4ca715f6 Updated config 2021-12-06 19:21:45 +00:00
972673f5d1 Updated config 2021-12-06 19:21:45 +00:00
cb1bc69f47 Updated config 2021-12-06 19:21:45 +00:00
a4058c475b Updated config 2021-12-06 19:21:45 +00:00
0004d2714f Updated config 2021-12-06 19:21:45 +00:00
20efd4444c Updated config 2021-12-06 19:21:45 +00:00
Vivek Santayana
13465859ab Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
53050f1358 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
f025eee4a6 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
506a6cf6c2 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
97db70abff Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
1a1d763d67 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
598dfa45e8 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
ca36772f29 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
bd3205f06e Favicons and OG Meta 2021-12-06 18:58:42 +00:00
ab7a25182f Favicons and OG Meta 2021-12-06 18:58:42 +00:00
e3bb2895ae Favicons and OG Meta 2021-12-06 18:58:42 +00:00
3e1e57a067 Favicons and OG Meta 2021-12-06 18:58:42 +00:00
42f90c667d Favicons and OG Meta 2021-12-06 18:58:42 +00:00
b02277f12f Favicons and OG Meta 2021-12-06 18:58:42 +00:00
a9ad171249 Favicons and OG Meta 2021-12-06 18:58:42 +00:00
bc42ae86d1 Favicons and OG Meta 2021-12-06 18:58:42 +00:00
Vivek Santayana
cc3410a1f6 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
953d3658a8 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
70f6875ac1 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
5da08d5c37 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
534247ece3 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
9525694e39 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
31903626f0 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
0111547676 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
e70592b276 Uploading Fonts 2021-12-06 18:06:11 +00:00
22a0d58996 Uploading Fonts 2021-12-06 18:06:11 +00:00
3d6a1dc7ba Uploading Fonts 2021-12-06 18:06:11 +00:00
51d468fb44 Uploading Fonts 2021-12-06 18:06:11 +00:00
164d43be8b Uploading Fonts 2021-12-06 18:06:11 +00:00
cdf47e0b88 Uploading Fonts 2021-12-06 18:06:11 +00:00
2427d55310 Uploading Fonts 2021-12-06 18:06:11 +00:00
757cc94f33 Uploading Fonts 2021-12-06 18:06:11 +00:00
Vivek Santayana
0cfac25ed3 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
0443e348ac Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
f2c0090aa3 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
ae75498edb Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
7f3e251ac4 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
233e173735 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
c5686fbd40 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
94556d0731 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
ccab358464 Correct error 2021-12-06 16:56:54 +00:00
79b0e83eba Correct error 2021-12-06 16:56:54 +00:00
22e163f036 Correct error 2021-12-06 16:56:54 +00:00
511eccac99 Correct error 2021-12-06 16:56:54 +00:00
8ec0967f40 Correct error 2021-12-06 16:56:54 +00:00
ae1380407c Correct error 2021-12-06 16:56:54 +00:00
1e7222c781 Correct error 2021-12-06 16:56:54 +00:00
b65b71df7a Correct error 2021-12-06 16:56:54 +00:00
9a4820c725 Added correct answer view 2021-12-06 13:44:40 +00:00
6c327c7978 Added correct answer view 2021-12-06 13:44:40 +00:00
c730fca3eb Added correct answer view 2021-12-06 13:44:40 +00:00
ba106ff684 Added correct answer view 2021-12-06 13:44:40 +00:00
738f4eae86 Added correct answer view 2021-12-06 13:44:40 +00:00
d114b061b4 Added correct answer view 2021-12-06 13:44:40 +00:00
9b5b97eb1d Added correct answer view 2021-12-06 13:44:40 +00:00
52ab3af1f2 Added correct answer view 2021-12-06 13:44:40 +00:00
79ca8fc932 Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
3a380c9f50 Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
b9bff4812b Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
dedd2d3449 Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
bf7e0a2a18 Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
d34aa82e86 Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
af9b5210fa Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
389fbf99aa Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
1cafa04763 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
bc68089f87 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
9b7a3b3ec0 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
23136b7e40 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
2e4035d8a4 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
7063fe271e Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
8d65b0c089 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
9988a989a6 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
20e418aeae Nginx Server 2021-12-06 13:29:20 +00:00
9affa657c4 Nginx Server 2021-12-06 13:29:20 +00:00
395ddbd460 Nginx Server 2021-12-06 13:29:20 +00:00
93b8ac40df Nginx Server 2021-12-06 13:29:20 +00:00
09f71fc5a7 Nginx Server 2021-12-06 13:29:20 +00:00
e694119a58 Nginx Server 2021-12-06 13:29:20 +00:00
67bbab0061 Nginx Server 2021-12-06 13:29:20 +00:00
9992138bc4 Nginx Server 2021-12-06 13:29:20 +00:00
f548221a10 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
4d883e8dce 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
92e2462bb9 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
6ea02c28d4 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
05a8a78ed9 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
ac5d17fc66 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
37d7e5010f 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
ce40568870 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
f4234f57b1 dockerise 2021-12-05 00:17:54 +00:00
b8c652e78a dockerise 2021-12-05 00:17:54 +00:00
9d760aafef dockerise 2021-12-05 00:17:54 +00:00
4da025d50f dockerise 2021-12-05 00:17:54 +00:00
787b741687 dockerise 2021-12-05 00:17:54 +00:00
2aca8015af dockerise 2021-12-05 00:17:54 +00:00
89ae75050b dockerise 2021-12-05 00:17:54 +00:00
efa83d2bf8 dockerise 2021-12-05 00:17:54 +00:00
388d89d95d Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
8a368dbd16 Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
4f842223cd Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
81eac4b880 Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
f03c92082e Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
3a63c72bbb Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
c3f6d45883 Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
27cead22ad Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
3a39ff6fc3 Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
8ab0a5e164 Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
c3c6e5084a Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
ef7de71a5b Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
1a1dff2c5d Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
da6d380786 Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
a1ed557dc2 Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
3ffb4a68e1 Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
12d9cd39be Finished making dashboards 2021-12-04 20:47:43 +00:00
0fd7ac7f1f Finished making dashboards 2021-12-04 20:47:43 +00:00
66d8fb7d93 Finished making dashboards 2021-12-04 20:47:43 +00:00
cca2633f1a Finished making dashboards 2021-12-04 20:47:43 +00:00
e1fcad3b42 Finished making dashboards 2021-12-04 20:47:43 +00:00
4aad0c1213 Finished making dashboards 2021-12-04 20:47:43 +00:00
ef1cad1995 Finished making dashboards 2021-12-04 20:47:43 +00:00
ab2ca04ceb Finished making dashboards 2021-12-04 20:47:43 +00:00
c88c142f7f Added question progress bar 2021-12-04 18:50:09 +00:00
ff6865c7ca Added question progress bar 2021-12-04 18:50:09 +00:00
488389057c Added question progress bar 2021-12-04 18:50:09 +00:00
186e83f92a Added question progress bar 2021-12-04 18:50:09 +00:00
da6ae3c826 Added question progress bar 2021-12-04 18:50:09 +00:00
23d6f833d7 Added question progress bar 2021-12-04 18:50:09 +00:00
17f9ef79b7 Added question progress bar 2021-12-04 18:50:09 +00:00
231f1d97bc Added question progress bar 2021-12-04 18:50:09 +00:00
dbc0c782c0 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
27bb07a942 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
0d63413835 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
a126d1f91d Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
30e298aa02 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
cc8db3fea4 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
7c2b9df0d0 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
3b605c3340 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
d8e7bf6ae8 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
3c903424fb 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
766487b669 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
0e52c12b35 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
3a1abe5157 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
9a2d738653 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
5c6f56f1c3 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
329538f7f5 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
cfdb4db0c3 Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
5151b98f97 Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
b102dc86aa Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
d9dc2e209f Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
86f8c12279 Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
c71e91326f Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
41d92b97a0 Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
2f6ccd530a Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
5d9dba0e3d Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
ee159402d0 Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
82ed0cf7cc Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
66f2da31b6 Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
cf39f83243 Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
5bd04d8dc0 Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
48624584fe Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
fb7f9e328d Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
c7ddf034a3 Typo correction 2021-12-04 12:48:01 +00:00
e001ccfa01 Typo correction 2021-12-04 12:48:01 +00:00
b6179430be Typo correction 2021-12-04 12:48:01 +00:00
8924232a93 Typo correction 2021-12-04 12:48:01 +00:00
ac36309527 Typo correction 2021-12-04 12:48:01 +00:00
7eddcabb7f Typo correction 2021-12-04 12:48:01 +00:00
f66d62db37 Typo correction 2021-12-04 12:48:01 +00:00
567b272161 Typo correction 2021-12-04 12:48:01 +00:00
2f04671ec5 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
c375576436 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
c536fb95b2 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
fbe3a59847 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
6472241dfc Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
998ec597b1 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
3470f7422c Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
9be3b1a487 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
c00ffd3ed0 Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
f17ba4f6bf Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
700850434a Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
019622bd85 Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
fe61456922 Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
64f1da772a Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
6b79fb8ebe Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
8963e5461e Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
a780b2330e Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
a3a1c2ab2f Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
dcd047a5ae Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
268fa36507 Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
f0ba8777e3 Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
43989af1f1 Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
0a6a14f8d0 Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
5dfc3379fc Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
c08e1c7010 Added result page. 2021-12-01 00:48:47 +00:00
2479fd193b Added result page. 2021-12-01 00:48:47 +00:00
a6ad184447 Added result page. 2021-12-01 00:48:47 +00:00
ff9ede6cce Added result page. 2021-12-01 00:48:47 +00:00
05b68fdd95 Added result page. 2021-12-01 00:48:47 +00:00
900929b875 Added result page. 2021-12-01 00:48:47 +00:00
8cf9629bf1 Added result page. 2021-12-01 00:48:47 +00:00
40926c1063 Added result page. 2021-12-01 00:48:47 +00:00
ba47f79d44 Finessing of client. 2021-12-01 00:48:38 +00:00
6f4353266c Finessing of client. 2021-12-01 00:48:38 +00:00
abfa7b21ba Finessing of client. 2021-12-01 00:48:38 +00:00
2536e595f0 Finessing of client. 2021-12-01 00:48:38 +00:00
bda9946859 Finessing of client. 2021-12-01 00:48:38 +00:00
a67ea9951b Finessing of client. 2021-12-01 00:48:38 +00:00
756af0a064 Finessing of client. 2021-12-01 00:48:38 +00:00
7caf54a5ba Finessing of client. 2021-12-01 00:48:38 +00:00
222b8e8a8b Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
2875c59460 Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
bb09930116 Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
31736bfbaf Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
b5625a5fb2 Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
6103010169 Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
283dfe8ecf Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
faeaeb8b2c Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
75db9fde3c Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
91621625e6 Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
d23d3ca6d1 Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
8969505383 Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
e9ff14d63e Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
10b325ad29 Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
a15844f52d Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
e0cac3c800 Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
be26a19f2e Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
218090d1e5 Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
f65e5b122f Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
f3cb7deaf4 Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
1745299e12 Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
b17e04de71 Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
b66b94fd83 Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
2af61ca986 Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
7269cec73d Added automated email notification of results. 2021-12-01 00:46:21 +00:00
68a6507c1b Added automated email notification of results. 2021-12-01 00:46:21 +00:00
e48ab4b58a Added automated email notification of results. 2021-12-01 00:46:21 +00:00
f38e9df6b9 Added automated email notification of results. 2021-12-01 00:46:21 +00:00
1f661a7038 Added automated email notification of results. 2021-12-01 00:46:21 +00:00
66b4c50221 Added automated email notification of results. 2021-12-01 00:46:21 +00:00
9f8a6e1a27 Added automated email notification of results. 2021-12-01 00:46:21 +00:00
d9b72bce0c Added automated email notification of results. 2021-12-01 00:46:21 +00:00
e829514e91 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
a1d19b4474 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
d29a5984f1 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
0b2a74ddd3 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
a1c3e79e90 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
7b1b789644 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
963453d2d6 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
46ab5d620b Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
6593d372e0 Corrected doubled import 2021-12-01 00:45:20 +00:00
cffafa82d9 Corrected doubled import 2021-12-01 00:45:20 +00:00
dc432c4ac9 Corrected doubled import 2021-12-01 00:45:20 +00:00
f0c4f237de Corrected doubled import 2021-12-01 00:45:20 +00:00
99bd4df741 Corrected doubled import 2021-12-01 00:45:20 +00:00
a866699f5d Corrected doubled import 2021-12-01 00:45:20 +00:00
75b43f8993 Corrected doubled import 2021-12-01 00:45:20 +00:00
e50ad9430e Corrected doubled import 2021-12-01 00:45:20 +00:00
173b1e329b Exam Code Time Controls 2021-11-30 18:16:52 +00:00
346238dab8 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
9913c9e084 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
ad16311941 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
493f71ac20 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
3f29b504b2 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
565486aef3 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
e5cecd6102 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
795545e8af Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
b4f021bb8b Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
dcafde1158 Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
9b038dc8e4 Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
4a201f3f9d Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
a57f5476c0 Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
240bcc6dd4 Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
add2001ba3 Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
70f362015c Built client interface 2021-11-30 03:11:28 +00:00
459c630db7 Built client interface 2021-11-30 03:11:28 +00:00
89bb802e45 Built client interface 2021-11-30 03:11:28 +00:00
475fdfcca7 Built client interface 2021-11-30 03:11:28 +00:00
db755334d0 Built client interface 2021-11-30 03:11:28 +00:00
1980363c12 Built client interface 2021-11-30 03:11:28 +00:00
07c8b62dc1 Built client interface 2021-11-30 03:11:28 +00:00
4c14c85a47 Built client interface 2021-11-30 03:11:28 +00:00
40119c9e9c Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
8432884479 Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
82b16ec9fb Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
11a0dc3a4a Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
2348c76ee8 Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
6518458768 Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
aab5325255 Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
af8ea5ddc3 Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
e730607c66 Added question generating API 2021-11-28 18:17:50 +00:00
87f60e1826 Added question generating API 2021-11-28 18:17:50 +00:00
0c3199515b Added question generating API 2021-11-28 18:17:50 +00:00
7c5e3c1e43 Added question generating API 2021-11-28 18:17:50 +00:00
274eb2d214 Added question generating API 2021-11-28 18:17:50 +00:00
7aa5be57cd Added question generating API 2021-11-28 18:17:50 +00:00
2e77b1a216 Added question generating API 2021-11-28 18:17:50 +00:00
e3fdf08b2c Added question generating API 2021-11-28 18:17:50 +00:00
2d1cdd5e94 Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
af5e6172e9 Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
88a4fc02d1 Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
d6bc6df86b Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
2fce2e0c80 Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
bf1d53d07d Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
2482242f20 Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
0d7fa41261 Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
2e9e15be95 Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
08f2585def Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
f8d05f2cec Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
fd89626172 Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
e1967bcd7e Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
79193d897e Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
2064ac508a Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
66a950f757 Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
119 changed files with 2678 additions and 2135 deletions

20
.env.example Normal file
View File

@ -0,0 +1,20 @@
SERVER_NAME= # URL where this will be hosted.
## Flask Configuration
SECRET_KEY= # Long, secure, secret string.
DATA=./data/
## Flask Mail Configuration
MAIL_SERVER=postfix # Must match name of the Docker service
MAIL_PORT=25
MAIL_USE_TLS=False
MAIL_USE_SSL=False
MAIL_USERNAME= # Username@domain, must match config values below
MAIL_PASSWORD= # Must match config value below
MAIL_DEFAULT_SENDER= # NoReply@domain or some such.
MAIL_MAX_EMAILS=25
MAIL_ASCII_ATTACHMENTS=True
# Postfix
maildomain= # Domain must match the section of username above
smtp_user= # username:password. Must match config values above.

149
README.md
View File

@ -14,27 +14,154 @@ The clien is designed to work on a server.
### Pre-Requisites
Server
Docker
Docker-Compose
Git
- A Debian- or Ubuntu-based server, preferably the latest distribution.
- Docker (specifically, Docker Engine)
- Docker Compose
- Git
### Installation
#### Install all the pre-requisites
The first step is to ensure all the prerequisites are available on the server.
To set up the server, consult some of the comprehensive guides on various hosting platforms like Linode or DigitalOcean.
Here is a [good starting point on setting up a server](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu-22-04).
To install Docker and Docker Compose, consult the respective documentation:
- [Install on Ubuntu](https://docs.docker.com/engine/install/ubuntu/) or [Install on Debian](https://docs.docker.com/engine/install/debian/)
- Docker Compose should be installed as part of that.
```
At the time of writing, there has been an upgrade to Docker and Docker Compose, meaning the syntax below might be different between versions.
```
Check if Git is installed on your server using the `git --version` command.
If it isn't installed, install it.
This should normally come pre-packaged with your OS distribution.
But if it doesn't, look up how to for whatever OS you use.
If you are using Ubuntu or Debian, it should be as easy as using the command:
```$ sudo apt-get install git -y```
#### Preliminary Set-Up: Clone repos and Configure Values
#### Set Up Web Server
Open a terminal and navigate to the folder where you want to install this app.
I would suggest using a subfolder within your Home folder:
#### Incorporate SSL
```$ cd ~ && mkdir ska-referee-test && cd ska-referee-test```
#### Set Up Auto-Renew
That way, you will ensure you can read and write all the necessary files during installation.
Once in the destination folder, clone all the relevant files you will need for the installation:
### Alterations
```$ git clone https://git.vsnt.uk/viveksantayana/ska-referee-test.git .```
## Use
(Remember to include the trailing dot at the end, as that indicates to Git to download the files in the current directory.)
## Compatibility
#### Populate Environment Variables
### iOS Limitations
Configuration values for the app are stored in the environment variables file.
To set it up, make a copy of the example file and populate it with appropriate values.
```$ cp .env.example .env```
Make sure to use complex, secure strings for passwords.
Also make sure that the various entries for usernames and passwords match.
#### Input Specific Values for Your Installation
There are some values in the following four files you will need to configure to reflect the domain you are installing this app.
```
# .env
SERVER_NAME= # URL where this will be hosted.
```
```
# install-script.sh
domains=(example.org www.example.org)
email="" # Adding a valid address is strongly recommended
```
Substitute the domain name `domain_name` in the two file paths in the following file:
```
# nginx/ssl.conf
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem;
...
```
And **six** locations in the following file, two for the regular version of the domain and two for the www version:
```
# nginx/conf.d/ref-test-app.conf
server {
server_name domain_name;
listen 80 default_server;
...
}
server {
server_name domain_name;
listen 443 ssl http2 default_server;
...
}
server {
server_name www.domain_name;
listen 80;
listen [::]:80;
# Redirect to non-www
return 301 $scheme://domain_name$request_uri; ...
}
server {
server_name www.domain_name;
listen 443 ssl http2;
listen [::]:443 ssl http2;
...
# Redirect to non-www
return 301 $scheme://domain_name$request_uri;
}
```
#### Installing SSL Certificates
The app will use SSL certificates to operate through a secure, `https` connection.
This will be set up automatically.
However, there is a specific chicken-and-egg problem as the web server, Nginx, won't run without certificates, Certbot, the certificate generator, won't run without the web server.
So to solve this, there is an automation script we can run that will set up a dummy certificate and then issue the appropriate certificates for us.
```
$ chmod +x install-script.sh
$ sudo ./install-script.sh
```
This will take a long time to run the first time because it will try and generate a fairly sizeable cypher.
When we later run the server, Certbot will check for renewals of the SSL certificates every 12 hours, and Nginx will reload the configurations every 6 hours, to make sure everything runs smoothly and stays live.
#### Run the Stack
Everything should be good to run on autopilot at this point.
Navigate to the root folder of the app, the folder where you have `install-script.sh` and `docker-compose.yml`.
Run the following command:
```sudo docker compose up -d```
And you should have the stack running.
You can register in the app and begin using it.
### Fonts
The app uses [OpenDyslexic](https://opendyslexic.org/), which is available on-line.
It also has the option of rendering in other system fonts, but this can vary depending on your operating system.
Because these are proprietary fonts, they cannot be made available on-line in the same way as open source ones should your system not have them.
Some fonts may not display correctly as a result.

View File

@ -1,14 +0,0 @@
set -e
mongo=( mongo --host 127.0.0.1 --port 27017 --quiet )
if [ "$MONGO_INITDB_ROOT_USERNAME" ] && [ "$MONGO_INITDB_ROOT_PASSWORD" ] && [ "$MONGO_INITDB_USERNAME" ] && [ "$MONGO_INITDB_PASSWORD" ]; then
rootAuthDatabase='admin'
"${mongo[@]}" "$rootAuthDatabase" <<-EOJS
db.createUser({
user: $(_js_escape "$MONGO_INITDB_USERNAME"),
pwd: $(_js_escape "$MONGO_INITDB_PASSWORD"),
roles: [ { role: 'readWrite', db: $(_js_escape "$MONGO_INITDB_DATABASE") } ]
})
EOJS
fi

View File

@ -1,15 +1,16 @@
version: '3.9'
services:
ref_test_server:
container_name: ref_test_server
image: nginx:1.21.4-alpine
nginx:
container_name: reftest_server
image: nginx:alpine
volumes:
- ./certbot:/etc/letsencrypt:ro
- ./nginx:/etc/nginx
- ./src/html:/usr/share/nginx/html/
- ./ref-test/admin/static:/usr/share/nginx/html/admin/static
- ./ref-test/quiz/static:/usr/share/nginx/html/quiz/static
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static
- ./ref-test/app/root:/usr/share/nginx/html/root
ports:
- 80:80
- 443:443
@ -17,10 +18,11 @@ services:
networks:
- frontend
depends_on:
- ref_test_app
- app
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
ref_test_app:
container_name: ref_test_app
app:
container_name: reftest_app
image: reftest
build: ./ref-test
env_file:
@ -28,32 +30,16 @@ services:
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
- postfix
ref_test_db:
container_name: ref_test_db
image: mongo:5.0.4-focal
restart: unless-stopped
volumes:
- ./database/data:/data/db
- ./database/initdb.d/:/docker-entrypoint-initdb.d/
env_file:
- ./.env
ports:
- 27017
networks:
- backend
ref_test_postfix:
container_name: ref_test_postfix
postfix:
container_name: reftest_postfix
image: catatnight/postfix:latest
restart: unless-stopped
env_file:
@ -63,15 +49,13 @@ services:
networks:
- backend
ref_test_certbot:
container_name: ref_test_certbot
image: certbot/certbot:v1.21.0
certbot:
container_name: reftest_certbot
image: certbot/certbot
volumes:
- ./certbot:/etc/letsencrypt
- ./src/html:/var/www/html
depends_on:
- ref_test_server
# command: certonly --webroot --webroot-path=/var/www/html --email (email) --agree-tos --no-eff-email -d (domain)
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
networks:
frontend:

87
install-script.sh Normal file
View File

@ -0,0 +1,87 @@
#!/bin/bash
# Source https://pentacent.medium.com/nginx-and-lets-encrypt-with-docker-in-less-than-5-minutes-b4b8a60d3a71
if ! [ -x "$(command -v docker compose)" ]; then
echo 'Error: docker compose is not installed.' >&2
exit 1
fi
domains=(example.org www.example.org)
rsa_key_size=4096
data_path="./certbot"
email="" # Adding a valid address is strongly recommended
staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits
if [ -d "$data_path" ]; then
read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision
if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
exit
fi
fi
if [ ! -e "$data_path/ssl-dhparams.pem" ]; then
echo "### Generating ssl-dhparams.pem ..."
docker compose run --rm --entrypoint "\
openssl dhparam -out /etc/letsencrypt/ssl-dhparams.pem 4096" certbot
echo
fi
echo "### Creating dummy certificate for $domains ..."
path="/etc/letsencrypt/live/$domains"
mkdir -p "$data_path/live/$domains"
docker compose run --rm --entrypoint "\
openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
-keyout '$path/privkey.pem' \
-out '$path/fullchain.pem' \
-subj '/CN=localhost'" certbot
echo
if [ ! -e "$data_path/lets-encrypt-x3-cross-signed.pem" ]; then
echo "### Downloading lets-encrypt-x3-cross-signed.pem ..."
wget -O $data_path/lets-encrypt-x3-cross-signed.pem \
"https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem"
docker compose run --rm --entrypoint "\
openssl dhparam -out /etc/letsencrypt/ssl-dhparams.pem 4096" certbot
echo
fi
echo "### Starting nginx ..."
docker compose up --force-recreate -d nginx
echo
echo "### Deleting dummy certificate for $domains ..."
docker compose run --rm --entrypoint "\
rm -Rf /etc/letsencrypt/live/$domains && \
rm -Rf /etc/letsencrypt/archive/$domains && \
rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot
echo
echo "### Requesting Let's Encrypt certificate for $domains ..."
#Join $domains to -d args
domain_args=""
for domain in "${domains[@]}"; do
domain_args="$domain_args -d $domain"
done
# Select appropriate email arg
case "$email" in
"") email_arg="--register-unsafely-without-email" ;;
*) email_arg="--email $email" ;;
esac
# Enable staging mode if needed
if [ $staging != "0" ]; then staging_arg="--staging"; fi
docker compose run --rm --entrypoint "\
certbot certonly --webroot -w /var/www/html \
$staging_arg \
$email_arg \
$domain_args \
--rsa-key-size $rsa_key_size \
--agree-tos \
--force-renewal" certbot
echo
echo "### Reloading nginx ..."
docker compose exec nginx nginx -s reload

View File

@ -1,33 +0,0 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
listen 443 ssl http2 default_server;
listen [::]:443 ssl http2 default_server;
access_log /var/log/nginx/host.access.log main;
# SSL configuration
include /etc/nginx/ssl.conf;
# Add index.php to the list if you are using PHP
index index.html index.htm index.nginx-debian.html;
# Default catch all to 404
# Added from Serverfault support https://serverfault.com/questions/994141/nginx-redirecting-the-wrong-subdomains
server_name _;
server_name_in_redirect off;
location / {
return 404;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

View File

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

View File

@ -1,2 +1,13 @@
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
ssl_prefer_server_ciphers on;
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/lets-encrypt-x3-cross-signed.pem;
add_header Strict-Transport-Security "max-age=31536000" always;
ssl_session_cache shared:SSL:40m;
ssl_session_timeout 4h;
ssl_session_tickets on;

View File

@ -1,147 +0,0 @@
from flask import Blueprint, render_template, request, session, redirect
from flask.helpers import flash, url_for
from flask.json import jsonify
from .models.users import User
from uuid import uuid4
from common.security.database import decrypt_find_one, encrypted_update
from werkzeug.security import check_password_hash
from .views import admin_account_required, disable_on_registration, login_required, disable_if_logged_in, get_id_from_cookie
auth = Blueprint(
'admin_auth',
__name__,
template_folder='templates',
static_folder='static'
)
@auth.route('/account/', methods = ['GET', 'POST'])
@admin_account_required
@login_required
def account():
from .models.forms import UpdateAccountForm
from main import db
form = UpdateAccountForm()
_id = get_id_from_cookie()
user = decrypt_find_one(db.users, {'_id': _id})
if request.method == 'GET':
return render_template('/admin/auth/account.html', form = form, user = user)
if request.method == 'POST':
if form.validate_on_submit():
password_confirm = request.form.get('password_confirm')
if not check_password_hash(user['password'], password_confirm):
return jsonify({ 'error': 'The password you entered is incorrect.' }), 401
entry = User(
_id = _id,
password = request.form.get('password'),
email = request.form.get('email')
)
return entry.update()
else:
errors = [*form.password_confirm.errors, *form.password_reenter.errors, *form.password.errors, *form.email.errors]
return jsonify({ 'error': errors}), 400
@auth.route('/login/', methods=['GET','POST'])
@admin_account_required
@disable_if_logged_in
def login():
from .models.forms import LoginForm
form = LoginForm()
if request.method == 'GET':
return render_template('/admin/auth/login.html', form=form)
if request.method == 'POST':
if form.validate_on_submit():
entry = User(
username = request.form.get('username').lower(),
password = request.form.get('password'),
remember = request.form.get('remember')
)
return entry.login()
else:
errors = [*form.username.errors, *form.password.errors]
return jsonify({ 'error': errors}), 400
@auth.route('/logout/')
@admin_account_required
@login_required
def logout():
_id = get_id_from_cookie()
return User(_id=_id).logout()
@auth.route('/register/', methods=['GET','POST'])
@disable_on_registration
def register():
from .models.forms import RegistrationForm
form = RegistrationForm()
if request.method == 'GET':
return render_template('/admin/auth/register.html', form=form)
if request.method == 'POST':
if form.validate_on_submit():
entry = User(
_id = uuid4().hex,
username = request.form.get('username').lower(),
email = request.form.get('email'),
password = request.form.get('password'),
)
return entry.register()
else:
errors = [*form.username.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
return jsonify({ 'error': errors}), 400
@auth.route('/reset/', methods = ['GET', 'POST'])
@admin_account_required
@disable_if_logged_in
def reset():
from .models.forms import ResetPasswordForm
form = ResetPasswordForm()
if request.method == 'GET':
return render_template('/admin/auth/reset.html', form=form)
if request.method == 'POST':
if form.validate_on_submit():
entry = User(
username = request.form.get('username').lower(),
email = request.form.get('email'),
)
return entry.reset_password()
else:
errors = [*form.username.errors, *form.email.errors]
return jsonify({ 'error': errors}), 400
@auth.route('/reset/<token1>/<token2>/', methods = ['GET'])
@admin_account_required
@disable_if_logged_in
def reset_gateway(token1,token2):
from main import db
user = decrypt_find_one( db.users, {'reset_token' : token1} )
if not user:
return redirect(url_for('admin_auth.login'))
encrypted_update( db.users, {'reset_token': token1}, {'$unset': {'reset_token' : '', 'verification_token': ''}})
if not user['verification_token'] == token2:
flash('The verification of your password reset request failed and the token has been invalidated. Please make a new reset password request.', 'error'), 401
return redirect(url_for('admin_auth.reset'))
session['_id'] = user['_id']
session['reset_validated'] = True
return redirect(url_for('admin_auth.update_password_'))
@auth.route('/reset/update/', methods = ['GET','POST'])
@admin_account_required
@disable_if_logged_in
def update_password_():
from .models.forms import UpdatePasswordForm
form = UpdatePasswordForm()
if request.method == 'GET':
if 'reset_validated' not in session:
return redirect(url_for('admin_auth.login'))
session.pop('reset_validated')
return render_template('/admin/auth/update-password.html', form=form)
if request.method == 'POST':
if form.validate_on_submit():
entry = User(
_id = session['_id'],
password = request.form.get('password')
)
session.pop('_id')
return entry.update()
else:
errors = [*form.password.errors, *form.password_reenter.errors]
return jsonify({ 'error': errors}), 400

View File

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

View File

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

View File

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

View File

@ -1,15 +0,0 @@
from flask import Blueprint, render_template
from .views import login_required, admin_account_required
results = Blueprint(
'results',
__name__,
template_folder='templates',
static_folder='static'
)
@results.route('/')
@admin_account_required
@login_required
def _results():
return render_template('/admin/results.html')

View File

@ -1,79 +0,0 @@
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
<div class="container">
<a href="{{ url_for('admin_views.home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbar"
aria-controls="navbar"
aria-expanded="false"
aria-label="Toggle Navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navbar">
<ul class="navbar-nav">
{% if not check_login() %}
<li class="nav-item" id="nav-login">
<a href="{{ url_for('admin_auth.login') }}" id="link-login" class="nav-link">Log In</a>
</li>
{% endif %}
{% if check_login() %}
<li class="nav-item" id="nav-results">
<a href="{{ url_for('results._results') }}" id="link-results" class="nav-link">View Results</a>
</li>
<li class="nav-item" id="nav-tests">
<a href="{{ url_for('admin_views.tests') }}" id="link-tests" class="nav-link">Manage Exams</a>
</li>
<li class="nav-item dropdown" id="nav-settings">
<a
class="nav-link dropdown-toggle"
id="dropdown-account"
role="button"
href="{{ url_for('admin_views.settings') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Settings
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-account"
>
<li>
<a href="{{ url_for('admin_views.users') }}" id="link-users" class="dropdown-item">Users</a>
</li>
<li>
<a href="{{ url_for('admin_views.questions') }}" id="link-questions" class="dropdown-item">Question Datasets</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-account">
<a
class="nav-link dropdown-toggle"
id="dropdown-account"
role="button"
href="{{ url_for('admin_auth.account') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Account
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-account"
>
<li>
<a href="{{ url_for('admin_auth.account') }}" id="link-account" class="dropdown-item">Account Settings</a>
</li>
<li>
<a href="{{ url_for('admin_auth.logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
</li>
</ul>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>

View File

@ -1,23 +0,0 @@
<div class="navbar navbar-expand-sm navbar-light bg-light">
<div class="container-fluid">
<div class="expand navbar-expand justify-content-center" id="navbar_secondary">
<ul class="nav">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='active') }}">Active</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='scheduled') }}">Scheduled</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='expired') }}">Expired</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='all') }}">All</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='create') }}">Create</a>
</li>
</ul>
</div>
</div>
</div>

View File

@ -1,509 +0,0 @@
from flask import Blueprint, render_template, flash, redirect, request, jsonify, abort, session
from flask.helpers import url_for
from functools import wraps
from datetime import datetime, timedelta
import os
from glob import glob
from json import loads
from werkzeug.security import check_password_hash
from common.security.database import decrypt_find, decrypt_find_one
from .models.users import User
from flask_mail import Message
from uuid import uuid4
import secrets
from datetime import datetime, date
from .models.tests import Test
from common.data_tools import get_default_dataset, get_time_options, available_datasets, get_datasets, get_correct_answers
views = Blueprint(
'admin_views',
__name__,
template_folder='templates',
static_folder='static'
)
def admin_account_required(function):
@wraps(function)
def decorated_function(*args, **kwargs):
from main import db
from main import db
if not db.users.find_one({}):
flash('No administrator accounts have been registered. Please register an administrator account.', 'alert')
return redirect(url_for('admin_auth.register'))
return function(*args, **kwargs)
return decorated_function
def disable_on_registration(function):
@wraps(function)
def decorated_function(*args, **kwargs):
from main import db
if db.users.find_one({}):
return abort(404)
return function(*args, **kwargs)
return decorated_function
def get_id_from_cookie():
return request.cookies.get('_id')
def get_user_from_db(_id):
from main import db
return db.users.find_one({'_id': _id})
def check_login():
_id = get_id_from_cookie()
return True if get_user_from_db(_id) else False
def login_required(function):
@wraps(function)
def decorated_function(*args, **kwargs):
if not check_login():
session['prev_page'] = request.url
flash('Please log in to view this page.', 'alert')
return redirect(url_for('admin_auth.login'))
return function(*args, **kwargs)
return decorated_function
def disable_if_logged_in(function):
@wraps(function)
def decorated_function(*args, **kwargs):
if check_login():
return abort(404)
return function(*args, **kwargs)
return decorated_function
@views.route('/')
@views.route('/home/')
@views.route('/dashboard/')
@admin_account_required
@login_required
def home():
from main import db
tests = db.tests.find()
results = decrypt_find(db.entries, {})
current_tests = [ test for test in tests if test['expiry_date'] >= datetime.utcnow() and test['start_date'].date() <= date.today() ]
current_tests.sort(key= lambda x: x['expiry_date'], reverse=True)
upcoming_tests = [ test for test in tests if test['start_date'] > datetime.utcnow()]
upcoming_tests.sort(key= lambda x: x['start_date'])
recent_results = [result for result in results if 'submission_time' in result ]
recent_results.sort(key= lambda x: x['submission_time'], reverse=True)
for result in recent_results:
result['percent'] = round(100*result['results']['score']/result['results']['max'])
return render_template('/admin/index.html', current_tests = current_tests[:5], upcomimg_tests = upcoming_tests[:5], recent_results = recent_results[:5])
@views.route('/settings/')
@admin_account_required
@login_required
def settings():
from main import db
users = decrypt_find(db.users, {})
users.sort(key= lambda x: x['username'])
datasets = get_datasets()
return render_template('/admin/settings/index.html', users=users[:5], datasets=datasets[:5])
@views.route('/settings/users/', methods=['GET','POST'])
@admin_account_required
@login_required
def users():
from main import db, mail
from .models.forms import CreateUserForm
form = CreateUserForm()
if request.method == 'GET':
users_list = decrypt_find(db.users, {})
return render_template('/admin/settings/users.html', users = users_list, form = form)
if request.method == 'POST':
if form.validate_on_submit():
entry = User(
_id = uuid4().hex,
username = request.form.get('username').lower(),
email = request.form.get('email'),
password = request.form.get('password') if not request.form.get('password') == '' else secrets.token_hex(12),
)
email = Message(
subject = 'RefTest | Registration Confirmation',
recipients = [entry.email],
body = f"""
Hello {entry.username}, \n\n
You have been registered as an administrator for the SKA RefTest App!\n\n
You can access your account using the username '{entry.username}'.\n\n
Your password is as follows:\n\n
{entry.password}\n\n
You can change your password by logging in to the admin console by copying the following URL into a web browser:\n\n
{url_for('admin_views.home', _external = True)}\n\n
Have a nice day.
""",
html = f"""
<p>Hello {entry.username},</p>
<p>You have been registered as an administrator for the SKA RefTest App!</p>
<p>You can access your account using the username '{entry.username}'.</p>
<p>Your password is as follows:</p>
<strong>{entry.password}</strong>
<p>You can change your password by logging in to the admin console at the link below:</p>
<p><a href='{url_for('admin_views.home', _external = True)}'>{url_for('admin_views.home', _external = True)}</a></p>
<p>Have a nice day.</p>
"""
)
mail.send(email)
return entry.register()
else:
errors = [*form.username.errors, *form.email.errors, *form.password.errors]
return jsonify({ 'error': errors}), 400
@views.route('/settings/users/delete/<string:_id>', methods = ['GET', 'POST'])
@admin_account_required
@login_required
def delete_user(_id:str):
from main import db, mail
if _id == get_id_from_cookie():
flash('Cannot delete your own user account.', 'error')
return redirect(url_for('admin_views.users'))
from .models.forms import DeleteUserForm
form = DeleteUserForm()
user = decrypt_find_one(db.users, {'_id': _id})
if request.method == 'GET':
if not user:
return abort(404)
return render_template('/admin/settings/delete-user.html', form = form, _id = _id, user = user)
if request.method == 'POST':
if not user:
return jsonify({ 'error': 'User does not exist.' }), 404
if form.validate_on_submit():
_user = decrypt_find_one(db.users, {'_id': get_id_from_cookie()})
password = request.form.get('password')
if not check_password_hash(_user['password'], password):
return jsonify({ 'error': 'The password you entered is incorrect.' }), 401
if request.form.get('notify'):
email = Message(
subject = 'RefTest | Account Deletion',
recipients = [user['email']],
bcc = [_user['email']],
body = f"""
Hello {user['username']}, \n\n
Your administrator account for the SKA RefTest App has been deleted by {_user['username']}. All data about your account has been deleted.\n\n
If you believe this was done in error, please contact them immediately.\n\n
If you would like to register to administer the app, please ask an existing administrator to create a new account.\n\n
Have a nice day.
""",
html = f"""
<p>Hello {user['username']},</p>
<p>Your administrator account for the SKA RefTest App has been deleted by {_user['username']}. All data about your account has been deleted.</p>
<p>If you believe this was done in error, please contact them immediately.</p>
<p>If you would like to register to administer the app, please ask an existing administrator to create a new account.</p>
<p>Have a nice day.</p>
"""
)
mail.send(email)
user = User(
_id = user['_id']
)
return user.delete()
else: return abort(400)
@views.route('/settings/users/update/<string:_id>', methods = ['GET', 'POST'])
@admin_account_required
@login_required
def update_user(_id:str):
from main import db, mail
if _id == get_id_from_cookie():
flash('Cannot delete your own user account.', 'error')
return redirect(url_for('admin_views.users'))
from .models.forms import UpdateUserForm
form = UpdateUserForm()
user = decrypt_find_one( db.users, {'_id': _id})
if request.method == 'GET':
if not user:
return abort(404)
return render_template('/admin/settings/update-user.html', form = form, _id = _id, user = user)
if request.method == 'POST':
if not user:
return jsonify({ 'error': 'User does not exist.' }), 404
if form.validate_on_submit():
_user = decrypt_find_one(db.users, {'_id': get_id_from_cookie()})
password = request.form.get('password')
if not check_password_hash(_user['password'], password):
return jsonify({ 'error': 'The password you entered is incorrect.' }), 401
if request.form.get('notify'):
recipient = request.form.get('email') if not request.form.get('email') == '' else user['email']
email = Message(
subject = 'RefTest | Account Update',
recipients = [recipient],
bcc = [_user['email']],
body = f"""
Hello {user['username']}, \n\n
Your administrator account for the SKA RefTest App has been updated by {_user['username']}.\n\n
Your new account details are as follows:\n\n
Email: {recipient}\n
Password: {request.form.get('password')}\n\n
You can update your email and password by logging in to the app.\n\n
Have a nice day.
""",
html = f"""
<p>Hello {user['username']},</p>
<p>Your administrator account for the SKA RefTest App has been updated by {_user['username']}.</p>
<p>Your new account details are as follows:</p>
<p>Email: {recipient} <br/>Password: {request.form.get('password')}</p>
<p>You can update your email and password by logging in to the app.</p>
<p>Have a nice day.</p>
"""
)
mail.send(email)
entry = User(
_id = _id,
email = request.form.get('email'),
password = request.form.get('password')
)
return entry.update()
else:
errors = [*form.user_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
return jsonify({ 'error': errors}), 400
@views.route('/settings/questions/', methods=['GET', 'POST'])
@admin_account_required
@login_required
def questions():
from .models.forms import UploadDataForm
from common.data_tools import check_json_format, validate_json_contents, store_data_file
form = UploadDataForm()
if request.method == 'GET':
data = get_datasets()
default = get_default_dataset()
return render_template('/admin/settings/questions.html', form=form, data=data, default=default)
if request.method == 'POST':
if form.validate_on_submit():
upload = form.data_file.data
default = True if request.form.get('default') else False
if not check_json_format(upload):
return jsonify({ 'error': 'Invalid file selected. Please upload a JSON file.'}), 400
if not validate_json_contents(upload):
return jsonify({'error': 'The data in the file is invalid.'}), 400
filename = store_data_file(upload, default=default)
flash(f'Dataset {form.data_file.data.filename} has been uploaded as {filename}.', 'success')
return jsonify({ 'success': f'Dataset {form.data_file.data.filename} has been uploaded as {filename}.'}), 200
errors = [*form.data_file.errors]
return jsonify({ 'error': errors}), 400
@views.route('/settings/questions/delete/', methods=['POST'])
@admin_account_required
@login_required
def delete_questions():
from main import db, app
filename = request.get_json()['filename']
data_files = glob(os.path.join(app.config["DATA_FILE_DIRECTORY"],'*.json'))
if any(filename in file for file in data_files):
default = get_default_dataset()
if default == filename:
return jsonify({'error': 'Cannot delete the default question dataset.'}), 400
data_file = os.path.join(app.config["DATA_FILE_DIRECTORY"],filename)
with open(data_file, 'r') as _data_file:
data = loads(_data_file.read())
if data['meta']['tests']:
return jsonify({'error': 'Cannot delete a dataset that is in use by an exam.'}), 400
if len(data_files) == 1:
return jsonify({'error': 'Cannot delete the only question dataset.'}), 400
os.remove(data_file)
flash(f'Question dataset {filename} has been deleted.', 'success')
return jsonify({'success': f'Question dataset {filename} has been deleted.'}), 200
return abort(404)
@views.route('/settings/questions/default/', methods=['POST'])
@admin_account_required
@login_required
def make_default_questions():
from main import app
filename = request.get_json()['filename']
data_files = glob(os.path.join(app.config["DATA_FILE_DIRECTORY"],'*.json'))
default_file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], '.default.txt')
if any(filename in file for file in data_files):
with open(default_file_path, 'r') as default_file:
default = default_file.read()
if default == filename:
return jsonify({'error': 'Cannot delete default question dataset.'}), 400
with open(default_file_path, 'w') as default_file:
default_file.write(filename)
flash(f'Set dataset f{filename} as the default.', 'success')
return jsonify({'success': f'Set dataset {filename} as the default.'})
return abort(404)
@views.route('/tests/<filter>/', methods=['GET'])
@views.route('/tests/', methods=['GET'])
@admin_account_required
@login_required
def tests(filter=''):
from main import db
if not available_datasets():
flash('There are no available question datasets. Please upload a question dataset in order to set up an exam.', 'error')
return redirect(url_for('admin_views.questions'))
if filter not in ['', 'create', 'active', 'scheduled', 'expired', 'all']:
return abort(404)
if filter == 'create':
from .models.forms import CreateTest
form = CreateTest()
form.time_limit.choices = get_time_options()
form.dataset.choices = available_datasets()
form.time_limit.default='none'
form.dataset.default=get_default_dataset()
form.process()
display_title = ''
error_none = ''
return render_template('/admin/tests.html', form = form, display_title=display_title, error_none=error_none, filter=filter)
_tests = db.tests.find({})
if filter == 'active' or filter == '':
tests = [ test for test in _tests if test['expiry_date'] >= datetime.utcnow() and test['start_date'].date() <= date.today() ]
display_title = 'Active Exams'
error_none = 'There are no exams that are currently active. You can create one using the Creat Exam form.'
if filter == 'expired':
tests = [ test for test in _tests if test['expiry_date'] < datetime.utcnow()]
display_title = 'Expired Exams'
error_none = 'There are no expired exams. Exams will appear in this category after their expiration date has passed.'
if filter == 'scheduled':
tests = [ test for test in _tests if test['start_date'].date() > date.today()]
display_title = 'Scheduled Exams'
error_none = 'There are no scheduled exams pending. You can schedule an exam for the future using the Create Exam form.'
if filter == 'all':
tests = _tests
display_title = 'All Exams'
error_none = 'There are no exams set up. You can create one using the Create Exam form.'
return render_template('/admin/tests.html', tests = tests, display_title=display_title, error_none=error_none, filter=filter)
@views.route('/tests/create/', methods=['POST'])
@admin_account_required
@login_required
def create_test():
from main import db
from .models.forms import CreateTest
form = CreateTest()
form.dataset.choices = available_datasets()
form.time_limit.choices = get_time_options()
if form.validate_on_submit():
start_date = request.form.get('start_date')
start_date = datetime.strptime(start_date, '%Y-%m-%d')
expiry_date = request.form.get('expiry_date')
expiry_date = datetime.strptime(expiry_date, '%Y-%m-%d') + timedelta(days= 1) - timedelta(milliseconds = 1)
dataset = request.form.get('dataset')
errors = []
if start_date.date() < date.today():
errors.append('The start date cannot be in the past.')
if expiry_date.date() < date.today():
errors.append('The expiry date cannot be in the past.')
if expiry_date < start_date:
errors.append('The expiry date cannot be before the start date.')
if errors:
return jsonify({'error': errors}), 400
creator_id = get_id_from_cookie()
creator = decrypt_find_one(db.users, { '_id': creator_id } )['username']
test = Test(
_id = uuid4().hex,
start_date = start_date,
expiry_date = expiry_date,
time_limit = request.form.get('time_limit'),
creator = creator,
dataset = dataset
)
test.create()
return jsonify({'success': 'New exam created.'}), 200
else:
errors = [*form.expiry.errors, *form.time_limit.errors]
return jsonify({ 'error': errors}), 400
@views.route('/tests/delete/', methods=['POST'])
@admin_account_required
@login_required
def delete_test():
from main import db
_id = request.get_json()['_id']
if db.tests.find_one({'_id': _id}):
return Test(_id = _id).delete()
return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
@views.route('/tests/close/', methods=['POST'])
@admin_account_required
@login_required
def close_test():
from main import db
_id = request.get_json()['_id']
if db.tests.find_one({'_id': _id}):
return Test(_id = _id, expiry_date= datetime.utcnow()).update()
return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
@views.route('/test/<_id>/', methods=['GET','POST'])
@admin_account_required
@login_required
def view_test(_id):
from main import db
from .models.forms import AddTimeAdjustment
form = AddTimeAdjustment()
test = decrypt_find_one(db.tests, {'_id': _id})
if request.method == 'GET':
if not test:
return abort(404)
return render_template('/admin/test.html', test = test, form = form)
if request.method == 'POST':
if form.validate_on_submit():
time = int(request.form.get('time'))
return Test(_id=_id).add_time_adjustment(time)
return jsonify({'error': form.time.errors }), 400
@views.route('/test/<_id>/delete-adjustment/', methods = ['POST'])
@admin_account_required
@login_required
def delete_adjustment(_id):
user_code = request.get_json()['user_code']
return Test(_id=_id).remove_time_adjustment(user_code)
@views.route('/results/')
@admin_account_required
@login_required
def view_entries():
from main import db
entries = decrypt_find(db.entries, {})
return render_template('/admin/results.html', entries = entries)
@views.route('/results/<_id>/', methods = ['GET', 'POST'])
@admin_account_required
@login_required
def view_entry(_id=''):
from main import app, db
entry = decrypt_find_one(db.entries, {'_id': _id})
if request.method == 'GET':
if not entry:
return abort(404)
test_code = entry['test_code']
test = db.tests.find_one({'test_code' : test_code})
dataset = test['dataset']
dataset_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], dataset)
with open(dataset_path, 'r') as _dataset:
data = loads(_dataset.read())
correct = get_correct_answers(dataset=data)
print(correct.values())
return render_template('/admin/result-detail.html', entry = entry, correct = correct)
if request.method == 'POST':
if not entry:
return jsonify({'error': 'A valid entry could no be found.'}), 404
action = request.get_json()['action']
if action == 'override':
late_ignore = db.entries.find_one_and_update({'_id': _id}, {'$set': {'status': 'late (allowed)'}})
if late_ignore:
flash('Late status for the entry has been allowed.', 'success')
return jsonify({'success': 'Late status allowed.'}), 200
return jsonify({'error': 'An error occurred.'}), 400
if action == 'delete':
test_code = entry['test_code']
test = db.tests.find_one_and_update({'test_code': test_code}, {'$pull': {'entries': _id}})
if not test:
return jsonify({'error': 'A valid exam could not be found.'}), 404
delete = db.entries.delete_one({'_id': _id})
if delete:
flash('Entry has been deleted.', 'success')
return jsonify({'success': 'Entry has been deleted.'}), 200
return jsonify({'error': 'An error occurred.'}), 400
@views.route('/certificate/', methods=['POST'])
@admin_account_required
@login_required
def generate_certificate():
from main import db
_id = request.get_json()['_id']
entry = decrypt_find_one(db.entries, {'_id': _id})
if not entry:
return abort(404)
return render_template('/admin/components/certificate.html', entry = entry)

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -70,14 +70,14 @@ $('form[name=form-upload-questions]').submit(function(event) {
// Edit and Delete Test Button Handlers
$('.test-action').click(function(event) {
let _id = $(this).data('_id');
let id = $(this).data('id');
let action = $(this).data('action');
if (action == 'delete') {
if (action == 'delete' || action == 'start' || action == 'end') {
$.ajax({
url: `/admin/tests/delete/`,
url: `/admin/tests/edit/`,
type: 'POST',
data: JSON.stringify({'_id': _id}),
data: JSON.stringify({'id': id, 'action': action}), // TODO Change how CRUD operations work
contentType: 'application/json',
success: function(response) {
window.location.href = '/admin/tests/';
@ -87,21 +87,7 @@ $('.test-action').click(function(event) {
},
});
} else if (action == 'edit') {
window.location.href = `/admin/test/${_id}/`
} else if (action == 'close'){
$.ajax({
url: `/admin/tests/close/`,
type: 'POST',
data: JSON.stringify({'_id': _id}),
contentType: 'application/json',
success: function(response) {
$(window).scrollTop(0);
window.location.reload();
},
error: function(response){
error_response(response);
},
});
window.location.href = `/admin/test/${id}/`
}
event.preventDefault();
@ -166,7 +152,7 @@ $('#dismiss-cookie-alert').click(function(event){
$.ajax({
url: '/cookies/',
type: 'GET',
type: 'POST',
data: {
time: Date.now()
},
@ -185,13 +171,13 @@ $('#dismiss-cookie-alert').click(function(event){
// Script for Result Actions
$('.result-action-buttons').click(function(event){
var _id = $(this).data('_id');
var id = $(this).data('id');
if ($(this).data('result-action') == 'generate') {
$.ajax({
url: '/admin/certificate/',
type: 'POST',
data: JSON.stringify({'_id': _id}),
data: JSON.stringify({'id': id}),
contentType: 'application/json',
dataType: 'html',
success: function(response) {
@ -207,7 +193,7 @@ $('.result-action-buttons').click(function(event){
$.ajax({
url: window.location.href,
type: 'POST',
data: JSON.stringify({'_id': _id, 'action': action}),
data: JSON.stringify({'id': id, 'action': action}),
contentType: 'application/json',
success: function(response) {
if (action == 'delete') {

View File

@ -2,7 +2,7 @@
{% block content %}
<div class="form-container">
<form name="form-update-account" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.home') }}">
<form name="form-update-account" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._home') }}">
{% include "admin/components/server-alerts.html" %}
<h2 class="form-heading">Update Your Account</h2>
{{ form.hidden_tag() }}
@ -32,7 +32,7 @@
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<a href="{{ url_for('admin_views.users') }}" class="btn btn-md btn-danger btn-block" type="button">
<a href="{{ url_for('admin._users') }}" class="btn btn-md btn-danger btn-block" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg>

View File

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

View File

@ -3,14 +3,14 @@
{% block navbar %}
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
<div class="container">
<a href="{{ url_for('admin_views.home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
</div>
</nav>
{% endblock %}
{% block content %}
<div class="form-container">
<form name="form-register" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_auth.login') }}">
<form name="form-register" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
{% include "admin/components/server-alerts.html" %}
<h2 class="form-heading">Register an Account</h2>
{{ form.hidden_tag() }}

View File

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

View File

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

View File

@ -15,30 +15,30 @@
<h5 class="mb-1">Candidate</h5>
</div>
<h2>
{{ entry.name.surname}}, {{ entry.name.first_name }}
{{ entry.get_surname()}}, {{ entry.get_first_name() }}
</h2>
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Email Address</h5>
</div>
{{ entry.email }}
{{ entry.get_email() }}
</li>
{% if entry['club'] %}
{% if entry.club %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Club</h5>
</div>
{{ entry.club }}
{{ entry.get_club() }}
</li>
{% endif %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Exam Code</h5>
</div>
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
{{ entry.test.get_code() }}
</li>
{% if entry['user_code'] %}
{% if entry.user_code %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">User Code</h5>
@ -59,19 +59,19 @@
<span class="badge bg-danger">Late</span>
{% endif %}
</div>
{{ entry.submission_time.strftime('%d %b %Y %H:%M:%S') }}
{{ entry.end_time.strftime('%d %b %Y %H:%M:%S') }}
</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;
{{ entry.result.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 %}">
<li class="list-group-item list-group-item-action {% if entry.result.grade == 'fail' %}list-group-item-danger {% elif entry.result.grade == 'merit' %} list-group-item-success {% endif %}">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Grade</h5>
</div>
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:]}}
{{ entry.result.grade[0]|upper }}{{ entry.result.grade[1:]}}
</li>
</ul>
<div class="site-footer mt-5">

View File

@ -0,0 +1,111 @@
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
<div class="container">
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbar"
aria-controls="navbar"
aria-expanded="false"
aria-label="Toggle Navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navbar">
<ul class="navbar-nav">
{% if not current_user.is_authenticated %}
<li class="nav-item" id="nav-login">
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
</li>
{% endif %}
{% if current_user.is_authenticated %}
<li class="nav-item" id="nav-results">
<a href="{{ url_for('admin._view_entries') }}" id="link-results" class="nav-link">View Results</a>
</li>
<li class="nav-item dropdown" id="nav-tests">
<a
class="nav-link dropdown-toggle"
id="dropdown-tests"
role="button"
href="{{ url_for('admin._tests') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Exams
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-settings"
>
<li>
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-settings">
<a
class="nav-link dropdown-toggle"
id="dropdown-account"
role="button"
href="{{ url_for('admin._settings') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Settings
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-settings"
>
<li>
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
</li>
<li>
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
</li>
<li>
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Question Datasets</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-account">
<a
class="nav-link dropdown-toggle"
id="dropdown-account"
role="button"
href="{{ url_for('admin._update_user', id=current_user.id) }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Account
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-account"
>
<li>
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
</li>
<li>
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
</li>
</ul>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>

View File

@ -0,0 +1,23 @@
<div class="navbar navbar-expand-sm navbar-light bg-light">
<div class="container-fluid">
<div class="expand navbar-expand justify-content-center" id="navbar_secondary">
<ul class="nav nav-pills">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin._tests', filter='active') }}">Active</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin._tests', filter='scheduled') }}">Scheduled</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin._tests', filter='expired') }}">Expired</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin._tests', filter='all') }}">All</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin._tests', filter='create') }}">Create</a>
</li>
</ul>
</div>
</div>
</div>

View File

@ -25,22 +25,22 @@
{% for test in current_tests %}
<tr>
<td>
<a href="{{ url_for('admin_views.view_test', _id=test._id) }}">{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}</a>
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
</td>
<td>
{{ test.expiry_date.strftime('%d %b %Y') }}
{{ test.end_date.strftime('%d %b %Y') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('admin_views.tests', filter='active') }}" class="btn btn-primary">View Exams</a>
<a href="{{ url_for('admin._tests', filter='active') }}" class="btn btn-primary">View Exams</a>
{% else %}
<div class="alert alert-primary">
There are currently no active exams.
</div>
<a href="{{ url_for('admin_views.tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
<a href="{{ url_for('admin._tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
{% endif %}
</div>
</div>
@ -69,20 +69,20 @@
{% for result in recent_results %}
<tr>
<td>
<a href="{{ url_for('admin_views.view_entry', _id=result._id) }}">{{ result.name.surname }}, {{ result.name.first_name }}</a>
<a href="{{ url_for('admin._view_entry', id=result.id) }}">{{ result.get_surname() }}, {{ result.get_first_name() }}</a>
</td>
<td>
{{ result.submission_time.strftime('%d %b %Y %H:%M') }}
{{ result.end_time.strftime('%d %b %Y %H:%M') }}
</td>
<td>
{{ result.percent }}&percnt; ({{ result.results.grade }})
{{ (100*result.result['score']/result.result['max'])|round|int }}&percnt; ({{ result.result.grade }})
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('admin_views.view_entries') }}" class="btn btn-primary">View Results</a>
<a href="{{ url_for('admin._view_entries') }}" class="btn btn-primary">View Results</a>
{% else %}
<div class="alert alert-primary">
There are currently no exam results to preview.
@ -114,22 +114,22 @@
{% for test in upcoming_tests %}
<tr>
<td>
<a href="{{ url_for('admin_views.view_test', _id=test._id) }}">{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}</a>
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
</td>
<td>
{{ test.expiry_date.strftime('%d %b %Y') }}
{{ test.end_date.strftime('%d %b %Y') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('admin_views.tests', filter='scheduled') }}" class="btn btn-primary">View Exams</a>
<a href="{{ url_for('admin._tests', filter='scheduled') }}" class="btn btn-primary">View Exams</a>
{% else %}
<div class="alert alert-primary">
There are currently no upcoming exams.
</div>
<a href="{{ url_for('admin_views.tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
<a href="{{ url_for('admin._tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
{% endif %}
</div>
</div>

View File

@ -13,30 +13,30 @@
<h5 class="mb-1">Candidate</h5>
</div>
<h2>
{{ entry.name.surname }}, {{ entry.name.first_name }}
{{ entry.get_surname() }}, {{ entry.get_first_name() }}
</h2>
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Email Address</h5>
</div>
{{ entry.email }}
{{ entry.get_email() }}
</li>
{% if entry['club'] %}
{% if entry.club %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Club</h5>
</div>
{{ entry.club }}
{{ entry.get_club() }}
</li>
{% endif %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Exam Code</h5>
</div>
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
{{ entry.test.get_code() }}
</li>
{% if entry['user_code'] %}
{% if entry.user_code %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">User Code</h5>
@ -44,7 +44,7 @@
{{ entry.user_code }}
</li>
{% endif %}
{% if 'start_time' in entry %}
{% if entry.start_time %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Start Time</h5>
@ -59,28 +59,28 @@
<span class="badge bg-danger">Late</span>
{% endif %}
</div>
{% if 'submission_time' in entry %}
{{ entry.submission_time.strftime('%d %b %Y %H:%M:%S') }}
{% if entry.end_time %}
{{ entry.end_time.strftime('%d %b %Y %H:%M:%S') }}
{% else %}
Incomplete
{% endif %}
</li>
{% if 'results' in entry %}
{% if entry.result %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Score</h5>
</div>
{{ entry.results.score }}&percnt;
{{ entry.result.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 %}">
<li class="list-group-item list-group-item-action {% if entry.result.grade == 'fail' %}list-group-item-danger {% elif entry.result.grade == 'merit' %} list-group-item-success {% endif %}">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Grade</h5>
</div>
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:]}}
{{ entry.result.grade[0]|upper }}{{ entry.result.grade[1:]}}
</li>
{% endif %}
</ul>
{% if 'results' in entry %}
{% if entry.result %}
<div class="accordion" id="results-breakdown">
<div class="accordion-item">
<h2 class="accordion-header" id="by-category">
@ -105,7 +105,7 @@
</tr>
</thead>
<tbody>
{% for tag, scores in entry.results.tags.items() %}
{% for tag, scores in entry.result.tags.items() %}
<tr>
<td>
{{ tag }}
@ -149,8 +149,8 @@
{{ question }}
</td>
<td>
{{ answer }}
{% if not correct[question] == answer %}
{{ answers[question|int][answer|int] }}
{% if not correct[question] == answer|int %}
<span class="badge badge-pill bg-danger badge-danger">Incorrect</span>
{% endif %}
</td>
@ -164,19 +164,19 @@
{% endif %}
<div class="container justify-content-center">
<div class="row">
<a href="#" class="btn btn-primary result-action-buttons" data-result-action="generate" data-_id="{{ entry._id }}">
<a href="#" class="btn btn-primary result-action-buttons" data-result-action="generate" data-id="{{ entry.id }}">
<i class="bi bi-printer-fill button-icon"></i>
Printable Version
</a>
</div>
<div class="row">
{% if entry.status == 'late' %}
<a href="#" class="btn btn-warning result-action-buttons" data-result-action="override" data-_id="{{ entry._id }}">
<a href="#" class="btn btn-warning result-action-buttons" data-result-action="override" data-id="{{ entry.id }}">
<i class="bi bi-clock-history button-icon"></i>
Allow Late Entry
</a>
{% endif %}
<a href="#" class="btn btn-danger result-action-buttons" data-result-action="delete" data-_id="{{ entry._id }}">
<a href="#" class="btn btn-danger result-action-buttons" data-result-action="delete" data-id="{{ entry.id }}">
<i class="bi bi-trash-fill button-icon"></i>
Delete Result
</a>

View File

@ -37,41 +37,41 @@
{% for entry in entries %}
<tr class="table-row">
<td>
{{ entry.name.surname }}, {{ entry.name.first_name }}
{{ entry.get_surname() }}, {{ entry.get_first_name() }}
</td>
<td>
{% if 'club' in entry %}
{{ entry.club }}
{% if entry.club %}
{{ entry.get_club() }}
{% endif %}
</td>
<td>
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
{{ entry.test.get_code() }}
</td>
<td>
{% if 'status' in entry %}
{% if entry.status %}
{{ entry.status }}
{% endif %}
</td>
<td>
{% if 'submission_time' in entry %}
{{ entry.submission_time.strftime('%d %b %Y') }}
{% if entry.end_time %}
{{ entry.end_time.strftime('%d %b %Y') }}
{% endif %}
</td>
<td>
{% if 'results' in entry %}
{{ entry.results.score }}&percnt;
{% if entry.result %}
{{ entry.result.score }}&percnt;
{% endif %}
</td>
<td>
{% if 'results' in entry %}
{{ entry.results.grade }}
{% if entry.result %}
{{ entry.result.grade }}
{% endif %}
</td>
<td class="row-actions">
<a
href="{{ url_for('admin_views.view_entry', _id = entry._id ) }}"
href="{{ url_for('admin._view_entry', id = entry.id ) }}"
class="btn btn-primary entry-details"
data-_id="{{entry._id}}"
data-id="{{entry.id}}"
title="View Details"
>
<i class="bi bi-file-medical-fill button-icon"></i>

View File

@ -2,11 +2,11 @@
{% block content %}
<div class="form-container">
<form name="form-delete-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.users') }}">
<form name="form-delete-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._users') }}">
{% include "admin/components/server-alerts.html" %}
<h2 class="form-heading">Delete User &lsquo;{{ user.username }}&rsquo;?</h2>
<h2 class="form-heading">Delete User &lsquo;{{ user.get_username() }}&rsquo;?</h2>
{{ form.hidden_tag() }}
<p>This action cannot be undone. Deleting an account will mean {{ user.username }} will no longer be able to log in to the admin console.</p>
<p>This action cannot be undone. Deleting an account will mean {{ user.get_username() }} will no longer be able to log in to the admin console.</p>
<p>Are you sure you want to proceed?</p>
<div class="form-label-group">
{{ form.password(class_="form-control", placeholder="Confirm Your Password", autofocus=true) }}
@ -20,7 +20,7 @@
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<a href="{{ url_for('admin_views.users') }}" autofocus="true" class="btn btn-md btn-primary btn-block" type="button">
<a href="{{ url_for('admin._users') }}" autofocus="true" class="btn btn-md btn-primary btn-block" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg>

View File

@ -28,22 +28,22 @@
<tr>
<td>
<a href="
{% if user._id == get_id_from_cookie() %}
{{ url_for('admin_auth.account') }}
{% if user == current_user %}
{{ url_for('admin._update_user', id=current_user.id) }}
{% else %}
{{ url_for('admin_views.update_user', _id=user._id) }}
{{ url_for('admin._update_user', id=user.id) }}
{% endif%}
">{{ user.username }}</a>
">{{ user.get_username() }}</a>
</td>
<td>
<a href="mailto:{{ user.email }}">{{ user.email }}</a>
<a href="mailto:{{ user.get_email() }}">{{ user.get_email() }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('admin_views.users') }}" class="btn btn-primary">Manage Users</a>
<a href="{{ url_for('admin._users') }}" class="btn btn-primary">Manage Users</a>
</div>
</div>
</div>
@ -57,7 +57,7 @@
<thead>
<tr>
<th>
File Name
Uploaded
</th>
<th>
Exams
@ -68,22 +68,22 @@
{% for dataset in datasets %}
<tr>
<td>
{{ dataset.filename }}
{{ dataset.date.strftime('%d %b %Y %H:%M') }}
</td>
<td>
{{ dataset.use }}
{{ dataset.tests|length }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('admin_views.questions') }}" class="btn btn-primary">Manage Datasets</a>
<a href="{{ url_for('admin._questions') }}" class="btn btn-primary">Manage Datasets</a>
{% else %}
<div class="alert alert-primary">
There are currently no question datasets uploaded.
</div>
<a href="{{ url_for('admin_views.questions') }}" class="btn btn-primary">Upload Dataset</a>
<a href="{{ url_for('admin._questions') }}" class="btn btn-primary">Upload Dataset</a>
{% endif %}
</div>
</div>

View File

@ -9,9 +9,6 @@
<tr>
<th>
</th>
<th data-priority="1">
File Name
</th>
<th data-priority="2">
Uploaded
@ -31,7 +28,7 @@
{% for element in data %}
<tr class="table-row">
<td>
{% if element.filename == default %}
{% if element.default %}
<div class="text-success" title="Default Dataset">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi success bi-caret-right-fill" viewBox="0 0 16 16">
<path d="m12.14 8.753-5.482 4.796c-.646.566-1.658.106-1.658-.753V3.204a1 1 0 0 1 1.659-.753l5.48 4.796a1 1 0 0 1 0 1.506z"/>
@ -40,16 +37,13 @@
{% endif %}
</td>
<td>
{{ element.filename }}
{{ element.date.strftime('%d %b %Y %H:%M') }}
</td>
<td>
{{ element.timestamp.strftime('%d %b %Y') }}
{{ element.creator.get_username() }}
</td>
<td>
{{ element.author }}
</td>
<td>
{{ element.use }}
{{ element.tests|length }}
</td>
<td class="row-actions">
<a
@ -112,10 +106,10 @@
$(document).ready(function() {
$('#question-datasets-table').DataTable({
'columnDefs': [
{'sortable': false, 'targets': [0,5]},
{'searchable': false, 'targets': [0,4,5]}
{'sortable': false, 'targets': [0,4]},
{'searchable': false, 'targets': [0,3,4]}
],
'order': [[2, 'desc'], [3, 'asc']],
'order': [[1, 'desc'], [2, 'asc']],
'responsive': 'true',
'fixedHeader': 'true',
});

View File

@ -2,12 +2,12 @@
{% block content %}
<div class="form-container">
<form name="form-update-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.users') }}">
<form name="form-update-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._users') }}">
{% include "admin/components/server-alerts.html" %}
<h2 class="form-heading">Update User &lsquo;{{ user.username }}&rsquo;</h2>
<h2 class="form-heading">Update User &lsquo;{{ user.get_username() }}&rsquo;</h2>
{{ form.hidden_tag() }}
<div class="form-label-group">
{{ form.email(class_="form-control", placeholder="Email Address", value = user.email) }}
{{ form.email(class_="form-control", placeholder="Email Address", value = user.get_email()) }}
{{ form.email.label }}
</div>
<div class="form-label-group">
@ -23,17 +23,17 @@
{{ form.notify.label }}
</div>
<div class="form-label-group">
Please confirm <strong>your password</strong> before committing any changes to a user account.
Please confirm <strong>your current password</strong> before committing any changes to a user account.
</div>
<div class="form-label-group">
{{ form.user_password(class_="form-control", placeholder="Your Password", value = user.email) }}
{{ form.user_password.label }}
{{ form.confirm_password(class_="form-control", placeholder="Your Password", value = user.email) }}
{{ form.confirm_password.label }}
</div>
{% include "admin/components/client-alerts.html" %}
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<a href="{{ url_for('admin_views.users') }}" class="btn btn-md btn-danger btn-block" type="button">
<a href="{{ url_for('admin._users') }}" class="btn btn-md btn-danger btn-block" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg>

View File

@ -23,7 +23,7 @@
{% for user in users %}
<tr class="table-row">
<td>
{% if user._id == get_id_from_cookie() %}
{% if user == current_user %}
<div class="text-success" title="Current User">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi success bi-caret-right-fill" viewBox="0 0 16 16">
<path d="m12.14 8.753-5.482 4.796c-.646.566-1.658.106-1.658-.753V3.204a1 1 0 0 1 1.659-.753l5.48 4.796a1 1 0 0 1 0 1.506z"/>
@ -32,18 +32,18 @@
{% endif %}
</td>
<td>
{{ user.username }}
{{ user.get_username() }}
</td>
<td>
{{ user.email }}
{{ user.get_email() }}
</td>
<td class="row-actions">
<a
href="
{% if not user._id == get_id_from_cookie() %}
{{ url_for('admin_views.update_user', _id = user._id ) }}
{% if not user == current_user %}
{{ url_for('admin._update_user', id = user.id ) }}
{% else %}
{{ url_for('admin_auth.account') }}
{{ url_for('admin._update_user', id=current_user.id) }}
{% endif %}
"
class="btn btn-primary"
@ -53,15 +53,15 @@
</a>
<a
href="
{% if not user._id == get_id_from_cookie() %}
{{ url_for('admin_views.delete_user', _id = user._id ) }}
{% if not user == current_user %}
{{ url_for('admin._delete_user', id = user.id ) }}
{% else %}
#
{% endif %}
"
class="btn btn-danger {% if user._id == get_id_from_cookie() %} disabled {% endif %}"
class="btn btn-danger {% if user == current_user %} disabled {% endif %}"
title="Delete User"
{% if user._id == get_id_from_cookie() %} onclick="return false" {% endif %}
{% if user == current_user %} onclick="return false" {% endif %}
>
<i class="bi bi-person-x-fill button-icon"></i>
</button>

View File

@ -12,38 +12,33 @@
<h5 class="mb-1">Exam Code</h5>
</div>
<h2>
{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}
{{ test.get_code() }}
</h2>
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Dataset</h5>
</div>
{{ test.dataset }}
{{ test.dataset.date.strftime('%Y%m%d%H%M%S') }}
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Created By</h5>
</div>
{{ test.creator }}
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Date Created</h5>
</div>
{{ test.date_created.strftime('%d %b %Y') }}
{{ test.creator.get_username() }}
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Start Date</h5>
</div>
{{ test.start_date.strftime('%d %b %Y') }}
{{ test.start_date.strftime('%d %b %Y %H:%M') }}
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Expiry Date</h5>
</div>
{{ test.expiry_date.strftime('%d %b %Y') }}
{{ test.end_date.strftime('%d %b %Y %H:%M') }}
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
@ -62,7 +57,7 @@
{% endif %}
</li>
<div class="accordion" id="test-info-detail">
{% if 'entries' in test and test.entries|length > 0 %}
{% if test.entries|length > 0 %}
<div class="accordion-item">
<h2 class="accordion-header" id="test-entries">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#test-entries-list" aria-expanded="false" aria-controls="test-entries-list">
@ -76,7 +71,7 @@
{% for entry in test.entries %}
<tr>
<td>
<a href="{{ url_for('admin_views.view_entry', _id=entry) }}" >Entry {{ loop.index }}</a>
<a href="{{ url_for('admin._view_entry', id=entry) }}" >Entry {{ loop.index }}</a>
</td>
</tr>
{% endfor %}
@ -86,7 +81,7 @@
</div>
</div>
{% endif %}
{% if 'time_adjustments' in test and test.time_adjustments|length > 0 %}
{% if test.adjustments %}
<div class="accordion-item">
<h2 class="accordion-header" id="test-adjustments">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#test-adjustments-list" aria-expanded="false" aria-controls="test-adjustments-list">
@ -110,10 +105,10 @@
</tr>
</thead>
<tbody>
{% for key, value in test.time_adjustments.items() %}
{% for key, value in test.adjustments.items() %}
<tr>
<td>
{{ key }}
{{ key.upper() }}
</td>
<td>
{{ value }}
@ -143,7 +138,7 @@
<form name="form-add-adjustment" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="">
{{ form.hidden_tag() }}
<div class="form-label-group">
{{ form.time(class_="form-control", placeholder="Enter Username") }}
{{ form.time(class_="form-control", placeholder="Enter Time") }}
{{ form.time.label }}
</div>
<div class="container form-submission-button">
@ -168,11 +163,18 @@
</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 }}">
{% if test.start_date <= now %}
<a href="#" class="btn btn-warning test-action {% if test.end_date < now %}disabled{% endif %}" data-action="end" data-id="{{ test.id }}">
<i class="bi bi-hourglass-bottom button-icon"></i>
Close Exam
</a>
{% else %}
<a href="#" class="btn btn-success test-action {% if test.start_date < now %}disabled{% endif %}" data-action="start" data-id="{{ test.id }}">
<i class="bi bi-hourglass-top button-icon"></i>
Start Exam
</a>
{% endif %}
<a href="#" class="btn btn-danger test-action" data-action="delete" data-id="{{ test.id }}">
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
Delete Exam
</a>

View File

@ -33,13 +33,13 @@
{% for test in tests %}
<tr class="table-row">
<td>
{{ test.start_date.strftime('%d %b %Y') }}
{{ test.start_date.strftime('%d %b %y %H:%M') }}
</td>
<td>
{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}
{{ test.get_code() }}
</td>
<td>
{{ test.expiry_date.strftime('%d %b %Y') }}
{{ test.end_date.strftime('%d %b %Y %H:%M') }}
</td>
<td>
{% if test.time_limit == None -%}
@ -61,7 +61,7 @@
<a
href="#"
class="btn btn-primary test-action"
data-_id="{{test._id}}"
data-id="{{test.id}}"
title="Edit Exam"
data-action="edit"
>
@ -70,7 +70,7 @@
<a
href="#"
class="btn btn-danger test-action"
data-_id="{{test._id}}"
data-id="{{test.id}}"
title="Delete Exam"
data-action="delete"
>

392
ref-test/app/admin/views.py Normal file
View File

@ -0,0 +1,392 @@
from ..forms.admin import AddTimeAdjustment, CreateTest, CreateUser, DeleteUser, Login, Register, ResetPassword, UpdatePassword, UpdateUser, UploadData
from ..models import Dataset, Entry, Test, User
from ..tools.auth import disable_if_logged_in, require_account_creation
from ..tools.forms import get_dataset_choices, get_time_options
from ..tools.data import check_is_json, validate_json
from ..tools.test import answer_options, get_correct_answers
from flask import Blueprint, jsonify, render_template, redirect, request, session
from flask.helpers import flash, url_for
from flask_login import current_user, login_required
from datetime import date, datetime
from json import loads
import secrets
admin = Blueprint(
name='admin',
import_name=__name__,
template_folder='templates',
static_folder='static'
)
@admin.route('/')
@admin.route('/home/')
@admin.route('/dashboard/')
@login_required
def _home():
tests = Test.query.all()
results = Entry.query.all()
current_tests = [ test for test in tests if test.end_date >= datetime.now() and test.start_date.date() <= date.today() ]
current_tests.sort(key= lambda x: x.end_date, reverse=True)
upcoming_tests = [ test for test in tests if test.start_date.date() > datetime.now().date()]
upcoming_tests.sort(key= lambda x: x.start_date)
recent_results = [result for result in results if not result.status == 'started' ]
recent_results.sort(key= lambda x: x.end_time, reverse=True)
return render_template('/admin/index.html', current_tests = current_tests, upcomimg_tests = upcoming_tests, recent_results = recent_results)
@admin.route('/settings/')
@login_required
def _settings():
users = User.query.all()
datasets = Dataset.query.all()
return render_template('/admin/settings/index.html', users=users, datasets=datasets)
@admin.route('/login/', methods=['GET','POST'])
@disable_if_logged_in
@require_account_creation
def _login():
form = Login()
if request.method == 'POST':
if form.validate_on_submit():
users = User.query.all()
user = None
for _user in users:
if _user.get_username() == request.form.get('username').lower():
user = _user
break
if user:
if user.verify_password(request.form.get('password')):
user.login(remember=request.form.get('remember'))
return jsonify({'success': f'Successfully logged in.'}), 200
return jsonify({'error': f'The password you entered is incorrect.'}), 401
return jsonify({'error': f'The username you entered does not exist.'}), 401
errors = [*form.username.errors, *form.password.errors]
return jsonify({ 'error': errors}), 400
if 'remembered_username' in session: form.username.data = session.pop('remembered_username')
next = request.args.get('next')
return render_template('/admin/auth/login.html', form=form, next=next)
@admin.route('/logout/')
@login_required
def _logout():
current_user.logout()
return redirect(url_for('admin._login'))
@admin.route('/register/', methods=['GET','POST'])
@disable_if_logged_in
def _register():
from ..models.user import User
form = Register()
if request.method == 'POST':
if form.validate_on_submit():
new_user = User()
new_user.set_username(request.form.get('username').lower())
new_user.set_email(request.form.get('email').lower())
success, message = new_user.register(password=request.form.get('password'))
if success:
flash(message=f'{message} Please log in to continue.', category='success')
session['remembered_username'] = request.form.get('username').lower()
return jsonify({'success': message}), 200
flash(message=message, category='error')
return jsonify({'error': message}), 401
errors = [*form.username.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
return jsonify({ 'error': errors}), 400
return render_template('admin/auth/register.html', form=form)
@admin.route('/reset/', methods=['GET','POST'])
def _reset():
form = ResetPassword()
if request.method == 'POST':
if form.validate_on_submit():
user = None
users = User.query.all()
for _user in users:
if _user.get_username() == request.form.get('username'):
user = _user
break
if not user: return jsonify({'error': 'The user account does not exist.'}), 400
if not user.get_email() == request.form.get('email'): return jsonify({'error': 'The email address does not match the user account.'}), 400
return user.reset_password()
errors = [*form.username.errors, *form.email.errors]
return jsonify({ 'error': errors}), 400
token = request.args.get('token')
if token:
user = User.query.filter_by(reset_token=token).first()
if not user: return redirect(url_for('admin._reset'))
verification_token = user.verification_token
user.clear_reset_tokens()
if request.args.get('verification') == verification_token:
form = UpdatePassword()
return render_template('/auth/update_password.html', form=form, user=user.id)
flash('The verification of your password reset request failed and the token has been invalidated. Please make a new reset password request.', 'error')
return render_template('/admin/auth/reset.html', form=form)
@admin.route('/update_password/', methods=['POST'])
def _update_password():
form = UpdatePassword()
if form.validate_on_submit():
user = request.form.get('user')
user = User.query.filter_by(id=user).first()
user.update(password=request.form.get('password'))
session['remembered_username'] = user.get_username()
flash('Your password has been reset.', 'success')
return jsonify({'success':'Your password has been reset'}), 200
errors = [*form.password.errors, *form.password_reenter.errors]
return jsonify({ 'error': errors}), 401
@admin.route('/settings/users/', methods=['GET', 'POST'])
@login_required
def _users():
form = CreateUser()
users = User.query.all()
if request.method == 'POST':
if form.validate_on_submit():
password = request.form.get('password')
password = secrets.token_hex(12) if not password else password
new_user = User()
new_user.set_username(request.form.get('username').lower())
new_user.set_email(request.form.get('email'))
success, message = new_user.register(notify=request.form.get('notify'), password=password)
if success: return jsonify({'success': message}), 200
return jsonify({'error': message}), 401
errors = [*form.username.errors, *form.email.errors, *form.password.errors]
return jsonify({ 'error': errors}), 401
return render_template('/admin/settings/users.html', form = form, users = users)
@admin.route('/settings/users/delete/<string:id>', methods=['GET', 'POST'])
@login_required
def _delete_user(id:str):
user = User.query.filter_by(id=id).first()
form = DeleteUser()
if request.method == 'POST':
if not user: return jsonify({'error': 'User does not exist.'}), 400
if id == current_user.id: return jsonify({'error': 'Cannot delete your own account.'}), 400
if form.validate_on_submit():
password = request.form.get('password')
if not current_user.verify_password(password): return jsonify({'error': 'The password you entered is incorrect.'}), 401
success, message = user.delete(notify=request.form.get('notify'))
if success: return jsonify({'success': message}), 200
return jsonify({'error': message}), 400
errors = form.password.errors
return jsonify({ 'error': errors}), 400
if id == current_user.id:
flash('Cannot delete your own user account.', 'error')
return redirect(url_for('admin._users'))
if not user:
flash('User not found.', 'error')
return redirect(url_for('admin._users'))
return render_template('/admin/settings/delete_user.html', form=form, id = id, user = user)
@admin.route('/settings/users/update/<string:id>', methods=['GET', 'POST'])
@login_required
def _update_user(id:str):
user = User.query.filter_by(id=id).first()
form = UpdateUser()
if request.method == 'POST':
if not user: return jsonify({'error': 'User does not exist.'}), 400
if form.validate_on_submit():
if not user.verify_password(request.form.get('confirm_password')): return jsonify({'error': 'Invalid password for your account.'}), 401
success, message = user.update(
password = request.form.get('password'),
email = request.form.get('email'),
notify = request.form.get('notify')
)
if success:
flash(message, 'success')
return jsonify({'success': message}), 200
return jsonify({'error': message}), 400
errors = [*form.confirm_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
return jsonify({ 'error': errors}), 400
if not user:
flash('User not found.', 'error')
return redirect(url_for('admin._users'))
return render_template('/admin/settings/update_user.html', form=form, id = id, user = user)
@admin.route('/settings/questions/', methods=['GET', 'POST'])
@login_required
def _questions():
form = UploadData()
if request.method == 'POST':
if form.validate_on_submit():
upload = form.data_file.data
if not check_is_json(upload): return jsonify({'error': 'Invalid file. Please upload a JSON file.'}), 400
if not validate_json(upload): return jsonify({'error': 'The data in the file is invalid.'}), 400 # TODO Perhaps make a more complex validation script
new_dataset = Dataset()
success, message = new_dataset.create(
upload = upload,
default = request.form.get('default')
)
if success: return jsonify({'success': message}), 200
return jsonify({'error': message}), 400
errors = form.data_file.errors
return jsonify({ 'error': errors}), 400
data = Dataset.query.all()
return render_template('/admin/settings/questions.html', form=form, data=data)
@admin.route('/settings/questions/edit/', methods=['POST'])
@login_required
def _edit_questions():
id = request.get_json()['id']
action = request.get_json()['action']
if action not in ['defailt', 'delete']: return jsonify({'error': 'Invalid action.'}), 400
dataset = Dataset.query.filter_by(id=id).first()
if action == 'delete': success, message = dataset.delete()
elif action == 'default': success, message = dataset.make_default()
if success: return jsonify({'success': message}), 200
return jsonify({'error': message}), 400
@admin.route('/tests/<string:filter>/', methods=['GET'])
@admin.route('/tests/', methods=['GET'])
@login_required
def _tests(filter:str=None):
datasets = Dataset.query.all()
tests = None
_tests = Test.query.all()
form = None
now = datetime.now()
if not datasets:
flash('There are no available question datasets. Please upload a question dataset in order to set up an exam.', 'error')
return redirect(url_for('admin._questions'))
if filter not in ['create','active','scheduled','expired','all']: return redirect(url_for('admin._tests', filter='active'))
if filter == 'create':
form = CreateTest()
form.time_limit.choices = get_time_options()
form.dataset.choices = get_dataset_choices()
form.time_limit.default='none'
form.process()
display_title = ''
error_none = ''
if filter in [None, '', 'active']:
tests = [ test for test in _tests if test.end_date >= now and test.start_date <= now ]
display_title = 'Active Exams'
error_none = 'There are no exams that are currently active. You can create one using the Creat Exam form.'
if filter == 'expired':
tests = [ test for test in _tests if test.end_date < now ]
display_title = 'Expired Exams'
error_none = 'There are no expired exams. Exams will appear in this category after their expiration date has passed.'
if filter == 'scheduled':
tests = [ test for test in _tests if test.start_date > now]
display_title = 'Scheduled Exams'
error_none = 'There are no scheduled exams pending. You can schedule an exam for the future using the Create Exam form.'
if filter == 'all':
tests = _tests
display_title = 'All Exams'
error_none = 'There are no exams set up. You can create one using the Create Exam form.'
return render_template('/admin/tests.html', form = form, tests=tests, display_title=display_title, error_none=error_none, filter=filter)
@admin.route('/tests/create/', methods=['POST'])
@login_required
def _create_test():
form = CreateTest()
form.dataset.choices = get_dataset_choices()
form.time_limit.choices = get_time_options()
if form.validate_on_submit():
new_test = Test()
new_test.start_date = request.form.get('start_date')
new_test.start_date = datetime.strptime(new_test.start_date, '%Y-%m-%dT%H:%M')
new_test.end_date = request.form.get('expiry_date')
new_test.end_date = datetime.strptime(new_test.end_date, '%Y-%m-%dT%H:%M')
new_test.time_limit = None if request.form.get('time_limit') == 'none' else int(request.form.get('time_limit'))
dataset = request.form.get('dataset')
new_test.dataset = Dataset.query.filter_by(id=dataset).first()
success, message = new_test.create()
if success:
flash(message=message, category='success')
return jsonify({'success': message}), 200
return jsonify({'error': message}), 400
else:
errors = [*form.start_date.errors, *form.expiry_date.errors, *form.time_limit.errors]
return jsonify({ 'error': errors}), 400
@admin.route('/tests/edit/', methods=['POST'])
@login_required
def _edit_test():
id = request.get_json()['id']
action = request.get_json()['action']
if action not in ['start', 'delete', 'end']: return jsonify({'error': 'Invalid action.'}), 400
test = Test.query.filter_by(id=id).first()
if not test: return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
if action == 'delete': success, message = test.delete()
if action == 'start': success, message = test.start()
if action == 'end': success, message = test.end()
if success:
flash(message=message, category='success')
return jsonify({'success': message}), 200
return jsonify({'error': message}), 400
@admin.route('/test/<string:id>/', methods=['GET','POST'])
@login_required
def _view_test(id:str=None):
form = AddTimeAdjustment()
test = Test.query.filter_by(id=id).first()
if request.method == 'POST':
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
if form.validate_on_submit():
time = int(request.form.get('time'))
success, message = test.add_adjustment(time)
if success: return jsonify({'success': message}), 200
return jsonify({'error': message}), 400
return jsonify({'error': form.time.errors }), 400
if not test:
flash('Invalid test ID.', 'error')
return redirect(url_for('admin._tests', filter='active'))
return render_template('/admin/test.html', test = test, form = form)
@admin.route('/test/<string:id>/delete-adjustment/', methods=['POST'])
@login_required
def _delete_adjustment(id:str=None):
test = Test.query.filter_by(id=id).first()
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
user_code = request.get_json()['user_code'].lower()
success, message = test.remove_adjustment(user_code)
if success: return jsonify({'success': message}), 200
return jsonify({'error': message}), 400
@admin.route('/results/')
@login_required
def _view_entries():
entries = Entry.query.all()
return render_template('/admin/results.html', entries = entries)
@admin.route('/results/<string:id>/', methods = ['GET', 'POST'])
@login_required
def _view_entry(id:str=None):
entry = Entry.query.filter_by(id=id).first()
if request.method == 'POST':
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
action = request.get_json()['action']
if action not in ['validate', 'delete']: return jsonify({'error': 'Invalid action.'}), 400
if action == 'validate':
success, message = entry.validate()
if action == 'delete':
success, message = entry.delete()
if success:
flash(message, 'success')
entry.notify_result()
return jsonify({'success': message}), 200
return jsonify({'error': message}),400
if not entry:
flash('Invalid entry ID.', 'error')
return redirect(url_for('admin._view_entries'))
test = entry.test
dataset = test.dataset
dataset_path = dataset.get_file()
with open(dataset_path, 'r') as _dataset:
data = loads(_dataset.read())
correct = get_correct_answers(dataset=data)
answers = answer_options(dataset=data)
return render_template('/admin/result-detail.html', entry = entry, correct = correct, answers = answers)
@admin.route('/certificate/',methods=['POST'])
@login_required
def _generate_certificate():
from main import db
id = request.get_json()['id']
entry = Entry.query.filter_by(id=id).first()
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
return render_template('/admin/components/certificate.html', entry = entry)

66
ref-test/app/api/views.py Normal file
View File

@ -0,0 +1,66 @@
from ..models import Dataset, Entry
from ..tools.test import evaluate_answers, generate_questions
from flask import Blueprint, jsonify, request
from datetime import datetime, timedelta
from json import loads
api = Blueprint(
name='api',
import_name=__name__
)
@api.route('/questions/', methods=['POST'])
def _fetch_questions():
id = request.get_json()['id']
entry = Entry.query.filter_by(id=id).first()
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 400
test = entry.test
user_code = entry.user_code
time_limit = test.time_limit
time_adjustment = 0
if time_limit:
_time_limit = int(time_limit)
if user_code:
time_adjustment = test.adjustments[user_code]
_time_limit += time_adjustment
end_delta = timedelta(minutes=_time_limit)
end_time = datetime.utcnow() + end_delta
else:
end_time = None
entry.start()
dataset = test.dataset
success, message = dataset.check_file()
if not success: return jsonify({'error': message}), 500
data_path = dataset.get_file()
with open(data_path, 'r') as data_file:
data = loads(data_file.read())
questions = generate_questions(data)
return jsonify({
'time_limit': end_time,
'questions': questions,
'start_time': entry.start_time,
'time_adjustment': time_adjustment
}), 200
@api.route('/submit/', methods=['POST'])
def _submit_quiz():
id = request.get_json()['id']
answers = request.get_json()['answers']
entry = Entry.query.filter_by(id=id).first()
if not entry: return jsonify({'error': 'Unrecognised Entry.'}), 400
test = entry.test
dataset = test.dataset
success, message = dataset.check_file()
if not success: return jsonify({'error': message}), 500
data_path = dataset.get_file()
with open(data_path, 'r') as data_file:
data = loads(data_file.read())
result = evaluate_answers(answers=answers, key=data)
entry.complete(answers=answers, result=result)
return jsonify({
'success': 'Your submission has been processed. Redirecting you to receive your results.',
'id': id
}), 200

47
ref-test/app/config.py Normal file
View File

@ -0,0 +1,47 @@
import os
from pathlib import Path
if not os.getenv('DATA'):
from dotenv import load_dotenv
load_dotenv('../.env')
class Config(object):
APP_HOST = '0.0.0.0'
DATA = os.getenv('DATA')
DEBUG = False
TESTING = False
SECRET_KEY = os.getenv('SECRET_KEY')
SERVER_NAME = os.getenv('SERVER_NAME')
SESSION_COOKIE_SECURE = True
SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(DATA)}/database.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
MAIL_SERVER = os.getenv('MAIL_SERVER')
MAIL_PORT = int(os.getenv('MAIL_PORT'))
MAIL_USE_TLS = False
MAIL_USE_SSL = False
MAIL_DEBUG = False
MAIL_USERNAME = os.getenv('MAIL_USERNAME')
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER')
MAIL_MAX_EMAILS = int(os.getenv('MAIL_MAX_EMAILS'))
MAIL_SUPPRESS_SEND = False
MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS'))
class ProductionConfig(Config):
pass
class DevelopmentConfig(Config):
APP_HOST = '127.0.0.1'
DEBUG = True
SESSION_COOKIE_SECURE = False
MAIL_SERVER = 'localhost'
MAIL_DEBUG = True
MAIL_SUPPRESS_SEND = False
class TestingConfig(DevelopmentConfig):
TESTING = True
SESSION_COOKIE_SECURE = False
MAIL_SERVER = os.getenv('MAIL_SERVER')
MAIL_DEBUG = True
MAIL_SUPPRESS_SEND = False

5
ref-test/app/data.py Normal file
View File

@ -0,0 +1,5 @@
from config import Config
from os import path
from pathlib import Path
data = Path(Config.DATA)

View File

@ -1,62 +1,64 @@
from ..tools.forms import value
from flask_wtf import FlaskForm
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 datetime import date, timedelta
from flask_wtf.file import FileAllowed, FileField, FileRequired
from wtforms import BooleanField, IntegerField, PasswordField, SelectField, StringField
from wtforms.fields import DateTimeLocalField
from wtforms.validators import InputRequired, Email, EqualTo, Length, Optional
from .validators import value
from datetime import date, datetime, timedelta
class LoginForm(FlaskForm):
class Login(FlaskForm):
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.')])
remember = BooleanField('Remember Log In', render_kw={'checked': True})
class RegistrationForm(FlaskForm):
class Register(FlaskForm):
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')])
class ResetPasswordForm(FlaskForm):
class ResetPassword(FlaskForm):
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
class UpdatePasswordForm(FlaskForm):
class UpdatePassword(FlaskForm):
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')])
class CreateUserForm(FlaskForm):
class CreateUser(FlaskForm):
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
password = PasswordField('Password (Optional)', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
notify = BooleanField('Notify accout creation by email', render_kw={'checked': True})
class DeleteUserForm(FlaskForm):
class DeleteUser(FlaskForm):
password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
notify = BooleanField('Notify deletion by email', render_kw={'checked': True})
class UpdateUserForm(FlaskForm):
user_password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
class UpdateUser(FlaskForm):
confirm_password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)])
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')])
notify = BooleanField('Notify changes by email', render_kw={'checked': True})
class UpdateAccountForm(FlaskForm):
password_confirm = PasswordField('Current Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
class UpdateAccount(FlaskForm):
confirm_password = PasswordField('Current Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)])
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')])
class CreateTest(FlaskForm):
start_date = DateField('Start Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() )
expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) )
start_date = DateTimeLocalField('Start Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()], default = datetime.now() )
expiry_date = DateTimeLocalField('Expiry Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()], default = date.today() + timedelta(days=1) )
time_limit = SelectField('Time Limit')
dataset = SelectField('Question Dataset')
class UploadDataForm(FlaskForm):
class UploadData(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

@ -1,6 +1,6 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField
from wtforms.validators import InputRequired, Email, Length, Optional
from wtforms import StringField
from wtforms.validators import InputRequired, Length, Email, Optional
class StartQuiz(FlaskForm):
first_name = StringField('First Name(s)', validators=[InputRequired(), Length(max=30)])

View File

@ -0,0 +1,4 @@
from .entry import Entry
from .test import Test
from .user import User
from .dataset import Dataset

View File

@ -0,0 +1,83 @@
from ..data import data
from ..modules import db
from ..tools.logs import write
from flask import flash
from flask_login import current_user
from werkzeug.utils import secure_filename
from datetime import datetime
from json import dump, loads
from os import path, remove
from uuid import uuid4
class Dataset(db.Model):
id = db.Column(db.String(36), primary_key=True)
tests = db.relationship('Test', backref='dataset')
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
date = db.Column(db.DateTime, nullable=False)
default = db.Column(db.Boolean, default=False, nullable=True)
def __repr__(self):
return f'<Dataset {self.id}> was added.'
@property
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
generate_id.setter
def generate_id(self): self.id = uuid4().hex
def make_default(self):
for dataset in Dataset.query.all():
dataset.default = False
self.default = True
db.session.commit()
write('system.log', f'Dataset {self.id} set as default by {current_user.get_username()}.')
flash(message='Dataset set as default.', category='success')
return True, f'Dataset set as default.'
def delete(self):
if self.default:
message = 'Cannot delete the default dataset.'
flash(message, 'error')
return False, message
if Dataset.query.all().count() == 1:
message = 'Cannot delete the only dataset.'
flash(message, 'error')
return False, message
write('system.log', f'Dataset {self.id} deleted by {current_user.get_username()}.')
filename = secure_filename('.'.join([self.id,'json']))
file_path = path.join(data, 'questions', filename)
remove(file_path)
db.session.delete(self)
db.session.commit()
return True, 'Dataset deleted.'
def create(self, upload, default:bool=False):
self.generate_id()
timestamp = datetime.now()
filename = secure_filename('.'.join([self.id,'json']))
file_path = path.join(data, 'questions', filename)
upload.stream.seek(0)
questions = loads(upload.read())
with open(file_path, 'w') as file:
dump(questions, file, indent=2)
self.date = timestamp
self.creator = current_user
if default: self.make_default()
write('system.log', f'New dataset {self.id} added by {current_user.get_username()}.')
db.session.add(self)
db.session.commit()
return True, 'Dataset uploaded.'
def check_file(self):
filename = secure_filename('.'.join([self.id,'json']))
file_path = path.join(data, 'questions', filename)
if not path.isfile(file_path): return False, 'Data file is missing.'
return True, 'Data file found.'
def get_file(self):
filename = secure_filename('.'.join([self.id,'json']))
file_path = path.join(data, 'questions', filename)
return file_path

View File

@ -0,0 +1,177 @@
from ..modules import db, mail
from ..tools.forms import JsonEncodedDict
from ..tools.encryption import decrypt, encrypt
from ..tools.logs import write
from .test import Test
from flask_login import current_user
from flask_mail import Message
from datetime import datetime, timedelta
from uuid import uuid4
class Entry(db.Model):
id = db.Column(db.String(36), primary_key=True)
first_name = db.Column(db.String(128), nullable=False)
surname = db.Column(db.String(128), nullable=False)
email = db.Column(db.String(128), nullable=False)
club = db.Column(db.String(128), nullable=True)
test_id = db.Column(db.String(36), db.ForeignKey('test.id'))
user_code = db.Column(db.String(6), nullable=True)
start_time = db.Column(db.DateTime, nullable=True)
end_time = db.Column(db.DateTime, nullable=True)
status = db.Column(db.String(16), nullable=True)
valid = db.Column(db.Boolean, default=True, nullable=True)
answers = db.Column(JsonEncodedDict, nullable=True)
result = db.Column(JsonEncodedDict, nullable=True)
def __repr__(self):
return f'<New entry by {self.first_name} {self.surname}> was added with <id {self.id}>.'
@property
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
generate_id.setter
def generate_id(self): self.id = uuid4().hex
@property
def set_first_name(self): raise AttributeError('set_first_name is not a readable attribute.')
set_first_name.setter
def set_first_name(self, name:str): self.first_name = encrypt(name)
def get_first_name(self): return decrypt(self.first_name)
@property
def set_surname(self): raise AttributeError('set_first_name is not a readable attribute.')
set_surname.setter
def set_surname(self, name:str): self.surname = encrypt(name)
def get_surname(self): return decrypt(self.surname)
@property
def set_email(self): raise AttributeError('set_email is not a readable attribute.')
set_email.setter
def set_email(self, email:str): self.email = encrypt(email)
def get_email(self): return decrypt(self.email)
@property
def set_club(self): raise AttributeError('set_club is not a readable attribute.')
set_club.setter
def set_club(self, club:str): self.club = encrypt(club)
def get_club(self): return decrypt(self.club)
def ready(self):
self.generate_id()
db.session.add(self)
db.session.commit()
write('tests.log', f'New test ready for {self.get_first_name()} {self.get_surname()}.')
return True, f'Test ready.'
def start(self):
self.start_time = datetime.now()
self.status = 'started'
write('tests.log', f'Test started by {self.get_first_name()} {self.get_surname()}.')
db.session.commit()
return True, f'New test started with id {self.id}.'
def complete(self, answers:dict=None, result:dict=None):
self.end_time = datetime.now()
self.answers = answers
self.result = result
write('tests.log', f'Test completed by {self.get_first_name()} {self.get_surname()}.')
delta = timedelta(minutes=int(0 if self.test.time_limit is None else self.test.time_limit)+1)
if not self.test.time_limit or self.end_time <= self.start_time + delta:
self.status = 'completed'
self.valid = True
else:
self.status = 'late'
self.valid = False
db.session.commit()
return True, f'Test entry completed for id {self.id}.'
def validate(self):
if self.valid: return False, f'The entry is already valid.'
if self.status == 'started': return False, 'The entry is still pending.'
self.valid = True
self.status = 'completed'
db.session.commit()
write('system.log', f'The entry {self.id} has been validated by {current_user.get_username()}.')
return True, f'The entry {self.id} has been validated.'
def delete(self):
id = self.id
name = f'{self.get_first_name()} {self.get_surname()}'
db.session.delete(self)
db.session.commit()
write('system.log', f'The entry {id} by {name} has been deleted by {current_user.get_username()}.')
return True, 'Entry deleted.'
def notify_result(self):
score = round(100*self.result['score']/self.result['max'])
tags_low = { tag: tag_result['max'] - tag_result['scored'] for tag, tag_result in self.result['tags'].items() }
sorted_low_tags = sorted(tags_low.items(), key=lambda x: x[1], reverse=True)
tag_output = [ tag[0] for tag in sorted_low_tags[0:3] if tag[1] > 3]
revision_plain = ''
revision_html = ''
if self.result['grade'] == 'pass':
flavour_text = """Well done on successfully completing the refereeing theory exam. We really appreciate members of our community taking the time to get qualified to referee.
"""
elif self.result['grade'] == 'merit':
flavour_text = """Congratulations on achieving a merit in the refereeing exam. We are delighted that members of our community work so hard with refereeing.
"""
elif self.result['grade'] == 'fail':
flavour_text = """Unfortunately, you were not successful in passing the theory exam in this attempt. We hope that this does not dissuade you, and that you try again in the future.
"""
revision_plain = f"""Based on your answers, we would also suggest you brush up on the following topics for your next attempt:\n\n
{','.join(tag_output)}\n\n
"""
revision_html = f"""<p>Based on your answers, we would also suggest you brush up on the following topics for your next attempt:</p>
<ul>
<li>{'</li><li>'.join(tag_output)}</li>
</ul>
"""
email = Message(
subject='RefTest | SKA Refereeing Theory Exam Results',
recipients=[self.get_email()],
body=f"""
SKA Refereeing Theory Exam
Candidate Results
Dear {self.get_first_name()},
This email is to confirm that you have taken the SKA Refereeing Theory Exam. Your submission has been evaluated and your results are as follows:
{self.get_surname()}, {self.get_first_name()}
Email Address: {self.get_email()}
{f'Club: {self.get_club()}' if self.club else ''}
Date of Exam: {self.end_time.strftime('%d %b %Y')}
Score: {score}%
Grade: {self.result['grade']}
{flavour_text}
{revision_plain}
Thank you for taking the time to become a qualified referee.
Best wishes,
SKA Refereeing
""",
html=f"""
<h1>SKA Refereeing Theory Exam</h1>
<h2>Candidate Results</h2>
<p>Dear {self.get_first_name()},</p>
<p>This email is to confirm that you have taken the SKA Refereeing Theory Exam. Your submission has been evaluated and your results are as follows:</p>
<h3>{self.get_surname()}, {self.get_first_name()}</h3>
<p><strong>Email Address</strong>: {self.get_email()}</p>
{f'<p><strong>Club</strong>: {self.get_club()}</p>' if self.club else ''}
<h1>{score}%</h1>
<h2>{self.result['grade']}</h2>
<p>{flavour_text}</p>
{revision_html}
<p>Thank you for taking the time to become a qualified referee.</p>
<p>Have a nice day!</p>
<p>Best wishes, <br/> SKA Refereeing</p>
"""
)
mail.send(email)

111
ref-test/app/models/test.py Normal file
View File

@ -0,0 +1,111 @@
from ..modules import db
from ..tools.encryption import decrypt, encrypt
from ..tools.forms import JsonEncodedDict
from ..tools.logs import write
from flask_login import current_user
from datetime import date, datetime
import secrets
from uuid import uuid4
class Test(db.Model):
id = db.Column(db.String(36), primary_key=True)
code = db.Column(db.String(36), nullable=False)
start_date = db.Column(db.DateTime, nullable=True)
end_date = db.Column(db.DateTime, nullable=True)
time_limit = db.Column(db.Integer, nullable=True)
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
dataset_id = db.Column(db.String(36), db.ForeignKey('dataset.id'))
adjustments = db.Column(JsonEncodedDict, nullable=True)
entries = db.relationship('Entry', backref='test')
def __repr__(self):
return f'<Test with code {self.get_code()} was created by {current_user.get_username()}.>'
@property
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
generate_id.setter
def generate_id(self): self.id = uuid4().hex
@property
def generate_code(self): raise AttributeError('generate_code is not a readable attribute.')
generate_code.setter
def generate_code(self): self.code = secrets.token_hex(6).lower()
def get_code(self):
code = self.code.upper()
return ''.join([code[:4], code[4:8], code[8:]])
def create(self):
self.generate_id()
self.generate_code()
self.creator = current_user
errors = []
if self.start_date.date() < date.today():
errors.append('The start date cannot be in the past.')
if self.end_date.date() < date.today():
errors.append('The expiry date cannot be in the past.')
if self.end_date < self.start_date:
errors.append('The expiry date cannot be before the start date.')
if errors:
return False, errors
db.session.add(self)
db.session.commit()
write('system.log', f'Test with code {self.get_code()} created by {current_user.get_username()}.')
return True, f'Test with code {self.get_code()} has been created.'
def delete(self):
code = self.code
if self.entries: return False, f'Cannot delete a test with submitted entries.'
db.session.delete(self)
db.session.commit()
write('system.log', f'Test with code {self.get_code()} has been deleted by {current_user.get_username()}.')
return True, f'Test with code {self.get_code()} has been deleted.'
def start(self):
now = datetime.now()
if self.start_date.date() > now.date():
self.start_date = now
db.session.commit()
write('system.log', f'Test with code {self.get_code()} has been started by {current_user.get_username()}.')
return True, f'Test with code {self.get_code()} has been started.'
return False, f'Test with code {self.get_code()} has already started.'
def end(self):
now = datetime.now()
if self.end_date >= now:
self.end_date = now
db.session.commit()
write('system.log', f'Test with code {self.get_code()} ended by {current_user.get_username()}.')
return True, f'Test with code {self.get_code()} has been ended.'
return False, f'Test with code {self.get_code()} has already ended.'
def add_adjustment(self, time:int):
adjustments = self.adjustments if self.adjustments is not None else {}
code = secrets.token_hex(3).lower()
adjustments[code] = time
self.adjustments = adjustments
db.session.commit()
write('system.log', f'Time adjustment for {time} minutes with code {code} added to test {self.get_code()} by {current_user.get_username()}.')
return True, f'Time adjustment for {time} minutes added to test {self.get_code()}. This can be accessed using the user code {code.upper()}.'
def remove_adjustment(self, code:str):
if not self.adjustments: return False, f'There are no adjustments configured for test {self.get_code()}.'
self.adjustments.pop(code)
if not self.adjustments: self.adjustments = None
db.session.commit()
write('system.log', f'Time adjustment for with code {code} has been removed from test {self.get_code()} by {current_user.get_username()}.')
return True, f'Time adjustment for with code {code} has been removed from test {self.get_code()}.'
def update(self, start_date:datetime=None, end_date:datetime=None, time_limit:int=None):
if not start_date and not end_date and time_limit is None: return False, 'There were no changes requested.'
if start_date: self.start_date = start_date
if end_date: self.end_date = end_date
if time_limit is not None: self.time_limit = time_limit
db.session.commit()
write('system.log', f'Test with code {self.get_code()} has been updated by user {current_user.get_username()}.')
return True, f'Test with code {self.get_code()} has been updated by.'

223
ref-test/app/models/user.py Normal file
View File

@ -0,0 +1,223 @@
from ..modules import db, mail
from ..tools.encryption import decrypt, encrypt
from ..tools.logs import write
from flask import flash, jsonify, session
from flask.helpers import url_for
from flask_login import current_user, login_user, logout_user, UserMixin
from flask_mail import Message
from werkzeug.security import check_password_hash, generate_password_hash
import secrets
from uuid import uuid4
class User(UserMixin, db.Model):
id = db.Column(db.String(36), primary_key=True)
username = db.Column(db.String(128), nullable=False)
password = db.Column(db.String(128), nullable=False)
email = db.Column(db.String(128), nullable=False)
reset_token = db.Column(db.String(20), nullable=True)
verification_token = db.Column(db.String(20), nullable=True)
tests = db.relationship('Test', backref='creator')
datasets = db.relationship('Dataset', backref='creator')
def __repr__(self):
return f'<user {self.username}> was added with <id {self.id}>.'
@property
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
generate_id.setter
def generate_id(self): self.id = uuid4().hex
@property
def set_username(self): raise AttributeError('set_username is not a readable attribute.')
set_username.setter
def set_username(self, username:str): self.username = encrypt(username)
def get_username(self): return decrypt(self.username)
@property
def set_password(self): raise AttributeError('set_password is not a readable attribute.')
set_password.setter
def set_password(self, password:str): self.password = generate_password_hash(password, method="sha256")
def verify_password(self, password:str): return check_password_hash(self.password, password)
@property
def set_email(self): raise AttributeError('set_email is not a readable attribute.')
set_email.setter
def set_email(self, email:str): self.email = encrypt(email)
def get_email(self): return decrypt(self.email)
def register(self, notify:bool=False, password:str=None):
self.generate_id()
users = User.query.all()
for user in users:
if user.get_username() == self.get_username(): return False, f'Username {self.get_username()} already in use.'
if user.get_email() == self.get_email(): return False, f'Email address {self.get_email()} already in use.'
self.set_password(password=password)
db.session.add(self)
db.session.commit()
write('users.log', f'User \'{self.get_username()}\' was created with id \'{self.id}\'.')
if notify:
email = Message(
subject='RefTest | Registration Confirmation',
recipients=[self.email],
body=f"""
Hello {self.get_username()},\n\n
You have been registered as an administrator on the SKA RefTest App!\n\n
You can access your account using the username '{self.get_username()}'\n\n
Your password is as follows:\n\n
{password}\n\n
You can log in to the admin console via the following URL, where you can administer the test or change your password:\n\n
{url_for('admin._home', _external=True)}\n\n
Have a nice day!\n\n
SKA Refereeing
""",
html=f"""
<p>Hello {self.get_username()},</p>
<p>You have been registered as an administrator on the SKA RefTest App!</p>
<p>You can access your account using the username '{self.get_username()}'</p>
<p>Your password is as follows:</p>
<strong>{password}</strong>
<p>You can log in to the admin console via the following URL, where you can administer the test or change your password:</p>
<p><a href='{url_for('admin._home', _external=True)}'>{url_for('admin._home', _external=True)}</a></p>
<p>Have a nice day!</p>
<p>SKA Refereeing</p>
"""
)
mail.send(email)
return True, f'User {self.get_username()} was created successfully.'
def login(self, remember:bool=False):
login_user(self, remember = remember)
write('users.log', f'User \'{self.get_username()}\' has logged in.')
flash(message=f'Welcome {self.get_username()}', category='success')
def logout(self):
session['remembered_username'] = self.get_username()
logout_user()
write('users.log', f'User \'{self.get_username()}\' has logged out.')
flash(message='You have successfully logged out.', category='success')
def reset_password(self):
new_password = secrets.token_hex(12)
self.set_password(new_password)
self.reset_token = secrets.token_urlsafe(16)
self.verification_token = secrets.token_urlsafe(16)
db.session.commit()
email = Message(
subject='RefTest | Password Reset',
recipients=[self.get_email()],
body=f"""
Hello {self.get_username()},\n\n
This email was generated because we received a request to reset the password for your administrator account for the SKA RefTest app.\n\n
If you did not make this request, please ignore this email.\n\n
If you did make this request, then you have two options to recover your account.\n\n
Your password has been reset to the following:\n\n
{new_password}\n\n
You may use this to log back in to your account, and subsequently change your password to something more suitable.\n\n
Alternatively, you may visit the following private link using your unique token to override your password. Copy and paste the following link in a web browser. Please note that this token is only valid once:\n\n
{url_for('admin._reset', token = self.reset_token, verification = self.verification_token, _external = True)}\n\n
Hopefully, this should enable access to your account once again.\n\n
Have a nice day!\n\n
SKA Refereeing
""",
html=f"""
<p>Hello {self.get_username()},</p>
<p>This email was generated because we received a request to reset the password for your administrator account for the SKA RefTest app.</p>
<p>If you did not make this request, please ignore this email.</p>
<p>If you did make this request, then you have two options to recover your account.</p>
<p>Your password has been reset to the following:</p>
<strong>{new_password}</strong>
<p>You may use this to log back in to your account, and subsequently change your password to something more suitable.</p>
<p>Alternatively, you may visit the following private link using your unique token to override your password. Copy and paste the following link in a web browser. Please note that this token is only valid once:</p>
<p><a href='{url_for('admin._reset', token = self.reset_token, verification = self.verification_token, _external = True)}'>{url_for('admin._reset', token = self.reset_token, verification = self.verification_token, _external = True)}</a></p>
<p>Hopefully, this should enable access to your account once again.</p>
<p>Have a nice day!</p>
<p>SKA Refereeing</p>
"""
)
mail.send(email)
print('Password', new_password)
print('Reset Token', self.reset_token)
print('Verification Token', self.verification_token)
print('Reset Link', f'{url_for("admin._reset", token=self.reset_token, verification=self.verification_token, _external=True)}')
return jsonify({'success': 'Your password reset link has been generated.'}), 200
def clear_reset_tokens(self):
self.reset_token = self.verification_token = None
db.session.commit()
def delete(self, notify:bool=False):
username = self.get_username()
email_address = self.get_email()
db.session.delete(self)
db.session.commit()
message = f'User \'{username}\' was deleted by \'{current_user.get_username()}\'.'
write('users.log', message)
if notify:
email = Message(
subject='RefTest | Account Deletion',
recipients=[email_address],
bcc=[current_user.get_email()],
body=f"""
Hello {username},\n\n
Your administrator account for the SKA RefTest App, as well as all data associated with the account, have been deleted by {current_user.get_username()}.\n\n
If you believe this was done in error, please contact them immediately.\n\n
If you would like to register to administer the app, please ask an existing administrator to create a new account.\n\n
Have a nice day!\n\n
SKA Refereeing
""",
html=f"""
<p>Hello {username},</p>
<p>Your administrator account for the SKA RefTest App, as well as all data associated with the account, have been deleted by {current_user.get_username()}.</p>
<p>If you believe this was done in error, please contact them immediately.</p>
<p>If you would like to register to administer the app, please ask an existing administrator to create a new account.</p>
<p>Have a nice day!</p>
<p>SKA Refereeing</p>
"""
)
mail.send(email)
return True, message
def update(self, password:str=None, email:str=None, notify:bool=False):
if not password and not email: return False, 'There were no changes requested.'
if password: self.set_password(password)
old_email = self.get_email()
if email: self.set_email(email)
db.session.commit()
write('system.log', f'Information for user {self.get_username()} has been updated by {current_user.get_username()}.')
if notify:
message = Message(
subject='RefTest | Account Update',
recipients=[email],
bcc=[old_email,current_user.get_email()],
body=f"""
Hello {self.get_username()},\n\n
Your administrator account for the SKA RefTest App has been updated by {current_user.get_username()}.\n\n
Your new account details are as follows:\n\n
Email: {email}\n
Password: {password if password else '<same as old>'}\n\n
You can update your email address and password by logging in to the admin console using the following URL:\n\n
{url_for('admin._home', _external=True)}\n\n
Have a nice day!\n\n
SKA Refereeing
""",
html=f"""
<p>Hello {self.get_username()},</p>
<p>Your administrator account for the SKA RefTest App has been updated by {current_user.get_username()}.</p>
<p>Your new account details are as follows:</p>
<p>Email: {email} <br/> Password: <strong>{password if password else '&lt;same as old&gt;'}</strong></p>
<p>You can update your email address and password by logging in to the admin console using the following URL:</p>
<p><a href='{url_for('admin._home', _external=True)}'>{url_for('admin._home', _external=True)}</a></p>
<p>Have a nice day!</p>
<p>SKA Refereeing</p>
"""
)
mail.send(message)
return True, f'Account {self.get_username()} has been updated.'

10
ref-test/app/modules.py Normal file
View File

@ -0,0 +1,10 @@
from flask_bootstrap import Bootstrap
bootstrap = Bootstrap()
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect()
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
from flask_login import LoginManager
login_manager = LoginManager()
from flask_mail import Mail
mail = Mail()

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -143,7 +143,7 @@ $("#btn-start-quiz").click(function(event){
$.ajax({
url: `/api/questions/`,
type: 'POST',
data: JSON.stringify({'_id': _id}),
data: JSON.stringify({'id': id}),
contentType: "application/json",
success: function(response) {
$(this).fadeOut();
@ -223,7 +223,7 @@ $("#q-review-answers").click(function(event){
$(".quiz-button-submit").click(function(event){
let submission = {
'_id': _id,
'id': id,
'answers': answers
}
@ -607,7 +607,7 @@ function count_questions(status) {
// Variable Definitions
const _id = window.localStorage.getItem('_id');
const id = window.localStorage.getItem('id');
var current_question = 0;
var total_questions = 0;

View File

@ -23,9 +23,9 @@ $('form[name=form-quiz-start]').submit(function(event) {
data: data,
dataType: 'json',
success: function(response) {
var _id = response._id
window.localStorage.setItem('_id', _id);
window.location.href = `/test/`;
var id = response.id
window.localStorage.setItem('id', id);
window.location.href = `/quiz/`;
},
error: function(response) {
error_response(response);
@ -68,7 +68,7 @@ $('#dismiss-cookie-alert').click(function(event){
$.ajax({
url: '/cookies/',
type: 'GET',
type: 'POST',
data: {
time: Date.now()
},

View File

@ -13,7 +13,7 @@
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>
<div class="button-container">
<a href="{{ url_for('quiz_views.instructions') }}" class="btn btn-success">
<a href="{{ url_for('quiz._instructions') }}" class="btn btn-success">
<i class="bi bi-book-fill button-icon"></i>
Read the Instructions
</a>

View File

@ -53,7 +53,7 @@
</p>
</div>
<div class="button-container">
<a href="{{ url_for('quiz_views.start') }}" class="btn btn-success">
<a href="{{ url_for('quiz._start') }}" class="btn btn-success">
<i class="bi bi-pencil-fill button-icon"></i>
Take the Exam
</a>

View File

@ -6,13 +6,13 @@
<h2>Candidate Results</h2>
<h3 class="results-name">
<span class="surname">{{ entry.name.surname }}</span>, {{ entry.name.first_name }}
<span class="surname">{{ entry.get_surname() }}</span>, {{ entry.get_first_name() }}
</h3>
<strong class="results-details">Email Address</strong>: {{ entry.email }} <br />
<strong class="results-details">Email Address</strong>: {{ entry.get_email() }} <br />
{% if entry.club %}
<strong class="results-details">Club</strong>: {{ entry.club }} <br />
<strong class="results-details">Club</strong>: {{ entry.get_club() }} <br />
{% endif%}
{% if entry.status == 'late' %}
@ -26,10 +26,10 @@
</div>
<div class="results-grade">
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:] }}
{{ entry.result.grade[0]|upper }}{{ entry.result.grade[1:] }}
</div>
{% if entry.results.grade == 'fail' %}
{% if entry.result.grade == 'fail' %}
Unfortunately, you have not passed the theory exam in this attempt. For your next attempt, it might help to brush up on the following topics:
<ul>

View File

@ -0,0 +1,80 @@
from ..forms.quiz import StartQuiz
from ..models import Entry, Test
from ..tools.test import redirect_if_started
from flask import abort, Blueprint, jsonify, redirect, render_template, request, session
from flask.helpers import flash, url_for
from datetime import datetime
quiz = Blueprint(
name='quiz',
import_name=__name__,
template_folder='templates',
static_folder='static',
static_url_path='/quiz/static'
)
@quiz.route('/')
@quiz.route('/home/')
@redirect_if_started
def _home():
return render_template('/quiz/index.html')
@quiz.route('/instructions/')
def _instructions():
return render_template('/quiz/instructions.html')
@quiz.route('/start/', methods=['GET', 'POST'])
def _start():
form = StartQuiz()
if request.method == 'POST':
if form.validate_on_submit():
entry = Entry()
entry.set_first_name(request.form.get('first_name'))
entry.set_surname(request.form.get('surname'))
entry.set_club(request.form.get('club'))
entry.set_email(request.form.get('email'))
code = request.form.get('test_code').replace('', '').lower()
test = Test.query.filter_by(code=code).first()
entry.test = test
entry.user_code = request.form.get('user_code')
entry.user_code = None if entry.user_code == '' else entry.user_code.lower()
if not test: return jsonify({'error': 'The exam code you entered is invalid.'}), 400
if entry.user_code and entry.user_code not in test.adjustments: return jsonify({'error': f'The user code you entered is not valid.'}), 400
if test.end_date < datetime.now(): return jsonify({'error': f'The exam code you entered expired on {test["expiry_date"].strftime("%d %b %Y %H:%M")}.'}), 400
if test.start_date > datetime.now(): 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")}.'}), 400
success, message = entry.ready()
if success:
session['id'] = entry.id
return jsonify({
'success': 'Received and validated test and/or user code. Redirecting to test client.',
'id': entry.id
}), 200
return jsonify({'error': 'There was an error processing the user test and/or user codes.'}), 400
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 render_template('/quiz/start_quiz.html', form = form)
@quiz.route('/quiz/')
def _quiz():
id = session.get('id')
if not id or not Entry.query.filter_by(id=id).first():
flash('Your session was not recognised. Please sign in to the quiz again.', 'error')
session.pop('id', None)
return redirect(url_for('quiz._start'))
return render_template('/quiz/client.html')
@quiz.route('/result/')
def _result():
id = session.get('id')
entry = Entry.query.filter_by(id=id).first()
if not entry: return abort(404)
session.pop('id',None)
score = round(100*entry.result['score']/entry.result['max'])
tags_low = { tag: tag_result['max'] - tag_result['scored'] for tag, tag_result in entry.result['tags'].items() }
sorted_low_tags = sorted(tags_low.items(), key=lambda x: x[1], reverse=True)
tag_output = [ tag[0] for tag in sorted_low_tags[0:3] if tag[1] > 3]
if not entry.status == 'late':
entry.notify_result()
return render_template('/quiz/result.html', entry=entry, score=score, tag_output=tag_output)

View File

@ -0,0 +1,218 @@
body {
padding: 80px 0;
line-height: 1.5;
font-size: 14pt;
}
#cookie-alert {
padding-right: 16px;
}
#dismiss-cookie-alert {
margin-top: 16px;
width: fit-content;
}
.button-container {
margin: 2rem auto;
width: fit-content;
}
.instruction-container {
margin: 2rem auto;
}
.site-footer {
background-color: lightgray;
font-size: small;
}
.site-footer p {
margin: 0;
}
.quiz-container {
max-width: 720px;
}
.form-container {
display: -ms-flexbox;
display: flex;
-ms-flex-align: center;
align-items: center;
padding-top: 40px;
padding-bottom: 40px;
}
.form-quiz-start {
width: 100%;
max-width: 420px;
padding: 15px;
margin: auto;
}
.form-heading {
margin-bottom: 2rem;
}
.form-label-group {
position: relative;
margin-bottom: 2rem;
}
.form-label-group input,
.form-label-group label {
padding: var(--input-padding-y) var(--input-padding-x);
font-size: 16pt;
}
.form-label-group label {
position: absolute;
top: 0;
left: 0;
display: block;
width: 100%;
margin-bottom: 0; /* Override default `<label>` margin */
line-height: 1.5;
color: #495057;
cursor: text; /* Match the input under the label */
border: 1px solid transparent;
border-radius: .25rem;
transition: all .1s ease-in-out;
z-index: -1;
}
.form-label-group input {
background-color: transparent;
border: none;
border-radius: 0%;
border-bottom: 2px solid #585858;
}
.form-label-group input:active, .form-label-group input:focus {
background-color: transparent;
}
.form-label-group input::-webkit-input-placeholder {
color: transparent;
}
.form-label-group input:-ms-input-placeholder {
color: transparent;
}
.form-label-group input::-ms-input-placeholder {
color: transparent;
}
.form-label-group input::-moz-placeholder {
color: transparent;
}
.form-label-group input::placeholder {
color: transparent;
}
.form-label-group input:not(:placeholder-shown) {
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
padding-bottom: calc(var(--input-padding-y) / 3);
}
.form-label-group input:not(:placeholder-shown) ~ label {
padding-top: calc(var(--input-padding-y) / 3);
padding-bottom: calc(var(--input-padding-y) / 3);
font-size: 12px;
color: #777;
}
.form-check-margin {
margin-bottom: 2rem;
}
.checkbox input {
transform: scale(1.5);
margin-right: 1rem;
}
.signin-forgot-password {
font-size: 14pt;
}
.form-submission-button {
margin-bottom: 2rem;
}
.form-submission-button button, .form-submission-button a {
margin: 1rem;
vertical-align: middle;
}
.form-submission-button button span, .form-submission-button button svg, .form-submission-button a span, .form-submission-button a svg {
margin: 0 2px;
}
.results-name {
margin: 3rem auto;
}
.results-name .surname {
font-variant: small-caps;
font-size: 24pt;
}
.results-score {
margin: 2rem auto;
width: fit-content;
font-size: 36pt;
}
.results-score::after {
content: '%';
}
.results-grade {
margin: 2rem auto;
width: fit-content;
font-size: 26pt;
}
.button-icon {
font-size: 20px;
margin-right: 2px;
}
/* Change Autocomplete styles in Chrome*/
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
transition: background-color 5000s ease-in-out 0s;
}
/* Fallback for Edge
-------------------------------------------------- */
@supports (-ms-ime-align: auto) {
.form-label-group label {
display: none;
}
.form-label-group input::-ms-input-placeholder {
color: #777;
}
}
/* Fallback for IE
-------------------------------------------------- */
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
.form-label-group label {
display: none;
}
.form-label-group input:-ms-input-placeholder {
color: #777;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

View File

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

View File

@ -1,4 +1,4 @@
{% extends "quiz/components/base.html" %}
{% extends "components/base.html" %}
{% block content %}
<h1>Page Not Found</h1>

View File

@ -0,0 +1,78 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
crossorigin="anonymous">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
<link
rel="stylesheet"
href="{{ url_for('.static', filename='css/style.css') }}"
/>
{% block style %}
{% endblock %}
<title>{% block title %} SKA Referee Test Beta {% endblock %}</title>
{% include "components/og-meta.html" %}
</head>
<body class="bg-light">
{% block navbar %}
{% include "components/navbar.html" %}
{% endblock %}
<div class="container quiz-container">
{% block top_alerts %}
{% include "components/server-alerts.html" %}
{% endblock %}
{% block content %}{% endblock %}
<footer class="container site-footer">
{% include "components/footer.html" %}
</footer>
</div>
<!-- JQuery, Popper, and Bootstrap js dependencies -->
<script
src="https://code.jquery.com/jquery-3.6.0.min.js"
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
crossorigin="anonymous">
</script>
<script>
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
</script>
<script
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
crossorigin="anonymous">
</script>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
crossorigin="anonymous"
></script>
<!-- Custom js -->
<script type="text/javascript">
var csrf_token = "{{ csrf_token() }}";
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
xhr.setRequestHeader("X-CSRFToken", csrf_token);
}
}
});
</script>
<script
type="text/javascript"
src="{{ url_for('.static', filename='js/script.js') }}"
></script>
{% block script %}
{% endblock %}
</body>
</html>

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

@ -0,0 +1,14 @@
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark" id="primary-nav">
<div class="container">
<p class="navbar-brand mb-0 h1">SKA Refereeing Test (Beta)</p>
<div class="quiz-console w-100" style="display: none;" id="q-topbar">
<div class="d-flex justify-content align-middle">
<div class="container d-flex justify-content-center">
<span class="text-light q-timer" id="q-timer-widget" style="display: none;"><i class="bi bi-stopwatch-fill"></i>&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>
</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" />

Some files were not shown because too many files have changed in this diff Show More