1197 Commits

Author SHA1 Message Date
06db47c597 Push production version to Master 2022-06-22 02:01:34 +01:00
c04c824585 Editor client javascript and css 2022-06-22 01:58:36 +01:00
8eb7fb6869 Cleaned unnecessary import 2022-06-22 01:58:17 +01:00
db88b84ecb Added editor home page form 2022-06-22 01:57:58 +01:00
13c587b7da Added Editor api views 2022-06-22 01:57:45 +01:00
2b2a6ddd25 Updated json structure validation
Only works with the data list
File parsed in the View layer
2022-06-22 01:57:03 +01:00
26a6b45d75 Added dataset name support 2022-06-22 01:56:13 +01:00
c6c62fc34c Changed the layer at which json files are parsed
Updated dataset database model
Updated create and edit function to use data list instead of file
2022-06-22 01:55:55 +01:00
6bbdb8fced Corrected timestamping 2022-06-22 01:54:53 +01:00
c633a474b5 Updated dataset edit button handlers 2022-06-22 01:54:40 +01:00
5af99d85b5 Added editor link to navbar 2022-06-22 01:54:00 +01:00
1e7124262e Added support for dataset names 2022-06-22 01:53:46 +01:00
2f509af1de Added redirect on login to previous page 2022-06-22 01:53:06 +01:00
3c8c1b5c16 Finished making editor console 2022-06-22 01:52:40 +01:00
3988559920 Cleaned up unused file 2022-06-22 01:47:07 +01:00
8988fee55d Merge branch 'editor' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test into editor 2022-06-21 02:50:09 +01:00
86d1522ca1 Rectified editor script 2022-06-21 02:47:09 +01:00
ed53b771ef Finished designing the editor console 2022-06-21 02:44:23 +01:00
bc3b811fc9 Finished designing the editor console 2022-06-21 02:44:23 +01:00
f314566591 Merge branch 'master' into editor 2022-06-20 13:55:39 +01:00
4b6dbd4441 Merge branch 'master' into editor 2022-06-20 13:55:39 +01:00
1ef34465c2 Debug install script 2022-06-20 12:53:40 +01:00
8b0ea1fec3 Make config production ready 2022-06-20 12:28:31 +01:00
39acebb3a6 Make config production ready 2022-06-20 12:28:31 +01:00
d9962f18ed Production ready v.0.2.1 2022-06-20 12:28:00 +01:00
d8044a7c76 Make config production ready 2022-06-20 12:27:32 +01:00
3025e83b66 Editor styling 2022-06-20 12:22:29 +01:00
a02a58a8db Editor styling 2022-06-20 12:22:29 +01:00
de6910b4bf Merge branch 'master' into editor 2022-06-20 12:16:25 +01:00
7bb93afacb Merge branch 'master' into editor 2022-06-20 12:16:25 +01:00
2663d5e3b7 Tidied up unused imports 2022-06-20 12:15:28 +01:00
500beed4cc Merge branch 'master' into editor 2022-06-20 12:13:09 +01:00
d83999aa43 Merge branch 'master' into editor 2022-06-20 12:13:09 +01:00
6a09559b70 Database URI absolute path fix 2022-06-20 12:10:52 +01:00
26227a66c5 App Factory pattern 2022-06-20 12:10:37 +01:00
d6836915bb Prevent edit user from duplicating email address 2022-06-20 12:09:31 +01:00
49a7fb1007 More elegant error handling 2022-06-20 11:27:05 +01:00
90bc30757a Added local server for development 2022-06-20 11:26:44 +01:00
fac3839ea3 Merge branch 'master' into editor 2022-06-19 13:25:02 +01:00
d8d5e92453 Merge branch 'master' into editor 2022-06-19 13:25:02 +01:00
12207d1159 Changed modules to extensions 2022-06-19 13:22:24 +01:00
ac02f4dee1 Changed structure of referencing data 2022-06-19 13:22:05 +01:00
a050a1eccf Merge branch 'master' into editor 2022-06-19 11:21:22 +01:00
8d91dd1d30 Merge branch 'master' into editor 2022-06-19 11:21:22 +01:00
76fa1e1dd9 Removed todo tags 2022-06-19 11:17:21 +01:00
6d5f74bd62 Tidied up code 2022-06-19 11:17:00 +01:00
2e00d503c8 Added detailed data validation 2022-06-19 11:13:47 +01:00
43cc0a5652 Added detailed data validation 2022-06-19 10:48:17 +01:00
4ce6536e33 Added detailed data validation 2022-06-19 10:48:17 +01:00
1f60054d46 Edited base template to set up Editor scripts/css 2022-06-19 10:47:50 +01:00
33bc7993fa Edited base template to set up Editor scripts/css 2022-06-19 10:47:50 +01:00
418dfe7a70 Added templates and static files for editor 2022-06-18 09:53:36 +01:00
645f69440f Added templates and static files for editor 2022-06-18 09:53:36 +01:00
e1e279e939 Base editor template 2022-06-18 09:43:07 +01:00
c197f6cb76 Base editor template 2022-06-18 09:43:07 +01:00
7fe1afb348 Create editor files 2022-06-18 09:39:31 +01:00
bed186f6b5 Create editor files 2022-06-18 09:39:31 +01:00
516c2cdf81 Buxfix: static folders bypass cookie consent 2022-06-18 09:26:05 +01:00
8f9b78ac32 Merge branch 'editor' 2022-06-18 02:18:45 +01:00
17b985d238 Bugfix: 404 errors with request.endpoint
Fixed static folder 404 errors
2022-06-18 02:18:07 +01:00
69a0791a6d Bug fixes to main branch 2022-06-18 02:11:29 +01:00
4414d1720e Typo 2022-06-17 13:16:40 +01:00
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
f170ff5e52 Whitespace corrections 2022-04-17 18:42:40 +01:00
cfd750894a Whitespace corrections 2022-04-17 18:42:40 +01:00
3bd16ae563 Bugfix change event not triggering 2021-12-08 13:25:50 +00:00
ede71f7d82 Bugfix change event not triggering 2021-12-08 13:25:50 +00:00
2f6de34051 Added click area to select background colour 2021-12-08 13:21:18 +00:00
27706572ed Added click area to select background colour 2021-12-08 13:21:18 +00:00
b9c4edeb48 update function call from attr to prop 2021-12-08 13:20:40 +00:00
08da6d71c4 update function call from attr to prop 2021-12-08 13:20:40 +00:00
587415c5db Local jQuery library fallback 2021-12-08 13:20:07 +00:00
c5a0bbb827 Local jQuery library fallback 2021-12-08 13:20:07 +00:00
c2d7dc7fe2 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
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
0059ec5270 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-08 12:46:49 +00:00
ff74e92297 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-08 12:46:49 +00:00
ce07bdf8b2 Only show topics to revise if failed 2021-12-08 12:46:33 +00:00
6b3b255cfd Only show topics to revise if failed 2021-12-08 12:46:33 +00:00
7db0d055e5 Merge 2021-12-08 11:33:27 +00:00
ecdb5df561 Merge 2021-12-08 11:33:27 +00:00
7c70da4b5c Removed personal information 2021-12-08 11:29:22 +00:00
c5b4d948f5 Removed personal information 2021-12-08 11:29:22 +00:00
b3791dfcf8 Removed personal information 2021-12-08 11:27:54 +00:00
c40ef7d070 Removed personal information 2021-12-08 11:27:54 +00:00
cd5fd686e9 Cookie bugfix, removed 'session' string from expiry/age 2021-12-08 11:26:18 +00:00
b8081bc1c8 Cookie bugfix, removed 'session' string from expiry/age 2021-12-08 11:26:18 +00:00
6e1f7c6df1 Debug form error handlers 2021-12-07 16:17:59 +00:00
efec599225 Debug form error handlers 2021-12-07 16:17:59 +00:00
de0b24b042 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
ab4496f06d Named image 2021-12-07 16:03:56 +00:00
6605620d9c Named image 2021-12-07 16:03:56 +00:00
150224c1d5 Named image 2021-12-07 16:03:56 +00:00
cd4d52692c Named image 2021-12-07 16:03:56 +00:00
a836e0c9e3 Bug fix and data persistence 2021-12-07 15:52:58 +00:00
2038965dcb Bug fix and data persistence 2021-12-07 15:52:58 +00:00
88a41de647 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
fc099dbbf7 Bugfix: security key location 2021-12-07 15:25:22 +00:00
f144097c5d Bugfix: security key location 2021-12-07 15:25:22 +00:00
a84c1037a2 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
deab85289b Bugfix: encryption lockout 2021-12-07 15:15:16 +00:00
57ee0bf971 Bugfix: encryption lockout 2021-12-07 15:15:16 +00:00
866267dc5f Bugfix: encryption lockout 2021-12-07 15:15:16 +00:00
735cdec139 Bugfix: encryption lockout 2021-12-07 15:15:16 +00:00
d76c8a5fed Bug fix: evaluating question 97 2021-12-07 15:03:21 +00:00
8591184da6 Bug fix: evaluating question 97 2021-12-07 15:03:21 +00:00
b87f99e138 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
ecf18a70a8 Merge 2021-12-07 13:37:12 +00:00
7b5861ade6 Merge 2021-12-07 13:37:12 +00:00
ce5be3a53e Merge 2021-12-07 13:37:12 +00:00
f0437dceaa Merge 2021-12-07 13:37:12 +00:00
7206ca6203 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 13:34:02 +00:00
fa4640840b Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 13:34:02 +00:00
84cb483ee0 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
b612b39e73 Typo 2021-12-07 13:33:31 +00:00
05a564f41d Typo 2021-12-07 13:33:31 +00:00
0b12b43621 Typo 2021-12-07 13:33:31 +00:00
7b2f155b14 Typo 2021-12-07 13:33:31 +00:00
9462d7aa7f Some more minor fixes to containerising and ignore 2021-12-07 13:29:25 +00:00
f9628df8c7 Some more minor fixes to containerising and ignore 2021-12-07 13:29:25 +00:00
0cbdfba45f 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
1d91e6d6ee Re-wrote compose and conf removing personal info 2021-12-07 13:26:24 +00:00
b5443c1331 Re-wrote compose and conf removing personal info 2021-12-07 13:26:24 +00:00
96c42f1ee1 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
ec52ebffa5 Finesse log in form css 2021-12-07 12:39:29 +00:00
3d7e144d12 Finesse log in form css 2021-12-07 12:39:29 +00:00
9da654f235 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
d9f967811f 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
355c049937 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
7d287874cd Finessed remaining block question counter 2021-12-07 12:27:06 +00:00
57f233f20f Finessed remaining block question counter 2021-12-07 12:27:06 +00:00
55a11d80d2 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
6fb8b62e5b 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
0453de9f62 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
78e4afdc71 Correcting an error 2021-12-07 07:24:39 +00:00
f2fb52aeca Correcting an error 2021-12-07 07:24:39 +00:00
eaf8197ae6 Correcting an error 2021-12-07 07:24:39 +00:00
52afd249b7 Correcting an error 2021-12-07 07:24:39 +00:00
79073f3d92 Correcting an error 2021-12-07 07:24:39 +00:00
4a8080f0c8 Correcting an error 2021-12-07 07:24:39 +00:00
99a4539559 Correcting an error 2021-12-07 07:24:39 +00:00
443568f8ff Correcting an error 2021-12-07 07:24:39 +00:00
11e740ea44 Finesse cookie dismiss button 2021-12-07 07:22:11 +00:00
5ab2e7e608 Finesse cookie dismiss button 2021-12-07 07:22:11 +00:00
cbf66d8768 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
b9a83a436e 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
0e28e3dc48 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
cc995925bb Finesse cookie consent display 2021-12-07 07:09:28 +00:00
4585b93136 Finesse cookie consent display 2021-12-07 07:09:28 +00:00
f15b7da648 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
706ba8409e 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
a5f1177d8f 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
757425494f removed fake link 2021-12-07 07:04:58 +00:00
f3f8ac955c removed fake link 2021-12-07 07:04:58 +00:00
b2e708cde0 removed fake link 2021-12-07 07:04:58 +00:00
8bfc8e119c removed fake link 2021-12-07 07:04:58 +00:00
103093c886 removed fake link 2021-12-07 07:04:58 +00:00
0ccb62ce3c removed fake link 2021-12-07 07:04:58 +00:00
440a5aad09 removed fake link 2021-12-07 07:04:58 +00:00
2507a1d00b removed fake link 2021-12-07 07:04:58 +00:00
6d887f1bfd Fixed subject-verb disagreement 2021-12-07 07:00:44 +00:00
fed4b6739f Fixed subject-verb disagreement 2021-12-07 07:00:44 +00:00
3fc9d4c3c1 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
89e8267a29 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
9c8c0d85a5 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
877b191a38 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 06:55:50 +00:00
485e51f239 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 06:55:50 +00:00
f74df18af1 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
c85fa69713 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
ff6468cce3 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
30175355a6 Technical issues help email 2021-12-07 06:46:34 +00:00
b7ef513870 Technical issues help email 2021-12-07 06:46:34 +00:00
af07a152ad 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
9bdff0d729 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
7fdc4bc26c 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
ab3312d45c 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
f867022207 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
e311c66abc 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
0d8450f667 Removed email address 2021-12-07 06:40:57 +00:00
f8126b42fe Removed email address 2021-12-07 06:40:57 +00:00
b151de39df Removed email address 2021-12-07 06:40:57 +00:00
407ee49bff Removed email address 2021-12-07 06:40:57 +00:00
e7a82d2437 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
46aeee416a 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
80391229a3 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
07395833e8 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
a33ed7a94d Started drafting documentation 2021-12-07 06:38:43 +00:00
b0980b1871 Started drafting documentation 2021-12-07 06:38:43 +00:00
23ddf6601b Started drafting documentation 2021-12-07 06:38:43 +00:00
ea9132542f Started drafting documentation 2021-12-07 06:38:43 +00:00
19d12226f5 Started drafting documentation 2021-12-07 06:38:43 +00:00
b7fb30ce36 Started drafting documentation 2021-12-07 06:38:43 +00:00
1873772167 Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
fe75fa1a49 Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
8726d4335c 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
a5f8cba71b 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
e1b2bd20f7 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
3b3656209a 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
58f40e221f 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
7e9b5eada0 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
183aeac9ee Updated test expiry 2021-12-06 23:37:16 +00:00
a4b4bfe0ee Updated test expiry 2021-12-06 23:37:16 +00:00
c915ae1182 Updated test expiry 2021-12-06 23:37:16 +00:00
0faef8651a Updated test expiry 2021-12-06 23:37:16 +00:00
973bafcdb2 Updated test expiry 2021-12-06 23:37:16 +00:00
4f925eae2f Updated test expiry 2021-12-06 23:37:16 +00:00
b821d40e85 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
fba539f933 Updated test expiry 2021-12-06 23:37:16 +00:00
eca786d444 Updated test expiry 2021-12-06 23:37:16 +00:00
220a378c63 Updated test expiry 2021-12-06 23:37:16 +00:00
affb309ffc Updated test expiry 2021-12-06 23:37:16 +00:00
b013ffec47 Updated test expiry 2021-12-06 23:37:16 +00:00
0e1db9d21d Updated test expiry 2021-12-06 23:37:16 +00:00
e1a90d5f64 Corrected bug in exam display 2021-12-06 23:24:57 +00:00
003d998b72 Corrected bug in exam display 2021-12-06 23:24:57 +00:00
b84654d931 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
d3f116e1ca 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
9bd1f29aad 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
991313e8b2 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
e2a0bc7b4e 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
9a4c2962a5 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
7b2a2ce90c I am bad at debugging. 2021-12-06 23:19:13 +00:00
dd72da6ae6 I am bad at debugging. 2021-12-06 23:19:13 +00:00
54121a4fda 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
0f1f79e237 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
d5f7ab0488 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
cc7712ccec 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
ed6360e7a3 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
3b92dc3005 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
cc45bf7acd Close Quiz function 2021-12-06 23:16:33 +00:00
667ad4ebc2 Close Quiz function 2021-12-06 23:16:33 +00:00
4c3805cfe7 Close Quiz function 2021-12-06 23:16:33 +00:00
52e3ce4c93 Close Quiz function 2021-12-06 23:16:33 +00:00
045f3aec0a Close Quiz function 2021-12-06 23:16:33 +00:00
ca0e6c82cb Close Quiz function 2021-12-06 23:16:33 +00:00
1f767da365 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
f069556afd Close Quiz function 2021-12-06 23:16:33 +00:00
421445d8d5 Close Quiz function 2021-12-06 23:16:33 +00:00
6d931bdf6c Close Quiz function 2021-12-06 23:16:33 +00:00
b0d3ff3fc1 Close Quiz function 2021-12-06 23:16:33 +00:00
0cb88390b8 Close Quiz function 2021-12-06 23:16:33 +00:00
68aef968e2 Close Quiz function 2021-12-06 23:16:33 +00:00
af9801ac24 Remove redundant file 2021-12-06 22:54:40 +00:00
8d29944d5d Remove redundant file 2021-12-06 22:54:40 +00:00
33314e2bc4 Remove redundant file 2021-12-06 22:54:40 +00:00
8fbb52d366 Remove redundant file 2021-12-06 22:54:40 +00:00
8749c6e590 Remove redundant file 2021-12-06 22:54:40 +00:00
1dbe4215ec Remove redundant file 2021-12-06 22:54:40 +00:00
c10c31c4b6 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
167bee38d6 Remove redundant file 2021-12-06 22:54:40 +00:00
cefb5fe849 Remove redundant file 2021-12-06 22:54:40 +00:00
bac083411c Remove redundant file 2021-12-06 22:54:40 +00:00
f0c7873257 Remove redundant file 2021-12-06 22:54:40 +00:00
6dcef4885a Remove redundant file 2021-12-06 22:54:40 +00:00
0cb8ff9991 Remove redundant file 2021-12-06 22:54:40 +00:00
536e1fe426 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
4d77021d58 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
5ab8be93ec 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
920287b7ae 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
f44fbb24da 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
8328f6fb1a 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
e9a5e72959 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
12480b32a5 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
96cca77b2f This fixes it, hopefully 2021-12-06 22:47:54 +00:00
e6841b7744 This fixes it, hopefully 2021-12-06 22:47:54 +00:00
28f944a6cf 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
00fb8e13fe 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
1b2b4f8dc6 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
2d29e6ac97 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
c462b76cb7 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
c2d95c1d52 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
673ccbcb9c And again 2021-12-06 22:26:48 +00:00
378e8eeae3 And again 2021-12-06 22:26:48 +00:00
28789c72f1 And again 2021-12-06 22:26:48 +00:00
fe898aaf7d And again 2021-12-06 22:26:48 +00:00
57e5a21ffa And again 2021-12-06 22:26:48 +00:00
a010d7d290 And again 2021-12-06 22:26:48 +00:00
13121b3037 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
b299f4ae55 And again 2021-12-06 22:26:48 +00:00
a14b7bf305 And again 2021-12-06 22:26:48 +00:00
e935524552 And again 2021-12-06 22:26:48 +00:00
3622baf988 And again 2021-12-06 22:26:48 +00:00
b8a7182f98 And again 2021-12-06 22:26:48 +00:00
24545feea0 And again 2021-12-06 22:26:48 +00:00
21641ce21f Trying to fix it again 2021-12-06 22:24:34 +00:00
bb9233eeae Trying to fix it again 2021-12-06 22:24:34 +00:00
3035ac6687 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
8d2a84a071 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
a77faa7eed 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
b4e7efdbe5 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
f61c12afd1 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
8a0e93e3e5 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
7874d3f99f I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
383ae11cd3 I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
37d6d3a003 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
1b2209d97c 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
2e340cce00 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
9cef9819fe 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
9e4e874401 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
47f996b19c 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
d0232557bc More Bug Fixes 2021-12-06 22:17:52 +00:00
a357ffe28d More Bug Fixes 2021-12-06 22:17:52 +00:00
cdc19e69b8 More Bug Fixes 2021-12-06 22:17:52 +00:00
e00e2b17b0 More Bug Fixes 2021-12-06 22:17:52 +00:00
a2e05d39e6 More Bug Fixes 2021-12-06 22:17:52 +00:00
65d679afbb More Bug Fixes 2021-12-06 22:17:52 +00:00
72a068c975 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
cfc62ee21f More Bug Fixes 2021-12-06 22:17:52 +00:00
efd4dc440d More Bug Fixes 2021-12-06 22:17:52 +00:00
7150e679c8 More Bug Fixes 2021-12-06 22:17:52 +00:00
935b465a19 More Bug Fixes 2021-12-06 22:17:52 +00:00
bce76d808d More Bug Fixes 2021-12-06 22:17:52 +00:00
05fa5bf274 More Bug Fixes 2021-12-06 22:17:52 +00:00
20f580d6a6 Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
1d1e2acf62 Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
700daa51ef 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
cdd35117bf 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
abcd4cbec5 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
715c7856fa 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
19521c3f23 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
d7ef628640 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
198e2cecb0 OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
4c2a6e7f74 OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
4ae587da12 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
fa43ab1879 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
a3101503d4 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
df94b9b486 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
9a5f073170 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
700c5ff39d 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
2641b3e060 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
45e0d37f81 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
21565592cc 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
d2cd8316da 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
c3fb5a9b0c 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
5954c1a68e 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
de969e0028 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
19d16ab7d9 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
e7077cd193 OG and Cookie settings 2021-12-06 21:51:29 +00:00
12c424be08 OG and Cookie settings 2021-12-06 21:51:29 +00:00
06aee6fa6d 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
2472323103 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
5d334e4da5 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
848f39aa66 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
5fad0cda1e 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
bfd8c8fa1e 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
fbe19e43f7 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
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
5a3aca732b 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
54653e82f2 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
fa290a2713 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
396400e7c0 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
cb39592bd3 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
5dbf3be732 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
372333cd84 Proxy Fix 2021-12-06 20:10:27 +00:00
835c5e2aa6 Proxy Fix 2021-12-06 20:10:27 +00:00
672888c5d9 Proxy Fix 2021-12-06 20:10:27 +00:00
6823c12b2d Proxy Fix 2021-12-06 20:10:27 +00:00
cda7ac480f Proxy Fix 2021-12-06 20:10:27 +00:00
c7907dc24d Proxy Fix 2021-12-06 20:10:27 +00:00
8926960a18 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
e499d1d54a Proxy Fix 2021-12-06 20:10:27 +00:00
dbd25ddf38 Proxy Fix 2021-12-06 20:10:27 +00:00
724ffbfdf4 Proxy Fix 2021-12-06 20:10:27 +00:00
11d839aada Proxy Fix 2021-12-06 20:10:27 +00:00
6d90ec4aa6 Proxy Fix 2021-12-06 20:10:27 +00:00
3980be3701 Proxy Fix 2021-12-06 20:10:27 +00:00
ca1a6efd57 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
43cb31849a Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
59e7d3d112 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
39cdafc847 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
727779f054 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
bdeb026a7c Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
b244fb34f7 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
73f4825bbe Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
e1ecb5bcb6 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
da5e115bbc Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
1651f63577 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
1edd25d3ea Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
a01d486d99 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
3c64240842 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
2b71c77c6c Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
cc466f4a20 Updated config 2021-12-06 19:21:45 +00:00
112c097d69 Updated config 2021-12-06 19:21:45 +00:00
79e015191a Updated config 2021-12-06 19:21:45 +00:00
b6af6d5c15 Updated config 2021-12-06 19:21:45 +00:00
529ac35bdb Updated config 2021-12-06 19:21:45 +00:00
6c4ca715f6 Updated config 2021-12-06 19:21:45 +00:00
8d6ca515dc 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
7550deab89 Updated config 2021-12-06 19:21:45 +00:00
a4058c475b Updated config 2021-12-06 19:21:45 +00:00
81b3e3a142 Updated config 2021-12-06 19:21:45 +00:00
0004d2714f Updated config 2021-12-06 19:21:45 +00:00
8527976007 Updated config 2021-12-06 19:21:45 +00:00
20efd4444c Updated config 2021-12-06 19:21:45 +00:00
039b58709e Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
13465859ab Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
0f3a84b54d Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
53050f1358 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
c8ccd002fa Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
f025eee4a6 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
6acf3fa204 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
506a6cf6c2 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
97db70abff Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
4d642cc1e9 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
1a1d763d67 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
fc765c0177 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
598dfa45e8 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
0d8200193d Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
ca36772f29 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
eb9ca82cb3 Favicons and OG Meta 2021-12-06 18:58:42 +00:00
bd3205f06e Favicons and OG Meta 2021-12-06 18:58:42 +00:00
fa9d08b10e 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
720de1f2de 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
bf64b78acc 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
5f06c9b624 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
7d90b6c7a2 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
861b61c1ca 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
d25dc5ed45 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
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
6c048a9c48 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
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
14bc50165b 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
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
4c40240346 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
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
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
ba9ed0ca40 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
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
bcc9e9c609 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
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
c6adac8288 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
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
50cff0245f Uploading Fonts 2021-12-06 18:06:11 +00:00
e70592b276 Uploading Fonts 2021-12-06 18:06:11 +00:00
b2a71bf51b Uploading Fonts 2021-12-06 18:06:11 +00:00
22a0d58996 Uploading Fonts 2021-12-06 18:06:11 +00:00
13b7249f2a Uploading Fonts 2021-12-06 18:06:11 +00:00
3d6a1dc7ba Uploading Fonts 2021-12-06 18:06:11 +00:00
bdffce21b7 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
7ae8cba851 Uploading Fonts 2021-12-06 18:06:11 +00:00
cdf47e0b88 Uploading Fonts 2021-12-06 18:06:11 +00:00
73c00ac333 Uploading Fonts 2021-12-06 18:06:11 +00:00
2427d55310 Uploading Fonts 2021-12-06 18:06:11 +00:00
b0f2d89956 Uploading Fonts 2021-12-06 18:06:11 +00:00
757cc94f33 Uploading Fonts 2021-12-06 18:06:11 +00:00
7f7a783c8a Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
0cfac25ed3 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
33e8d8482f Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
0443e348ac Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
a7e3a5fe47 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
f2c0090aa3 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
4ff62d3b36 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
ae75498edb Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
7f3e251ac4 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
da2ac4c0ae Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
233e173735 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
36334ef186 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
c5686fbd40 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
40f3bb3b20 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
94556d0731 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
207f748c93 Correct error 2021-12-06 16:56:54 +00:00
ccab358464 Correct error 2021-12-06 16:56:54 +00:00
a0aaa6b035 Correct error 2021-12-06 16:56:54 +00:00
79b0e83eba Correct error 2021-12-06 16:56:54 +00:00
9b34fb8f73 Correct error 2021-12-06 16:56:54 +00:00
22e163f036 Correct error 2021-12-06 16:56:54 +00:00
c90a37a99b 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
2f611d43cc Correct error 2021-12-06 16:56:54 +00:00
ae1380407c Correct error 2021-12-06 16:56:54 +00:00
1a290e3bd6 Correct error 2021-12-06 16:56:54 +00:00
1e7222c781 Correct error 2021-12-06 16:56:54 +00:00
39af68cf36 Correct error 2021-12-06 16:56:54 +00:00
b65b71df7a Correct error 2021-12-06 16:56:54 +00:00
2c0dcd8661 Added correct answer view 2021-12-06 13:44:40 +00:00
9a4820c725 Added correct answer view 2021-12-06 13:44:40 +00:00
1cc5b9cf57 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
3d03fb79a7 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
f4dbc55f88 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
1015148d4d 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
6db6baab50 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
acfcca13b3 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
05ec62994e Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
79ca8fc932 Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
10a524b7b8 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
41a7129959 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
33f555f847 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
9bf39107fb 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
349dd030d6 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
03d419c3fc 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
e69c79df52 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
1cafa04763 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
ac21f571b4 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
8cb4435517 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
87c15070bd 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
9a7758f208 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
ce31c3e691 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
8ead32c34d 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
ea4edf71ba Nginx Server 2021-12-06 13:29:20 +00:00
20e418aeae Nginx Server 2021-12-06 13:29:20 +00:00
0fce697095 Nginx Server 2021-12-06 13:29:20 +00:00
9affa657c4 Nginx Server 2021-12-06 13:29:20 +00:00
1a20f1ec67 Nginx Server 2021-12-06 13:29:20 +00:00
395ddbd460 Nginx Server 2021-12-06 13:29:20 +00:00
0a1a9b007d 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
e6d22f2a89 Nginx Server 2021-12-06 13:29:20 +00:00
e694119a58 Nginx Server 2021-12-06 13:29:20 +00:00
f242413911 Nginx Server 2021-12-06 13:29:20 +00:00
67bbab0061 Nginx Server 2021-12-06 13:29:20 +00:00
41a9892538 Nginx Server 2021-12-06 13:29:20 +00:00
9992138bc4 Nginx Server 2021-12-06 13:29:20 +00:00
ca25159830 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
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
347378d785 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
db59e6c85c 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
281575bbf7 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
4b62ff6e80 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
79c23471ee 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
350c67ab10 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
e53d7ef230 dockerise 2021-12-05 00:17:54 +00:00
f4234f57b1 dockerise 2021-12-05 00:17:54 +00:00
e715d07bf1 dockerise 2021-12-05 00:17:54 +00:00
b8c652e78a dockerise 2021-12-05 00:17:54 +00:00
8d76ecb78a dockerise 2021-12-05 00:17:54 +00:00
9d760aafef dockerise 2021-12-05 00:17:54 +00:00
f9d16b3608 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
21f54c9789 dockerise 2021-12-05 00:17:54 +00:00
2aca8015af dockerise 2021-12-05 00:17:54 +00:00
c4f088f29c dockerise 2021-12-05 00:17:54 +00:00
89ae75050b dockerise 2021-12-05 00:17:54 +00:00
0c01bff022 dockerise 2021-12-05 00:17:54 +00:00
efa83d2bf8 dockerise 2021-12-05 00:17:54 +00:00
eb6395a793 Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
388d89d95d Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
3856d5fa84 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
0318ddbf21 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
78746b5e1a 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
aafde86012 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
31d7e978f4 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
3dd7739a16 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
dc934f10ee Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
3a39ff6fc3 Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
a0a33b81c4 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
486aeb86a2 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
21a7eeea21 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
9b8f0f3d8e 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
77267f944b 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
a3cdc42fab 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
c00a465410 Finished making dashboards 2021-12-04 20:47:43 +00:00
12d9cd39be Finished making dashboards 2021-12-04 20:47:43 +00:00
9fa515cf9b Finished making dashboards 2021-12-04 20:47:43 +00:00
0fd7ac7f1f Finished making dashboards 2021-12-04 20:47:43 +00:00
e313df57d6 Finished making dashboards 2021-12-04 20:47:43 +00:00
66d8fb7d93 Finished making dashboards 2021-12-04 20:47:43 +00:00
0ad4ae38fa 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
031d18e922 Finished making dashboards 2021-12-04 20:47:43 +00:00
4aad0c1213 Finished making dashboards 2021-12-04 20:47:43 +00:00
85efd755d8 Finished making dashboards 2021-12-04 20:47:43 +00:00
ef1cad1995 Finished making dashboards 2021-12-04 20:47:43 +00:00
292c642e73 Finished making dashboards 2021-12-04 20:47:43 +00:00
ab2ca04ceb Finished making dashboards 2021-12-04 20:47:43 +00:00
81a4d5dbda Added question progress bar 2021-12-04 18:50:09 +00:00
c88c142f7f Added question progress bar 2021-12-04 18:50:09 +00:00
85460b7192 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
ecaa4fa95f 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
6442c7f678 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
efb89e7626 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
fdb5cc1cf9 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
335c42f924 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
2799190b97 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
dbc0c782c0 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
383f303127 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
6331dda37b 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
eb812a9ebb 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
196c4774a2 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
727fc2d8c0 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
f0bfecaad3 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
2ba8980dd8 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
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
9ec9a5e80f 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
8b2daf400a 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
cf5ac8b221 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
eff7b25d71 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
93bf9e94fb 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
efb69efa10 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
c7185f24d4 Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
cfdb4db0c3 Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
c46facdf8b 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
f97b2c7cbb 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
c8c93dc721 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
be63eed81d 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
cca01a6c2f 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
6855ddfdcb 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
6b01841529 Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
5d9dba0e3d Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
0eda083bf2 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
56b3e6a2f5 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
49b0ea14f0 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
8f8a12b609 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
4b603b70a0 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
4902d40787 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
c7ca26202e Typo correction 2021-12-04 12:48:01 +00:00
c7ddf034a3 Typo correction 2021-12-04 12:48:01 +00:00
f47e22ccae Typo correction 2021-12-04 12:48:01 +00:00
e001ccfa01 Typo correction 2021-12-04 12:48:01 +00:00
121dd32bfb Typo correction 2021-12-04 12:48:01 +00:00
b6179430be Typo correction 2021-12-04 12:48:01 +00:00
4b671242ff 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
ba082d4ed7 Typo correction 2021-12-04 12:48:01 +00:00
7eddcabb7f Typo correction 2021-12-04 12:48:01 +00:00
d890a45f2b Typo correction 2021-12-04 12:48:01 +00:00
f66d62db37 Typo correction 2021-12-04 12:48:01 +00:00
b23d583bfb Typo correction 2021-12-04 12:48:01 +00:00
567b272161 Typo correction 2021-12-04 12:48:01 +00:00
048d06ca14 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
2f04671ec5 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
5f9b30cc01 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
9fac4ebd82 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
018be71ed3 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
72aa7696ac 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
237aabf4ba 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
37367cecc3 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
f086a6e32b Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
c00ffd3ed0 Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
a341974ebc 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
4d734dbbe8 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
6a4fe535e1 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
989d6900df 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
e69f60d813 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
da35da2b76 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
34e82de922 Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
a780b2330e Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
f068c6c937 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
a0fc1653e7 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
3f5d0feedb 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
1d778a6bdd 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
cf82a85070 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
4cfe7c2cba 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
7ba1f22ad7 Added result page. 2021-12-01 00:48:47 +00:00
c08e1c7010 Added result page. 2021-12-01 00:48:47 +00:00
e84fb91452 Added result page. 2021-12-01 00:48:47 +00:00
2479fd193b Added result page. 2021-12-01 00:48:47 +00:00
4da20115d2 Added result page. 2021-12-01 00:48:47 +00:00
a6ad184447 Added result page. 2021-12-01 00:48:47 +00:00
01d1a35238 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
0725b8b490 Added result page. 2021-12-01 00:48:47 +00:00
900929b875 Added result page. 2021-12-01 00:48:47 +00:00
7e65416f80 Added result page. 2021-12-01 00:48:47 +00:00
8cf9629bf1 Added result page. 2021-12-01 00:48:47 +00:00
ecc5780604 Added result page. 2021-12-01 00:48:47 +00:00
40926c1063 Added result page. 2021-12-01 00:48:47 +00:00
848aa88dac Finessing of client. 2021-12-01 00:48:38 +00:00
ba47f79d44 Finessing of client. 2021-12-01 00:48:38 +00:00
4d81172deb Finessing of client. 2021-12-01 00:48:38 +00:00
6f4353266c Finessing of client. 2021-12-01 00:48:38 +00:00
d1cf44fd18 Finessing of client. 2021-12-01 00:48:38 +00:00
abfa7b21ba Finessing of client. 2021-12-01 00:48:38 +00:00
23aee7abed 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
93552023f6 Finessing of client. 2021-12-01 00:48:38 +00:00
a67ea9951b Finessing of client. 2021-12-01 00:48:38 +00:00
61ac4c1cb0 Finessing of client. 2021-12-01 00:48:38 +00:00
756af0a064 Finessing of client. 2021-12-01 00:48:38 +00:00
3907ede872 Finessing of client. 2021-12-01 00:48:38 +00:00
7caf54a5ba Finessing of client. 2021-12-01 00:48:38 +00:00
bcafc8f545 Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
222b8e8a8b Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
29d015cdb4 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
19054a9c67 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
e2a9d79484 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
aab1a2815e 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
e326729ddb 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
ae4c418ed7 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
3d274c8189 Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
75db9fde3c Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
bbc2af5962 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
fb19b12e7f 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
516200d881 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
da251b57b0 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
8a2d81ec23 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
6ad73aa3c9 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
2ba5df152a Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
be26a19f2e Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
b9ea8dffa3 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
51f40311e0 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
038a4e44ba 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
7904c52671 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
779b06b4bf 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
2672f9e45f 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
c57461f118 Added automated email notification of results. 2021-12-01 00:46:21 +00:00
7269cec73d Added automated email notification of results. 2021-12-01 00:46:21 +00:00
26350a7eb6 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
f49b2f7df1 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
3c7b1a70ce 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
ab4290a706 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
a4a3b6de1b 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
2ca0929fe8 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
3d5939ed9c Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
e829514e91 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
6355afef59 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
636c1fdadb 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
7df7465012 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
7358c4440c 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
e7ca3ac0d7 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
99f1a8d681 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
4c0a1c8f3f Corrected doubled import 2021-12-01 00:45:20 +00:00
6593d372e0 Corrected doubled import 2021-12-01 00:45:20 +00:00
df503cf810 Corrected doubled import 2021-12-01 00:45:20 +00:00
cffafa82d9 Corrected doubled import 2021-12-01 00:45:20 +00:00
6495904cf1 Corrected doubled import 2021-12-01 00:45:20 +00:00
dc432c4ac9 Corrected doubled import 2021-12-01 00:45:20 +00:00
b6171637af 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
1d38d77c57 Corrected doubled import 2021-12-01 00:45:20 +00:00
a866699f5d Corrected doubled import 2021-12-01 00:45:20 +00:00
594354e459 Corrected doubled import 2021-12-01 00:45:20 +00:00
75b43f8993 Corrected doubled import 2021-12-01 00:45:20 +00:00
268d3a371e Corrected doubled import 2021-12-01 00:45:20 +00:00
e50ad9430e Corrected doubled import 2021-12-01 00:45:20 +00:00
cc0398f878 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
173b1e329b Exam Code Time Controls 2021-11-30 18:16:52 +00:00
4eb29750ab 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
6f57a4b24c 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
ae583bd2ef 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
ae3d34e0f2 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
2e5c87f0b1 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
17ba7a8bdd 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
c1068acbf7 Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +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
535fe31054 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
1f4848cc83 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
288ecb60e1 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
ee71044421 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
71b39d467d 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
2fc8523bfe 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
eca2165247 Built client interface 2021-11-30 03:11:28 +00:00
70f362015c Built client interface 2021-11-30 03:11:28 +00:00
8ad71d3a06 Built client interface 2021-11-30 03:11:28 +00:00
459c630db7 Built client interface 2021-11-30 03:11:28 +00:00
dc0e3bf11c Built client interface 2021-11-30 03:11:28 +00:00
89bb802e45 Built client interface 2021-11-30 03:11:28 +00:00
dd7df3080e 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
42f9cd9ea8 Built client interface 2021-11-30 03:11:28 +00:00
1980363c12 Built client interface 2021-11-30 03:11:28 +00:00
2bd07bf598 Built client interface 2021-11-30 03:11:28 +00:00
07c8b62dc1 Built client interface 2021-11-30 03:11:28 +00:00
d20a25f261 Built client interface 2021-11-30 03:11:28 +00:00
4c14c85a47 Built client interface 2021-11-30 03:11:28 +00:00
ef7c2f8271 Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
40119c9e9c Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
5b819c5e52 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
9e9ceab81f 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
97719badef 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
59f10c789a 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
7c325e7c9e 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
6a898bc2dc 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
47e69da60b Added question generating API 2021-11-28 18:17:50 +00:00
e730607c66 Added question generating API 2021-11-28 18:17:50 +00:00
ac1ad771f0 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
6285014938 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
272cd1441c 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
9fa553bd4a 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
3cedfcaeaf 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
2cf3329131 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
96a8c8da6c Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
2d1cdd5e94 Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
e6b37ce453 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
fdc68079dc 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
f8d97314d3 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
69139b9ac5 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
3797adfc95 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
6929136f90 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
60b6a462c8 Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +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
e0eda9df49 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
53cc25b4ce 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
e37d287397 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
6d5f8bc00c 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
c7252d0f7b 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
35b0d30739 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
6e463ca588 Refactor to have all models in the models package. 2021-11-25 23:21:48 +00:00
95abec2b4b Refactor to have all models in the models package. 2021-11-25 23:21:48 +00:00
0fff71b1fb Refactor to have all models in the models package. 2021-11-25 23:21:48 +00:00
3bde83cf92 Refactor to have all models in the models package. 2021-11-25 23:21:48 +00:00
5b2e6dda67 Refactor to have all models in the models package. 2021-11-25 23:21:48 +00:00
408aa965fd Refactor to have all models in the models package. 2021-11-25 23:21:48 +00:00
39e80c64fa Refactor to have all models in the models package. 2021-11-25 23:21:48 +00:00
d3df5fe2d3 Quiz registration form 2021-11-25 23:12:20 +00:00
52019e61c1 Quiz registration form 2021-11-25 23:12:20 +00:00
c745e3c27c Quiz registration form 2021-11-25 23:12:20 +00:00
bf18944761 Finished delete and data table fiew for tests 2021-11-25 20:12:50 +00:00
9b1d8fca71 Finished delete and data table fiew for tests 2021-11-25 20:12:50 +00:00
2d0bb883bd Finished delete and data table fiew for tests 2021-11-25 20:12:50 +00:00
f68571900e Working version of test tables 2021-11-25 10:34:01 +00:00
d6d809a60e Working version of test tables 2021-11-25 10:34:01 +00:00
c0d79d1bc7 Working version of test tables 2021-11-25 10:34:01 +00:00
2f45e58369 Building new test form
Added CRUD for tests
2021-11-24 17:17:56 +00:00
610b6a5766 Building new test form
Added CRUD for tests
2021-11-24 17:17:56 +00:00
a862a0f03a Building new test form
Added CRUD for tests
2021-11-24 17:17:56 +00:00
c3c6da1acc Deleted user views registration 2021-11-23 14:53:58 +00:00
f2943e4bc1 Deleted user views registration 2021-11-23 14:53:58 +00:00
9f198ed133 Deleted user views registration 2021-11-23 14:53:58 +00:00
93367b6e70 Finished most of admin console
Basic CRUD operations for managing registered admin users
Encrypted personal information
Still missing sections on managing tests and results
Also missing dashboards/index/category landing pages
2021-11-23 13:00:03 +00:00
43b5973dbe Finished most of admin console
Basic CRUD operations for managing registered admin users
Encrypted personal information
Still missing sections on managing tests and results
Also missing dashboards/index/category landing pages
2021-11-23 13:00:03 +00:00
0002c15e94 Finished most of admin console
Basic CRUD operations for managing registered admin users
Encrypted personal information
Still missing sections on managing tests and results
Also missing dashboards/index/category landing pages
2021-11-23 13:00:03 +00:00
142 changed files with 4591 additions and 2227 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.

3
.gitignore vendored
View File

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

150
README.md
View File

@ -14,27 +14,155 @@ 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,41 +18,28 @@ 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
volumes:
- ./ref-test:/ref-test
env_file:
- ./.env
ports:
- 5000
volumes:
- ./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
- ./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:
@ -61,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 vsdomainmanager@gmail.com --agree-tos --no-eff-email -d reftest.vsnt.uk
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
networks:
frontend:

92
install-script.sh Normal file
View File

@ -0,0 +1,92 @@
#!/bin/bash
# Source https://pentacent.medium.com/nginx-and-lets-encrypt-with-docker-in-less-than-5-minutes-b4b8a60d3a71
if ! [ -x "$(command -v docker)" ]; then
echo 'Error: docker is not installed.' >&2
exit 1
fi
if ! [ -x "$(command -v compose)" ]; then
echo 'Error: docker compose is not installed.' >&2
exit 1
fi
domains=(example.org www.example.org)
rsa_key_size=4096
data_path="./certbot"
email="" # Adding a valid address is strongly recommended
staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits
if [ -d "$data_path" ]; then
read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision
if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
exit
fi
fi
if [ ! -e "$data_path/ssl-dhparams.pem" ]; then
echo "### Generating ssl-dhparams.pem ..."
docker compose run --rm --entrypoint "\
openssl dhparam -out /etc/letsencrypt/ssl-dhparams.pem 4096" certbot
echo
fi
echo "### Creating dummy certificate for $domains ..."
path="/etc/letsencrypt/live/$domains"
mkdir -p "$data_path/live/$domains"
docker compose run --rm --entrypoint "\
openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
-keyout '$path/privkey.pem' \
-out '$path/fullchain.pem' \
-subj '/CN=localhost'" certbot
echo
if [ ! -e "$data_path/lets-encrypt-x3-cross-signed.pem" ]; then
echo "### Downloading lets-encrypt-x3-cross-signed.pem ..."
wget -O $data_path/lets-encrypt-x3-cross-signed.pem \
"https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem"
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

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

13
nginx/ssl.conf Normal file
View File

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

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,205 +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):
resp.set_cookie(
key = '_id',
value = self._id,
max_age = timedelta(days=14) if self.remember else 'Session',
path = '/',
expires = datetime.utcnow() + timedelta(days=14) if self.remember else 'Session',
domain = '.reftest.vsnt.uk',
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 = '.reftest.vsnt.uk',
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')))
resp.set_cookie(
key = '_id',
value = '',
max_age = timedelta(days=-1),
path = '/',
expires= datetime.utcnow() + timedelta(days=-1),
domain = '.reftest.vsnt.uk',
secure = True
)
resp.set_cookie (
key = 'cookie_consent',
value = 'True',
max_age = 'Session',
path = '/',
expires = 'Session',
domain = '.reftest.vsnt.uk',
secure = True
)
resp.set_cookie (
key = 'remember',
value = 'True',
max_age = timedelta(days=-1),
path = '/',
expires = datetime.utcnow() + timedelta(days=-1),
domain = '.reftest.vsnt.uk',
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 +0,0 @@
{% extends "admin/components/base.html" %}

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.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)

59
ref-test/app/__init__.py Normal file
View File

@ -0,0 +1,59 @@
from .config import Production as Config
from .install import install_app
from .models import User
from .extensions import bootstrap, csrf, db, login_manager, mail
from flask import flash, Flask, render_template, request
from flask.helpers import url_for
from flask.json import jsonify
from flask_wtf.csrf import CSRFError
from werkzeug.middleware.proxy_fix import ProxyFix
from datetime import datetime
def create_app():
app = Flask(__name__)
app.config.from_object(Config())
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto= 1, x_host= 1)
bootstrap.init_app(app)
csrf.init_app(app)
db.init_app(app)
login_manager.init_app(app)
mail.init_app(app)
login_manager.login_view = 'admin._login'
@login_manager.user_loader
def _load_user(id):
return User.query.filter_by(id=id).first()
@app.before_request
def _check_cookie_consent():
if request.cookies.get('cookie_consent'):
return
if any([ request.path.startswith(x) for x in [ '/admin/static/', '/root/', '/quiz/static', '/cookies/', '/admin/editor/static' ] ]):
return
flash(f'<strong>Cookie Consent</strong>: This web site only stores minimal, functional cookies. It does not store any tracking information. By using this site, you consent to this use of cookies. For more information, see our <a href="{url_for("views._privacy")}">privacy policy</a>.', 'cookie_alert')
@app.errorhandler(404)
def _404_handler(error): return render_template('404.html')
@app.errorhandler(CSRFError)
def _csrf_handler(): return jsonify({'error':'Could not validate a secure connection.'}), 403
@app.context_processor
def _now(): return {'now': datetime.now()}
from .admin.views import admin
from .api.views import api
from .quiz.views import quiz
from .views import views
from .editor.views import editor
app.register_blueprint(admin, url_prefix='/admin')
app.register_blueprint(api, url_prefix='/api')
app.register_blueprint(views)
app.register_blueprint(quiz)
app.register_blueprint(editor, url_prefix='/admin/editor')
install_app(app)
return app

View File

@ -158,7 +158,7 @@ table.dataTable {
#dismiss-cookie-alert {
margin-top: 16px;
width: 100%;
width: fit-content;
}
.alert-db-empty {
@ -223,6 +223,19 @@ table.dataTable {
background-color: unset;
}
/* Change Autocomplete styles in Chrome*/
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
transition: background-color 5000s ease-in-out 0s;
}
/* Fallback for Edge
-------------------------------------------------- */
@supports (-ms-ime-align: auto) {

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

@ -18,7 +18,7 @@ $('form.form-post').submit(function(event) {
var $form = $(this);
var data = $form.serialize();
var url = $(this).attr('action');
var url = $(this).prop('action');
var rel_success = $(this).data('rel-success');
$.ajax({
@ -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}),
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();
@ -110,23 +96,32 @@ $('.test-action').click(function(event) {
// Edit Dataset Button Handlers
$('.edit-question-dataset').click(function(event) {
var filename = $(this).data('filename');
var id = $(this).data('id');
var action = $(this).data('action');
var disabled = $(this).hasClass('disabled');
if ( !disabled ) {
$.ajax({
url: `/admin/settings/questions/${action}/`,
type: 'POST',
data: JSON.stringify({'filename': filename}),
contentType: 'application/json',
success: function(response) {
window.location.reload();
},
error: function(response){
error_response(response);
},
});
if (action == 'delete') {
$.ajax({
url: `/admin/settings/questions/${action}/`,
type: 'POST',
data: JSON.stringify({
'id': id,
'action': action,
}),
contentType: 'application/json',
success: function(response) {
window.location.reload();
},
error: function(response){
error_response(response);
},
});
} else if (action == 'edit') {
window.location.href = `/admin/editor/${id}/`
} else if (action == 'download') {
window.location.href = `/admin/settings/questions/download/${id}/`
}
};
event.preventDefault();
});
@ -166,7 +161,7 @@ $('#dismiss-cookie-alert').click(function(event){
$.ajax({
url: '/cookies/',
type: 'GET',
type: 'POST',
data: {
time: Date.now()
},
@ -185,13 +180,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 +202,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="{{ next or 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

@ -45,6 +45,9 @@
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"

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,114 @@
<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>
<li>
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Question Editor</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

@ -2,16 +2,17 @@
<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:url" content="{{ url_for(request.endpoint, _external = True, **(request.view_args or {})) }}" />
<meta property="og:site_name" content="Scottish Korfball Association Referee Theory Exam" />
<meta property="og:image" content="{{ url_for('static', filename='favicon.png', _external = True) }}" />
<meta property="og:image" 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" 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" />
<meta name="theme-color" content="#343a40" />
<link rel="shortcut icon" href="{{ url_for('.static', filename='favicon.ico') }}">

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

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,12 +44,14 @@
{{ entry.user_code }}
</li>
{% endif %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Start Time</h5>
</div>
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') }}
</li>
{% if entry.start_time %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Start Time</h5>
</div>
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') }}
</li>
{% endif %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Submission Time</h5>
@ -57,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">
@ -103,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 }}
@ -147,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>
@ -162,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
Name
</th>
<th>
Exams
@ -68,22 +68,24 @@
{% for dataset in datasets %}
<tr>
<td>
{{ dataset.filename }}
<a href="{{ url_for('editor._editor_console', id=dataset.id) }}">
{{ dataset.get_name() }}
</a>
</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

@ -11,10 +11,10 @@
</th>
<th data-priority="1">
File Name
Name
</th>
<th data-priority="2">
Uploaded
Updated
</th>
<th data-priority="3">
Author
@ -31,7 +31,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,31 +40,40 @@
{% endif %}
</td>
<td>
{{ element.filename }}
{{ element.get_name() }}
</td>
<td>
{{ element.timestamp.strftime('%d %b %Y') }}
{{ element.date.strftime('%d %b %Y %H:%M') }}
</td>
<td>
{{ element.author }}
{{ element.creator.get_username() }}
</td>
<td>
{{ element.use }}
{{ element.tests|length }}
</td>
<td class="row-actions">
<a
href="#"
class="btn btn-primary edit-question-dataset {% if element.filename == default %}disabled{% endif %}"
data-filename="{{ element.filename }}"
data-action="default"
title="Make Default"
href="javascript:void(0)"
class="btn btn-primary edit-question-dataset"
data-id="{{ element.id }}"
data-action="download"
title="Download Dataset"
>
<i class="bi bi-cloud-arrow-down-fill button-icon"></i>
</button>
<a
href="javascript:void(0)"
class="btn btn-primary edit-question-dataset"
data-id="{{ element.id }}"
data-action="edit"
title="Edit Dataset"
>
<i class="bi bi-file-earmark-text-fill button-icon"></i>
</button>
<a
href="#"
class="btn btn-danger edit-question-dataset {% if element.filename == default %}disabled{% endif %}"
data-filename="{{ element.filename }}"
href="javascript:void(0)"
class="btn btn-danger edit-question-dataset {% if element.default %}disabled{% endif %}"
data-id="{{ element.id }}"
data-action="delete"
title="Delete Dataset"
>
@ -78,13 +87,23 @@
{% else %}
<div class="alert alert-primary alert-db-empty">
<i class="bi bi-info-circle-fill" aria-title="Alert" title="Alert"></i>
There are no question datasets uploaded. Please use the panel below to upload a new question dataset.
There are no question datasets uploaded. Please use the panel below to upload a new question dataset or create a new dataset using the editor console.
</div>
{% endif %}
<div class="col text-center">
<button title="Create New" class="btn btn-md btn-primary btn-block create-new-dataset">
<i class="bi bi-cloud-plus-fill button-icon"></i>
Create New Dataset
</button>
</div>
<div class="form-container">
<form name="form-upload-questions" class="form-display" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="" enctype="multipart/form-data">
<h2 class="form-heading">Upload Question Dataset</h2>
{{ form.hidden_tag() }}
<div class="form-label-group">
{{ form.name(class_="form-control", autofocus=true, placeholder="Enter Name of Dataset") }}
{{ form.name.label }}
</div>
<div class="form-upload">
{{ form.data_file() }}
</div>
@ -95,8 +114,8 @@
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<button title="Create User" class="btn btn-md btn-success btn-block" type="submit">
<i class="bi bi-file-earmark-arrow-up-fill button-icon"></i>
<button title="Upload Dataset" class="btn btn-md btn-success btn-block" type="submit">
<i class="bi bi-cloud-arrow-up-fill button-icon"></i>
Upload Dataset
</button>
</div>
@ -113,9 +132,9 @@
$('#question-datasets-table').DataTable({
'columnDefs': [
{'sortable': false, 'targets': [0,5]},
{'searchable': false, 'targets': [0,4,5]}
{'searchable': false, 'targets': [1,2,3]}
],
'order': [[2, 'desc'], [3, 'asc']],
'order': [[1, 'asc'], [2, 'desc'], [3, '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"
>

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

@ -0,0 +1,393 @@
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, send_errors_to_client
from ..tools.data import check_is_json, validate_json
from ..tools.test import answer_options, get_correct_answers
from flask import abort, Blueprint, jsonify, render_template, redirect, request, send_file, 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
from os import path
import secrets
admin = Blueprint(
name='admin',
import_name=__name__,
template_folder='templates',
static_folder='static'
)
@admin.route('/')
@admin.route('/home/')
@admin.route('/dashboard/')
@login_required
def _home():
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
return send_errors_to_client(form=form)
if 'remembered_username' in session: form.username.data = session.pop('remembered_username')
next = request.args.get('next')
return render_template('/admin/auth/login.html', form=form, next=next)
@admin.route('/logout/')
@login_required
def _logout():
current_user.logout()
return redirect(url_for('admin._login'))
@admin.route('/register/', methods=['GET','POST'])
@disable_if_logged_in
def _register():
from ..models.user import User
form = Register()
if request.method == 'POST':
if form.validate_on_submit():
new_user = User()
new_user.set_username(request.form.get('username').lower())
new_user.set_email(request.form.get('email').lower())
success, message = new_user.register(password=request.form.get('password'))
if success:
flash(message=f'{message} Please log in to continue.', category='success')
session['remembered_username'] = request.form.get('username').lower()
return jsonify({'success': message}), 200
flash(message=message, category='error')
return jsonify({'error': message}), 401
return send_errors_to_client(form=form)
return render_template('admin/auth/register.html', form=form)
@admin.route('/reset/', methods=['GET','POST'])
def _reset():
form = ResetPassword()
if request.method == 'POST':
if form.validate_on_submit():
user = None
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()
return send_errors_to_client(form=form)
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
return send_errors_to_client(form=form)
@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
return send_errors_to_client(form=form)
return render_template('/admin/settings/users.html', form = form, users = users)
@admin.route('/settings/users/delete/<string:id>', methods=['GET', 'POST'])
@login_required
def _delete_user(id:str):
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
return send_errors_to_client(form=form)
if id == current_user.id:
flash('Cannot delete your own user account.', 'error')
return redirect(url_for('admin._users'))
if not user:
flash('User not found.', 'error')
return redirect(url_for('admin._users'))
return render_template('/admin/settings/delete_user.html', form=form, id = id, user = user)
@admin.route('/settings/users/update/<string:id>', methods=['GET', 'POST'])
@login_required
def _update_user(id:str):
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
return send_errors_to_client(form=form)
if not user:
flash('User not found.', 'error')
return redirect(url_for('admin._users'))
return render_template('/admin/settings/update_user.html', form=form, id = id, user = user)
@admin.route('/settings/questions/', methods=['GET', 'POST'])
@login_required
def _questions():
form = UploadData()
if request.method == 'POST':
if form.validate_on_submit():
upload = form.data_file.data
if not check_is_json(upload): return jsonify({'error': 'Invalid file. Please upload a JSON file.'}), 400
upload.stream.seek(0)
data = loads(upload.read())
if not validate_json(data=data): return jsonify({'error': 'The data in the file is invalid.'}), 400
new_dataset = Dataset()
new_dataset.set_name(request.form.get('name'))
success, message = new_dataset.create(
data = data,
default = request.form.get('default')
)
if success: return jsonify({'success': message}), 200
return jsonify({'error': message}), 400
return send_errors_to_client(form=form)
data = Dataset.query.all()
return render_template('/admin/settings/questions.html', form=form, data=data)
@admin.route('/settings/questions/delete/', methods=['POST'])
@login_required
def _edit_questions():
id = request.get_json()['id']
action = request.get_json()['action']
if not action == 'delete': return jsonify({'error': 'Invalid action.'}), 400
dataset = Dataset.query.filter_by(id=id).first()
if action == 'delete': success, message = dataset.delete()
if success: return jsonify({'success': message}), 200
return jsonify({'error': message}), 400
@admin.route('/settings/questions/download/<string:id>/')
@login_required
def _download(id:str):
dataset = Dataset.query.filter_by(id=id).first()
if not dataset: return abort(404)
data_path = path.abspath(dataset.get_file())
return send_file(data_path, as_attachment=True, attachment_filename=f'{dataset.get_name()}.json')
@admin.route('/tests/<string:filter>/', methods=['GET'])
@admin.route('/tests/', methods=['GET'])
@login_required
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
return send_errors_to_client(form=form)
@admin.route('/tests/edit/', methods=['POST'])
@login_required
def _edit_test():
id = request.get_json()['id']
action = request.get_json()['action']
if action not in ['start', 'delete', 'end']: return jsonify({'error': 'Invalid action.'}), 400
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 ..extensions 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)

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

@ -0,0 +1,101 @@
from ..models import Dataset, Entry, User
from ..tools.data import validate_json
from ..tools.test import evaluate_answers, generate_questions
from flask import Blueprint, flash, jsonify, request, url_for
from flask_login import login_required
from datetime import datetime, timedelta
from json import loads
api = Blueprint(
name='api',
import_name=__name__
)
@api.route('/questions/', methods=['POST'])
def _fetch_questions():
id = request.get_json()['id']
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.now() + end_delta
else:
end_time = None
entry.start()
dataset = test.dataset
success, message = dataset.check_file()
if not success: return jsonify({'error': message}), 500
data_path = dataset.get_file()
with open(data_path, 'r') as data_file:
data = loads(data_file.read())
questions = generate_questions(data)
return jsonify({
'time_limit': end_time,
'questions': questions,
'start_time': entry.start_time,
'time_adjustment': time_adjustment
}), 200
@api.route('/submit/', methods=['POST'])
def _submit_quiz():
id = request.get_json()['id']
answers = request.get_json()['answers']
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
@api.route('/editor/', methods=['POST'])
@login_required
def _editor(id:str=None):
request_data = request.get_json()
id = request_data['id']
dataset = Dataset.query.filter_by(id=id).first()
if not dataset: return jsonify({'error': 'Invalid request. Dataset not found.'}), 404
data_path = dataset.get_file()
if request_data['action'] == 'fetch':
with open(data_path, 'r') as data_file:
data = loads(data_file.read())
return jsonify({'success': 'Successfully downloaded dataset', 'data': data}), 200
default = request_data['default']
creator = request_data['creator']
name = request_data['name']
data = request_data['data']
if not validate_json(data): return jsonify({'error': 'The data you submitted was invalid.'}), 400
user = User.query.filter_by(id=creator).first()
dataset.set_name(name)
dataset.creator = user
success, message = dataset.update(data=data, default=default)
if not success: return jsonify({'error': message}), 400
return jsonify({'success': message}), 200
@api.route('/editor/new/', methods=['POST'])
@login_required
def _editor_new():
new_dataset = Dataset()
new_dataset.set_name('New Dataset')
success, message = new_dataset.create(data=[], default=False)
if not success: return jsonify({'error':message}), 400
flash(message, 'success')
return jsonify({'success': message, 'redirect_to': url_for('editor._editor_console', id=new_dataset.id)}), 200

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

@ -0,0 +1,46 @@
import os
from pathlib import Path
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(os.path.abspath(f"{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 Production(Config):
pass
class Development(Config):
APP_HOST = '127.0.0.1'
DEBUG = True
SERVER_NAME = '127.0.0.1:5000'
SESSION_COOKIE_SECURE = False
MAIL_SERVER = 'localhost'
MAIL_DEBUG = True
MAIL_SUPPRESS_SEND = False
class Testing(Development):
TESTING = True
SESSION_COOKIE_SECURE = False
MAIL_SERVER = os.getenv('MAIL_SERVER')
MAIL_DEBUG = True
MAIL_SUPPRESS_SEND = False

View File

@ -0,0 +1,87 @@
.accordion-button {
color: inherit;
background-color: inherit;
display: block;
border: 1px solid rgb(0 0 0 / .3);
height: 60px;
}
.editor-controls {
width: fit-content;
display: block;
margin: 10px auto;
}
.editor-controls a {
margin: 10px 10px;
}
.editor-controls a i {
font-size: larger;
margin: 2px;
}
.option-controls, .block-controls {
width: fit-content;
display: block;
margin: 10px auto;
}
.option-controls a, .block-controls a {
margin: 0 10px;
z-index: 10;
}
.option-controls a i, .block-controls a i {
font-size: larger;
margin: 2px;
}
.accordion-button div {
margin: 0;
position: relative;
top: 50%;
transform: translate(0, -50%);
}
.accordion-button::after {
content: none;
}
.accordion-error {
background-color: #bb2d3b;
color: white;
}
.accordion-error:not(.collapsed) {
background-color: #bb2d3b;
color: white;
}
.panel-button {
padding: 6px;
margin: 0px 2px;
}
.panel-button i {
font-size: larger;
}
.editor-panel, .info-panel {
margin: 30pt auto;
}
.info-panel {
display: none;
}
.control-panel {
margin-left: auto;
margin-right: 0;
width:fit-content;
}
#alert-box {
margin: 30px auto;
max-width: 460px;
}

View File

@ -0,0 +1,260 @@
body {
padding: 80px 0;
}
.site-footer {
background-color: lightgray;
font-size: small;
}
.site-footer p {
margin: 0;
}
.form-container {
display: -ms-flexbox;
display: flex;
-ms-flex-align: center;
align-items: center;
padding-top: 40px;
padding-bottom: 40px;
}
.form-display {
width: 100%;
max-width: 420px;
padding: 15px;
margin: auto;
}
.form-heading {
margin-bottom: 2rem;
}
.form-label-group {
position: relative;
margin-bottom: 2rem;
}
.form-label-group input,
.form-label-group label {
padding: var(--input-padding-y) var(--input-padding-x);
font-size: 16pt;
}
.form-label-group label {
position: absolute;
top: 0;
left: 0;
display: block;
width: 100%;
margin-bottom: 0; /* Override default `<label>` margin */
line-height: 1.5;
color: #495057;
cursor: text; /* Match the input under the label */
border: 1px solid transparent;
border-radius: .25rem;
transition: all .1s ease-in-out;
z-index: -1;
}
.form-label-group input {
background-color: transparent;
border: none;
border-radius: 0%;
border-bottom: 2px solid #585858;
}
.form-label-group input:active, .form-label-group input:focus {
background-color: transparent;
}
.form-label-group input::-webkit-input-placeholder {
color: transparent;
}
.form-label-group input:-ms-input-placeholder {
color: transparent;
}
.form-label-group input::-ms-input-placeholder {
color: transparent;
}
.form-label-group input::-moz-placeholder {
color: transparent;
}
.form-label-group input::placeholder {
color: transparent;
}
.form-label-group input:not(:placeholder-shown) {
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
padding-bottom: calc(var(--input-padding-y) / 3);
}
.form-label-group input:not(:placeholder-shown) ~ label {
padding-top: calc(var(--input-padding-y) / 3);
padding-bottom: calc(var(--input-padding-y) / 3);
font-size: 12px;
color: #777;
}
.form-check {
margin-bottom: 2rem;
}
.checkbox input {
transform: scale(1.5);
margin-right: 1rem;
}
.signin-forgot-password {
font-size: 14pt;
}
.form-submission-button {
margin-bottom: 2rem;
}
.form-submission-button button, .form-submission-button a {
margin: 1rem;
vertical-align: middle;
}
.form-submission-button button span, .form-submission-button button svg, .form-submission-button a span, .form-submission-button a svg {
margin: 0 2px;
}
table.dataTable {
border-collapse: collapse;
width: 100%;
}
.table-row {
vertical-align: middle;
}
.row-actions {
text-align: center;
white-space: nowrap;
}
.dataTables_wrapper .dt-buttons {
left: 50%;
transform: translateX(-50%);
float:none;
text-align:center;
}
.row-actions button, .row-actions a {
margin: 0px 5px;
}
#cookie-alert {
padding-right: 16px;
}
#dismiss-cookie-alert {
margin-top: 16px;
width: fit-content;
}
.alert-db-empty {
width: 100%;
max-width: 720px;
font-size: 14pt;
margin: 20px auto;
}
.form-date-input, .form-select-input {
position: relative;
margin: 2rem 0;
}
.form-date-input input,
.form-date-input label, .form-select-input select, .form-select-input label {
padding: var(--input-padding-y) var(--input-padding-x);
font-size: 16pt;
width: 100%;
background-color: transparent;
border: none;
border-bottom: 2px solid #585858;
}
.datepicker::-webkit-calendar-picker-indicator {
border: 1px;
border-color: gray;
border-radius: 10%;
}
.form-date-input label, .form-select-input label {
/* position: absolute; */
/* top: 0;
left: 0; */
display: block;
width: 100%;
margin-bottom: 0; /* Override default `<label>` margin */
line-height: 1.5;
color: #495057;
cursor: text; /* Match the input under the label */
border: 1px solid transparent;
border-radius: .25rem;
transition: all .1s ease-in-out;
z-index: -1;
}
.button-icon {
font-size: 20px;
}
.form-upload {
margin: 2rem 0;
font-size: 14pt;
}
.result-action-buttons, .test-action {
margin: 5px auto;
width: fit-content;
}
.accordion-item {
background-color: unset;
}
/* Change Autocomplete styles in Chrome*/
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
transition: background-color 5000s ease-in-out 0s;
}
/* Fallback for Edge
-------------------------------------------------- */
@supports (-ms-ime-align: auto) {
.form-label-group label {
display: none;
}
.form-label-group input::-ms-input-placeholder {
color: #777;
}
}
/* Fallback for IE
-------------------------------------------------- */
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
.form-label-group label {
display: none;
}
.form-label-group input:-ms-input-placeholder {
color: #777;
}
}

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

@ -0,0 +1,499 @@
// Variable Declarations
const $root = $('#editor-root')
const target = $root.data('target')
const id = $root.data('id')
const $control_panel = $('.control-panel')
const $info_panel = $('.info-panel')
const $editor_panel = $('.editor-panel')
var element_index = 0
// Initialise Sortable and trigger renumbering on end of drag
Sortable.create($root.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
// Info Button Listener
$control_panel.find('button').click(function(event){
if ($info_panel.is(":hidden")) {
$editor_panel.hide()
$info_panel.fadeIn()
$(this).addClass('active')
} else {
$info_panel.hide()
$editor_panel.fadeIn()
$(this).removeClass('active')
}
event.preventDefault()
})
// Control Button Listeners
$root.on('click', '.block-controls > a', function(event){
event.preventDefault()
var action = $(this).data('action')
var root_accordion = $(this).closest('div').siblings('.accordion')
if (action == 'add-question') {
var question = generate_single_question(root_container_id=`#${root_accordion.attr('id')}`)
$(question).appendTo(root_accordion).hide().fadeIn()
if (root_accordion.children().length > 1 ) {
root_accordion.find('.panel-controls > a[data-action="delete"]').removeClass('disabled')
} else {
root_accordion.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
}
renumber_blocks()
}
})
$root.on('click', '.panel-controls > a', function(event) {
event.preventDefault()
event.stopPropagation()
var action = $(this).data('action')
var element = $(this).closest('.accordion-item')
var root_container = $(this).closest('.accordion')
if (action == 'delete') {
element.fadeOut(function(){
$(this).remove()
renumber_blocks()
if (root_container.get(0) != $root.get(0) && root_container.children().length < 2 ) {
root_container.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
}
})
} else if (action == 'add-question') {
var question = generate_single_question(root_container_id=`#${root_container.attr('id')}`)
$(question).insertBefore(element).hide().fadeIn()
if (root_container.get(0) != $root.get(0) && root_container.children().length > 1 ) {
root_container.find('.panel-controls > a[data-action="delete"]').removeClass('disabled')
}
} else if (action == 'add-block') {
var block = generate_block(root_container_id=`#${root_container.attr('id')}`)
$(block).insertBefore(element).hide().fadeIn()
var block_container = $(`#element${element_index-1}`).children().find('.accordion')
Sortable.create(block_container.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
var question = generate_single_question(root_container_id=`#${block_container.attr('id')}`)
block_container.append(question)
block_container.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
}
renumber_blocks()
})
$root.on('click', '.option-controls > a', function(event) {
event.preventDefault()
var action = $(this).data('action')
var options = $(this).closest('div.option-controls').siblings('.options')
var length = options.children().length
var correct = $(this).closest('div.option-controls').siblings().find('.question-correct')
if (action == 'delete') {
if (length > 2) {
options.children().last().fadeOut(function(){
$(this).remove()
length = options.children().length
if (length <= 2) {
options.siblings('div.option-controls').children('a[data-action="delete"]').addClass('disabled')
} else {
options.siblings('div.option-controls').children('a[data-action="delete"]').removeClass('disabled')
}
})
correct.children().last().fadeOut(function(){
$(this).remove()
})
}
} else {
var opt = `
<div class="input-group mb-3">
<span class="input-group-text">${length}</span>
<input type="text" class="form-control" value="Option ${length}">
</div>
`
$(opt).appendTo(options).hide().fadeIn()
var cor = `<option value="${length}">${length}</option>`
correct.append(cor)
}
length = options.children().length
if (length <= 2) {
$(this).closest('div.option-controls').children('a[data-action="delete"]').addClass('disabled')
} else {
$(this).closest('div.option-controls').children('a[data-action="delete"]').removeClass('disabled')
}
})
$('.editor-controls > a').click(function(event){
event.preventDefault()
var action = $(this).data('action')
var root_accordion = $(this).closest('div').siblings('.accordion')
if (action == 'add-question') {
var obj = generate_single_question(root_container_id=`#${root_accordion.attr('id')}`)
$(obj).appendTo($root).hide().fadeIn()
} else if (action == 'add-block') {
var obj = generate_block(root_container_id=`#${root_accordion.attr('id')}`)
$(obj).appendTo($root).hide().fadeIn()
var block_container = $(`#element${element_index-1}`).children().find('.accordion')
Sortable.create(block_container.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
var question = generate_single_question(root_container_id=`#${block_container.attr('id')}`)
block_container.append(question)
block_container.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
} else if (action == 'discard') {
window.location.href = '/admin/settings/questions/'
} else if (action == 'delete') {
$.ajax({
url: '/admin/settings/questions/delete/',
type: 'POST',
data: JSON.stringify({
'id': id,
'action': action
}),
contentType: 'application/json',
success: function(response) {
window.location.href = '/admin/settings/questions/'
},
error: function(response) {
error_response(response)
}
})
} else if (action == 'save') {
var input = parse_input()
var def = $('.dataset-default').is(':checked')
var name = $('.dataset-name').val()
var creator = $('.dataset-creator').val()
console.log([def, name, creator])
$.ajax({
url: target,
type: 'POST',
data: JSON.stringify({
'id': id,
'action': 'upload',
'data': input,
'default': def,
'name': name,
'creator': creator
}),
contentType: 'application/json',
success: function(response) {
window.location.href = '/admin/settings/questions/'
},
error: function(response) {
error_response(response)
}
})
}
renumber_blocks()
})
// Question Type Select Menu Listener
$root.on('change', '.form-select.question-type', function(event) {
event.preventDefault()
var type = $(this).val()
var options = $(this).closest('div.input-group').siblings('.options')
var option_controls = $(this).closest('div.input-group').siblings('.option-controls')
var correct = $(this).closest('div.input-group').siblings().find('.question-correct')
if (type == 'Yes/No') {
options.empty()
correct.empty()
var opt = `
<div class="input-group mb-3">
<span class="input-group-text">0</span>
<input type="text" class="form-control" value="Yes" disabled>
</div>
<div class="input-group mb-3">
<span class="input-group-text">1</span>
<input type="text" class="form-control" value="No" disabled>
</div>
`
$(opt).appendTo(options).hide().fadeIn()
option_controls.children('a').addClass('disabled')
var cor = `
<option value ="0" default>0</option>
<option value="1">1</option>
`
correct.append(cor)
} else {
option_controls.children('a').removeClass('disabled')
options.find('input').removeAttr('disabled')
if (options.children().length <= 2 ){
option_controls.children('a[data-action="delete"]').addClass('disabled')
}
}
})
// Data and Rendering Functions
function renumber_blocks () {
$( ".block-number" ).each(function(index) {
$( this ).text($( this ).closest('.accordion-item').index() + 1)
})
}
function parse_input() {
var data = []
var element = {}
var question = {}
var block_container
var q_no = 0
$root.children().each(function(index) {
element = {}
if ($(this).data('type') == 'block') {
element['type'] = 'block'
element['question_header'] = $(this).find('.block-header-text').val()
element['questions'] = []
block_container = $(this).children().find('.accordion')
block_container.children().each(function(index) {
question = {}
question['q_no'] = q_no
question['text'] = $(this).find('.question-text').val()
question['q_type'] = $(this).find('.question-type').val()
question['correct'] = parseInt($(this).find('.question-correct').val())
question['options'] = []
$(this).find('.options').find('input').each(function(index) {
question['options'].push($(this).val())
})
question['tags'] = $(this).find('.question-tags').val().split('\r\n')
element['questions'].push(question)
q_no ++
})
} else if ( $(this).data('type') == 'question') {
element['type'] = 'question'
element['q_no'] = q_no
element['text'] = $(this).find('.question-text').val()
element['q_type'] = $(this).find('.question-type').val()
element['correct'] = parseInt($(this).find('.question-correct').val())
element['options'] = []
$(this).find('.options').find('input').each(function(index) {
element['options'].push($(this).val())
})
element['tags'] = $(this).find('.question-tags').val().split('\r\n')
q_no ++
}
data.push(element)
})
return data
}
function parse_data(data) {
var block, obj, new_block, block_container, question, _question, new_question, options, correct, opt, tags
for (let c = 0; c < data.length; c++) {
block = data[c]
if (block['type'] == 'block') {
obj = generate_block(root_container_id=`#${$root.attr('id')}`)
$root.append(obj)
new_block = $(`#element${element_index-1}`)
new_block.find('.block-header-text').val(block['question_header']).trigger('change')
block_container = $(`#element${element_index-1}`).children().find('.accordion')
Sortable.create(block_container.get(0), {handle: '.move-handle', onEnd: function(evt) {renumber_blocks()}})
for (let _c = 0; _c < block['questions'].length; _c ++) {
question = block['questions'][_c]
_question = generate_single_question(root_container_id=`#${block_container.attr('id')}`)
block_container.append(_question)
if (block_container.children().length <= 1) {
block_container.find('.panel-controls > a[data-action="delete"]').addClass('disabled')
} else {
block_container.find('.panel-controls > a[data-action="delete"]').removeClass('disabled')
}
new_question = $(`#element${element_index-1}`)
new_question.find('.question-text').val(question['text']).trigger('change')
new_question.find('.question-type').val(question['q_type']).trigger('change')
correct = new_question.find('.question-correct')
if (question['q_type'] != 'Yes/No') {
options = new_question.find('.options')
options.empty()
correct.empty()
for ( var __c = 0; __c < question['options'].length; __c++) {
option = question['options'][__c]
opt = `
<div class="input-group mb-3">
<span class="input-group-text">${__c}</span>
<input type="text" class="form-control" value="${option}">
</div>
`
options.append(opt)
correct.append(`<option value="${__c}">${__c}</option>`)
}
}
correct.val(String(question['correct']))
tags = question['tags'].join('\r\n')
new_question.find('.question-tags').val(tags)
}
} else {
question = block
obj = generate_single_question(root_container_id=`#${$root.attr('id')}`)
$root.append(obj)
new_question = $(`#element${element_index-1}`)
new_question.find('.question-text').val(question['text']).trigger('change')
new_question.find('.question-type').val(question['q_type']).trigger('change')
correct = new_question.find('.question-correct')
if (question['q_type'] != 'Yes/No') {
options = new_question.find('.options')
options.empty()
correct.empty()
for ( var _c = 0; _c < question['options'].length; _c++) {
option = question['options'][_c]
opt = `
<div class="input-group mb-3">
<span class="input-group-text">${_c}</span>
<input type="text" class="form-control" value="${option}">
</div>
`
options.append(opt)
correct.append(`<option value="${_c}">${_c}</option>`)
}
}
correct.val(String(question['correct']))
tags = question['tags'].join('\r\n')
new_question.find('.question-tags').val(tags)
}
}
renumber_blocks()
}
// Content Generator Functions
function generate_single_question(root_container_id) {
if (root_container_id == `#${$root.attr('id')}`) {
var block_button = `
<a href="javascript:void(0)" class="btn btn-primary" data-action="add-block" title="Add Block Above" aria-title="Add Block Above" data-bs-toggle="collapse" data-bs-target>
<i class="bi bi-folder-plus"></i>
</a>
`
} else {
var block_button = ''
}
var question = `
<div class="accordion-item" id="element${element_index}" data-type="question">
<h2 class="accordion-header" id="element${element_index}-header">
<div class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#element${element_index}-content" aria-expanded="true" aria-controls="element${element_index}-content">
<div class="float-start">
<div class="accordion-caption">
<span class="block-number"></span>.
Question
</div>
</div>
<div class="panel-controls float-end">
<a href="javascript:void(0)" class="btn btn-success move-handle" data-action="move-question" title="Move Question" aria-title="Move Question" data-bs-toggle="collapse" data-bs-target>
<i class="bi bi-arrows-move"></i>
</a>
${block_button}
<a href="javascript:void(0)" class="btn btn-primary" data-action="add-question" title="Add Question Above" aria-title="Add Question Above" data-bs-toggle="collapse" data-bs-target>
<i class="bi bi-file-plus-fill"></i>
</a>
<a href="javascript:void(0)" class="btn btn-danger" data-action="delete" title="Delete Block" aria-title="Delete Block" data-bs-toggle="collapse" data-bs-target>
<i class="bi bi-trash-fill"></i>
</a>
</div>
</div>
</h2>
<div id="element${element_index}-content" class="accordion-collapse collapse" aria-labelledby="element${element_index}-header" data-bs-parent="${root_container_id}">
<div class="accordion-body">
<div class="input-group mb-3">
<span class="input-group-text">Question</span>
<textarea type="text" class="form-control question-text">Enter question here.</textarea>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Question Type</span>
<select class="form-select question-type">
<option value ="Multiple Choice" default>Multiple Choice</option>
<option value="Yes/No">Yes/No</option>
<option value="List">Ordered List</option>
</select>
</div>
<label class="form-label">Options</label>
<ul class="options">
<div class="input-group mb-3">
<span class="input-group-text">0</span>
<input type="text" class="form-control" value="Option 0">
</div>
<div class="input-group mb-3">
<span class="input-group-text">1</span>
<input type="text" class="form-control" value="Option 1">
</div>
</ul>
<div class="option-controls">
<a href="javascript:void(0)" class="btn btn-danger disabled" data-action="delete" title="Delete Question" aria-title="Delete Question">
<i class="bi bi-patch-minus-fill"></i>
Delete
</a>
<a href="javascript:void(0)" class="btn btn-success" data-action="add" title="Add Question" aria-title="Add Question">
<i class="bi bi-patch-plus-fill"></i>
Add
</a>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Correct</span>
<select class="form-select question-correct">
<option value ="0" default>0</option>
<option value="1">1</option>
</select>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Tags</span>
<textarea type="text" class="form-control question-tags"></textarea>
</div>
</div>
</div>
</div>
`
element_index ++
return question
}
function generate_block(root_container_id) {
var block = `
<div class="accordion-item" id="element${element_index}" data-type="block">
<h2 class="accordion-header" id="element${element_index}-header">
<div class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#element${element_index}-content" aria-expanded="true" aria-controls="element${element_index}-content">
<div class="float-start">
<div class="accordion-caption">
<span class="block-number"></span>.
Block
</div>
</div>
<div class="panel-controls float-end">
<a href="javascript:void(0)" class="btn btn-success move-handle" data-action="move-question" title="Move Question" aria-title="Move Question" data-bs-toggle="collapse" data-bs-target>
<i class="bi bi-arrows-move"></i>
</a>
<a href="javascript:void(0)" class="btn btn-primary" data-action="add-block" title="Add Block Above" aria-title="Add Block Above" data-bs-toggle="collapse" data-bs-target>
<i class="bi bi-folder-plus"></i>
</a>
<a href="javascript:void(0)" class="btn btn-primary" data-action="add-question" title="Add Question Above" aria-title="Add Question Above" data-bs-toggle="collapse" data-bs-target>
<i class="bi bi-file-plus-fill"></i>
</a>
<a href="javascript:void(0)" class="btn btn-danger" data-action="delete" title="Delete Block" aria-title="Delete Block" data-bs-toggle="collapse" data-bs-target>
<i class="bi bi-trash-fill"></i>
</a>
</div>
</div>
</h2>
<div id="element${element_index}-content" class="accordion-collapse collapse" aria-labelledby="element${element_index}-header" data-bs-parent="${root_container_id}">
<div class="accordion-body">
<div class="input-group mb-3">
<span class="input-group-text">Block Header</span>
<textarea type="text" class="form-control block-header-text">Enter the header text for this block of questions.</textarea>
</div>
<div class="accordion" id="element${element_index}-questions">
</div>
<div class="block-controls">
<a href="javascript:void(0)" class="btn btn-success" data-action="add-question" title="Add Question" aria-title="Add Question">
<i class="bi bi-file-plus-fill"></i>
Add Question
</a>
</div>
</div>
</div>
</div>
`
element_index ++
return block
}
// Fetch data once page finishes loading
$(window).on('load', function() {
$.ajax({
url: target,
type: 'POST',
data: JSON.stringify({
'id': id,
'action': 'fetch'
}),
contentType: 'application/json',
success: function(response) {
parse_data(response['data'])
},
error: function(response) {
console.log(response)
}
})
})

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,115 @@
// Menu Highlight Scripts
const menuItems = document.getElementsByClassName('nav-link');
for(let i = 0; i < menuItems.length; i++) {
if(menuItems[i].pathname == window.location.pathname) {
menuItems[i].classList.add('active');
}
}
const dropdownItems = document.getElementsByClassName('dropdown-item');
for(let i = 0; i< dropdownItems.length; i++) {
if(dropdownItems[i].pathname == window.location.pathname) {
dropdownItems[i].classList.add('active');
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active');
}
}
// General Post Method Form Processing Script
$('form.form-post').submit(function(event) {
var $form = $(this);
var data = $form.serialize();
var url = $(this).prop('action');
var rel_success = $(this).data('rel-success');
$.ajax({
url: url,
type: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.redirect_to) {
window.location.href = response.redirect_to;
}
else {
window.location.href = rel_success;
}
},
error: function(response) {
error_response(response);
}
});
event.preventDefault();
});
function error_response(response) {
const $alert = $("#alert-box");
$alert.html('');
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
$alert.html(`
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`);
} else if (response.responseJSON.error instanceof Array) {
var output = ''
for (var i = 0; i < response.responseJSON.error.length; i ++) {
output += `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
$alert.html(output);
}
}
$alert.focus()
}
// Dismiss Cookie Alert
$('#dismiss-cookie-alert').click(function(event){
$.ajax({
url: '/cookies/',
type: 'POST',
data: {
time: Date.now()
},
dataType: 'json',
success: function(response){
console.log(response);
},
error: function(response){
console.log(response);
}
})
event.preventDefault();
})
// Create New Dataset
$('.create-new-dataset').click(function(event){
$.ajax({
url: '/api/editor/new/',
type: 'POST',
data: {
time: Date.now()
},
dataType: 'json',
success: function(response){
if (response.redirect_to) {
window.location.href = response.redirect_to;
}
},
error: function(response){
console.log(response);
}
})
event.preventDefault()
})

View File

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

View File

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

View File

@ -0,0 +1,28 @@
{% extends "editor/components/base.html" %}
{% block datatable_css %}
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.11.3/css/dataTables.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/buttons/2.0.1/css/buttons.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/colreorder/1.5.5/css/colReorder.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/fixedheader/3.2.0/css/fixedHeader.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/keytable/2.6.4/css/keyTable.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/searchbuilder/1.3.0/css/searchBuilder.dataTables.min.css"/>
{% endblock %}
{% block datatable_scripts %}
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jszip/2.5.0/jszip.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/pdfmake.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/vfs_fonts.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/jquery.dataTables.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/dataTables.bootstrap5.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/dataTables.buttons.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.bootstrap5.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.colVis.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.html5.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.print.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/colreorder/1.5.5/js/dataTables.colReorder.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/fixedheader/3.2.0/js/dataTables.fixedHeader.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/keytable/2.6.4/js/dataTables.keyTable.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/responsive.bootstrap5.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/searchbuilder/1.3.0/js/dataTables.searchBuilder.min.js"></script>
{% endblock %}

View File

@ -0,0 +1,2 @@
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <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>

View File

@ -0,0 +1,4 @@
{% extends "admin/components/base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block top_alerts %}
{% endblock %}

View File

@ -0,0 +1,114 @@
<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>
<li>
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Question Editor</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

@ -2,16 +2,17 @@
<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:url" content="{{ url_for(request.endpoint, _external = True, **(request.view_args or {})) }}" />
<meta property="og:site_name" content="Scottish Korfball Association Referee Theory Exam" />
<meta property="og:image" content="{{ url_for('static', filename='favicon.png', _external = True) }}" />
<meta property="og:image" 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" 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" />
<meta name="theme-color" content="#343a40" />
<link rel="shortcut icon" href="{{ url_for('.static', filename='favicon.ico') }}">

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

View File

@ -0,0 +1,147 @@
{% extends "editor/components/base.html" %}
{% block style %}
<link
rel="stylesheet"
href="{{ url_for('.static', filename='css/editor.css') }}"
/>
{% endblock %}
{% block content %}
<h1>Editor</h1>
<div class="container">
<p class="lead">
Use this console to edit the questions in this dataset. For more information on using the editor console, click on the the blue information button.
</p>
</div>
<div class="container control-panel">
<button class="btn btn-primary" aria-title="Infrmation" title="Information"><i class="bi bi-info-circle-fill"></i></button>
</div>
<div class="container info-panel">
<h3>
About the Editor Console
</h3>
<p>
This console will allow you to edit the question data for the RefTest App.
All of the questions will be visually displayed as blocks on the screen that you can minimise, expand, and rearrange.
</p>
<p>
Blocks can be of two types: <strong>Blocks</strong> of multiple related questions, and <strong>Single Questions</strong> that are not part of a block.
You can add, remove, or edit both Blockss and Questions through this editor.
</p>
<p>
<strong>Blocks</strong> are useful when you have a section of the test that contains multiple questions that are related to each other, for example if there is a scenario-based section where a series of questions are about the same situation.
</p>
<p>
Blocks can contain any number of questions within them, but cannot contain nested blocks.
</p>
<p>
When you set up a block, you can also add <strong>header text</strong> that will be displayed with each question.
You can use this to provide common information for a scenario across a series of questions.
</p>
<p>
Questions come in three types:
<ul>
<li>
<strong>Yes/No</strong> for when there is only a yes or no option,
</li>
<li>
<strong>Multiple Choice</strong> for your regular multiple choice questions, and
</li>
<li>
<strong>Ordered List</strong> for multiple choice questions that will be displayed in the same order as listed here.
</li>
</ul>
</p>
<p>
Normally, multiple choice questions will have the order of the options randomised.
</p>
<p>
Questions will be displayed to candidates in a randomised order.
Blocks of questions will be kept together, but the order within the block will also be randomised.
</p>
<p><strong>Do not use language that will assume the flow of questions, such as saying &lsquo;the previous question&rsquo;, or &lsquo;the next question&rsquo;, etc. because of randomisation.</strong></p>
<p>
Each option will be referenced by an <strong>index number</strong>.
Make sure to select which index number represents the <strong>correct option</strong>.
</p>
<p>
You will also be able to define <strong>tags</strong> for each question.
Separate multiple tags in <strong>new lines</strong>.
Make sure to keep the spelling, capitalisation, and punctuation for tags consistent.
</p>
<p class="lead">
Placeholder for Questions Remaining in a Block
</p>
<p>
In order to show how many questions are remaining inside a block, e.g. to say &lsquo;the next n questions are about a specific scenario&rsquo;, use the placeholder <code>&lt;block_remaining_questions&gt;</code>.
</p>
</div>
<div class="container editor-panel">
<h3>
Edit Dataset
</h3>
<div class="container dataset-metadata">
<div class="input-group mb-3">
<span class="input-group-text">Dataset Name</span>
<input type="text" class="form-control dataset-name" value="{{ dataset.get_name() }}">
</div>
<div class="input-group mb-3">
<span class="input-group-text">Author</span>
<select class="form-select dataset-creator">
{% for user in users %}
<option value="{{ user.id }}" {{default if dataset.user == user else None }}>{{ user.get_username() }}</option>
{% endfor %}
</select>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Last Updated</span>
<span class="form-control">
{{ dataset.date.strftime('%d %b %Y %H:%M') }}
</select>
</div>
<div class="input-group mb-3">
<span class="input-group-text">
<input type="checkbox" aria-label="Default" class="dataset-default" {% if dataset.default %}checked{% endif %}>
</span>
<span class="form-control">
Make Dataset the Default
</select>
</div>
</div>
<div class="accordion" id="editor-root" data-target="{{ url_for('api._editor') }}" data-id="{{ dataset.id }}">
</div>
{% include "editor/components/client-alerts.html" %}
<div class="editor-controls container">
<a href="javascript:void(0);" class="btn btn-primary" data-action="add-block" title="Add Block" aria-title="Add Block">
<i class="bi bi-folder-plus"></i>
Add Block
</a>
<a href="javascript:void(0);" class="btn btn-primary" data-action="add-question" title="Add Question" aria-title="Add Question">
<i class="bi bi-file-plus-fill"></i>
Add Question
</a>
</div>
<div class="editor-controls container">
<a href="javascript:void(0);" class="btn btn-warning" data-action="discard" title="Discard Changes" aria-title="Discard Changes">
<i class="bi bi-x-circle-fill"></i>
Discard Changes
</a>
<a href="javascript:void(0);" class="btn btn-danger {% if datasets <=1 or dataset.default or dataset.tests|length > 0 %}disabled{% endif %}" data-action="delete" title="Delete" aria-title="Delete">
<i class="bi bi-trash-fill"></i>
Delete
</a>
<a href="javascript:void(0);" class="btn btn-success" data-action="save" title="Save" aria-title="Save">
<i class="bi bi-cloud-arrow-up-fill"></i>
Save Changes
</a>
</div>
</div>
{% endblock %}
{% block script %}
<script
type="text/javascript"
src="{{ url_for('.static', filename='js/editor.js') }}"
></script>
{% endblock %}

View File

@ -0,0 +1,31 @@
{% extends "editor/components/input-forms.html" %}
{% block content %}
<div class="form-container">
<form name="form-editor" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for(request.endpoint, **request.view_args) }}">
{% include "admin/components/server-alerts.html" %}
<h2 class="form">Dataset Editor</h2>
{{ form.hidden_tag() }}
<div class="form-select-input">
{{ form.dataset(placeholder="Select Question Dataset") }}
{{ form.dataset.label }}
</div>
{% include "admin/components/client-alerts.html" %}
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<button class="btn btn-md btn-success btn-block" type="submit">
<i class="bi bi-pencil-fill button-icon"></i>
Edit
</button>
<button title="New" class="btn btn-md btn-primary create-new-dataset">
<i class="bi bi-cloud-plus-fill button-icon"></i>
New
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,38 @@
from ..forms.admin import EditDataset
from ..models import Dataset, User
from ..tools.forms import get_dataset_choices, send_errors_to_client
from flask import Blueprint, flash, jsonify, redirect, render_template, request
from flask.helpers import url_for
from flask_login import login_required
editor = Blueprint(
name='editor',
import_name=__name__,
template_folder='templates',
static_folder='static'
)
@editor.route('/', methods=['GET','POST'])
@login_required
def _editor():
form = EditDataset()
form.dataset.choices = get_dataset_choices()
if request.method == 'POST':
if form.validate_on_submit():
id = request.form.get('dataset')
return jsonify({'success': 'Selected dataset', 'redirect_to': url_for('editor._editor_console', id=id)}),200
return send_errors_to_client(form=form)
form.process()
return render_template('/editor/index.html', form=form)
@editor.route('/<string:id>/')
@login_required
def _editor_console(id:str=None):
dataset = Dataset.query.filter_by(id=id).first()
datasets = Dataset.query.count()
users = User.query.all()
if not dataset:
flash('Invalid dataset ID.', 'error')
return redirect(url_for('admin._questions'))
return render_template('/editor/console.html', dataset=dataset, datasets=datasets, users=users)

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

@ -1,62 +1,68 @@
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):
name = StringField('Name', validators=[InputRequired()])
data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])])
default = BooleanField('Make Default', render_kw={'checked': True})
class AddTimeAdjustment(FlaskForm):
time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)])
class EditDataset(FlaskForm):
dataset = SelectField('Question Dataset')

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)])

34
ref-test/app/install.py Normal file
View File

@ -0,0 +1,34 @@
from .extensions import db
from .tools.data import save
from .tools.logs import write
from sqlalchemy_utils import create_database, database_exists
from cryptography.fernet import Fernet
from os import mkdir, path
from pathlib import Path
def install_app(app):
with app.app_context():
data = Path(app.config.get('DATA'))
database_uri = app.config.get('SQLALCHEMY_DATABASE_URI')
if not path.isdir(f'./{data}'): mkdir(f'./{data}')
if not path.isdir(f'./{data}/questions'): mkdir(f'./{data}/questions')
if not path.isfile(f'./{data}/.gitignore'):
with open(f'./{data}/.gitignore', 'a+') as file: file.write(f'*')
if not path.isfile(f'./{data}/config.json'): save({}, 'config.json')
if not path.isdir(f'./{data}/logs'): mkdir(f'./{data}/logs')
if not path.isfile(f'./{data}/logs/users.log'): write('users.log', 'Log file created.')
if not path.isfile(f'./{data}/logs/system.log'): write('system.log', 'Log file created.')
if not path.isfile(f'./{data}/logs/tests.log'): write('tests.log', 'Log file created.')
if not database_exists(database_uri):
create_database(database_uri)
write('system.log', 'No database found. Creating a new database.')
from .models import Entry, Dataset, Test, User
db.create_all()
write('system.log', 'Creating database schema.')
if not path.isfile(f'./{data}/.encryption.key'):
write('system.log', 'No encryption key found. Generating new encryption key.')
with open(f'./{data}/.encryption.key', 'wb') as key_file:
key = Fernet.generate_key()
key_file.write(key)

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,108 @@
from ..extensions import db
from ..tools.encryption import decrypt, encrypt
from ..tools.logs import write
from flask import flash
from flask import current_app as app
from flask_login import current_user
from werkzeug.utils import secure_filename
from datetime import datetime
from json import dump
from os import path, remove
from pathlib import Path
from uuid import uuid4
class Dataset(db.Model):
id = db.Column(db.String(36), primary_key=True)
name = db.Column(db.String(128), nullable=False)
tests = db.relationship('Test', backref='dataset')
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
date = db.Column(db.DateTime, nullable=False)
default = db.Column(db.Boolean, default=False, nullable=True)
accessed = db.Column(db.DateTime, nullable=True)
locked = db.Column(db.Boolean, default=False, nullable=True)
def __repr__(self):
return f'<Dataset {self.id}>.'
@property
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
generate_id.setter
def generate_id(self): self.id = uuid4().hex
@property
def set_name(self): raise AttributeError('set_name is not a readable attribute.')
set_name.setter
def set_name(self, name:str): self.name = encrypt(name)
def get_name(self): return decrypt(self.name)
def make_default(self):
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.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']))
data = Path(app.config.get('DATA'))
file_path = path.join(data, 'questions', filename)
remove(file_path)
db.session.delete(self)
db.session.commit()
return True, 'Dataset deleted.'
def create(self, data:list, default:bool=False):
self.generate_id()
timestamp = datetime.now()
file_path = self.get_file()
with open(file_path, 'w') as file:
dump(data, file, indent=2)
self.date = timestamp
self.creator = current_user
if default: self.make_default()
write('system.log', f'New dataset {self.get_name()} added by {current_user.get_username()}.')
db.session.add(self)
db.session.commit()
return True, 'Dataset created.'
def check_file(self):
filename = secure_filename('.'.join([self.id,'json']))
data = Path(app.config.get('DATA'))
file_path = path.join(data, 'questions', filename)
if not path.isfile(file_path): return False, 'Data file is missing.'
return True, 'Data file found.'
def get_file(self):
filename = secure_filename('.'.join([self.id,'json']))
data = Path(app.config.get('DATA'))
file_path = path.join(data, 'questions', filename)
return file_path
def update(self, data:list=None, default:bool=False):
self.date = datetime.now()
if default: self.make_default()
file_path = self.get_file()
with open(file_path, 'w') as file:
dump(data, file, indent=2)
write('system.log', f'Dataset {self.id} edited by {current_user.get_username()}.')
flash(f'Dataset {self.name} successfully edited.', 'success')
db.session.add(self)
db.session.commit()
return True, 'Dataset successfully edited.'

View File

@ -0,0 +1,177 @@
from ..extensions 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)

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

@ -0,0 +1,110 @@
from ..extensions import db
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.'

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

@ -0,0 +1,226 @@
from ..extensions 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:
for entry in User.query.all():
if entry.get_email() == email and not entry == self: return False, f'The email address {email} is already in use.'
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.'

View File

@ -4,9 +4,13 @@ body {
font-size: 14pt;
}
#cookie-alert {
padding-right: 16px;
}
#dismiss-cookie-alert {
margin-top: 16px;
width: 100%;
width: fit-content;
}
.button-container {

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

@ -15,6 +15,10 @@ $("input[name='bg-select']").change(function(){
set_bg_colour($choice);
});
$(".bg-select-area").click(function(event){
$(this).find("input[name='bg-select']").prop("checked", true).change();
});
$("#btn-toggle-navigator").click(function(event){
check_answered();
update_navigator();
@ -84,7 +88,7 @@ $(".btn-dummy").click(function(event){
$("#navigator-container").on("click", ".q-navigator-button", function(event){
check_answered();
update_navigator();
current_question = parseInt($(this).attr("name"));
current_question = parseInt($(this).prop("name"));
$quiz_navigator.fadeOut();
$quiz_render.fadeIn();
$question_title.focus();
@ -99,16 +103,16 @@ $("#navigator-container").on("click", ".q-navigator-button", function(event){
$(".q-question-nav").click(function(event){
check_answered();
update_navigator();
if ($(this).attr("id") == "q-nav-next") {
if ($(this).prop("id") == "q-nav-next") {
if (current_question < questions.length) {
current_question ++;
}
} else if ($(this).attr("id") == "q-nav-prev") {
} else if ($(this).prop("id") == "q-nav-prev") {
if (current_question > 0) {
current_question --;
}
} else if ($(this).hasClass("q-navigator-button")) {
current_question = $(this).attr("name");
current_question = $(this).prop("name");
$quiz_render.fadeIn();
$quiz_navigator.fadeOut();
toggle_navigator = false;
@ -123,11 +127,11 @@ $("#q-nav-flag").click(function(event){
if (question_status[current_question] != 1) {
question_status[current_question] = 1;
$(this).removeClass().addClass("btn btn-warning");
$(this).attr("title", "Question Flagged for revision. Click to un-flag.");
$(this).prop("title", "Question Flagged for revision. Click to un-flag.");
} else {
question_status[current_question] = 0;
$(this).removeClass().addClass("btn btn-secondary");
$(this).attr("title", "Question Un-Flagged. Click to flag for revision.");
$(this).prop("title", "Question Un-Flagged. Click to flag for revision.");
}
window.localStorage.setItem('question_status', JSON.stringify(question_status));
update_navigator();
@ -139,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();
@ -185,8 +189,8 @@ $("#btn-start-quiz").click(function(event){
});
$("#quiz-question-options").on("change", ".quiz-option", function(event){
$name = parseInt($(this).attr("name"));
$value = $(this).attr("value");
$name = parseInt($(this).prop("name"));
$value = $(this).prop("value");
answers[$name] = $value;
window.localStorage.setItem('answers', JSON.stringify(answers));
});
@ -219,7 +223,7 @@ $("#q-review-answers").click(function(event){
$(".quiz-button-submit").click(function(event){
let submission = {
'_id': _id,
'id': id,
'answers': answers
}
@ -347,7 +351,13 @@ function render_question() {
if ('block_q_no' in question) {
block_q_no = question['block_q_no'];
}
header_text = header_text.replace('<block_remaining>', (block_length - block_q_no).toString());
let remaining_qs = (block_length - block_q_no).toString();
if (block_length - block_q_no > 1) {
remaining_qs += ' questions';
} else {
remaining_qs += ' question';
}
header_text = header_text.replace('<block_remaining_questions>', remaining_qs);
$question_header.html(header_text);
$question_text.html(question.text);
$question_title.html(`Question ${current_question + 1} of ${ questions.length }.`);
@ -358,13 +368,13 @@ function render_question() {
for (let i = 0; i < options.length; i ++) {
var add_checked = ''
if (q_no in answers) {
if (answers[q_no] == options[i]) {
if (answers[q_no] == options[i][0]) {
add_checked = 'checked';
}
}
options_output += `<div class="form-check">
<input type="radio" class="form-check-input quiz-option" id="q${current_question}-${i}" name="${q_no}" value="${options[i]}" ${add_checked}>
<label for="q${current_question}-${i}" class="form-check-label">${options[i]}</label>
<input type="radio" class="form-check-input quiz-option" id="q${current_question}-${i}" name="${q_no}" value="${options[i][0]}" ${add_checked}>
<label for="q${current_question}-${i}" class="form-check-label">${options[i][1]}</label>
</div>`;
}
$question_options.html(options_output);
@ -372,8 +382,8 @@ function render_question() {
let answered = count_questions(2);
let flagged = count_questions(1);
$progress_skipped.attr('title', `Skipped: ${skipped}`);
$progress_skipped.attr('aria-valuenow', skipped);
$progress_skipped.prop('title', `Skipped: ${skipped}`);
$progress_skipped.prop('aria-valuenow', skipped);
$progress_skipped.css('width', `${skipped}%`);
$skipped_count.text(`Skipped: ${skipped}`);
if (skipped < 1) {
@ -382,8 +392,8 @@ function render_question() {
$skipped_count.fadeIn()
}
$progress_flagged.attr('title', `Flagged: ${flagged}`);
$progress_flagged.attr('aria-valuenow', flagged);
$progress_flagged.prop('title', `Flagged: ${flagged}`);
$progress_flagged.prop('aria-valuenow', flagged);
$progress_flagged.css('width', `${flagged}%`);
$flagged_count.text(`Flagged: ${flagged}`);
if (flagged < 1) {
@ -392,8 +402,8 @@ function render_question() {
$flagged_count.fadeIn()
}
$progress_answered.attr('title', `Answered: ${answered}`);
$progress_answered.attr('aria-valuenow', answered);
$progress_answered.prop('title', `Answered: ${answered}`);
$progress_answered.prop('aria-valuenow', answered);
$progress_answered.css('width', `${answered}%`);
$answered_count.text(`Answered: ${answered}`);
if (answered < 1) {
@ -427,19 +437,19 @@ function check_flag() {
switch (question_status[current_question]) {
case -1:
$nav_flag.removeClass().addClass('btn btn-danger progress-bar-striped');
$nav_flag.attr("title", "Question Incomplete. Click to flag for revision.");
$nav_flag.prop("title", "Question Incomplete. Click to flag for revision.");
break;
case 1:
$nav_flag.removeClass().addClass('btn btn-warning');
$nav_flag.attr("title", "Question Flagged for revision. Click to un-flag.");
$nav_flag.prop("title", "Question Flagged for revision. Click to un-flag.");
break;
case 2:
$nav_flag.removeClass().addClass('btn btn-success');
$nav_flag.attr("title", "Question Answered. Click to flag for revision.");
$nav_flag.prop("title", "Question Answered. Click to flag for revision.");
break;
default:
$nav_flag.removeClass().addClass('btn btn-secondary');
$nav_flag.attr("title", "Question Un-Flagged. Click to flag for revision.");
$nav_flag.prop("title", "Question Un-Flagged. Click to flag for revision.");
}
}
@ -480,19 +490,19 @@ function update_navigator() {
switch (question_status[current_question]) {
case -1:
button.removeClass().addClass("q-navigator-button btn btn-danger progress-bar-striped");
button.attr("title", `Question ${current_question + 1}: Incomplete`);
button.prop("title", `Question ${current_question + 1}: Incomplete`);
break;
case 1:
button.removeClass().addClass("q-navigator-button btn btn-warning");
button.attr("title", `Question ${current_question + 1}: Flagged`);
button.prop("title", `Question ${current_question + 1}: Flagged`);
break;
case 2:
button.removeClass().addClass("q-navigator-button btn btn-success");
button.attr("title", `Question ${current_question + 1}: Answered`);
button.prop("title", `Question ${current_question + 1}: Answered`);
break;
default:
button.removeClass().addClass("q-navigator-button btn btn-secondary disabled");
button.attr("title", `Question ${current_question + 1}: Unseen`);
button.prop("title", `Question ${current_question + 1}: Unseen`);
}
}
}
@ -597,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()
},

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