234 Commits

Author SHA1 Message Date
25fb3fdda4 Update python version number 2025-10-06 16:14:04 +01:00
fa104d7d1b Updated for before_first_request decorator deprecation 2025-10-06 16:11:57 +01:00
cb747b4832 Removed deprecated cryptography method 2025-10-06 16:11:07 +01:00
845fdcdf8d Updated dependencies 2025-10-06 16:09:57 +01:00
54e1653cb5 Fixed typo 2025-10-06 15:14:07 +01:00
716206dc65 Disable randomisation 2025-10-06 14:50:18 +01:00
a8eda4078d Added Barrowland Bears Korfball Club to list of clubs dropdown 2025-10-06 14:50:00 +01:00
d28cd6daed Updated instructions for the test 2023-10-20 20:58:50 +01:00
57b25cd214 Formatted DataTable date to ISO-8601 for sorting 2023-07-01 21:48:36 +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
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
178 changed files with 11299 additions and 479 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 # Ignore Encryption Keyfile
.encryption.key .encryption.key
# Ignore Data Dir
**/data/*
# Ignore Logs Dir
logs/*
# Ignore Certbot Dir
certbot/*
# Ignore src dir (exception for robots.txt)
src/html/*
src/html/robots.txt

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

View File

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

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

90
install-script.sh Normal file
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 # Certbot Renewal
location ^~ /.well-known/acme-challenge/ { location ^~ /.well-known/acme-challenge/ {
root /usr/share/nginx/html; root /usr/share/nginx/html/certbot;
allow all; allow all;
default_type "text/plain"; default_type "text/plain";
} }

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,16 @@
from .modules.bootstrap import bootstrap from .config import Production as Config
from .modules.csrf import csrf from .models import *
from .modules.db import db from .extensions import bootstrap, csrf, db, login_manager, mail
from .modules.login_manager import login_manager from .tools.logs import write
from .modules.mail import mail
from .config import DevelopmentConfig as Config
from flask import Flask from flask import flash, Flask, render_template, request
from flask_wtf.csrf import CSRFError from flask.helpers import abort, url_for
from flask.json import jsonify from flask.json import jsonify
from flask_wtf.csrf import CSRFError
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from datetime import datetime from datetime import datetime
from .admin.views import admin
from .api.views import api
from .views import views
from .quiz.views import quiz
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(Config()) app.config.from_object(Config())
@@ -30,22 +24,45 @@ def create_app():
login_manager.login_view = 'admin._login' login_manager.login_view = 'admin._login'
@login_manager.user_loader @login_manager.user_loader
def _load_user(user_id): def _load_user(id):
pass try: return User.query.filter_by(id=id).first()
except Exception as exception:
write('system.log', f'Database error when loading user fo login manager: {exception}')
return abort(500)
@app.before_request
def _check_cookie_consent():
if request.cookies.get('cookie_consent'):
return
if any([ request.path.startswith(x) for x in [ '/admin/static/', '/root/', '/quiz/static', '/cookies/', '/admin/editor/static', '/admin/view/static' ] ]):
return
flash(f'<strong>Cookie Consent</strong>: This web site only stores minimal, functional cookies. It does not store any tracking information. By using this site, you consent to this use of cookies. For more information, see our <a href="{url_for("views._privacy")}">privacy policy</a>.', 'cookie_alert')
@app.errorhandler(404) @app.errorhandler(404)
def _404_handler(error): def _404_handler(error): return render_template('404.html')
return jsonify({'error':'404 &mdash; Not Found'}), 404
@app.errorhandler(CSRFError) @app.errorhandler(CSRFError)
def _csrf_handler(): def _csrf_handler(): return jsonify({'error':'Could not validate a secure connection.'}), 403
return jsonify({'error':'Could not validate a secure connection.'}), 403
@app.context_processor @app.context_processor
def _now(): def _now(): return {'now': datetime.now()}
return {'now': datetime.utcnow()}
from .admin.views import admin
from .api.views import api
from .quiz.views import quiz
from .views import views
from .editor.views import editor
from .view.views import view
from .analysis.views import analysis
app.register_blueprint(admin, url_prefix='/admin') app.register_blueprint(admin, url_prefix='/admin')
app.register_blueprint(api, url_prefix='/api') app.register_blueprint(api, url_prefix='/api')
app.register_blueprint(views) app.register_blueprint(views)
app.register_blueprint(quiz) app.register_blueprint(quiz)
app.register_blueprint(editor, url_prefix='/admin/editor')
app.register_blueprint(view, url_prefix='/admin/view')
app.register_blueprint(analysis, url_prefix='/admin/analysis')
"""Create Database Tables when creating app"""
with app.app_context():
db.create_all()
return app 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,170 @@
{% extends "analysis/components/datatable.html" %}
{% block style %}
<link
rel="stylesheet"
href="{{ url_for('.static', filename='css/analysis.css') }}"
/>
{% endblock %}
{% block content %}
<h1>Analysis</h1>
<div class="container">
<p class="lead">
Analysis for {{ type }} {{ subject }}.
</p>
</div>
<div class="container">
<h3>
Question List
</h3>
<div class="container dataset-metadata">
<div class="input-group mb-3">
<span class="input-group-text">Dataset Name</span>
<span class="form-control">
{{ dataset.get_name() }}
</span>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Author</span>
<span class="form-control">
{{ dataset.creator.get_username() }}
</span>
</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>
{% if dataset.default %}
<div class="input-group mb-3">
<span class="input-group-text">
<input type="checkbox" aria-label="Default" class="dataset-default" checked disabled>
</span>
<span class="form-control">
Default Dataset
</select>
</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>
</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(dataset=data, randomise=False)
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 import os
from dotenv import load_dotenv
load_dotenv()
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv
load_dotenv('../.env')
class Config(object): class Config(object):
"""Basic App Configuration"""
APP_HOST = '0.0.0.0' APP_HOST = '0.0.0.0'
DATA = os.getenv('DATA') DATA = './data/'
DEBUG = False DEBUG = False
TESTING = False TESTING = False
SECRET_KEY = os.getenv('SECRET_KEY') SECRET_KEY = os.getenv('SECRET_KEY')
SERVER_NAME = os.getenv('SERVER_NAME')
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(DATA)}/database.db' WTF_CSRF_TIME_LIMIT = None
SQLALCHEMY_TRACK_MODIFICATIONS = False
"""Email Engine Configuration"""
MAIL_SERVER = os.getenv('MAIL_SERVER') MAIL_SERVER = os.getenv('MAIL_SERVER')
MAIL_PORT = int(os.getenv('MAIL_PORT')) MAIL_PORT = int(os.getenv('MAIL_PORT') or 25)
MAIL_USE_TLS = False MAIL_USE_TLS = False
MAIL_USE_SSL = False MAIL_USE_SSL = False
MAIL_DEBUG = False MAIL_DEBUG = False
MAIL_USERNAME = os.getenv('MAIL_USERNAME') MAIL_USERNAME = os.getenv('MAIL_USERNAME')
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD') MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER') MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER')
MAIL_MAX_EMAILS = int(os.getenv('MAIL_MAX_EMAILS')) MAIL_MAX_EMAILS = int(os.getenv('MAIL_MAX_EMAILS') or 25)
MAIL_SUPPRESS_SEND = False MAIL_SUPPRESS_SEND = False
MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS')) MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS') or True)
class ProductionConfig(Config): """Database Driver Configuration"""
DATABASE_TYPE = os.getenv('DATABASE_TYPE') or 'SQLite'
SQLALCHEMY_TRACK_MODIFICATIONS = False
if DATABASE_TYPE.lower() == 'mysql' and os.getenv('MYSQL_DATABASE') and os.getenv('MYSQL_USER') and os.getenv('MYSQL_PASSWORD'):
DATABASE_HOST = os.getenv('DATABASE_HOST') or 'localhost'
DATABASE_PORT = int(os.getenv('DATABASE_PORT') or 3306)
MYSQL_DATABASE = os.getenv('MYSQL_DATABASE')
MYSQL_USER = os.getenv('MYSQL_USER')
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD')
SQLALCHEMY_DATABASE_URI = f'mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{MYSQL_DATABASE}'
else: SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(os.path.abspath(f"{DATA}/db.sqlite"))}'
class Production(Config):
pass pass
class DevelopmentConfig(Config): class Development(Config):
APP_HOST = '127.0.0.1' APP_HOST = '127.0.0.1'
DEBUG = True DEBUG = True
SERVER_NAME = '127.0.0.1:5000'
SESSION_COOKIE_SECURE = False SESSION_COOKIE_SECURE = False
MAIL_SERVER = 'localhost' MAIL_SERVER = 'localhost'
MAIL_DEBUG = True MAIL_DEBUG = True
MAIL_SUPPRESS_SEND = False MAIL_SUPPRESS_SEND = False
class TestingConfig(DevelopmentConfig): class Testing(Development):
TESTING = True TESTING = True
SESSION_COOKIE_SECURE = False SESSION_COOKIE_SECURE = False
MAIL_SERVER = os.getenv('MAIL_SERVER') MAIL_SERVER = os.getenv('MAIL_SERVER')

View File

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

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,10 @@
from flask_bootstrap import Bootstrap
bootstrap = Bootstrap()
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect()
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
from flask_login import LoginManager
login_manager = LoginManager()
from flask_mail import Mail
mail = Mail()

View File

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

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