Compare commits

...

803 Commits

Author SHA1 Message Date
502e694a17 Merge branch 'development' 2023-10-20 20:59:35 +01:00
d28cd6daed Updated instructions for the test 2023-10-20 20:58:50 +01:00
58782f6db7 Merge branch 'development' 2023-07-01 21:49:21 +01:00
57b25cd214 Formatted DataTable date to ISO-8601 for sorting 2023-07-01 21:48:36 +01:00
666e12253e Merge branch 'development' 2023-07-01 21:33:04 +01:00
8013a776a9 Merge branch 'development' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test into development 2023-07-01 21:26:24 +01:00
aa1f46ee62 Bugfix: response to invalid exam codes 2023-07-01 21:24:29 +01:00
dbd8d6bbe3 Serve static files for analysis directly 2023-03-07 11:38:04 +00:00
fed46eaa1e Bugfix: stdev exception when only one test 2023-03-07 11:21:41 +00:00
79ad96a93f Added additional statistics and improvements to UI 2023-03-05 03:05:42 +00:00
ba851cb7dc Added analysis UI 2023-03-05 00:33:15 +00:00
fcc4d55947 Delete redundant lines 2023-03-05 00:32:15 +00:00
a56358b8dd Added question parser for analysis 2023-03-05 00:31:33 +00:00
179a608089 Made randomising of question order optional 2023-03-05 00:29:25 +00:00
a1289da09c Added analysis button and scripting 2023-03-05 00:28:54 +00:00
ea86fd9ae6 Updated parameter for new library version 2023-03-05 00:27:30 +00:00
76d60546e2 Cleared whitespace 2023-03-05 00:27:03 +00:00
9a02048199 Added get_file method to datasets 2023-03-05 00:26:39 +00:00
c9ad8e87cd Bugfix: rendering htm elements in results page 2023-03-04 19:48:49 +00:00
3714919ba5 Add analysis function 2023-03-04 18:56:34 +00:00
1026cc71a9 Add dataset to entry on creation 2023-03-04 18:56:10 +00:00
07fb170656 Add dataset and entry relation to database models 2023-03-04 18:55:30 +00:00
1ea93994ab Load and register analysis module blueprints 2023-03-04 18:54:47 +00:00
607b132996 Removed passing of unused values 2023-03-04 18:54:02 +00:00
7aa4d81e65 Corrected indentation 2023-03-04 18:53:02 +00:00
0ef39dcfbe Update navbars 2023-03-04 18:52:13 +00:00
e1517b89c0 Add analysis module 2023-03-04 18:51:50 +00:00
d0ed228824 Updated libraries 2023-03-04 18:49:35 +00:00
a2c52a4261 Corrected value to id property of entry 2023-02-28 20:59:12 +00:00
b2c9bdd7d2 Removed CSRF time limit 2023-02-28 20:37:23 +00:00
7536c33a48 Tidied up form. Removed help text. 2023-02-03 17:08:50 +00:00
850c2b13b7 Data use disclaimer for UI choices 2023-02-03 17:02:32 +00:00
eb69979f59 Spelling consistency advice 2023-02-03 17:02:16 +00:00
95cea46a8f Merge branch 'master' into development 2023-02-03 16:31:41 +00:00
02a1129390 Adding jquery ui css
Nesting script inside jquery function call
2023-02-03 16:26:31 +00:00
438e09f1ec Bugfix: club field selector 2023-02-03 16:15:50 +00:00
9241e1c0f7 Added club suggestion auto-complete 2023-02-03 16:06:06 +00:00
8deefb9035 Bugfix: displaying scores for incomplete entries 2023-02-02 22:44:19 +00:00
4f2984deea Bugfix: exception for incomplete entry dates 2023-02-02 22:38:49 +00:00
70d2325579 Bugfix: datetime reference 2023-02-02 22:26:19 +00:00
36d840c752 Typo 2023-02-02 22:16:29 +00:00
4400446718 Bugfix: Sorting for empty dates 2023-02-02 22:16:09 +00:00
adead30a77 Updated privacy policy 2022-11-01 08:51:03 +00:00
487f24732d Copy edit privacy notice. 2022-11-01 08:50:46 +00:00
3c06cebddf Updated credit in footer to identify maintainer. 2022-11-01 08:48:54 +00:00
d1d52fa4b6 source /home/vivek/Git/ska-referee-test/ref-test/env/bin/activateMerge branch 'development' 2022-09-13 12:05:37 +01:00
80dc8b3cff Fixed docker-compose depends_on mappings 2022-09-13 12:03:40 +01:00
a9ccd64de2 Updated dependency list 2022-09-13 11:17:17 +01:00
f5b9758bb1 Removed unused imports 2022-09-13 11:17:03 +01:00
84570d5974 Added indices to various database fields 2022-09-13 11:01:28 +01:00
edb8241ad3 Removed the word Beta from site title 2022-09-13 11:00:53 +01:00
644a539ed9 Changed to conventional extension for sqlite db 2022-09-13 11:00:07 +01:00
f05568b0de source /home/vivek/Git/ska-referee-test/ref-test/env/bin/activateMerge branch 'development' 2022-08-27 09:44:07 +01:00
da4a3e41c6 Bugfix: Wrong account password for updating user 2022-08-27 09:42:48 +01:00
77f86f7102 Bugfix: Corrected dataset name in test editor 2022-08-23 11:03:18 +01:00
358695977f Docker compose glitches 2022-08-20 18:21:52 +01:00
ddfd75c1f8 Added selecting database to Readme 2022-08-20 17:46:45 +01:00
f4642767ac Tweaking formatting of docker-compose file 2022-08-20 17:28:45 +01:00
2f729de40b mysql compose 2022-08-20 17:25:07 +01:00
d68beb938f Tweaking docker-compose 2022-08-20 17:21:21 +01:00
ca667f7896 Create database before first request 2022-08-20 16:51:13 +01:00
0cc00ef911 Updated install script to only create SQLite file 2022-08-20 16:50:34 +01:00
5ec2a86d08 Added certbot directory for nginx to serve renewal 2022-08-20 15:46:19 +01:00
cd57eca7d3 Restructure install script 2022-08-20 15:40:41 +01:00
a46338fdcb Update gitignore and dockerignore 2022-08-20 15:39:50 +01:00
40f1cebb7b Unsaved files 2022-08-20 14:58:31 +01:00
2a6478f3cf Clean up unnecessary exception imports 2022-08-20 14:53:49 +01:00
b6e250a7cd Generate random root password for MySQL 2022-08-20 14:48:56 +01:00
bcee2eedd0 Generalise exception handling 2022-08-20 14:47:46 +01:00
d9837246de Updated SQL Json support 2022-08-20 13:01:32 +01:00
62fac48904 Making logs accessible from install root 2022-08-20 13:00:09 +01:00
2bf0eeb33d Bugfix: variable definition for different actions 2022-08-20 12:59:26 +01:00
72f2af1df8 Include connection errors in exception handling 2022-08-20 12:58:47 +01:00
168b2b288a Added mysql-related database variables
Added options for different database engines
2022-08-20 12:01:08 +01:00
9a5f69f889 Added database-related env variables 2022-08-20 11:59:33 +01:00
7d6f256392 Added PyMySQL driver dependency 2022-08-20 11:59:02 +01:00
866c9b10cf Exception handling for database queries 2022-08-20 10:56:43 +01:00
b8fd65d856 Added command line password reset tool. 2022-08-19 15:29:27 +01:00
5490bd083f Make reset script executable during image creation 2022-08-19 15:28:27 +01:00
3cb78055ff Added check for password reset from command line 2022-08-19 15:28:05 +01:00
f9d85a8028 Updated .env variable for future Flask versions
FLASK_ENV has been deprecated
2022-08-19 15:27:25 +01:00
4f193e7fa5 Corrected password length prompt 2022-08-19 15:26:51 +01:00
df3149abba Exception to cookie consent check for view/static 2022-08-19 13:29:29 +01:00
7ab87c2966 Exception handling for database commit operations 2022-08-19 13:25:20 +01:00
f4f501def5 Deleted redundant line 2022-08-19 13:24:54 +01:00
1c57950558 Exception handling and logging for SMTP errors
Should mitigate internal server errors if SMTP server fails.
2022-08-19 12:07:38 +01:00
f132cdbeef Updated dependencies 2022-08-19 12:03:20 +01:00
0387c05055 Updated readme formatting 2022-08-19 12:02:54 +01:00
552b2ffc47 Updating some of the references, deleting old ones 2022-08-19 11:17:19 +01:00
a2e859af5d Tidied up file mounting locations and server alias 2022-08-18 17:17:56 +01:00
81b09190de Corrected erroneous mounting 2022-08-18 17:14:15 +01:00
ed100ee9e5 Added server directive for root folder 2022-08-18 17:08:03 +01:00
5dc6c4998d Corrected typo 2022-08-18 17:07:48 +01:00
0d68233d41 Update Dockerfile mounting,fix Nginx config typo 2022-08-18 17:00:51 +01:00
4caac25b14 Merge remote-tracking branch 'refs/remotes/origin/master' 2022-08-18 16:33:42 +01:00
3defe020f5 Bugfix: Dockerfile mounting static directory 2022-08-18 16:32:27 +01:00
f14085f4c1 Typo correction 2022-08-17 16:38:31 +01:00
be5343a4bd Added decorator to test availability of datasets
Used decorator tool to validate dataset exists on views
2022-08-17 16:37:03 +01:00
2da8eb7712 Added cross-reference to question viewer
Changed question number countint to be consistent with viewer
2022-08-17 16:36:16 +01:00
3a0abaac6a Stylistic change of name dataset to questions 2022-08-17 16:35:22 +01:00
b15f76701e Code clean up: redundant semicolons
Made variable declaration style in for loops consistent
2022-08-17 16:34:59 +01:00
02290e968c Added question viewer functionality
Added view questions panel to editor interface
Added view questions section of web site
Added links to navbars
2022-08-17 16:32:58 +01:00
294f1e42f7 Added timezone env variable 2022-08-11 17:31:20 +01:00
070ce19fcc Added instructions on updating 2022-08-11 17:19:05 +01:00
615e59fc6d Updated form error handling 2022-08-11 16:58:00 +01:00
68314a4ed2 Add handling of anonymous user when updating account 2022-08-11 16:28:47 +01:00
b90761fd2c Simplify variabe nale 2022-08-11 16:28:13 +01:00
af03193217 Change user variable name 2022-08-11 16:13:31 +01:00
730a75c44d Bugfix: reset password 2022-08-11 16:05:28 +01:00
70883db5ad Changed dockerignore stricture 2022-08-11 13:09:41 +01:00
7cefb487da Bugfix: reset password 2022-08-11 13:09:34 +01:00
2e1b01ec9b Bugfix: reset password 2022-08-11 13:02:55 +01:00
a7a5a03991 Consistency in paths for templates 2022-08-11 13:02:41 +01:00
b36c6bfd18 Bugfix: reset password 2022-08-11 12:51:17 +01:00
a613b0006b Bugfix: password reset 2022-08-11 12:44:42 +01:00
d4db8692e7 Remove debug line 2022-08-11 12:13:33 +01:00
37ad36da31 Add debug for email reset 2022-08-11 11:42:50 +01:00
d140f93d25 Bugfix: hude club field when empty 2022-08-11 11:41:57 +01:00
26a6248a61 Tidied up nnecessary imports 2022-08-11 11:39:53 +01:00
9f8ea16974 Bugfix: button display 2022-08-11 11:01:29 +01:00
bc5ec44145 Bugfix default datetime 2022-08-11 10:59:12 +01:00
ff5b19fa0b Editing text: remove repetition 2022-08-11 10:24:15 +01:00
6c50be49c6 Bugfix: default time for exam creation 2022-08-11 10:23:40 +01:00
8bfe028e2c Make certbot silent 2022-06-22 15:05:56 +01:00
519394a656 Store data in docker volume instead of project dir 2022-06-22 15:05:44 +01:00
9e1c9caec6 Updated config to have defaults for keys
Removed abstraction of data location for image build
2022-06-22 11:56:36 +01:00
ea850c9ae2 Added defaults for config keys to avoid exceptions 2022-06-22 11:45:37 +01:00
591b868920 Separated install script to avoid launch errors 2022-06-22 11:20:30 +01:00
91dc93758a Added nginx static serving editor files 2022-06-22 11:18:53 +01:00
5d27baee08 Editor flash message bugfix 2022-06-22 10:46:43 +01:00
1254cf3698 Bugfix install script dhparam 2022-06-22 09:55:59 +01:00
efab086057 Gitignore bugfix 2022-06-22 09:31:16 +01:00
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
b27016aaf4 Progress
Problems with database access and subdirectories still
2022-06-11 13:26:50 +01:00
6992a75855 Name correction 2022-06-11 11:33:06 +01:00
85ced0cc20 Progress 2022-06-11 11:29:15 +01:00
fcfde34c72 Restore 2022-06-11 02:56:38 +01:00
436c8e0e2d Started from scratch and failed
Issue with register_blueprint
2022-06-11 02:39:47 +01:00
7af588da6c Added new files 2022-06-10 22:11:29 +01:00
cfd750894a Whitespace corrections 2022-04-17 18:42:40 +01:00
ede71f7d82 Bugfix change event not triggering 2021-12-08 13:25:50 +00:00
27706572ed Added click area to select background colour 2021-12-08 13:21:18 +00:00
08da6d71c4 update function call from attr to prop 2021-12-08 13:20:40 +00:00
c5a0bbb827 Local jQuery library fallback 2021-12-08 13:20:07 +00:00
8680c73e86 Changed answer option object semantics to indices
Evaluating answer should no longer require string matching
Answers evaluated based on matching index value integers
2021-12-08 12:49:32 +00:00
ff74e92297 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-08 12:46:49 +00:00
6b3b255cfd Only show topics to revise if failed 2021-12-08 12:46:33 +00:00
ecdb5df561 Merge 2021-12-08 11:33:27 +00:00
c5b4d948f5 Removed personal information 2021-12-08 11:29:22 +00:00
c40ef7d070 Removed personal information 2021-12-08 11:27:54 +00:00
b8081bc1c8 Cookie bugfix, removed 'session' string from expiry/age 2021-12-08 11:26:18 +00:00
efec599225 Debug form error handlers 2021-12-07 16:17:59 +00:00
614ad91e3d Debug form error handlers 2021-12-07 16:17:59 +00:00
6605620d9c Named image 2021-12-07 16:03:56 +00:00
cd4d52692c Named image 2021-12-07 16:03:56 +00:00
2038965dcb Bug fix and data persistence 2021-12-07 15:52:58 +00:00
b4c94a7ddb Bug fix and data persistence 2021-12-07 15:52:58 +00:00
f144097c5d Bugfix: security key location 2021-12-07 15:25:22 +00:00
63f72e35d2 Bugfix: security key location 2021-12-07 15:25:22 +00:00
57ee0bf971 Bugfix: encryption lockout 2021-12-07 15:15:16 +00:00
735cdec139 Bugfix: encryption lockout 2021-12-07 15:15:16 +00:00
8591184da6 Bug fix: evaluating question 97 2021-12-07 15:03:21 +00:00
38d3420e4d Bug fix: evaluating question 97 2021-12-07 15:03:21 +00:00
7b5861ade6 Merge 2021-12-07 13:37:12 +00:00
f0437dceaa Merge 2021-12-07 13:37:12 +00:00
fa4640840b Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 13:34:02 +00:00
ca30b002ed Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 13:34:02 +00:00
05a564f41d Typo 2021-12-07 13:33:31 +00:00
7b2f155b14 Typo 2021-12-07 13:33:31 +00:00
f9628df8c7 Some more minor fixes to containerising and ignore 2021-12-07 13:29:25 +00:00
a10bb0384f Some more minor fixes to containerising and ignore 2021-12-07 13:29:25 +00:00
b5443c1331 Re-wrote compose and conf removing personal info 2021-12-07 13:26:24 +00:00
fe83a47dae Re-wrote compose and conf removing personal info 2021-12-07 13:26:24 +00:00
3d7e144d12 Finesse log in form css 2021-12-07 12:39:29 +00:00
3c9fcae9f8 Finesse log in form css 2021-12-07 12:39:29 +00:00
d093c4e636 Finesse log in form css 2021-12-07 12:39:29 +00:00
1d5dfaa5ee Finesse log in form css 2021-12-07 12:39:29 +00:00
57f233f20f Finessed remaining block question counter 2021-12-07 12:27:06 +00:00
a35d0ef7f1 Finessed remaining block question counter 2021-12-07 12:27:06 +00:00
4a5bc48889 Finessed remaining block question counter 2021-12-07 12:27:06 +00:00
0bdd50f432 Finessed remaining block question counter 2021-12-07 12:27:06 +00:00
f2fb52aeca Correcting an error 2021-12-07 07:24:39 +00:00
52afd249b7 Correcting an error 2021-12-07 07:24:39 +00:00
4a8080f0c8 Correcting an error 2021-12-07 07:24:39 +00:00
443568f8ff Correcting an error 2021-12-07 07:24:39 +00:00
5ab2e7e608 Finesse cookie dismiss button 2021-12-07 07:22:11 +00:00
7b1ae3b354 Finesse cookie dismiss button 2021-12-07 07:22:11 +00:00
bae8d1e6f8 Finesse cookie dismiss button 2021-12-07 07:22:11 +00:00
36ed23564d Finesse cookie dismiss button 2021-12-07 07:22:11 +00:00
4585b93136 Finesse cookie consent display 2021-12-07 07:09:28 +00:00
14272ba0b8 Finesse cookie consent display 2021-12-07 07:09:28 +00:00
0130f7412d Finesse cookie consent display 2021-12-07 07:09:28 +00:00
8b4ca65122 Finesse cookie consent display 2021-12-07 07:09:28 +00:00
f3f8ac955c removed fake link 2021-12-07 07:04:58 +00:00
8bfc8e119c removed fake link 2021-12-07 07:04:58 +00:00
0ccb62ce3c removed fake link 2021-12-07 07:04:58 +00:00
2507a1d00b removed fake link 2021-12-07 07:04:58 +00:00
fed4b6739f Fixed subject-verb disagreement 2021-12-07 07:00:44 +00:00
dd22b51fe1 Fixed subject-verb disagreement 2021-12-07 07:00:44 +00:00
f2b261f0b0 Fixed subject-verb disagreement 2021-12-07 07:00:44 +00:00
526d940c54 Fixed subject-verb disagreement 2021-12-07 07:00:44 +00:00
485e51f239 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 06:55:50 +00:00
9f4e9637c9 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 06:55:50 +00:00
1adb4867d5 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 06:55:50 +00:00
55aa5496db Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-07 06:55:50 +00:00
b7ef513870 Technical issues help email 2021-12-07 06:46:34 +00:00
331e49a6bc Technical issues help email 2021-12-07 06:46:34 +00:00
2027e525e2 Technical issues help email 2021-12-07 06:46:34 +00:00
59fc703bcb Technical issues help email 2021-12-07 06:46:34 +00:00
c466f06384 Remove personal data from document 2021-12-07 06:42:46 +00:00
8d80666ed8 Remove personal data from document 2021-12-07 06:42:46 +00:00
3d9a3ecdff Remove personal data from document 2021-12-07 06:42:46 +00:00
a8e938e802 Remove personal data from document 2021-12-07 06:42:46 +00:00
4c4927df31 Removed email address 2021-12-07 06:40:57 +00:00
f8126b42fe Removed email address 2021-12-07 06:40:57 +00:00
407ee49bff Removed email address 2021-12-07 06:40:57 +00:00
b0bb600e12 Removed email address 2021-12-07 06:40:57 +00:00
0e8fbf148a Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test
Sync
2021-12-07 06:38:54 +00:00
0ef72ec338 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test
Sync
2021-12-07 06:38:54 +00:00
721af501d1 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test
Sync
2021-12-07 06:38:54 +00:00
e6f1338ee4 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test
Sync
2021-12-07 06:38:54 +00:00
0e50e2c1b9 Started drafting documentation 2021-12-07 06:38:43 +00:00
b0980b1871 Started drafting documentation 2021-12-07 06:38:43 +00:00
ea9132542f Started drafting documentation 2021-12-07 06:38:43 +00:00
b7fb30ce36 Started drafting documentation 2021-12-07 06:38:43 +00:00
fe75fa1a49 Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
f86fa6f4b5 Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
6c293c2ce6 Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
d3ed32183c Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
e8090f30d7 Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
176a0f069f Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
302d8a933a Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
c5587fcb73 Better precision in expiring quizzes 2021-12-07 00:08:01 +00:00
a4b4bfe0ee Updated test expiry 2021-12-06 23:37:16 +00:00
0faef8651a Updated test expiry 2021-12-06 23:37:16 +00:00
4f925eae2f Updated test expiry 2021-12-06 23:37:16 +00:00
a9f5ba51c4 Updated test expiry 2021-12-06 23:37:16 +00:00
5b0fd0ced3 Updated test expiry 2021-12-06 23:37:16 +00:00
eca786d444 Updated test expiry 2021-12-06 23:37:16 +00:00
affb309ffc Updated test expiry 2021-12-06 23:37:16 +00:00
0e1db9d21d Updated test expiry 2021-12-06 23:37:16 +00:00
003d998b72 Corrected bug in exam display 2021-12-06 23:24:57 +00:00
dccc85370e Corrected bug in exam display 2021-12-06 23:24:57 +00:00
355a6bff5e Corrected bug in exam display 2021-12-06 23:24:57 +00:00
98638e803a Corrected bug in exam display 2021-12-06 23:24:57 +00:00
6c4ab2e1e3 Corrected bug in exam display 2021-12-06 23:24:57 +00:00
e13069bed6 Corrected bug in exam display 2021-12-06 23:24:57 +00:00
5b6f83c294 Corrected bug in exam display 2021-12-06 23:24:57 +00:00
7295a2751c Corrected bug in exam display 2021-12-06 23:24:57 +00:00
dd72da6ae6 I am bad at debugging. 2021-12-06 23:19:13 +00:00
36cdeb15ad I am bad at debugging. 2021-12-06 23:19:13 +00:00
eb6f5b876c I am bad at debugging. 2021-12-06 23:19:13 +00:00
14500434d7 I am bad at debugging. 2021-12-06 23:19:13 +00:00
35dffd358b I am bad at debugging. 2021-12-06 23:19:13 +00:00
fafb3fcc2e I am bad at debugging. 2021-12-06 23:19:13 +00:00
4131dd054a I am bad at debugging. 2021-12-06 23:19:13 +00:00
f370496780 I am bad at debugging. 2021-12-06 23:19:13 +00:00
667ad4ebc2 Close Quiz function 2021-12-06 23:16:33 +00:00
52e3ce4c93 Close Quiz function 2021-12-06 23:16:33 +00:00
ca0e6c82cb Close Quiz function 2021-12-06 23:16:33 +00:00
860c18c5fd Close Quiz function 2021-12-06 23:16:33 +00:00
46cef8cd1e Close Quiz function 2021-12-06 23:16:33 +00:00
421445d8d5 Close Quiz function 2021-12-06 23:16:33 +00:00
b0d3ff3fc1 Close Quiz function 2021-12-06 23:16:33 +00:00
68aef968e2 Close Quiz function 2021-12-06 23:16:33 +00:00
8d29944d5d Remove redundant file 2021-12-06 22:54:40 +00:00
8fbb52d366 Remove redundant file 2021-12-06 22:54:40 +00:00
1dbe4215ec Remove redundant file 2021-12-06 22:54:40 +00:00
101f6786f5 Remove redundant file 2021-12-06 22:54:40 +00:00
fe5cf189cc Remove redundant file 2021-12-06 22:54:40 +00:00
cefb5fe849 Remove redundant file 2021-12-06 22:54:40 +00:00
f0c7873257 Remove redundant file 2021-12-06 22:54:40 +00:00
0cb8ff9991 Remove redundant file 2021-12-06 22:54:40 +00:00
4d77021d58 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
fa05a17508 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
5960d0103d Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
3535622380 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
86abae01c0 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
7c2adc9cac Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
e119c344dd Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
c7b54d2119 Merge branch 'master' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test 2021-12-06 22:54:02 +00:00
e6841b7744 This fixes it, hopefully 2021-12-06 22:47:54 +00:00
6835232698 This fixes it, hopefully 2021-12-06 22:47:54 +00:00
5392ff86ed This fixes it, hopefully 2021-12-06 22:47:54 +00:00
328a78a923 This fixes it, hopefully 2021-12-06 22:47:54 +00:00
9810577c5d This fixes it, hopefully 2021-12-06 22:47:54 +00:00
2c93b0d3a7 This fixes it, hopefully 2021-12-06 22:47:54 +00:00
343cb3f8b1 This fixes it, hopefully 2021-12-06 22:47:54 +00:00
961e8629cb This fixes it, hopefully 2021-12-06 22:47:54 +00:00
378e8eeae3 And again 2021-12-06 22:26:48 +00:00
fe898aaf7d And again 2021-12-06 22:26:48 +00:00
a010d7d290 And again 2021-12-06 22:26:48 +00:00
8b962c53a9 And again 2021-12-06 22:26:48 +00:00
bceb91b406 And again 2021-12-06 22:26:48 +00:00
a14b7bf305 And again 2021-12-06 22:26:48 +00:00
3622baf988 And again 2021-12-06 22:26:48 +00:00
24545feea0 And again 2021-12-06 22:26:48 +00:00
bb9233eeae Trying to fix it again 2021-12-06 22:24:34 +00:00
60b8aad419 Trying to fix it again 2021-12-06 22:24:34 +00:00
6e541c6a7b Trying to fix it again 2021-12-06 22:24:34 +00:00
685b1b928d Trying to fix it again 2021-12-06 22:24:34 +00:00
e0c2570515 Trying to fix it again 2021-12-06 22:24:34 +00:00
5163914875 Trying to fix it again 2021-12-06 22:24:34 +00:00
467b6d9ce7 Trying to fix it again 2021-12-06 22:24:34 +00:00
e5aab6268d Trying to fix it again 2021-12-06 22:24:34 +00:00
383ae11cd3 I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
348ee95d1c I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
9db80c9148 I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
20b447adbb I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
669bbd2f7b I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
22b483b021 I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
21ad8b2f94 I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
a3a13d4eb6 I am really bad at fixing bugs 2021-12-06 22:20:27 +00:00
a357ffe28d More Bug Fixes 2021-12-06 22:17:52 +00:00
e00e2b17b0 More Bug Fixes 2021-12-06 22:17:52 +00:00
65d679afbb More Bug Fixes 2021-12-06 22:17:52 +00:00
891ec2fd38 More Bug Fixes 2021-12-06 22:17:52 +00:00
4be21a2ca2 More Bug Fixes 2021-12-06 22:17:52 +00:00
efd4dc440d More Bug Fixes 2021-12-06 22:17:52 +00:00
935b465a19 More Bug Fixes 2021-12-06 22:17:52 +00:00
05fa5bf274 More Bug Fixes 2021-12-06 22:17:52 +00:00
1d1e2acf62 Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
c742edb57c Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
529504509e Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
852b2664ce Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
8b1b0162cc Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
56e5d29416 Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
ee50306370 Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
559e5b96c4 Bugfix: lack of submission time in detailed result 2021-12-06 22:11:16 +00:00
4c2a6e7f74 OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
daaf173ff6 OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
05de6d716b OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
f740ee7f1b OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
c56c0dc822 OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
0c446b9ae7 OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
9ebec5000c OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
ce32b33eaa OG Meta and navbar bug fix 2021-12-06 21:58:51 +00:00
45e0d37f81 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
d353a80269 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
8e7a09edca Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
616bd3f578 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
108297cbfd Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
9e03db595b Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
3bfd08411b Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
a4affa72a9 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 21:53:00 +00:00
12c424be08 OG and Cookie settings 2021-12-06 21:51:29 +00:00
e00b4a9045 OG and Cookie settings 2021-12-06 21:51:29 +00:00
0ad7089722 OG and Cookie settings 2021-12-06 21:51:29 +00:00
707890ce3a OG and Cookie settings 2021-12-06 21:51:29 +00:00
7bdca9b895 OG and Cookie settings 2021-12-06 21:51:29 +00:00
bd1ac46942 OG and Cookie settings 2021-12-06 21:51:29 +00:00
11f965e20f OG and Cookie settings 2021-12-06 21:51:29 +00:00
ee99dd9038 OG and Cookie settings 2021-12-06 21:51:29 +00:00
65ec27b35b Bugfixes: case sensitivity, percent sign rendering
Temporary work-around: disabled client navbar home link to prevent exit
2021-12-06 21:47:02 +00:00
63ca5e33de Bugfixes: case sensitivity, percent sign rendering
Temporary work-around: disabled client navbar home link to prevent exit
2021-12-06 21:47:02 +00:00
1f228c7f1c Bugfixes: case sensitivity, percent sign rendering
Temporary work-around: disabled client navbar home link to prevent exit
2021-12-06 21:47:02 +00:00
56191f5e7a Bugfixes: case sensitivity, percent sign rendering
Temporary work-around: disabled client navbar home link to prevent exit
2021-12-06 21:47:02 +00:00
cbc8d276eb Bugfixes: case sensitivity, percent sign rendering
Temporary work-around: disabled client navbar home link to prevent exit
2021-12-06 21:47:02 +00:00
cd68a60001 Bugfixes: case sensitivity, percent sign rendering
Temporary work-around: disabled client navbar home link to prevent exit
2021-12-06 21:47:02 +00:00
dd7e3cad7a Bugfixes: case sensitivity, percent sign rendering
Temporary work-around: disabled client navbar home link to prevent exit
2021-12-06 21:47:02 +00:00
32908bde7d Bugfixes: case sensitivity, percent sign rendering
Temporary work-around: disabled client navbar home link to prevent exit
2021-12-06 21:47:02 +00:00
835c5e2aa6 Proxy Fix 2021-12-06 20:10:27 +00:00
6823c12b2d Proxy Fix 2021-12-06 20:10:27 +00:00
c7907dc24d Proxy Fix 2021-12-06 20:10:27 +00:00
e4d97869da Proxy Fix 2021-12-06 20:10:27 +00:00
dfbf10e2dd Proxy Fix 2021-12-06 20:10:27 +00:00
dbd25ddf38 Proxy Fix 2021-12-06 20:10:27 +00:00
11d839aada Proxy Fix 2021-12-06 20:10:27 +00:00
3980be3701 Proxy Fix 2021-12-06 20:10:27 +00:00
Vivek Santayana
43cb31849a Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
39cdafc847 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
bdeb026a7c Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
73f4825bbe Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
e1ecb5bcb6 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
1651f63577 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
a01d486d99 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
Vivek Santayana
2b71c77c6c Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Fix config
2021-12-06 19:22:06 +00:00
112c097d69 Updated config 2021-12-06 19:21:45 +00:00
b6af6d5c15 Updated config 2021-12-06 19:21:45 +00:00
6c4ca715f6 Updated config 2021-12-06 19:21:45 +00:00
972673f5d1 Updated config 2021-12-06 19:21:45 +00:00
cb1bc69f47 Updated config 2021-12-06 19:21:45 +00:00
a4058c475b Updated config 2021-12-06 19:21:45 +00:00
0004d2714f Updated config 2021-12-06 19:21:45 +00:00
20efd4444c Updated config 2021-12-06 19:21:45 +00:00
Vivek Santayana
13465859ab Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
53050f1358 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
f025eee4a6 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
506a6cf6c2 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
97db70abff Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
1a1d763d67 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
598dfa45e8 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
Vivek Santayana
ca36772f29 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Pull icons
2021-12-06 18:59:05 +00:00
bd3205f06e Favicons and OG Meta 2021-12-06 18:58:42 +00:00
ab7a25182f Favicons and OG Meta 2021-12-06 18:58:42 +00:00
e3bb2895ae Favicons and OG Meta 2021-12-06 18:58:42 +00:00
3e1e57a067 Favicons and OG Meta 2021-12-06 18:58:42 +00:00
42f90c667d Favicons and OG Meta 2021-12-06 18:58:42 +00:00
b02277f12f Favicons and OG Meta 2021-12-06 18:58:42 +00:00
a9ad171249 Favicons and OG Meta 2021-12-06 18:58:42 +00:00
bc42ae86d1 Favicons and OG Meta 2021-12-06 18:58:42 +00:00
Vivek Santayana
cc3410a1f6 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Download fonts and remove private files
2021-12-06 18:06:47 +00:00
Vivek Santayana
953d3658a8 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Download fonts and remove private files
2021-12-06 18:06:47 +00:00
Vivek Santayana
70f6875ac1 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Download fonts and remove private files
2021-12-06 18:06:47 +00:00
Vivek Santayana
5da08d5c37 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Download fonts and remove private files
2021-12-06 18:06:47 +00:00
Vivek Santayana
534247ece3 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Download fonts and remove private files
2021-12-06 18:06:47 +00:00
Vivek Santayana
9525694e39 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Download fonts and remove private files
2021-12-06 18:06:47 +00:00
Vivek Santayana
31903626f0 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Download fonts and remove private files
2021-12-06 18:06:47 +00:00
Vivek Santayana
0111547676 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test
Download fonts and remove private files
2021-12-06 18:06:47 +00:00
e70592b276 Uploading Fonts 2021-12-06 18:06:11 +00:00
22a0d58996 Uploading Fonts 2021-12-06 18:06:11 +00:00
3d6a1dc7ba Uploading Fonts 2021-12-06 18:06:11 +00:00
51d468fb44 Uploading Fonts 2021-12-06 18:06:11 +00:00
164d43be8b Uploading Fonts 2021-12-06 18:06:11 +00:00
cdf47e0b88 Uploading Fonts 2021-12-06 18:06:11 +00:00
2427d55310 Uploading Fonts 2021-12-06 18:06:11 +00:00
757cc94f33 Uploading Fonts 2021-12-06 18:06:11 +00:00
Vivek Santayana
0cfac25ed3 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
0443e348ac Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
f2c0090aa3 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
ae75498edb Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
7f3e251ac4 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
233e173735 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
c5686fbd40 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
Vivek Santayana
94556d0731 Merge branch 'master' of https://git.vsnt.uk/viveksantayana/ska-referee-test 2021-12-06 18:02:59 +00:00
ccab358464 Correct error 2021-12-06 16:56:54 +00:00
79b0e83eba Correct error 2021-12-06 16:56:54 +00:00
22e163f036 Correct error 2021-12-06 16:56:54 +00:00
511eccac99 Correct error 2021-12-06 16:56:54 +00:00
8ec0967f40 Correct error 2021-12-06 16:56:54 +00:00
ae1380407c Correct error 2021-12-06 16:56:54 +00:00
1e7222c781 Correct error 2021-12-06 16:56:54 +00:00
b65b71df7a Correct error 2021-12-06 16:56:54 +00:00
9a4820c725 Added correct answer view 2021-12-06 13:44:40 +00:00
6c327c7978 Added correct answer view 2021-12-06 13:44:40 +00:00
c730fca3eb Added correct answer view 2021-12-06 13:44:40 +00:00
ba106ff684 Added correct answer view 2021-12-06 13:44:40 +00:00
738f4eae86 Added correct answer view 2021-12-06 13:44:40 +00:00
d114b061b4 Added correct answer view 2021-12-06 13:44:40 +00:00
9b5b97eb1d Added correct answer view 2021-12-06 13:44:40 +00:00
52ab3af1f2 Added correct answer view 2021-12-06 13:44:40 +00:00
79ca8fc932 Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
3a380c9f50 Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
b9bff4812b Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
dedd2d3449 Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
bf7e0a2a18 Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
d34aa82e86 Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
af9b5210fa Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
389fbf99aa Added individual result correct/incorrect flag 2021-12-06 13:42:26 +00:00
1cafa04763 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
bc68089f87 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
9b7a3b3ec0 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
23136b7e40 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
2e4035d8a4 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
7063fe271e Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
8d65b0c089 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
9988a989a6 Added Certbot and Nginx to Docker stack 2021-12-06 13:39:06 +00:00
20e418aeae Nginx Server 2021-12-06 13:29:20 +00:00
9affa657c4 Nginx Server 2021-12-06 13:29:20 +00:00
395ddbd460 Nginx Server 2021-12-06 13:29:20 +00:00
93b8ac40df Nginx Server 2021-12-06 13:29:20 +00:00
09f71fc5a7 Nginx Server 2021-12-06 13:29:20 +00:00
e694119a58 Nginx Server 2021-12-06 13:29:20 +00:00
67bbab0061 Nginx Server 2021-12-06 13:29:20 +00:00
9992138bc4 Nginx Server 2021-12-06 13:29:20 +00:00
f548221a10 Dockerised. Restructured to remove circular import
Moved most of app definitions out of guard function to use wsgi
Updated configuration files and referencing of .env values.
Local version needs dotenv or exporting of env variables.
Dockerised version works fine without load_dotenv.
Ready to test now!
2021-12-05 03:49:31 +00:00
4d883e8dce Dockerised. Restructured to remove circular import
Moved most of app definitions out of guard function to use wsgi
Updated configuration files and referencing of .env values.
Local version needs dotenv or exporting of env variables.
Dockerised version works fine without load_dotenv.
Ready to test now!
2021-12-05 03:49:31 +00:00
92e2462bb9 Dockerised. Restructured to remove circular import
Moved most of app definitions out of guard function to use wsgi
Updated configuration files and referencing of .env values.
Local version needs dotenv or exporting of env variables.
Dockerised version works fine without load_dotenv.
Ready to test now!
2021-12-05 03:49:31 +00:00
6ea02c28d4 Dockerised. Restructured to remove circular import
Moved most of app definitions out of guard function to use wsgi
Updated configuration files and referencing of .env values.
Local version needs dotenv or exporting of env variables.
Dockerised version works fine without load_dotenv.
Ready to test now!
2021-12-05 03:49:31 +00:00
05a8a78ed9 Dockerised. Restructured to remove circular import
Moved most of app definitions out of guard function to use wsgi
Updated configuration files and referencing of .env values.
Local version needs dotenv or exporting of env variables.
Dockerised version works fine without load_dotenv.
Ready to test now!
2021-12-05 03:49:31 +00:00
ac5d17fc66 Dockerised. Restructured to remove circular import
Moved most of app definitions out of guard function to use wsgi
Updated configuration files and referencing of .env values.
Local version needs dotenv or exporting of env variables.
Dockerised version works fine without load_dotenv.
Ready to test now!
2021-12-05 03:49:31 +00:00
37d7e5010f Dockerised. Restructured to remove circular import
Moved most of app definitions out of guard function to use wsgi
Updated configuration files and referencing of .env values.
Local version needs dotenv or exporting of env variables.
Dockerised version works fine without load_dotenv.
Ready to test now!
2021-12-05 03:49:31 +00:00
ce40568870 Dockerised. Restructured to remove circular import
Moved most of app definitions out of guard function to use wsgi
Updated configuration files and referencing of .env values.
Local version needs dotenv or exporting of env variables.
Dockerised version works fine without load_dotenv.
Ready to test now!
2021-12-05 03:49:31 +00:00
f4234f57b1 dockerise 2021-12-05 00:17:54 +00:00
b8c652e78a dockerise 2021-12-05 00:17:54 +00:00
9d760aafef dockerise 2021-12-05 00:17:54 +00:00
4da025d50f dockerise 2021-12-05 00:17:54 +00:00
787b741687 dockerise 2021-12-05 00:17:54 +00:00
2aca8015af dockerise 2021-12-05 00:17:54 +00:00
89ae75050b dockerise 2021-12-05 00:17:54 +00:00
efa83d2bf8 dockerise 2021-12-05 00:17:54 +00:00
388d89d95d Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
8a368dbd16 Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
4f842223cd Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
81eac4b880 Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
f03c92082e Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
3a63c72bbb Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
c3f6d45883 Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
27cead22ad Deleted redundant variable assignment 2021-12-04 21:29:15 +00:00
3a39ff6fc3 Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
8ab0a5e164 Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
c3c6e5084a Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
ef7de71a5b Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
1a1dff2c5d Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
da6d380786 Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
a1ed557dc2 Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
3ffb4a68e1 Updated footer, added question counters. 2021-12-04 21:25:43 +00:00
12d9cd39be Finished making dashboards 2021-12-04 20:47:43 +00:00
0fd7ac7f1f Finished making dashboards 2021-12-04 20:47:43 +00:00
66d8fb7d93 Finished making dashboards 2021-12-04 20:47:43 +00:00
cca2633f1a Finished making dashboards 2021-12-04 20:47:43 +00:00
e1fcad3b42 Finished making dashboards 2021-12-04 20:47:43 +00:00
4aad0c1213 Finished making dashboards 2021-12-04 20:47:43 +00:00
ef1cad1995 Finished making dashboards 2021-12-04 20:47:43 +00:00
ab2ca04ceb Finished making dashboards 2021-12-04 20:47:43 +00:00
c88c142f7f Added question progress bar 2021-12-04 18:50:09 +00:00
ff6865c7ca Added question progress bar 2021-12-04 18:50:09 +00:00
488389057c Added question progress bar 2021-12-04 18:50:09 +00:00
186e83f92a Added question progress bar 2021-12-04 18:50:09 +00:00
da6ae3c826 Added question progress bar 2021-12-04 18:50:09 +00:00
23d6f833d7 Added question progress bar 2021-12-04 18:50:09 +00:00
17f9ef79b7 Added question progress bar 2021-12-04 18:50:09 +00:00
231f1d97bc Added question progress bar 2021-12-04 18:50:09 +00:00
dbc0c782c0 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
27bb07a942 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
0d63413835 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
a126d1f91d Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
30e298aa02 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
cc8db3fea4 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
7c2b9df0d0 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
3b605c3340 Added custom 404 display and login redirect 2021-12-04 17:40:01 +00:00
d8e7bf6ae8 Completed client side time adjustment handling
Corrected error display bug
Removed redundant auth and models in quiz client app
2021-12-04 17:14:28 +00:00
3c903424fb Completed client side time adjustment handling
Corrected error display bug
Removed redundant auth and models in quiz client app
2021-12-04 17:14:28 +00:00
766487b669 Completed client side time adjustment handling
Corrected error display bug
Removed redundant auth and models in quiz client app
2021-12-04 17:14:28 +00:00
0e52c12b35 Completed client side time adjustment handling
Corrected error display bug
Removed redundant auth and models in quiz client app
2021-12-04 17:14:28 +00:00
3a1abe5157 Completed client side time adjustment handling
Corrected error display bug
Removed redundant auth and models in quiz client app
2021-12-04 17:14:28 +00:00
9a2d738653 Completed client side time adjustment handling
Corrected error display bug
Removed redundant auth and models in quiz client app
2021-12-04 17:14:28 +00:00
5c6f56f1c3 Completed client side time adjustment handling
Corrected error display bug
Removed redundant auth and models in quiz client app
2021-12-04 17:14:28 +00:00
329538f7f5 Completed client side time adjustment handling
Corrected error display bug
Removed redundant auth and models in quiz client app
2021-12-04 17:14:28 +00:00
cfdb4db0c3 Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
5151b98f97 Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
b102dc86aa Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
d9dc2e209f Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
86f8c12279 Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
c71e91326f Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
41d92b97a0 Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
2f6ccd530a Added server and admin-side time limit adjustments 2021-12-04 15:41:47 +00:00
5d9dba0e3d Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
ee159402d0 Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
82ed0cf7cc Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
66f2da31b6 Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
cf39f83243 Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
5bd04d8dc0 Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
48624584fe Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
fb7f9e328d Refactored form model for custom validators 2021-12-04 15:41:24 +00:00
c7ddf034a3 Typo correction 2021-12-04 12:48:01 +00:00
e001ccfa01 Typo correction 2021-12-04 12:48:01 +00:00
b6179430be Typo correction 2021-12-04 12:48:01 +00:00
8924232a93 Typo correction 2021-12-04 12:48:01 +00:00
ac36309527 Typo correction 2021-12-04 12:48:01 +00:00
7eddcabb7f Typo correction 2021-12-04 12:48:01 +00:00
f66d62db37 Typo correction 2021-12-04 12:48:01 +00:00
567b272161 Typo correction 2021-12-04 12:48:01 +00:00
2f04671ec5 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
c375576436 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
c536fb95b2 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
fbe3a59847 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
6472241dfc Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
998ec597b1 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
3470f7422c Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
9be3b1a487 Added results CRUD and result detailed view 2021-12-04 12:20:03 +00:00
c00ffd3ed0 Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
f17ba4f6bf Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
700850434a Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
019622bd85 Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
fe61456922 Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
64f1da772a Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
6b79fb8ebe Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
8963e5461e Streamlined post form handlers for admin console 2021-12-01 08:26:08 +00:00
a780b2330e Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
a3a1c2ab2f Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
dcd047a5ae Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
268fa36507 Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
f0ba8777e3 Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
43989af1f1 Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
0a6a14f8d0 Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
5dfc3379fc Added footer text to quiz site. 2021-12-01 01:00:53 +00:00
c08e1c7010 Added result page. 2021-12-01 00:48:47 +00:00
2479fd193b Added result page. 2021-12-01 00:48:47 +00:00
a6ad184447 Added result page. 2021-12-01 00:48:47 +00:00
ff9ede6cce Added result page. 2021-12-01 00:48:47 +00:00
05b68fdd95 Added result page. 2021-12-01 00:48:47 +00:00
900929b875 Added result page. 2021-12-01 00:48:47 +00:00
8cf9629bf1 Added result page. 2021-12-01 00:48:47 +00:00
40926c1063 Added result page. 2021-12-01 00:48:47 +00:00
ba47f79d44 Finessing of client. 2021-12-01 00:48:38 +00:00
6f4353266c Finessing of client. 2021-12-01 00:48:38 +00:00
abfa7b21ba Finessing of client. 2021-12-01 00:48:38 +00:00
2536e595f0 Finessing of client. 2021-12-01 00:48:38 +00:00
bda9946859 Finessing of client. 2021-12-01 00:48:38 +00:00
a67ea9951b Finessing of client. 2021-12-01 00:48:38 +00:00
756af0a064 Finessing of client. 2021-12-01 00:48:38 +00:00
7caf54a5ba Finessing of client. 2021-12-01 00:48:38 +00:00
222b8e8a8b Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
2875c59460 Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
bb09930116 Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
31736bfbaf Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
b5625a5fb2 Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
6103010169 Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
283dfe8ecf Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
faeaeb8b2c Finessing display on index and instruction pages 2021-12-01 00:48:05 +00:00
75db9fde3c Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
91621625e6 Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
d23d3ca6d1 Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
8969505383 Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
e9ff14d63e Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
10b325ad29 Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
a15844f52d Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
e0cac3c800 Set timer to be hidden by default
Swapped default behaviour of timer around.
2021-12-01 00:47:19 +00:00
be26a19f2e Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
218090d1e5 Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
f65e5b122f Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
f3cb7deaf4 Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
1745299e12 Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
b17e04de71 Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
b66b94fd83 Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
2af61ca986 Added text to index and instruction pages. 2021-12-01 00:46:41 +00:00
7269cec73d Added automated email notification of results. 2021-12-01 00:46:21 +00:00
68a6507c1b Added automated email notification of results. 2021-12-01 00:46:21 +00:00
e48ab4b58a Added automated email notification of results. 2021-12-01 00:46:21 +00:00
f38e9df6b9 Added automated email notification of results. 2021-12-01 00:46:21 +00:00
1f661a7038 Added automated email notification of results. 2021-12-01 00:46:21 +00:00
66b4c50221 Added automated email notification of results. 2021-12-01 00:46:21 +00:00
9f8a6e1a27 Added automated email notification of results. 2021-12-01 00:46:21 +00:00
d9b72bce0c Added automated email notification of results. 2021-12-01 00:46:21 +00:00
e829514e91 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
a1d19b4474 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
d29a5984f1 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
0b2a74ddd3 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
a1c3e79e90 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
7b1b789644 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
963453d2d6 Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
46ab5d620b Corrected missing assignment of variable 2021-12-01 00:45:56 +00:00
6593d372e0 Corrected doubled import 2021-12-01 00:45:20 +00:00
cffafa82d9 Corrected doubled import 2021-12-01 00:45:20 +00:00
dc432c4ac9 Corrected doubled import 2021-12-01 00:45:20 +00:00
f0c4f237de Corrected doubled import 2021-12-01 00:45:20 +00:00
99bd4df741 Corrected doubled import 2021-12-01 00:45:20 +00:00
a866699f5d Corrected doubled import 2021-12-01 00:45:20 +00:00
75b43f8993 Corrected doubled import 2021-12-01 00:45:20 +00:00
e50ad9430e Corrected doubled import 2021-12-01 00:45:20 +00:00
173b1e329b Exam Code Time Controls 2021-11-30 18:16:52 +00:00
346238dab8 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
9913c9e084 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
ad16311941 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
493f71ac20 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
3f29b504b2 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
565486aef3 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
e5cecd6102 Exam Code Time Controls 2021-11-30 18:16:52 +00:00
795545e8af Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
b4f021bb8b Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
dcafde1158 Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
9b038dc8e4 Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
4a201f3f9d Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
a57f5476c0 Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
240bcc6dd4 Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
add2001ba3 Finished client result API.
Need to work on adjustment user codes and server email notifications.
2021-11-30 18:06:24 +00:00
70f362015c Built client interface 2021-11-30 03:11:28 +00:00
459c630db7 Built client interface 2021-11-30 03:11:28 +00:00
89bb802e45 Built client interface 2021-11-30 03:11:28 +00:00
475fdfcca7 Built client interface 2021-11-30 03:11:28 +00:00
db755334d0 Built client interface 2021-11-30 03:11:28 +00:00
1980363c12 Built client interface 2021-11-30 03:11:28 +00:00
07c8b62dc1 Built client interface 2021-11-30 03:11:28 +00:00
4c14c85a47 Built client interface 2021-11-30 03:11:28 +00:00
40119c9e9c Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
8432884479 Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
82b16ec9fb Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
11a0dc3a4a Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
2348c76ee8 Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
6518458768 Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
aab5325255 Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
af8ea5ddc3 Added CSRF protection to all ajax requests 2021-11-29 09:13:21 +00:00
e730607c66 Added question generating API 2021-11-28 18:17:50 +00:00
87f60e1826 Added question generating API 2021-11-28 18:17:50 +00:00
0c3199515b Added question generating API 2021-11-28 18:17:50 +00:00
7c5e3c1e43 Added question generating API 2021-11-28 18:17:50 +00:00
274eb2d214 Added question generating API 2021-11-28 18:17:50 +00:00
7aa5be57cd Added question generating API 2021-11-28 18:17:50 +00:00
2e77b1a216 Added question generating API 2021-11-28 18:17:50 +00:00
e3fdf08b2c Added question generating API 2021-11-28 18:17:50 +00:00
2d1cdd5e94 Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
af5e6172e9 Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
88a4fc02d1 Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
d6bc6df86b Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
2fce2e0c80 Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
bf1d53d07d Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
2482242f20 Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
0d7fa41261 Added functionality for default datasets.
Incorporated dataset selector into test creation.
2021-11-28 17:28:14 +00:00
2e9e15be95 Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
08f2585def Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
f8d05f2cec Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
fd89626172 Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
e1967bcd7e Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
79193d897e Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
2064ac508a Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
66a950f757 Finished data upload
Refactored to move security package inside common
Moved data folder to process root.
2021-11-28 02:30:46 +00:00
173 changed files with 11320 additions and 456 deletions

33
.env.example Normal file
View File

@ -0,0 +1,33 @@
SERVER_NAME= # URL where this will be hosted.
FLASK_DEBUG=False
TZ=Europe/London # Time Zone
## App Configuration
SECRET_KEY= # Long, secure, secret string.
DATA=./data/
DATABASE_TYPE=SQLite # SQLite or MySQL, defaults to SQLite
DATABASE_HOST= # Required if MySQL. Must match name of Docker service, or provide host if database is on an external server. Defaults to localhost.
DATABASE_PORT= # Required if MySQL. Defaults to 3306
## MySQL Database Configuration (Required if configured to MySQL Database.)
# Note that if using the Docker service, these configuration values will also be used when creating the database in the mysql container.
MYSQL_RANDOM_ROOT_PASSWORD=True
MYSQL_DATABASE= # Required if MySQL.
MYSQL_USER= # Required if MySQL
MYSQL_PASSWORD= # Required if MySQL. Create secure password string. Note '@' character cannot be used.
## Flask Mail Configuration
MAIL_SERVER=postfix # Must match name of the Docker service
MAIL_PORT=25
MAIL_USE_TLS=False
MAIL_USE_SSL=False
MAIL_USERNAME= # Username@domain, must match config values below
MAIL_PASSWORD= # Must match config value below
MAIL_DEFAULT_SENDER= # NoReply@domain or some such.
MAIL_MAX_EMAILS=25
MAIL_ASCII_ATTACHMENTS=True
# Postfix
maildomain= # Domain must match the section of username above
smtp_user= # username:password. Must match config values above.

13
.gitignore vendored
View File

@ -150,3 +150,16 @@ database/data/
# Ignore Encryption Keyfile
.encryption.key
# Ignore Data Dir
**/data/*
# Ignore Logs Dir
logs/*
# Ignore Certbot Dir
certbot/*
# Ignore src dir (exception for robots.txt)
src/html/*
src/html/robots.txt

217
README.md
View File

@ -10,31 +10,224 @@ The exam client is made with accessibility in mind, and has been designed to be
## Set Up and Installation
The clien is designed to work on a server.
The app is designed to be hosted on a server.
### Pre-Requisites
Server
Docker
Docker-Compose
Git
- A Debian- or Ubuntu-based server, preferably the latest distribution.
- Docker (specifically, Docker Engine)
- Docker Compose
- Git
### Installation
#### Install all the pre-requisites
The first step is to ensure all the prerequisites are available on the server.
To set up the server, consult some of the comprehensive guides on various hosting platforms like Linode or DigitalOcean.
Here is a [good starting point on setting up a server](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu-22-04).
To install Docker and Docker Compose, consult the respective documentation:
- [Install on Ubuntu](https://docs.docker.com/engine/install/ubuntu/) or [Install on Debian](https://docs.docker.com/engine/install/debian/)
- Docker Compose should be installed as part of that.
> At the time of writing, there has been an upgrade to Docker and Docker Compose, meaning the syntax below might be different between versions.
Check if Git is installed on your server using the `git --version` command.
If it isn't installed, install it.
This should normally come pre-packaged with your OS distribution.
But if it doesn't, look up how to for whatever OS you use.
If you are using Ubuntu or Debian, it should be as easy as using the command:
```$ sudo apt-get install git -y```
#### Preliminary Set-Up: Clone repos and Configure Values
#### Set Up Web Server
Open a terminal and navigate to the folder where you want to install this app.
I would suggest using a subfolder within your Home folder:
#### Incorporate SSL
```$ cd ~ && mkdir ska-referee-test && cd ska-referee-test```
#### Set Up Auto-Renew
That way, you will ensure you can read and write all the necessary files during installation.
Once in the destination folder, clone all the relevant files you will need for the installation:
### Alterations
```$ git clone https://git.vsnt.uk/viveksantayana/ska-referee-test.git .```
## Use
(Remember to include the trailing dot at the end, as that indicates to Git to download the files in the current directory.)
## Compatibility
#### Choose What Database Engine You Will Use
### iOS Limitations
This app is designed to use an SQLite database by default.
You can set it up to use a MySQL database by configuring the environment variables accordingly.
If your database is being hosted remotely, make sure the MySQL database has the proper authentication for the user from a remote server.
Alternatively, you can also use the second `docker-compose-mysql.yml` file which provides a MySQL database as part of the cluster.
To use the second `docker-compose-mysql.yml` file, use the following command at the last step of the installation:
```sudo docker compose -f docker-compose-mysql.yml up```
#### Populate Environment Variables
Configuration values for the app are stored in the environment variables file.
To set it up, make a copy of the example file and populate it with appropriate values.
```$ cp .env.example .env```
Make sure to use complex, secure strings for passwords.
Also make sure that the various entries for usernames and passwords match.
#### Input Specific Values for Your Installation
There are some values in the following four files you will need to configure to reflect the domain you are installing this app.
```sh
# .env
SERVER_NAME= # URL where this will be hosted.
```
```sh
# install-script.sh
domains=(example.org www.example.org)
email="" # Adding a valid address is strongly recommended
```
Substitute the domain name `domain_name` in the two file paths in the following file:
```sh
# nginx/ssl.conf
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem;
...
```
And **six** locations in the following file, two for the regular version of the domain and four for the www version (remember to keep the www. prefix where present):
```nginx
# nginx/conf.d/ref-test-app.conf
server {
server_name domain_name;
listen 80 default_server;
...
}
server {
server_name domain_name;
listen 443 ssl http2 default_server;
...
}
server {
server_name www.domain_name;
listen 80;
listen [::]:80;
# Redirect to non-www
return 301 $scheme://domain_name$request_uri;
...
}
server {
server_name www.domain_name;
listen 443 ssl http2;
listen [::]:443 ssl http2;
...
# Redirect to non-www
return 301 $scheme://domain_name$request_uri;
}
```
#### Installing SSL Certificates
The app will use SSL certificates to operate through a secure, `https` connection.
This will be set up automatically.
However, there is a specific chicken-and-egg problem as the web server, Nginx, won't run without certificates, Certbot, the certificate generator, won't run without the web server.
So to solve this, there is an automation script we can run that will set up a dummy certificate and then issue the appropriate certificates for us.
```sh
chmod +x install-script.sh
sudo ./install-script.sh
```
This will take a long time to run the first time because it will try and generate a fairly sizeable cypher.
When we later run the server, Certbot will check for renewals of the SSL certificates every 12 hours, and Nginx will reload the configurations every 6 hours, to make sure everything runs smoothly and stays live.
#### Run the Stack
Everything should be good to run on autopilot at this point.
Navigate to the root folder of the app, the folder where you have `install-script.sh` and `docker-compose.yml`.
Run the following command:
```sudo docker compose up -d```
And you should have the stack running.
You can register in the app and begin using it.
### Fonts
The app uses [OpenDyslexic](https://opendyslexic.org/), which is available on-line.
It also has the option of rendering in other system fonts, but this can vary depending on your operating system.
Because these are proprietary fonts, they cannot be made available on-line in the same way as open source ones should your system not have them.
Some fonts may not display correctly as a result.
## Updating the Installation
If the app is updated, you can update the version on your installation using the following method:
### Navigate to the root folder
This will be the root folder into which you cloned the git repository when you set the app up.
### Stash your local changes
When you update the code, there is a risk the changes you made to your configuration will be overwritten.
To avoid this, use the following command:
```git stash```
This will stash the changes you made, and we can re-apply the changes once the new code has been downloaded.
If you do not have any other changes stashed, the index number of these changes should be `0` in a later step.
If there are other changes, make sure to note what the correct index number for the stashed changes is.
### Take down the Docker containers
We will need to stop the current containers with the following command:
```sudo docker compose down```
This may take a few seconds.
### Pull the updated code
Download the updated code from the Git repository:
```git pull```
This step might fail if you have any un-stashed local changed.
### Re-Apply your local configurations
Because we stashed our local configurations, we can re-apply them once again:
```git stash pop 0```
The index number (`0`) is assuming there were no other changes saved on your git repository.
If you have a different index number for the relevant changes from the above step, change this accordingly.
### Re-build the docker image
Now that we have the base code downloaded, we just need to update the docker image:
```sudo docker compose build app```
### Re-build the containers
This is the same last step as running the containers in the last step of the installation:
```sudo docker compose up -d```

View File

@ -6,11 +6,6 @@
- [Docker Compose Reference](https://docs.docker.com/compose/compose-file/compose-file-v3/)
### MongoDB/PyMongo
- [MongoDB Shell Commands](https://docs.mongodb.com/manual/reference/)
- [PyMongo Driver](https://pymongo.readthedocs.io/en/stable/)
## Source Code
- [MongoDB Docker Image entrypoint shell script](https://github.com/docker-library/mongo/blob/master/5.0/docker-entrypoint.sh) (Context: Tried to replicate the command to create a new user in the original entrypoint script in the custom initialisation script in this app.)
@ -23,15 +18,6 @@
- [Tables](https://www.blog.pythonlibrary.org/2017/12/14/flask-101-adding-editing-and-displaying-data/)
- [Tables, but interactive](https://blog.miguelgrinberg.com/post/beautiful-interactive-tables-for-your-flask-templates)
## Stack Exchange/Overflow
### MongoDB
- [Creating MongoDB Database on Container Start](https://stackoverflow.com/questions/42912755/how-to-create-a-db-for-mongodb-container-on-start-up)
- [Passing Environment Variables to Docker Container Entrypoint](https://stackoverflow.com/questions/64606674/how-can-i-pass-environment-variables-to-mongo-docker-entrypoint-initdb-d)
- [Integrating Flask-Login with MongoDB](https://stackoverflow.com/questions/54992412/flask-login-usermixin-class-with-a-mongodb) (**This does not work with the app as is, and is possibly something that needs more research and development in the future**)
- [Setting up a Postfix email notification system](https://medium.com/@vietgoeswest/a-simple-outbound-email-service-for-your-app-in-15-minutes-cc4da70a2af7)
## YouTube Tutorials
### General Flask Introduction
@ -72,7 +58,7 @@ A much simpler and more rudimentary introduction to Flask and MongoDB.
- [Build a User Login System with `flask-login`, `flask-wtforms`, `flask-bootstrap`, and `flask-sqlalchemy`](https://www.youtube.com/watch?v=8aTnmsDMldY)
A much more robust method that uses the various Flask modules to make a more powerful framework.
Uses SQL rather than MongoDB.
Uses SQL.
### Flask techniques
@ -80,4 +66,4 @@ Uses SQL rather than MongoDB.
### Flask handling file uploads
- [Handlin File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask)
- [Handling File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask)

2
certbot/.gitignore vendored
View File

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

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

90
docker-compose-mysql.yml Normal file
View File

@ -0,0 +1,90 @@
version: '3.9'
volumes:
app:
mysql:
services:
nginx:
container_name: reftest_server
image: nginx:alpine
volumes:
- ./certbot:/etc/letsencrypt:ro
- ./nginx:/etc/nginx
- ./src/html/certbot:/usr/share/nginx/html/certbot:ro
- ./src/html/robots.txt:/usr/share/nginx/html/robots.txt:ro
- ./ref-test/app/root:/usr/share/nginx/html/root:ro
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static:ro
- ./ref-test/app/editor/static:/usr/share/nginx/html/editor/static:ro
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static:ro
- ./ref-test/app/view/static:/usr/share/nginx/html/view/static:ro
ports:
- 80:80
- 443:443
restart: unless-stopped
networks:
- frontend
depends_on:
- app
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
app:
container_name: reftest_app
image: reftest
build: ./ref-test
env_file:
- ./.env
ports:
- 5000
volumes:
- app:/ref-test/data
- ./logs:/ref-test/data/logs
restart: unless-stopped
networks:
- frontend
- backend
depends_on:
postfix:
mysql:
condition: service_healthy
postfix:
container_name: reftest_postfix
image: catatnight/postfix:latest
restart: unless-stopped
env_file:
- ./.env
ports:
- 25
networks:
- backend
certbot:
container_name: reftest_certbot
image: certbot/certbot
volumes:
- ./certbot:/etc/letsencrypt
- ./src/html/certbot:/var/www/html
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
mysql:
container_name: reftest_db
image: mysql:8.0
env_file:
- ./.env
volumes:
- mysql:/var/lib/mysql
ports:
- 3306
networks:
- backend
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
timeout: 10s
retries: 10
networks:
frontend:
external: false
backend:
external: false

View File

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

90
install-script.sh Normal file
View File

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

View File

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

View File

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

View File

@ -1,25 +1,31 @@
upstream reftest {
server ref_test_app:5000;
server app:5000;
}
server {
server_name domain_name;
listen 80;
listen [::]:80;
listen 80 default_server;
listen [::]:80 default_server;
# Redirect to ssl
return 301 https://$host$request_uri;
}
server {
server_name domain_name;
listen 443 ssl http2;
listen [::]:443 ssl http2;
listen 443 ssl http2 default_server;
listen [::]:443 ssl http2 default_server;
#SSL configuration
# SSL configuration
include /etc/nginx/ssl.conf;
include /etc/nginx/certbot-challenge.conf;
location ^~ /static/ {
# Define locations for static files to be served by Nginx
location ^~ /root/ {
include /etc/nginx/mime.types;
alias /usr/share/nginx/html/root/;
}
location ^~ /quiz/static/ {
include /etc/nginx/mime.types;
alias /usr/share/nginx/html/quiz/static/;
}
@ -29,8 +35,45 @@ server {
alias /usr/share/nginx/html/admin/static/;
}
location ^~ /admin/editor/static/ {
include /etc/nginx/mime.types;
alias /usr/share/nginx/html/editor/static/;
}
location ^~ /admin/view/static/ {
include /etc/nginx/mime.types;
alias /usr/share/nginx/html/view/static/;
}
location ^~ /admin/analysis/static/ {
include /etc/nginx/mime.types;
alias /usr/share/nginx/html/analysis/static/;
}
# Proxy to the main app for all other requests
location / {
include /etc/nginx/conf.d/common-location.conf;
include /etc/nginx/conf.d/proxy_headers.conf;
proxy_pass http://reftest;
}
}
server {
server_name www.domain_name;
listen 80;
listen [::]:80;
# Redirect to non-www
return 301 $scheme://domain_name$request_uri;
}
server {
server_name www.domain_name;
listen 443 ssl http2;
listen [::]:443 ssl http2;
# SSL configuration
include /etc/nginx/ssl.conf;
include /etc/nginx/certbot-challenge.conf;
# Redirect to non-www
return 301 $scheme://domain_name$request_uri;
}

View File

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

View File

@ -1,2 +1,3 @@
env/
__pycache__/
data/

View File

@ -1,5 +1,8 @@
FROM python:3.10-slim
ARG DATA=./data/
ENV DATA=$DATA
WORKDIR /ref-test
COPY . .
RUN pip install --upgrade pip && pip install -r requirements.txt
RUN chmod +x install.py reset.py && ./install.py
CMD [ "gunicorn", "-b", "0.0.0.0:5000", "-w", "5", "wsgi:app" ]

View File

@ -1,18 +1,16 @@
from .modules import bootstrap, csrf, db, login_manager, mail
from .config import DevelopmentConfig as Config
from .config import Production as Config
from .models import *
from .extensions import bootstrap, csrf, db, login_manager, mail
from .tools.logs import write
from flask import Flask
from flask_wtf.csrf import CSRFError
from flask import flash, Flask, render_template, request
from flask.helpers import abort, url_for
from flask.json import jsonify
from flask_wtf.csrf import CSRFError
from werkzeug.middleware.proxy_fix import ProxyFix
from datetime import datetime
from .admin.views import admin
from .api.views import api
from .views import views
from .quiz.views import quiz
def create_app():
app = Flask(__name__)
app.config.from_object(Config())
@ -26,22 +24,47 @@ def create_app():
login_manager.login_view = 'admin._login'
@login_manager.user_loader
def _load_user(user_id):
pass
def _load_user(id):
try: return User.query.filter_by(id=id).first()
except Exception as exception:
write('system.log', f'Database error when loading user fo login manager: {exception}')
return abort(500)
@app.before_request
def _check_cookie_consent():
if request.cookies.get('cookie_consent'):
return
if any([ request.path.startswith(x) for x in [ '/admin/static/', '/root/', '/quiz/static', '/cookies/', '/admin/editor/static', '/admin/view/static' ] ]):
return
flash(f'<strong>Cookie Consent</strong>: This web site only stores minimal, functional cookies. It does not store any tracking information. By using this site, you consent to this use of cookies. For more information, see our <a href="{url_for("views._privacy")}">privacy policy</a>.', 'cookie_alert')
@app.errorhandler(404)
def _404_handler(error):
return jsonify({'error':'404 &mdash; Not Found'}), 404
def _404_handler(error): return render_template('404.html')
@app.errorhandler(CSRFError)
def _csrf_handler():
return jsonify({'error':'Could not validate a secure connection.'}), 403
def _csrf_handler(): return jsonify({'error':'Could not validate a secure connection.'}), 403
@app.context_processor
def _now():
return {'now': datetime.utcnow()}
def _now(): return {'now': datetime.now()}
from .admin.views import admin
from .api.views import api
from .quiz.views import quiz
from .views import views
from .editor.views import editor
from .view.views import view
from .analysis.views import analysis
app.register_blueprint(admin, url_prefix='/admin')
app.register_blueprint(api, url_prefix='/api')
app.register_blueprint(views)
app.register_blueprint(quiz)
app.register_blueprint(editor, url_prefix='/admin/editor')
app.register_blueprint(view, url_prefix='/admin/view')
app.register_blueprint(analysis, url_prefix='/admin/analysis')
"""Create Database Tables before First Request"""
@app.before_first_request
def _create_database_tables():
with app.app_context():
db.create_all()
return app

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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,277 @@
// Menu Highlight Scripts
const menuItems = document.getElementsByClassName('nav-link')
for(let i = 0; i < menuItems.length; i++) {
if(menuItems[i].pathname == window.location.pathname) {
menuItems[i].classList.add('active')
}
}
const dropdownItems = document.getElementsByClassName('dropdown-item')
for(let i = 0; i< dropdownItems.length; i++) {
if(dropdownItems[i].pathname == window.location.pathname) {
dropdownItems[i].classList.add('active')
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active')
}
}
// General Post Method Form Processing Script
$('form.form-post').submit(function(event) {
var $form = $(this)
var data = $form.serialize()
var url = $(this).prop('action')
var rel_success = $(this).data('rel-success')
$.ajax({
url: url,
type: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.redirect_to) {
window.location.href = response.redirect_to
}
else {
window.location.href = rel_success
}
},
error: function(response) {
error_response(response)
}
})
event.preventDefault()
})
// Form Upload Questions - Special case, needs to handle files.
$('form[name=form-upload-questions]').submit(function(event) {
var $form = $(this)
var data = new FormData($form[0])
var file = $('input[name=data_file]')[0].files[0]
data.append('file', file)
$.ajax({
url: window.location.pathname,
type: 'POST',
data: data,
processData: false,
contentType: false,
success: function(response) {
window.location.reload()
},
error: function(response) {
error_response(response)
}
})
event.preventDefault()
})
// Edit and Delete Test Button Handlers
$('.test-action').click(function(event) {
let id = $(this).data('id')
let action = $(this).data('action')
if (action == 'delete' || action == 'start' || action == 'end') {
$.ajax({
url: `/admin/tests/edit/`,
type: 'POST',
data: JSON.stringify({'id': id, 'action': action}),
contentType: 'application/json',
success: function(response) {
window.location.href = '/admin/tests/'
},
error: function(response){
error_response(response)
},
})
} else if (action == 'edit') {
window.location.href = `/admin/test/${id}/`
} else if (action == 'analyse') {
$.ajax({
url: `/admin/analysis/`,
type: 'POST',
data: JSON.stringify({'id': id, 'class': 'test'}),
contentType: 'application/json',
success: function(response) {
window.location.href = response
},
error: function(response){
error_response(response)
},
})
}
event.preventDefault()
})
// Edit Dataset Button Handlers
$('.edit-question-dataset').click(function(event) {
var id = $(this).data('id')
var action = $(this).data('action')
var disabled = $(this).hasClass('disabled')
if ( !disabled ) {
if (action == 'delete') {
$.ajax({
url: `/admin/settings/questions/${action}/`,
type: 'POST',
data: JSON.stringify({
'id': id,
'action': action,
}),
contentType: 'application/json',
success: function(response) {
window.location.reload()
},
error: function(response){
error_response(response)
},
})
} else if (action == 'edit') {
window.location.href = `/admin/editor/${id}/`
} else if (action == 'view') {
window.location.href = `/admin/view/${id}`
} else if (action == 'download') {
window.location.href = `/admin/settings/questions/download/${id}/`
} else if (action == 'analyse') {
$.ajax({
url: `/admin/analysis/`,
type: 'POST',
data: JSON.stringify({'id': id, 'class': 'dataset'}),
contentType: 'application/json',
success: function(response) {
window.location.href = response
},
error: function(response){
error_response(response)
},
})
}
}
event.preventDefault()
})
function error_response(response) {
const $alert = $("#alert-box")
$alert.html('')
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
$alert.html(`
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`)
} else if (response.responseJSON.error instanceof Array) {
var output = ''
for (let i = 0; i < response.responseJSON.error.length; i ++) {
output += `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`
$alert.html(output)
}
}
$alert.focus()
}
// Dismiss Cookie Alert
$('#dismiss-cookie-alert').click(function(event){
$.ajax({
url: '/cookies/',
type: 'POST',
data: {
time: Date.now()
},
dataType: 'json',
success: function(response){
console.log(response)
},
error: function(response){
console.log(response)
}
})
event.preventDefault()
})
// Script for Result Actions
$('.result-action-buttons').click(function(event){
var id = $(this).data('id')
if ($(this).data('result-action') == 'generate') {
$.ajax({
url: '/admin/certificate/',
type: 'POST',
data: JSON.stringify({'id': id}),
contentType: 'application/json',
dataType: 'html',
success: function(response) {
var display_window = window.open()
display_window.document.write(response)
},
error: function(response){
error_response(response)
},
})
} else {
var action = $(this).data('result-action')
$.ajax({
url: window.location.href,
type: 'POST',
data: JSON.stringify({'id': id, 'action': action}),
contentType: 'application/json',
success: function(response) {
if (action == 'delete') {
window.location.href = '/admin/results/'
} else window.location.reload()
},
error: function(response){
error_response(response)
},
})
}
event.preventDefault()
})
// Script for Deleting Time Adjustment
$('.adjustment-delete').click(function(event){
var user_code = $(this).data('user_code')
var location = window.location.href
location = location.replace('#', '')
$.ajax({
url: location + 'delete-adjustment/',
type: 'POST',
data: JSON.stringify({'user_code': user_code}),
contentType: 'application/json',
success: function(response) {
window.location.reload()
},
error: function(response){
error_response(response)
},
})
event.preventDefault()
})
// Detailed Results view questions
$('.view-full-questions').click(function(event) {
var dataset = $(this).data('dataset')
window.open(`/admin/view/${dataset}`, '_blank')
event.preventDefault()
})

View File

@ -0,0 +1,56 @@
{% extends "admin/components/input-forms.html" %}
{% block content %}
<div class="form-container">
<form name="form-update-account" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._home') }}">
{% include "admin/components/server-alerts.html" %}
<h2 class="form-heading">Update Your Account</h2>
{{ form.hidden_tag() }}
<div class="form-label-group">
Please confirm <strong>your current password</strong> before making any changes to your user account.
</div>
<div class="form-label-group">
{{ form.password_confirm(class_="form-control", placeholder="Current Password", value = user.email, autofocus=true) }}
{{ form.password_confirm.label }}
</div>
<div class="form-label-group">
You can use this panel to update your email address or password.
</div>
<div class="form-label-group">
{{ form.email(class_="form-control", placeholder="Email Address", value = user.email) }}
{{ form.email.label }}
</div>
<div class="form-label-group">
{{ form.password(class_="form-control", placeholder="Password") }}
{{ form.password.label }}
</div>
<div class="form-label-group">
{{ form.password_reenter(class_="form-control", placeholder="Password") }}
{{ form.password_reenter.label }}
</div>
{% include "admin/components/client-alerts.html" %}
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<a href="{{ url_for('admin._users') }}" class="btn btn-md btn-danger btn-block" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg>
<span>
Cancel
</span>
</a>
<button class="btn btn-md btn-primary btn-block" type="submit">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-person-lines-fill" viewBox="0 0 16 16">
<path d="M6 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-5 6s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zM11 3.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5zm.5 2.5a.5.5 0 0 0 0 1h4a.5.5 0 0 0 0-1h-4zm2 3a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1h-2zm0 3a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1h-2z"/>
</svg>
<span>
Update
</span>
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,32 @@
{% extends "admin/components/input-forms.html" %}
{% block content %}
<div class="form-container">
<form name="form-login" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ next or url_for('admin._home') }}">
{% include "admin/components/server-alerts.html" %}
<h2 class="form">Log In</h2>
{{ form.hidden_tag() }}
<div class="form-label-group">
{{ form.username(class_="form-control", autofocus=true, placeholder="Enter Username") }}
{{ form.username.label }}
</div>
<div class="form-label-group">
{{ form.password(class_="form-control", placeholder="Enter Password") }}
{{ form.password.label }}
</div>
<div class="form-check">
{{ form.remember(class_="form-check-input") }}
{{ form.remember.label }}
</div>
{% include "admin/components/client-alerts.html" %}
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<button class="btn btn-md btn-success btn-block" type="submit">Log In</button>
</div>
</div>
</div>
<a href="{{ url_for('admin._reset') }}" class="signin-forgot-password">Reset Password</a>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,43 @@
{% extends "admin/components/input-forms.html" %}
{% block navbar %}
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
<div class="container">
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
</div>
</nav>
{% endblock %}
{% block content %}
<div class="form-container">
<form name="form-register" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
{% include "admin/components/server-alerts.html" %}
<h2 class="form-heading">Register an Account</h2>
{{ form.hidden_tag() }}
<div class="form-label-group">
{{ form.username(class_="form-control", autofocus=true, placeholder="Username") }}
{{ form.username.label }}
</div>
<div class="form-label-group">
{{ form.email(class_="form-control", placeholder="Email Address") }}
{{ form.email.label }}
</div>
<div class="form-label-group">
{{ form.password(class_="form-control", placeholder="Password") }}
{{ form.password.label }}
</div>
<div class="form-label-group">
{{ form.password_reenter(class_="form-control", placeholder="Password") }}
{{ form.password_reenter.label }}
</div>
{% include "admin/components/client-alerts.html" %}
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<button class="btn btn-md btn-success btn-block" type="submit">Register</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends "admin/components/input-forms.html" %}
{% block content %}
<div class="form-container">
<form name="form-reset" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
{% include "admin/components/server-alerts.html" %}
<h2 class="form-heading">Reset Password</h2>
{{ form.hidden_tag() }}
<div class="form-label-group">
{{ form.username(class_="form-control", autofocus=true, placeholder="Enter Username") }}
{{ form.username.label }}
</div>
<div class="form-label-group">
{{ form.email(class_="form-control", placeholder="Enter Email Address") }}
{{ form.email.label }}
</div>
{% include "admin/components/client-alerts.html" %}
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<button class="btn btn-md btn-success btn-block" type="submit">Reset Password</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "admin/components/input-forms.html" %}
{% block content %}
<div class="form-container">
<form name="form-update-password" class="form-display form-post" action="{{ url_for('admin._update_password', **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
{% include "admin/components/server-alerts.html" %}
<h2 class="form-heading">Update Password</h2>
{{ form.hidden_tag() }}
<div class="form-label-group">
{{ form.password(class_="form-control", placeholder="Password") }}
{{ form.password.label }}
{{ form.password.errors[0] }}
</div>
<div class="form-label-group">
{{ form.password_reenter(class_="form-control", placeholder="Password") }}
{{ form.password_reenter.label }}
{{ form.password_reenter.errors[0] }}
</div>
{% include "admin/components/client-alerts.html" %}
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<button class="btn btn-md btn-success btn-block" type="submit">Update Password</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}

View File

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

View File

@ -0,0 +1,84 @@
{% extends "admin/components/base.html" %}
{% block title %} SKA Referee Test | Detailed Results {% endblock %}
{% block navbar %}{% endblock %}
{% block top_alerts %}{% endblock %}
{% block content %}
<div class="d-flex justify-content-center">
<h1 class="center">SKA Referee Theory Exam Results</h1>
</div>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-lg-6">
<ul class="list-group">
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Candidate</h5>
</div>
<h2>
{{ entry.get_surname()}}, {{ entry.get_first_name() }}
</h2>
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Email Address</h5>
</div>
{{ entry.get_email() }}
</li>
{% if entry.get_club() %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Club</h5>
</div>
{{ entry.get_club() }}
</li>
{% endif %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Exam Code</h5>
</div>
{{ entry.test.get_code() }}
</li>
{% if entry.user_code %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">User Code</h5>
</div>
{{ entry.user_code }}
</li>
{% endif %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Start Time</h5>
</div>
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') if entry.start_time else None }}
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Submission Time</h5>
{% if entry.status == 'late' %}
<span class="badge bg-danger">Late</span>
{% endif %}
</div>
{{ entry.end_time.strftime('%d %b %Y %H:%M:%S') if entry.end_time else None }}
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Score</h5>
</div>
{{ entry.result.score }}&percnt;
</li>
<li class="list-group-item list-group-item-action {% if entry.result.grade == 'fail' %}list-group-item-danger {% elif entry.result.grade == 'merit' %} list-group-item-success {% endif %}">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Grade</h5>
</div>
{{ entry.result.grade[0]|upper }}{{ entry.result.grade[1:]}}
</li>
</ul>
<div class="site-footer mt-5">
These results were generated using the SKA RefTest web app on {{ now.strftime('%d %b %Y at %H:%M:%S') }}.
</div>
{% block footer %}{% endblock %}
</div>
</div>
</div>
{% endblock %}

View File

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

View File

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

View File

@ -0,0 +1,2 @@
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&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,137 @@
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
<div class="container">
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbar"
aria-controls="navbar"
aria-expanded="false"
aria-label="Toggle Navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navbar">
<ul class="navbar-nav">
{% if not current_user.is_authenticated %}
<li class="nav-item" id="nav-login">
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
</li>
{% endif %}
{% if current_user.is_authenticated %}
<li class="nav-item dropdown" id="nav-results">
<a
class="nav-link dropdown-toggle"
id="dropdown-results"
role="button"
href="{{ url_for('admin._view_entries') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Results
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-results"
>
<li>
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
</li>
<li>
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-tests">
<a
class="nav-link dropdown-toggle"
id="dropdown-tests"
role="button"
href="{{ url_for('admin._tests') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Exams
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-tests"
>
<li>
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-settings">
<a
class="nav-link dropdown-toggle"
id="dropdown-settings"
role="button"
href="{{ url_for('admin._settings') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Settings
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-settings"
>
<li>
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
</li>
<li>
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
</li>
<li>
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
</li>
<li>
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
</li>
<li>
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-account">
<a
class="nav-link dropdown-toggle"
id="dropdown-account"
role="button"
href="{{ url_for('admin._update_user', id=current_user.id) }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Account
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-account"
>
<li>
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
</li>
<li>
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
</li>
</ul>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>

View File

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

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

@ -0,0 +1,43 @@
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% set cookie_flash_flag = namespace(value=False) %}
{% for category, message in messages %}
{% if category == "error" %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Error" aria-title="Error"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif category == "success" %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check2-circle" title="Success" aria-title="Success"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif category == "warning" %}
<div class="alert alert-warning alert-dismissible fade show" role="alert">
<i class="bi bi-info-circle-fill" aria-title="Warning" title="Warning"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif category == "cookie_alert" %}
{% if not cookie_flash_flag.value %}
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
{{ message|safe }}
<div class="d-flex justify-content-center w-100">
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
</div>
</div>
{% set cookie_flash_flag.value = True %}
{% endif %}
{% else %}
<div class="alert alert-primary alert-dismissible fade show" role="alert">
<i class="bi bi-info-circle-fill" title="Alert"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% endwith %}

View File

@ -0,0 +1,152 @@
{% extends "admin/components/base.html" %}
{% block content %}
<h1>Dashboard</h1>
<div class="container">
<div class="row">
<div class="col-sm">
<div class="card m-3">
<div class="card-body">
<h5 class="card-title">Current Exams</h5>
{% if current_tests %}
<div class="card-text">
<table class="table table-striped">
<thead>
<tr>
<th>
Exam Code
</th>
<th>
Expiry Date
</th>
</tr>
</thead>
<tbody>
{% for test in current_tests %}
<tr>
<td>
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
</td>
<td>
{{ test.end_date.strftime('%d %b %Y') if test.end_date else None }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('admin._tests', filter='active') }}" class="btn btn-primary">View Exams</a>
{% else %}
<div class="alert alert-primary">
There are currently no active exams.
</div>
<a href="{{ url_for('admin._tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
{% endif %}
</div>
</div>
</div>
<div class="col-sm">
<div class="card m-3">
<div class="card-body">
<h5 class="card-title">Recent Results</h5>
{% if recent_results %}
<div class="card-text">
<table class="table table-striped">
<thead>
<tr>
<th>
Name
</th>
<th>
Date Submitted
</th>
<th>
Result
</th>
</tr>
</thead>
<tbody>
{% for result in recent_results %}
<tr>
<td>
<a href="{{ url_for('admin._view_entry', id=result.id) }}">{{ result.get_surname() }}, {{ result.get_first_name() }}</a>
</td>
<td>
{{ result.end_time.strftime('%d %b %Y %H:%M') if result.end_time else None }}
</td>
<td>
{% if result.result %}
{{ (100*result.result['score']/result.result['max'])|round|int }}&percnt; ({{ result.result.grade }})
{% else %}
Incomplete
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('admin._view_entries') }}" class="btn btn-primary">View Results</a>
{% else %}
<div class="alert alert-primary">
There are currently no exam results to preview.
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-sm">
<div class="card m-3">
<div class="card-body">
<h5 class="card-title">Upcoming Exams</h5>
{% if upcoming_tests %}
<div class="card-text">
<table class="table table-striped">
<thead>
<tr>
<th>
Exam Code
</th>
<th>
Expiry Date
</th>
</tr>
</thead>
<tbody>
{% for test in upcoming_tests %}
<tr>
<td>
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
</td>
<td>
{{ test.end_date.strftime('%d %b %Y') if test.end_date else None }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('admin._tests', filter='scheduled') }}" class="btn btn-primary">View Exams</a>
{% else %}
<div class="alert alert-primary">
There are currently no upcoming exams.
</div>
<a href="{{ url_for('admin._tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
{% endif %}
</div>
</div>
</div>
<div class="col-sm">
<div class="card m-3">
<div class="card-body">
<h5 class="card-title">Help</h5>
<p class="card-text">This web app was developed and is maintained by Vivek Santayana. If there are any issues with the app, any bugs you need to report, or any features you would like to request, please feel free to <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues">open an issue at the Git Repository</a>.</p>
<a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues" class="btn btn-primary">Open an Issue</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,189 @@
{% extends "admin/components/base.html" %}
{% block title %} SKA Referee Test | Detailed Results {% endblock %}
{% block content %}
{% include "admin/components/client-alerts.html" %}
<h1>Exam Results</h1>
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-6">
<ul class="list-group">
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Candidate</h5>
</div>
<h2>
{{ entry.get_surname() }}, {{ entry.get_first_name() }}
</h2>
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Email Address</h5>
</div>
{{ entry.get_email() }}
</li>
{% if entry.get_club() %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Club</h5>
</div>
{{ entry.get_club() }}
</li>
{% endif %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Exam Code</h5>
</div>
{{ entry.test.get_code() }}
</li>
{% if entry.user_code %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">User Code</h5>
</div>
{{ entry.user_code }}
</li>
{% endif %}
{% if entry.start_time %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Start Time</h5>
</div>
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') if entry.start_time else None }}
</li>
{% endif %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Submission Time</h5>
{% if entry.status == 'late' %}
<span class="badge bg-danger">Late</span>
{% endif %}
</div>
{% if entry.end_time %}
{{ entry.end_time.strftime('%d %b %Y %H:%M:%S') }}
{% else %}
Incomplete
{% endif %}
</li>
{% if entry.result %}
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Score</h5>
</div>
{{ entry.result.score }}&percnt;
</li>
<li class="list-group-item list-group-item-action {% if entry.result.grade == 'fail' %}list-group-item-danger {% elif entry.result.grade == 'merit' %} list-group-item-success {% endif %}">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Grade</h5>
</div>
{{ entry.result.grade[0]|upper }}{{ entry.result.grade[1:]}}
</li>
{% endif %}
</ul>
{% if entry.result %}
<div class="accordion" id="results-breakdown">
<div class="accordion-item">
<h2 class="accordion-header" id="by-category">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#by-category-breakdown" aria-expanded="false" aria-controls="by-category-breakdown">
Score By Categories
</button>
</h2>
<div id="by-category-breakdown" class="accordion-collapse collapse" aria-labelledby="by-category" data-bs-parent="#results-breakdown">
<div class="accordion-body">
<table class="table table-striped">
<thead>
<tr>
<th>
Category
</th>
<th>
Score
</th>
<th>
Max
</th>
</tr>
</thead>
<tbody>
{% for tag, scores in entry.result.tags.items() %}
<tr>
<td>
{{ tag|safe }}
</td>
<td>
{{ scores.scored }}
</td>
<td>
{{ scores.max }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="by-question">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#by-question-breakdown" aria-expanded="false" aria-controls="by-question-breakdown">
View All Answers
</button>
</h2>
<div id="by-question-breakdown" class="accordion-collapse collapse" aria-labelledby="by-question" data-bs-parent="#results-breakdown">
<div class="accordion-body">
<a class="view-full-questions" data-dataset="{{ entry.test.dataset.id }}">View Questions</a>
<table class="table table-striped">
<thead>
<tr>
<th>
Question
</th>
<th>
Answer
</th>
</tr>
</thead>
<tbody>
{% for question, answer in entry.answers.items() %}
<tr>
<td>
{{ question|int + 1 }}
</td>
<td>
{{ answers[question|int][answer|int] }}
{% if not correct[question] == answer|int %}
<span class="badge badge-pill bg-danger badge-danger">Incorrect</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<div class="container justify-content-center">
<div class="row">
<a href="#" class="btn btn-primary result-action-buttons" data-result-action="generate" data-id="{{ entry.id }}">
<i class="bi bi-printer-fill button-icon"></i>
Printable Version
</a>
</div>
<div class="row">
{% if entry.status == 'late' %}
<a href="#" class="btn btn-warning result-action-buttons" data-result-action="override" data-id="{{ entry.id }}">
<i class="bi bi-clock-history button-icon"></i>
Allow Late Entry
</a>
{% endif %}
<a href="#" class="btn btn-danger result-action-buttons" data-result-action="delete" data-id="{{ entry.id }}">
<i class="bi bi-trash-fill button-icon"></i>
Delete Result
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,138 @@
{% extends "admin/components/datatable.html" %}
{% block title %} SKA Referee Test | View Results {% endblock %}
{% block content %}
{% include "admin/components/client-alerts.html" %}
<h1>View Results</h1>
{% if entries %}
<table id="results-table" class="table table-striped" style="width:100%">
<thead>
<tr>
<th data-priority="1">
Name
</th>
<th data-priority="4">
Club
</th>
<th data-priority="5">
Exam Code
</th>
<th data-priority="3">
Status
</th>
<th data-priority="4">
Submitted
</th>
<th data-priority="2">
Result
</th>
<th data-priority="3">
Grade
</th>
<th data-priority="1">
Details
</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr class="table-row">
<td>
{{ entry.get_surname() }}, {{ entry.get_first_name() }}
</td>
<td>
{% if entry.get_club() %}
{{ entry.get_club() }}
{% endif %}
</td>
<td>
{{ entry.test.get_code() }}
</td>
<td>
{% if entry.status %}
{{ entry.status }}
{% endif %}
</td>
<td>
{% if entry.end_time %}
{{ entry.end_time.strftime('%Y-%m-%d %H:%M') }}
{% endif %}
</td>
<td>
{% if entry.result %}
{{ entry.result.score }}&percnt;
{% endif %}
</td>
<td>
{% if entry.result %}
{{ entry.result.grade }}
{% endif %}
</td>
<td class="row-actions">
<a
href="{{ url_for('admin._view_entry', id = entry.id ) }}"
class="btn btn-primary entry-details"
data-id="{{entry.id}}"
title="View Details"
>
<i class="bi bi-file-medical-fill button-icon"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-primary alert-db-empty">
<i class="bi bi-info-circle-fill" aria-title="Alert" title="Alert"></i>
There are no exam attempts to view.
</div>
{% endif %}
{% endblock %}
{% if entries %}
{% block custom_data_script %}
<script>
$(document).ready(function() {
$('#results-table').DataTable({
'searching': false,
'columnDefs': [
{'sortable': false, 'targets': [7]},
{'searchable': false, 'targets': [7]}
],
'order': [[4, 'desc'], [0, 'asc']],
'buttons': [
{
extend: 'print',
exportOptions: {
columns: [0, 1, 3, 4, 5, 6]
}
},
{
extend: 'excel',
exportOptions: {
columns: [0, 1, 3, 4, 5, 6]
}
},
{
extend: 'pdf',
exportOptions: {
columns: [0, 1, 3, 4, 5, 6]
}
}
],
'responsive': 'true',
'colReorder': 'true',
'fixedHeader': 'true',
'searchBuilder': {
depthLimit: 2,
columns: [1, 5, 6],
},
dom: 'BQlfrtip'
});
// $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') -->
} );
$('#results-table').show();
$(window).trigger('resize');
</script>
{% endblock %}
{% endif %}

View File

@ -0,0 +1,44 @@
{% extends "admin/components/input-forms.html" %}
{% block content %}
<div class="form-container">
<form name="form-delete-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._users') }}">
{% include "admin/components/server-alerts.html" %}
<h2 class="form-heading">Delete User &lsquo;{{ user.get_username() }}&rsquo;?</h2>
{{ form.hidden_tag() }}
<p>This action cannot be undone. Deleting an account will mean {{ user.get_username() }} will no longer be able to log in to the admin console.</p>
<p>Are you sure you want to proceed?</p>
<div class="form-label-group">
{{ form.password(class_="form-control", placeholder="Confirm Your Password", autofocus=true) }}
{{ form.password.label }}
</div>
<div class="form-check">
{{ form.notify(class_="form-check-input") }}
{{ form.notify.label }}
</div>
{% include "admin/components/client-alerts.html" %}
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<a href="{{ url_for('admin._users') }}" autofocus="true" class="btn btn-md btn-primary btn-block" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg>
<span>
Cancel
</span>
</a>
<button class="btn btn-md btn-danger btn-block" type="submit">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-person-x-fill" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm6.146-2.854a.5.5 0 0 1 .708 0L14 6.293l1.146-1.147a.5.5 0 0 1 .708.708L14.707 7l1.147 1.146a.5.5 0 0 1-.708.708L14 7.707l-1.146 1.147a.5.5 0 0 1-.708-.708L13.293 7l-1.147-1.146a.5.5 0 0 1 0-.708z"></path>
</svg>
<span>
Delete
</span>
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,95 @@
{% extends "admin/components/base.html" %}
{% block title %}Settings — SKA Referee Test | Admin Console{% endblock %}
{% block content %}
<h1>
Settings
</h1>
<div class="container">
<div class="row">
<div class="col-sm">
<div class="card m-3">
<div class="card-body">
<h5 class="card-title">Admin Users</h5>
<div class="card-text">
<table class="table table-striped">
<thead>
<tr>
<th>
Username
</th>
<th>
Email Address
</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
<a href="
{% if user == current_user %}
{{ url_for('admin._update_user', id=current_user.id) }}
{% else %}
{{ url_for('admin._update_user', id=user.id) }}
{% endif%}
">{{ user.get_username() }}</a>
</td>
<td>
<a href="mailto:{{ user.get_email() }}">{{ user.get_email() }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('admin._users') }}" class="btn btn-primary">Manage Users</a>
</div>
</div>
</div>
<div class="col-sm">
<div class="card m-3">
<div class="card-body">
<h5 class="card-title">Question Datasets</h5>
{% if datasets %}
<div class="card-text">
<table class="table table-striped">
<thead>
<tr>
<th>
Name
</th>
<th>
Exams
</th>
</tr>
</thead>
<tbody>
{% for dataset in datasets %}
<tr>
<td>
<a href="{{ url_for('editor._editor_console', id=dataset.id) }}">
{{ dataset.get_name() }}
</a>
</td>
<td>
{{ dataset.tests|length }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('admin._questions') }}" class="btn btn-primary">Manage Datasets</a>
{% else %}
<div class="alert alert-primary">
There are currently no question datasets uploaded.
</div>
<a href="{{ url_for('admin._questions') }}" class="btn btn-primary">Upload Dataset</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,164 @@
{% extends "admin/components/datatable.html" %}
{% block title %} SKA Referee Test | Upload Questions {% endblock %}
{% block content %}
{% include "admin/components/client-alerts.html" %}
<h1>Manage Question Datasets</h1>
{% if data %}
<table id="question-datasets-table" class="table table-striped" style="width:100%">
<thead>
<tr>
<th>
</th>
<th data-priority="1">
Name
</th>
<th data-priority="2">
Updated
</th>
<th data-priority="3">
Author
</th>
<th data-priority="3">
Exams
</th>
<th data-priority="1">
Actions
</th>
</tr>
</thead>
<tbody>
{% for element in data %}
<tr class="table-row">
<td>
{% if element.default %}
<div class="text-success" title="Default Dataset">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi success bi-caret-right-fill" viewBox="0 0 16 16">
<path d="m12.14 8.753-5.482 4.796c-.646.566-1.658.106-1.658-.753V3.204a1 1 0 0 1 1.659-.753l5.48 4.796a1 1 0 0 1 0 1.506z"/>
</svg>
</div>
{% endif %}
</td>
<td>
{{ element.get_name() }}
</td>
<td>
{{ element.date.strftime('%Y-%m-%d %H:%M') }}
</td>
<td>
{{ element.creator.get_username() }}
</td>
<td>
{{ element.tests|length }}
</td>
<td class="row-actions">
<a
href="javascript:void(0)"
class="btn btn-success edit-question-dataset {% if not element.entries %} disabled {% endif %}"
data-id="{{ element.id }}"
data-action="analyse"
title="Analyse Answers"
>
<i class="bi bi-search button-icon"></i>
</a>
<a
href="javascript:void(0)"
class="btn btn-primary edit-question-dataset"
data-id="{{ element.id }}"
data-action="download"
title="Download Questions"
>
<i class="bi bi-cloud-arrow-down-fill button-icon"></i>
</a>
<a
href="javascript:void(0)"
class="btn btn-primary edit-question-dataset"
data-id="{{ element.id }}"
data-action="view"
title="View Questions"
>
<i class="bi bi-file-earmark-text-fill button-icon"></i>
</a>
<a
href="javascript:void(0)"
class="btn btn-primary edit-question-dataset"
data-id="{{ element.id }}"
data-action="edit"
title="Edit Questions"
>
<i class="bi bi-pencil-fill button-icon"></i>
</a>
<a
href="javascript:void(0)"
class="btn btn-danger edit-question-dataset {% if element.default %}disabled{% endif %}"
data-id="{{ element.id }}"
data-action="delete"
title="Delete Questions"
>
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-primary alert-db-empty">
<i class="bi bi-info-circle-fill" aria-title="Alert" title="Alert"></i>
There are no question datasets uploaded. Please use the panel below to upload a new question dataset or create a new dataset using the editor console.
</div>
{% endif %}
<div class="col text-center">
<button title="Create New" class="btn btn-md btn-primary btn-block create-new-dataset">
<i class="bi bi-cloud-plus-fill button-icon"></i>
Create New Dataset
</button>
</div>
<div class="form-container">
<form name="form-upload-questions" class="form-display" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="" enctype="multipart/form-data">
<h2 class="form-heading">Upload Question Dataset</h2>
{{ form.hidden_tag() }}
<div class="form-label-group">
{{ form.name(class_="form-control", autofocus=true, placeholder="Enter Name of Dataset") }}
{{ form.name.label }}
</div>
<div class="form-upload">
{{ form.data_file() }}
</div>
<div class="form-check">
{{ form.default(class_="form-check-input") }}
{{ form.default.label }}
</div>
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<button title="Upload Dataset" class="btn btn-md btn-success btn-block" type="submit">
<i class="bi bi-cloud-arrow-up-fill button-icon"></i>
Upload Dataset
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}
{% if data %}
{% block custom_data_script %}
<script>
$(document).ready(function() {
$('#question-datasets-table').DataTable({
'columnDefs': [
{'sortable': false, 'targets': [0,5]},
{'searchable': false, 'targets': [1,2,3]}
],
'order': [[1, 'asc'], [2, 'desc'], [3, 'asc']],
'responsive': 'true',
'fixedHeader': 'true',
});
} );
$('#question-datasets-table').show();
$(window).trigger('resize');
</script>
{% endblock %}
{% endif %}

View File

@ -0,0 +1,57 @@
{% extends "admin/components/input-forms.html" %}
{% block content %}
<div class="form-container">
<form name="form-update-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._users') }}">
{% include "admin/components/server-alerts.html" %}
<h2 class="form-heading">Update User &lsquo;{{ user.get_username() }}&rsquo;</h2>
{{ form.hidden_tag() }}
<div class="form-label-group">
{{ form.email(class_="form-control", placeholder="Email Address", value = user.get_email()) }}
{{ form.email.label }}
</div>
<div class="form-label-group">
{{ form.password(class_="form-control", placeholder="Password") }}
{{ form.password.label }}
</div>
<div class="form-label-group">
{{ form.password_reenter(class_="form-control", placeholder="Password") }}
{{ form.password_reenter.label }}
</div>
<div class="form-check">
{{ form.notify(class_="form-check-input") }}
{{ form.notify.label }}
</div>
<div class="form-label-group">
Please confirm <strong>your current password</strong> before committing any changes to a user account.
</div>
<div class="form-label-group">
{{ form.confirm_password(class_="form-control", placeholder="Your Password", value = user.email) }}
{{ form.confirm_password.label }}
</div>
{% include "admin/components/client-alerts.html" %}
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<a href="{{ url_for('admin._users') }}" class="btn btn-md btn-danger btn-block" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg>
<span>
Cancel
</span>
</a>
<button class="btn btn-md btn-primary btn-block" type="submit">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-person-lines-fill" viewBox="0 0 16 16">
<path d="M6 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-5 6s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zM11 3.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5zm.5 2.5a.5.5 0 0 0 0 1h4a.5.5 0 0 0 0-1h-4zm2 3a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1h-2zm0 3a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1h-2z"/>
</svg>
<span>
Update
</span>
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,125 @@
{% extends "admin/components/datatable.html" %}
{% block title %} SKA Referee Test | Manage Users {% endblock %}
{% block content %}
<h1>Manage Users</h1>
<table id="user-table" class="table table-striped" style="width:100%">
<thead>
<tr>
<th>
</th>
<th data-priority="1">
Username
</th>
<th>
Email Address
</th>
<th data-priority="1">
Actions
</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr class="table-row">
<td>
{% if user == current_user %}
<div class="text-success" title="Current User">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi success bi-caret-right-fill" viewBox="0 0 16 16">
<path d="m12.14 8.753-5.482 4.796c-.646.566-1.658.106-1.658-.753V3.204a1 1 0 0 1 1.659-.753l5.48 4.796a1 1 0 0 1 0 1.506z"/>
</svg>
</div>
{% endif %}
</td>
<td>
{{ user.get_username() }}
</td>
<td>
{{ user.get_email() }}
</td>
<td class="row-actions">
<a
href="
{% if not user == current_user %}
{{ url_for('admin._update_user', id = user.id ) }}
{% else %}
{{ url_for('admin._update_user', id=current_user.id) }}
{% endif %}
"
class="btn btn-primary"
title="Update User"
>
<i class="bi bi-person-lines-fill button-icon"></i>
</a>
<a
href="
{% if not user == current_user %}
{{ url_for('admin._delete_user', id = user.id ) }}
{% else %}
#
{% endif %}
"
class="btn btn-danger {% if user == current_user %} disabled {% endif %}"
title="Delete User"
{% if user == current_user %} onclick="return false" {% endif %}
>
<i class="bi bi-person-x-fill button-icon"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="form-container">
<form name="form-create-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="">
<h2 class="form-heading">Create User</h2>
{{ form.hidden_tag() }}
<div class="form-label-group">
{{ form.username(class_="form-control", placeholder="Enter Username") }}
{{ form.username.label }}
</div>
<div class="form-label-group">
{{ form.email(class_="form-control", placeholder="Enter Email") }}
{{ form.email.label }}
</div>
<div class="form-label-group">
If you do not enter a password, a random one will be generated.
</div>
<div class="form-label-group">
{{ form.password(class_="form-control", placeholder="Enter Password") }}
{{ form.password.label }}
</div>
{% include "admin/components/client-alerts.html" %}
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<button title="Create User" class="btn btn-md btn-success btn-block" type="submit">
<i class="bi bi-person-plus-fill button-icon"></i>
Create User
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}
{% block custom_data_script %}
<script>
$(document).ready(function() {
$('#user-table').DataTable({
'columnDefs': [
{'sortable': false, 'targets': [0,3]}
],
'order': [[1, 'asc'], [2, 'asc']],
'buttons': [
'copy', 'excel', 'pdf'
],
'responsive': 'true',
'colReorder': 'true',
'fixedHeader': 'true'
});
} );
$('#user-table').show();
$(window).trigger('resize');
</script>
{% endblock %}

View File

@ -0,0 +1,196 @@
{% extends "admin/components/base.html" %}
{% block title %} SKA Referee Test | Edit Exam {% endblock %}
{% block content %}
<h1>Edit Exam</h1>
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-6">
<ul class="list-group">
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Exam Code</h5>
</div>
<h2>
{{ test.get_code() }}
</h2>
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Dataset</h5>
</div>
<a href="{{ url_for('view._view_console', id=test.dataset.id) }}">{{ test.dataset.get_name() }}</a>
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Created By</h5>
</div>
{{ test.creator.get_username() }}
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Start Date</h5>
</div>
{{ test.start_date.strftime('%d %b %Y %H:%M') if test.start_date else None }}
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Expiry Date</h5>
</div>
{{ test.end_date.strftime('%d %b %Y %H:%M') if test.end_date else None }}
</li>
<li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Time Limit</h5>
</div>
{% if test.time_limit == None -%}
None
{% elif test.time_limit == 60 -%}
1 hour
{% elif test.time_limit == 90 -%}
1 hour 30 min
{% elif test.time_limit == 120 -%}
2 hours
{% else -%}
{{ test.time_limit }}
{% endif %}
</li>
<div class="accordion" id="test-info-detail">
{% if test.entries|length > 0 %}
<div class="accordion-item">
<h2 class="accordion-header" id="test-entries">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#test-entries-list" aria-expanded="false" aria-controls="test-entries-list">
List Test Entries
</button>
</h2>
<div id="test-entries-list" class="accordion-collapse collapse" aria-labelledby="test-entries" data-bs-parent="#test-info-detail">
<div class="accordion-body">
<table class="table table-striped">
<tbody>
{% for entry in test.entries %}
<tr>
<td>
<a href="{{ url_for('admin._view_entry', id=entry.id) }}" >Entry {{ loop.index }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{% if test.adjustments %}
<div class="accordion-item">
<h2 class="accordion-header" id="test-adjustments">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#test-adjustments-list" aria-expanded="false" aria-controls="test-adjustments-list">
List Time Adjustments
</button>
</h2>
<div id="test-adjustments-list" class="accordion-collapse collapse" aria-labelledby="test-adjustments" data-bs-parent="#test-info-detail">
<div class="accordion-body">
<table class="table table-striped">
<thead>
<tr>
<th>
User Code
</th>
<th>
Adjustment (Minutes)
</th>
<th>
Delete
</th>
</tr>
</thead>
<tbody>
{% for key, value in test.adjustments.items() %}
<tr>
<td>
{{ key.upper() }}
</td>
<td>
{{ value }}
</td>
<td>
<a href="javascript::void(0);" class="btn btn-danger adjustment-delete" title="Delete Adjustment" data-user_code="{{ key }}">
<i class="bi bi-slash-circle-fill button-icon"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{% if not test.time_limit == None %}
<div class="accordion-item">
<h2 class="accordion-header" id="test-add-adjustments">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#test-adjustments-add" aria-expanded="false" aria-controls="test-adjustments-add">
Add Time Adjustments
</button>
</h2>
<div id="test-adjustments-add" class="accordion-collapse collapse" aria-labelledby="test-add-adjustments" data-bs-parent="#test-info-detail">
<div class="accordion-body">
<form name="form-add-adjustment" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="">
{{ form.hidden_tag() }}
<div class="form-label-group">
{{ form.time(class_="form-control", placeholder="Enter Time") }}
{{ form.time.label }}
</div>
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<button title="Add Time Adjustment" class="btn btn-md btn-success btn-block" type="submit">
<i class="bi bi-clock-history button-icon"></i>
Add Time Adjustment
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
{% endif %}
</div>
</ul>
<div class="row">
{% include "admin/components/client-alerts.html" %}
</div>
<div class="container justify-content-center">
<div class="my-3 row">
{% if test.start_date <= now %}
<a href="#" class="btn btn-warning test-action {% if test.end_date < now %}disabled{% endif %}" data-action="end" data-id="{{ test.id }}">
<i class="bi bi-hourglass-bottom button-icon"></i>
Close Exam
</a>
{% else %}
<a href="#" class="btn btn-success test-action {% if test.start_date < now %}disabled{% endif %}" data-action="start" data-id="{{ test.id }}">
<i class="bi bi-hourglass-top button-icon"></i>
Start Exam
</a>
{% endif %}
<a
href="#"
class="btn btn-success test-analyse test-action {% if not test.entries %} disabled {% endif %}"
data-id="{{test.id}}"
title="Analyse Exam"
data-action="analyse"
>
<i class="bi bi-search button-icon"></i>
Analyse Exam
</a>
<a href="#" class="btn btn-danger test-action" data-action="delete" data-id="{{ test.id }}">
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
Delete Exam
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,176 @@
{% extends "admin/components/datatable.html" %}
{% block title %} SKA Referee Test | Manage Exams {% endblock %}
{% block content %}
{% include "admin/components/client-alerts.html" %}
<h1>Manage Exams</h1>
{% include "admin/components/secondary-navs/tests.html" %}
<h2>{{ display_title }}</h2>
{% if tests %}
<table id="active-test-table" class="table table-striped" style="width:100%">
<thead>
<tr>
<th data-priority="1">
Start Date
</th>
<th data-priority="1">
Exam Code
</th>
<th data-priority="2">
Expiry Date
</th>
<th data-priority="3">
Time Limit
</th>
<th data-priority="4">
Entries
</th>
<th data-priority="1">
Actions
</th>
</tr>
</thead>
<tbody>
{% for test in tests %}
<tr class="table-row">
<td>
{{ test.start_date.strftime('%Y-%m-%d %H:%M') }}
</td>
<td>
{{ test.get_code() }}
</td>
<td>
{{ test.end_date.strftime('%Y-%m-%d %H:%M') }}
</td>
<td>
{% if test.time_limit == None -%}
None
{% elif test.time_limit == 60 -%}
1 hour
{% elif test.time_limit == 90 -%}
1 hour 30 min
{% elif test.time_limit == 120 -%}
2 hours
{% else -%}
{{ test.time_limit }}
{% endif %}
</td>
<td>
{{ test.entries|length }}
</td>
<td class="row-actions">
<a
href="#"
class="btn btn-success test-analyse test-action {% if not test.entries %} disabled {% endif %}"
data-id="{{test.id}}"
title="Analyse Exam"
data-action="analyse"
>
<i class="bi bi-search button-icon"></i>
</a>
<a
href="#"
class="btn btn-primary test-action"
data-id="{{test.id}}"
title="Edit Exam"
data-action="edit"
>
<i class="bi bi-file-earmark-text-fill button-icon"></i>
</a>
<a
href="#"
class="btn btn-danger test-action"
data-id="{{test.id}}"
title="Delete Exam"
data-action="delete"
>
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% elif not filter == 'create' %}
<div class="alert alert-primary alert-db-empty">
<i class="bi bi-info-circle-fill" aria-title="Alert" title="Alert"></i>
{{ error_none }}
</div>
{% endif %}
{% if form %}
<div class="form-container">
<form name="form-create-test" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="/admin/tests/">
<h2 class="form-heading">Create Exam</h2>
{{ form.hidden_tag() }}
<div class="form-date-input">
{{ form.start_date(placeholder="Enter Start Date", class_ = "datepicker") }}
{{ form.start_date.label }}
</div>
<div class="form-date-input">
{{ form.expiry_date(placeholder="Enter Expiry Date", class_ = "datepicker") }}
{{ form.expiry_date.label }}
</div>
<div class="form-select-input">
{{ form.time_limit(placeholder="Select Time Limit") }}
{{ form.time_limit.label }}
</div>
<div class="form-select-input">
{{ form.dataset(placeholder="Select Question Dataset") }}
{{ form.dataset.label }}
</div>
{% include "admin/components/client-alerts.html" %}
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<button title="Create Exam" class="btn btn-md btn-success btn-block" type="submit">
<i class="bi bi-file-earmark-plus-fill button-icon"></i>
Create Exam
</button>
</div>
</div>
</div>
</form>
</div>
{% endif %}
{% endblock %}
{% if tests %}
{% block custom_data_script %}
<script>
$(document).ready(function() {
$('#active-test-table').DataTable({
'columnDefs': [
{'sortable': false, 'targets': [1,3,5]},
{'searchable': false, 'targets': [3,5]}
],
'order': [[0, 'desc'], [2, 'asc']],
dom: 'lfBrtip',
'buttons': [
{
extend: 'print',
exportOptions: {
columns: [0, 1, 2, 3]
}
},
{
extend: 'excel',
exportOptions: {
columns: [0, 1, 2, 3]
}
},
{
extend: 'pdf',
exportOptions: {
columns: [0, 1, 2, 3]
}
},
],
'responsive': 'true',
'colReorder': 'true',
'fixedHeader': 'true',
});
// $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') -->
} );
$('#active-test-table').show();
$(window).trigger('resize');
</script>
{% endblock %}
{% endif %}

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

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

View File

@ -1,20 +0,0 @@
from flask import Blueprint
admin = Blueprint(
name='admin',
import_name=__name__,
template_folder='templates',
static_folder='static'
)
@admin.route('/')
@admin.route('/home/')
@admin.route('/dashboard/')
def _home():
return 'Home Page'
@admin.route('/settings/')
def _settings():
return 'Settings Page'
from . import auth, questions, results, tests, users

View File

@ -1,21 +0,0 @@
from . import admin
@admin.route('/login/')
def _login():
return 'Login Page'
@admin.route('/logout/')
def _logout():
return 'Logout Command'
@admin.route('/register/')
def _register():
return 'Registration Page'
@admin.route('/reset/')
def _reset():
return 'Reset Page'
@admin.route('/update_password/', methods=['POST'])
def _update_password():
return 'Password Update'

View File

@ -1,6 +0,0 @@
from . import admin
@admin.route('/settings/users/')
def _users():
return 'Manage Users'

View File

@ -0,0 +1,8 @@
#alert-box {
margin: 30px auto;
max-width: 460px;
}
.cell-percentage::after {
content: '%';
}

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

@ -0,0 +1,27 @@
// Analyse Button Listener
$('.button-analyse').click(function(event) {
let buttonClass = $(this).data('class')
let id = null
if (buttonClass == 'test' ) {
id = $('#select-test').children('option:selected').val()
} else if (buttonClass == 'dataset' ) {
id = $('#select-dataset').children('option:selected').val()
}
$.ajax({
url: `/admin/analysis/`,
type: 'POST',
data: JSON.stringify({'id': id, 'class': buttonClass}),
contentType: 'application/json',
success: function(response) {
window.location.href = response
},
error: function(response){
error_response(response)
},
})
event.preventDefault()
})

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 (let i = 0; i < response.responseJSON.error.length; i ++) {
output += `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`
$alert.html(output)
}
}
$alert.focus()
}
// Dismiss Cookie Alert
$('#dismiss-cookie-alert').click(function(event){
$.ajax({
url: '/cookies/',
type: 'POST',
data: {
time: Date.now()
},
dataType: 'json',
success: function(response){
console.log(response)
},
error: function(response){
console.log(response)
}
})
event.preventDefault()
})
// Create New Dataset
$('.create-new-dataset').click(function(event){
$.ajax({
url: '/api/editor/new/',
type: 'POST',
data: {
time: Date.now()
},
dataType: 'json',
success: function(response){
if (response.redirect_to) {
window.location.href = response.redirect_to
}
},
error: function(response){
console.log(response)
}
})
event.preventDefault()
})

View File

@ -0,0 +1,198 @@
{% extends "analysis/components/datatable.html" %}
{% block style %}
<link
rel="stylesheet"
href="{{ url_for('.static', filename='css/analysis.css') }}"
/>
{% endblock %}
{% block content %}
<h1>Analysis by {{ type[0]|upper }}{{ type[1:] }}</h1>
<div class="container">
<p class="lead">
The analysis section displays statistics for all test results as well as answers to individual questions.
Analysis reports can be generated per exam or per question dataset to identify common mistakes or patterns in answers.
</p>
<div class="input-group mb-3">
<span class="input-group-text">
{% if type == 'exam' %}
Exam Code
{% elif type == 'dataset' %}
Dataset Name
{% endif %}
</span>
<span class="form-control">
{{ subject }}
</span>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Total Entries</span>
<span class="form-control">
{{ analysis.entries }}
</span>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Passed</span>
<span class="form-control">
{{ analysis.grades.merit + analysis.grades.pass }} ({{ ((analysis.grades.merit + analysis.grades.pass)*100/analysis.entries)|round(2) }} &percnt;)
</span>
</div>
<div class="mb-3">
<span class="badge rounded-pill progress-bar-striped bg-success">Merit: {{ analysis.grades.merit }}</span> <span class="badge rounded-pill bg-primary progress-bar-striped">Pass: {{ analysis.grades.pass }}</span> <span class="badge rounded-pill progress-bar-striped bg-danger">Fail: {{ analysis.grades.fail }}</span>
<div class="my-1 progress">
<div class="progress-bar progress-bar-striped bg-success" role="progressbar" style="width: {{ (analysis.grades.merit*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.merit }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.merit*100/analysis.entries)|round(2) }} &percnt;</div>
<div class="progress-bar progress-bar-striped" role="progressbar" style="width: {{ (analysis.grades.pass*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.pass }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.pass*100/analysis.entries)|round(2) }} &percnt;</div>
<div class="progress-bar progress-bar-striped bg-danger" role="progressbar" style="width: {{ (analysis.grades.fail*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.fail }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.fail*100/analysis.entries)|round(2) }} &percnt;</div>
</div>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Mean Score</span>
<span class="form-control">
{{ analysis.scores.mean|round(2) }} &percnt;
</span>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Standard Deviation</span>
<span class="form-control">
{% if analysis.scores.stdev %}
{{ analysis.scores.stdev|round(2) }}
{% else %}
{{ None }}
{% endif %}
</span>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Median Score</span>
<span class="form-control">
{{ analysis.scores.median|round(2) }} &percnt;
</span>
</div>
{% if type == 'exam' %}
<div class="input-group mb-3">
<span class="input-group-text">Dataset Name</span>
<span class="form-control">
{{ dataset.get_name() }}
</span>
</div>
{% endif %}
</div>
<div class="container">
<table id="analysis-table" class="table table-striped" style="width:100%">
<thead>
<th data-priority="1">
Question
</th>
<th data-priority="1">
Percent Correct
</th>
<th data-priority="2">
Answers
</th>
<th data-priority="3">
Tags
</th>
</thead>
<tbody>
{% for question in questions %}
<tr class="table-row">
<td>
{{ question.q_no + 1 }}
</td>
<td class="cell-percentage">
{{ ((analysis.answers[question.q_no][question.correct] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}
</td>
<td>
<table style="width:100%">
{% for option in question.options %}
<tr>
<td style="width:50%">
{{ option[1] }}
</td>
<td>
{% if question.correct == option[0] %}
<div class="progress">
<div class="progress-bar bg-success progress-bar-striped" role="progressbar" style="width: {{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}%;" aria-valuenow="{{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}" aria-valuemin="0" aria-valuemax="100">{{ ((analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}%</div>
</div>
{% else %}
<div class="progress">
<div class="progress-bar bg-danger progress-bar-striped" role="progressbar" style="width: {{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}%;" aria-valuenow="{{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}" aria-valuemin="0" aria-valuemax="100">{{ ((analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}%</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</td>
<td>
<ul>
{% for tag in question.tags %}
<li>{{ tag|safe }}</li>
{% endfor %}
</ul>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
{% block script %}
<script>
const target = "{{ url_for('api._editor') }}"
const id = "{{ dataset.id }}"
</script>
<script
type="text/javascript"
src="{{ url_for('.static', filename='js/analysis.js') }}"
></script>
{% endblock %}
{% block custom_data_script %}
<script>
console.log($('#analysis-table'))
$(document).ready(function() {
$('#analysis-table').DataTable({
'searching': true,
'columnDefs': [
{'sortable': true, 'targets': [0,1]},
{'sortable': false, 'targets': [2,3]},
{'searchable': true, 'targets': [0,2,3]}
],
'order': [[0, 'asc'], [1, 'desc']],
'buttons': [
{
extend: 'print',
exportOptions: {
columns: [0, 1, 2, 3]
}
},
{
extend: 'excel',
exportOptions: {
columns: [0, 1, 2, 3]
}
},
{
extend: 'pdf',
exportOptions: {
columns: [0, 1, 2, 3]
}
}
],
'responsive': 'true',
'colReorder': 'true',
'fixedHeader': 'true',
'searchBuilder': {
depthLimit: 2,
columns: [2, 3],
},
dom: 'BQlfrtip'
});
// $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') -->
} );
$('#analysis-table').show();
$(window).trigger('resize');
</script>
{% endblock %}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&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 "analysis/components/base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block top_alerts %}
{% endblock %}

View File

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

View File

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

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

@ -0,0 +1,43 @@
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% set cookie_flash_flag = namespace(value=False) %}
{% for category, message in messages %}
{% if category == "error" %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Error" aria-title="Error"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif category == "success" %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check2-circle" title="Success" aria-title="Success"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif category == "warning" %}
<div class="alert alert-warning alert-dismissible fade show" role="alert">
<i class="bi bi-info-circle-fill" aria-title="Warning" title="Warning"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif category == "cookie_alert" %}
{% if not cookie_flash_flag.value %}
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
{{ message|safe }}
<div class="d-flex justify-content-center w-100">
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
</div>
</div>
{% set cookie_flash_flag.value = True %}
{% endif %}
{% else %}
<div class="alert alert-primary alert-dismissible fade show" role="alert">
<i class="bi bi-info-circle-fill" title="Alert"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% endwith %}

View File

@ -0,0 +1,54 @@
{% extends "analysis/components/input-forms.html" %}
{% block content %}
<h1>Analysis</h1>
<div class="container">
<div class="row">
<div class="col-sm">
<div class="card m-3">
<div class="card-body">
<h5 class="card-title">Exams</h5>
<div class="card-text">
<div class="form-select-input">
<select name="select-test" id="select-test">
{% for test in tests %}
<option value="{{ test.id }}">{{ test.get_code() }}</option>
{% endfor %}
</select>
</div>
<div class="my-3">
<a href="{{ url_for('analysis._test') }}" class="btn btn-primary button-analyse" data-class="test">
<i class="bi bi-search button-icon"></i>
Analyse
</a>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm">
<div class="card m-3">
<div class="card-body">
<h5 class="card-title">Datasets</h5>
<div class="card-text">
<div class="form-select-input">
<select name="select-dataset" id="select-dataset">
{% for dataset in datasets %}
<option value="{{ dataset.id }}">{{ dataset.get_name() }}</option>
{% endfor %}
</select>
</div>
<div class="my-3">
<a href="{{ url_for('analysis._dataset') }}" class="btn btn-primary button-analyse" data-class="dataset">
<i class="bi bi-search button-icon"></i>
Analyse
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% include "analysis/components/client-alerts.html" %}
{% endblock %}

View File

@ -0,0 +1,85 @@
from ..models import Dataset, Test
from ..tools.data import analyse, check_dataset_exists, check_test_exists
from ..tools.logs import write
from ..tools.data import parse_questions
from flask import Blueprint, render_template, request, jsonify
from flask.helpers import abort, flash, redirect, url_for
from flask_login import login_required
analysis = Blueprint(
name='analysis',
import_name=__name__,
template_folder='templates',
static_folder='static'
)
@analysis.route('/', methods=['GET','POST'])
@login_required
@check_dataset_exists
@check_test_exists
def _analysis():
try:
_tests = Test.query.all()
_datasets = Dataset.query.all()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
tests = [ test for test in _tests if test.entries ]
datasets = [ dataset for dataset in _datasets if dataset.entries ]
if request.method == 'POST':
selection = request.get_json()
if selection['class'] == 'test':
try:
test = Test.query.filter_by(id=selection['id']).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
if not test: return jsonify({'error': 'Invalid entry ID.'}), 404
return url_for('analysis._test', id=selection['id']), 200
if selection['class'] == 'dataset':
try:
dataset = Dataset.query.filter_by(id=selection['id']).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
if not dataset: return jsonify({'error': 'Invalid entry ID.'}), 404
return url_for('analysis._dataset', id=selection['id']), 200
return jsonify({'error': 'Invalid entry ID.'}), 404
return render_template('/analysis/index.html', tests=tests, datasets=datasets)
@analysis.route('/test/<string:id>')
@analysis.route('/test/')
@login_required
@check_test_exists
def _test(id:str=None):
if id in [None, '']:
flash(message='Please select a valid exam.', category='error')
return redirect(url_for('analysis._analysis'))
try:
test = Test.query.filter_by(id=id).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
if not test:
flash('Invalid exam.', 'error')
return redirect(url_for('analysis._analysis'))
return render_template('/analysis/analysis.html', analysis=analyse(test), subject=test.get_code(), type='exam', dataset=test.dataset, questions=parse_questions(test.dataset.get_data()))
@analysis.route('/dataset/<string:id>')
@analysis.route('/dataset/')
@login_required
@check_dataset_exists
def _dataset(id:str=None):
if id in [None, '']:
flash(message='Please select a valid dataset.', category='error')
return redirect(url_for('analysis._analysis'))
try:
dataset = Dataset.query.filter_by(id=id).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
if not dataset:
flash('Invalid dataset.', 'error')
return redirect(url_for('analysis._analysis'))
return render_template('/analysis/analysis.html', analysis=analyse(dataset), subject=dataset.get_name(), type='dataset', dataset=dataset, questions=parse_questions(dataset.get_data()))

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

@ -0,0 +1,115 @@
from ..models import Dataset, Entry, User
from ..tools.data import validate_json
from ..tools.logs import write
from ..tools.test import evaluate_answers, generate_questions
from flask import Blueprint, jsonify, request
from flask.helpers import abort, flash, url_for
from flask_login import login_required
from datetime import datetime, timedelta
from json import loads
api = Blueprint(
name='api',
import_name=__name__
)
@api.route('/questions/', methods=['POST'])
def _fetch_questions():
id = request.get_json()['id']
try: entry = Entry.query.filter_by(id=id).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 400
test = entry.test
user_code = entry.user_code
time_limit = test.time_limit
time_adjustment = 0
if time_limit:
_time_limit = int(time_limit)
if user_code:
time_adjustment = test.adjustments[user_code]
_time_limit += time_adjustment
end_delta = timedelta(minutes=_time_limit)
end_time = datetime.now() + end_delta
else:
end_time = None
entry.start()
dataset = test.dataset
success, message = dataset.check_file()
if not success: return jsonify({'error': message}), 500
data_path = dataset.get_file()
with open(data_path, 'r') as data_file:
data = loads(data_file.read())
questions = generate_questions(data)
return jsonify({
'time_limit': end_time,
'questions': questions,
'start_time': entry.start_time,
'time_adjustment': time_adjustment
}), 200
@api.route('/submit/', methods=['POST'])
def _submit_quiz():
id = request.get_json()['id']
answers = request.get_json()['answers']
try: entry = Entry.query.filter_by(id=id).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
if not entry: return jsonify({'error': 'Unrecognised Entry.'}), 400
test = entry.test
dataset = test.dataset
success, message = dataset.check_file()
if not success: return jsonify({'error': message}), 500
data_path = dataset.get_file()
with open(data_path, 'r') as data_file:
data = loads(data_file.read())
result = evaluate_answers(answers=answers, key=data)
entry.complete(answers=answers, result=result)
return jsonify({
'success': 'Your submission has been processed. Redirecting you to receive your results.',
'id': id
}), 200
@api.route('/editor/', methods=['POST'])
@login_required
def _editor(id:str=None):
request_data = request.get_json()
id = request_data['id']
try: dataset = Dataset.query.filter_by(id=id).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
if not dataset: return jsonify({'error': 'Invalid request. Dataset not found.'}), 404
data_path = dataset.get_file()
if request_data['action'] == 'fetch':
with open(data_path, 'r') as data_file:
data = loads(data_file.read())
return jsonify({'success': 'Successfully downloaded dataset', 'data': data}), 200
default = request_data['default']
creator = request_data['creator']
try: user = User.query.filter_by(id=creator).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
name = request_data['name']
data = request_data['data']
if not validate_json(data): return jsonify({'error': 'The data you submitted was invalid.'}), 400
dataset.set_name(name)
dataset.creator = user
success, message = dataset.update(data=data, default=default)
if not success: return jsonify({'error': message}), 400
return jsonify({'success': message}), 200
@api.route('/editor/new/', methods=['POST'])
@login_required
def _editor_new():
new_dataset = Dataset()
new_dataset.set_name('New Dataset')
success, message = new_dataset.create(data=[], default=False)
if not success: return jsonify({'error':message}), 400
flash(message, 'success')
return jsonify({'success': message, 'redirect_to': url_for('editor._editor_console', id=new_dataset.id)}), 200

View File

@ -1,14 +0,0 @@
from flask import Blueprint
api = Blueprint(
name='api',
import_name=__name__
)
@api.route('/questions/', methods=['POST'])
def _fetch_questions():
return 'Fetch Questions'
@api.route('/submit/', methods=['POST'])
def _submit_quiz():
return 'Submit Quiz'

View File

@ -1,42 +1,58 @@
import os
from dotenv import load_dotenv
load_dotenv()
from pathlib import Path
from dotenv import load_dotenv
load_dotenv('../.env')
class Config(object):
"""Basic App Configuration"""
APP_HOST = '0.0.0.0'
DATA_FILE_DIRECTORY = os.getenv('DATA_FILE_DIRECTORY')
DATA = './data/'
DEBUG = False
TESTING = False
SECRET_KEY = os.getenv('SECRET_KEY')
SERVER_NAME = os.getenv('SERVER_NAME')
SESSION_COOKIE_SECURE = True
SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(DATA_FILE_DIRECTORY)}/database.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
WTF_CSRF_TIME_LIMIT = None
"""Email Engine Configuration"""
MAIL_SERVER = os.getenv('MAIL_SERVER')
MAIL_PORT = int(os.getenv('MAIL_PORT'))
MAIL_PORT = int(os.getenv('MAIL_PORT') or 25)
MAIL_USE_TLS = False
MAIL_USE_SSL = False
MAIL_DEBUG = False
MAIL_USERNAME = os.getenv('MAIL_USERNAME')
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER')
MAIL_MAX_EMAILS = int(os.getenv('MAIL_MAX_EMAILS'))
MAIL_MAX_EMAILS = int(os.getenv('MAIL_MAX_EMAILS') or 25)
MAIL_SUPPRESS_SEND = False
MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS'))
MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS') or True)
class ProductionConfig(Config):
"""Database Driver Configuration"""
DATABASE_TYPE = os.getenv('DATABASE_TYPE') or 'SQLite'
SQLALCHEMY_TRACK_MODIFICATIONS = False
if DATABASE_TYPE.lower() == 'mysql' and os.getenv('MYSQL_DATABASE') and os.getenv('MYSQL_USER') and os.getenv('MYSQL_PASSWORD'):
DATABASE_HOST = os.getenv('DATABASE_HOST') or 'localhost'
DATABASE_PORT = int(os.getenv('DATABASE_PORT') or 3306)
MYSQL_DATABASE = os.getenv('MYSQL_DATABASE')
MYSQL_USER = os.getenv('MYSQL_USER')
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD')
SQLALCHEMY_DATABASE_URI = f'mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{MYSQL_DATABASE}'
else: SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(os.path.abspath(f"{DATA}/db.sqlite"))}'
class Production(Config):
pass
class DevelopmentConfig(Config):
class Development(Config):
APP_HOST = '127.0.0.1'
DEBUG = True
SERVER_NAME = '127.0.0.1:5000'
SESSION_COOKIE_SECURE = False
MAIL_SERVER = 'localhost'
MAIL_DEBUG = True
MAIL_SUPPRESS_SEND = False
class TestingConfig(DevelopmentConfig):
class Testing(Development):
TESTING = True
SESSION_COOKIE_SECURE = False
MAIL_SERVER = os.getenv('MAIL_SERVER')

View File

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

View File

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

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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

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

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 (let i = 0; i < response.responseJSON.error.length; i ++) {
output += `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
$alert.html(output);
}
}
$alert.focus()
}
// Dismiss Cookie Alert
$('#dismiss-cookie-alert').click(function(event){
$.ajax({
url: '/cookies/',
type: 'POST',
data: {
time: Date.now()
},
dataType: 'json',
success: function(response){
console.log(response);
},
error: function(response){
console.log(response);
}
})
event.preventDefault();
})
// Create New Dataset
$('.create-new-dataset').click(function(event){
$.ajax({
url: '/api/editor/new/',
type: 'POST',
data: {
time: Date.now()
},
dataType: 'json',
success: function(response){
if (response.redirect_to) {
window.location.href = response.redirect_to;
}
},
error: function(response){
console.log(response);
}
})
event.preventDefault()
})

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 and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&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,137 @@
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
<div class="container">
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbar"
aria-controls="navbar"
aria-expanded="false"
aria-label="Toggle Navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navbar">
<ul class="navbar-nav">
{% if not current_user.is_authenticated %}
<li class="nav-item" id="nav-login">
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
</li>
{% endif %}
{% if current_user.is_authenticated %}
<li class="nav-item dropdown" id="nav-results">
<a
class="nav-link dropdown-toggle"
id="dropdown-results"
role="button"
href="{{ url_for('admin._view_entries') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Results
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-results"
>
<li>
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
</li>
<li>
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-tests">
<a
class="nav-link dropdown-toggle"
id="dropdown-tests"
role="button"
href="{{ url_for('admin._tests') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Exams
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-tests"
>
<li>
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-settings">
<a
class="nav-link dropdown-toggle"
id="dropdown-settings"
role="button"
href="{{ url_for('admin._settings') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Settings
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-settings"
>
<li>
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
</li>
<li>
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
</li>
<li>
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
</li>
<li>
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
</li>
<li>
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-account">
<a
class="nav-link dropdown-toggle"
id="dropdown-account"
role="button"
href="{{ url_for('admin._update_user', id=current_user.id) }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Account
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-account"
>
<li>
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
</li>
<li>
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
</li>
</ul>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>

View File

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

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

@ -0,0 +1,43 @@
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% set cookie_flash_flag = namespace(value=False) %}
{% for category, message in messages %}
{% if category == "error" %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Error" aria-title="Error"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif category == "success" %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check2-circle" title="Success" aria-title="Success"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif category == "warning" %}
<div class="alert alert-warning alert-dismissible fade show" role="alert">
<i class="bi bi-info-circle-fill" aria-title="Warning" title="Warning"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif category == "cookie_alert" %}
{% if not cookie_flash_flag.value %}
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
{{ message|safe }}
<div class="d-flex justify-content-center w-100">
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
</div>
</div>
{% set cookie_flash_flag.value = True %}
{% endif %}
{% else %}
<div class="alert alert-primary alert-dismissible fade show" role="alert">
<i class="bi bi-info-circle-fill" title="Alert"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% endwith %}

View File

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

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">Edit Questions</h2>
{{ form.hidden_tag() }}
<div class="form-select-input">
{{ form.dataset(placeholder="Select Question Dataset") }}
{{ form.dataset.label }}
</div>
{% include "admin/components/client-alerts.html" %}
<div class="container form-submission-button">
<div class="row">
<div class="col text-center">
<button class="btn btn-md btn-success btn-block" type="submit">
<i class="bi bi-pencil-fill button-icon"></i>
Edit
</button>
<button title="New" class="btn btn-md btn-primary create-new-dataset">
<i class="bi bi-cloud-plus-fill button-icon"></i>
New
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}

View File

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

View File

@ -0,0 +1,66 @@
from ..tools.forms import value
from flask_wtf import FlaskForm
from flask_wtf.file import FileAllowed, FileField, FileRequired
from wtforms import BooleanField, IntegerField, PasswordField, SelectField, StringField
from wtforms.fields import DateTimeLocalField
from wtforms.validators import InputRequired, Email, EqualTo, Length, Optional
class Login(FlaskForm):
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
remember = BooleanField('Remember Log In', render_kw={'checked': True})
class Register(FlaskForm):
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')])
class ResetPassword(FlaskForm):
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
class UpdatePassword(FlaskForm):
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')])
class CreateUser(FlaskForm):
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
password = PasswordField('Password (Optional)', validators=[Optional(),Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
notify = BooleanField('Notify accout creation by email', render_kw={'checked': True})
class DeleteUser(FlaskForm):
password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
notify = BooleanField('Notify deletion by email', render_kw={'checked': True})
class UpdateUser(FlaskForm):
confirm_password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)])
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')])
notify = BooleanField('Notify changes by email', render_kw={'checked': True})
class UpdateAccount(FlaskForm):
confirm_password = PasswordField('Current Password', validators=[InputRequired(), Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)])
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=20, message='The password must be between 6 and 20 characters long.')])
password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')])
class CreateTest(FlaskForm):
start_date = DateTimeLocalField('Start Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()] )
expiry_date = DateTimeLocalField('Expiry Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()] )
time_limit = SelectField('Time Limit')
dataset = SelectField('Question Dataset')
class UploadData(FlaskForm):
name = StringField('Name', validators=[InputRequired()])
data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])])
default = BooleanField('Make Default', render_kw={'checked': True})
class AddTimeAdjustment(FlaskForm):
time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)])
class EditDataset(FlaskForm):
dataset = SelectField('Question Dataset')

View File

@ -0,0 +1,11 @@
from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms.validators import InputRequired, Length, Email, Optional
class StartQuiz(FlaskForm):
first_name = StringField('First Name(s)', validators=[InputRequired(), Length(max=30)])
surname = StringField('Surname', validators=[InputRequired(), Length(max=30)])
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
club = StringField('Affiliated Club (Optional)', validators=[Optional(), Length(max=50)])
test_code = StringField('Exam Code', validators=[InputRequired(), Length(min=14, max=14)])
user_code = StringField('User Code (Optional)', validators=[Optional(), Length(min=6, max=6)])

View File

@ -1,142 +1,4 @@
from ..modules import db
from ..tools.encryption import decrypt, encrypt
from ..tools.logs import write
import secrets
from flask import flash, jsonify, session
from flask.helpers import url_for
from flask_login import UserMixin, login_user, logout_user
from werkzeug.security import check_password_hash, generate_password_hash
class User(UserMixin, db.Model):
id = db.Column(db.String(36), primary_key=True)
username = db.Column(db.String(128), nullable=False)
password = db.Column(db.String(128), nullable=False)
email = db.Column(db.String(128), nullable=False)
reset_token = db.Column(db.String(20), nullable=True)
verification_token = db.Column(db.String(20), nullable=True)
def __repr__(self):
return f'<user {self.username}> was added with <id {self.id}>.'
@property
def set_username(self): raise AttributeError('set_username is not a readable attribute.')
set_username.setter
def set_username(self, username:str): self.username = encrypt(username)
def get_username(self): return decrypt(self.username)
@property
def set_password(self): raise AttributeError('set_password is not a readable attribute.')
set_password.setter
def set_password(self, password:str): self.password = generate_password_hash(password, method="sha256")
def verify_password(self, password:str): return check_password_hash(self.password, password)
@property
def set_email(self): raise AttributeError('set_email is not a readable attribute.')
set_email.setter
def set_email(self, email:str): self.email = encrypt(email)
def get_email(self): return decrypt(self.email)
def register(self):
users = User.query.all()
for user in users:
if user.get_username() == self.get_username():
return False, f'Username {self.get_username()} already in use.'
elif user.get_email() == self.get_email():
return False, f'Email address {self.get_email()} already in use.'
db.session.add(self)
db.session.commit()
write('users.log', f'User \'{self.get_username()}\' was created with id \'{self.id}\'.')
return True, f'User {self.get_username()} was created successfully.'
def login(self, remember:bool=False):
self.authenticated = True
db.session.add(self)
db.session.commit()
login_user(self, remember = remember)
write('users.log', f'User \'{self.get_username()}\' has logged in.')
flash(message=f'Welcome {self.get_username()}', category='success')
def logout(self):
self.authenticated = False
db.session.add(self)
db.session.commit()
session['remembered_username'] = self.get_username()
logout_user()
write('users.log', f'User \'{self.get_username()}\' has logged out.')
flash(message='You have successfully logged out.', category='success')
def reset_password(self):
new_password = secrets.token_hex(12)
self.set_password(new_password)
self.reset_token = secrets.token_urlsafe(16)
self.verification_token = secrets.token_urlsafe(16)
db.session.commit()
print('Password', new_password)
print('Reset Token', self.reset_token)
print('Verification Token', self.verification_token)
print('Reset Link', f'{url_for("auth._reset", token=self.reset_token, verification=self.verification_token, _external=True)}')
return jsonify({'success': 'Your password reset link has been generated.'}), 200
def clear_reset_tokens(self):
self.reset_token = self.verification_token = None
db.session.commit()
def delete(self):
username = self.get_username()
db.session.delete(self)
db.session.commit()
write('users.log', f'User \'{username}\' was deleted.') # TODO add current user
class Device(db.Model):
id = db.Column(db.String(36), primary_key=True)
name = db.Column(db.String(128), nullable=False)
mac_address = db.Column(db.String(128), nullable=False)
ip_address = db.Column(db.String(128), nullable=False)
description = db.Column(db.String(250), nullable=True)
@property
def set_name(self): raise AttributeError('set_name is not a readable attribute.')
set_name.setter
def set_name(self, name:str): self.name = encrypt(name)
def get_name(self): return decrypt(self.name)
@property
def set_mac_address(self): raise AttributeError('set_mac_address is not a readable attribute.')
set_mac_address.setter
def set_mac_address(self, mac_address:str): self.mac_address = encrypt(mac_address)
def get_mac_address(self): return decrypt(self.mac_address)
@property
def set_ip_address(self): raise AttributeError('set_ip_address is not a readable attribute.')
set_ip_address.setter
def set_ip_address(self, ip_address:str): self.ip_address = encrypt(ip_address)
def get_ip_address(self): return decrypt(self.ip_address)
def add(self):
db.session.add(self)
db.session.commit()
write('commands.log', f'Device \'{self.get_name()}\' was added at the IP address \'{self.get_ip_address()}\' and the MAC address \'{self.get_mac_address()}\'.')
return True, f'Device {self.get_name()} was added.'
def delete(self):
name = self.get_name()
ip_address = self.get_ip_address()
mac_address = self.get_mac_address()
db.session.delete(self)
db.session.commit()
write('commands.log', f'Device \'{name}\' with the IP address {ip_address} and MAC address {mac_address} was deleted.')
return True, f'Device {name} was deleted.'
from .entry import Entry
from .test import Test
from .user import User
from .dataset import Dataset

View File

@ -0,0 +1,140 @@
from ..extensions import db
from ..tools.encryption import decrypt, encrypt
from ..tools.logs import write
from flask import current_app as app
from flask.helpers import flash
from flask_login import current_user
from werkzeug.utils import secure_filename
from datetime import datetime
from json import dump, loads
from os import path, remove
from pathlib import Path
from uuid import uuid4
class Dataset(db.Model):
id = db.Column(db.String(36), index=True, primary_key=True)
name = db.Column(db.String(128), nullable=False)
tests = db.relationship('Test', backref='dataset')
entries = db.relationship('Entry', backref='dataset')
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
date = db.Column(db.DateTime, nullable=False)
default = db.Column(db.Boolean, default=False, nullable=True)
accessed = db.Column(db.DateTime, nullable=True)
locked = db.Column(db.Boolean, default=False, nullable=True)
def __repr__(self):
return f'<Dataset {self.id}>.'
@property
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
generate_id.setter
def generate_id(self): self.id = uuid4().hex
@property
def set_name(self): raise AttributeError('set_name is not a readable attribute.')
set_name.setter
def set_name(self, name:str): self.name = encrypt(name)
def get_name(self): return decrypt(self.name)
def make_default(self):
try:
for dataset in Dataset.query.all(): dataset.default = False
except Exception as exception:
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
return False, f'Database error {exception}.'
self.default = True
try: db.session.commit()
except Exception as exception:
db.session.rollback()
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
return False, f'Database error {exception}.'
write('system.log', f'Dataset {self.id} set as default by {current_user.get_username()}.')
flash(message='Dataset set as default.', category='success')
return True, f'Dataset set as default.'
def delete(self):
if self.default:
message = 'Cannot delete the default dataset.'
flash(message, 'error')
return False, message
try:
if Dataset.query.count() == 1:
message = 'Cannot delete the only dataset.'
flash(message, 'error')
return False, message
except Exception as exception:
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
return False, f'Database error {exception}.'
write('system.log', f'Dataset {self.id} deleted by {current_user.get_username()}.')
filename = secure_filename('.'.join([self.id,'json']))
data = Path(app.config.get('DATA'))
file_path = path.join(data, 'questions', filename)
try:
db.session.delete(self)
db.session.commit()
except Exception as exception:
db.session.rollback()
write('system.log', f'Database error when trying to delete dataset {self.id}: {exception}')
return False, f'Database error: {exception}'
remove(file_path)
return True, 'Dataset deleted.'
def create(self, data:list, default:bool=False):
self.generate_id()
timestamp = datetime.now()
file_path = self.get_file()
with open(file_path, 'w') as file:
dump(data, file, indent=2)
self.date = timestamp
self.creator = current_user
if default: self.make_default()
write('system.log', f'New dataset {self.get_name()} added by {current_user.get_username()}.')
try:
db.session.add(self)
db.session.commit()
except Exception as exception:
db.session.rollback()
write('system.log', f'Database error when trying to crreate dataset {self.id}: {exception}')
return False, f'Database error: {exception}'
return True, 'Dataset created.'
def check_file(self):
filename = secure_filename('.'.join([self.id,'json']))
data = Path(app.config.get('DATA'))
file_path = path.join(data, 'questions', filename)
if not path.isfile(file_path): return False, 'Data file is missing.'
return True, 'Data file found.'
def get_file(self):
filename = secure_filename('.'.join([self.id,'json']))
data = Path(app.config.get('DATA'))
file_path = path.join(data, 'questions', filename)
return file_path
def get_data(self):
dataset_path = self.get_file()
with open(dataset_path, 'r') as _dataset:
data = loads(_dataset.read())
return data
def update(self, data:list=None, default:bool=False):
self.date = datetime.now()
if default: self.make_default()
file_path = self.get_file()
with open(file_path, 'w') as file:
dump(data, file, indent=2)
write('system.log', f'Dataset {self.id} edited by {current_user.get_username()}.')
flash(f'Dataset {self.get_name()} successfully edited.', 'success')
try:
db.session.add(self)
db.session.commit()
except Exception as exception:
db.session.rollback()
write('system.log', f'Database error when trying to update dataset {self.id}: {exception}')
return False, f'Database error: {exception}'
return True, 'Dataset successfully edited.'

View File

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

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