Compare commits
	
		
			623 Commits
		
	
	
		
			7db0d055e5
			...
			sqlite-gro
		
	
	| 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 | |||
| f170ff5e52 | |||
| cfd750894a | |||
| 3bd16ae563 | |||
| ede71f7d82 | |||
| 2f6de34051 | |||
| 27706572ed | |||
| b9c4edeb48 | |||
| 08da6d71c4 | |||
| 587415c5db | |||
| c5a0bbb827 | |||
| c2d7dc7fe2 | |||
| 8680c73e86 | |||
| 0059ec5270 | |||
| ff74e92297 | |||
| ce07bdf8b2 | |||
| 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 | ### Pre-Requisites | ||||||
|  |  | ||||||
| Server | - A Debian- or Ubuntu-based server, preferably the latest distribution. | ||||||
| Docker | - Docker (specifically, Docker Engine) | ||||||
| Docker-Compose | - Docker Compose | ||||||
| Git | - Git | ||||||
|  |  | ||||||
| ### Installation | ### Installation | ||||||
|  |  | ||||||
|  | #### Install all the pre-requisites | ||||||
|  |  | ||||||
|  | The first step is to ensure all the prerequisites are available on the server. | ||||||
|  |  | ||||||
|  | To set up the server, consult some of the comprehensive guides on various hosting platforms like Linode or DigitalOcean. | ||||||
|  | Here is a [good starting point on setting up a server](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu-22-04). | ||||||
|  |  | ||||||
|  | To install Docker and Docker Compose, consult the respective documentation: | ||||||
|  |   - [Install on Ubuntu](https://docs.docker.com/engine/install/ubuntu/) or [Install on Debian](https://docs.docker.com/engine/install/debian/) | ||||||
|  |   - Docker Compose should be installed as part of that. | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | At the time of writing, there has been an upgrade to Docker and Docker Compose, meaning the syntax below might be different between versions. | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | Check if Git is installed on your server using the `git --version` command. | ||||||
|  | If it isn't installed, install it. | ||||||
|  | This should normally come pre-packaged with your OS distribution. | ||||||
|  | But if it doesn't, look up how to for whatever OS you use. | ||||||
|  | If you are using Ubuntu or Debian, it should be as easy as using the command: | ||||||
|  |  | ||||||
|  | ```$ sudo apt-get install git -y``` | ||||||
|  |  | ||||||
| #### Preliminary Set-Up: Clone repos and Configure Values | #### Preliminary Set-Up: Clone repos and Configure Values | ||||||
|  |  | ||||||
| #### Set Up Web Server | Open a terminal and navigate to the folder where you want to install this app. | ||||||
|  | I would suggest using a subfolder within your Home folder: | ||||||
|  |  | ||||||
| #### Incorporate SSL | ```$ cd ~ && mkdir ska-referee-test && cd ska-referee-test``` | ||||||
|  |  | ||||||
| #### Set Up Auto-Renew | That way, you will ensure you can read and write all the necessary files during installation. | ||||||
|  | Once in the destination folder, clone all the relevant files you will need for the installation: | ||||||
|  |  | ||||||
| ### Alterations | ```$ git clone https://git.vsnt.uk/viveksantayana/ska-referee-test.git .``` | ||||||
|  |  | ||||||
| ## Use | (Remember to include the trailing dot at the end, as that indicates to Git to download the files in the current directory.) | ||||||
|  |  | ||||||
| ## Compatibility | #### 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 | ### 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' | version: '3.9' | ||||||
|  |  | ||||||
| services: | services: | ||||||
|   ref_test_server: |   nginx: | ||||||
|     container_name: ref_test_server |     container_name: reftest_server | ||||||
|     image: nginx:1.21.4-alpine |     image: nginx:alpine | ||||||
|     volumes: |     volumes: | ||||||
|       - ./certbot:/etc/letsencrypt:ro |       - ./certbot:/etc/letsencrypt:ro | ||||||
|       - ./nginx:/etc/nginx |       - ./nginx:/etc/nginx | ||||||
|       - ./src/html:/usr/share/nginx/html/ |       - ./src/html:/usr/share/nginx/html/ | ||||||
|       - ./ref-test/admin/static:/usr/share/nginx/html/admin/static |       - ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static | ||||||
|       - ./ref-test/quiz/static:/usr/share/nginx/html/quiz/static |       - ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static | ||||||
|  |       - ./ref-test/app/root:/usr/share/nginx/html/root | ||||||
|     ports: |     ports: | ||||||
|       - 80:80 |       - 80:80 | ||||||
|       - 443:443 |       - 443:443 | ||||||
| @@ -17,10 +18,11 @@ services: | |||||||
|     networks: |     networks: | ||||||
|       - frontend |       - frontend | ||||||
|     depends_on: |     depends_on: | ||||||
|       - ref_test_app |       - app | ||||||
|  |     command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" | ||||||
|    |    | ||||||
|   ref_test_app: |   app: | ||||||
|     container_name: ref_test_app |     container_name: reftest_app | ||||||
|     image: reftest |     image: reftest | ||||||
|     build: ./ref-test |     build: ./ref-test | ||||||
|     env_file: |     env_file: | ||||||
| @@ -28,32 +30,16 @@ services: | |||||||
|     ports: |     ports: | ||||||
|       - 5000 |       - 5000 | ||||||
|     volumes: |     volumes: | ||||||
|       - ./.security:/ref-test/.security |  | ||||||
|       - ./ref-test/data:/ref-test/data |       - ./ref-test/data:/ref-test/data | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|     networks: |     networks: | ||||||
|       - frontend |       - frontend | ||||||
|       - backend |       - backend | ||||||
|     depends_on: |     depends_on: | ||||||
|       - ref_test_db |       - postfix | ||||||
|       - ref_test_postfix |  | ||||||
|  |  | ||||||
|   ref_test_db: |   postfix: | ||||||
|     container_name: ref_test_db |     container_name: reftest_postfix | ||||||
|     image: mongo:5.0.4-focal |  | ||||||
|     restart: unless-stopped |  | ||||||
|     volumes: |  | ||||||
|       - ./database/data:/data/db |  | ||||||
|       - ./database/initdb.d/:/docker-entrypoint-initdb.d/ |  | ||||||
|     env_file: |  | ||||||
|       - ./.env |  | ||||||
|     ports: |  | ||||||
|       - 27017 |  | ||||||
|     networks: |  | ||||||
|       - backend |  | ||||||
|    |  | ||||||
|   ref_test_postfix: |  | ||||||
|     container_name: ref_test_postfix |  | ||||||
|     image: catatnight/postfix:latest |     image: catatnight/postfix:latest | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|     env_file: |     env_file: | ||||||
| @@ -63,15 +49,13 @@ services: | |||||||
|     networks: |     networks: | ||||||
|       - backend |       - backend | ||||||
|    |    | ||||||
|   ref_test_certbot: |   certbot: | ||||||
|     container_name: ref_test_certbot |     container_name: reftest_certbot | ||||||
|     image: certbot/certbot:v1.21.0 |     image: certbot/certbot | ||||||
|     volumes: |     volumes: | ||||||
|       - ./certbot:/etc/letsencrypt |       - ./certbot:/etc/letsencrypt | ||||||
|       - ./src/html:/var/www/html |       - ./src/html:/var/www/html | ||||||
|     depends_on: |     entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" | ||||||
|       - ref_test_server |  | ||||||
|     # command: certonly --webroot --webroot-path=/var/www/html --email (email) --agree-tos --no-eff-email -d (domain) |  | ||||||
|    |    | ||||||
| networks: | networks: | ||||||
|   frontend: |   frontend: | ||||||
|   | |||||||
							
								
								
									
										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 { | upstream reftest { | ||||||
|     server  ref_test_app:5000; |     server  app:5000; | ||||||
| } | } | ||||||
|  |  | ||||||
| server { | server { | ||||||
|     server_name domain_name; |     server_name domain_name; | ||||||
| 	listen 80; |     listen 80 default_server; | ||||||
| 	listen [::]:80; |     listen [::]:80 default_server; | ||||||
|     # Redirect to ssl |     # Redirect to ssl | ||||||
|     return 301 https://$host$request_uri; |     return 301 https://$host$request_uri; | ||||||
| } | } | ||||||
|  |  | ||||||
| server { | server { | ||||||
|     server_name domain_name; |     server_name domain_name; | ||||||
| 	listen 443 ssl http2; |     listen 443 ssl http2 default_server; | ||||||
| 	listen [::]:443 ssl http2; |     listen [::]:443 ssl http2 default_server; | ||||||
|  |  | ||||||
| 	# SSL configuration | 	# SSL configuration | ||||||
|     include	/etc/nginx/ssl.conf; |     include	/etc/nginx/ssl.conf; | ||||||
|     include	/etc/nginx/certbot-challenge.conf; |     include	/etc/nginx/certbot-challenge.conf; | ||||||
|  |  | ||||||
|     location ^~ /static/  { |     location ^~ /quiz/static/  { | ||||||
|         include  /etc/nginx/mime.types; |         include  /etc/nginx/mime.types; | ||||||
|         alias /usr/share/nginx/html/quiz/static/; |         alias /usr/share/nginx/html/quiz/static/; | ||||||
|     } |     } | ||||||
| @@ -30,7 +30,28 @@ server { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     location / { |     location / { | ||||||
|         include /etc/nginx/conf.d/common-location.conf; |         include /etc/nginx/conf.d/proxy_headers.conf; | ||||||
|         proxy_pass http://reftest; |         proxy_pass http://reftest; | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | server { | ||||||
|  |     server_name www.domain_name; | ||||||
|  |     listen 80; | ||||||
|  |     listen [::]:80; | ||||||
|  |     # Redirect to non-www | ||||||
|  |     return 301 $scheme://domain_name$request_uri; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | server { | ||||||
|  |     server_name www.domain_name; | ||||||
|  |     listen 443 ssl http2; | ||||||
|  |     listen [::]:443 ssl http2; | ||||||
|  |  | ||||||
|  | 	# SSL configuration | ||||||
|  |     include	/etc/nginx/ssl.conf; | ||||||
|  |     include	/etc/nginx/certbot-challenge.conf; | ||||||
|  |  | ||||||
|  |     # Redirect to non-www | ||||||
|  |     return 301 $scheme://domain_name$request_uri; | ||||||
|  | } | ||||||
| @@ -1,2 +1,13 @@ | |||||||
| ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem; # managed by Certbot | ssl_certificate /etc/letsencrypt/live/domain_name/fullchain.pem; | ||||||
| ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem; # managed by Certbot | ssl_certificate_key /etc/letsencrypt/live/domain_name/privkey.pem; | ||||||
|  |  | ||||||
|  | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; | ||||||
|  | ssl_prefer_server_ciphers on; | ||||||
|  | ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384"; | ||||||
|  | ssl_stapling on; | ||||||
|  | ssl_stapling_verify on; | ||||||
|  | ssl_trusted_certificate /etc/letsencrypt/lets-encrypt-x3-cross-signed.pem; | ||||||
|  | add_header Strict-Transport-Security "max-age=31536000" always; | ||||||
|  | ssl_session_cache   shared:SSL:40m; | ||||||
|  | ssl_session_timeout 4h; | ||||||
|  | ssl_session_tickets on; | ||||||
| @@ -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 | 
| @@ -18,7 +18,7 @@ $('form.form-post').submit(function(event) { | |||||||
|      |      | ||||||
|     var $form = $(this); |     var $form = $(this); | ||||||
|     var data = $form.serialize(); |     var data = $form.serialize(); | ||||||
|     var url = $(this).attr('action'); |     var url = $(this).prop('action'); | ||||||
|     var rel_success = $(this).data('rel-success'); |     var rel_success = $(this).data('rel-success'); | ||||||
| 
 | 
 | ||||||
|     $.ajax({ |     $.ajax({ | ||||||
| @@ -70,14 +70,14 @@ $('form[name=form-upload-questions]').submit(function(event) { | |||||||
| // Edit and Delete Test Button Handlers
 | // Edit and Delete Test Button Handlers
 | ||||||
| $('.test-action').click(function(event) { | $('.test-action').click(function(event) { | ||||||
|      |      | ||||||
|     let _id = $(this).data('_id'); |     let id = $(this).data('id'); | ||||||
|     let action = $(this).data('action'); |     let action = $(this).data('action'); | ||||||
| 
 | 
 | ||||||
|     if (action == 'delete') { |     if (action == 'delete' || action == 'start' || action == 'end') { | ||||||
|         $.ajax({ |         $.ajax({ | ||||||
|             url: `/admin/tests/delete/`, |             url: `/admin/tests/edit/`, | ||||||
|             type: 'POST', |             type: 'POST', | ||||||
|             data: JSON.stringify({'_id': _id}), |             data: JSON.stringify({'id': id, 'action': action}), // TODO Change how CRUD operations work
 | ||||||
|             contentType: 'application/json', |             contentType: 'application/json', | ||||||
|             success: function(response) { |             success: function(response) { | ||||||
|                 window.location.href = '/admin/tests/'; |                 window.location.href = '/admin/tests/'; | ||||||
| @@ -87,21 +87,7 @@ $('.test-action').click(function(event) { | |||||||
|             }, |             }, | ||||||
|         }); |         }); | ||||||
|     } else if (action == 'edit') { |     } else if (action == 'edit') { | ||||||
|         window.location.href = `/admin/test/${_id}/` |         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); |  | ||||||
|             }, |  | ||||||
|         }); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     event.preventDefault(); |     event.preventDefault(); | ||||||
| @@ -166,7 +152,7 @@ $('#dismiss-cookie-alert').click(function(event){ | |||||||
| 
 | 
 | ||||||
|     $.ajax({ |     $.ajax({ | ||||||
|         url: '/cookies/', |         url: '/cookies/', | ||||||
|         type: 'GET', |         type: 'POST', | ||||||
|         data: { |         data: { | ||||||
|             time: Date.now() |             time: Date.now() | ||||||
|         }, |         }, | ||||||
| @@ -185,13 +171,13 @@ $('#dismiss-cookie-alert').click(function(event){ | |||||||
| // Script for Result Actions
 | // Script for Result Actions
 | ||||||
| $('.result-action-buttons').click(function(event){ | $('.result-action-buttons').click(function(event){ | ||||||
| 
 | 
 | ||||||
|     var _id = $(this).data('_id'); |     var id = $(this).data('id'); | ||||||
| 
 | 
 | ||||||
|     if ($(this).data('result-action') == 'generate') { |     if ($(this).data('result-action') == 'generate') { | ||||||
|         $.ajax({ |         $.ajax({ | ||||||
|             url: '/admin/certificate/', |             url: '/admin/certificate/', | ||||||
|             type: 'POST', |             type: 'POST', | ||||||
|             data: JSON.stringify({'_id': _id}), |             data: JSON.stringify({'id': id}), | ||||||
|             contentType: 'application/json', |             contentType: 'application/json', | ||||||
|             dataType: 'html', |             dataType: 'html', | ||||||
|             success: function(response) { |             success: function(response) { | ||||||
| @@ -207,7 +193,7 @@ $('.result-action-buttons').click(function(event){ | |||||||
|         $.ajax({ |         $.ajax({ | ||||||
|             url: window.location.href, |             url: window.location.href, | ||||||
|             type: 'POST', |             type: 'POST', | ||||||
|             data: JSON.stringify({'_id': _id, 'action': action}), |             data: JSON.stringify({'id': id, 'action': action}), | ||||||
|             contentType: 'application/json', |             contentType: 'application/json', | ||||||
|             success: function(response) { |             success: function(response) { | ||||||
|                 if (action == 'delete') { |                 if (action == 'delete') { | ||||||
| @@ -2,7 +2,7 @@ | |||||||
| 
 | 
 | ||||||
| {% block content %} | {% block content %} | ||||||
|     <div class="form-container"> |     <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" %} |             {% include "admin/components/server-alerts.html" %} | ||||||
|             <h2 class="form-heading">Update Your Account</h2> |             <h2 class="form-heading">Update Your Account</h2> | ||||||
|             {{ form.hidden_tag() }} |             {{ form.hidden_tag() }} | ||||||
| @@ -32,7 +32,7 @@ | |||||||
|             <div class="container form-submission-button"> |             <div class="container form-submission-button"> | ||||||
|                 <div class="row"> |                 <div class="row"> | ||||||
|                     <div class="col text-center"> |                     <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"> |                             <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"/> |                                 <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> |                             </svg> | ||||||
| @@ -2,7 +2,7 @@ | |||||||
| 
 | 
 | ||||||
| {% block content %} | {% block content %} | ||||||
|     <div class="form-container"> |     <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" %} |             {% include "admin/components/server-alerts.html" %} | ||||||
|             <h2 class="form">Log In</h2> |             <h2 class="form">Log In</h2> | ||||||
|             {{ form.hidden_tag() }} |             {{ form.hidden_tag() }} | ||||||
| @@ -26,7 +26,7 @@ | |||||||
|                     </div> |                     </div> | ||||||
|                 </div> |                 </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> |         </form> | ||||||
|     </div> |     </div> | ||||||
| {% endblock %} | {% endblock %} | ||||||
| @@ -3,14 +3,14 @@ | |||||||
| {% block navbar %} | {% block navbar %} | ||||||
|     <nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark"> |     <nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark"> | ||||||
|         <div class="container"> |         <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> |         </div> | ||||||
| </nav> | </nav> | ||||||
| {% endblock %} | {% endblock %} | ||||||
| 
 | 
 | ||||||
| {% block content %} | {% block content %} | ||||||
|     <div class="form-container"> |     <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" %} |             {% include "admin/components/server-alerts.html" %} | ||||||
|             <h2 class="form-heading">Register an Account</h2> |             <h2 class="form-heading">Register an Account</h2> | ||||||
|                 {{ form.hidden_tag() }} |                 {{ form.hidden_tag() }} | ||||||
| @@ -2,7 +2,7 @@ | |||||||
| 
 | 
 | ||||||
| {% block content %} | {% block content %} | ||||||
| <div class="form-container"> | <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" %} |         {% include "admin/components/server-alerts.html" %} | ||||||
|         <h2 class="form-heading">Reset Password</h2> |         <h2 class="form-heading">Reset Password</h2> | ||||||
|         {{ form.hidden_tag() }} |         {{ form.hidden_tag() }} | ||||||
| @@ -2,7 +2,7 @@ | |||||||
| 
 | 
 | ||||||
| {% block content %} | {% block content %} | ||||||
| <div class="form-container"> | <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" %} |         {% include "admin/components/server-alerts.html" %} | ||||||
|         <h2 class="form-heading">Update Password</h2> |         <h2 class="form-heading">Update Password</h2> | ||||||
|         {{ form.hidden_tag() }} |         {{ form.hidden_tag() }} | ||||||
| @@ -45,6 +45,9 @@ | |||||||
|             integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" |             integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" | ||||||
|             crossorigin="anonymous"> |             crossorigin="anonymous"> | ||||||
|         </script> |         </script> | ||||||
|  |         <script> | ||||||
|  |             window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`) | ||||||
|  |         </script> | ||||||
|         <script |         <script | ||||||
|             src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js" |             src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js" | ||||||
|             integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB" |             integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB" | ||||||
| @@ -15,30 +15,30 @@ | |||||||
|                             <h5 class="mb-1">Candidate</h5> |                             <h5 class="mb-1">Candidate</h5> | ||||||
|                         </div> |                         </div> | ||||||
|                         <h2> |                         <h2> | ||||||
|                             {{ entry.name.surname}}, {{ entry.name.first_name }} |                             {{ entry.get_surname()}}, {{ entry.get_first_name() }} | ||||||
|                         </h2> |                         </h2> | ||||||
|                     </li> |                     </li> | ||||||
|                     <li class="list-group-item list-group-item-action"> |                     <li class="list-group-item list-group-item-action"> | ||||||
|                         <div class="d-flex w-100 justify-content-between"> |                         <div class="d-flex w-100 justify-content-between"> | ||||||
|                             <h5 class="mb-1">Email Address</h5> |                             <h5 class="mb-1">Email Address</h5> | ||||||
|                         </div> |                         </div> | ||||||
|                         {{ entry.email }} |                         {{ entry.get_email() }} | ||||||
|                     </li> |                     </li> | ||||||
|                     {% if entry['club'] %} |                     {% if entry.club %} | ||||||
|                         <li class="list-group-item list-group-item-action"> |                         <li class="list-group-item list-group-item-action"> | ||||||
|                             <div class="d-flex w-100 justify-content-between"> |                             <div class="d-flex w-100 justify-content-between"> | ||||||
|                                 <h5 class="mb-1">Club</h5> |                                 <h5 class="mb-1">Club</h5> | ||||||
|                             </div> |                             </div> | ||||||
|                             {{ entry.club }} |                             {{ entry.get_club() }} | ||||||
|                         </li> |                         </li> | ||||||
|                     {% endif %} |                     {% endif %} | ||||||
|                     <li class="list-group-item list-group-item-action"> |                     <li class="list-group-item list-group-item-action"> | ||||||
|                         <div class="d-flex w-100 justify-content-between"> |                         <div class="d-flex w-100 justify-content-between"> | ||||||
|                             <h5 class="mb-1">Exam Code</h5> |                             <h5 class="mb-1">Exam Code</h5> | ||||||
|                         </div> |                         </div> | ||||||
|                         {{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }} |                         {{ entry.test.get_code() }} | ||||||
|                     </li> |                     </li> | ||||||
|                     {% if entry['user_code'] %} |                     {% if entry.user_code %} | ||||||
|                         <li class="list-group-item list-group-item-action"> |                         <li class="list-group-item list-group-item-action"> | ||||||
|                             <div class="d-flex w-100 justify-content-between"> |                             <div class="d-flex w-100 justify-content-between"> | ||||||
|                                 <h5 class="mb-1">User Code</h5> |                                 <h5 class="mb-1">User Code</h5> | ||||||
| @@ -59,19 +59,19 @@ | |||||||
|                                 <span class="badge bg-danger">Late</span> |                                 <span class="badge bg-danger">Late</span> | ||||||
|                             {% endif %} |                             {% endif %} | ||||||
|                         </div> |                         </div> | ||||||
|                         {{ entry.submission_time.strftime('%d %b %Y %H:%M:%S') }} |                         {{ entry.end_time.strftime('%d %b %Y %H:%M:%S') }} | ||||||
|                     </li>        |                     </li>        | ||||||
|                     <li class="list-group-item list-group-item-action"> |                     <li class="list-group-item list-group-item-action"> | ||||||
|                         <div class="d-flex w-100 justify-content-between"> |                         <div class="d-flex w-100 justify-content-between"> | ||||||
|                             <h5 class="mb-1">Score</h5> |                             <h5 class="mb-1">Score</h5> | ||||||
|                         </div> |                         </div> | ||||||
|                         {{ entry.results.score }}% |                         {{ entry.result.score }}% | ||||||
|                     </li> |                     </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"> |                         <div class="d-flex w-100 justify-content-between"> | ||||||
|                             <h5 class="mb-1">Grade</h5> |                             <h5 class="mb-1">Grade</h5> | ||||||
|                         </div> |                         </div> | ||||||
|                         {{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:]}} |                         {{ entry.result.grade[0]|upper }}{{ entry.result.grade[1:]}} | ||||||
|                     </li> |                     </li> | ||||||
|                 </ul> |                 </ul> | ||||||
|                 <div class="site-footer mt-5"> |                 <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 %} |                                         {% for test in current_tests %} | ||||||
|                                             <tr> |                                             <tr> | ||||||
|                                                 <td> |                                                 <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> | ||||||
|                                                 <td> |                                                 <td> | ||||||
|                                                     {{ test.expiry_date.strftime('%d %b %Y') }} |                                                     {{ test.end_date.strftime('%d %b %Y') }} | ||||||
|                                                 </td> |                                                 </td> | ||||||
|                                             </tr> |                                             </tr> | ||||||
|                                         {% endfor %} |                                         {% endfor %} | ||||||
|                                     </tbody> |                                     </tbody> | ||||||
|                                 </table> |                                 </table> | ||||||
|                             </div> |                             </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 %} |                         {% else %} | ||||||
|                             <div class="alert alert-primary"> |                             <div class="alert alert-primary"> | ||||||
|                                 There are currently no active exams. |                                 There are currently no active exams. | ||||||
|                             </div> |                             </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 %} |                         {% endif %} | ||||||
|                     </div> |                     </div> | ||||||
|                   </div> |                   </div> | ||||||
| @@ -69,20 +69,20 @@ | |||||||
|                                         {% for result in recent_results %} |                                         {% for result in recent_results %} | ||||||
|                                             <tr> |                                             <tr> | ||||||
|                                                 <td> |                                                 <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> | ||||||
|                                                 <td> |                                                 <td> | ||||||
|                                                     {{ result.submission_time.strftime('%d %b %Y %H:%M') }} |                                                     {{ result.end_time.strftime('%d %b %Y %H:%M') }} | ||||||
|                                                 </td> |                                                 </td> | ||||||
|                                                 <td> |                                                 <td> | ||||||
|                                                     {{ result.percent }}% ({{ result.results.grade }}) |                                                     {{ (100*result.result['score']/result.result['max'])|round|int }}% ({{ result.result.grade }}) | ||||||
|                                                 </td> |                                                 </td> | ||||||
|                                             </tr> |                                             </tr> | ||||||
|                                         {% endfor %} |                                         {% endfor %} | ||||||
|                                     </tbody> |                                     </tbody> | ||||||
|                                 </table> |                                 </table> | ||||||
|                             </div> |                             </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 %} |                         {% else %} | ||||||
|                             <div class="alert alert-primary"> |                             <div class="alert alert-primary"> | ||||||
|                                 There are currently no exam results to preview. |                                 There are currently no exam results to preview. | ||||||
| @@ -114,22 +114,22 @@ | |||||||
|                                             {% for test in upcoming_tests %} |                                             {% for test in upcoming_tests %} | ||||||
|                                                 <tr> |                                                 <tr> | ||||||
|                                                     <td> |                                                     <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> | ||||||
|                                                     <td> |                                                     <td> | ||||||
|                                                         {{ test.expiry_date.strftime('%d %b %Y') }} |                                                         {{ test.end_date.strftime('%d %b %Y') }} | ||||||
|                                                     </td> |                                                     </td> | ||||||
|                                                 </tr> |                                                 </tr> | ||||||
|                                             {% endfor %} |                                             {% endfor %} | ||||||
|                                         </tbody> |                                         </tbody> | ||||||
|                                     </table> |                                     </table> | ||||||
|                                 </div> |                                 </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 %} |                             {% else %} | ||||||
|                                 <div class="alert alert-primary"> |                                 <div class="alert alert-primary"> | ||||||
|                                     There are currently no upcoming exams. |                                     There are currently no upcoming exams. | ||||||
|                                 </div> |                                 </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 %} |                             {% endif %} | ||||||
|                     </div> |                     </div> | ||||||
|                   </div> |                   </div> | ||||||
| @@ -13,30 +13,30 @@ | |||||||
|                             <h5 class="mb-1">Candidate</h5> |                             <h5 class="mb-1">Candidate</h5> | ||||||
|                         </div> |                         </div> | ||||||
|                         <h2> |                         <h2> | ||||||
|                             {{ entry.name.surname }}, {{ entry.name.first_name }} |                             {{ entry.get_surname() }}, {{ entry.get_first_name() }} | ||||||
|                         </h2> |                         </h2> | ||||||
|                     </li> |                     </li> | ||||||
|                     <li class="list-group-item list-group-item-action"> |                     <li class="list-group-item list-group-item-action"> | ||||||
|                         <div class="d-flex w-100 justify-content-between"> |                         <div class="d-flex w-100 justify-content-between"> | ||||||
|                             <h5 class="mb-1">Email Address</h5> |                             <h5 class="mb-1">Email Address</h5> | ||||||
|                         </div> |                         </div> | ||||||
|                         {{ entry.email }} |                         {{ entry.get_email() }} | ||||||
|                     </li> |                     </li> | ||||||
|                     {% if entry['club'] %} |                     {% if entry.club %} | ||||||
|                         <li class="list-group-item list-group-item-action"> |                         <li class="list-group-item list-group-item-action"> | ||||||
|                             <div class="d-flex w-100 justify-content-between"> |                             <div class="d-flex w-100 justify-content-between"> | ||||||
|                                 <h5 class="mb-1">Club</h5> |                                 <h5 class="mb-1">Club</h5> | ||||||
|                             </div> |                             </div> | ||||||
|                             {{ entry.club }} |                             {{ entry.get_club() }} | ||||||
|                         </li> |                         </li> | ||||||
|                     {% endif %} |                     {% endif %} | ||||||
|                     <li class="list-group-item list-group-item-action"> |                     <li class="list-group-item list-group-item-action"> | ||||||
|                         <div class="d-flex w-100 justify-content-between"> |                         <div class="d-flex w-100 justify-content-between"> | ||||||
|                             <h5 class="mb-1">Exam Code</h5> |                             <h5 class="mb-1">Exam Code</h5> | ||||||
|                         </div> |                         </div> | ||||||
|                         {{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }} |                         {{ entry.test.get_code() }} | ||||||
|                     </li> |                     </li> | ||||||
|                     {% if entry['user_code'] %} |                     {% if entry.user_code %} | ||||||
|                         <li class="list-group-item list-group-item-action"> |                         <li class="list-group-item list-group-item-action"> | ||||||
|                             <div class="d-flex w-100 justify-content-between"> |                             <div class="d-flex w-100 justify-content-between"> | ||||||
|                                 <h5 class="mb-1">User Code</h5> |                                 <h5 class="mb-1">User Code</h5> | ||||||
| @@ -44,7 +44,7 @@ | |||||||
|                             {{ entry.user_code }} |                             {{ entry.user_code }} | ||||||
|                         </li> |                         </li> | ||||||
|                     {% endif %} |                     {% endif %} | ||||||
|                     {% if 'start_time' in entry %} |                     {% if entry.start_time %} | ||||||
|                         <li class="list-group-item list-group-item-action"> |                         <li class="list-group-item list-group-item-action"> | ||||||
|                             <div class="d-flex w-100 justify-content-between"> |                             <div class="d-flex w-100 justify-content-between"> | ||||||
|                                 <h5 class="mb-1">Start Time</h5> |                                 <h5 class="mb-1">Start Time</h5> | ||||||
| @@ -59,28 +59,28 @@ | |||||||
|                                 <span class="badge bg-danger">Late</span> |                                 <span class="badge bg-danger">Late</span> | ||||||
|                             {% endif %} |                             {% endif %} | ||||||
|                         </div> |                         </div> | ||||||
|                         {% if 'submission_time' in entry %} |                         {% if entry.end_time %} | ||||||
|                             {{ entry.submission_time.strftime('%d %b %Y %H:%M:%S') }} |                             {{ entry.end_time.strftime('%d %b %Y %H:%M:%S') }} | ||||||
|                         {% else %} |                         {% else %} | ||||||
|                             Incomplete |                             Incomplete | ||||||
|                         {% endif %} |                         {% endif %} | ||||||
|                     </li> |                     </li> | ||||||
|                     {% if 'results' in entry %} |                     {% if entry.result %} | ||||||
|                         <li class="list-group-item list-group-item-action"> |                         <li class="list-group-item list-group-item-action"> | ||||||
|                             <div class="d-flex w-100 justify-content-between"> |                             <div class="d-flex w-100 justify-content-between"> | ||||||
|                                 <h5 class="mb-1">Score</h5> |                                 <h5 class="mb-1">Score</h5> | ||||||
|                             </div> |                             </div> | ||||||
|                             {{ entry.results.score }}% |                             {{ entry.result.score }}% | ||||||
|                         </li> |                         </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"> |                             <div class="d-flex w-100 justify-content-between"> | ||||||
|                                 <h5 class="mb-1">Grade</h5> |                                 <h5 class="mb-1">Grade</h5> | ||||||
|                             </div> |                             </div> | ||||||
|                             {{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:]}} |                             {{ entry.result.grade[0]|upper }}{{ entry.result.grade[1:]}} | ||||||
|                         </li> |                         </li> | ||||||
|                     {% endif %} |                     {% endif %} | ||||||
|                 </ul> |                 </ul> | ||||||
|                 {% if 'results' in entry %} |                 {% if entry.result %} | ||||||
|                     <div class="accordion" id="results-breakdown"> |                     <div class="accordion" id="results-breakdown"> | ||||||
|                         <div class="accordion-item"> |                         <div class="accordion-item"> | ||||||
|                             <h2 class="accordion-header" id="by-category"> |                             <h2 class="accordion-header" id="by-category"> | ||||||
| @@ -105,7 +105,7 @@ | |||||||
|                                             </tr> |                                             </tr> | ||||||
|                                         </thead> |                                         </thead> | ||||||
|                                         <tbody> |                                         <tbody> | ||||||
|                                             {% for tag, scores in entry.results.tags.items() %} |                                             {% for tag, scores in entry.result.tags.items() %} | ||||||
|                                                 <tr> |                                                 <tr> | ||||||
|                                                     <td> |                                                     <td> | ||||||
|                                                         {{ tag }} |                                                         {{ tag }} | ||||||
| @@ -149,8 +149,8 @@ | |||||||
|                                                         {{ question }} |                                                         {{ question }} | ||||||
|                                                     </td> |                                                     </td> | ||||||
|                                                     <td> |                                                     <td> | ||||||
|                                                         {{ answer }} |                                                         {{ answers[question|int][answer|int] }} | ||||||
|                                                         {% if not correct[question] == answer %} |                                                         {% if not correct[question] == answer|int %} | ||||||
|                                                             <span class="badge badge-pill bg-danger badge-danger">Incorrect</span> |                                                             <span class="badge badge-pill bg-danger badge-danger">Incorrect</span> | ||||||
|                                                         {% endif %} |                                                         {% endif %} | ||||||
|                                                     </td> |                                                     </td> | ||||||
| @@ -164,19 +164,19 @@ | |||||||
|                 {% endif %} |                 {% endif %} | ||||||
|                 <div class="container justify-content-center"> |                 <div class="container justify-content-center"> | ||||||
|                     <div class="row"> |                     <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> |                             <i class="bi bi-printer-fill button-icon"></i> | ||||||
|                             Printable Version |                             Printable Version | ||||||
|                         </a> |                         </a> | ||||||
|                     </div> |                     </div> | ||||||
|                     <div class="row"> |                     <div class="row"> | ||||||
|                             {% if entry.status == 'late' %} |                             {% 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> |                                     <i class="bi bi-clock-history button-icon"></i> | ||||||
|                                     Allow Late Entry |                                     Allow Late Entry | ||||||
|                                 </a> |                                 </a> | ||||||
|                             {% endif %} |                             {% 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> |                             <i class="bi bi-trash-fill button-icon"></i> | ||||||
|                             Delete Result |                             Delete Result | ||||||
|                         </a> |                         </a> | ||||||
| @@ -37,41 +37,41 @@ | |||||||
|                 {% for entry in entries %} |                 {% for entry in entries %} | ||||||
|                     <tr class="table-row"> |                     <tr class="table-row"> | ||||||
|                         <td> |                         <td> | ||||||
|                             {{ entry.name.surname }}, {{ entry.name.first_name }} |                             {{ entry.get_surname() }}, {{ entry.get_first_name() }} | ||||||
|                         </td> |                         </td> | ||||||
|                         <td> |                         <td> | ||||||
|                             {% if 'club' in entry %} |                             {% if entry.club %} | ||||||
|                                 {{ entry.club }} |                                 {{ entry.get_club() }} | ||||||
|                             {% endif %} |                             {% endif %} | ||||||
|                         </td> |                         </td> | ||||||
|                         <td> |                         <td> | ||||||
|                             {{ '—'.join([entry.test_code[:4], entry.test_code[4:8], entry.test_code[8:]]) }} |                             {{ entry.test.get_code() }} | ||||||
|                         </td> |                         </td> | ||||||
|                         <td> |                         <td> | ||||||
|                             {% if 'status' in entry %} |                             {% if entry.status %} | ||||||
|                                 {{ entry.status }} |                                 {{ entry.status }} | ||||||
|                             {% endif %} |                             {% endif %} | ||||||
|                         </td> |                         </td> | ||||||
|                         <td> |                         <td> | ||||||
|                             {% if 'submission_time' in entry %} |                             {% if entry.end_time %} | ||||||
|                                 {{ entry.submission_time.strftime('%d %b %Y') }} |                                 {{ entry.end_time.strftime('%d %b %Y') }} | ||||||
|                             {% endif %} |                             {% endif %} | ||||||
|                         </td> |                         </td> | ||||||
|                         <td> |                         <td> | ||||||
|                             {% if 'results' in entry %} |                             {% if entry.result %} | ||||||
|                                 {{ entry.results.score }}% |                                 {{ entry.result.score }}% | ||||||
|                             {% endif %} |                             {% endif %} | ||||||
|                         </td> |                         </td> | ||||||
|                         <td> |                         <td> | ||||||
|                             {% if 'results' in entry %} |                             {% if entry.result %} | ||||||
|                                 {{ entry.results.grade }} |                                 {{ entry.result.grade }} | ||||||
|                             {% endif %} |                             {% endif %} | ||||||
|                         </td> |                         </td> | ||||||
|                         <td class="row-actions"> |                         <td class="row-actions"> | ||||||
|                             <a |                             <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" |                                 class="btn btn-primary entry-details" | ||||||
|                                 data-_id="{{entry._id}}" |                                 data-id="{{entry.id}}" | ||||||
|                                 title="View Details" |                                 title="View Details" | ||||||
|                             > |                             > | ||||||
|                                 <i class="bi bi-file-medical-fill button-icon"></i> |                                 <i class="bi bi-file-medical-fill button-icon"></i> | ||||||
| @@ -2,11 +2,11 @@ | |||||||
| 
 | 
 | ||||||
| {% block content %} | {% block content %} | ||||||
|     <div class="form-container"> |     <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" %} |             {% 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() }} |             {{ 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> |             <p>Are you sure you want to proceed?</p> | ||||||
|             <div class="form-label-group"> |             <div class="form-label-group"> | ||||||
|                 {{ form.password(class_="form-control", placeholder="Confirm Your Password", autofocus=true) }} |                 {{ form.password(class_="form-control", placeholder="Confirm Your Password", autofocus=true) }} | ||||||
| @@ -20,7 +20,7 @@ | |||||||
|             <div class="container form-submission-button"> |             <div class="container form-submission-button"> | ||||||
|                 <div class="row"> |                 <div class="row"> | ||||||
|                     <div class="col text-center"> |                     <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"> |                             <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"/> |                                 <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> |                             </svg> | ||||||
| @@ -28,22 +28,22 @@ | |||||||
|                                         <tr> |                                         <tr> | ||||||
|                                             <td> |                                             <td> | ||||||
|                                                 <a href=" |                                                 <a href=" | ||||||
|                                                 {% if user._id == get_id_from_cookie() %} |                                                 {% if user == current_user %} | ||||||
|                                                     {{ url_for('admin_auth.account') }} |                                                     {{ url_for('admin._update_user', id=current_user.id) }} | ||||||
|                                                 {% else %} |                                                 {% else %} | ||||||
|                                                     {{ url_for('admin_views.update_user', _id=user._id) }} |                                                     {{ url_for('admin._update_user', id=user.id) }} | ||||||
|                                                 {% endif%} |                                                 {% endif%} | ||||||
|                                                 ">{{ user.username }}</a> |                                                 ">{{ user.get_username() }}</a> | ||||||
|                                             </td> |                                             </td> | ||||||
|                                             <td> |                                             <td> | ||||||
|                                                 <a href="mailto:{{ user.email }}">{{ user.email }}</a> |                                                 <a href="mailto:{{ user.get_email() }}">{{ user.get_email() }}</a> | ||||||
|                                             </td> |                                             </td> | ||||||
|                                         </tr> |                                         </tr> | ||||||
|                                     {% endfor %} |                                     {% endfor %} | ||||||
|                                 </tbody> |                                 </tbody> | ||||||
|                             </table> |                             </table> | ||||||
|                         </div> |                         </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> |                 </div> | ||||||
|             </div> |             </div> | ||||||
| @@ -57,7 +57,7 @@ | |||||||
|                                     <thead> |                                     <thead> | ||||||
|                                         <tr> |                                         <tr> | ||||||
|                                             <th> |                                             <th> | ||||||
|                                                 File Name |                                                 Uploaded | ||||||
|                                             </th> |                                             </th> | ||||||
|                                             <th> |                                             <th> | ||||||
|                                                 Exams |                                                 Exams | ||||||
| @@ -68,22 +68,22 @@ | |||||||
|                                         {% for dataset in datasets %} |                                         {% for dataset in datasets %} | ||||||
|                                             <tr> |                                             <tr> | ||||||
|                                                 <td> |                                                 <td> | ||||||
|                                                     {{ dataset.filename }} |                                                     {{ dataset.date.strftime('%d %b %Y %H:%M') }} | ||||||
|                                                 </td> |                                                 </td> | ||||||
|                                                 <td> |                                                 <td> | ||||||
|                                                     {{ dataset.use }} |                                                     {{ dataset.tests|length }} | ||||||
|                                                 </td> |                                                 </td> | ||||||
|                                             </tr> |                                             </tr> | ||||||
|                                         {% endfor %} |                                         {% endfor %} | ||||||
|                                     </tbody> |                                     </tbody> | ||||||
|                                 </table> |                                 </table> | ||||||
|                             </div> |                             </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 %} |                         {% else %} | ||||||
|                             <div class="alert alert-primary"> |                             <div class="alert alert-primary"> | ||||||
|                                 There are currently no question datasets uploaded. |                                 There are currently no question datasets uploaded. | ||||||
|                             </div> |                             </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 %} |                         {% endif %} | ||||||
|                     </div> |                     </div> | ||||||
|                 </div> |                 </div> | ||||||
| @@ -9,9 +9,6 @@ | |||||||
|                 <tr> |                 <tr> | ||||||
|                     <th> |                     <th> | ||||||
| 
 | 
 | ||||||
|                     </th> |  | ||||||
|                     <th data-priority="1"> |  | ||||||
|                         File Name |  | ||||||
|                     </th> |                     </th> | ||||||
|                     <th data-priority="2"> |                     <th data-priority="2"> | ||||||
|                         Uploaded |                         Uploaded | ||||||
| @@ -31,7 +28,7 @@ | |||||||
|                 {% for element in data %} |                 {% for element in data %} | ||||||
|                     <tr class="table-row"> |                     <tr class="table-row"> | ||||||
|                         <td> |                         <td> | ||||||
|                             {% if element.filename == default %} |                             {% if element.default %} | ||||||
|                                 <div class="text-success" title="Default Dataset"> |                                 <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"> |                                     <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"/> |                                         <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 %} |                             {% endif %} | ||||||
|                         </td> |                         </td> | ||||||
|                         <td> |                         <td> | ||||||
|                             {{ element.filename }} |                             {{ element.date.strftime('%d %b %Y %H:%M') }} | ||||||
|                         </td> |                         </td> | ||||||
|                         <td> |                         <td> | ||||||
|                             {{ element.timestamp.strftime('%d %b %Y') }} |                             {{ element.creator.get_username() }} | ||||||
|                         </td> |                         </td> | ||||||
|                         <td> |                         <td> | ||||||
|                             {{ element.author }} |                             {{ element.tests|length }} | ||||||
|                         </td> |  | ||||||
|                         <td> |  | ||||||
|                             {{ element.use }} |  | ||||||
|                         </td> |                         </td> | ||||||
|                         <td class="row-actions"> |                         <td class="row-actions"> | ||||||
|                             <a |                             <a | ||||||
| @@ -112,10 +106,10 @@ | |||||||
|             $(document).ready(function() { |             $(document).ready(function() { | ||||||
|                 $('#question-datasets-table').DataTable({ |                 $('#question-datasets-table').DataTable({ | ||||||
|                     'columnDefs': [ |                     'columnDefs': [ | ||||||
|                         {'sortable': false, 'targets': [0,5]}, |                         {'sortable': false, 'targets': [0,4]}, | ||||||
|                         {'searchable': false, 'targets': [0,4,5]} |                         {'searchable': false, 'targets': [0,3,4]} | ||||||
|                     ], |                     ], | ||||||
|                     'order': [[2, 'desc'], [3, 'asc']], |                     'order': [[1, 'desc'], [2, 'asc']], | ||||||
|                     'responsive': 'true', |                     'responsive': 'true', | ||||||
|                     'fixedHeader': 'true', |                     'fixedHeader': 'true', | ||||||
|                 }); |                 }); | ||||||
| @@ -2,12 +2,12 @@ | |||||||
| 
 | 
 | ||||||
| {% block content %} | {% block content %} | ||||||
|     <div class="form-container"> |     <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" %} |             {% 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() }} |             {{ form.hidden_tag() }} | ||||||
|             <div class="form-label-group"> |             <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 }} |                 {{ form.email.label }} | ||||||
|             </div> |             </div> | ||||||
|             <div class="form-label-group"> |             <div class="form-label-group"> | ||||||
| @@ -23,17 +23,17 @@ | |||||||
|                 {{ form.notify.label }} |                 {{ form.notify.label }} | ||||||
|             </div> |             </div> | ||||||
|             <div class="form-label-group"> |             <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> | ||||||
|             <div class="form-label-group"> |             <div class="form-label-group"> | ||||||
|                 {{ form.user_password(class_="form-control", placeholder="Your Password", value = user.email) }} |                 {{ form.confirm_password(class_="form-control", placeholder="Your Password", value = user.email) }} | ||||||
|                 {{ form.user_password.label }} |                 {{ form.confirm_password.label }} | ||||||
|             </div> |             </div> | ||||||
|             {% include "admin/components/client-alerts.html" %} |             {% include "admin/components/client-alerts.html" %} | ||||||
|             <div class="container form-submission-button"> |             <div class="container form-submission-button"> | ||||||
|                 <div class="row"> |                 <div class="row"> | ||||||
|                     <div class="col text-center"> |                     <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"> |                             <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"/> |                                 <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> |                             </svg> | ||||||
| @@ -23,7 +23,7 @@ | |||||||
|             {% for user in users %} |             {% for user in users %} | ||||||
|                 <tr class="table-row"> |                 <tr class="table-row"> | ||||||
|                     <td> |                     <td> | ||||||
|                         {% if user._id == get_id_from_cookie() %} |                         {% if user == current_user %} | ||||||
|                             <div class="text-success" title="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"> |                                 <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"/> |                                     <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 %} |                         {% endif %} | ||||||
|                     </td> |                     </td> | ||||||
|                     <td> |                     <td> | ||||||
|                         {{ user.username }} |                         {{ user.get_username() }} | ||||||
|                     </td> |                     </td> | ||||||
|                     <td> |                     <td> | ||||||
|                         {{ user.email }} |                         {{ user.get_email() }} | ||||||
|                     </td> |                     </td> | ||||||
|                     <td class="row-actions"> |                     <td class="row-actions"> | ||||||
|                         <a |                         <a | ||||||
|                             href=" |                             href=" | ||||||
|                             {% if not user._id == get_id_from_cookie() %} |                             {% if not user == current_user %} | ||||||
|                                 {{ url_for('admin_views.update_user', _id = user._id ) }} |                                 {{ url_for('admin._update_user', id = user.id ) }} | ||||||
|                             {% else %} |                             {% else %} | ||||||
|                                 {{ url_for('admin_auth.account') }} |                                 {{ url_for('admin._update_user', id=current_user.id) }} | ||||||
|                             {% endif %} |                             {% endif %} | ||||||
|                             " |                             " | ||||||
|                             class="btn btn-primary" |                             class="btn btn-primary" | ||||||
| @@ -53,15 +53,15 @@ | |||||||
|                         </a> |                         </a> | ||||||
|                         <a |                         <a | ||||||
|                             href=" |                             href=" | ||||||
|                             {% if not user._id == get_id_from_cookie()  %} |                             {% if not user == current_user %} | ||||||
|                                 {{ url_for('admin_views.delete_user', _id = user._id ) }} |                                 {{ url_for('admin._delete_user', id = user.id ) }} | ||||||
|                             {% else %} |                             {% else %} | ||||||
|                                 # |                                 # | ||||||
|                             {% endif %} |                             {% 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" |                             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> |                             <i class="bi bi-person-x-fill button-icon"></i> | ||||||
|                         </button> |                         </button> | ||||||
| @@ -12,38 +12,33 @@ | |||||||
|                             <h5 class="mb-1">Exam Code</h5> |                             <h5 class="mb-1">Exam Code</h5> | ||||||
|                         </div> |                         </div> | ||||||
|                         <h2> |                         <h2> | ||||||
|                             {{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }} |                             {{ test.get_code() }} | ||||||
|                         </h2> |                         </h2> | ||||||
|                     </li> |                     </li> | ||||||
|                     <li class="list-group-item list-group-item-action"> |                     <li class="list-group-item list-group-item-action"> | ||||||
|                         <div class="d-flex w-100 justify-content-between"> |                         <div class="d-flex w-100 justify-content-between"> | ||||||
|                             <h5 class="mb-1">Dataset</h5> |                             <h5 class="mb-1">Dataset</h5> | ||||||
|                         </div> |                         </div> | ||||||
|                         {{ test.dataset }} |                         {{ test.dataset.date.strftime('%Y%m%d%H%M%S') }} | ||||||
|                     </li> |                     </li> | ||||||
|                     <li class="list-group-item list-group-item-action"> |                     <li class="list-group-item list-group-item-action"> | ||||||
|                         <div class="d-flex w-100 justify-content-between"> |                         <div class="d-flex w-100 justify-content-between"> | ||||||
|                             <h5 class="mb-1">Created By</h5> |                             <h5 class="mb-1">Created By</h5> | ||||||
|                         </div> |                         </div> | ||||||
|                         {{ test.creator }} |                         {{ 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">Date Created</h5> |  | ||||||
|                         </div> |  | ||||||
|                         {{ test.date_created.strftime('%d %b %Y') }} |  | ||||||
|                     </li> |                     </li> | ||||||
|  |                      | ||||||
|                     <li class="list-group-item list-group-item-action"> |                     <li class="list-group-item list-group-item-action"> | ||||||
|                         <div class="d-flex w-100 justify-content-between"> |                         <div class="d-flex w-100 justify-content-between"> | ||||||
|                             <h5 class="mb-1">Start Date</h5> |                             <h5 class="mb-1">Start Date</h5> | ||||||
|                         </div> |                         </div> | ||||||
|                         {{ test.start_date.strftime('%d %b %Y') }} |                         {{ test.start_date.strftime('%d %b %Y %H:%M') }} | ||||||
|                     </li> |                     </li> | ||||||
|                     <li class="list-group-item list-group-item-action"> |                     <li class="list-group-item list-group-item-action"> | ||||||
|                         <div class="d-flex w-100 justify-content-between"> |                         <div class="d-flex w-100 justify-content-between"> | ||||||
|                             <h5 class="mb-1">Expiry Date</h5> |                             <h5 class="mb-1">Expiry Date</h5> | ||||||
|                         </div> |                         </div> | ||||||
|                         {{ test.expiry_date.strftime('%d %b %Y') }} |                         {{ test.end_date.strftime('%d %b %Y %H:%M') }} | ||||||
|                     </li> |                     </li> | ||||||
|                     <li class="list-group-item list-group-item-action"> |                     <li class="list-group-item list-group-item-action"> | ||||||
|                         <div class="d-flex w-100 justify-content-between"> |                         <div class="d-flex w-100 justify-content-between"> | ||||||
| @@ -62,7 +57,7 @@ | |||||||
|                         {% endif %} |                         {% endif %} | ||||||
|                     </li> |                     </li> | ||||||
|                     <div class="accordion" id="test-info-detail"> |                     <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"> |                             <div class="accordion-item"> | ||||||
|                                 <h2 class="accordion-header" id="test-entries"> |                                 <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"> |                                     <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 %} |                                                 {% for entry in test.entries %} | ||||||
|                                                     <tr> |                                                     <tr> | ||||||
|                                                         <td> |                                                         <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> |                                                         </td> | ||||||
|                                                     </tr> |                                                     </tr> | ||||||
|                                                 {% endfor %} |                                                 {% endfor %} | ||||||
| @@ -86,7 +81,7 @@ | |||||||
|                                 </div> |                                 </div> | ||||||
|                             </div> |                             </div> | ||||||
|                         {% endif %} |                         {% endif %} | ||||||
|                         {% if 'time_adjustments' in test and test.time_adjustments|length > 0 %} |                         {% if test.adjustments %} | ||||||
|                             <div class="accordion-item"> |                             <div class="accordion-item"> | ||||||
|                                 <h2 class="accordion-header" id="test-adjustments"> |                                 <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"> |                                     <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> |                                                 </tr> | ||||||
|                                             </thead> |                                             </thead> | ||||||
|                                             <tbody> |                                             <tbody> | ||||||
|                                                 {% for key, value in test.time_adjustments.items() %} |                                                 {% for key, value in test.adjustments.items() %} | ||||||
|                                                     <tr> |                                                     <tr> | ||||||
|                                                         <td> |                                                         <td> | ||||||
|                                                             {{ key }} |                                                             {{ key.upper() }} | ||||||
|                                                         </td> |                                                         </td> | ||||||
|                                                         <td> |                                                         <td> | ||||||
|                                                             {{ value }} |                                                             {{ 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 name="form-add-adjustment" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success=""> | ||||||
|                                             {{ form.hidden_tag() }} |                                             {{ form.hidden_tag() }} | ||||||
|                                             <div class="form-label-group"> |                                             <div class="form-label-group"> | ||||||
|                                                 {{ form.time(class_="form-control", placeholder="Enter Username") }} |                                                 {{ form.time(class_="form-control", placeholder="Enter Time") }} | ||||||
|                                                 {{ form.time.label }} |                                                 {{ form.time.label }} | ||||||
|                                             </div> |                                             </div> | ||||||
|                                             <div class="container form-submission-button"> |                                             <div class="container form-submission-button"> | ||||||
| @@ -168,11 +163,18 @@ | |||||||
|                 </div> |                 </div> | ||||||
|                 <div class="container justify-content-center"> |                 <div class="container justify-content-center"> | ||||||
|                     <div class="row"> |                     <div class="row"> | ||||||
|                         <a href="#" class="btn btn-warning test-action" data-action="close" data-_id="{{ test._id }}"> |                         {% if test.start_date <= now %} | ||||||
|                             <i class="bi bi-hourglass button-icon"></i> |                             <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 |                                 Close Exam | ||||||
|                             </a> |                             </a> | ||||||
|                         <a href="#" class="btn btn-danger test-action" data-action="delete" data-_id="{{ test._id }}"> |                         {% 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> |                             <i class="bi bi-file-earmark-excel-fill button-icon"></i> | ||||||
|                             Delete Exam |                             Delete Exam | ||||||
|                         </a> |                         </a> | ||||||
| @@ -33,13 +33,13 @@ | |||||||
|                 {% for test in tests %} |                 {% for test in tests %} | ||||||
|                     <tr class="table-row"> |                     <tr class="table-row"> | ||||||
|                         <td> |                         <td> | ||||||
|                             {{ test.start_date.strftime('%d %b %Y') }} |                             {{ test.start_date.strftime('%d %b %y %H:%M') }} | ||||||
|                         </td> |                         </td> | ||||||
|                         <td> |                         <td> | ||||||
|                             {{ '—'.join([test.test_code[:4], test.test_code[4:8], test.test_code[8:]]) }} |                             {{ test.get_code() }} | ||||||
|                         </td> |                         </td> | ||||||
|                         <td> |                         <td> | ||||||
|                             {{ test.expiry_date.strftime('%d %b %Y') }} |                             {{ test.end_date.strftime('%d %b %Y %H:%M') }} | ||||||
|                         </td> |                         </td> | ||||||
|                         <td> |                         <td> | ||||||
|                             {% if test.time_limit == None -%} |                             {% if test.time_limit == None -%} | ||||||
| @@ -61,7 +61,7 @@ | |||||||
|                             <a |                             <a | ||||||
|                                 href="#" |                                 href="#" | ||||||
|                                 class="btn btn-primary test-action" |                                 class="btn btn-primary test-action" | ||||||
|                                 data-_id="{{test._id}}" |                                 data-id="{{test.id}}" | ||||||
|                                 title="Edit Exam" |                                 title="Edit Exam" | ||||||
|                                 data-action="edit" |                                 data-action="edit" | ||||||
|                             > |                             > | ||||||
| @@ -70,7 +70,7 @@ | |||||||
|                             <a |                             <a | ||||||
|                                 href="#" |                                 href="#" | ||||||
|                                 class="btn btn-danger test-action" |                                 class="btn btn-danger test-action" | ||||||
|                                 data-_id="{{test._id}}" |                                 data-id="{{test.id}}" | ||||||
|                                 title="Delete Exam" |                                 title="Delete Exam" | ||||||
|                                 data-action="delete" |                                 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 import FlaskForm | ||||||
| from flask_wtf.file import FileField, FileRequired, FileAllowed | from flask_wtf.file import FileAllowed, FileField, FileRequired | ||||||
| from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField, IntegerField | from wtforms import BooleanField, IntegerField, PasswordField, SelectField, StringField | ||||||
| from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional | from wtforms.fields import DateTimeLocalField | ||||||
| from datetime import date, timedelta | 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)]) |     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.')]) |     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}) |     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)]) |     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)]) |     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 = 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.')]) |     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)]) |     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)]) |     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 = 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.')]) |     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)]) |     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)]) |     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.')]) |     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.')]) |     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}) |     notify = BooleanField('Notify deletion by email', render_kw={'checked': True}) | ||||||
| 
 | 
 | ||||||
| class UpdateUserForm(FlaskForm): | class UpdateUser(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.')]) |     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)]) |     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 = 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.')]) |     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}) |     notify = BooleanField('Notify changes by email', render_kw={'checked': True}) | ||||||
| 
 | 
 | ||||||
| class UpdateAccountForm(FlaskForm): | class UpdateAccount(FlaskForm): | ||||||
|     password_confirm = PasswordField('Current Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) |     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)]) |     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 = 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.')]) |     password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')]) | ||||||
| 
 | 
 | ||||||
| class CreateTest(FlaskForm): | class CreateTest(FlaskForm): | ||||||
|     start_date = DateField('Start Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() ) |     start_date = DateTimeLocalField('Start Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()], default = datetime.now() ) | ||||||
|     expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) ) |     expiry_date = DateTimeLocalField('Expiry Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()], default = date.today() + timedelta(days=1) ) | ||||||
|     time_limit = SelectField('Time Limit') |     time_limit = SelectField('Time Limit') | ||||||
|     dataset = SelectField('Question Dataset') |     dataset = SelectField('Question Dataset') | ||||||
| 
 | 
 | ||||||
| class UploadDataForm(FlaskForm): | class UploadData(FlaskForm): | ||||||
|     data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) |     data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) | ||||||
|     default = BooleanField('Make Default', render_kw={'checked': True}) |     default = BooleanField('Make Default', render_kw={'checked': True}) | ||||||
| 
 | 
 | ||||||
| class AddTimeAdjustment(FlaskForm): | 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 flask_wtf import FlaskForm | ||||||
| from wtforms import StringField, PasswordField, BooleanField | from wtforms import StringField | ||||||
| from wtforms.validators import InputRequired, Email, Length, Optional | from wtforms.validators import InputRequired, Length, Email, Optional | ||||||
| 
 | 
 | ||||||
| class StartQuiz(FlaskForm): | class StartQuiz(FlaskForm): | ||||||
|     first_name = StringField('First Name(s)', validators=[InputRequired(), Length(max=30)]) |     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
									
								
							
							
						
						
							
								
								
									
										2
									
								
								ref-test/app/quiz/static/js/jquery-3.6.0.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -15,6 +15,10 @@ $("input[name='bg-select']").change(function(){ | |||||||
|     set_bg_colour($choice); |     set_bg_colour($choice); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
|  | $(".bg-select-area").click(function(event){ | ||||||
|  |     $(this).find("input[name='bg-select']").prop("checked", true).change(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
| $("#btn-toggle-navigator").click(function(event){ | $("#btn-toggle-navigator").click(function(event){ | ||||||
|     check_answered(); |     check_answered(); | ||||||
|     update_navigator(); |     update_navigator(); | ||||||
| @@ -84,7 +88,7 @@ $(".btn-dummy").click(function(event){ | |||||||
| $("#navigator-container").on("click", ".q-navigator-button", function(event){ | $("#navigator-container").on("click", ".q-navigator-button", function(event){ | ||||||
|     check_answered(); |     check_answered(); | ||||||
|     update_navigator(); |     update_navigator(); | ||||||
|     current_question = parseInt($(this).attr("name")); |     current_question = parseInt($(this).prop("name")); | ||||||
|     $quiz_navigator.fadeOut(); |     $quiz_navigator.fadeOut(); | ||||||
|     $quiz_render.fadeIn(); |     $quiz_render.fadeIn(); | ||||||
|     $question_title.focus(); |     $question_title.focus(); | ||||||
| @@ -99,16 +103,16 @@ $("#navigator-container").on("click", ".q-navigator-button", function(event){ | |||||||
| $(".q-question-nav").click(function(event){ | $(".q-question-nav").click(function(event){ | ||||||
|     check_answered(); |     check_answered(); | ||||||
|     update_navigator(); |     update_navigator(); | ||||||
|     if ($(this).attr("id") == "q-nav-next") { |     if ($(this).prop("id") == "q-nav-next") { | ||||||
|         if (current_question < questions.length) { |         if (current_question < questions.length) { | ||||||
|             current_question ++; |             current_question ++; | ||||||
|         } |         } | ||||||
|     } else if ($(this).attr("id") == "q-nav-prev") { |     } else if ($(this).prop("id") == "q-nav-prev") { | ||||||
|         if (current_question > 0) { |         if (current_question > 0) { | ||||||
|             current_question --; |             current_question --; | ||||||
|         } |         } | ||||||
|     } else if ($(this).hasClass("q-navigator-button")) { |     } else if ($(this).hasClass("q-navigator-button")) { | ||||||
|         current_question = $(this).attr("name"); |         current_question = $(this).prop("name"); | ||||||
|         $quiz_render.fadeIn(); |         $quiz_render.fadeIn(); | ||||||
|         $quiz_navigator.fadeOut(); |         $quiz_navigator.fadeOut(); | ||||||
|         toggle_navigator = false; |         toggle_navigator = false; | ||||||
| @@ -123,11 +127,11 @@ $("#q-nav-flag").click(function(event){ | |||||||
|     if (question_status[current_question] != 1) { |     if (question_status[current_question] != 1) { | ||||||
|         question_status[current_question] = 1; |         question_status[current_question] = 1; | ||||||
|         $(this).removeClass().addClass("btn btn-warning"); |         $(this).removeClass().addClass("btn btn-warning"); | ||||||
|         $(this).attr("title", "Question Flagged for revision. Click to un-flag."); |         $(this).prop("title", "Question Flagged for revision. Click to un-flag."); | ||||||
|     } else { |     } else { | ||||||
|         question_status[current_question] = 0; |         question_status[current_question] = 0; | ||||||
|         $(this).removeClass().addClass("btn btn-secondary"); |         $(this).removeClass().addClass("btn btn-secondary"); | ||||||
|         $(this).attr("title", "Question Un-Flagged. Click to flag for revision."); |         $(this).prop("title", "Question Un-Flagged. Click to flag for revision."); | ||||||
|     } |     } | ||||||
|     window.localStorage.setItem('question_status', JSON.stringify(question_status)); |     window.localStorage.setItem('question_status', JSON.stringify(question_status)); | ||||||
|     update_navigator(); |     update_navigator(); | ||||||
| @@ -139,7 +143,7 @@ $("#btn-start-quiz").click(function(event){ | |||||||
|     $.ajax({ |     $.ajax({ | ||||||
|         url: `/api/questions/`, |         url: `/api/questions/`, | ||||||
|         type: 'POST', |         type: 'POST', | ||||||
|         data: JSON.stringify({'_id': _id}), |         data: JSON.stringify({'id': id}), | ||||||
|         contentType: "application/json", |         contentType: "application/json", | ||||||
|         success: function(response) { |         success: function(response) { | ||||||
|             $(this).fadeOut(); |             $(this).fadeOut(); | ||||||
| @@ -185,8 +189,8 @@ $("#btn-start-quiz").click(function(event){ | |||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| $("#quiz-question-options").on("change", ".quiz-option", function(event){ | $("#quiz-question-options").on("change", ".quiz-option", function(event){ | ||||||
|     $name = parseInt($(this).attr("name")); |     $name = parseInt($(this).prop("name")); | ||||||
|     $value = $(this).attr("value"); |     $value = $(this).prop("value"); | ||||||
|     answers[$name] = $value; |     answers[$name] = $value; | ||||||
|     window.localStorage.setItem('answers', JSON.stringify(answers)); |     window.localStorage.setItem('answers', JSON.stringify(answers)); | ||||||
| }); | }); | ||||||
| @@ -219,7 +223,7 @@ $("#q-review-answers").click(function(event){ | |||||||
| 
 | 
 | ||||||
| $(".quiz-button-submit").click(function(event){ | $(".quiz-button-submit").click(function(event){ | ||||||
|     let submission = { |     let submission = { | ||||||
|         '_id': _id, |         'id': id, | ||||||
|         'answers': answers |         'answers': answers | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @@ -364,13 +368,13 @@ function render_question() { | |||||||
|     for (let i = 0; i < options.length; i ++) { |     for (let i = 0; i < options.length; i ++) { | ||||||
|         var add_checked = '' |         var add_checked = '' | ||||||
|         if (q_no in answers) { |         if (q_no in answers) { | ||||||
|             if (answers[q_no] == options[i]) { |             if (answers[q_no] == options[i][0]) { | ||||||
|                 add_checked = 'checked'; |                 add_checked = 'checked'; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|         options_output += `<div class="form-check">
 |         options_output += `<div class="form-check">
 | ||||||
|             <input type="radio" class="form-check-input quiz-option" id="q${current_question}-${i}" name="${q_no}" value="${options[i]}" ${add_checked}> |             <input type="radio" class="form-check-input quiz-option" id="q${current_question}-${i}" name="${q_no}" value="${options[i][0]}" ${add_checked}> | ||||||
|             <label for="q${current_question}-${i}" class="form-check-label">${options[i]}</label> |             <label for="q${current_question}-${i}" class="form-check-label">${options[i][1]}</label> | ||||||
|         </div>`; |         </div>`; | ||||||
|     } |     } | ||||||
|     $question_options.html(options_output); |     $question_options.html(options_output); | ||||||
| @@ -378,8 +382,8 @@ function render_question() { | |||||||
|     let answered = count_questions(2); |     let answered = count_questions(2); | ||||||
|     let flagged = count_questions(1); |     let flagged = count_questions(1); | ||||||
| 
 | 
 | ||||||
|     $progress_skipped.attr('title', `Skipped: ${skipped}`); |     $progress_skipped.prop('title', `Skipped: ${skipped}`); | ||||||
|     $progress_skipped.attr('aria-valuenow', skipped); |     $progress_skipped.prop('aria-valuenow', skipped); | ||||||
|     $progress_skipped.css('width', `${skipped}%`); |     $progress_skipped.css('width', `${skipped}%`); | ||||||
|     $skipped_count.text(`Skipped: ${skipped}`); |     $skipped_count.text(`Skipped: ${skipped}`); | ||||||
|     if (skipped < 1) { |     if (skipped < 1) { | ||||||
| @@ -388,8 +392,8 @@ function render_question() { | |||||||
|         $skipped_count.fadeIn() |         $skipped_count.fadeIn() | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     $progress_flagged.attr('title', `Flagged: ${flagged}`); |     $progress_flagged.prop('title', `Flagged: ${flagged}`); | ||||||
|     $progress_flagged.attr('aria-valuenow', flagged); |     $progress_flagged.prop('aria-valuenow', flagged); | ||||||
|     $progress_flagged.css('width', `${flagged}%`); |     $progress_flagged.css('width', `${flagged}%`); | ||||||
|     $flagged_count.text(`Flagged: ${flagged}`); |     $flagged_count.text(`Flagged: ${flagged}`); | ||||||
|     if (flagged < 1) { |     if (flagged < 1) { | ||||||
| @@ -398,8 +402,8 @@ function render_question() { | |||||||
|         $flagged_count.fadeIn() |         $flagged_count.fadeIn() | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     $progress_answered.attr('title', `Answered: ${answered}`); |     $progress_answered.prop('title', `Answered: ${answered}`); | ||||||
|     $progress_answered.attr('aria-valuenow', answered); |     $progress_answered.prop('aria-valuenow', answered); | ||||||
|     $progress_answered.css('width', `${answered}%`); |     $progress_answered.css('width', `${answered}%`); | ||||||
|     $answered_count.text(`Answered: ${answered}`); |     $answered_count.text(`Answered: ${answered}`); | ||||||
|     if (answered < 1) { |     if (answered < 1) { | ||||||
| @@ -433,19 +437,19 @@ function check_flag() { | |||||||
|     switch (question_status[current_question]) { |     switch (question_status[current_question]) { | ||||||
|         case -1: |         case -1: | ||||||
|             $nav_flag.removeClass().addClass('btn btn-danger progress-bar-striped'); |             $nav_flag.removeClass().addClass('btn btn-danger progress-bar-striped'); | ||||||
|             $nav_flag.attr("title", "Question Incomplete. Click to flag for revision."); |             $nav_flag.prop("title", "Question Incomplete. Click to flag for revision."); | ||||||
|             break; |             break; | ||||||
|         case 1: |         case 1: | ||||||
|             $nav_flag.removeClass().addClass('btn btn-warning'); |             $nav_flag.removeClass().addClass('btn btn-warning'); | ||||||
|             $nav_flag.attr("title", "Question Flagged for revision. Click to un-flag."); |             $nav_flag.prop("title", "Question Flagged for revision. Click to un-flag."); | ||||||
|             break; |             break; | ||||||
|         case 2: |         case 2: | ||||||
|             $nav_flag.removeClass().addClass('btn btn-success'); |             $nav_flag.removeClass().addClass('btn btn-success'); | ||||||
|             $nav_flag.attr("title", "Question Answered. Click to flag for revision."); |             $nav_flag.prop("title", "Question Answered. Click to flag for revision."); | ||||||
|             break; |             break; | ||||||
|         default: |         default: | ||||||
|             $nav_flag.removeClass().addClass('btn btn-secondary'); |             $nav_flag.removeClass().addClass('btn btn-secondary'); | ||||||
|             $nav_flag.attr("title", "Question Un-Flagged. Click to flag for revision."); |             $nav_flag.prop("title", "Question Un-Flagged. Click to flag for revision."); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @@ -486,19 +490,19 @@ function update_navigator() { | |||||||
|         switch (question_status[current_question]) { |         switch (question_status[current_question]) { | ||||||
|             case -1: |             case -1: | ||||||
|                 button.removeClass().addClass("q-navigator-button btn btn-danger progress-bar-striped"); |                 button.removeClass().addClass("q-navigator-button btn btn-danger progress-bar-striped"); | ||||||
|                 button.attr("title", `Question ${current_question + 1}: Incomplete`); |                 button.prop("title", `Question ${current_question + 1}: Incomplete`); | ||||||
|                 break; |                 break; | ||||||
|             case 1: |             case 1: | ||||||
|                 button.removeClass().addClass("q-navigator-button btn btn-warning"); |                 button.removeClass().addClass("q-navigator-button btn btn-warning"); | ||||||
|                 button.attr("title", `Question ${current_question + 1}: Flagged`); |                 button.prop("title", `Question ${current_question + 1}: Flagged`); | ||||||
|                 break; |                 break; | ||||||
|             case 2: |             case 2: | ||||||
|                 button.removeClass().addClass("q-navigator-button btn btn-success"); |                 button.removeClass().addClass("q-navigator-button btn btn-success"); | ||||||
|                 button.attr("title", `Question ${current_question + 1}: Answered`); |                 button.prop("title", `Question ${current_question + 1}: Answered`); | ||||||
|                 break; |                 break; | ||||||
|             default: |             default: | ||||||
|                 button.removeClass().addClass("q-navigator-button btn btn-secondary disabled"); |                 button.removeClass().addClass("q-navigator-button btn btn-secondary disabled"); | ||||||
|                 button.attr("title", `Question ${current_question + 1}: Unseen`); |                 button.prop("title", `Question ${current_question + 1}: Unseen`); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -603,7 +607,7 @@ function count_questions(status) { | |||||||
| 
 | 
 | ||||||
| // Variable Definitions
 | // Variable Definitions
 | ||||||
| 
 | 
 | ||||||
| const _id = window.localStorage.getItem('_id'); | const id = window.localStorage.getItem('id'); | ||||||
| 
 | 
 | ||||||
| var current_question = 0; | var current_question = 0; | ||||||
| var total_questions = 0; | var total_questions = 0; | ||||||
| @@ -23,9 +23,9 @@ $('form[name=form-quiz-start]').submit(function(event) { | |||||||
|         data: data, |         data: data, | ||||||
|         dataType: 'json', |         dataType: 'json', | ||||||
|         success: function(response) { |         success: function(response) { | ||||||
|             var _id = response._id |             var id = response.id | ||||||
|             window.localStorage.setItem('_id', _id); |             window.localStorage.setItem('id', id); | ||||||
|             window.location.href = `/test/`; |             window.location.href = `/quiz/`; | ||||||
|         }, |         }, | ||||||
|         error: function(response) { |         error: function(response) { | ||||||
|             error_response(response); |             error_response(response); | ||||||
| @@ -68,7 +68,7 @@ $('#dismiss-cookie-alert').click(function(event){ | |||||||
| 
 | 
 | ||||||
|     $.ajax({ |     $.ajax({ | ||||||
|         url: '/cookies/', |         url: '/cookies/', | ||||||
|         type: 'GET', |         type: 'POST', | ||||||
|         data: { |         data: { | ||||||
|             time: Date.now() |             time: Date.now() | ||||||
|         }, |         }, | ||||||
| @@ -74,43 +74,43 @@ | |||||||
|                 <div class="row gx-5 gy-5 mt-1"> |                 <div class="row gx-5 gy-5 mt-1"> | ||||||
|                     <div class="col"> |                     <div class="col"> | ||||||
|                         <h5>Select Background Colour</h5> |                         <h5>Select Background Colour</h5> | ||||||
|                         <div class="p-3 bg-light text-dark"> |                         <div class="p-3 bg-light text-dark bg-select-area"> | ||||||
|                             <div class="form-check"> |                             <div class="form-check"> | ||||||
|                                 <input type="radio" class="form-check-input" id="bg-light" name="bg-select" value="bg-light" checked> |                                 <input type="radio" class="form-check-input" id="bg-light" name="bg-select" value="bg-light" checked> | ||||||
|                                 <label for="bg-light" class="form-check-label">Default Light</label> |                                 <label for="bg-light" class="form-check-label">Default Light</label> | ||||||
|                             </div> |                             </div> | ||||||
|                         </div> |                         </div> | ||||||
|                         <div class="p-3 q-bg-light-1 text-dark"> |                         <div class="p-3 q-bg-light-1 text-dark bg-select-area"> | ||||||
|                             <div class="form-check"> |                             <div class="form-check"> | ||||||
|                                 <input type="radio" class="form-check-input" id="q-bg-light-1" name="bg-select" value="q-bg-light-1"> |                                 <input type="radio" class="form-check-input" id="q-bg-light-1" name="bg-select" value="q-bg-light-1"> | ||||||
|                                 <label for="q-bg-light-1" class="form-check-label">Light Shade 1</label> |                                 <label for="q-bg-light-1" class="form-check-label">Light Shade 1</label> | ||||||
|                             </div> |                             </div> | ||||||
|                         </div> |                         </div> | ||||||
|                         <div class="p-3 q-bg-light-2 text-dark"> |                         <div class="p-3 q-bg-light-2 text-dark bg-select-area"> | ||||||
|                             <div class="form-check"> |                             <div class="form-check"> | ||||||
|                                 <input type="radio" class="form-check-input" id="q-bg-light-2" name="bg-select" value="q-bg-light-2"> |                                 <input type="radio" class="form-check-input" id="q-bg-light-2" name="bg-select" value="q-bg-light-2"> | ||||||
|                                 <label for="q-bg-light-2" class="form-check-label">Light Shade 2</label> |                                 <label for="q-bg-light-2" class="form-check-label">Light Shade 2</label> | ||||||
|                             </div> |                             </div> | ||||||
|                         </div> |                         </div> | ||||||
|                         <div class="p-3 alert-primary text-dark"> |                         <div class="p-3 alert-primary text-dark bg-select-area"> | ||||||
|                             <div class="form-check"> |                             <div class="form-check"> | ||||||
|                                 <input type="radio" class="form-check-input" id="alert-primary" name="bg-select" value="alert-primary"> |                                 <input type="radio" class="form-check-input" id="alert-primary" name="bg-select" value="alert-primary"> | ||||||
|                                 <label for="alert-primary" class="form-check-label">Blue</label> |                                 <label for="alert-primary" class="form-check-label">Blue</label> | ||||||
|                             </div> |                             </div> | ||||||
|                         </div> |                         </div> | ||||||
|                         <div class="p-3 alert-secondary text-dark"> |                         <div class="p-3 alert-secondary text-dark bg-select-area"> | ||||||
|                             <div class="form-check"> |                             <div class="form-check"> | ||||||
|                                 <input type="radio" class="form-check-input" id="alert-secondary" name="bg-select" value="alert-secondary"> |                                 <input type="radio" class="form-check-input" id="alert-secondary" name="bg-select" value="alert-secondary"> | ||||||
|                                 <label for="alert-secondary" class="form-check-label">Grey 1</label> |                                 <label for="alert-secondary" class="form-check-label">Grey 1</label> | ||||||
|                             </div> |                             </div> | ||||||
|                         </div> |                         </div> | ||||||
|                         <div class="p-3 alert-dark text-dark"> |                         <div class="p-3 alert-dark text-dark bg-select-area"> | ||||||
|                             <div class="form-check"> |                             <div class="form-check"> | ||||||
|                                 <input type="radio" class="form-check-input" id="alert-dark" name="bg-select" value="alert-dark"> |                                 <input type="radio" class="form-check-input" id="alert-dark" name="bg-select" value="alert-dark"> | ||||||
|                                 <label for="alert-dark" class="form-check-label">Grey 2</label> |                                 <label for="alert-dark" class="form-check-label">Grey 2</label> | ||||||
|                             </div> |                             </div> | ||||||
|                         </div> |                         </div> | ||||||
|                         <div class="p-3 bg-dark text-light"> |                         <div class="p-3 bg-dark text-light bg-select-area"> | ||||||
|                             <div class="form-check"> |                             <div class="form-check"> | ||||||
|                                 <input type="radio" class="form-check-input" id="bg-dark" name="bg-select" value="bg-dark"> |                                 <input type="radio" class="form-check-input" id="bg-dark" name="bg-select" value="bg-dark"> | ||||||
|                                 <label for="bg-dark" class="form-check-label">Dark</label> |                                 <label for="bg-dark" class="form-check-label">Dark</label> | ||||||
| @@ -43,6 +43,9 @@ | |||||||
|             integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" |             integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" | ||||||
|             crossorigin="anonymous"> |             crossorigin="anonymous"> | ||||||
|         </script> |         </script> | ||||||
|  |         <script> | ||||||
|  |             window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`) | ||||||
|  |         </script> | ||||||
|         <script |         <script | ||||||
|             src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js" |             src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js" | ||||||
|             integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB" |             integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB" | ||||||
| @@ -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. |         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> |     </p> | ||||||
|     <div class="button-container"> |     <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> |             <i class="bi bi-book-fill button-icon"></i> | ||||||
|             Read the Instructions |             Read the Instructions | ||||||
|         </a> |         </a> | ||||||
| @@ -53,7 +53,7 @@ | |||||||
|         </p> |         </p> | ||||||
|     </div> |     </div> | ||||||
|     <div class="button-container"> |     <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> |             <i class="bi bi-pencil-fill button-icon"></i> | ||||||
|             Take the Exam |             Take the Exam | ||||||
|         </a> |         </a> | ||||||
| @@ -6,13 +6,13 @@ | |||||||
|     <h2>Candidate Results</h2> |     <h2>Candidate Results</h2> | ||||||
| 
 | 
 | ||||||
|     <h3 class="results-name"> |     <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> |     </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 %} |     {% if entry.club %} | ||||||
|         <strong class="results-details">Club</strong>: {{ entry.club }} <br /> |         <strong class="results-details">Club</strong>: {{ entry.get_club() }} <br /> | ||||||
|     {% endif%} |     {% endif%} | ||||||
| 
 | 
 | ||||||
|     {% if entry.status == 'late' %} |     {% if entry.status == 'late' %} | ||||||
| @@ -26,11 +26,11 @@ | |||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
|         <div class="results-grade"> |         <div class="results-grade"> | ||||||
|             {{ entry.results.grade[0]|upper }}{{ entry.results.grade[1:] }} |             {{ entry.result.grade[0]|upper }}{{ entry.result.grade[1:] }} | ||||||
|         </div> |         </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 revise the following topics: |             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> |             <ul> | ||||||
|                 {% for tag in tag_output %} |                 {% for tag in tag_output %} | ||||||
							
								
								
									
										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 %} | {% block content %} | ||||||
|     <h1>Page Not Found</h1> |     <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" /> | ||||||