Compare commits
615 Commits
f170ff5e52
...
43895bead0
Author | SHA1 | Date | |
---|---|---|---|
43895bead0 | |||
067ef4fd7f | |||
73f31016fd | |||
25115a6fae | |||
6028ac2d3c | |||
225ef71518 | |||
fbae88eed1 | |||
647d156802 | |||
08a140a73b | |||
a8a01e17da | |||
3f59d1b1b7 | |||
5123365567 | |||
d0166f0901 | |||
f6231dc779 | |||
5c8435d39e | |||
e4e07c43b4 | |||
d202e83189 | |||
e264b808fc | |||
4b08c830a1 | |||
b9d45f94fe | |||
2ea778143e | |||
62160beab2 | |||
1a7983052f | |||
a1bee61679 | |||
126bf9203c | |||
a58f267586 | |||
22878b5398 | |||
52b44128fa | |||
8439d99949 | |||
66e7b2b9f8 | |||
9459b93c9b | |||
09e444344d | |||
767dcede54 | |||
4431564304 | |||
da821bcadb | |||
b58a23cf13 | |||
dc126459bc | |||
2c5ed21011 | |||
59281db9cb | |||
2a3927a140 | |||
9a225543c6 | |||
dd8685b103 | |||
625ef8883b | |||
f903f9d060 | |||
eac9ee7ab1 | |||
8946e3eaf3 | |||
b27016aaf4 | |||
89788550fb | |||
6992a75855 | |||
9539ba22fe | |||
85ced0cc20 | |||
eac6cac7bc | |||
fcfde34c72 | |||
1b111727be | |||
436c8e0e2d | |||
9c0c7f6ba1 | |||
7af588da6c | |||
cfd750894a | |||
ede71f7d82 | |||
27706572ed | |||
08da6d71c4 | |||
c5a0bbb827 | |||
8680c73e86 | |||
ff74e92297 | |||
6b3b255cfd | |||
ecdb5df561 | |||
c5b4d948f5 | |||
c40ef7d070 | |||
b8081bc1c8 | |||
efec599225 | |||
614ad91e3d | |||
6605620d9c | |||
cd4d52692c | |||
2038965dcb | |||
b4c94a7ddb | |||
f144097c5d | |||
63f72e35d2 | |||
57ee0bf971 | |||
735cdec139 | |||
8591184da6 | |||
38d3420e4d | |||
7b5861ade6 | |||
f0437dceaa | |||
fa4640840b | |||
ca30b002ed | |||
05a564f41d | |||
7b2f155b14 | |||
f9628df8c7 | |||
a10bb0384f | |||
b5443c1331 | |||
fe83a47dae | |||
3d7e144d12 | |||
3c9fcae9f8 | |||
d093c4e636 | |||
1d5dfaa5ee | |||
57f233f20f | |||
a35d0ef7f1 | |||
4a5bc48889 | |||
0bdd50f432 | |||
f2fb52aeca | |||
52afd249b7 | |||
4a8080f0c8 | |||
443568f8ff | |||
5ab2e7e608 | |||
7b1ae3b354 | |||
bae8d1e6f8 | |||
36ed23564d | |||
4585b93136 | |||
14272ba0b8 | |||
0130f7412d | |||
8b4ca65122 | |||
f3f8ac955c | |||
8bfc8e119c | |||
0ccb62ce3c | |||
2507a1d00b | |||
fed4b6739f | |||
dd22b51fe1 | |||
f2b261f0b0 | |||
526d940c54 | |||
485e51f239 | |||
9f4e9637c9 | |||
1adb4867d5 | |||
55aa5496db | |||
b7ef513870 | |||
331e49a6bc | |||
2027e525e2 | |||
59fc703bcb | |||
c466f06384 | |||
8d80666ed8 | |||
3d9a3ecdff | |||
a8e938e802 | |||
4c4927df31 | |||
f8126b42fe | |||
407ee49bff | |||
b0bb600e12 | |||
0e8fbf148a | |||
0ef72ec338 | |||
721af501d1 | |||
e6f1338ee4 | |||
0e50e2c1b9 | |||
b0980b1871 | |||
ea9132542f | |||
b7fb30ce36 | |||
fe75fa1a49 | |||
f86fa6f4b5 | |||
6c293c2ce6 | |||
d3ed32183c | |||
e8090f30d7 | |||
176a0f069f | |||
302d8a933a | |||
c5587fcb73 | |||
a4b4bfe0ee | |||
0faef8651a | |||
4f925eae2f | |||
a9f5ba51c4 | |||
5b0fd0ced3 | |||
eca786d444 | |||
affb309ffc | |||
0e1db9d21d | |||
003d998b72 | |||
dccc85370e | |||
355a6bff5e | |||
98638e803a | |||
6c4ab2e1e3 | |||
e13069bed6 | |||
5b6f83c294 | |||
7295a2751c | |||
dd72da6ae6 | |||
36cdeb15ad | |||
eb6f5b876c | |||
14500434d7 | |||
35dffd358b | |||
fafb3fcc2e | |||
4131dd054a | |||
f370496780 | |||
667ad4ebc2 | |||
52e3ce4c93 | |||
ca0e6c82cb | |||
860c18c5fd | |||
46cef8cd1e | |||
421445d8d5 | |||
b0d3ff3fc1 | |||
68aef968e2 | |||
8d29944d5d | |||
8fbb52d366 | |||
1dbe4215ec | |||
101f6786f5 | |||
fe5cf189cc | |||
cefb5fe849 | |||
f0c7873257 | |||
0cb8ff9991 | |||
4d77021d58 | |||
fa05a17508 | |||
5960d0103d | |||
3535622380 | |||
86abae01c0 | |||
7c2adc9cac | |||
e119c344dd | |||
c7b54d2119 | |||
e6841b7744 | |||
6835232698 | |||
5392ff86ed | |||
328a78a923 | |||
9810577c5d | |||
2c93b0d3a7 | |||
343cb3f8b1 | |||
961e8629cb | |||
378e8eeae3 | |||
fe898aaf7d | |||
a010d7d290 | |||
8b962c53a9 | |||
bceb91b406 | |||
a14b7bf305 | |||
3622baf988 | |||
24545feea0 | |||
bb9233eeae | |||
60b8aad419 | |||
6e541c6a7b | |||
685b1b928d | |||
e0c2570515 | |||
5163914875 | |||
467b6d9ce7 | |||
e5aab6268d | |||
383ae11cd3 | |||
348ee95d1c | |||
9db80c9148 | |||
20b447adbb | |||
669bbd2f7b | |||
22b483b021 | |||
21ad8b2f94 | |||
a3a13d4eb6 | |||
a357ffe28d | |||
e00e2b17b0 | |||
65d679afbb | |||
891ec2fd38 | |||
4be21a2ca2 | |||
efd4dc440d | |||
935b465a19 | |||
05fa5bf274 | |||
1d1e2acf62 | |||
c742edb57c | |||
529504509e | |||
852b2664ce | |||
8b1b0162cc | |||
56e5d29416 | |||
ee50306370 | |||
559e5b96c4 | |||
4c2a6e7f74 | |||
daaf173ff6 | |||
05de6d716b | |||
f740ee7f1b | |||
c56c0dc822 | |||
0c446b9ae7 | |||
9ebec5000c | |||
ce32b33eaa | |||
45e0d37f81 | |||
d353a80269 | |||
8e7a09edca | |||
616bd3f578 | |||
108297cbfd | |||
9e03db595b | |||
3bfd08411b | |||
a4affa72a9 | |||
12c424be08 | |||
e00b4a9045 | |||
0ad7089722 | |||
707890ce3a | |||
7bdca9b895 | |||
bd1ac46942 | |||
11f965e20f | |||
ee99dd9038 | |||
65ec27b35b | |||
63ca5e33de | |||
1f228c7f1c | |||
56191f5e7a | |||
cbc8d276eb | |||
cd68a60001 | |||
dd7e3cad7a | |||
32908bde7d | |||
835c5e2aa6 | |||
6823c12b2d | |||
c7907dc24d | |||
e4d97869da | |||
dfbf10e2dd | |||
dbd25ddf38 | |||
11d839aada | |||
3980be3701 | |||
|
43cb31849a | ||
|
39cdafc847 | ||
|
bdeb026a7c | ||
|
73f4825bbe | ||
|
e1ecb5bcb6 | ||
|
1651f63577 | ||
|
a01d486d99 | ||
|
2b71c77c6c | ||
112c097d69 | |||
b6af6d5c15 | |||
6c4ca715f6 | |||
972673f5d1 | |||
cb1bc69f47 | |||
a4058c475b | |||
0004d2714f | |||
20efd4444c | |||
|
13465859ab | ||
|
53050f1358 | ||
|
f025eee4a6 | ||
|
506a6cf6c2 | ||
|
97db70abff | ||
|
1a1d763d67 | ||
|
598dfa45e8 | ||
|
ca36772f29 | ||
bd3205f06e | |||
ab7a25182f | |||
e3bb2895ae | |||
3e1e57a067 | |||
42f90c667d | |||
b02277f12f | |||
a9ad171249 | |||
bc42ae86d1 | |||
|
cc3410a1f6 | ||
|
953d3658a8 | ||
|
70f6875ac1 | ||
|
5da08d5c37 | ||
|
534247ece3 | ||
|
9525694e39 | ||
|
31903626f0 | ||
|
0111547676 | ||
e70592b276 | |||
22a0d58996 | |||
3d6a1dc7ba | |||
51d468fb44 | |||
164d43be8b | |||
cdf47e0b88 | |||
2427d55310 | |||
757cc94f33 | |||
|
0cfac25ed3 | ||
|
0443e348ac | ||
|
f2c0090aa3 | ||
|
ae75498edb | ||
|
7f3e251ac4 | ||
|
233e173735 | ||
|
c5686fbd40 | ||
|
94556d0731 | ||
ccab358464 | |||
79b0e83eba | |||
22e163f036 | |||
511eccac99 | |||
8ec0967f40 | |||
ae1380407c | |||
1e7222c781 | |||
b65b71df7a | |||
9a4820c725 | |||
6c327c7978 | |||
c730fca3eb | |||
ba106ff684 | |||
738f4eae86 | |||
d114b061b4 | |||
9b5b97eb1d | |||
52ab3af1f2 | |||
79ca8fc932 | |||
3a380c9f50 | |||
b9bff4812b | |||
dedd2d3449 | |||
bf7e0a2a18 | |||
d34aa82e86 | |||
af9b5210fa | |||
389fbf99aa | |||
1cafa04763 | |||
bc68089f87 | |||
9b7a3b3ec0 | |||
23136b7e40 | |||
2e4035d8a4 | |||
7063fe271e | |||
8d65b0c089 | |||
9988a989a6 | |||
20e418aeae | |||
9affa657c4 | |||
395ddbd460 | |||
93b8ac40df | |||
09f71fc5a7 | |||
e694119a58 | |||
67bbab0061 | |||
9992138bc4 | |||
f548221a10 | |||
4d883e8dce | |||
92e2462bb9 | |||
6ea02c28d4 | |||
05a8a78ed9 | |||
ac5d17fc66 | |||
37d7e5010f | |||
ce40568870 | |||
f4234f57b1 | |||
b8c652e78a | |||
9d760aafef | |||
4da025d50f | |||
787b741687 | |||
2aca8015af | |||
89ae75050b | |||
efa83d2bf8 | |||
388d89d95d | |||
8a368dbd16 | |||
4f842223cd | |||
81eac4b880 | |||
f03c92082e | |||
3a63c72bbb | |||
c3f6d45883 | |||
27cead22ad | |||
3a39ff6fc3 | |||
8ab0a5e164 | |||
c3c6e5084a | |||
ef7de71a5b | |||
1a1dff2c5d | |||
da6d380786 | |||
a1ed557dc2 | |||
3ffb4a68e1 | |||
12d9cd39be | |||
0fd7ac7f1f | |||
66d8fb7d93 | |||
cca2633f1a | |||
e1fcad3b42 | |||
4aad0c1213 | |||
ef1cad1995 | |||
ab2ca04ceb | |||
c88c142f7f | |||
ff6865c7ca | |||
488389057c | |||
186e83f92a | |||
da6ae3c826 | |||
23d6f833d7 | |||
17f9ef79b7 | |||
231f1d97bc | |||
dbc0c782c0 | |||
27bb07a942 | |||
0d63413835 | |||
a126d1f91d | |||
30e298aa02 | |||
cc8db3fea4 | |||
7c2b9df0d0 | |||
3b605c3340 | |||
d8e7bf6ae8 | |||
3c903424fb | |||
766487b669 | |||
0e52c12b35 | |||
3a1abe5157 | |||
9a2d738653 | |||
5c6f56f1c3 | |||
329538f7f5 | |||
cfdb4db0c3 | |||
5151b98f97 | |||
b102dc86aa | |||
d9dc2e209f | |||
86f8c12279 | |||
c71e91326f | |||
41d92b97a0 | |||
2f6ccd530a | |||
5d9dba0e3d | |||
ee159402d0 | |||
82ed0cf7cc | |||
66f2da31b6 | |||
cf39f83243 | |||
5bd04d8dc0 | |||
48624584fe | |||
fb7f9e328d | |||
c7ddf034a3 | |||
e001ccfa01 | |||
b6179430be | |||
8924232a93 | |||
ac36309527 | |||
7eddcabb7f | |||
f66d62db37 | |||
567b272161 | |||
2f04671ec5 | |||
c375576436 | |||
c536fb95b2 | |||
fbe3a59847 | |||
6472241dfc | |||
998ec597b1 | |||
3470f7422c | |||
9be3b1a487 | |||
c00ffd3ed0 | |||
f17ba4f6bf | |||
700850434a | |||
019622bd85 | |||
fe61456922 | |||
64f1da772a | |||
6b79fb8ebe | |||
8963e5461e | |||
a780b2330e | |||
a3a1c2ab2f | |||
dcd047a5ae | |||
268fa36507 | |||
f0ba8777e3 | |||
43989af1f1 | |||
0a6a14f8d0 | |||
5dfc3379fc | |||
c08e1c7010 | |||
2479fd193b | |||
a6ad184447 | |||
ff9ede6cce | |||
05b68fdd95 | |||
900929b875 | |||
8cf9629bf1 | |||
40926c1063 | |||
ba47f79d44 | |||
6f4353266c | |||
abfa7b21ba | |||
2536e595f0 | |||
bda9946859 | |||
a67ea9951b | |||
756af0a064 | |||
7caf54a5ba | |||
222b8e8a8b | |||
2875c59460 | |||
bb09930116 | |||
31736bfbaf | |||
b5625a5fb2 | |||
6103010169 | |||
283dfe8ecf | |||
faeaeb8b2c | |||
75db9fde3c | |||
91621625e6 | |||
d23d3ca6d1 | |||
8969505383 | |||
e9ff14d63e | |||
10b325ad29 | |||
a15844f52d | |||
e0cac3c800 | |||
be26a19f2e | |||
218090d1e5 | |||
f65e5b122f | |||
f3cb7deaf4 | |||
1745299e12 | |||
b17e04de71 | |||
b66b94fd83 | |||
2af61ca986 | |||
7269cec73d | |||
68a6507c1b | |||
e48ab4b58a | |||
f38e9df6b9 | |||
1f661a7038 | |||
66b4c50221 | |||
9f8a6e1a27 | |||
d9b72bce0c | |||
e829514e91 | |||
a1d19b4474 | |||
d29a5984f1 | |||
0b2a74ddd3 | |||
a1c3e79e90 | |||
7b1b789644 | |||
963453d2d6 | |||
46ab5d620b | |||
6593d372e0 | |||
cffafa82d9 | |||
dc432c4ac9 | |||
f0c4f237de | |||
99bd4df741 | |||
a866699f5d | |||
75b43f8993 | |||
e50ad9430e | |||
173b1e329b | |||
346238dab8 | |||
9913c9e084 | |||
ad16311941 | |||
493f71ac20 | |||
3f29b504b2 | |||
565486aef3 | |||
e5cecd6102 | |||
795545e8af | |||
b4f021bb8b | |||
dcafde1158 | |||
9b038dc8e4 | |||
4a201f3f9d | |||
a57f5476c0 | |||
240bcc6dd4 | |||
add2001ba3 | |||
70f362015c | |||
459c630db7 | |||
89bb802e45 | |||
475fdfcca7 | |||
db755334d0 | |||
1980363c12 | |||
07c8b62dc1 | |||
4c14c85a47 | |||
40119c9e9c | |||
8432884479 | |||
82b16ec9fb | |||
11a0dc3a4a | |||
2348c76ee8 | |||
6518458768 | |||
aab5325255 | |||
af8ea5ddc3 | |||
e730607c66 | |||
87f60e1826 | |||
0c3199515b | |||
7c5e3c1e43 | |||
274eb2d214 | |||
7aa5be57cd | |||
2e77b1a216 | |||
e3fdf08b2c | |||
2d1cdd5e94 | |||
af5e6172e9 | |||
88a4fc02d1 | |||
d6bc6df86b | |||
2fce2e0c80 | |||
bf1d53d07d | |||
2482242f20 | |||
0d7fa41261 | |||
2e9e15be95 | |||
08f2585def | |||
f8d05f2cec | |||
fd89626172 | |||
e1967bcd7e | |||
79193d897e | |||
2064ac508a | |||
66a950f757 |
20
.env.example
Normal file
@ -0,0 +1,20 @@
|
||||
SERVER_NAME= # URL where this will be hosted.
|
||||
|
||||
## Flask Configuration
|
||||
SECRET_KEY= # Long, secure, secret string.
|
||||
DATA=./data/
|
||||
|
||||
## Flask Mail Configuration
|
||||
MAIL_SERVER=postfix # Must match name of the Docker service
|
||||
MAIL_PORT=25
|
||||
MAIL_USE_TLS=False
|
||||
MAIL_USE_SSL=False
|
||||
MAIL_USERNAME= # Username@domain, must match config values below
|
||||
MAIL_PASSWORD= # Must match config value below
|
||||
MAIL_DEFAULT_SENDER= # NoReply@domain or some such.
|
||||
MAIL_MAX_EMAILS=25
|
||||
MAIL_ASCII_ATTACHMENTS=True
|
||||
|
||||
# Postfix
|
||||
maildomain= # Domain must match the section of username above
|
||||
smtp_user= # username:password. Must match config values above.
|
149
README.md
@ -14,27 +14,154 @@ The clien is designed to work on a server.
|
||||
|
||||
### Pre-Requisites
|
||||
|
||||
Server
|
||||
Docker
|
||||
Docker-Compose
|
||||
Git
|
||||
- A Debian- or Ubuntu-based server, preferably the latest distribution.
|
||||
- Docker (specifically, Docker Engine)
|
||||
- Docker Compose
|
||||
- Git
|
||||
|
||||
### Installation
|
||||
|
||||
#### Install all the pre-requisites
|
||||
|
||||
The first step is to ensure all the prerequisites are available on the server.
|
||||
|
||||
To set up the server, consult some of the comprehensive guides on various hosting platforms like Linode or DigitalOcean.
|
||||
Here is a [good starting point on setting up a server](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu-22-04).
|
||||
|
||||
To install Docker and Docker Compose, consult the respective documentation:
|
||||
- [Install on Ubuntu](https://docs.docker.com/engine/install/ubuntu/) or [Install on Debian](https://docs.docker.com/engine/install/debian/)
|
||||
- Docker Compose should be installed as part of that.
|
||||
|
||||
```
|
||||
At the time of writing, there has been an upgrade to Docker and Docker Compose, meaning the syntax below might be different between versions.
|
||||
```
|
||||
|
||||
Check if Git is installed on your server using the `git --version` command.
|
||||
If it isn't installed, install it.
|
||||
This should normally come pre-packaged with your OS distribution.
|
||||
But if it doesn't, look up how to for whatever OS you use.
|
||||
If you are using Ubuntu or Debian, it should be as easy as using the command:
|
||||
|
||||
```$ sudo apt-get install git -y```
|
||||
|
||||
#### Preliminary Set-Up: Clone repos and Configure Values
|
||||
|
||||
#### Set Up Web Server
|
||||
Open a terminal and navigate to the folder where you want to install this app.
|
||||
I would suggest using a subfolder within your Home folder:
|
||||
|
||||
#### Incorporate SSL
|
||||
```$ cd ~ && mkdir ska-referee-test && cd ska-referee-test```
|
||||
|
||||
#### Set Up Auto-Renew
|
||||
That way, you will ensure you can read and write all the necessary files during installation.
|
||||
Once in the destination folder, clone all the relevant files you will need for the installation:
|
||||
|
||||
### Alterations
|
||||
```$ git clone https://git.vsnt.uk/viveksantayana/ska-referee-test.git .```
|
||||
|
||||
## Use
|
||||
(Remember to include the trailing dot at the end, as that indicates to Git to download the files in the current directory.)
|
||||
|
||||
## Compatibility
|
||||
#### Populate Environment Variables
|
||||
|
||||
### iOS Limitations
|
||||
Configuration values for the app are stored in the environment variables file.
|
||||
To set it up, make a copy of the example file and populate it with appropriate values.
|
||||
|
||||
```$ cp .env.example .env```
|
||||
|
||||
Make sure to use complex, secure strings for passwords.
|
||||
Also make sure that the various entries for usernames and passwords match.
|
||||
|
||||
#### Input Specific Values for Your Installation
|
||||
|
||||
There are some values in the following four files you will need to configure to reflect the domain you are installing this app.
|
||||
|
||||
```
|
||||
# .env
|
||||
|
||||
SERVER_NAME= # URL where this will be hosted.
|
||||
```
|
||||
|
||||
```
|
||||
# install-script.sh
|
||||
|
||||
domains=(example.org www.example.org)
|
||||
email="" # Adding a valid address is strongly recommended
|
||||
```
|
||||
|
||||
Substitute the domain name `domain_name` in the two file paths in the following file:
|
||||
|
||||
```
|
||||
# nginx/ssl.conf
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem;
|
||||
...
|
||||
```
|
||||
|
||||
And **six** locations in the following file, two for the regular version of the domain and two for the www version:
|
||||
|
||||
```
|
||||
# nginx/conf.d/ref-test-app.conf
|
||||
|
||||
server {
|
||||
server_name domain_name;
|
||||
listen 80 default_server;
|
||||
...
|
||||
}
|
||||
|
||||
server {
|
||||
server_name domain_name;
|
||||
listen 443 ssl http2 default_server;
|
||||
...
|
||||
}
|
||||
|
||||
server {
|
||||
server_name www.domain_name;
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
# Redirect to non-www
|
||||
return 301 $scheme://domain_name$request_uri; ...
|
||||
}
|
||||
|
||||
server {
|
||||
server_name www.domain_name;
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
...
|
||||
|
||||
# Redirect to non-www
|
||||
return 301 $scheme://domain_name$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
#### Installing SSL Certificates
|
||||
|
||||
The app will use SSL certificates to operate through a secure, `https` connection.
|
||||
This will be set up automatically.
|
||||
However, there is a specific chicken-and-egg problem as the web server, Nginx, won't run without certificates, Certbot, the certificate generator, won't run without the web server.
|
||||
So to solve this, there is an automation script we can run that will set up a dummy certificate and then issue the appropriate certificates for us.
|
||||
|
||||
```
|
||||
$ chmod +x install-script.sh
|
||||
$ sudo ./install-script.sh
|
||||
```
|
||||
|
||||
This will take a long time to run the first time because it will try and generate a fairly sizeable cypher.
|
||||
|
||||
When we later run the server, Certbot will check for renewals of the SSL certificates every 12 hours, and Nginx will reload the configurations every 6 hours, to make sure everything runs smoothly and stays live.
|
||||
|
||||
#### Run the Stack
|
||||
|
||||
Everything should be good to run on autopilot at this point.
|
||||
Navigate to the root folder of the app, the folder where you have `install-script.sh` and `docker-compose.yml`.
|
||||
Run the following command:
|
||||
|
||||
```sudo docker compose up -d```
|
||||
|
||||
And you should have the stack running.
|
||||
You can register in the app and begin using it.
|
||||
|
||||
### Fonts
|
||||
|
||||
The app uses [OpenDyslexic](https://opendyslexic.org/), which is available on-line.
|
||||
It also has the option of rendering in other system fonts, but this can vary depending on your operating system.
|
||||
Because these are proprietary fonts, they cannot be made available on-line in the same way as open source ones should your system not have them.
|
||||
Some fonts may not display correctly as a result.
|
||||
|
@ -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
|
@ -1,15 +1,16 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
ref_test_server:
|
||||
container_name: ref_test_server
|
||||
image: nginx:1.21.4-alpine
|
||||
nginx:
|
||||
container_name: reftest_server
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
- ./certbot:/etc/letsencrypt:ro
|
||||
- ./nginx:/etc/nginx
|
||||
- ./src/html:/usr/share/nginx/html/
|
||||
- ./ref-test/admin/static:/usr/share/nginx/html/admin/static
|
||||
- ./ref-test/quiz/static:/usr/share/nginx/html/quiz/static
|
||||
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static
|
||||
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static
|
||||
- ./ref-test/app/root:/usr/share/nginx/html/root
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
@ -17,10 +18,11 @@ services:
|
||||
networks:
|
||||
- frontend
|
||||
depends_on:
|
||||
- ref_test_app
|
||||
- app
|
||||
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
||||
|
||||
ref_test_app:
|
||||
container_name: ref_test_app
|
||||
app:
|
||||
container_name: reftest_app
|
||||
image: reftest
|
||||
build: ./ref-test
|
||||
env_file:
|
||||
@ -28,32 +30,16 @@ services:
|
||||
ports:
|
||||
- 5000
|
||||
volumes:
|
||||
- ./.security:/ref-test/.security
|
||||
- ./ref-test/data:/ref-test/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
depends_on:
|
||||
- ref_test_db
|
||||
- ref_test_postfix
|
||||
- postfix
|
||||
|
||||
ref_test_db:
|
||||
container_name: ref_test_db
|
||||
image: mongo:5.0.4-focal
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./database/data:/data/db
|
||||
- ./database/initdb.d/:/docker-entrypoint-initdb.d/
|
||||
env_file:
|
||||
- ./.env
|
||||
ports:
|
||||
- 27017
|
||||
networks:
|
||||
- backend
|
||||
|
||||
ref_test_postfix:
|
||||
container_name: ref_test_postfix
|
||||
postfix:
|
||||
container_name: reftest_postfix
|
||||
image: catatnight/postfix:latest
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
@ -63,15 +49,13 @@ services:
|
||||
networks:
|
||||
- backend
|
||||
|
||||
ref_test_certbot:
|
||||
container_name: ref_test_certbot
|
||||
image: certbot/certbot:v1.21.0
|
||||
certbot:
|
||||
container_name: reftest_certbot
|
||||
image: certbot/certbot
|
||||
volumes:
|
||||
- ./certbot:/etc/letsencrypt
|
||||
- ./src/html:/var/www/html
|
||||
depends_on:
|
||||
- ref_test_server
|
||||
# command: certonly --webroot --webroot-path=/var/www/html --email (email) --agree-tos --no-eff-email -d (domain)
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
|
87
install-script.sh
Normal file
@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Source https://pentacent.medium.com/nginx-and-lets-encrypt-with-docker-in-less-than-5-minutes-b4b8a60d3a71
|
||||
|
||||
if ! [ -x "$(command -v docker compose)" ]; then
|
||||
echo 'Error: docker compose is not installed.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
domains=(example.org www.example.org)
|
||||
rsa_key_size=4096
|
||||
data_path="./certbot"
|
||||
email="" # Adding a valid address is strongly recommended
|
||||
staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits
|
||||
|
||||
if [ -d "$data_path" ]; then
|
||||
read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision
|
||||
if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
|
||||
exit
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -e "$data_path/ssl-dhparams.pem" ]; then
|
||||
echo "### Generating ssl-dhparams.pem ..."
|
||||
docker compose run --rm --entrypoint "\
|
||||
openssl dhparam -out /etc/letsencrypt/ssl-dhparams.pem 4096" certbot
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "### Creating dummy certificate for $domains ..."
|
||||
path="/etc/letsencrypt/live/$domains"
|
||||
mkdir -p "$data_path/live/$domains"
|
||||
docker compose run --rm --entrypoint "\
|
||||
openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
|
||||
-keyout '$path/privkey.pem' \
|
||||
-out '$path/fullchain.pem' \
|
||||
-subj '/CN=localhost'" certbot
|
||||
echo
|
||||
|
||||
if [ ! -e "$data_path/lets-encrypt-x3-cross-signed.pem" ]; then
|
||||
echo "### Downloading lets-encrypt-x3-cross-signed.pem ..."
|
||||
wget -O $data_path/lets-encrypt-x3-cross-signed.pem \
|
||||
"https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem"
|
||||
docker compose run --rm --entrypoint "\
|
||||
openssl dhparam -out /etc/letsencrypt/ssl-dhparams.pem 4096" certbot
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "### Starting nginx ..."
|
||||
docker compose up --force-recreate -d nginx
|
||||
echo
|
||||
|
||||
echo "### Deleting dummy certificate for $domains ..."
|
||||
docker compose run --rm --entrypoint "\
|
||||
rm -Rf /etc/letsencrypt/live/$domains && \
|
||||
rm -Rf /etc/letsencrypt/archive/$domains && \
|
||||
rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot
|
||||
echo
|
||||
|
||||
echo "### Requesting Let's Encrypt certificate for $domains ..."
|
||||
#Join $domains to -d args
|
||||
domain_args=""
|
||||
for domain in "${domains[@]}"; do
|
||||
domain_args="$domain_args -d $domain"
|
||||
done
|
||||
|
||||
# Select appropriate email arg
|
||||
case "$email" in
|
||||
"") email_arg="--register-unsafely-without-email" ;;
|
||||
*) email_arg="--email $email" ;;
|
||||
esac
|
||||
|
||||
# Enable staging mode if needed
|
||||
if [ $staging != "0" ]; then staging_arg="--staging"; fi
|
||||
|
||||
docker compose run --rm --entrypoint "\
|
||||
certbot certonly --webroot -w /var/www/html \
|
||||
$staging_arg \
|
||||
$email_arg \
|
||||
$domain_args \
|
||||
--rsa-key-size $rsa_key_size \
|
||||
--agree-tos \
|
||||
--force-renewal" certbot
|
||||
echo
|
||||
|
||||
echo "### Reloading nginx ..."
|
||||
docker compose exec nginx nginx -s reload
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
@ -1,25 +1,25 @@
|
||||
upstream reftest {
|
||||
server ref_test_app:5000;
|
||||
server app:5000;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name domain_name;
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
# Redirect to ssl
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name domain_name;
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
listen 443 ssl http2 default_server;
|
||||
listen [::]:443 ssl http2 default_server;
|
||||
|
||||
#SSL configuration
|
||||
# SSL configuration
|
||||
include /etc/nginx/ssl.conf;
|
||||
include /etc/nginx/certbot-challenge.conf;
|
||||
|
||||
location ^~ /static/ {
|
||||
location ^~ /quiz/static/ {
|
||||
include /etc/nginx/mime.types;
|
||||
alias /usr/share/nginx/html/quiz/static/;
|
||||
}
|
||||
@ -30,7 +30,28 @@ server {
|
||||
}
|
||||
|
||||
location / {
|
||||
include /etc/nginx/conf.d/common-location.conf;
|
||||
include /etc/nginx/conf.d/proxy_headers.conf;
|
||||
proxy_pass http://reftest;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
server_name www.domain_name;
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
# Redirect to non-www
|
||||
return 301 $scheme://domain_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name www.domain_name;
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
# SSL configuration
|
||||
include /etc/nginx/ssl.conf;
|
||||
include /etc/nginx/certbot-challenge.conf;
|
||||
|
||||
# Redirect to non-www
|
||||
return 301 $scheme://domain_name$request_uri;
|
||||
}
|
@ -1,2 +1,13 @@
|
||||
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem;
|
||||
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
ssl_trusted_certificate /etc/letsencrypt/lets-encrypt-x3-cross-signed.pem;
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
ssl_session_cache shared:SSL:40m;
|
||||
ssl_session_timeout 4h;
|
||||
ssl_session_tickets on;
|
@ -1,147 +0,0 @@
|
||||
from flask import Blueprint, render_template, request, session, redirect
|
||||
from flask.helpers import flash, url_for
|
||||
from flask.json import jsonify
|
||||
from .models.users import User
|
||||
from uuid import uuid4
|
||||
from common.security.database import decrypt_find_one, encrypted_update
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from .views import admin_account_required, disable_on_registration, login_required, disable_if_logged_in, get_id_from_cookie
|
||||
|
||||
auth = Blueprint(
|
||||
'admin_auth',
|
||||
__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static'
|
||||
)
|
||||
|
||||
@auth.route('/account/', methods = ['GET', 'POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def account():
|
||||
from .models.forms import UpdateAccountForm
|
||||
from main import db
|
||||
form = UpdateAccountForm()
|
||||
_id = get_id_from_cookie()
|
||||
user = decrypt_find_one(db.users, {'_id': _id})
|
||||
if request.method == 'GET':
|
||||
return render_template('/admin/auth/account.html', form = form, user = user)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
password_confirm = request.form.get('password_confirm')
|
||||
if not check_password_hash(user['password'], password_confirm):
|
||||
return jsonify({ 'error': 'The password you entered is incorrect.' }), 401
|
||||
entry = User(
|
||||
_id = _id,
|
||||
password = request.form.get('password'),
|
||||
email = request.form.get('email')
|
||||
)
|
||||
return entry.update()
|
||||
else:
|
||||
errors = [*form.password_confirm.errors, *form.password_reenter.errors, *form.password.errors, *form.email.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@auth.route('/login/', methods=['GET','POST'])
|
||||
@admin_account_required
|
||||
@disable_if_logged_in
|
||||
def login():
|
||||
from .models.forms import LoginForm
|
||||
form = LoginForm()
|
||||
if request.method == 'GET':
|
||||
return render_template('/admin/auth/login.html', form=form)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
entry = User(
|
||||
username = request.form.get('username').lower(),
|
||||
password = request.form.get('password'),
|
||||
remember = request.form.get('remember')
|
||||
)
|
||||
return entry.login()
|
||||
else:
|
||||
errors = [*form.username.errors, *form.password.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@auth.route('/logout/')
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def logout():
|
||||
_id = get_id_from_cookie()
|
||||
return User(_id=_id).logout()
|
||||
|
||||
@auth.route('/register/', methods=['GET','POST'])
|
||||
@disable_on_registration
|
||||
def register():
|
||||
from .models.forms import RegistrationForm
|
||||
form = RegistrationForm()
|
||||
if request.method == 'GET':
|
||||
return render_template('/admin/auth/register.html', form=form)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
entry = User(
|
||||
_id = uuid4().hex,
|
||||
username = request.form.get('username').lower(),
|
||||
email = request.form.get('email'),
|
||||
password = request.form.get('password'),
|
||||
)
|
||||
return entry.register()
|
||||
else:
|
||||
errors = [*form.username.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@auth.route('/reset/', methods = ['GET', 'POST'])
|
||||
@admin_account_required
|
||||
@disable_if_logged_in
|
||||
def reset():
|
||||
from .models.forms import ResetPasswordForm
|
||||
form = ResetPasswordForm()
|
||||
if request.method == 'GET':
|
||||
return render_template('/admin/auth/reset.html', form=form)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
entry = User(
|
||||
username = request.form.get('username').lower(),
|
||||
email = request.form.get('email'),
|
||||
)
|
||||
return entry.reset_password()
|
||||
else:
|
||||
errors = [*form.username.errors, *form.email.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@auth.route('/reset/<token1>/<token2>/', methods = ['GET'])
|
||||
@admin_account_required
|
||||
@disable_if_logged_in
|
||||
def reset_gateway(token1,token2):
|
||||
from main import db
|
||||
user = decrypt_find_one( db.users, {'reset_token' : token1} )
|
||||
if not user:
|
||||
return redirect(url_for('admin_auth.login'))
|
||||
encrypted_update( db.users, {'reset_token': token1}, {'$unset': {'reset_token' : '', 'verification_token': ''}})
|
||||
if not user['verification_token'] == token2:
|
||||
flash('The verification of your password reset request failed and the token has been invalidated. Please make a new reset password request.', 'error'), 401
|
||||
return redirect(url_for('admin_auth.reset'))
|
||||
session['_id'] = user['_id']
|
||||
session['reset_validated'] = True
|
||||
return redirect(url_for('admin_auth.update_password_'))
|
||||
|
||||
@auth.route('/reset/update/', methods = ['GET','POST'])
|
||||
@admin_account_required
|
||||
@disable_if_logged_in
|
||||
def update_password_():
|
||||
from .models.forms import UpdatePasswordForm
|
||||
form = UpdatePasswordForm()
|
||||
if request.method == 'GET':
|
||||
if 'reset_validated' not in session:
|
||||
return redirect(url_for('admin_auth.login'))
|
||||
session.pop('reset_validated')
|
||||
return render_template('/admin/auth/update-password.html', form=form)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
entry = User(
|
||||
_id = session['_id'],
|
||||
password = request.form.get('password')
|
||||
)
|
||||
session.pop('_id')
|
||||
return entry.update()
|
||||
else:
|
||||
errors = [*form.password.errors, *form.password_reenter.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
@ -1,11 +0,0 @@
|
||||
from wtforms.validators import ValidationError
|
||||
|
||||
def value(min=0, max=None):
|
||||
message = f'Value must be between {min} and {max}.'
|
||||
|
||||
def _length(form, field):
|
||||
value = field.data or 0
|
||||
if value < min or max != None and value > max:
|
||||
raise ValidationError(message)
|
||||
|
||||
return _length
|
@ -1,122 +0,0 @@
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
from flask import flash, jsonify
|
||||
import secrets
|
||||
import os
|
||||
from json import dump, loads
|
||||
|
||||
from common.security import encrypt
|
||||
|
||||
class Test:
|
||||
|
||||
def __init__(self, _id=None, start_date=None, expiry_date=None, time_limit=None, creator=None, dataset=None):
|
||||
self._id = _id
|
||||
self.start_date = start_date
|
||||
self.expiry_date = expiry_date
|
||||
self.time_limit = None if time_limit == 'none' or time_limit == '' or time_limit == None else int(time_limit)
|
||||
self.creator = creator
|
||||
self.dataset = dataset
|
||||
|
||||
def create(self):
|
||||
from main import app, db
|
||||
test = {
|
||||
'_id': self._id,
|
||||
'date_created': datetime.today(),
|
||||
'start_date': self.start_date,
|
||||
'expiry_date': self.expiry_date,
|
||||
'time_limit': self.time_limit,
|
||||
'creator': encrypt(self.creator),
|
||||
'test_code': secrets.token_hex(6).upper(),
|
||||
'dataset': self.dataset
|
||||
}
|
||||
if db.tests.insert_one(test):
|
||||
dataset_file_path = os.path.join(app.config["DATA_FILE_DIRECTORY"],self.dataset)
|
||||
with open(dataset_file_path, 'r') as dataset_file:
|
||||
data = loads(dataset_file.read())
|
||||
data['meta']['tests'].append(self._id)
|
||||
with open(dataset_file_path, 'w') as dataset_file:
|
||||
dump(data, dataset_file, indent=2)
|
||||
flash(f'Created a new exam with Exam Code <strong>{self.render_test_code(test["test_code"])}</strong>.', 'success')
|
||||
return jsonify({'success': test}), 200
|
||||
return jsonify({'error': f'Could not create exam. An error occurred.'}), 400
|
||||
|
||||
def add_time_adjustment(self, time_adjustment):
|
||||
from main import db
|
||||
user_code = secrets.token_hex(3).upper()
|
||||
adjustment = {
|
||||
user_code: time_adjustment
|
||||
}
|
||||
if db.tests.find_one_and_update({'_id': self._id}, {'$set': {'time_adjustments': adjustment}},upsert=False):
|
||||
flash(f'Time adjustment for {time_adjustment} minutes has been added. This can be enabled using the user code {user_code}.')
|
||||
return jsonify({'success': adjustment})
|
||||
return jsonify({'error': 'Failed to add the time adjustment. An error occurred.'}), 400
|
||||
|
||||
def remove_time_adjustment(self, user_code):
|
||||
from main import db
|
||||
if db.tests.find_one_and_update({'_id': self._id}, {'$unset': {f'time_adjustments.{user_code}': {}}}):
|
||||
message = 'Time adjustment has been deleted.'
|
||||
flash(message, 'success')
|
||||
return jsonify({'success': message})
|
||||
return jsonify({'error': 'Failed to delete the time adjustment. An error occurred.'}), 400
|
||||
|
||||
def render_test_code(self, test_code):
|
||||
return '—'.join([test_code[:4], test_code[4:8], test_code[8:]])
|
||||
|
||||
def parse_test_code(self, test_code):
|
||||
return test_code.replace('—', '')
|
||||
|
||||
def delete(self):
|
||||
from main import app, db
|
||||
test = db.tests.find_one({'_id': self._id})
|
||||
if 'entries' in test:
|
||||
if test['entries']:
|
||||
return jsonify({'error': 'Cannot delete an exam that has entries submitted to it.'}), 400
|
||||
if self.dataset is None:
|
||||
self.dataset = db.tests.find_one({'_id': self._id})['dataset']
|
||||
if db.tests.delete_one({'_id': self._id}):
|
||||
dataset_file_path = os.path.join(app.config["DATA_FILE_DIRECTORY"],self.dataset)
|
||||
with open(dataset_file_path, 'r') as dataset_file:
|
||||
data = loads(dataset_file.read())
|
||||
data['meta']['tests'].remove(self._id)
|
||||
with open(dataset_file_path, 'w') as dataset_file:
|
||||
dump(data, dataset_file, indent=2)
|
||||
message = 'Deleted exam.'
|
||||
flash(message, 'alert')
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': f'Could not create exam. An error occurred.'}), 400
|
||||
|
||||
def update(self):
|
||||
from main import db
|
||||
test = {}
|
||||
updated = []
|
||||
if not self.start_date == '' and self.start_date is not None:
|
||||
test['start_date'] = self.start_date
|
||||
updated.append('start date')
|
||||
if not self.expiry_date == '' and self.expiry_date is not None:
|
||||
test['expiry_date'] = self.expiry_date
|
||||
updated.append('expiry date')
|
||||
if not self.time_limit == '' and self.time_limit is not None:
|
||||
test['time_limit'] = int(self.time_limit)
|
||||
updated.append('time limit')
|
||||
output = ''
|
||||
if len(updated) == 0:
|
||||
flash(f'There were no changes requested for your account.', 'alert'), 200
|
||||
return jsonify({'success': 'There were no changes requested for your account.'}), 200
|
||||
elif len(updated) == 1:
|
||||
output = updated[0]
|
||||
elif len(updated) == 2:
|
||||
output = ' and '.join(updated)
|
||||
elif len(updated) > 2:
|
||||
output = updated[0]
|
||||
for index in range(1,len(updated)):
|
||||
if index < len(updated) - 2:
|
||||
output = ', '.join([output, updated[index]])
|
||||
elif index == len(updated) - 2:
|
||||
output = ', and '.join([output, updated[index]])
|
||||
else:
|
||||
output = ''.join([output, updated[index]])
|
||||
db.tests.find_one_and_update({'_id': self._id}, {'$set': test})
|
||||
_output = f'The {output} of the test {"has" if len(updated) == 1 else "have"} been updated.'
|
||||
flash(_output)
|
||||
return jsonify({'success': _output}), 200
|
@ -1,207 +0,0 @@
|
||||
from flask import flash, make_response, Response, session
|
||||
from flask.helpers import url_for
|
||||
from flask.json import jsonify
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from werkzeug.utils import redirect
|
||||
from flask_mail import Message
|
||||
import secrets
|
||||
|
||||
from common.security import encrypt, decrypt
|
||||
from common.security.database import decrypt_find_one, encrypted_update
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class User:
|
||||
|
||||
def __init__(self, _id=None, username=None, password=None, email=None, remember=False):
|
||||
self._id = _id
|
||||
self.username = username
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.remember = remember
|
||||
|
||||
def start_session(self, resp:Response):
|
||||
from main import app
|
||||
resp.set_cookie(
|
||||
key = '_id',
|
||||
value = self._id,
|
||||
max_age = timedelta(days=14) if self.remember else None,
|
||||
path = '/',
|
||||
expires = datetime.utcnow() + timedelta(days=14) if self.remember else None,
|
||||
domain = f'.{app.config["SERVER_NAME"]}',
|
||||
secure = True
|
||||
)
|
||||
if self.remember:
|
||||
resp.set_cookie (
|
||||
key = 'remember',
|
||||
value = 'True',
|
||||
max_age = timedelta(days=14),
|
||||
path = '/',
|
||||
expires = datetime.utcnow() + timedelta(days=14),
|
||||
domain = f'.{app.config["SERVER_NAME"]}',
|
||||
secure = True
|
||||
)
|
||||
|
||||
def register(self):
|
||||
from main import db
|
||||
from ..views import get_id_from_cookie
|
||||
user = {
|
||||
'_id': self._id,
|
||||
'email': encrypt(self.email),
|
||||
'password': generate_password_hash(self.password, method='sha256'),
|
||||
'username': encrypt(self.username)
|
||||
}
|
||||
if decrypt_find_one(db.users, { 'username': self.username }):
|
||||
return jsonify({ 'error': f'Username {self.username} is not available.' }), 400
|
||||
if db.users.insert_one(user):
|
||||
flash(f'User {self.username} has been created successfully. You may now use it to log in and administer the tests.', 'success')
|
||||
resp = make_response(jsonify(user), 200)
|
||||
if not get_id_from_cookie:
|
||||
self.start_session(resp)
|
||||
return resp
|
||||
return jsonify({ 'error': f'Registration failed. An error occurred.' }), 400
|
||||
|
||||
def login(self):
|
||||
from main import db
|
||||
user = decrypt_find_one( db.users, { 'username': self.username })
|
||||
if not user:
|
||||
return jsonify({ 'error': f'Username {self.username} does not exist.' }), 401
|
||||
if not check_password_hash( user['password'], self.password ):
|
||||
return jsonify({ 'error': f'The password you entered is incorrect.' }), 401
|
||||
response = {
|
||||
'success': f'Successfully logged in user {self.username}.'
|
||||
}
|
||||
if 'prev_page' in session:
|
||||
response['redirect_to'] = session['prev_page']
|
||||
session.pop('prev_page')
|
||||
resp = make_response(jsonify(response), 200)
|
||||
self._id = user['_id']
|
||||
self.start_session(resp)
|
||||
return resp
|
||||
|
||||
def logout(self):
|
||||
resp = make_response(redirect(url_for('admin_auth.login')))
|
||||
from main import app
|
||||
resp.set_cookie(
|
||||
key = '_id',
|
||||
value = '',
|
||||
max_age = timedelta(days=-1),
|
||||
path = '/',
|
||||
expires= datetime.utcnow() + timedelta(days=-1),
|
||||
domain = f'.{app.config["SERVER_NAME"]}',
|
||||
secure = True
|
||||
)
|
||||
resp.set_cookie (
|
||||
key = 'cookie_consent',
|
||||
value = 'True',
|
||||
max_age = None,
|
||||
path = '/',
|
||||
expires = None,
|
||||
domain = f'.{app.config["SERVER_NAME"]}',
|
||||
secure = True
|
||||
)
|
||||
resp.set_cookie (
|
||||
key = 'remember',
|
||||
value = 'True',
|
||||
max_age = timedelta(days=-1),
|
||||
path = '/',
|
||||
expires = datetime.utcnow() + timedelta(days=-1),
|
||||
domain = f'.{app.config["SERVER_NAME"]}',
|
||||
secure = True
|
||||
)
|
||||
flash('You have been logged out. All cookies pertaining to your account have been deleted. Have a nice day.', 'alert')
|
||||
return resp
|
||||
|
||||
def reset_password(self):
|
||||
from main import db, mail
|
||||
user = decrypt_find_one(db.users, { 'username': self.username })
|
||||
if not user:
|
||||
return jsonify({ 'error': f'Username {self.username} does not exist.' }), 401
|
||||
if not user['email'] == self.email:
|
||||
return jsonify({ 'error': f'The email address {self.email} does not match the user account {self.username}.' }), 401
|
||||
new_password = secrets.token_hex(12)
|
||||
reset_token = secrets.token_urlsafe(16)
|
||||
verification_token = secrets.token_urlsafe(16)
|
||||
user['password'] = generate_password_hash(new_password, method='sha256')
|
||||
if encrypted_update(db.users, { 'username': self.username }, { '$set': {'password': user['password'], 'reset_token': reset_token, 'verification_token': verification_token } } ):
|
||||
flash(f'Your password has been reset. Instructions to recover your account have been sent to {self.email}. Please be sure to check your spam folder in case you have not received the email.', 'alert')
|
||||
email = Message(
|
||||
subject = 'RefTest | Password Reset',
|
||||
recipients = [self.email],
|
||||
body = f"""
|
||||
Hello {user['username']}, \n\n
|
||||
This email was generated because we received a request to reset the password for your administrator account for the SKA RefTest app.\n\n
|
||||
If you did not make this request, please ignore this email.\n\n
|
||||
If you did make this request, then you have two options to recover your account.\n\n
|
||||
For the time being, your password has been reset to the following:\n\n
|
||||
{new_password}\n\n
|
||||
You may use this to log back in to your account, and subsequently change your password to something more suitable.\n\n
|
||||
Alternatively, you may visit the following private link using your unique token to override your password. Copy and paste the following link in a web browser. Please note that this token is only valid once:\n\n
|
||||
{url_for('admin_auth.reset_gateway', token1 = reset_token, token2 = verification_token, _external = True)}\n\n
|
||||
Have a nice day.
|
||||
""",
|
||||
html = f"""
|
||||
<p>Hello {user['username']},</p>
|
||||
<p>This email was generated because we received a request to reset the password for your administrator account for the SKA RefTest app. </p>
|
||||
<p>If you did not make this request, please ignore this email.</p>
|
||||
<p>If you did make this request, then you have two options to recover your account.</p>
|
||||
<p>For the time being, your password has been reset to the following:</p>
|
||||
<strong>{new_password}</strong>
|
||||
<p>You may use this to log back in to your account, and subsequently change your password to something more suitable.</p>
|
||||
<p>Alternatively, you may visit the following private link using your unique token to override your password. Click on the following link, or copy and paste it into a browser. <strong>Please note that this token is only valid once</strong>:</p>
|
||||
<p><a href='{url_for('admin_auth.reset_gateway', token1 = reset_token, token2 = verification_token, _external = True)}'>{url_for('admin_auth.reset_gateway', token1 = reset_token, token2 = verification_token, _external = True)}</a></p>
|
||||
<p>Have a nice day.</p>
|
||||
"""
|
||||
)
|
||||
mail.send(email)
|
||||
return jsonify({ 'success': 'Password reset request has been processed.'}), 200
|
||||
|
||||
def update(self):
|
||||
from main import db
|
||||
from ..views import get_id_from_cookie
|
||||
retrieved_user = decrypt_find_one(db.users, { '_id': self._id })
|
||||
if not retrieved_user:
|
||||
return jsonify({ 'error': f'User {retrieved_user["username"]} does not exist.' }), 401
|
||||
user = {}
|
||||
updated = []
|
||||
if not self.email == '' and self.email is not None:
|
||||
user['email'] = self.email
|
||||
updated.append('email')
|
||||
if not self.password == '' and self.password is not None:
|
||||
user['password'] = generate_password_hash(self.password, method='sha256')
|
||||
updated.append('password')
|
||||
output = ''
|
||||
if len(updated) == 0:
|
||||
flash(f'There were no changes requested for your account.', 'alert'), 200
|
||||
return jsonify({'success': 'There were no changes requested for your account.'}), 200
|
||||
elif len(updated) == 1:
|
||||
output = updated[0]
|
||||
elif len(updated) == 2:
|
||||
output = ' and '.join(updated)
|
||||
elif len(updated) > 2:
|
||||
output = updated[0]
|
||||
for index in range(1,len(updated)):
|
||||
if index < len(updated) - 2:
|
||||
output = ', '.join([output, updated[index]])
|
||||
elif index == len(updated) - 2:
|
||||
output = ', and '.join([output, updated[index]])
|
||||
else:
|
||||
output = ''.join([output, updated[index]])
|
||||
encrypted_update(db.users, {'_id': self._id}, { '$set': user })
|
||||
if self._id == get_id_from_cookie():
|
||||
_output = 'Your '
|
||||
elif retrieved_user['username'][-1] == 's':
|
||||
_output = '’'.join([retrieved_user['username'], ''])
|
||||
else:
|
||||
_output = '’'.join([retrieved_user['username'], 's'])
|
||||
_output = f'{_output} {output} {"has" if len(updated) == 1 else "have"} been updated.'
|
||||
flash(_output)
|
||||
return jsonify({'success': _output}), 200
|
||||
|
||||
def delete(self):
|
||||
from main import db
|
||||
retrieved_user = decrypt_find_one(db.users, { '_id': self._id })
|
||||
if not retrieved_user:
|
||||
return jsonify({ 'error': f'User does not exist.' }), 401
|
||||
db.users.find_one_and_delete({'_id': self._id})
|
||||
flash(f'User {retrieved_user["username"]} has been deleted.')
|
||||
return jsonify({'success': f'User {retrieved_user["username"]} has been deleted.'}), 200
|
@ -1,15 +0,0 @@
|
||||
from flask import Blueprint, render_template
|
||||
from .views import login_required, admin_account_required
|
||||
|
||||
results = Blueprint(
|
||||
'results',
|
||||
__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static'
|
||||
)
|
||||
|
||||
@results.route('/')
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def _results():
|
||||
return render_template('/admin/results.html')
|
@ -1,79 +0,0 @@
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin_views.home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbar"
|
||||
aria-controls="navbar"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle Navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navbar">
|
||||
<ul class="navbar-nav">
|
||||
{% if not check_login() %}
|
||||
<li class="nav-item" id="nav-login">
|
||||
<a href="{{ url_for('admin_auth.login') }}" id="link-login" class="nav-link">Log In</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if check_login() %}
|
||||
<li class="nav-item" id="nav-results">
|
||||
<a href="{{ url_for('results._results') }}" id="link-results" class="nav-link">View Results</a>
|
||||
</li>
|
||||
<li class="nav-item" id="nav-tests">
|
||||
<a href="{{ url_for('admin_views.tests') }}" id="link-tests" class="nav-link">Manage Exams</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-settings">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
role="button"
|
||||
href="{{ url_for('admin_views.settings') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-account"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin_views.users') }}" id="link-users" class="dropdown-item">Users</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin_views.questions') }}" id="link-questions" class="dropdown-item">Question Datasets</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-account">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
role="button"
|
||||
href="{{ url_for('admin_auth.account') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Account
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-account"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin_auth.account') }}" id="link-account" class="dropdown-item">Account Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin_auth.logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
@ -1,23 +0,0 @@
|
||||
<div class="navbar navbar-expand-sm navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<div class="expand navbar-expand justify-content-center" id="navbar_secondary">
|
||||
<ul class="nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='active') }}">Active</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='scheduled') }}">Scheduled</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='expired') }}">Expired</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='all') }}">All</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin_views.tests', filter='create') }}">Create</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1,509 +0,0 @@
|
||||
from flask import Blueprint, render_template, flash, redirect, request, jsonify, abort, session
|
||||
from flask.helpers import url_for
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import os
|
||||
from glob import glob
|
||||
from json import loads
|
||||
from werkzeug.security import check_password_hash
|
||||
from common.security.database import decrypt_find, decrypt_find_one
|
||||
from .models.users import User
|
||||
from flask_mail import Message
|
||||
from uuid import uuid4
|
||||
import secrets
|
||||
from datetime import datetime, date
|
||||
from .models.tests import Test
|
||||
from common.data_tools import get_default_dataset, get_time_options, available_datasets, get_datasets, get_correct_answers
|
||||
|
||||
views = Blueprint(
|
||||
'admin_views',
|
||||
__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static'
|
||||
)
|
||||
|
||||
def admin_account_required(function):
|
||||
@wraps(function)
|
||||
def decorated_function(*args, **kwargs):
|
||||
from main import db
|
||||
from main import db
|
||||
if not db.users.find_one({}):
|
||||
flash('No administrator accounts have been registered. Please register an administrator account.', 'alert')
|
||||
return redirect(url_for('admin_auth.register'))
|
||||
return function(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def disable_on_registration(function):
|
||||
@wraps(function)
|
||||
def decorated_function(*args, **kwargs):
|
||||
from main import db
|
||||
if db.users.find_one({}):
|
||||
return abort(404)
|
||||
return function(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def get_id_from_cookie():
|
||||
return request.cookies.get('_id')
|
||||
|
||||
def get_user_from_db(_id):
|
||||
from main import db
|
||||
return db.users.find_one({'_id': _id})
|
||||
|
||||
def check_login():
|
||||
_id = get_id_from_cookie()
|
||||
return True if get_user_from_db(_id) else False
|
||||
|
||||
def login_required(function):
|
||||
@wraps(function)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not check_login():
|
||||
session['prev_page'] = request.url
|
||||
flash('Please log in to view this page.', 'alert')
|
||||
return redirect(url_for('admin_auth.login'))
|
||||
return function(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def disable_if_logged_in(function):
|
||||
@wraps(function)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if check_login():
|
||||
return abort(404)
|
||||
return function(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@views.route('/')
|
||||
@views.route('/home/')
|
||||
@views.route('/dashboard/')
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def home():
|
||||
from main import db
|
||||
tests = db.tests.find()
|
||||
results = decrypt_find(db.entries, {})
|
||||
current_tests = [ test for test in tests if test['expiry_date'] >= datetime.utcnow() and test['start_date'].date() <= date.today() ]
|
||||
current_tests.sort(key= lambda x: x['expiry_date'], reverse=True)
|
||||
upcoming_tests = [ test for test in tests if test['start_date'] > datetime.utcnow()]
|
||||
upcoming_tests.sort(key= lambda x: x['start_date'])
|
||||
recent_results = [result for result in results if 'submission_time' in result ]
|
||||
recent_results.sort(key= lambda x: x['submission_time'], reverse=True)
|
||||
for result in recent_results:
|
||||
result['percent'] = round(100*result['results']['score']/result['results']['max'])
|
||||
return render_template('/admin/index.html', current_tests = current_tests[:5], upcomimg_tests = upcoming_tests[:5], recent_results = recent_results[:5])
|
||||
|
||||
@views.route('/settings/')
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def settings():
|
||||
from main import db
|
||||
users = decrypt_find(db.users, {})
|
||||
users.sort(key= lambda x: x['username'])
|
||||
datasets = get_datasets()
|
||||
return render_template('/admin/settings/index.html', users=users[:5], datasets=datasets[:5])
|
||||
|
||||
@views.route('/settings/users/', methods=['GET','POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def users():
|
||||
from main import db, mail
|
||||
from .models.forms import CreateUserForm
|
||||
form = CreateUserForm()
|
||||
if request.method == 'GET':
|
||||
users_list = decrypt_find(db.users, {})
|
||||
return render_template('/admin/settings/users.html', users = users_list, form = form)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
entry = User(
|
||||
_id = uuid4().hex,
|
||||
username = request.form.get('username').lower(),
|
||||
email = request.form.get('email'),
|
||||
password = request.form.get('password') if not request.form.get('password') == '' else secrets.token_hex(12),
|
||||
)
|
||||
email = Message(
|
||||
subject = 'RefTest | Registration Confirmation',
|
||||
recipients = [entry.email],
|
||||
body = f"""
|
||||
Hello {entry.username}, \n\n
|
||||
You have been registered as an administrator for the SKA RefTest App!\n\n
|
||||
You can access your account using the username '{entry.username}'.\n\n
|
||||
Your password is as follows:\n\n
|
||||
{entry.password}\n\n
|
||||
You can change your password by logging in to the admin console by copying the following URL into a web browser:\n\n
|
||||
{url_for('admin_views.home', _external = True)}\n\n
|
||||
Have a nice day.
|
||||
""",
|
||||
html = f"""
|
||||
<p>Hello {entry.username},</p>
|
||||
<p>You have been registered as an administrator for the SKA RefTest App!</p>
|
||||
<p>You can access your account using the username '{entry.username}'.</p>
|
||||
<p>Your password is as follows:</p>
|
||||
<strong>{entry.password}</strong>
|
||||
<p>You can change your password by logging in to the admin console at the link below:</p>
|
||||
<p><a href='{url_for('admin_views.home', _external = True)}'>{url_for('admin_views.home', _external = True)}</a></p>
|
||||
<p>Have a nice day.</p>
|
||||
"""
|
||||
)
|
||||
mail.send(email)
|
||||
return entry.register()
|
||||
else:
|
||||
errors = [*form.username.errors, *form.email.errors, *form.password.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@views.route('/settings/users/delete/<string:_id>', methods = ['GET', 'POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def delete_user(_id:str):
|
||||
from main import db, mail
|
||||
if _id == get_id_from_cookie():
|
||||
flash('Cannot delete your own user account.', 'error')
|
||||
return redirect(url_for('admin_views.users'))
|
||||
from .models.forms import DeleteUserForm
|
||||
form = DeleteUserForm()
|
||||
user = decrypt_find_one(db.users, {'_id': _id})
|
||||
if request.method == 'GET':
|
||||
if not user:
|
||||
return abort(404)
|
||||
return render_template('/admin/settings/delete-user.html', form = form, _id = _id, user = user)
|
||||
if request.method == 'POST':
|
||||
if not user:
|
||||
return jsonify({ 'error': 'User does not exist.' }), 404
|
||||
if form.validate_on_submit():
|
||||
_user = decrypt_find_one(db.users, {'_id': get_id_from_cookie()})
|
||||
password = request.form.get('password')
|
||||
if not check_password_hash(_user['password'], password):
|
||||
return jsonify({ 'error': 'The password you entered is incorrect.' }), 401
|
||||
if request.form.get('notify'):
|
||||
email = Message(
|
||||
subject = 'RefTest | Account Deletion',
|
||||
recipients = [user['email']],
|
||||
bcc = [_user['email']],
|
||||
body = f"""
|
||||
Hello {user['username']}, \n\n
|
||||
Your administrator account for the SKA RefTest App has been deleted by {_user['username']}. All data about your account has been deleted.\n\n
|
||||
If you believe this was done in error, please contact them immediately.\n\n
|
||||
If you would like to register to administer the app, please ask an existing administrator to create a new account.\n\n
|
||||
Have a nice day.
|
||||
""",
|
||||
html = f"""
|
||||
<p>Hello {user['username']},</p>
|
||||
<p>Your administrator account for the SKA RefTest App has been deleted by {_user['username']}. All data about your account has been deleted.</p>
|
||||
<p>If you believe this was done in error, please contact them immediately.</p>
|
||||
<p>If you would like to register to administer the app, please ask an existing administrator to create a new account.</p>
|
||||
<p>Have a nice day.</p>
|
||||
"""
|
||||
)
|
||||
mail.send(email)
|
||||
user = User(
|
||||
_id = user['_id']
|
||||
)
|
||||
return user.delete()
|
||||
else: return abort(400)
|
||||
|
||||
@views.route('/settings/users/update/<string:_id>', methods = ['GET', 'POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def update_user(_id:str):
|
||||
from main import db, mail
|
||||
if _id == get_id_from_cookie():
|
||||
flash('Cannot delete your own user account.', 'error')
|
||||
return redirect(url_for('admin_views.users'))
|
||||
from .models.forms import UpdateUserForm
|
||||
form = UpdateUserForm()
|
||||
user = decrypt_find_one( db.users, {'_id': _id})
|
||||
if request.method == 'GET':
|
||||
if not user:
|
||||
return abort(404)
|
||||
return render_template('/admin/settings/update-user.html', form = form, _id = _id, user = user)
|
||||
if request.method == 'POST':
|
||||
if not user:
|
||||
return jsonify({ 'error': 'User does not exist.' }), 404
|
||||
if form.validate_on_submit():
|
||||
_user = decrypt_find_one(db.users, {'_id': get_id_from_cookie()})
|
||||
password = request.form.get('password')
|
||||
if not check_password_hash(_user['password'], password):
|
||||
return jsonify({ 'error': 'The password you entered is incorrect.' }), 401
|
||||
if request.form.get('notify'):
|
||||
recipient = request.form.get('email') if not request.form.get('email') == '' else user['email']
|
||||
email = Message(
|
||||
subject = 'RefTest | Account Update',
|
||||
recipients = [recipient],
|
||||
bcc = [_user['email']],
|
||||
body = f"""
|
||||
Hello {user['username']}, \n\n
|
||||
Your administrator account for the SKA RefTest App has been updated by {_user['username']}.\n\n
|
||||
Your new account details are as follows:\n\n
|
||||
Email: {recipient}\n
|
||||
Password: {request.form.get('password')}\n\n
|
||||
You can update your email and password by logging in to the app.\n\n
|
||||
Have a nice day.
|
||||
""",
|
||||
html = f"""
|
||||
<p>Hello {user['username']},</p>
|
||||
<p>Your administrator account for the SKA RefTest App has been updated by {_user['username']}.</p>
|
||||
<p>Your new account details are as follows:</p>
|
||||
<p>Email: {recipient} <br/>Password: {request.form.get('password')}</p>
|
||||
<p>You can update your email and password by logging in to the app.</p>
|
||||
<p>Have a nice day.</p>
|
||||
"""
|
||||
)
|
||||
mail.send(email)
|
||||
entry = User(
|
||||
_id = _id,
|
||||
email = request.form.get('email'),
|
||||
password = request.form.get('password')
|
||||
)
|
||||
return entry.update()
|
||||
else:
|
||||
errors = [*form.user_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@views.route('/settings/questions/', methods=['GET', 'POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def questions():
|
||||
from .models.forms import UploadDataForm
|
||||
from common.data_tools import check_json_format, validate_json_contents, store_data_file
|
||||
form = UploadDataForm()
|
||||
if request.method == 'GET':
|
||||
data = get_datasets()
|
||||
default = get_default_dataset()
|
||||
return render_template('/admin/settings/questions.html', form=form, data=data, default=default)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
upload = form.data_file.data
|
||||
default = True if request.form.get('default') else False
|
||||
if not check_json_format(upload):
|
||||
return jsonify({ 'error': 'Invalid file selected. Please upload a JSON file.'}), 400
|
||||
if not validate_json_contents(upload):
|
||||
return jsonify({'error': 'The data in the file is invalid.'}), 400
|
||||
filename = store_data_file(upload, default=default)
|
||||
flash(f'Dataset {form.data_file.data.filename} has been uploaded as {filename}.', 'success')
|
||||
return jsonify({ 'success': f'Dataset {form.data_file.data.filename} has been uploaded as {filename}.'}), 200
|
||||
errors = [*form.data_file.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@views.route('/settings/questions/delete/', methods=['POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def delete_questions():
|
||||
from main import db, app
|
||||
filename = request.get_json()['filename']
|
||||
data_files = glob(os.path.join(app.config["DATA_FILE_DIRECTORY"],'*.json'))
|
||||
if any(filename in file for file in data_files):
|
||||
default = get_default_dataset()
|
||||
if default == filename:
|
||||
return jsonify({'error': 'Cannot delete the default question dataset.'}), 400
|
||||
data_file = os.path.join(app.config["DATA_FILE_DIRECTORY"],filename)
|
||||
with open(data_file, 'r') as _data_file:
|
||||
data = loads(_data_file.read())
|
||||
if data['meta']['tests']:
|
||||
return jsonify({'error': 'Cannot delete a dataset that is in use by an exam.'}), 400
|
||||
if len(data_files) == 1:
|
||||
return jsonify({'error': 'Cannot delete the only question dataset.'}), 400
|
||||
os.remove(data_file)
|
||||
flash(f'Question dataset {filename} has been deleted.', 'success')
|
||||
return jsonify({'success': f'Question dataset {filename} has been deleted.'}), 200
|
||||
return abort(404)
|
||||
|
||||
@views.route('/settings/questions/default/', methods=['POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def make_default_questions():
|
||||
from main import app
|
||||
filename = request.get_json()['filename']
|
||||
data_files = glob(os.path.join(app.config["DATA_FILE_DIRECTORY"],'*.json'))
|
||||
default_file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], '.default.txt')
|
||||
if any(filename in file for file in data_files):
|
||||
with open(default_file_path, 'r') as default_file:
|
||||
default = default_file.read()
|
||||
if default == filename:
|
||||
return jsonify({'error': 'Cannot delete default question dataset.'}), 400
|
||||
with open(default_file_path, 'w') as default_file:
|
||||
default_file.write(filename)
|
||||
flash(f'Set dataset f{filename} as the default.', 'success')
|
||||
return jsonify({'success': f'Set dataset {filename} as the default.'})
|
||||
return abort(404)
|
||||
|
||||
@views.route('/tests/<filter>/', methods=['GET'])
|
||||
@views.route('/tests/', methods=['GET'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def tests(filter=''):
|
||||
from main import db
|
||||
if not available_datasets():
|
||||
flash('There are no available question datasets. Please upload a question dataset in order to set up an exam.', 'error')
|
||||
return redirect(url_for('admin_views.questions'))
|
||||
if filter not in ['', 'create', 'active', 'scheduled', 'expired', 'all']:
|
||||
return abort(404)
|
||||
if filter == 'create':
|
||||
from .models.forms import CreateTest
|
||||
form = CreateTest()
|
||||
form.time_limit.choices = get_time_options()
|
||||
form.dataset.choices = available_datasets()
|
||||
form.time_limit.default='none'
|
||||
form.dataset.default=get_default_dataset()
|
||||
form.process()
|
||||
display_title = ''
|
||||
error_none = ''
|
||||
return render_template('/admin/tests.html', form = form, display_title=display_title, error_none=error_none, filter=filter)
|
||||
_tests = db.tests.find({})
|
||||
if filter == 'active' or filter == '':
|
||||
tests = [ test for test in _tests if test['expiry_date'] >= datetime.utcnow() and test['start_date'].date() <= date.today() ]
|
||||
display_title = 'Active Exams'
|
||||
error_none = 'There are no exams that are currently active. You can create one using the Creat Exam form.'
|
||||
if filter == 'expired':
|
||||
tests = [ test for test in _tests if test['expiry_date'] < datetime.utcnow()]
|
||||
display_title = 'Expired Exams'
|
||||
error_none = 'There are no expired exams. Exams will appear in this category after their expiration date has passed.'
|
||||
if filter == 'scheduled':
|
||||
tests = [ test for test in _tests if test['start_date'].date() > date.today()]
|
||||
display_title = 'Scheduled Exams'
|
||||
error_none = 'There are no scheduled exams pending. You can schedule an exam for the future using the Create Exam form.'
|
||||
if filter == 'all':
|
||||
tests = _tests
|
||||
display_title = 'All Exams'
|
||||
error_none = 'There are no exams set up. You can create one using the Create Exam form.'
|
||||
return render_template('/admin/tests.html', tests = tests, display_title=display_title, error_none=error_none, filter=filter)
|
||||
|
||||
@views.route('/tests/create/', methods=['POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def create_test():
|
||||
from main import db
|
||||
from .models.forms import CreateTest
|
||||
form = CreateTest()
|
||||
form.dataset.choices = available_datasets()
|
||||
form.time_limit.choices = get_time_options()
|
||||
if form.validate_on_submit():
|
||||
start_date = request.form.get('start_date')
|
||||
start_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||
expiry_date = request.form.get('expiry_date')
|
||||
expiry_date = datetime.strptime(expiry_date, '%Y-%m-%d') + timedelta(days= 1) - timedelta(milliseconds = 1)
|
||||
dataset = request.form.get('dataset')
|
||||
errors = []
|
||||
if start_date.date() < date.today():
|
||||
errors.append('The start date cannot be in the past.')
|
||||
if expiry_date.date() < date.today():
|
||||
errors.append('The expiry date cannot be in the past.')
|
||||
if expiry_date < start_date:
|
||||
errors.append('The expiry date cannot be before the start date.')
|
||||
if errors:
|
||||
return jsonify({'error': errors}), 400
|
||||
creator_id = get_id_from_cookie()
|
||||
creator = decrypt_find_one(db.users, { '_id': creator_id } )['username']
|
||||
test = Test(
|
||||
_id = uuid4().hex,
|
||||
start_date = start_date,
|
||||
expiry_date = expiry_date,
|
||||
time_limit = request.form.get('time_limit'),
|
||||
creator = creator,
|
||||
dataset = dataset
|
||||
)
|
||||
test.create()
|
||||
return jsonify({'success': 'New exam created.'}), 200
|
||||
else:
|
||||
errors = [*form.expiry.errors, *form.time_limit.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@views.route('/tests/delete/', methods=['POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def delete_test():
|
||||
from main import db
|
||||
_id = request.get_json()['_id']
|
||||
if db.tests.find_one({'_id': _id}):
|
||||
return Test(_id = _id).delete()
|
||||
return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
|
||||
|
||||
@views.route('/tests/close/', methods=['POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def close_test():
|
||||
from main import db
|
||||
_id = request.get_json()['_id']
|
||||
if db.tests.find_one({'_id': _id}):
|
||||
return Test(_id = _id, expiry_date= datetime.utcnow()).update()
|
||||
return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
|
||||
|
||||
@views.route('/test/<_id>/', methods=['GET','POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def view_test(_id):
|
||||
from main import db
|
||||
from .models.forms import AddTimeAdjustment
|
||||
form = AddTimeAdjustment()
|
||||
test = decrypt_find_one(db.tests, {'_id': _id})
|
||||
if request.method == 'GET':
|
||||
if not test:
|
||||
return abort(404)
|
||||
return render_template('/admin/test.html', test = test, form = form)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
time = int(request.form.get('time'))
|
||||
return Test(_id=_id).add_time_adjustment(time)
|
||||
return jsonify({'error': form.time.errors }), 400
|
||||
|
||||
@views.route('/test/<_id>/delete-adjustment/', methods = ['POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def delete_adjustment(_id):
|
||||
user_code = request.get_json()['user_code']
|
||||
return Test(_id=_id).remove_time_adjustment(user_code)
|
||||
|
||||
@views.route('/results/')
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def view_entries():
|
||||
from main import db
|
||||
entries = decrypt_find(db.entries, {})
|
||||
return render_template('/admin/results.html', entries = entries)
|
||||
|
||||
@views.route('/results/<_id>/', methods = ['GET', 'POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def view_entry(_id=''):
|
||||
from main import app, db
|
||||
entry = decrypt_find_one(db.entries, {'_id': _id})
|
||||
if request.method == 'GET':
|
||||
if not entry:
|
||||
return abort(404)
|
||||
test_code = entry['test_code']
|
||||
test = db.tests.find_one({'test_code' : test_code})
|
||||
dataset = test['dataset']
|
||||
dataset_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], dataset)
|
||||
with open(dataset_path, 'r') as _dataset:
|
||||
data = loads(_dataset.read())
|
||||
correct = get_correct_answers(dataset=data)
|
||||
print(correct.values())
|
||||
return render_template('/admin/result-detail.html', entry = entry, correct = correct)
|
||||
if request.method == 'POST':
|
||||
if not entry:
|
||||
return jsonify({'error': 'A valid entry could no be found.'}), 404
|
||||
action = request.get_json()['action']
|
||||
if action == 'override':
|
||||
late_ignore = db.entries.find_one_and_update({'_id': _id}, {'$set': {'status': 'late (allowed)'}})
|
||||
if late_ignore:
|
||||
flash('Late status for the entry has been allowed.', 'success')
|
||||
return jsonify({'success': 'Late status allowed.'}), 200
|
||||
return jsonify({'error': 'An error occurred.'}), 400
|
||||
if action == 'delete':
|
||||
test_code = entry['test_code']
|
||||
test = db.tests.find_one_and_update({'test_code': test_code}, {'$pull': {'entries': _id}})
|
||||
if not test:
|
||||
return jsonify({'error': 'A valid exam could not be found.'}), 404
|
||||
delete = db.entries.delete_one({'_id': _id})
|
||||
if delete:
|
||||
flash('Entry has been deleted.', 'success')
|
||||
return jsonify({'success': 'Entry has been deleted.'}), 200
|
||||
return jsonify({'error': 'An error occurred.'}), 400
|
||||
|
||||
@views.route('/certificate/', methods=['POST'])
|
||||
@admin_account_required
|
||||
@login_required
|
||||
def generate_certificate():
|
||||
from main import db
|
||||
_id = request.get_json()['_id']
|
||||
entry = decrypt_find_one(db.entries, {'_id': _id})
|
||||
if not entry:
|
||||
return abort(404)
|
||||
return render_template('/admin/components/certificate.html', entry = entry)
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
@ -70,14 +70,14 @@ $('form[name=form-upload-questions]').submit(function(event) {
|
||||
// Edit and Delete Test Button Handlers
|
||||
$('.test-action').click(function(event) {
|
||||
|
||||
let _id = $(this).data('_id');
|
||||
let id = $(this).data('id');
|
||||
let action = $(this).data('action');
|
||||
|
||||
if (action == 'delete') {
|
||||
if (action == 'delete' || action == 'start' || action == 'end') {
|
||||
$.ajax({
|
||||
url: `/admin/tests/delete/`,
|
||||
url: `/admin/tests/edit/`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({'_id': _id}),
|
||||
data: JSON.stringify({'id': id, 'action': action}), // TODO Change how CRUD operations work
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
window.location.href = '/admin/tests/';
|
||||
@ -87,21 +87,7 @@ $('.test-action').click(function(event) {
|
||||
},
|
||||
});
|
||||
} else if (action == 'edit') {
|
||||
window.location.href = `/admin/test/${_id}/`
|
||||
} else if (action == 'close'){
|
||||
$.ajax({
|
||||
url: `/admin/tests/close/`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({'_id': _id}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
$(window).scrollTop(0);
|
||||
window.location.reload();
|
||||
},
|
||||
error: function(response){
|
||||
error_response(response);
|
||||
},
|
||||
});
|
||||
window.location.href = `/admin/test/${id}/`
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
@ -166,7 +152,7 @@ $('#dismiss-cookie-alert').click(function(event){
|
||||
|
||||
$.ajax({
|
||||
url: '/cookies/',
|
||||
type: 'GET',
|
||||
type: 'POST',
|
||||
data: {
|
||||
time: Date.now()
|
||||
},
|
||||
@ -185,13 +171,13 @@ $('#dismiss-cookie-alert').click(function(event){
|
||||
// Script for Result Actions
|
||||
$('.result-action-buttons').click(function(event){
|
||||
|
||||
var _id = $(this).data('_id');
|
||||
var id = $(this).data('id');
|
||||
|
||||
if ($(this).data('result-action') == 'generate') {
|
||||
$.ajax({
|
||||
url: '/admin/certificate/',
|
||||
type: 'POST',
|
||||
data: JSON.stringify({'_id': _id}),
|
||||
data: JSON.stringify({'id': id}),
|
||||
contentType: 'application/json',
|
||||
dataType: 'html',
|
||||
success: function(response) {
|
||||
@ -207,7 +193,7 @@ $('.result-action-buttons').click(function(event){
|
||||
$.ajax({
|
||||
url: window.location.href,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({'_id': _id, 'action': action}),
|
||||
data: JSON.stringify({'id': id, 'action': action}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
if (action == 'delete') {
|
@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-update-account" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.home') }}">
|
||||
<form name="form-update-account" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._home') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Update Your Account</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
@ -32,7 +32,7 @@
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<a href="{{ url_for('admin_views.users') }}" class="btn btn-md btn-danger btn-block" type="button">
|
||||
<a href="{{ url_for('admin._users') }}" class="btn btn-md btn-danger btn-block" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||
</svg>
|
@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-login" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.home') }}">
|
||||
<form name="form-login" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._home') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form">Log In</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
@ -26,7 +26,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('admin_auth.reset') }}" class="signin-forgot-password">Reset Password</a>
|
||||
<a href="{{ url_for('admin._reset') }}" class="signin-forgot-password">Reset Password</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
@ -3,14 +3,14 @@
|
||||
{% block navbar %}
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin_views.home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-register" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_auth.login') }}">
|
||||
<form name="form-register" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Register an Account</h2>
|
||||
{{ form.hidden_tag() }}
|
@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-reset" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_auth.login') }}">
|
||||
<form name="form-reset" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Reset Password</h2>
|
||||
{{ form.hidden_tag() }}
|
@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-update-password" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_auth.login') }}">
|
||||
<form name="form-update-password" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Update Password</h2>
|
||||
{{ form.hidden_tag() }}
|
@ -15,30 +15,30 @@
|
||||
<h5 class="mb-1">Candidate</h5>
|
||||
</div>
|
||||
<h2>
|
||||
{{ entry.name.surname}}, {{ entry.name.first_name }}
|
||||
{{ entry.get_surname()}}, {{ entry.get_first_name() }}
|
||||
</h2>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Email Address</h5>
|
||||
</div>
|
||||
{{ entry.email }}
|
||||
{{ entry.get_email() }}
|
||||
</li>
|
||||
{% if entry['club'] %}
|
||||
{% if entry.club %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Club</h5>
|
||||
</div>
|
||||
{{ entry.club }}
|
||||
{{ entry.get_club() }}
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Exam Code</h5>
|
||||
</div>
|
||||
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
|
||||
{{ entry.test.get_code() }}
|
||||
</li>
|
||||
{% if entry['user_code'] %}
|
||||
{% if entry.user_code %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">User Code</h5>
|
||||
@ -59,19 +59,19 @@
|
||||
<span class="badge bg-danger">Late</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{{ entry.submission_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||
{{ entry.end_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Score</h5>
|
||||
</div>
|
||||
{{ entry.results.score }}%
|
||||
{{ entry.result.score }}%
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action {% if entry.results.grade == 'fail' %}list-group-item-danger {% elif entry.results.grade == 'merit' %} list-group-item-success {% endif %}">
|
||||
<li class="list-group-item list-group-item-action {% if entry.result.grade == 'fail' %}list-group-item-danger {% elif entry.result.grade == 'merit' %} list-group-item-success {% endif %}">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Grade</h5>
|
||||
</div>
|
||||
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:]}}
|
||||
{{ entry.result.grade[0]|upper }}{{ entry.result.grade[1:]}}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="site-footer mt-5">
|
111
ref-test/app/admin/templates/admin/components/navbar.html
Normal file
@ -0,0 +1,111 @@
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbar"
|
||||
aria-controls="navbar"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle Navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navbar">
|
||||
<ul class="navbar-nav">
|
||||
{% if not current_user.is_authenticated %}
|
||||
<li class="nav-item" id="nav-login">
|
||||
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item" id="nav-results">
|
||||
<a href="{{ url_for('admin._view_entries') }}" id="link-results" class="nav-link">View Results</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-tests">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-tests"
|
||||
role="button"
|
||||
href="{{ url_for('admin._tests') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Exams
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-settings"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-settings">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
role="button"
|
||||
href="{{ url_for('admin._settings') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-settings"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Question Datasets</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-account">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
role="button"
|
||||
href="{{ url_for('admin._update_user', id=current_user.id) }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Account
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-account"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
@ -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>
|
@ -25,22 +25,22 @@
|
||||
{% for test in current_tests %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('admin_views.view_test', _id=test._id) }}">{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}</a>
|
||||
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ test.expiry_date.strftime('%d %b %Y') }}
|
||||
{{ test.end_date.strftime('%d %b %Y') }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a href="{{ url_for('admin_views.tests', filter='active') }}" class="btn btn-primary">View Exams</a>
|
||||
<a href="{{ url_for('admin._tests', filter='active') }}" class="btn btn-primary">View Exams</a>
|
||||
{% else %}
|
||||
<div class="alert alert-primary">
|
||||
There are currently no active exams.
|
||||
</div>
|
||||
<a href="{{ url_for('admin_views.tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
|
||||
<a href="{{ url_for('admin._tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@ -69,20 +69,20 @@
|
||||
{% for result in recent_results %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('admin_views.view_entry', _id=result._id) }}">{{ result.name.surname }}, {{ result.name.first_name }}</a>
|
||||
<a href="{{ url_for('admin._view_entry', id=result.id) }}">{{ result.get_surname() }}, {{ result.get_first_name() }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ result.submission_time.strftime('%d %b %Y %H:%M') }}
|
||||
{{ result.end_time.strftime('%d %b %Y %H:%M') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ result.percent }}% ({{ result.results.grade }})
|
||||
{{ (100*result.result['score']/result.result['max'])|round|int }}% ({{ result.result.grade }})
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a href="{{ url_for('admin_views.view_entries') }}" class="btn btn-primary">View Results</a>
|
||||
<a href="{{ url_for('admin._view_entries') }}" class="btn btn-primary">View Results</a>
|
||||
{% else %}
|
||||
<div class="alert alert-primary">
|
||||
There are currently no exam results to preview.
|
||||
@ -114,22 +114,22 @@
|
||||
{% for test in upcoming_tests %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('admin_views.view_test', _id=test._id) }}">{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}</a>
|
||||
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ test.expiry_date.strftime('%d %b %Y') }}
|
||||
{{ test.end_date.strftime('%d %b %Y') }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a href="{{ url_for('admin_views.tests', filter='scheduled') }}" class="btn btn-primary">View Exams</a>
|
||||
<a href="{{ url_for('admin._tests', filter='scheduled') }}" class="btn btn-primary">View Exams</a>
|
||||
{% else %}
|
||||
<div class="alert alert-primary">
|
||||
There are currently no upcoming exams.
|
||||
</div>
|
||||
<a href="{{ url_for('admin_views.tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
|
||||
<a href="{{ url_for('admin._tests', filter='create') }}" class="btn btn-primary">Create Exam</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
@ -13,30 +13,30 @@
|
||||
<h5 class="mb-1">Candidate</h5>
|
||||
</div>
|
||||
<h2>
|
||||
{{ entry.name.surname }}, {{ entry.name.first_name }}
|
||||
{{ entry.get_surname() }}, {{ entry.get_first_name() }}
|
||||
</h2>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Email Address</h5>
|
||||
</div>
|
||||
{{ entry.email }}
|
||||
{{ entry.get_email() }}
|
||||
</li>
|
||||
{% if entry['club'] %}
|
||||
{% if entry.club %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Club</h5>
|
||||
</div>
|
||||
{{ entry.club }}
|
||||
{{ entry.get_club() }}
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Exam Code</h5>
|
||||
</div>
|
||||
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
|
||||
{{ entry.test.get_code() }}
|
||||
</li>
|
||||
{% if entry['user_code'] %}
|
||||
{% if entry.user_code %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">User Code</h5>
|
||||
@ -44,7 +44,7 @@
|
||||
{{ entry.user_code }}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if 'start_time' in entry %}
|
||||
{% if entry.start_time %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Start Time</h5>
|
||||
@ -59,28 +59,28 @@
|
||||
<span class="badge bg-danger">Late</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if 'submission_time' in entry %}
|
||||
{{ entry.submission_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||
{% if entry.end_time %}
|
||||
{{ entry.end_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||
{% else %}
|
||||
Incomplete
|
||||
{% endif %}
|
||||
</li>
|
||||
{% if 'results' in entry %}
|
||||
{% if entry.result %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Score</h5>
|
||||
</div>
|
||||
{{ entry.results.score }}%
|
||||
{{ entry.result.score }}%
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action {% if entry.results.grade == 'fail' %}list-group-item-danger {% elif entry.results.grade == 'merit' %} list-group-item-success {% endif %}">
|
||||
<li class="list-group-item list-group-item-action {% if entry.result.grade == 'fail' %}list-group-item-danger {% elif entry.result.grade == 'merit' %} list-group-item-success {% endif %}">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Grade</h5>
|
||||
</div>
|
||||
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:]}}
|
||||
{{ entry.result.grade[0]|upper }}{{ entry.result.grade[1:]}}
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% if 'results' in entry %}
|
||||
{% if entry.result %}
|
||||
<div class="accordion" id="results-breakdown">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="by-category">
|
||||
@ -105,7 +105,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag, scores in entry.results.tags.items() %}
|
||||
{% for tag, scores in entry.result.tags.items() %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ tag }}
|
||||
@ -149,8 +149,8 @@
|
||||
{{ question }}
|
||||
</td>
|
||||
<td>
|
||||
{{ answer }}
|
||||
{% if not correct[question] == answer %}
|
||||
{{ answers[question|int][answer|int] }}
|
||||
{% if not correct[question] == answer|int %}
|
||||
<span class="badge badge-pill bg-danger badge-danger">Incorrect</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
@ -164,19 +164,19 @@
|
||||
{% endif %}
|
||||
<div class="container justify-content-center">
|
||||
<div class="row">
|
||||
<a href="#" class="btn btn-primary result-action-buttons" data-result-action="generate" data-_id="{{ entry._id }}">
|
||||
<a href="#" class="btn btn-primary result-action-buttons" data-result-action="generate" data-id="{{ entry.id }}">
|
||||
<i class="bi bi-printer-fill button-icon"></i>
|
||||
Printable Version
|
||||
</a>
|
||||
</div>
|
||||
<div class="row">
|
||||
{% if entry.status == 'late' %}
|
||||
<a href="#" class="btn btn-warning result-action-buttons" data-result-action="override" data-_id="{{ entry._id }}">
|
||||
<a href="#" class="btn btn-warning result-action-buttons" data-result-action="override" data-id="{{ entry.id }}">
|
||||
<i class="bi bi-clock-history button-icon"></i>
|
||||
Allow Late Entry
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="#" class="btn btn-danger result-action-buttons" data-result-action="delete" data-_id="{{ entry._id }}">
|
||||
<a href="#" class="btn btn-danger result-action-buttons" data-result-action="delete" data-id="{{ entry.id }}">
|
||||
<i class="bi bi-trash-fill button-icon"></i>
|
||||
Delete Result
|
||||
</a>
|
@ -37,41 +37,41 @@
|
||||
{% for entry in entries %}
|
||||
<tr class="table-row">
|
||||
<td>
|
||||
{{ entry.name.surname }}, {{ entry.name.first_name }}
|
||||
{{ entry.get_surname() }}, {{ entry.get_first_name() }}
|
||||
</td>
|
||||
<td>
|
||||
{% if 'club' in entry %}
|
||||
{{ entry.club }}
|
||||
{% if entry.club %}
|
||||
{{ entry.get_club() }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }}
|
||||
{{ entry.test.get_code() }}
|
||||
</td>
|
||||
<td>
|
||||
{% if 'status' in entry %}
|
||||
{% if entry.status %}
|
||||
{{ entry.status }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if 'submission_time' in entry %}
|
||||
{{ entry.submission_time.strftime('%d %b %Y') }}
|
||||
{% if entry.end_time %}
|
||||
{{ entry.end_time.strftime('%d %b %Y') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if 'results' in entry %}
|
||||
{{ entry.results.score }}%
|
||||
{% if entry.result %}
|
||||
{{ entry.result.score }}%
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if 'results' in entry %}
|
||||
{{ entry.results.grade }}
|
||||
{% if entry.result %}
|
||||
{{ entry.result.grade }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<a
|
||||
href="{{ url_for('admin_views.view_entry', _id = entry._id ) }}"
|
||||
href="{{ url_for('admin._view_entry', id = entry.id ) }}"
|
||||
class="btn btn-primary entry-details"
|
||||
data-_id="{{entry._id}}"
|
||||
data-id="{{entry.id}}"
|
||||
title="View Details"
|
||||
>
|
||||
<i class="bi bi-file-medical-fill button-icon"></i>
|
@ -2,11 +2,11 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-delete-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.users') }}">
|
||||
<form name="form-delete-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._users') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Delete User ‘{{ user.username }}’?</h2>
|
||||
<h2 class="form-heading">Delete User ‘{{ user.get_username() }}’?</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<p>This action cannot be undone. Deleting an account will mean {{ user.username }} will no longer be able to log in to the admin console.</p>
|
||||
<p>This action cannot be undone. Deleting an account will mean {{ user.get_username() }} will no longer be able to log in to the admin console.</p>
|
||||
<p>Are you sure you want to proceed?</p>
|
||||
<div class="form-label-group">
|
||||
{{ form.password(class_="form-control", placeholder="Confirm Your Password", autofocus=true) }}
|
||||
@ -20,7 +20,7 @@
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<a href="{{ url_for('admin_views.users') }}" autofocus="true" class="btn btn-md btn-primary btn-block" type="button">
|
||||
<a href="{{ url_for('admin._users') }}" autofocus="true" class="btn btn-md btn-primary btn-block" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||
</svg>
|
@ -28,22 +28,22 @@
|
||||
<tr>
|
||||
<td>
|
||||
<a href="
|
||||
{% if user._id == get_id_from_cookie() %}
|
||||
{{ url_for('admin_auth.account') }}
|
||||
{% if user == current_user %}
|
||||
{{ url_for('admin._update_user', id=current_user.id) }}
|
||||
{% else %}
|
||||
{{ url_for('admin_views.update_user', _id=user._id) }}
|
||||
{{ url_for('admin._update_user', id=user.id) }}
|
||||
{% endif%}
|
||||
">{{ user.username }}</a>
|
||||
">{{ user.get_username() }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="mailto:{{ user.email }}">{{ user.email }}</a>
|
||||
<a href="mailto:{{ user.get_email() }}">{{ user.get_email() }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a href="{{ url_for('admin_views.users') }}" class="btn btn-primary">Manage Users</a>
|
||||
<a href="{{ url_for('admin._users') }}" class="btn btn-primary">Manage Users</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -57,7 +57,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
File Name
|
||||
Uploaded
|
||||
</th>
|
||||
<th>
|
||||
Exams
|
||||
@ -68,22 +68,22 @@
|
||||
{% for dataset in datasets %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ dataset.filename }}
|
||||
{{ dataset.date.strftime('%d %b %Y %H:%M') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ dataset.use }}
|
||||
{{ dataset.tests|length }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a href="{{ url_for('admin_views.questions') }}" class="btn btn-primary">Manage Datasets</a>
|
||||
<a href="{{ url_for('admin._questions') }}" class="btn btn-primary">Manage Datasets</a>
|
||||
{% else %}
|
||||
<div class="alert alert-primary">
|
||||
There are currently no question datasets uploaded.
|
||||
</div>
|
||||
<a href="{{ url_for('admin_views.questions') }}" class="btn btn-primary">Upload Dataset</a>
|
||||
<a href="{{ url_for('admin._questions') }}" class="btn btn-primary">Upload Dataset</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
@ -9,9 +9,6 @@
|
||||
<tr>
|
||||
<th>
|
||||
|
||||
</th>
|
||||
<th data-priority="1">
|
||||
File Name
|
||||
</th>
|
||||
<th data-priority="2">
|
||||
Uploaded
|
||||
@ -31,7 +28,7 @@
|
||||
{% for element in data %}
|
||||
<tr class="table-row">
|
||||
<td>
|
||||
{% if element.filename == default %}
|
||||
{% if element.default %}
|
||||
<div class="text-success" title="Default Dataset">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi success bi-caret-right-fill" viewBox="0 0 16 16">
|
||||
<path d="m12.14 8.753-5.482 4.796c-.646.566-1.658.106-1.658-.753V3.204a1 1 0 0 1 1.659-.753l5.48 4.796a1 1 0 0 1 0 1.506z"/>
|
||||
@ -40,16 +37,13 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ element.filename }}
|
||||
{{ element.date.strftime('%d %b %Y %H:%M') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ element.timestamp.strftime('%d %b %Y') }}
|
||||
{{ element.creator.get_username() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ element.author }}
|
||||
</td>
|
||||
<td>
|
||||
{{ element.use }}
|
||||
{{ element.tests|length }}
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<a
|
||||
@ -112,10 +106,10 @@
|
||||
$(document).ready(function() {
|
||||
$('#question-datasets-table').DataTable({
|
||||
'columnDefs': [
|
||||
{'sortable': false, 'targets': [0,5]},
|
||||
{'searchable': false, 'targets': [0,4,5]}
|
||||
{'sortable': false, 'targets': [0,4]},
|
||||
{'searchable': false, 'targets': [0,3,4]}
|
||||
],
|
||||
'order': [[2, 'desc'], [3, 'asc']],
|
||||
'order': [[1, 'desc'], [2, 'asc']],
|
||||
'responsive': 'true',
|
||||
'fixedHeader': 'true',
|
||||
});
|
@ -2,12 +2,12 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-update-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin_views.users') }}">
|
||||
<form name="form-update-user" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._users') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Update User ‘{{ user.username }}’</h2>
|
||||
<h2 class="form-heading">Update User ‘{{ user.get_username() }}’</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form-label-group">
|
||||
{{ form.email(class_="form-control", placeholder="Email Address", value = user.email) }}
|
||||
{{ form.email(class_="form-control", placeholder="Email Address", value = user.get_email()) }}
|
||||
{{ form.email.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
@ -23,17 +23,17 @@
|
||||
{{ form.notify.label }}
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
Please confirm <strong>your password</strong> before committing any changes to a user account.
|
||||
Please confirm <strong>your current password</strong> before committing any changes to a user account.
|
||||
</div>
|
||||
<div class="form-label-group">
|
||||
{{ form.user_password(class_="form-control", placeholder="Your Password", value = user.email) }}
|
||||
{{ form.user_password.label }}
|
||||
{{ form.confirm_password(class_="form-control", placeholder="Your Password", value = user.email) }}
|
||||
{{ form.confirm_password.label }}
|
||||
</div>
|
||||
{% include "admin/components/client-alerts.html" %}
|
||||
<div class="container form-submission-button">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<a href="{{ url_for('admin_views.users') }}" class="btn btn-md btn-danger btn-block" type="button">
|
||||
<a href="{{ url_for('admin._users') }}" class="btn btn-md btn-danger btn-block" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||
</svg>
|
@ -23,7 +23,7 @@
|
||||
{% for user in users %}
|
||||
<tr class="table-row">
|
||||
<td>
|
||||
{% if user._id == get_id_from_cookie() %}
|
||||
{% if user == current_user %}
|
||||
<div class="text-success" title="Current User">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi success bi-caret-right-fill" viewBox="0 0 16 16">
|
||||
<path d="m12.14 8.753-5.482 4.796c-.646.566-1.658.106-1.658-.753V3.204a1 1 0 0 1 1.659-.753l5.48 4.796a1 1 0 0 1 0 1.506z"/>
|
||||
@ -32,18 +32,18 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ user.username }}
|
||||
{{ user.get_username() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ user.email }}
|
||||
{{ user.get_email() }}
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<a
|
||||
href="
|
||||
{% if not user._id == get_id_from_cookie() %}
|
||||
{{ url_for('admin_views.update_user', _id = user._id ) }}
|
||||
{% if not user == current_user %}
|
||||
{{ url_for('admin._update_user', id = user.id ) }}
|
||||
{% else %}
|
||||
{{ url_for('admin_auth.account') }}
|
||||
{{ url_for('admin._update_user', id=current_user.id) }}
|
||||
{% endif %}
|
||||
"
|
||||
class="btn btn-primary"
|
||||
@ -53,15 +53,15 @@
|
||||
</a>
|
||||
<a
|
||||
href="
|
||||
{% if not user._id == get_id_from_cookie() %}
|
||||
{{ url_for('admin_views.delete_user', _id = user._id ) }}
|
||||
{% if not user == current_user %}
|
||||
{{ url_for('admin._delete_user', id = user.id ) }}
|
||||
{% else %}
|
||||
#
|
||||
{% endif %}
|
||||
"
|
||||
class="btn btn-danger {% if user._id == get_id_from_cookie() %} disabled {% endif %}"
|
||||
class="btn btn-danger {% if user == current_user %} disabled {% endif %}"
|
||||
title="Delete User"
|
||||
{% if user._id == get_id_from_cookie() %} onclick="return false" {% endif %}
|
||||
{% if user == current_user %} onclick="return false" {% endif %}
|
||||
>
|
||||
<i class="bi bi-person-x-fill button-icon"></i>
|
||||
</button>
|
@ -12,38 +12,33 @@
|
||||
<h5 class="mb-1">Exam Code</h5>
|
||||
</div>
|
||||
<h2>
|
||||
{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}
|
||||
{{ test.get_code() }}
|
||||
</h2>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Dataset</h5>
|
||||
</div>
|
||||
{{ test.dataset }}
|
||||
{{ test.dataset.date.strftime('%Y%m%d%H%M%S') }}
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Created By</h5>
|
||||
</div>
|
||||
{{ test.creator }}
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Date Created</h5>
|
||||
</div>
|
||||
{{ test.date_created.strftime('%d %b %Y') }}
|
||||
{{ test.creator.get_username() }}
|
||||
</li>
|
||||
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Start Date</h5>
|
||||
</div>
|
||||
{{ test.start_date.strftime('%d %b %Y') }}
|
||||
{{ test.start_date.strftime('%d %b %Y %H:%M') }}
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Expiry Date</h5>
|
||||
</div>
|
||||
{{ test.expiry_date.strftime('%d %b %Y') }}
|
||||
{{ test.end_date.strftime('%d %b %Y %H:%M') }}
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
@ -62,7 +57,7 @@
|
||||
{% endif %}
|
||||
</li>
|
||||
<div class="accordion" id="test-info-detail">
|
||||
{% if 'entries' in test and test.entries|length > 0 %}
|
||||
{% if test.entries|length > 0 %}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="test-entries">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#test-entries-list" aria-expanded="false" aria-controls="test-entries-list">
|
||||
@ -76,7 +71,7 @@
|
||||
{% for entry in test.entries %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('admin_views.view_entry', _id=entry) }}" >Entry {{ loop.index }}</a>
|
||||
<a href="{{ url_for('admin._view_entry', id=entry) }}" >Entry {{ loop.index }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -86,7 +81,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if 'time_adjustments' in test and test.time_adjustments|length > 0 %}
|
||||
{% if test.adjustments %}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="test-adjustments">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#test-adjustments-list" aria-expanded="false" aria-controls="test-adjustments-list">
|
||||
@ -110,10 +105,10 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, value in test.time_adjustments.items() %}
|
||||
{% for key, value in test.adjustments.items() %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ key }}
|
||||
{{ key.upper() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ value }}
|
||||
@ -143,7 +138,7 @@
|
||||
<form name="form-add-adjustment" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="form-label-group">
|
||||
{{ form.time(class_="form-control", placeholder="Enter Username") }}
|
||||
{{ form.time(class_="form-control", placeholder="Enter Time") }}
|
||||
{{ form.time.label }}
|
||||
</div>
|
||||
<div class="container form-submission-button">
|
||||
@ -168,11 +163,18 @@
|
||||
</div>
|
||||
<div class="container justify-content-center">
|
||||
<div class="row">
|
||||
<a href="#" class="btn btn-warning test-action" data-action="close" data-_id="{{ test._id }}">
|
||||
<i class="bi bi-hourglass button-icon"></i>
|
||||
Close Exam
|
||||
</a>
|
||||
<a href="#" class="btn btn-danger test-action" data-action="delete" data-_id="{{ test._id }}">
|
||||
{% if test.start_date <= now %}
|
||||
<a href="#" class="btn btn-warning test-action {% if test.end_date < now %}disabled{% endif %}" data-action="end" data-id="{{ test.id }}">
|
||||
<i class="bi bi-hourglass-bottom button-icon"></i>
|
||||
Close Exam
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-success test-action {% if test.start_date < now %}disabled{% endif %}" data-action="start" data-id="{{ test.id }}">
|
||||
<i class="bi bi-hourglass-top button-icon"></i>
|
||||
Start Exam
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="#" class="btn btn-danger test-action" data-action="delete" data-id="{{ test.id }}">
|
||||
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
||||
Delete Exam
|
||||
</a>
|
@ -33,13 +33,13 @@
|
||||
{% for test in tests %}
|
||||
<tr class="table-row">
|
||||
<td>
|
||||
{{ test.start_date.strftime('%d %b %Y') }}
|
||||
{{ test.start_date.strftime('%d %b %y %H:%M') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }}
|
||||
{{ test.get_code() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ test.expiry_date.strftime('%d %b %Y') }}
|
||||
{{ test.end_date.strftime('%d %b %Y %H:%M') }}
|
||||
</td>
|
||||
<td>
|
||||
{% if test.time_limit == None -%}
|
||||
@ -61,7 +61,7 @@
|
||||
<a
|
||||
href="#"
|
||||
class="btn btn-primary test-action"
|
||||
data-_id="{{test._id}}"
|
||||
data-id="{{test.id}}"
|
||||
title="Edit Exam"
|
||||
data-action="edit"
|
||||
>
|
||||
@ -70,7 +70,7 @@
|
||||
<a
|
||||
href="#"
|
||||
class="btn btn-danger test-action"
|
||||
data-_id="{{test._id}}"
|
||||
data-id="{{test.id}}"
|
||||
title="Delete Exam"
|
||||
data-action="delete"
|
||||
>
|
392
ref-test/app/admin/views.py
Normal file
@ -0,0 +1,392 @@
|
||||
from ..forms.admin import AddTimeAdjustment, CreateTest, CreateUser, DeleteUser, Login, Register, ResetPassword, UpdatePassword, UpdateUser, UploadData
|
||||
from ..models import Dataset, Entry, Test, User
|
||||
from ..tools.auth import disable_if_logged_in, require_account_creation
|
||||
from ..tools.forms import get_dataset_choices, get_time_options
|
||||
from ..tools.data import check_is_json, validate_json
|
||||
from ..tools.test import answer_options, get_correct_answers
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, redirect, request, session
|
||||
from flask.helpers import flash, url_for
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from datetime import date, datetime
|
||||
from json import loads
|
||||
import secrets
|
||||
|
||||
admin = Blueprint(
|
||||
name='admin',
|
||||
import_name=__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static'
|
||||
)
|
||||
|
||||
@admin.route('/')
|
||||
@admin.route('/home/')
|
||||
@admin.route('/dashboard/')
|
||||
@login_required
|
||||
def _home():
|
||||
tests = Test.query.all()
|
||||
results = Entry.query.all()
|
||||
current_tests = [ test for test in tests if test.end_date >= datetime.now() and test.start_date.date() <= date.today() ]
|
||||
current_tests.sort(key= lambda x: x.end_date, reverse=True)
|
||||
upcoming_tests = [ test for test in tests if test.start_date.date() > datetime.now().date()]
|
||||
upcoming_tests.sort(key= lambda x: x.start_date)
|
||||
recent_results = [result for result in results if not result.status == 'started' ]
|
||||
recent_results.sort(key= lambda x: x.end_time, reverse=True)
|
||||
return render_template('/admin/index.html', current_tests = current_tests, upcomimg_tests = upcoming_tests, recent_results = recent_results)
|
||||
|
||||
@admin.route('/settings/')
|
||||
@login_required
|
||||
def _settings():
|
||||
users = User.query.all()
|
||||
datasets = Dataset.query.all()
|
||||
return render_template('/admin/settings/index.html', users=users, datasets=datasets)
|
||||
|
||||
@admin.route('/login/', methods=['GET','POST'])
|
||||
@disable_if_logged_in
|
||||
@require_account_creation
|
||||
def _login():
|
||||
form = Login()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
users = User.query.all()
|
||||
user = None
|
||||
for _user in users:
|
||||
if _user.get_username() == request.form.get('username').lower():
|
||||
user = _user
|
||||
break
|
||||
if user:
|
||||
if user.verify_password(request.form.get('password')):
|
||||
user.login(remember=request.form.get('remember'))
|
||||
return jsonify({'success': f'Successfully logged in.'}), 200
|
||||
return jsonify({'error': f'The password you entered is incorrect.'}), 401
|
||||
return jsonify({'error': f'The username you entered does not exist.'}), 401
|
||||
errors = [*form.username.errors, *form.password.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
if 'remembered_username' in session: form.username.data = session.pop('remembered_username')
|
||||
next = request.args.get('next')
|
||||
return render_template('/admin/auth/login.html', form=form, next=next)
|
||||
|
||||
@admin.route('/logout/')
|
||||
@login_required
|
||||
def _logout():
|
||||
current_user.logout()
|
||||
return redirect(url_for('admin._login'))
|
||||
|
||||
@admin.route('/register/', methods=['GET','POST'])
|
||||
@disable_if_logged_in
|
||||
def _register():
|
||||
from ..models.user import User
|
||||
form = Register()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
new_user = User()
|
||||
new_user.set_username(request.form.get('username').lower())
|
||||
new_user.set_email(request.form.get('email').lower())
|
||||
success, message = new_user.register(password=request.form.get('password'))
|
||||
if success:
|
||||
flash(message=f'{message} Please log in to continue.', category='success')
|
||||
session['remembered_username'] = request.form.get('username').lower()
|
||||
return jsonify({'success': message}), 200
|
||||
flash(message=message, category='error')
|
||||
return jsonify({'error': message}), 401
|
||||
errors = [*form.username.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
return render_template('admin/auth/register.html', form=form)
|
||||
|
||||
@admin.route('/reset/', methods=['GET','POST'])
|
||||
def _reset():
|
||||
form = ResetPassword()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
user = None
|
||||
users = User.query.all()
|
||||
for _user in users:
|
||||
if _user.get_username() == request.form.get('username'):
|
||||
user = _user
|
||||
break
|
||||
if not user: return jsonify({'error': 'The user account does not exist.'}), 400
|
||||
if not user.get_email() == request.form.get('email'): return jsonify({'error': 'The email address does not match the user account.'}), 400
|
||||
return user.reset_password()
|
||||
errors = [*form.username.errors, *form.email.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
token = request.args.get('token')
|
||||
if token:
|
||||
user = User.query.filter_by(reset_token=token).first()
|
||||
if not user: return redirect(url_for('admin._reset'))
|
||||
verification_token = user.verification_token
|
||||
user.clear_reset_tokens()
|
||||
if request.args.get('verification') == verification_token:
|
||||
form = UpdatePassword()
|
||||
return render_template('/auth/update_password.html', form=form, user=user.id)
|
||||
flash('The verification of your password reset request failed and the token has been invalidated. Please make a new reset password request.', 'error')
|
||||
|
||||
return render_template('/admin/auth/reset.html', form=form)
|
||||
|
||||
@admin.route('/update_password/', methods=['POST'])
|
||||
def _update_password():
|
||||
form = UpdatePassword()
|
||||
if form.validate_on_submit():
|
||||
user = request.form.get('user')
|
||||
user = User.query.filter_by(id=user).first()
|
||||
user.update(password=request.form.get('password'))
|
||||
session['remembered_username'] = user.get_username()
|
||||
flash('Your password has been reset.', 'success')
|
||||
return jsonify({'success':'Your password has been reset'}), 200
|
||||
errors = [*form.password.errors, *form.password_reenter.errors]
|
||||
return jsonify({ 'error': errors}), 401
|
||||
|
||||
@admin.route('/settings/users/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _users():
|
||||
form = CreateUser()
|
||||
users = User.query.all()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
password = request.form.get('password')
|
||||
password = secrets.token_hex(12) if not password else password
|
||||
new_user = User()
|
||||
new_user.set_username(request.form.get('username').lower())
|
||||
new_user.set_email(request.form.get('email'))
|
||||
success, message = new_user.register(notify=request.form.get('notify'), password=password)
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 401
|
||||
errors = [*form.username.errors, *form.email.errors, *form.password.errors]
|
||||
return jsonify({ 'error': errors}), 401
|
||||
return render_template('/admin/settings/users.html', form = form, users = users)
|
||||
|
||||
@admin.route('/settings/users/delete/<string:id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _delete_user(id:str):
|
||||
user = User.query.filter_by(id=id).first()
|
||||
form = DeleteUser()
|
||||
if request.method == 'POST':
|
||||
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
||||
if id == current_user.id: return jsonify({'error': 'Cannot delete your own account.'}), 400
|
||||
if form.validate_on_submit():
|
||||
password = request.form.get('password')
|
||||
if not current_user.verify_password(password): return jsonify({'error': 'The password you entered is incorrect.'}), 401
|
||||
success, message = user.delete(notify=request.form.get('notify'))
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
errors = form.password.errors
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
if id == current_user.id:
|
||||
flash('Cannot delete your own user account.', 'error')
|
||||
return redirect(url_for('admin._users'))
|
||||
if not user:
|
||||
flash('User not found.', 'error')
|
||||
return redirect(url_for('admin._users'))
|
||||
return render_template('/admin/settings/delete_user.html', form=form, id = id, user = user)
|
||||
|
||||
@admin.route('/settings/users/update/<string:id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _update_user(id:str):
|
||||
user = User.query.filter_by(id=id).first()
|
||||
form = UpdateUser()
|
||||
if request.method == 'POST':
|
||||
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
||||
if form.validate_on_submit():
|
||||
if not user.verify_password(request.form.get('confirm_password')): return jsonify({'error': 'Invalid password for your account.'}), 401
|
||||
success, message = user.update(
|
||||
password = request.form.get('password'),
|
||||
email = request.form.get('email'),
|
||||
notify = request.form.get('notify')
|
||||
)
|
||||
if success:
|
||||
flash(message, 'success')
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
errors = [*form.confirm_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
if not user:
|
||||
flash('User not found.', 'error')
|
||||
return redirect(url_for('admin._users'))
|
||||
return render_template('/admin/settings/update_user.html', form=form, id = id, user = user)
|
||||
|
||||
@admin.route('/settings/questions/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _questions():
|
||||
form = UploadData()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
upload = form.data_file.data
|
||||
if not check_is_json(upload): return jsonify({'error': 'Invalid file. Please upload a JSON file.'}), 400
|
||||
if not validate_json(upload): return jsonify({'error': 'The data in the file is invalid.'}), 400 # TODO Perhaps make a more complex validation script
|
||||
new_dataset = Dataset()
|
||||
success, message = new_dataset.create(
|
||||
upload = upload,
|
||||
default = request.form.get('default')
|
||||
)
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
errors = form.data_file.errors
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
data = Dataset.query.all()
|
||||
return render_template('/admin/settings/questions.html', form=form, data=data)
|
||||
|
||||
@admin.route('/settings/questions/edit/', methods=['POST'])
|
||||
@login_required
|
||||
def _edit_questions():
|
||||
id = request.get_json()['id']
|
||||
action = request.get_json()['action']
|
||||
if action not in ['defailt', 'delete']: return jsonify({'error': 'Invalid action.'}), 400
|
||||
dataset = Dataset.query.filter_by(id=id).first()
|
||||
if action == 'delete': success, message = dataset.delete()
|
||||
elif action == 'default': success, message = dataset.make_default()
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
|
||||
@admin.route('/tests/<string:filter>/', methods=['GET'])
|
||||
@admin.route('/tests/', methods=['GET'])
|
||||
@login_required
|
||||
def _tests(filter:str=None):
|
||||
datasets = Dataset.query.all()
|
||||
tests = None
|
||||
_tests = Test.query.all()
|
||||
form = None
|
||||
now = datetime.now()
|
||||
if not datasets:
|
||||
flash('There are no available question datasets. Please upload a question dataset in order to set up an exam.', 'error')
|
||||
return redirect(url_for('admin._questions'))
|
||||
if filter not in ['create','active','scheduled','expired','all']: return redirect(url_for('admin._tests', filter='active'))
|
||||
if filter == 'create':
|
||||
form = CreateTest()
|
||||
form.time_limit.choices = get_time_options()
|
||||
form.dataset.choices = get_dataset_choices()
|
||||
form.time_limit.default='none'
|
||||
form.process()
|
||||
display_title = ''
|
||||
error_none = ''
|
||||
if filter in [None, '', 'active']:
|
||||
tests = [ test for test in _tests if test.end_date >= now and test.start_date <= now ]
|
||||
display_title = 'Active Exams'
|
||||
error_none = 'There are no exams that are currently active. You can create one using the Creat Exam form.'
|
||||
if filter == 'expired':
|
||||
tests = [ test for test in _tests if test.end_date < now ]
|
||||
display_title = 'Expired Exams'
|
||||
error_none = 'There are no expired exams. Exams will appear in this category after their expiration date has passed.'
|
||||
if filter == 'scheduled':
|
||||
tests = [ test for test in _tests if test.start_date > now]
|
||||
display_title = 'Scheduled Exams'
|
||||
error_none = 'There are no scheduled exams pending. You can schedule an exam for the future using the Create Exam form.'
|
||||
if filter == 'all':
|
||||
tests = _tests
|
||||
display_title = 'All Exams'
|
||||
error_none = 'There are no exams set up. You can create one using the Create Exam form.'
|
||||
return render_template('/admin/tests.html', form = form, tests=tests, display_title=display_title, error_none=error_none, filter=filter)
|
||||
|
||||
@admin.route('/tests/create/', methods=['POST'])
|
||||
@login_required
|
||||
def _create_test():
|
||||
form = CreateTest()
|
||||
form.dataset.choices = get_dataset_choices()
|
||||
form.time_limit.choices = get_time_options()
|
||||
if form.validate_on_submit():
|
||||
new_test = Test()
|
||||
new_test.start_date = request.form.get('start_date')
|
||||
new_test.start_date = datetime.strptime(new_test.start_date, '%Y-%m-%dT%H:%M')
|
||||
new_test.end_date = request.form.get('expiry_date')
|
||||
new_test.end_date = datetime.strptime(new_test.end_date, '%Y-%m-%dT%H:%M')
|
||||
new_test.time_limit = None if request.form.get('time_limit') == 'none' else int(request.form.get('time_limit'))
|
||||
dataset = request.form.get('dataset')
|
||||
new_test.dataset = Dataset.query.filter_by(id=dataset).first()
|
||||
success, message = new_test.create()
|
||||
if success:
|
||||
flash(message=message, category='success')
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
else:
|
||||
errors = [*form.start_date.errors, *form.expiry_date.errors, *form.time_limit.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
|
||||
@admin.route('/tests/edit/', methods=['POST'])
|
||||
@login_required
|
||||
def _edit_test():
|
||||
id = request.get_json()['id']
|
||||
action = request.get_json()['action']
|
||||
if action not in ['start', 'delete', 'end']: return jsonify({'error': 'Invalid action.'}), 400
|
||||
test = Test.query.filter_by(id=id).first()
|
||||
if not test: return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
|
||||
if action == 'delete': success, message = test.delete()
|
||||
if action == 'start': success, message = test.start()
|
||||
if action == 'end': success, message = test.end()
|
||||
if success:
|
||||
flash(message=message, category='success')
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
|
||||
@admin.route('/test/<string:id>/', methods=['GET','POST'])
|
||||
@login_required
|
||||
def _view_test(id:str=None):
|
||||
form = AddTimeAdjustment()
|
||||
test = Test.query.filter_by(id=id).first()
|
||||
if request.method == 'POST':
|
||||
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
|
||||
if form.validate_on_submit():
|
||||
time = int(request.form.get('time'))
|
||||
success, message = test.add_adjustment(time)
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
return jsonify({'error': form.time.errors }), 400
|
||||
if not test:
|
||||
flash('Invalid test ID.', 'error')
|
||||
return redirect(url_for('admin._tests', filter='active'))
|
||||
return render_template('/admin/test.html', test = test, form = form)
|
||||
|
||||
@admin.route('/test/<string:id>/delete-adjustment/', methods=['POST'])
|
||||
@login_required
|
||||
def _delete_adjustment(id:str=None):
|
||||
test = Test.query.filter_by(id=id).first()
|
||||
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
|
||||
user_code = request.get_json()['user_code'].lower()
|
||||
success, message = test.remove_adjustment(user_code)
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
|
||||
@admin.route('/results/')
|
||||
@login_required
|
||||
def _view_entries():
|
||||
entries = Entry.query.all()
|
||||
return render_template('/admin/results.html', entries = entries)
|
||||
|
||||
@admin.route('/results/<string:id>/', methods = ['GET', 'POST'])
|
||||
@login_required
|
||||
def _view_entry(id:str=None):
|
||||
entry = Entry.query.filter_by(id=id).first()
|
||||
if request.method == 'POST':
|
||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||
action = request.get_json()['action']
|
||||
if action not in ['validate', 'delete']: return jsonify({'error': 'Invalid action.'}), 400
|
||||
if action == 'validate':
|
||||
success, message = entry.validate()
|
||||
if action == 'delete':
|
||||
success, message = entry.delete()
|
||||
if success:
|
||||
flash(message, 'success')
|
||||
entry.notify_result()
|
||||
return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}),400
|
||||
if not entry:
|
||||
flash('Invalid entry ID.', 'error')
|
||||
return redirect(url_for('admin._view_entries'))
|
||||
test = entry.test
|
||||
dataset = test.dataset
|
||||
dataset_path = dataset.get_file()
|
||||
with open(dataset_path, 'r') as _dataset:
|
||||
data = loads(_dataset.read())
|
||||
correct = get_correct_answers(dataset=data)
|
||||
answers = answer_options(dataset=data)
|
||||
return render_template('/admin/result-detail.html', entry = entry, correct = correct, answers = answers)
|
||||
|
||||
@admin.route('/certificate/',methods=['POST'])
|
||||
@login_required
|
||||
def _generate_certificate():
|
||||
from main import db
|
||||
id = request.get_json()['id']
|
||||
entry = Entry.query.filter_by(id=id).first()
|
||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||
return render_template('/admin/components/certificate.html', entry = entry)
|
66
ref-test/app/api/views.py
Normal file
@ -0,0 +1,66 @@
|
||||
from ..models import Dataset, Entry
|
||||
from ..tools.test import evaluate_answers, generate_questions
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from json import loads
|
||||
|
||||
api = Blueprint(
|
||||
name='api',
|
||||
import_name=__name__
|
||||
)
|
||||
|
||||
@api.route('/questions/', methods=['POST'])
|
||||
def _fetch_questions():
|
||||
id = request.get_json()['id']
|
||||
entry = Entry.query.filter_by(id=id).first()
|
||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 400
|
||||
test = entry.test
|
||||
user_code = entry.user_code
|
||||
time_limit = test.time_limit
|
||||
time_adjustment = 0
|
||||
if time_limit:
|
||||
_time_limit = int(time_limit)
|
||||
if user_code:
|
||||
time_adjustment = test.adjustments[user_code]
|
||||
_time_limit += time_adjustment
|
||||
end_delta = timedelta(minutes=_time_limit)
|
||||
end_time = datetime.utcnow() + end_delta
|
||||
else:
|
||||
end_time = None
|
||||
entry.start()
|
||||
dataset = test.dataset
|
||||
success, message = dataset.check_file()
|
||||
if not success: return jsonify({'error': message}), 500
|
||||
data_path = dataset.get_file()
|
||||
with open(data_path, 'r') as data_file:
|
||||
data = loads(data_file.read())
|
||||
questions = generate_questions(data)
|
||||
return jsonify({
|
||||
'time_limit': end_time,
|
||||
'questions': questions,
|
||||
'start_time': entry.start_time,
|
||||
'time_adjustment': time_adjustment
|
||||
}), 200
|
||||
|
||||
@api.route('/submit/', methods=['POST'])
|
||||
def _submit_quiz():
|
||||
id = request.get_json()['id']
|
||||
answers = request.get_json()['answers']
|
||||
entry = Entry.query.filter_by(id=id).first()
|
||||
if not entry: return jsonify({'error': 'Unrecognised Entry.'}), 400
|
||||
test = entry.test
|
||||
dataset = test.dataset
|
||||
success, message = dataset.check_file()
|
||||
if not success: return jsonify({'error': message}), 500
|
||||
data_path = dataset.get_file()
|
||||
with open(data_path, 'r') as data_file:
|
||||
data = loads(data_file.read())
|
||||
result = evaluate_answers(answers=answers, key=data)
|
||||
entry.complete(answers=answers, result=result)
|
||||
return jsonify({
|
||||
'success': 'Your submission has been processed. Redirecting you to receive your results.',
|
||||
'id': id
|
||||
}), 200
|
||||
|
47
ref-test/app/config.py
Normal file
@ -0,0 +1,47 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
if not os.getenv('DATA'):
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv('../.env')
|
||||
|
||||
class Config(object):
|
||||
APP_HOST = '0.0.0.0'
|
||||
DATA = os.getenv('DATA')
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||
SERVER_NAME = os.getenv('SERVER_NAME')
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(DATA)}/database.db'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
MAIL_SERVER = os.getenv('MAIL_SERVER')
|
||||
MAIL_PORT = int(os.getenv('MAIL_PORT'))
|
||||
MAIL_USE_TLS = False
|
||||
MAIL_USE_SSL = False
|
||||
MAIL_DEBUG = False
|
||||
MAIL_USERNAME = os.getenv('MAIL_USERNAME')
|
||||
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
|
||||
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER')
|
||||
MAIL_MAX_EMAILS = int(os.getenv('MAIL_MAX_EMAILS'))
|
||||
MAIL_SUPPRESS_SEND = False
|
||||
MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS'))
|
||||
|
||||
class ProductionConfig(Config):
|
||||
pass
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
APP_HOST = '127.0.0.1'
|
||||
DEBUG = True
|
||||
SESSION_COOKIE_SECURE = False
|
||||
MAIL_SERVER = 'localhost'
|
||||
MAIL_DEBUG = True
|
||||
MAIL_SUPPRESS_SEND = False
|
||||
|
||||
class TestingConfig(DevelopmentConfig):
|
||||
TESTING = True
|
||||
SESSION_COOKIE_SECURE = False
|
||||
MAIL_SERVER = os.getenv('MAIL_SERVER')
|
||||
MAIL_DEBUG = True
|
||||
MAIL_SUPPRESS_SEND = False
|
5
ref-test/app/data.py
Normal file
@ -0,0 +1,5 @@
|
||||
from config import Config
|
||||
from os import path
|
||||
from pathlib import Path
|
||||
|
||||
data = Path(Config.DATA)
|
@ -1,62 +1,64 @@
|
||||
from ..tools.forms import value
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileField, FileRequired, FileAllowed
|
||||
from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField, IntegerField
|
||||
from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional
|
||||
from datetime import date, timedelta
|
||||
from flask_wtf.file import FileAllowed, FileField, FileRequired
|
||||
from wtforms import BooleanField, IntegerField, PasswordField, SelectField, StringField
|
||||
from wtforms.fields import DateTimeLocalField
|
||||
from wtforms.validators import InputRequired, Email, EqualTo, Length, Optional
|
||||
|
||||
from .validators import value
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
class Login(FlaskForm):
|
||||
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
||||
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||
remember = BooleanField('Remember Log In', render_kw={'checked': True})
|
||||
|
||||
class RegistrationForm(FlaskForm):
|
||||
class Register(FlaskForm):
|
||||
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
||||
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||
password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')])
|
||||
|
||||
class ResetPasswordForm(FlaskForm):
|
||||
class ResetPassword(FlaskForm):
|
||||
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
||||
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||
|
||||
class UpdatePasswordForm(FlaskForm):
|
||||
class UpdatePassword(FlaskForm):
|
||||
password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||
password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')])
|
||||
|
||||
class CreateUserForm(FlaskForm):
|
||||
class CreateUser(FlaskForm):
|
||||
username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)])
|
||||
email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||
password = PasswordField('Password (Optional)', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||
notify = BooleanField('Notify accout creation by email', render_kw={'checked': True})
|
||||
|
||||
class DeleteUserForm(FlaskForm):
|
||||
class DeleteUser(FlaskForm):
|
||||
password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||
notify = BooleanField('Notify deletion by email', render_kw={'checked': True})
|
||||
|
||||
class UpdateUserForm(FlaskForm):
|
||||
user_password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||
class UpdateUser(FlaskForm):
|
||||
confirm_password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||
email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||
password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')])
|
||||
notify = BooleanField('Notify changes by email', render_kw={'checked': True})
|
||||
|
||||
class UpdateAccountForm(FlaskForm):
|
||||
password_confirm = PasswordField('Current Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||
class UpdateAccount(FlaskForm):
|
||||
confirm_password = PasswordField('Current Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||
email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)])
|
||||
password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')])
|
||||
password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')])
|
||||
|
||||
class CreateTest(FlaskForm):
|
||||
start_date = DateField('Start Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() )
|
||||
expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) )
|
||||
start_date = DateTimeLocalField('Start Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()], default = datetime.now() )
|
||||
expiry_date = DateTimeLocalField('Expiry Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()], default = date.today() + timedelta(days=1) )
|
||||
time_limit = SelectField('Time Limit')
|
||||
dataset = SelectField('Question Dataset')
|
||||
|
||||
class UploadDataForm(FlaskForm):
|
||||
class UploadData(FlaskForm):
|
||||
data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])])
|
||||
default = BooleanField('Make Default', render_kw={'checked': True})
|
||||
|
||||
class AddTimeAdjustment(FlaskForm):
|
||||
time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)])
|
||||
|
||||
time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)])
|
@ -1,6 +1,6 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, BooleanField
|
||||
from wtforms.validators import InputRequired, Email, Length, Optional
|
||||
from wtforms import StringField
|
||||
from wtforms.validators import InputRequired, Length, Email, Optional
|
||||
|
||||
class StartQuiz(FlaskForm):
|
||||
first_name = StringField('First Name(s)', validators=[InputRequired(), Length(max=30)])
|
4
ref-test/app/models/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from .entry import Entry
|
||||
from .test import Test
|
||||
from .user import User
|
||||
from .dataset import Dataset
|
83
ref-test/app/models/dataset.py
Normal file
@ -0,0 +1,83 @@
|
||||
from ..data import data
|
||||
from ..modules import db
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask import flash
|
||||
from flask_login import current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from datetime import datetime
|
||||
from json import dump, loads
|
||||
from os import path, remove
|
||||
from uuid import uuid4
|
||||
|
||||
class Dataset(db.Model):
|
||||
|
||||
id = db.Column(db.String(36), primary_key=True)
|
||||
tests = db.relationship('Test', backref='dataset')
|
||||
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
|
||||
date = db.Column(db.DateTime, nullable=False)
|
||||
default = db.Column(db.Boolean, default=False, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Dataset {self.id}> was added.'
|
||||
|
||||
@property
|
||||
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
|
||||
|
||||
generate_id.setter
|
||||
def generate_id(self): self.id = uuid4().hex
|
||||
|
||||
def make_default(self):
|
||||
for dataset in Dataset.query.all():
|
||||
dataset.default = False
|
||||
self.default = True
|
||||
db.session.commit()
|
||||
write('system.log', f'Dataset {self.id} set as default by {current_user.get_username()}.')
|
||||
flash(message='Dataset set as default.', category='success')
|
||||
return True, f'Dataset set as default.'
|
||||
|
||||
def delete(self):
|
||||
if self.default:
|
||||
message = 'Cannot delete the default dataset.'
|
||||
flash(message, 'error')
|
||||
return False, message
|
||||
if Dataset.query.all().count() == 1:
|
||||
message = 'Cannot delete the only dataset.'
|
||||
flash(message, 'error')
|
||||
return False, message
|
||||
write('system.log', f'Dataset {self.id} deleted by {current_user.get_username()}.')
|
||||
filename = secure_filename('.'.join([self.id,'json']))
|
||||
file_path = path.join(data, 'questions', filename)
|
||||
remove(file_path)
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
return True, 'Dataset deleted.'
|
||||
|
||||
def create(self, upload, default:bool=False):
|
||||
self.generate_id()
|
||||
timestamp = datetime.now()
|
||||
filename = secure_filename('.'.join([self.id,'json']))
|
||||
file_path = path.join(data, 'questions', filename)
|
||||
upload.stream.seek(0)
|
||||
questions = loads(upload.read())
|
||||
with open(file_path, 'w') as file:
|
||||
dump(questions, file, indent=2)
|
||||
self.date = timestamp
|
||||
self.creator = current_user
|
||||
if default: self.make_default()
|
||||
write('system.log', f'New dataset {self.id} added by {current_user.get_username()}.')
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
return True, 'Dataset uploaded.'
|
||||
|
||||
def check_file(self):
|
||||
filename = secure_filename('.'.join([self.id,'json']))
|
||||
file_path = path.join(data, 'questions', filename)
|
||||
if not path.isfile(file_path): return False, 'Data file is missing.'
|
||||
return True, 'Data file found.'
|
||||
|
||||
def get_file(self):
|
||||
filename = secure_filename('.'.join([self.id,'json']))
|
||||
file_path = path.join(data, 'questions', filename)
|
||||
return file_path
|
177
ref-test/app/models/entry.py
Normal file
@ -0,0 +1,177 @@
|
||||
from ..modules import db, mail
|
||||
from ..tools.forms import JsonEncodedDict
|
||||
from ..tools.encryption import decrypt, encrypt
|
||||
from ..tools.logs import write
|
||||
from .test import Test
|
||||
|
||||
from flask_login import current_user
|
||||
from flask_mail import Message
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
class Entry(db.Model):
|
||||
|
||||
id = db.Column(db.String(36), primary_key=True)
|
||||
first_name = db.Column(db.String(128), nullable=False)
|
||||
surname = db.Column(db.String(128), nullable=False)
|
||||
email = db.Column(db.String(128), nullable=False)
|
||||
club = db.Column(db.String(128), nullable=True)
|
||||
test_id = db.Column(db.String(36), db.ForeignKey('test.id'))
|
||||
user_code = db.Column(db.String(6), nullable=True)
|
||||
start_time = db.Column(db.DateTime, nullable=True)
|
||||
end_time = db.Column(db.DateTime, nullable=True)
|
||||
status = db.Column(db.String(16), nullable=True)
|
||||
valid = db.Column(db.Boolean, default=True, nullable=True)
|
||||
answers = db.Column(JsonEncodedDict, nullable=True)
|
||||
result = db.Column(JsonEncodedDict, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<New entry by {self.first_name} {self.surname}> was added with <id {self.id}>.'
|
||||
|
||||
@property
|
||||
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
|
||||
|
||||
generate_id.setter
|
||||
def generate_id(self): self.id = uuid4().hex
|
||||
|
||||
@property
|
||||
def set_first_name(self): raise AttributeError('set_first_name is not a readable attribute.')
|
||||
|
||||
set_first_name.setter
|
||||
def set_first_name(self, name:str): self.first_name = encrypt(name)
|
||||
|
||||
def get_first_name(self): return decrypt(self.first_name)
|
||||
|
||||
@property
|
||||
def set_surname(self): raise AttributeError('set_first_name is not a readable attribute.')
|
||||
|
||||
set_surname.setter
|
||||
def set_surname(self, name:str): self.surname = encrypt(name)
|
||||
|
||||
def get_surname(self): return decrypt(self.surname)
|
||||
|
||||
@property
|
||||
def set_email(self): raise AttributeError('set_email is not a readable attribute.')
|
||||
|
||||
set_email.setter
|
||||
def set_email(self, email:str): self.email = encrypt(email)
|
||||
|
||||
def get_email(self): return decrypt(self.email)
|
||||
|
||||
@property
|
||||
def set_club(self): raise AttributeError('set_club is not a readable attribute.')
|
||||
|
||||
set_club.setter
|
||||
def set_club(self, club:str): self.club = encrypt(club)
|
||||
|
||||
def get_club(self): return decrypt(self.club)
|
||||
|
||||
def ready(self):
|
||||
self.generate_id()
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
write('tests.log', f'New test ready for {self.get_first_name()} {self.get_surname()}.')
|
||||
return True, f'Test ready.'
|
||||
|
||||
def start(self):
|
||||
self.start_time = datetime.now()
|
||||
self.status = 'started'
|
||||
write('tests.log', f'Test started by {self.get_first_name()} {self.get_surname()}.')
|
||||
db.session.commit()
|
||||
return True, f'New test started with id {self.id}.'
|
||||
|
||||
def complete(self, answers:dict=None, result:dict=None):
|
||||
self.end_time = datetime.now()
|
||||
self.answers = answers
|
||||
self.result = result
|
||||
write('tests.log', f'Test completed by {self.get_first_name()} {self.get_surname()}.')
|
||||
delta = timedelta(minutes=int(0 if self.test.time_limit is None else self.test.time_limit)+1)
|
||||
if not self.test.time_limit or self.end_time <= self.start_time + delta:
|
||||
self.status = 'completed'
|
||||
self.valid = True
|
||||
else:
|
||||
self.status = 'late'
|
||||
self.valid = False
|
||||
db.session.commit()
|
||||
return True, f'Test entry completed for id {self.id}.'
|
||||
|
||||
def validate(self):
|
||||
if self.valid: return False, f'The entry is already valid.'
|
||||
if self.status == 'started': return False, 'The entry is still pending.'
|
||||
self.valid = True
|
||||
self.status = 'completed'
|
||||
db.session.commit()
|
||||
write('system.log', f'The entry {self.id} has been validated by {current_user.get_username()}.')
|
||||
return True, f'The entry {self.id} has been validated.'
|
||||
|
||||
def delete(self):
|
||||
id = self.id
|
||||
name = f'{self.get_first_name()} {self.get_surname()}'
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
write('system.log', f'The entry {id} by {name} has been deleted by {current_user.get_username()}.')
|
||||
return True, 'Entry deleted.'
|
||||
|
||||
def notify_result(self):
|
||||
score = round(100*self.result['score']/self.result['max'])
|
||||
tags_low = { tag: tag_result['max'] - tag_result['scored'] for tag, tag_result in self.result['tags'].items() }
|
||||
sorted_low_tags = sorted(tags_low.items(), key=lambda x: x[1], reverse=True)
|
||||
tag_output = [ tag[0] for tag in sorted_low_tags[0:3] if tag[1] > 3]
|
||||
revision_plain = ''
|
||||
revision_html = ''
|
||||
if self.result['grade'] == 'pass':
|
||||
flavour_text = """Well done on successfully completing the refereeing theory exam. We really appreciate members of our community taking the time to get qualified to referee.
|
||||
"""
|
||||
elif self.result['grade'] == 'merit':
|
||||
flavour_text = """Congratulations on achieving a merit in the refereeing exam. We are delighted that members of our community work so hard with refereeing.
|
||||
"""
|
||||
elif self.result['grade'] == 'fail':
|
||||
flavour_text = """Unfortunately, you were not successful in passing the theory exam in this attempt. We hope that this does not dissuade you, and that you try again in the future.
|
||||
"""
|
||||
revision_plain = f"""Based on your answers, we would also suggest you brush up on the following topics for your next attempt:\n\n
|
||||
{','.join(tag_output)}\n\n
|
||||
"""
|
||||
revision_html = f"""<p>Based on your answers, we would also suggest you brush up on the following topics for your next attempt:</p>
|
||||
<ul>
|
||||
<li>{'</li><li>'.join(tag_output)}</li>
|
||||
</ul>
|
||||
"""
|
||||
email = Message(
|
||||
subject='RefTest | SKA Refereeing Theory Exam Results',
|
||||
recipients=[self.get_email()],
|
||||
body=f"""
|
||||
SKA Refereeing Theory Exam
|
||||
Candidate Results
|
||||
Dear {self.get_first_name()},
|
||||
This email is to confirm that you have taken the SKA Refereeing Theory Exam. Your submission has been evaluated and your results are as follows:
|
||||
{self.get_surname()}, {self.get_first_name()}
|
||||
Email Address: {self.get_email()}
|
||||
{f'Club: {self.get_club()}' if self.club else ''}
|
||||
Date of Exam: {self.end_time.strftime('%d %b %Y')}
|
||||
Score: {score}%
|
||||
Grade: {self.result['grade']}
|
||||
{flavour_text}
|
||||
{revision_plain}
|
||||
Thank you for taking the time to become a qualified referee.
|
||||
Best wishes,
|
||||
SKA Refereeing
|
||||
""",
|
||||
html=f"""
|
||||
<h1>SKA Refereeing Theory Exam</h1>
|
||||
<h2>Candidate Results</h2>
|
||||
<p>Dear {self.get_first_name()},</p>
|
||||
<p>This email is to confirm that you have taken the SKA Refereeing Theory Exam. Your submission has been evaluated and your results are as follows:</p>
|
||||
<h3>{self.get_surname()}, {self.get_first_name()}</h3>
|
||||
<p><strong>Email Address</strong>: {self.get_email()}</p>
|
||||
{f'<p><strong>Club</strong>: {self.get_club()}</p>' if self.club else ''}
|
||||
<h1>{score}%</h1>
|
||||
<h2>{self.result['grade']}</h2>
|
||||
<p>{flavour_text}</p>
|
||||
{revision_html}
|
||||
<p>Thank you for taking the time to become a qualified referee.</p>
|
||||
<p>Have a nice day!</p>
|
||||
<p>Best wishes, <br/> SKA Refereeing</p>
|
||||
"""
|
||||
)
|
||||
mail.send(email)
|
111
ref-test/app/models/test.py
Normal file
@ -0,0 +1,111 @@
|
||||
from ..modules import db
|
||||
from ..tools.encryption import decrypt, encrypt
|
||||
from ..tools.forms import JsonEncodedDict
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask_login import current_user
|
||||
|
||||
from datetime import date, datetime
|
||||
import secrets
|
||||
from uuid import uuid4
|
||||
|
||||
class Test(db.Model):
|
||||
|
||||
id = db.Column(db.String(36), primary_key=True)
|
||||
code = db.Column(db.String(36), nullable=False)
|
||||
start_date = db.Column(db.DateTime, nullable=True)
|
||||
end_date = db.Column(db.DateTime, nullable=True)
|
||||
time_limit = db.Column(db.Integer, nullable=True)
|
||||
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
|
||||
dataset_id = db.Column(db.String(36), db.ForeignKey('dataset.id'))
|
||||
adjustments = db.Column(JsonEncodedDict, nullable=True)
|
||||
entries = db.relationship('Entry', backref='test')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Test with code {self.get_code()} was created by {current_user.get_username()}.>'
|
||||
|
||||
@property
|
||||
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
|
||||
|
||||
generate_id.setter
|
||||
def generate_id(self): self.id = uuid4().hex
|
||||
|
||||
@property
|
||||
def generate_code(self): raise AttributeError('generate_code is not a readable attribute.')
|
||||
|
||||
generate_code.setter
|
||||
def generate_code(self): self.code = secrets.token_hex(6).lower()
|
||||
|
||||
def get_code(self):
|
||||
code = self.code.upper()
|
||||
return '—'.join([code[:4], code[4:8], code[8:]])
|
||||
|
||||
def create(self):
|
||||
self.generate_id()
|
||||
self.generate_code()
|
||||
self.creator = current_user
|
||||
errors = []
|
||||
if self.start_date.date() < date.today():
|
||||
errors.append('The start date cannot be in the past.')
|
||||
if self.end_date.date() < date.today():
|
||||
errors.append('The expiry date cannot be in the past.')
|
||||
if self.end_date < self.start_date:
|
||||
errors.append('The expiry date cannot be before the start date.')
|
||||
if errors:
|
||||
return False, errors
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
write('system.log', f'Test with code {self.get_code()} created by {current_user.get_username()}.')
|
||||
return True, f'Test with code {self.get_code()} has been created.'
|
||||
|
||||
def delete(self):
|
||||
code = self.code
|
||||
if self.entries: return False, f'Cannot delete a test with submitted entries.'
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
write('system.log', f'Test with code {self.get_code()} has been deleted by {current_user.get_username()}.')
|
||||
return True, f'Test with code {self.get_code()} has been deleted.'
|
||||
|
||||
def start(self):
|
||||
now = datetime.now()
|
||||
if self.start_date.date() > now.date():
|
||||
self.start_date = now
|
||||
db.session.commit()
|
||||
write('system.log', f'Test with code {self.get_code()} has been started by {current_user.get_username()}.')
|
||||
return True, f'Test with code {self.get_code()} has been started.'
|
||||
return False, f'Test with code {self.get_code()} has already started.'
|
||||
|
||||
def end(self):
|
||||
now = datetime.now()
|
||||
if self.end_date >= now:
|
||||
self.end_date = now
|
||||
db.session.commit()
|
||||
write('system.log', f'Test with code {self.get_code()} ended by {current_user.get_username()}.')
|
||||
return True, f'Test with code {self.get_code()} has been ended.'
|
||||
return False, f'Test with code {self.get_code()} has already ended.'
|
||||
|
||||
def add_adjustment(self, time:int):
|
||||
adjustments = self.adjustments if self.adjustments is not None else {}
|
||||
code = secrets.token_hex(3).lower()
|
||||
adjustments[code] = time
|
||||
self.adjustments = adjustments
|
||||
db.session.commit()
|
||||
write('system.log', f'Time adjustment for {time} minutes with code {code} added to test {self.get_code()} by {current_user.get_username()}.')
|
||||
return True, f'Time adjustment for {time} minutes added to test {self.get_code()}. This can be accessed using the user code {code.upper()}.'
|
||||
|
||||
def remove_adjustment(self, code:str):
|
||||
if not self.adjustments: return False, f'There are no adjustments configured for test {self.get_code()}.'
|
||||
self.adjustments.pop(code)
|
||||
if not self.adjustments: self.adjustments = None
|
||||
db.session.commit()
|
||||
write('system.log', f'Time adjustment for with code {code} has been removed from test {self.get_code()} by {current_user.get_username()}.')
|
||||
return True, f'Time adjustment for with code {code} has been removed from test {self.get_code()}.'
|
||||
|
||||
def update(self, start_date:datetime=None, end_date:datetime=None, time_limit:int=None):
|
||||
if not start_date and not end_date and time_limit is None: return False, 'There were no changes requested.'
|
||||
if start_date: self.start_date = start_date
|
||||
if end_date: self.end_date = end_date
|
||||
if time_limit is not None: self.time_limit = time_limit
|
||||
db.session.commit()
|
||||
write('system.log', f'Test with code {self.get_code()} has been updated by user {current_user.get_username()}.')
|
||||
return True, f'Test with code {self.get_code()} has been updated by.'
|
223
ref-test/app/models/user.py
Normal file
@ -0,0 +1,223 @@
|
||||
from ..modules import db, mail
|
||||
from ..tools.encryption import decrypt, encrypt
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask import flash, jsonify, session
|
||||
from flask.helpers import url_for
|
||||
from flask_login import current_user, login_user, logout_user, UserMixin
|
||||
from flask_mail import Message
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
import secrets
|
||||
from uuid import uuid4
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.String(36), primary_key=True)
|
||||
username = db.Column(db.String(128), nullable=False)
|
||||
password = db.Column(db.String(128), nullable=False)
|
||||
email = db.Column(db.String(128), nullable=False)
|
||||
reset_token = db.Column(db.String(20), nullable=True)
|
||||
verification_token = db.Column(db.String(20), nullable=True)
|
||||
tests = db.relationship('Test', backref='creator')
|
||||
datasets = db.relationship('Dataset', backref='creator')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<user {self.username}> was added with <id {self.id}>.'
|
||||
|
||||
@property
|
||||
def generate_id(self): raise AttributeError('generate_id is not a readable attribute.')
|
||||
|
||||
generate_id.setter
|
||||
def generate_id(self): self.id = uuid4().hex
|
||||
|
||||
@property
|
||||
def set_username(self): raise AttributeError('set_username is not a readable attribute.')
|
||||
|
||||
set_username.setter
|
||||
def set_username(self, username:str): self.username = encrypt(username)
|
||||
|
||||
def get_username(self): return decrypt(self.username)
|
||||
|
||||
@property
|
||||
def set_password(self): raise AttributeError('set_password is not a readable attribute.')
|
||||
|
||||
set_password.setter
|
||||
def set_password(self, password:str): self.password = generate_password_hash(password, method="sha256")
|
||||
|
||||
def verify_password(self, password:str): return check_password_hash(self.password, password)
|
||||
|
||||
@property
|
||||
def set_email(self): raise AttributeError('set_email is not a readable attribute.')
|
||||
|
||||
set_email.setter
|
||||
def set_email(self, email:str): self.email = encrypt(email)
|
||||
|
||||
def get_email(self): return decrypt(self.email)
|
||||
|
||||
def register(self, notify:bool=False, password:str=None):
|
||||
self.generate_id()
|
||||
users = User.query.all()
|
||||
for user in users:
|
||||
if user.get_username() == self.get_username(): return False, f'Username {self.get_username()} already in use.'
|
||||
if user.get_email() == self.get_email(): return False, f'Email address {self.get_email()} already in use.'
|
||||
self.set_password(password=password)
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
write('users.log', f'User \'{self.get_username()}\' was created with id \'{self.id}\'.')
|
||||
if notify:
|
||||
email = Message(
|
||||
subject='RefTest | Registration Confirmation',
|
||||
recipients=[self.email],
|
||||
body=f"""
|
||||
Hello {self.get_username()},\n\n
|
||||
You have been registered as an administrator on the SKA RefTest App!\n\n
|
||||
You can access your account using the username '{self.get_username()}'\n\n
|
||||
Your password is as follows:\n\n
|
||||
{password}\n\n
|
||||
You can log in to the admin console via the following URL, where you can administer the test or change your password:\n\n
|
||||
{url_for('admin._home', _external=True)}\n\n
|
||||
Have a nice day!\n\n
|
||||
SKA Refereeing
|
||||
""",
|
||||
html=f"""
|
||||
<p>Hello {self.get_username()},</p>
|
||||
<p>You have been registered as an administrator on the SKA RefTest App!</p>
|
||||
<p>You can access your account using the username '{self.get_username()}'</p>
|
||||
<p>Your password is as follows:</p>
|
||||
<strong>{password}</strong>
|
||||
<p>You can log in to the admin console via the following URL, where you can administer the test or change your password:</p>
|
||||
<p><a href='{url_for('admin._home', _external=True)}'>{url_for('admin._home', _external=True)}</a></p>
|
||||
<p>Have a nice day!</p>
|
||||
<p>SKA Refereeing</p>
|
||||
"""
|
||||
)
|
||||
mail.send(email)
|
||||
return True, f'User {self.get_username()} was created successfully.'
|
||||
|
||||
def login(self, remember:bool=False):
|
||||
login_user(self, remember = remember)
|
||||
write('users.log', f'User \'{self.get_username()}\' has logged in.')
|
||||
flash(message=f'Welcome {self.get_username()}', category='success')
|
||||
|
||||
def logout(self):
|
||||
session['remembered_username'] = self.get_username()
|
||||
logout_user()
|
||||
write('users.log', f'User \'{self.get_username()}\' has logged out.')
|
||||
flash(message='You have successfully logged out.', category='success')
|
||||
|
||||
def reset_password(self):
|
||||
new_password = secrets.token_hex(12)
|
||||
self.set_password(new_password)
|
||||
self.reset_token = secrets.token_urlsafe(16)
|
||||
self.verification_token = secrets.token_urlsafe(16)
|
||||
db.session.commit()
|
||||
email = Message(
|
||||
subject='RefTest | Password Reset',
|
||||
recipients=[self.get_email()],
|
||||
body=f"""
|
||||
Hello {self.get_username()},\n\n
|
||||
This email was generated because we received a request to reset the password for your administrator account for the SKA RefTest app.\n\n
|
||||
If you did not make this request, please ignore this email.\n\n
|
||||
If you did make this request, then you have two options to recover your account.\n\n
|
||||
Your password has been reset to the following:\n\n
|
||||
{new_password}\n\n
|
||||
You may use this to log back in to your account, and subsequently change your password to something more suitable.\n\n
|
||||
Alternatively, you may visit the following private link using your unique token to override your password. Copy and paste the following link in a web browser. Please note that this token is only valid once:\n\n
|
||||
{url_for('admin._reset', token = self.reset_token, verification = self.verification_token, _external = True)}\n\n
|
||||
Hopefully, this should enable access to your account once again.\n\n
|
||||
Have a nice day!\n\n
|
||||
SKA Refereeing
|
||||
""",
|
||||
html=f"""
|
||||
<p>Hello {self.get_username()},</p>
|
||||
<p>This email was generated because we received a request to reset the password for your administrator account for the SKA RefTest app.</p>
|
||||
<p>If you did not make this request, please ignore this email.</p>
|
||||
<p>If you did make this request, then you have two options to recover your account.</p>
|
||||
<p>Your password has been reset to the following:</p>
|
||||
<strong>{new_password}</strong>
|
||||
<p>You may use this to log back in to your account, and subsequently change your password to something more suitable.</p>
|
||||
<p>Alternatively, you may visit the following private link using your unique token to override your password. Copy and paste the following link in a web browser. Please note that this token is only valid once:</p>
|
||||
<p><a href='{url_for('admin._reset', token = self.reset_token, verification = self.verification_token, _external = True)}'>{url_for('admin._reset', token = self.reset_token, verification = self.verification_token, _external = True)}</a></p>
|
||||
<p>Hopefully, this should enable access to your account once again.</p>
|
||||
<p>Have a nice day!</p>
|
||||
<p>SKA Refereeing</p>
|
||||
"""
|
||||
)
|
||||
mail.send(email)
|
||||
print('Password', new_password)
|
||||
print('Reset Token', self.reset_token)
|
||||
print('Verification Token', self.verification_token)
|
||||
print('Reset Link', f'{url_for("admin._reset", token=self.reset_token, verification=self.verification_token, _external=True)}')
|
||||
return jsonify({'success': 'Your password reset link has been generated.'}), 200
|
||||
|
||||
def clear_reset_tokens(self):
|
||||
self.reset_token = self.verification_token = None
|
||||
db.session.commit()
|
||||
|
||||
def delete(self, notify:bool=False):
|
||||
username = self.get_username()
|
||||
email_address = self.get_email()
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
message = f'User \'{username}\' was deleted by \'{current_user.get_username()}\'.'
|
||||
write('users.log', message)
|
||||
if notify:
|
||||
email = Message(
|
||||
subject='RefTest | Account Deletion',
|
||||
recipients=[email_address],
|
||||
bcc=[current_user.get_email()],
|
||||
body=f"""
|
||||
Hello {username},\n\n
|
||||
Your administrator account for the SKA RefTest App, as well as all data associated with the account, have been deleted by {current_user.get_username()}.\n\n
|
||||
If you believe this was done in error, please contact them immediately.\n\n
|
||||
If you would like to register to administer the app, please ask an existing administrator to create a new account.\n\n
|
||||
Have a nice day!\n\n
|
||||
SKA Refereeing
|
||||
""",
|
||||
html=f"""
|
||||
<p>Hello {username},</p>
|
||||
<p>Your administrator account for the SKA RefTest App, as well as all data associated with the account, have been deleted by {current_user.get_username()}.</p>
|
||||
<p>If you believe this was done in error, please contact them immediately.</p>
|
||||
<p>If you would like to register to administer the app, please ask an existing administrator to create a new account.</p>
|
||||
<p>Have a nice day!</p>
|
||||
<p>SKA Refereeing</p>
|
||||
"""
|
||||
)
|
||||
mail.send(email)
|
||||
return True, message
|
||||
|
||||
def update(self, password:str=None, email:str=None, notify:bool=False):
|
||||
if not password and not email: return False, 'There were no changes requested.'
|
||||
if password: self.set_password(password)
|
||||
old_email = self.get_email()
|
||||
if email: self.set_email(email)
|
||||
db.session.commit()
|
||||
write('system.log', f'Information for user {self.get_username()} has been updated by {current_user.get_username()}.')
|
||||
if notify:
|
||||
message = Message(
|
||||
subject='RefTest | Account Update',
|
||||
recipients=[email],
|
||||
bcc=[old_email,current_user.get_email()],
|
||||
body=f"""
|
||||
Hello {self.get_username()},\n\n
|
||||
Your administrator account for the SKA RefTest App has been updated by {current_user.get_username()}.\n\n
|
||||
Your new account details are as follows:\n\n
|
||||
Email: {email}\n
|
||||
Password: {password if password else '<same as old>'}\n\n
|
||||
You can update your email address and password by logging in to the admin console using the following URL:\n\n
|
||||
{url_for('admin._home', _external=True)}\n\n
|
||||
Have a nice day!\n\n
|
||||
SKA Refereeing
|
||||
""",
|
||||
html=f"""
|
||||
<p>Hello {self.get_username()},</p>
|
||||
<p>Your administrator account for the SKA RefTest App has been updated by {current_user.get_username()}.</p>
|
||||
<p>Your new account details are as follows:</p>
|
||||
<p>Email: {email} <br/> Password: <strong>{password if password else '<same as old>'}</strong></p>
|
||||
<p>You can update your email address and password by logging in to the admin console using the following URL:</p>
|
||||
<p><a href='{url_for('admin._home', _external=True)}'>{url_for('admin._home', _external=True)}</a></p>
|
||||
<p>Have a nice day!</p>
|
||||
<p>SKA Refereeing</p>
|
||||
"""
|
||||
)
|
||||
mail.send(message)
|
||||
return True, f'Account {self.get_username()} has been updated.'
|
10
ref-test/app/modules.py
Normal file
@ -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()
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
0
ref-test/app/quiz/static/fonts/stylesheet.css
Normal file
@ -143,7 +143,7 @@ $("#btn-start-quiz").click(function(event){
|
||||
$.ajax({
|
||||
url: `/api/questions/`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({'_id': _id}),
|
||||
data: JSON.stringify({'id': id}),
|
||||
contentType: "application/json",
|
||||
success: function(response) {
|
||||
$(this).fadeOut();
|
||||
@ -223,7 +223,7 @@ $("#q-review-answers").click(function(event){
|
||||
|
||||
$(".quiz-button-submit").click(function(event){
|
||||
let submission = {
|
||||
'_id': _id,
|
||||
'id': id,
|
||||
'answers': answers
|
||||
}
|
||||
|
||||
@ -607,7 +607,7 @@ function count_questions(status) {
|
||||
|
||||
// Variable Definitions
|
||||
|
||||
const _id = window.localStorage.getItem('_id');
|
||||
const id = window.localStorage.getItem('id');
|
||||
|
||||
var current_question = 0;
|
||||
var total_questions = 0;
|
@ -23,9 +23,9 @@ $('form[name=form-quiz-start]').submit(function(event) {
|
||||
data: data,
|
||||
dataType: 'json',
|
||||
success: function(response) {
|
||||
var _id = response._id
|
||||
window.localStorage.setItem('_id', _id);
|
||||
window.location.href = `/test/`;
|
||||
var id = response.id
|
||||
window.localStorage.setItem('id', id);
|
||||
window.location.href = `/quiz/`;
|
||||
},
|
||||
error: function(response) {
|
||||
error_response(response);
|
||||
@ -68,7 +68,7 @@ $('#dismiss-cookie-alert').click(function(event){
|
||||
|
||||
$.ajax({
|
||||
url: '/cookies/',
|
||||
type: 'GET',
|
||||
type: 'POST',
|
||||
data: {
|
||||
time: Date.now()
|
||||
},
|
@ -13,7 +13,7 @@
|
||||
The presentation of the questions is just a start, and we acknowledge we still have a long way to go. We welcome any feedback on how we can further improve this test.
|
||||
</p>
|
||||
<div class="button-container">
|
||||
<a href="{{ url_for('quiz_views.instructions') }}" class="btn btn-success">
|
||||
<a href="{{ url_for('quiz._instructions') }}" class="btn btn-success">
|
||||
<i class="bi bi-book-fill button-icon"></i>
|
||||
Read the Instructions
|
||||
</a>
|
@ -53,7 +53,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="button-container">
|
||||
<a href="{{ url_for('quiz_views.start') }}" class="btn btn-success">
|
||||
<a href="{{ url_for('quiz._start') }}" class="btn btn-success">
|
||||
<i class="bi bi-pencil-fill button-icon"></i>
|
||||
Take the Exam
|
||||
</a>
|
@ -6,13 +6,13 @@
|
||||
<h2>Candidate Results</h2>
|
||||
|
||||
<h3 class="results-name">
|
||||
<span class="surname">{{ entry.name.surname }}</span>, {{ entry.name.first_name }}
|
||||
<span class="surname">{{ entry.get_surname() }}</span>, {{ entry.get_first_name() }}
|
||||
</h3>
|
||||
|
||||
<strong class="results-details">Email Address</strong>: {{ entry.email }} <br />
|
||||
<strong class="results-details">Email Address</strong>: {{ entry.get_email() }} <br />
|
||||
|
||||
{% if entry.club %}
|
||||
<strong class="results-details">Club</strong>: {{ entry.club }} <br />
|
||||
<strong class="results-details">Club</strong>: {{ entry.get_club() }} <br />
|
||||
{% endif%}
|
||||
|
||||
{% if entry.status == 'late' %}
|
||||
@ -26,10 +26,10 @@
|
||||
</div>
|
||||
|
||||
<div class="results-grade">
|
||||
{{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:] }}
|
||||
{{ entry.result.grade[0]|upper }}{{ entry.result.grade[1:] }}
|
||||
</div>
|
||||
|
||||
{% if entry.results.grade == 'fail' %}
|
||||
{% if entry.result.grade == 'fail' %}
|
||||
Unfortunately, you have not passed the theory exam in this attempt. For your next attempt, it might help to brush up on the following topics:
|
||||
|
||||
<ul>
|
80
ref-test/app/quiz/views.py
Normal file
@ -0,0 +1,80 @@
|
||||
from ..forms.quiz import StartQuiz
|
||||
from ..models import Entry, Test
|
||||
from ..tools.test import redirect_if_started
|
||||
|
||||
from flask import abort, Blueprint, jsonify, redirect, render_template, request, session
|
||||
from flask.helpers import flash, url_for
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
quiz = Blueprint(
|
||||
name='quiz',
|
||||
import_name=__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static',
|
||||
static_url_path='/quiz/static'
|
||||
)
|
||||
|
||||
@quiz.route('/')
|
||||
@quiz.route('/home/')
|
||||
@redirect_if_started
|
||||
def _home():
|
||||
return render_template('/quiz/index.html')
|
||||
|
||||
@quiz.route('/instructions/')
|
||||
def _instructions():
|
||||
return render_template('/quiz/instructions.html')
|
||||
|
||||
@quiz.route('/start/', methods=['GET', 'POST'])
|
||||
def _start():
|
||||
form = StartQuiz()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
entry = Entry()
|
||||
entry.set_first_name(request.form.get('first_name'))
|
||||
entry.set_surname(request.form.get('surname'))
|
||||
entry.set_club(request.form.get('club'))
|
||||
entry.set_email(request.form.get('email'))
|
||||
code = request.form.get('test_code').replace('—', '').lower()
|
||||
test = Test.query.filter_by(code=code).first()
|
||||
entry.test = test
|
||||
entry.user_code = request.form.get('user_code')
|
||||
entry.user_code = None if entry.user_code == '' else entry.user_code.lower()
|
||||
if not test: return jsonify({'error': 'The exam code you entered is invalid.'}), 400
|
||||
if entry.user_code and entry.user_code not in test.adjustments: return jsonify({'error': f'The user code you entered is not valid.'}), 400
|
||||
if test.end_date < datetime.now(): return jsonify({'error': f'The exam code you entered expired on {test["expiry_date"].strftime("%d %b %Y %H:%M")}.'}), 400
|
||||
if test.start_date > datetime.now(): return jsonify({'error': f'The exam has not yet opened. Your exam code will be valid from {test["start_date"].strftime("%d %b %Y %H:%M")}.'}), 400
|
||||
success, message = entry.ready()
|
||||
if success:
|
||||
session['id'] = entry.id
|
||||
return jsonify({
|
||||
'success': 'Received and validated test and/or user code. Redirecting to test client.',
|
||||
'id': entry.id
|
||||
}), 200
|
||||
return jsonify({'error': 'There was an error processing the user test and/or user codes.'}), 400
|
||||
errors = [*form.test_code.errors, *form.user_code.errors, *form.first_name.errors, *form.surname.errors, *form.email.errors, *form.club.errors]
|
||||
return jsonify({ 'error': errors}), 400
|
||||
return render_template('/quiz/start_quiz.html', form = form)
|
||||
|
||||
@quiz.route('/quiz/')
|
||||
def _quiz():
|
||||
id = session.get('id')
|
||||
if not id or not Entry.query.filter_by(id=id).first():
|
||||
flash('Your session was not recognised. Please sign in to the quiz again.', 'error')
|
||||
session.pop('id', None)
|
||||
return redirect(url_for('quiz._start'))
|
||||
return render_template('/quiz/client.html')
|
||||
|
||||
@quiz.route('/result/')
|
||||
def _result():
|
||||
id = session.get('id')
|
||||
entry = Entry.query.filter_by(id=id).first()
|
||||
if not entry: return abort(404)
|
||||
session.pop('id',None)
|
||||
score = round(100*entry.result['score']/entry.result['max'])
|
||||
tags_low = { tag: tag_result['max'] - tag_result['scored'] for tag, tag_result in entry.result['tags'].items() }
|
||||
sorted_low_tags = sorted(tags_low.items(), key=lambda x: x[1], reverse=True)
|
||||
tag_output = [ tag[0] for tag in sorted_low_tags[0:3] if tag[1] > 3]
|
||||
if not entry.status == 'late':
|
||||
entry.notify_result()
|
||||
return render_template('/quiz/result.html', entry=entry, score=score, tag_output=tag_output)
|
218
ref-test/app/root/css/style.css
Normal file
@ -0,0 +1,218 @@
|
||||
body {
|
||||
padding: 80px 0;
|
||||
line-height: 1.5;
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
#cookie-alert {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
#dismiss-cookie-alert {
|
||||
margin-top: 16px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
margin: 2rem auto;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.instruction-container {
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
background-color: lightgray;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.site-footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.quiz-container {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
padding-top: 40px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.form-quiz-start {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 15px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.form-heading {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-label-group {
|
||||
position: relative;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-label-group input,
|
||||
.form-label-group label {
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
font-size: 16pt;
|
||||
}
|
||||
|
||||
.form-label-group label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0; /* Override default `<label>` margin */
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
cursor: text; /* Match the input under the label */
|
||||
border: 1px solid transparent;
|
||||
border-radius: .25rem;
|
||||
transition: all .1s ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.form-label-group input {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 0%;
|
||||
border-bottom: 2px solid #585858;
|
||||
}
|
||||
|
||||
.form-label-group input:active, .form-label-group input:focus {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-webkit-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input:-ms-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-ms-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-moz-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input:not(:placeholder-shown) {
|
||||
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
|
||||
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||
}
|
||||
|
||||
.form-label-group input:not(:placeholder-shown) ~ label {
|
||||
padding-top: calc(var(--input-padding-y) / 3);
|
||||
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.form-check-margin {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
transform: scale(1.5);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.signin-forgot-password {
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
.form-submission-button {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-submission-button button, .form-submission-button a {
|
||||
margin: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.form-submission-button button span, .form-submission-button button svg, .form-submission-button a span, .form-submission-button a svg {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.results-name {
|
||||
margin: 3rem auto;
|
||||
}
|
||||
|
||||
.results-name .surname {
|
||||
font-variant: small-caps;
|
||||
font-size: 24pt;
|
||||
}
|
||||
|
||||
.results-score {
|
||||
margin: 2rem auto;
|
||||
width: fit-content;
|
||||
font-size: 36pt;
|
||||
}
|
||||
|
||||
.results-score::after {
|
||||
content: '%';
|
||||
}
|
||||
|
||||
.results-grade {
|
||||
margin: 2rem auto;
|
||||
width: fit-content;
|
||||
font-size: 26pt;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 20px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
/* Change Autocomplete styles in Chrome*/
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
textarea:-webkit-autofill,
|
||||
textarea:-webkit-autofill:hover,
|
||||
textarea:-webkit-autofill:focus,
|
||||
select:-webkit-autofill,
|
||||
select:-webkit-autofill:hover,
|
||||
select:-webkit-autofill:focus {
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
/* Fallback for Edge
|
||||
-------------------------------------------------- */
|
||||
@supports (-ms-ime-align: auto) {
|
||||
.form-label-group label {
|
||||
display: none;
|
||||
}
|
||||
.form-label-group input::-ms-input-placeholder {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback for IE
|
||||
-------------------------------------------------- */
|
||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||
.form-label-group label {
|
||||
display: none;
|
||||
}
|
||||
.form-label-group input:-ms-input-placeholder {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
BIN
ref-test/app/root/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
ref-test/app/root/favicon.png
Normal file
After Width: | Height: | Size: 84 KiB |
2
ref-test/app/root/js/jquery-3.6.0.min.js
vendored
Normal file
86
ref-test/app/root/js/script.js
Normal file
@ -0,0 +1,86 @@
|
||||
$(document).ready(function() {
|
||||
$("#od-font-test").click(function(){
|
||||
$("body").css("font-family", "opendyslexic3regular")
|
||||
});
|
||||
|
||||
$('.test-code-input').keyup(function() {
|
||||
var input = $(this).val().split("-").join("").split("—").join("");
|
||||
if (input.length > 0) {
|
||||
input = input.match(new RegExp('.{1,4}', 'g')).join("—");
|
||||
}
|
||||
$(this).val(input);
|
||||
});
|
||||
});
|
||||
|
||||
$('form[name=form-quiz-start]').submit(function(event) {
|
||||
|
||||
var $form = $(this);
|
||||
var data = $form.serialize();
|
||||
|
||||
$.ajax({
|
||||
url: window.location.pathname,
|
||||
type: 'POST',
|
||||
data: data,
|
||||
dataType: 'json',
|
||||
success: function(response) {
|
||||
var id = response.id
|
||||
window.localStorage.setItem('id', id);
|
||||
window.location.href = `/quiz/`;
|
||||
},
|
||||
error: function(response) {
|
||||
error_response(response);
|
||||
}
|
||||
});
|
||||
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
function error_response(response) {
|
||||
|
||||
const $alert = $("#alert-box");
|
||||
$alert.html('');
|
||||
|
||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
||||
$alert.html(`
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||
${response.responseJSON.error}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`);
|
||||
} else if (response.responseJSON.error instanceof Array) {
|
||||
var output = ''
|
||||
for (var i = 0; i < response.responseJSON.error.length; i ++) {
|
||||
output += `
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||
${response.responseJSON.error[i]}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`;
|
||||
$alert.html(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss Cookie Alert
|
||||
$('#dismiss-cookie-alert').click(function(event){
|
||||
|
||||
$.ajax({
|
||||
url: '/cookies/',
|
||||
type: 'POST',
|
||||
data: {
|
||||
time: Date.now()
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response){
|
||||
console.log(response);
|
||||
},
|
||||
error: function(response){
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
})
|
@ -1,4 +1,4 @@
|
||||
{% extends "quiz/components/base.html" %}
|
||||
{% extends "components/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Page Not Found</h1>
|
78
ref-test/app/templates/components/base.html
Normal file
@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
|
||||
crossorigin="anonymous">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('.static', filename='css/style.css') }}"
|
||||
/>
|
||||
{% block style %}
|
||||
{% endblock %}
|
||||
<title>{% block title %} SKA Referee Test Beta {% endblock %}</title>
|
||||
{% include "components/og-meta.html" %}
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
||||
{% block navbar %}
|
||||
{% include "components/navbar.html" %}
|
||||
{% endblock %}
|
||||
|
||||
<div class="container quiz-container">
|
||||
{% block top_alerts %}
|
||||
{% include "components/server-alerts.html" %}
|
||||
{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<footer class="container site-footer">
|
||||
{% include "components/footer.html" %}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- JQuery, Popper, and Bootstrap js dependencies -->
|
||||
<script
|
||||
src="https://code.jquery.com/jquery-3.6.0.min.js"
|
||||
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script>
|
||||
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
|
||||
</script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
||||
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
|
||||
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<!-- Custom js -->
|
||||
<script type="text/javascript">
|
||||
var csrf_token = "{{ csrf_token() }}";
|
||||
|
||||
$.ajaxSetup({
|
||||
beforeSend: function(xhr, settings) {
|
||||
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
|
||||
xhr.setRequestHeader("X-CSRFToken", csrf_token);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="{{ url_for('.static', filename='js/script.js') }}"
|
||||
></script>
|
||||
{% block script %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
3
ref-test/app/templates/components/footer.html
Normal file
@ -0,0 +1,3 @@
|
||||
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
||||
<p>OpenDyslexic 3 is an open source typeface created by Abbie Gonzalez, licensed under a <a href="https://scripts.sil.org/OFL">SIL-OFL</a>. More information about OpenDyslexic is available <a href="https://opendyslexic.org/">on the project web site</a>.</p>
|
14
ref-test/app/templates/components/navbar.html
Normal file
@ -0,0 +1,14 @@
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark" id="primary-nav">
|
||||
<div class="container">
|
||||
<p class="navbar-brand mb-0 h1">SKA Refereeing Test (Beta)</p>
|
||||
<div class="quiz-console w-100" style="display: none;" id="q-topbar">
|
||||
<div class="d-flex justify-content align-middle">
|
||||
<div class="container d-flex justify-content-center">
|
||||
<span class="text-light q-timer" id="q-timer-widget" style="display: none;"><i class="bi bi-stopwatch-fill"></i> <span id="q-timer-display"></span></span>
|
||||
</div>
|
||||
<a href="#" class="btn btn-warning" aria-title="Question Grid" title="Question Grid" id="btn-toggle-navigator"><i class="bi bi-table"></i></a>
|
||||
<a href="#" class="btn btn-danger" aria-title="Settings" title="Settings" id="btn-toggle-settings"><i class="bi bi-gear-fill"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
17
ref-test/app/templates/components/og-meta.html
Normal file
@ -0,0 +1,17 @@
|
||||
<meta name="description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta property="og:locale" content="en_UK" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta property="og:url" content="{{ url_for(request.endpoint, _external = True, **request.view_args) }}" />
|
||||
<meta property="og:site_name" content="Scottish Korfball Association Referee Theory Exam" />
|
||||
<meta property="og:image" content="{{ url_for('static', filename='favicon.png', _external = True) }}" />
|
||||
<meta property="og:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||
<meta property="og:image:width" content="512" />
|
||||
<meta property="og:image:height" content="512" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta name="twitter:image" content="{{ url_for('static', filename='favicon.png', _external = True) }}" />
|
||||
<meta name="twitter:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||
<meta name="twitter:creator" content="@viveksantayana" />
|
||||
<meta name="twitter:site" content="@viveksantayana" />
|
||||
<meta name="theme-color" content="#343a40" />
|