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