mirror of
				https://gitlab.crans.org/bde/nk20
				synced 2025-11-04 09:12:11 +01:00 
			
		
		
		
	Compare commits
	
		
			1326 Commits
		
	
	
		
			3fea17c555
			...
			fix_activi
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					0d0fdef363 | ||
| 
						 | 
					821efbf78b | ||
| 
						 | 
					a209e0d366 | ||
| 
						 | 
					ef485e0628 | ||
| 
						 | 
					1481aa0635 | ||
| 
						 | 
					867bf9fd25 | ||
| 
						 | 
					47fda0ea36 | ||
| 
						 | 
					623290827a | ||
| 
						 | 
					a87ce625f3 | ||
| 
						 | 
					3559787fa7 | ||
| 
						 | 
					bd6ed27ae5 | ||
| 
						 | 
					43dc676747 | ||
| 
						 | 
					caaeab6b0b | ||
| 
						 | 
					54ba786884 | ||
| 
						 | 
					80e109114f | ||
| 
						 | 
					787005e60d | ||
| 
						 | 
					414e103686 | ||
| 
						 | 
					942d887c2e | ||
| 
						 | 
					a63c34fe37 | ||
| 
						 | 
					2be6133458 | ||
| 
						 | 
					7975fe47a6 | ||
| 
						 | 
					476fbceeea | ||
| 
						 | 
					8fbaa0bdc8 | ||
| 
						 | 
					a0de63effd | ||
| 
						 | 
					09fb1d227e | ||
| 
						 | 
					2e27d4f05c | ||
| 
						 | 
					5d16dc4e7d | ||
| 
						 | 
					3c34033bf5 | ||
| 
						 | 
					131f508433 | ||
| 
						 | 
					c1a353963a | ||
| 
						 | 
					178ce2b579 | ||
| 
						 | 
					9162319734 | ||
| 
						 | 
					5d2a8e9b79 | ||
| 
						 | 
					33c94d0720 | ||
| 
						 | 
					5040e8e8ea | ||
| 
						 | 
					c5697c4cb4 | ||
| 
						 | 
					e188c5a153 | ||
| 
						 | 
					94e1fdc93a | ||
| 
						 | 
					d1ef367bab | ||
| 
						 | 
					0fbb19c5fd | ||
| 
						 | 
					21cbf2b21a | ||
| 
						 | 
					185a2cabf2 | ||
| 
						 | 
					7552e55c8d | ||
| 
						 | 
					361de9f8b4 | ||
| 
						 | 
					e2426bd6a6 | ||
| 
						 | 
					7fea619a9f | ||
| 
						 | 
					7b5eefcc0a | ||
| 
						 | 
					e4aa16986f | ||
| 
						 | 
					b92e6e4e10 | ||
| 
						 | 
					dd675b3676 | ||
| 
						 | 
					f50849b4f8 | ||
| 
						 | 
					73ff35c232 | ||
| 
						 | 
					a5df98224f | ||
| 
						 | 
					2cb9ac8735 | ||
| 
						 | 
					35d4849a28 | ||
| 
						 | 
					96539d262f | ||
| 
						 | 
					946674f59b | ||
| 
						 | 
					a201d8376a | ||
| 
						 | 
					a21b9275ea | ||
| 
						 | 
					d4e85e8215 | ||
| 
						 | 
					7af2ebba40 | ||
| 
						 | 
					bd94400883 | ||
| 
						 | 
					5558341c8c | ||
| 
						 | 
					35ef82223c | ||
| 
						 | 
					9ccac36831 | ||
| 
						 | 
					2e71ce05a9 | ||
| 
						 | 
					f2cb10b69f | ||
| 
						 | 
					24c4edf2e3 | ||
| 
						 | 
					213e9a8b12 | ||
| 
						 | 
					2c56178b15 | ||
| 
						 | 
					48a5b04579 | ||
| 
						 | 
					2ab5c4082a | ||
| 
						 | 
					053225c6dc | ||
| 
						 | 
					ac7b86651d | ||
| 
						 | 
					21f5a5d566 | ||
| 
						 | 
					ff9c78ed4e | ||
| 
						 | 
					1e121297d1 | ||
| 
						 | 
					549f56dc0b | ||
| 
						 | 
					debeb33d46 | ||
| 
						 | 
					6d7076b03e | ||
| 
						 | 
					196df1e775 | ||
| 
						 | 
					28117c8c61 | ||
| 
						 | 
					0d9891fbd8 | ||
| 
						 | 
					4be4a18dd1 | ||
| 
						 | 
					27b00ba4f0 | ||
| 
						 | 
					3fcbb4f310 | ||
| 
						 | 
					d1c9a2a7f1 | ||
| 
						 | 
					a673fd6871 | ||
| 
						 | 
					a324d3a892 | ||
| 
						 | 
					951ba74f8f | ||
| 
						 | 
					abc4f14bd1 | ||
| 
						 | 
					47138bafd4 | ||
| 
						 | 
					a3920fcae3 | ||
| 
						 | 
					ae4213d087 | ||
| 
						 | 
					b2b1f03b46 | ||
| 
						 | 
					1c5ed2bd3f | ||
| 
						 | 
					a7e87ea639 | ||
| 
						 | 
					cbf92651f0 | ||
| 
						 | 
					12c93ff9da | ||
| 
						 | 
					354c79bb82 | ||
| 
						 | 
					1ea7b3dda1 | ||
| 
						 | 
					35ffbfcf55 | ||
| 
						 | 
					162371042c | ||
| 
						 | 
					581715d804 | ||
| 
						 | 
					c7c6f0350f | ||
| 
						 | 
					9d1024024b | ||
| d595d908c6 | |||
| 734f5b242d | |||
| b0c7d43a50 | |||
| 
						 | 
					6f67d2c629 | ||
| 
						 | 
					4b97ab2e2a | ||
| 
						 | 
					dcfd0167e7 | ||
| 
						 | 
					50a680eed2 | ||
| 
						 | 
					226a2a6357 | ||
| 
						 | 
					48462f2ffc | ||
| 
						 | 
					260513ae3b | ||
| 
						 | 
					210a3cc93c | ||
| 
						 | 
					896095a44c | ||
| 
						 | 
					3f997f94fa | ||
| 
						 | 
					0801ad64ae | ||
| 
						 | 
					64bd5ed546 | ||
| 
						 | 
					4c390dce17 | ||
| 
						 | 
					adacc293f5 | ||
| 
						 | 
					968fa64d37 | ||
| 
						 | 
					a481adbae4 | ||
| 
						 | 
					4de2e987ef | ||
| 
						 | 
					9e6342c929 | ||
| 
						 | 
					74de358953 | ||
| 
						 | 
					7322d55789 | ||
| 1a258dfe9e | |||
| 
						 | 
					b8f81048a5 | ||
| 
						 | 
					af819f45a1 | ||
| 
						 | 
					076d065ffa | ||
| 
						 | 
					2da77d9c17 | ||
| 
						 | 
					01584d6330 | ||
| 
						 | 
					4c0a5922c4 | ||
| 
						 | 
					f90b28fc7c | ||
| 
						 | 
					bbbdcc7247 | ||
| 
						 | 
					925e0f26f5 | ||
| 
						 | 
					feeb99041f | ||
| 
						 | 
					c912383f86 | ||
| 
						 | 
					32830e43fd | ||
| 
						 | 
					11c6a6fa7a | ||
| 
						 | 
					201d6b114a | ||
| 
						 | 
					19e77df299 | ||
| 
						 | 
					5fd6ec5668 | ||
| 
						 | 
					10a01c5bc2 | ||
| 
						 | 
					989905ea64 | ||
| 
						 | 
					0218d43a17 | ||
| 
						 | 
					5d30b0e819 | ||
| 
						 | 
					ec759dd3c0 | ||
| 
						 | 
					2eb965291d | ||
| 
						 | 
					7f182ee2ee | ||
| 
						 | 
					3132aa4c38 | ||
| 
						 | 
					c7eb774859 | ||
| 
						 | 
					32f8d285b3 | ||
| 
						 | 
					050256ea13 | ||
| 
						 | 
					7afd15b1cc | ||
| 
						 | 
					258361f116 | ||
| 
						 | 
					a307530579 | ||
| 
						 | 
					5de930bf40 | ||
| 
						 | 
					f7ebe0e99b | ||
| 
						 | 
					73de6e2176 | ||
| 
						 | 
					201611b105 | ||
| 
						 | 
					40c239e9da | ||
| 
						 | 
					2aaab2b454 | ||
| 
						 | 
					fc088dec86 | ||
| 
						 | 
					2d60f1fd7b | ||
| 
						 | 
					7b48b09329 | ||
| 
						 | 
					ffac940511 | ||
| 
						 | 
					50f98fd5ad | ||
| 
						 | 
					402e19d1ce | ||
| 
						 | 
					0b0394b61f | ||
| 
						 | 
					98422d8259 | ||
| 
						 | 
					29509b5b26 | ||
| 
						 | 
					0d64ad31e0 | ||
| 
						 | 
					5781cbd6a5 | ||
| 
						 | 
					5295e61a00 | ||
| 
						 | 
					e79ed6226a | ||
| 
						 | 
					68152e6354 | ||
| 
						 | 
					6c61daf1c5 | ||
| 
						 | 
					b8cc297baf | ||
| 
						 | 
					cd8224f2e0 | ||
| 
						 | 
					3c882a7854 | ||
| 
						 | 
					357e1bbaa2 | ||
| 
						 | 
					f5c4c58525 | ||
| 
						 | 
					dafb602b08 | ||
| 
						 | 
					5b377e6a75 | ||
| 
						 | 
					28bd62531e | ||
| 
						 | 
					b3a31c27a5 | ||
| 
						 | 
					c7a8e6a1a5 | ||
| 
						 | 
					546a3a72b1 | ||
| 
						 | 
					2e5664f79d | ||
| 
						 | 
					e367666fe9 | ||
| 
						 | 
					04a9b3daf0 | ||
| 
						 | 
					d1df8f3eac | ||
| 
						 | 
					a5221f66ef | ||
| 
						 | 
					7d59cd6cd2 | ||
| 
						 | 
					96215cc1ff | ||
| 
						 | 
					b7a71d911d | ||
| 
						 | 
					2ee7f41dfe | ||
| 
						 | 
					fb3337966e | ||
| 
						 | 
					0db0474217 | ||
| 
						 | 
					2b3eb15f59 | ||
| 
						 | 
					399a32bece | ||
| 
						 | 
					82fea65b5e | ||
| 
						 | 
					abc88d0118 | ||
| 
						 | 
					b6b81a8b8f | ||
| 
						 | 
					d228dbf225 | ||
| 
						 | 
					a6b479db19 | ||
| 
						 | 
					048d251f75 | ||
| 
						 | 
					7b11cb0797 | ||
| 
						 | 
					516a7f4be5 | ||
| 
						 | 
					2f8c9b54e7 | ||
| 
						 | 
					e9f18c3ed9 | ||
| 
						 | 
					ff3c30517e | ||
| 
						 | 
					f481ea6acb | ||
| 
						 | 
					802fd8c2d7 | ||
| 
						 | 
					5209a586a9 | ||
| 
						 | 
					24f54ac876 | ||
| 
						 | 
					988b4c9e88 | ||
| 
						 | 
					e32c267995 | ||
| 
						 | 
					5e39209ab1 | ||
| 
						 | 
					08b2fabe07 | ||
| 
						 | 
					405479e5ad | ||
| 
						 | 
					0cc130092f | ||
| 
						 | 
					ff6e207512 | ||
| 
						 | 
					0f1e4d2e60 | ||
| 
						 | 
					6255bcbbb1 | ||
| 
						 | 
					d82a1001c4 | ||
| 
						 | 
					31a54482f0 | ||
| 
						 | 
					4ee02345d4 | ||
| 
						 | 
					422c087d17 | ||
| 
						 | 
					30d6e2c95e | ||
| 
						 | 
					f3a3f07e38 | ||
| 
						 | 
					a5e802f370 | ||
| 
						 | 
					540f3bc354 | ||
| 
						 | 
					2d19457506 | ||
| 
						 | 
					72786d0d2b | ||
| 
						 | 
					f099cbc879 | ||
| 
						 | 
					977eb7c0d4 | ||
| 
						 | 
					d81b1f2710 | ||
| 
						 | 
					6a69590a82 | ||
| 
						 | 
					7afc583282 | ||
| 
						 | 
					4fb0b7d736 | ||
| 
						 | 
					18a5b65a1c | ||
| 
						 | 
					f545af4977 | ||
| 
						 | 
					103e2d0635 | ||
| 
						 | 
					aedf0e87ba | ||
| 
						 | 
					dab45b5fd4 | ||
| 
						 | 
					b3353b563c | ||
| 
						 | 
					6bc52be707 | ||
| 
						 | 
					834d68fe35 | ||
| 
						 | 
					c6a2849d35 | ||
| 
						 | 
					4ab22c92b3 | ||
| 
						 | 
					c328c1457c | ||
| 
						 | 
					96da7d01ae | ||
| 
						 | 
					d27f942339 | ||
| 
						 | 
					738d6c932d | ||
| 
						 | 
					1760196578 | ||
| 
						 | 
					13b9b6edea | ||
| 
						 | 
					e06e3b2972 | ||
| 
						 | 
					9596aa7b8c | ||
| 
						 | 
					ba0d64f0d4 | ||
| 
						 | 
					8d17801e28 | ||
| 
						 | 
					609362c4f8 | ||
| 
						 | 
					03d2d5f03e | ||
| 
						 | 
					d2057a9f45 | ||
| 
						 | 
					b6e68eeebe | ||
| 
						 | 
					6410542027 | ||
| 
						 | 
					6b1cd3ba7a | ||
| 
						 | 
					9f114b8ca2 | ||
| 
						 | 
					e0132b6dc8 | ||
| 
						 | 
					f1cc82fab3 | ||
| 
						 | 
					644cf14c4b | ||
| 
						 | 
					f19a489313 | ||
| 
						 | 
					dedd6c69cc | ||
| 
						 | 
					b42f5afeab | ||
| 
						 | 
					31e67ae3f6 | ||
| 
						 | 
					b08da7a727 | ||
| 
						 | 
					451aa64f33 | ||
| 
						 | 
					3c99b0f3e9 | ||
| 
						 | 
					201a179947 | ||
| 
						 | 
					96784aee3b | ||
| 
						 | 
					981c4d0300 | ||
| 
						 | 
					11223430fd | ||
| 
						 | 
					7aeb977e72 | ||
| 
						 | 
					52fef1df42 | ||
| 
						 | 
					16f8a60a3f | ||
| 
						 | 
					2839d3de1e | ||
| 
						 | 
					30afa6da0a | ||
| 
						 | 
					84fc77696f | ||
| 
						 | 
					19fc620d1f | ||
| 
						 | 
					d5819ac562 | ||
| 
						 | 
					a79df8f1f6 | ||
| 
						 | 
					364b18e188 | ||
| 
						 | 
					10a883b2e5 | ||
| 
						 | 
					1410ab6c4f | ||
| 
						 | 
					623dd61be6 | ||
| 
						 | 
					48a0a87e7c | ||
| 
						 | 
					563f525b11 | ||
| 
						 | 
					63c1d74f1a | ||
| 
						 | 
					c42fb380a6 | ||
| 
						 | 
					c636d52a73 | ||
| 
						 | 
					6a9021ec14 | ||
| 
						 | 
					9c9149b53a | ||
| 
						 | 
					cb74311e7b | ||
| 
						 | 
					9d7dd566c9 | ||
| 
						 | 
					6bceb394c5 | ||
| 
						 | 
					62cf8f9d84 | ||
| 
						 | 
					9944ebcaad | ||
| 
						 | 
					8537f043f7 | ||
| 
						 | 
					2dd1c3fb89 | ||
| 
						 | 
					c8665c5798 | ||
| 
						 | 
					e9f1b6f52d | ||
| 
						 | 
					1d95ae4810 | ||
| 
						 | 
					c89a95f8d2 | ||
| 
						 | 
					73640b1dfa | ||
| 
						 | 
					84b16ab603 | ||
| 
						 | 
					6a1b51dbbf | ||
| 
						 | 
					c441a43a8b | ||
| 
						 | 
					87f3b51b04 | ||
| 
						 | 
					0a853fd3e6 | ||
| 
						 | 
					c429734810 | ||
| 
						 | 
					5d759111b6 | ||
| 
						 | 
					70baf7566c | ||
| 
						 | 
					eb355f547c | ||
| 
						 | 
					7068170f18 | ||
| 
						 | 
					45ee9a8941 | ||
| 
						 | 
					454ea19603 | ||
| 5a77a66391 | |||
| 
						 | 
					
						
						
							
						
						761fc170eb
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						ac23d7eb54
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						40e7415062
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						319405d2b1
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						633ab88b04
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						e29b42eecc
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						dc69faaf1d
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						442a5c5e36
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						7ab0fec3bc
	
				 | 
					
					
						||
| 
						 | 
					bd4fb23351 | ||
| 
						 | 
					ee22e9b3b6 | ||
| 
						 | 
					19ae616fb4 | ||
| 
						 | 
					b7657ec362 | ||
| 
						 | 
					4d03d9460d | ||
| 3633f66a87 | |||
| d43fbe7ac6 | |||
| 
						 | 
					df5f9b5f1e | ||
| 
						
						
							
						
						4161248bff
	
				 | 
					
					
						|||
| 
						
						
							
						
						58136f3c48
	
				 | 
					
					
						|||
| 
						
						
							
						
						d9b4e0a9a9
	
				 | 
					
					
						|||
| 
						
						
							
						
						8563a8d235
	
				 | 
					
					
						|||
| 5f69232560 | |||
| 
						
						
							
						
						d3273e9ee2
	
				 | 
					
					
						|||
| 4e30f805a7 | |||
| 
						
						
							
						
						546e422e64
	
				 | 
					
					
						|||
| 
						
						
							
						
						9048a416df
	
				 | 
					
					
						|||
| 
						
						
							
						
						8578bd743c
	
				 | 
					
					
						|||
| 
						
						
							
						
						45a10dad00
	
				 | 
					
					
						|||
| 
						
						
							
						
						18a1282773
	
				 | 
					
					
						|||
| 
						
						
							
						
						132afc3d15
	
				 | 
					
					
						|||
| 
						
						
							
						
						6bf16a181a
	
				 | 
					
					
						|||
| 
						
						
							
						
						e20df82346
	
				 | 
					
					
						|||
| 1eb72044c2 | |||
| 
						
						
							
						
						f88eae924c
	
				 | 
					
					
						|||
| 
						
						
							
						
						4b6e3ba546
	
				 | 
					
					
						|||
| bf0fe3479f | |||
| 
						
						
							
						
						45ba4f9537
	
				 | 
					
					
						|||
| 
						
						
							
						
						b204805ce2
	
				 | 
					
					
						|||
| 
						
						
							
						
						2f28e34cec
	
				 | 
					
					
						|||
| 
						
						
							
						
						9c8ea2cd41
	
				 | 
					
					
						|||
| 41289857b2 | |||
| 
						
						
							
						
						28a8792c9f
	
				 | 
					
					
						|||
| 
						
						
							
						
						58cafad032
	
				 | 
					
					
						|||
| 
						
						
							
						
						7848cd9cc2
	
				 | 
					
					
						|||
| 
						
						
							
						
						d18ccfac23
	
				 | 
					
					
						|||
| 
						 | 
					e479e1e3a4 | ||
| 
						 | 
					82b0c83b1f | ||
| 
						
						
							
						
						38ca414ef6
	
				 | 
					
					
						|||
| 
						
						
							
						
						fd811053c7
	
				 | 
					
					
						|||
| 
						
						
							
						
						9d386d1ecf
	
				 | 
					
					
						|||
| 
						 | 
					0bd447b608 | ||
| 
						 | 
					3f3c93d928 | ||
| 
						 | 
					340c90f5d3 | ||
| ca2b9f061c | |||
| 
						
						
							
						
						a05dfcbf3d
	
				 | 
					
					
						|||
| 
						
						
							
						
						ba3c0fb18d
	
				 | 
					
					
						|||
| ab69963ea1 | |||
| 
						
						
							
						
						654c01631a
	
				 | 
					
					
						|||
| 
						
						
							
						
						d94cc2a7ad
	
				 | 
					
					
						|||
| 
						
						
							
						
						69bb38297f
	
				 | 
					
					
						|||
| 
						
						
							
						
						9628560d64
	
				 | 
					
					
						|||
| 
						
						
							
						
						df3bb71357
	
				 | 
					
					
						|||
| 
						
						
							
						
						2a216fd994
	
				 | 
					
					
						|||
| 
						
						
							
						
						8dd2619013
	
				 | 
					
					
						|||
| 
						
						
							
						
						62431a4910
	
				 | 
					
					
						|||
| 
						 | 
					946bc1e497 | ||
| 
						
						
							
						
						d4896bfd76
	
				 | 
					
					
						|||
| 
						
						
							
						
						23f46cc598
	
				 | 
					
					
						|||
| d1a9f21b56 | |||
| 
						
						
							
						
						d809b2595a
	
				 | 
					
					
						|||
| 97803ac983 | |||
| b951c4aa05 | |||
| 
						
						
							
						
						69b3d2ac9c
	
				 | 
					
					
						|||
| 
						
						
							
						
						f29054558a
	
				 | 
					
					
						|||
| 11dd8adbb7 | |||
| 
						
						
							
						
						d437f2bdbd
	
				 | 
					
					
						|||
| 
						
						
							
						
						ac8453b04c
	
				 | 
					
					
						|||
| 
						 | 
					6b4d18f4b3 | ||
| 
						 | 
					668cfa71a7 | ||
| 
						
						
							
						
						161db0b00b
	
				 | 
					
					
						|||
| 
						
						
							
						
						8638c16b34
	
				 | 
					
					
						|||
| 
						
						
							
						
						9583cec3ff
	
				 | 
					
					
						|||
| 
						
						
							
						
						1ef25924a0
	
				 | 
					
					
						|||
| 
						
						
							
						
						e89383e3f4
	
				 | 
					
					
						|||
| 
						
						
							
						
						79a116d9c6
	
				 | 
					
					
						|||
| 
						
						
							
						
						aa75ce5c7a
	
				 | 
					
					
						|||
| 
						
						
							
						
						a3a9dfc812
	
				 | 
					
					
						|||
| 
						
						
							
						
						76531595ad
	
				 | 
					
					
						|||
| 
						
						
							
						
						a0b920ac94
	
				 | 
					
					
						|||
| 
						
						
							
						
						ab2e580e68
	
				 | 
					
					
						|||
| 
						
						
							
						
						0234f19a33
	
				 | 
					
					
						|||
| 
						
						
							
						
						1a4b7c83e8
	
				 | 
					
					
						|||
| 
						
						
							
						
						4c17e2a92b
	
				 | 
					
					
						|||
| 
						
						
							
						
						e68afc7d0a
	
				 | 
					
					
						|||
| 
						
						
							
						
						c6e3b54f94
	
				 | 
					
					
						|||
| 7e6a14296a | |||
| 780f78b385 | |||
| 
						
						
							
						
						4e3c32eb5e
	
				 | 
					
					
						|||
| 
						
						
							
						
						ef118c2445
	
				 | 
					
					
						|||
| 
						
						
							
						
						600ba15faa
	
				 | 
					
					
						|||
| 
						
						
							
						
						944bb127e2
	
				 | 
					
					
						|||
| 
						
						
							
						
						f6d042c998
	
				 | 
					
					
						|||
| 
						
						
							
						
						bb9a0a2593
	
				 | 
					
					
						|||
| 
						
						
							
						
						61feac13c7
	
				 | 
					
					
						|||
| 
						
						
							
						
						81e708a7e3
	
				 | 
					
					
						|||
| 
						
						
							
						
						3532846c87
	
				 | 
					
					
						|||
| 
						
						
							
						
						49551e88f8
	
				 | 
					
					
						|||
| 
						
						
							
						
						db936bf75a
	
				 | 
					
					
						|||
| 5828a20383 | |||
| cea3138daf | |||
| 
						
						
							
						
						fb98d9cd8b
	
				 | 
					
					
						|||
| 
						
						
							
						
						0dd3da5c01
	
				 | 
					
					
						|||
| 
						
						
							
						
						af4be98b5b
	
				 | 
					
					
						|||
| 
						
						
							
						
						be6059eba6
	
				 | 
					
					
						|||
| 
						
						
							
						
						5793b83de7
	
				 | 
					
					
						|||
| 
						
						
							
						
						2c02c747f4
	
				 | 
					
					
						|||
| 
						
						
							
						
						a78f3b7caa
	
				 | 
					
					
						|||
| 
						
						
							
						
						1ee40cb94e
	
				 | 
					
					
						|||
| 
						
						
							
						
						bd035744a4
	
				 | 
					
					
						|||
| 
						
						
							
						
						7edd622755
	
				 | 
					
					
						|||
| 
						
						
							
						
						8fd5b6ee01
	
				 | 
					
					
						|||
| 
						
						
							
						
						03411ac9bd
	
				 | 
					
					
						|||
| 
						
						
							
						
						d965732b65
	
				 | 
					
					
						|||
| 
						
						
							
						
						048266ed61
	
				 | 
					
					
						|||
| 
						
						
							
						
						b27341009e
	
				 | 
					
					
						|||
| 
						
						
							
						
						da1e15c5e6
	
				 | 
					
					
						|||
| 
						
						
							
						
						4b03a78ad6
	
				 | 
					
					
						|||
| 
						
						
							
						
						fb6e3c3de0
	
				 | 
					
					
						|||
| 
						
						
							
						
						391f3bde8f
	
				 | 
					
					
						|||
| 
						
						
							
						
						ad04e45992
	
				 | 
					
					
						|||
| 
						
						
							
						
						4e1ba1447a
	
				 | 
					
					
						|||
| 
						
						
							
						
						b646f549d6
	
				 | 
					
					
						|||
| 
						
						
							
						
						ba9ef0371a
	
				 | 
					
					
						|||
| 
						
						
							
						
						881cd88f48
	
				 | 
					
					
						|||
| b4ed354b73 | |||
| 
						
						
							
						
						e5051ab018
	
				 | 
					
					
						|||
| 
						
						
							
						
						bb69627ac5
	
				 | 
					
					
						|||
| 
						
						
							
						
						ffaa020310
	
				 | 
					
					
						|||
| 
						
						
							
						
						6d2b7054e2
	
				 | 
					
					
						|||
| 
						
						
							
						
						d888d5863a
	
				 | 
					
					
						|||
| 
						
						
							
						
						dbc7b3444b
	
				 | 
					
					
						|||
| 
						
						
							
						
						f25eb1d2c5
	
				 | 
					
					
						|||
| 
						
						
							
						
						a2a749e1ca
	
				 | 
					
					
						|||
| 
						
						
							
						
						5bf6a5501d
	
				 | 
					
					
						|||
| 
						
						
							
						
						9523b5f05f
	
				 | 
					
					
						|||
| 5eb3ffca66 | |||
| 9930c48253 | |||
| 
						
						
							
						
						d902e63a0c
	
				 | 
					
					
						|||
| 
						
						
							
						
						48b0bade51
	
				 | 
					
					
						|||
| 
						
						
							
						
						f75dbc4525
	
				 | 
					
					
						|||
| 
						
						
							
						
						fbf64db16e
	
				 | 
					
					
						|||
| 
						
						
							
						
						a3fd8ba063
	
				 | 
					
					
						|||
| 
						
						
							
						
						9b26207515
	
				 | 
					
					
						|||
| 
						
						
							
						
						7ea36a5415
	
				 | 
					
					
						|||
| 
						
						
							
						
						898f6d52bf
	
				 | 
					
					
						|||
| 
						
						
							
						
						8be16e7b58
	
				 | 
					
					
						|||
| 
						
						
							
						
						ea092803d7
	
				 | 
					
					
						|||
| 
						
						
							
						
						5e9f36ef1a
	
				 | 
					
					
						|||
| 
						
						
							
						
						b4d87bc6b5
	
				 | 
					
					
						|||
| 
						
						
							
						
						dd639d829e
	
				 | 
					
					
						|||
| 7b809ff3a6 | |||
| 
						
						
							
						
						d36edfc063
	
				 | 
					
					
						|||
| 
						
						
							
						
						cf87da096f
	
				 | 
					
					
						|||
| 
						
						
							
						
						e452b7acbf
	
				 | 
					
					
						|||
| 
						
						
							
						
						74ab4df9fe
	
				 | 
					
					
						|||
| 
						
						
							
						
						451851c955
	
				 | 
					
					
						|||
| 789ca149af | |||
| 7d3f1930b8 | |||
| 
						
						
							
						
						e8f4ca1e09
	
				 | 
					
					
						|||
| 
						
						
							
						
						733f145be3
	
				 | 
					
					
						|||
| 
						
						
							
						
						48c37353ea
	
				 | 
					
					
						|||
| 
						
						
							
						
						8056dc096d
	
				 | 
					
					
						|||
| 
						
						
							
						
						6d5b69cd26
	
				 | 
					
					
						|||
| 
						
						
							
						
						a7bdffd71a
	
				 | 
					
					
						|||
| 
						
						
							
						
						0887e4bbde
	
				 | 
					
					
						|||
| 
						
						
							
						
						199f4ca1f2
	
				 | 
					
					
						|||
| 
						
						
							
						
						802a6c68cb
	
				 | 
					
					
						|||
| 
						
						
							
						
						41a0b3a1c1
	
				 | 
					
					
						|||
| 
						
						
							
						
						aa35724be2
	
				 | 
					
					
						|||
| 
						
						
							
						
						9086d33158
	
				 | 
					
					
						|||
| 
						
						
							
						
						43d214b982
	
				 | 
					
					
						|||
| 
						
						
							
						
						b93e4a8d11
	
				 | 
					
					
						|||
| 
						
						
							
						
						b9a9704061
	
				 | 
					
					
						|||
| 
						
						
							
						
						fee52f326a
	
				 | 
					
					
						|||
| 317966d5c1 | |||
| 
						
						
							
						
						9f0a22d3d1
	
				 | 
					
					
						|||
| a5ecdd100c | |||
| 
						
						
							
						
						f60691846b
	
				 | 
					
					
						|||
| 
						
						
							
						
						d5ecb72a71
	
				 | 
					
					
						|||
| 
						
						
							
						
						8cf9dfb9b9
	
				 | 
					
					
						|||
| 
						
						
							
						
						c3ab61bd04
	
				 | 
					
					
						|||
| 0b4b6dcb3e | |||
| 0d5f6c0332 | |||
| 
						
						
							
						
						7b28938cde
	
				 | 
					
					
						|||
| 
						
						
							
						
						35ffb36fbd
	
				 | 
					
					
						|||
| 
						 | 
					08ba0b263a | ||
| 
						 | 
					c4c4e9594f | ||
| 
						 | 
					4166823d55 | ||
| 
						 | 
					dc0f3dbcef | ||
| 
						 | 
					4583958f50 | ||
| 
						 | 
					b3abe9ab18 | ||
| 
						 | 
					27f23b48b6 | ||
| 
						 | 
					67e170d4a6 | ||
| 
						 | 
					8f895dc4d7 | ||
| 
						 | 
					1187577728 | ||
| 
						 | 
					8a58af3b31 | ||
| 
						 | 
					0c23625147 | ||
| 
						 | 
					21219b9c62 | ||
| 
						 | 
					5ab8beecef | ||
| 
						 | 
					1ca5133026 | ||
| 
						 | 
					93bc6bb245 | ||
| 
						 | 
					952c4383e7 | ||
| 
						
						
							
						
						15dd2b8f0c
	
				 | 
					
					
						|||
| 
						
						
							
						
						c540b6334c
	
				 | 
					
					
						|||
| 
						 | 
					bab394908d | ||
| 0b93968b9e | |||
| 
						
						
							
						
						97375ef6c0
	
				 | 
					
					
						|||
| 
						
						
							
						
						36cfcd533f
	
				 | 
					
					
						|||
| 
						
						
							
						
						21dbc53615
	
				 | 
					
					
						|||
| 
						
						
							
						
						e6f10ebdac
	
				 | 
					
					
						|||
| 
						
						
							
						
						47968844ce
	
				 | 
					
					
						|||
| 
						
						
							
						
						a435460e29
	
				 | 
					
					
						|||
| 
						
						
							
						
						b7c4360108
	
				 | 
					
					
						|||
| 
						 | 
					
						
						
							
						
						8d8c417c50
	
				 | 
					
					
						||
| 
						
						
							
						
						2b189af25b
	
				 | 
					
					
						|||
| 
						
						
							
						
						5a07c8a94f
	
				 | 
					
					
						|||
| 
						
						
							
						
						6cc1857eb6
	
				 | 
					
					
						|||
| 
						
						
							
						
						601534d610
	
				 | 
					
					
						|||
| 
						
						
							
						
						c271593839
	
				 | 
					
					
						|||
| 
						
						
							
						
						f351794aa0
	
				 | 
					
					
						|||
| 2793fee58c | |||
| 
						
						
							
						
						7a715df121
	
				 | 
					
					
						|||
| 
						
						
							
						
						9308878054
	
				 | 
					
					
						|||
| 
						
						
							
						
						b5ccf5b800
	
				 | 
					
					
						|||
| 5e63254439 | |||
| 
						
						
							
						
						da96506218
	
				 | 
					
					
						|||
| b4714b896a | |||
| 
						
						
							
						
						cdb2647a4d
	
				 | 
					
					
						|||
| 
						
						
							
						
						cc12e3ec63
	
				 | 
					
					
						|||
| 
						
						
							
						
						be168c5ada
	
				 | 
					
					
						|||
| 
						
						
							
						
						b46ae6f856
	
				 | 
					
					
						|||
| 
						
						
							
						
						ec0bcbf015
	
				 | 
					
					
						|||
| 81303b8ef8 | |||
| 
						
						
							
						
						910b98fefc
	
				 | 
					
					
						|||
| 
						
						
							
						
						5a7a219ba8
	
				 | 
					
					
						|||
| 116451603c | |||
| 
						
						
							
						
						b2437ef9b5
	
				 | 
					
					
						|||
| 
						
						
							
						
						d8c9618772
	
				 | 
					
					
						|||
| 
						
						
							
						
						c825dee95a
	
				 | 
					
					
						|||
| 
						
						
							
						
						73d27e820b
	
				 | 
					
					
						|||
| 
						
						
							
						
						40e1b42078
	
				 | 
					
					
						|||
| 
						
						
							
						
						72806f0ace
	
				 | 
					
					
						|||
| 
						
						
							
						
						b244e01231
	
				 | 
					
					
						|||
| 
						
						
							
						
						76d1784aea
	
				 | 
					
					
						|||
| 
						
						
							
						
						56c5fa4057
	
				 | 
					
					
						|||
| 
						
						
							
						
						b5ef937a03
	
				 | 
					
					
						|||
| 
						
						
							
						
						e95a8b6e18
	
				 | 
					
					
						|||
| 
						
						
							
						
						635adf1360
	
				 | 
					
					
						|||
| 
						
						
							
						
						d5a9bf175f
	
				 | 
					
					
						|||
| 
						
						
							
						
						b597a6ac5b
	
				 | 
					
					
						|||
| 
						 | 
					a704b92c3d | ||
| 53090b1a21 | |||
| c49af0b83a | |||
| 
						
						
							
						
						5a05997d9d
	
				 | 
					
					
						|||
| 
						 | 
					
						
						
							
						
						c109cd3ddd
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						84304971d7
	
				 | 
					
					
						||
| b8b781f9a2 | |||
| 002128eed2 | |||
| 8d71783c42 | |||
| 
						 | 
					
						
						
							
						
						a6f23df7d5
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						d9c97628e2
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						893534955d
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						dfbf9972c2
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						b5f3b3ffc1
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						3aad4e7398
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						b4a1b513cc
	
				 | 
					
					
						||
| c0c64f225c | |||
| 
						 | 
					
						
						
							
						
						9d8f47115c
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						f4156f1b94
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						e60994e065
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						801f711994
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						e4568b410f
	
				 | 
					
					
						||
| c8f7986d5a | |||
| 
						 | 
					
						
						
							
						
						d3a9c442a5
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						016ab5a9c9
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						7866ab7ec0
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						f570ff3cd5
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						6b2638c271
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						5cb4183e9f
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						3a20555663
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						95be0042e9
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						48880e7fd3
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						e0030771e4
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						d47799e6ee
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						eae091625a
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						aceb77ffb9
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						338c94ed05
	
				 | 
					
					
						||
| 
						 | 
					290848f904 | ||
| 
						 | 
					72dca54bbf | ||
| 
						 | 
					117d9da3ba | ||
| 
						 | 
					37efebe85b | ||
| 
						 | 
					3af2ec71b6 | ||
| 
						 | 
					0b4a95525b | ||
| 
						 | 
					af664e481f | ||
| 
						 | 
					0171f16311 | ||
| 
						 | 
					296b94d237 | ||
| 
						 | 
					4942553335 | ||
| 
						 | 
					c1efb87180 | ||
| 
						 | 
					72eead8595 | ||
| 
						 | 
					ade7e583e5 | ||
| 4a8a101822 | |||
| dd2cfa6327 | |||
| 2adf84b7fc | |||
| 
						 | 
					2f54e64ea2 | ||
| 
						 | 
					8434c0062c | ||
| 
						 | 
					6d976f32bf | ||
| 
						 | 
					b9d49d53f2 | ||
| 
						 | 
					23243e09bb | ||
| 
						 | 
					2682e9a610 | ||
| 
						 | 
					5635598bbc | ||
| 
						 | 
					b58a0c43cd | ||
| 
						 | 
					e1f647bd02 | ||
| 
						 | 
					39fd3a2471 | ||
| 
						 | 
					1072e227b8 | ||
| 
						 | 
					cbf7e6fe6c | ||
| 
						 | 
					950922d041 | ||
| 
						 | 
					78fe070cd3 | ||
| 
						 | 
					51d5733578 | ||
| 
						 | 
					7bd895c1df | ||
| 
						 | 
					e5e94c52f2 | ||
| 
						 | 
					051591cb7a | ||
| 
						 | 
					0e7390b669 | ||
| 
						 | 
					fe4363b83d | ||
| 
						 | 
					6e80016b38 | ||
| 
						 | 
					08e50ffc22 | ||
| 
						 | 
					9cb65277f3 | ||
| 
						 | 
					224a0fdd8c | ||
| 
						 | 
					6dc7604e90 | ||
| 
						 | 
					cb7f3c9f18 | ||
| 
						 | 
					f910feca9e | ||
| 
						 | 
					91f784872c | ||
| 
						 | 
					b655135a42 | ||
| 
						 | 
					58aa4983e3 | ||
| 
						 | 
					6cc3cf4174 | ||
| 
						 | 
					2097e67321 | ||
| 
						 | 
					d773303d18 | ||
| 
						 | 
					3cabcf40e7 | ||
| 
						 | 
					bf29efda0a | ||
| 
						 | 
					ceccba0d71 | ||
| 
						 | 
					3eced33082 | ||
| 
						 | 
					acb3fb4a91 | ||
| 
						 | 
					1c5e951c2f | ||
| 
						 | 
					beb1853aef | ||
| 
						 | 
					0078eb8f90 | ||
| 
						 | 
					e5e758f9d9 | ||
| 
						 | 
					4a78328717 | ||
| 
						 | 
					65a2e8c08c | ||
| 
						 | 
					b5fa428bad | ||
| 
						 | 
					fb72385773 | ||
| 
						 | 
					2f68601e8b | ||
| 
						 | 
					0b1bed8048 | ||
| 
						 | 
					8ada0e51f2 | ||
| 
						 | 
					c3d613947f | ||
| 
						 | 
					36b8157372 | ||
| 
						 | 
					992cfe8e23 | ||
| 
						 | 
					18a8ff1b8a | ||
| 
						 | 
					c61bb2e90d | ||
| 
						 | 
					4b12e3ed08 | ||
| 
						 | 
					af07ed9807 | ||
| 
						 | 
					bbe53b3b63 | ||
| 
						 | 
					536f0ec226 | ||
| 
						 | 
					541ed59f40 | ||
| 
						 | 
					e172b4f4bb | ||
| 
						 | 
					d666179037 | ||
| 
						 | 
					f22e92132c | ||
| 
						 | 
					ca7ad05746 | ||
| 
						 | 
					f55ca2f725 | ||
| 
						 | 
					d4e4ed580f | ||
| 
						 | 
					8756751344 | ||
| 
						 | 
					fd83fe19bf | ||
| 
						 | 
					a00d95608b | ||
| 
						 | 
					3303edd01f | ||
| 
						 | 
					e48ef92137 | ||
| 
						 | 
					919d0b7e85 | ||
| 
						 | 
					439bf35b62 | ||
| 
						 | 
					74b26335d1 | ||
| 
						 | 
					3d733ed6af | ||
| 
						 | 
					d54ab94ceb | ||
| 
						 | 
					4f188ca3e5 | ||
| 
						 | 
					72bac75fbd | ||
| 
						 | 
					6d54aae614 | ||
| 
						 | 
					8052152ea5 | ||
| 
						 | 
					70448db8e5 | ||
| 
						 | 
					ac2d1e8111 | ||
| 
						 | 
					3ba61385a3 | ||
| 
						 | 
					7353348d7a | ||
| 
						 | 
					f63e2e088e | ||
| 
						 | 
					420a24ebac | ||
| 
						 | 
					d566def706 | ||
| 
						 | 
					eaf6769e8b | ||
| 
						 | 
					a61ec81cff | ||
| 
						 | 
					60f2a73cc5 | ||
| 
						 | 
					bcd96b2ed8 | ||
| 
						 | 
					5c702187e5 | ||
| 
						 | 
					905d65371f | ||
| 
						 | 
					180cd3e1ec | ||
| 
						 | 
					73ca65aa91 | ||
| 
						 | 
					5ed0560953 | ||
| 
						 | 
					dbc6fbbf71 | ||
| 
						 | 
					872fd8f86d | ||
| 
						 | 
					f89234b69a | ||
| 
						 | 
					36a980555b | ||
| 
						 | 
					826cd4d87f | ||
| 
						 | 
					e8005a6c58 | ||
| 
						 | 
					2270a0aa82 | ||
| 
						 | 
					0f53ac45f7 | ||
| 
						 | 
					670556c59e | ||
| 
						 | 
					5b02ba48e0 | ||
| 
						 | 
					f3f18bc25e | ||
| 
						 | 
					03124e124c | ||
| 
						 | 
					6308964e93 | ||
| 
						 | 
					ed79097288 | ||
| 
						 | 
					d7eaef8cee | ||
| 
						 | 
					01d405e54b | ||
| 
						 | 
					80e3cba4c6 | ||
| 
						 | 
					f190053e84 | ||
| 
						 | 
					218960adb5 | ||
| 
						 | 
					88a1eae631 | ||
| 
						 | 
					2a2ecb2acc | ||
| 
						 | 
					f5486bdb63 | ||
| 
						 | 
					9b090a145c | ||
| 
						 | 
					860c7b50e5 | ||
| 
						 | 
					afdc75c0bd | ||
| 
						 | 
					c6603e8aa7 | ||
| 
						 | 
					72cc1638e6 | ||
| 
						 | 
					6a0dc4cb10 | ||
| 
						 | 
					0f1f3b9560 | ||
| 
						 | 
					c720e5483e | ||
| 
						 | 
					0fd3e9db78 | ||
| 
						 | 
					c34296c923 | ||
| 
						 | 
					ce4c22a4a1 | ||
| 
						 | 
					3e0f665ef8 | ||
| 
						 | 
					be8751c815 | ||
| 
						 | 
					8225445c3e | ||
| 
						 | 
					f333e6a875 | ||
| 
						 | 
					e5835b46a5 | ||
| 
						 | 
					fe937405a6 | ||
| 
						 | 
					0741c8ad2b | ||
| 
						 | 
					3191dba31f | ||
| 
						 | 
					428de69d93 | ||
| 
						 | 
					9b8caa7fa1 | ||
| 
						 | 
					fa3c723140 | ||
| 
						 | 
					dc6a5f56f6 | ||
| 
						 | 
					6b06853678 | ||
| 
						 | 
					346aa94ead | ||
| 
						 | 
					78586b9343 | ||
| 
						 | 
					353416618a | ||
| 
						 | 
					9eff3d8850 | ||
| 
						 | 
					7a32c30b8c | ||
| 
						 | 
					0183ba193c | ||
| 
						 | 
					f3f746aba8 | ||
| 
						 | 
					53c4e38771 | ||
| 
						 | 
					4a9c37905c | ||
| 
						 | 
					4452d112e3 | ||
| 
						 | 
					27aa2e9da8 | ||
| 
						 | 
					89b2ff52e3 | ||
| 
						 | 
					48407cacf8 | ||
| 
						 | 
					b6901ea1e5 | ||
| 
						 | 
					012b84614c | ||
| 
						 | 
					3988261a64 | ||
| 
						 | 
					c06354211b | ||
| 
						 | 
					1023c6c502 | ||
| 
						 | 
					cc5996121b | ||
| 
						 | 
					40a3405f47 | ||
| 
						 | 
					82924c999a | ||
| 
						 | 
					f1dac73c08 | ||
| 
						 | 
					72c004cb56 | ||
| 
						 | 
					1ed74021a2 | ||
| 
						 | 
					b1fed3d476 | ||
| 
						 | 
					d5f324c2d5 | ||
| 
						 | 
					dcdd8e56e8 | ||
| 
						 | 
					ae028b7d06 | ||
| 
						 | 
					5ebdb015ad | ||
| 
						 | 
					69f87c0f64 | ||
| 
						 | 
					eb58db7df9 | ||
| 
						 | 
					81ad38927d | ||
| 
						 | 
					8aac738c4a | ||
| 
						 | 
					eb4641ed35 | ||
| 
						 | 
					ae31cdf15e | ||
| 
						 | 
					fcd1bb98a8 | ||
| 
						 | 
					15ed9d81d5 | ||
| 
						 | 
					de3660b23c | ||
| 
						 | 
					487c3ef0da | ||
| 
						 | 
					af48eeeaec | ||
| 
						 | 
					c503e77b23 | ||
| 
						 | 
					1a28e876b8 | ||
| 
						 | 
					2a824cadf6 | ||
| 
						 | 
					2a1bfa9735 | ||
| 
						 | 
					a64dc9ffc2 | ||
| 
						 | 
					b63fa19644 | ||
| 
						 | 
					2f6c7ed156 | ||
| 
						 | 
					00bc9550f2 | ||
| 
						 | 
					be8e74d056 | ||
| 
						 | 
					2b2dde85dc | ||
| 
						 | 
					9f619a9df8 | ||
| 
						 | 
					96954b1afd | ||
| 
						 | 
					2a8a5cd736 | ||
| 
						 | 
					e73b3cf69d | ||
| 
						 | 
					2e13356e39 | ||
| 
						 | 
					d273193b1d | ||
| 
						 | 
					3e9b3d690f | ||
| 
						 | 
					863150d200 | ||
| 
						 | 
					f96b1f26a4 | ||
| 
						 | 
					466db42318 | ||
| 
						 | 
					3af083fb6b | ||
| 
						 | 
					bcb2398d68 | ||
| 
						 | 
					8c23726f88 | ||
| 
						 | 
					751a4291ab | ||
| 
						 | 
					77b0241406 | ||
| 
						 | 
					afc367cfb8 | ||
| 
						 | 
					bad5fe3c22 | ||
| 
						 | 
					a97a36bc9e | ||
| 
						 | 
					94706328ff | ||
| 
						 | 
					2fc13e5418 | ||
| 
						 | 
					2e80233cbc | ||
| 
						 | 
					ebe6ce61e4 | ||
| 
						 | 
					0f47412c38 | ||
| 
						 | 
					3c636e9f71 | ||
| 
						 | 
					4ddd763886 | ||
| 
						 | 
					0888afe439 | ||
| 
						 | 
					6d1b75b9b6 | ||
| 
						 | 
					70e1a611dd | ||
| 
						 | 
					3111c30e56 | ||
| 
						 | 
					5c7fe716ad | ||
| 
						 | 
					9b4923fc04 | ||
| 
						 | 
					c93c81861d | ||
| 
						 | 
					f71fb1fa81 | ||
| 
						 | 
					c03c18e93a | ||
| 
						 | 
					b6847415b5 | ||
| 
						 | 
					cb545417ac | ||
| 
						 | 
					f8a0e20772 | ||
| 
						 | 
					43fffdf56f | ||
| 
						 | 
					c66d66bc64 | ||
| 
						 | 
					d29e1d69d1 | ||
| 
						 | 
					ff187581c9 | ||
| 
						 | 
					f02efd3b39 | ||
| 
						 | 
					4b85a35a9d | ||
| 
						 | 
					f7f6f053f7 | ||
| 
						 | 
					22bae51808 | ||
| 
						 | 
					76aacaf048 | ||
| 
						 | 
					cc7ebd2d8a | ||
| 
						 | 
					42778baf20 | ||
| 
						 | 
					fed9567522 | ||
| 
						 | 
					177128f593 | ||
| 
						 | 
					7bdf5a4366 | ||
| 
						 | 
					4b149213f9 | ||
| 
						 | 
					7fc2559530 | ||
| 
						 | 
					be6cf93cdb | ||
| 
						 | 
					bf7f5b9cd6 | ||
| 
						 | 
					1b8cb7abb0 | ||
| 
						 | 
					3d20987b18 | ||
| 
						 | 
					06679b2e6a | ||
| 
						 | 
					9d1a355ea1 | ||
| 
						 | 
					6a2b46be72 | ||
| 
						 | 
					4da5c41f40 | ||
| 
						 | 
					8db9e92986 | ||
| 
						 | 
					3e42f4fffb | ||
| 
						 | 
					cde35ea9f9 | ||
| 
						 | 
					f1fe6c4996 | ||
| 
						 | 
					d73d9f8bda | ||
| 
						 | 
					e85ec1fa05 | ||
| 
						 | 
					d74007d523 | ||
| 
						 | 
					cc5f04e2b3 | ||
| 
						 | 
					d054d58661 | ||
| 
						 | 
					cf7101fc0f | ||
| 
						 | 
					fb47e22ae1 | ||
| 
						 | 
					980032bfbf | ||
| 
						 | 
					31585a9c7e | ||
| 
						 | 
					9f42ecb97a | ||
| 
						 | 
					b5028b9814 | ||
| 
						 | 
					0e8557404d | ||
| 
						 | 
					0f56e90e48 | ||
| 
						 | 
					9bd7569935 | ||
| 
						 | 
					a3fe13aeb4 | ||
| 
						 | 
					22140a1428 | ||
| 
						 | 
					cdae654034 | ||
| 
						 | 
					ebe9c62823 | ||
| 
						 | 
					d76aa3fec9 | ||
| 
						 | 
					05164636a1 | ||
| 
						 | 
					361ea8cad3 | ||
| 
						 | 
					819795c1f9 | ||
| 
						 | 
					b5c1289358 | ||
| 
						 | 
					0395717d19 | ||
| 
						 | 
					4bb8a443d5 | ||
| 
						 | 
					646c23ccb8 | ||
| 
						 | 
					cf4e1f33b4 | ||
| 
						 | 
					5efb150583 | ||
| 
						 | 
					08defd84e6 | ||
| 
						 | 
					7c9287e387 | ||
| 
						 | 
					c6abad107a | ||
| 
						 | 
					81e418e17e | ||
| 
						 | 
					1977e403e3 | ||
| 
						 | 
					eaf256b1b6 | ||
| 
						 | 
					2b70a05a9e | ||
| 
						 | 
					be08c12dca | ||
| 
						 | 
					aa247c281f | ||
| 
						 | 
					739da3a090 | ||
| 
						 | 
					85c9c4b2ba | ||
| 
						 | 
					27845303b8 | ||
| 
						 | 
					e3fc79231d | ||
| 
						 | 
					9b2a8c4f6f | ||
| 
						 | 
					d89f6dcf5c | ||
| 
						 | 
					5feb23ad51 | ||
| 
						 | 
					b4ef4b8089 | ||
| 
						 | 
					6de46a9264 | ||
| 
						 | 
					bfd08fec09 | ||
| 
						 | 
					55be6be6c5 | ||
| 
						 | 
					bf7b187048 | ||
| 
						 | 
					09853ce990 | ||
| 
						 | 
					2acb47c516 | ||
| 
						 | 
					ff7e954652 | ||
| 
						 | 
					9c794205f0 | ||
| 
						 | 
					3922fcd93a | ||
| 
						 | 
					534831f380 | ||
| 
						 | 
					dd9ca315fa | ||
| 
						 | 
					d9e003a8f4 | ||
| 
						 | 
					dbca5db7d7 | ||
| 
						 | 
					c1c211629d | ||
| 
						 | 
					b787c8cfe2 | ||
| 
						 | 
					affa2b1a4d | ||
| 
						 | 
					e0c1a5f590 | ||
| 
						 | 
					e8dcf295ad | ||
| 
						 | 
					5642c268e9 | ||
| 
						 | 
					e74f92cf8d | ||
| 
						 | 
					ee26850e34 | ||
| 
						 | 
					08c8792aed | ||
| 
						 | 
					a9da4a38e1 | ||
| 
						 | 
					b8c1cfba40 | ||
| 
						 | 
					ca6f7cac9a | ||
| 
						 | 
					5e65e2d74a | ||
| 
						 | 
					0c753c3288 | ||
| 
						 | 
					1bbe7df797 | ||
| 
						 | 
					abbe74cc55 | ||
| 
						 | 
					8744455cbe | ||
| 
						 | 
					56c41258b9 | ||
| 
						 | 
					48eb0749e0 | ||
| 
						 | 
					8ac551e1bc | ||
| 
						 | 
					805ceda249 | ||
| 
						 | 
					a9258c332a | ||
| 
						 | 
					ca7f4791ed | ||
| 
						 | 
					b454ad8dad | ||
| 
						 | 
					7d539d44e5 | ||
| 
						 | 
					227cb2a801 | ||
| 
						 | 
					ef1e805538 | ||
| 
						 | 
					374e6ed7f8 | ||
| 
						 | 
					c5f40e0952 | ||
| 
						 | 
					4cb162de87 | ||
| 
						 | 
					bca301700d | ||
| 
						 | 
					5ba18a2d89 | ||
| 
						 | 
					22a0af640e | ||
| 
						 | 
					1712d1725a | ||
| 
						 | 
					93e5e4c8cd | ||
| 
						 | 
					1fd37bb1ce | ||
| 
						 | 
					e3785e11f1 | ||
| 
						 | 
					2e659c63cd | ||
| 
						 | 
					63dc184ce4 | ||
| 
						 | 
					b25935e579 | ||
| 
						 | 
					550242226e | ||
| 
						 | 
					bac14521ae | ||
| 
						 | 
					e14c8734c2 | ||
| 
						 | 
					c64de202a6 | ||
| 
						 | 
					44b7fe8f52 | ||
| 
						 | 
					cbc3e39bd6 | ||
| 
						 | 
					1c16d6ef18 | ||
| 
						 | 
					e3898d0b1e | ||
| 
						 | 
					ac0e9b9da2 | ||
| 
						 | 
					0ba77fb8f0 | ||
| 
						 | 
					342d3910c7 | ||
| 
						 | 
					2c1cf148fa | ||
| 
						 | 
					196f796570 | ||
| 
						 | 
					8691421ce3 | ||
| 
						 | 
					9cad8fcc65 | ||
| 
						 | 
					891955cedf | ||
| 
						 | 
					8063354e0f | ||
| 
						 | 
					f077a5d72f | ||
| 
						 | 
					2272cf5294 | ||
| 
						 | 
					8465b24d7d | ||
| 
						 | 
					aa98c4848d | ||
| 
						 | 
					00b07147f6 | ||
| 
						 | 
					26775aa561 | ||
| 
						 | 
					83d2c18d1e | ||
| 
						 | 
					5ea1eed76d | ||
| 
						 | 
					b7d4a17ffd | ||
| 
						 | 
					5c3451bda7 | ||
| 
						 | 
					8c46321c95 | ||
| 
						 | 
					a3af2b0d9a | ||
| 
						 | 
					501d02d05c | ||
| 
						 | 
					310f55a28e | ||
| 
						 | 
					197bd28ceb | ||
| 
						 | 
					e03a3f7fd2 | ||
| 
						 | 
					51230e029d | ||
| 
						 | 
					bd49a36bcc | ||
| 
						 | 
					2672721235 | ||
| 
						 | 
					ba636fc401 | ||
| 
						 | 
					c090b4af76 | ||
| 
						 | 
					a1dc8fe530 | ||
| 
						 | 
					6ea92cdcde | ||
| 
						 | 
					9c9214b5df | ||
| 
						 | 
					00935a8c02 | ||
| 
						 | 
					b0ebc7c0a4 | ||
| 
						 | 
					60b1cdbcf8 | ||
| 
						 | 
					f324965f1a | ||
| 
						 | 
					7c291b115a | ||
| 
						 | 
					6217f35f67 | ||
| 
						 | 
					448d379315 | ||
| 
						 | 
					e974eaa1fe | ||
| 
						 | 
					61ace4af74 | ||
| 
						 | 
					b8c3dda95b | ||
| 
						 | 
					da23df05cb | ||
| 
						 | 
					9c061d9837 | ||
| 
						 | 
					5abb155287 | ||
| 
						 | 
					9f258e39b6 | ||
| 
						 | 
					4997a37058 | ||
| 
						 | 
					b16871d925 | ||
| 
						 | 
					1186b0f9a9 | ||
| 
						 | 
					5abbb84254 | ||
| 
						 | 
					5f8c4a2857 | ||
| 
						 | 
					14b969b2dd | ||
| 
						 | 
					f95a0875db | ||
| 
						 | 
					430036bfc2 | ||
| 
						 | 
					d6fd925fdd | ||
| 
						 | 
					89c15cbe3e | ||
| 
						 | 
					75cd34f5dd | ||
| 
						 | 
					6927f5fbb6 | ||
| 
						 | 
					482a04d37c | ||
| 
						 | 
					0bf5067b60 | ||
| 
						 | 
					fe2af5ac2b | ||
| 
						 | 
					d4090a4043 | ||
| 
						 | 
					242b85676d | ||
| 
						 | 
					eca4767155 | ||
| 
						 | 
					21ba46c1bc | ||
| 
						 | 
					74097ecc44 | ||
| 
						 | 
					d962763987 | ||
| 
						 | 
					a43abee00b | ||
| 
						 | 
					912ce5da2e | ||
| 
						 | 
					29f8b9215d | ||
| 
						 | 
					f5f379e6ad | ||
| 
						 | 
					c50fdd6689 | ||
| 
						 | 
					1e4cbf60c5 | ||
| 
						 | 
					dfe4bf2175 | ||
| 
						 | 
					a25e663a26 | ||
| 
						 | 
					721da093e9 | ||
| 
						 | 
					d98e46ffc2 | ||
| 
						 | 
					2d69e36adf | ||
| 
						 | 
					bb2704323a | ||
| 
						 | 
					c466715e8a | ||
| 
						 | 
					71f6436d06 | ||
| 
						 | 
					106e97f5df | ||
| 
						 | 
					b7a88a387c | ||
| 
						 | 
					25e26fe8cf | ||
| 
						 | 
					0fae5b3e62 | ||
| 
						 | 
					3784e97d60 | ||
| 
						 | 
					6567d2f8cc | ||
| 
						 | 
					999cc0a6b2 | ||
| 
						 | 
					60de58b78a | ||
| 
						 | 
					9c816a288d | ||
| 
						 | 
					c277d8bccd | ||
| 
						 | 
					4a4c3d33b0 | ||
| 
						 | 
					9c679d5bc9 | ||
| 
						 | 
					3b49b7f4c1 | ||
| 
						 | 
					747a878cca | ||
| 
						 | 
					c612e159cf | ||
| 
						 | 
					1b84c8c603 | ||
| 
						 | 
					3a52af33a2 | ||
| 
						 | 
					ccfc1e74ac | ||
| 
						 | 
					7719ff41ad | ||
| 
						 | 
					8933fddaf3 | ||
| 
						 | 
					eadc8fa193 | ||
| 
						 | 
					f74b19b2af | ||
| 
						 | 
					c3081d9cc3 | ||
| 
						 | 
					8e886f1431 | ||
| 
						 | 
					bf7c253607 | ||
| 
						 | 
					027ae5b97f | ||
| 
						 | 
					63562d3fbb | ||
| 
						 | 
					bba69f0a60 | ||
| 
						 | 
					beff848796 | ||
| 
						 | 
					e78ba49252 | ||
| 
						 | 
					ce35e8f7e8 | ||
| 
						 | 
					50f4a43343 | ||
| 
						 | 
					b66d6635fc | ||
| 
						 | 
					9a52c81bff | ||
| 
						 | 
					48d3e8960a | ||
| 
						 | 
					f6dfbb0b6c | ||
| 
						 | 
					c6e3a57801 | ||
| 
						 | 
					40b826a375 | ||
| 
						 | 
					f0089d0bc5 | ||
| 
						 | 
					5e75a56eda | ||
| 
						 | 
					d73f7c31a1 | ||
| 
						 | 
					31f4105c9a | ||
| 
						 | 
					e9ae8531b8 | ||
| 
						 | 
					7b40ee1ca4 | ||
| 
						 | 
					53b496546d | ||
| 
						 | 
					8c1cf754ed | ||
| 
						 | 
					efe833cec3 | ||
| 
						 | 
					ccfc37d226 | ||
| 
						 | 
					764eaafb95 | ||
| 
						 | 
					5846f03220 | ||
| 
						 | 
					52e8b46aa2 | ||
| 
						 | 
					29f84ea007 | ||
| 
						 | 
					49bda926c6 | ||
| 
						 | 
					11fbbca2a8 | ||
| 
						 | 
					901af1a86a | ||
| 
						 | 
					5f87e76be8 | ||
| 
						 | 
					8c885d372b | ||
| 
						 | 
					255e4dd0aa | ||
| 
						 | 
					4afb849aec | ||
| 
						 | 
					872456df20 | ||
| 
						 | 
					963ba05d01 | ||
| 
						 | 
					18eaf4477e | ||
| 
						 | 
					e4998cb6e3 | ||
| 
						 | 
					ad59b5c81e | ||
| 
						 | 
					88917dde23 | ||
| 
						 | 
					aab194b987 | ||
| 
						 | 
					9751a5ad92 | ||
| 
						 | 
					679ac3a652 | ||
| 
						 | 
					1fb14ea33d | ||
| 
						 | 
					e23eafd56c | ||
| 
						 | 
					3e28ed8716 | ||
| 
						 | 
					5c01c0bb6c | ||
| 
						 | 
					979628b02d | ||
| 
						 | 
					bb8e3aaccf | ||
| 
						 | 
					86ff23357c | ||
| 
						 | 
					fd2f426f55 | ||
| 
						 | 
					48a7128370 | ||
| 
						 | 
					f222ba134d | ||
| 
						 | 
					d95cd8c7c7 | ||
| 
						 | 
					5b3361f086 | ||
| 
						 | 
					9c7cb07dec | ||
| 
						 | 
					dd4b24d999 | ||
| 
						 | 
					eb3d426947 | ||
| 
						 | 
					d8c7018b9a | ||
| 
						 | 
					f47a0b8c9d | ||
| 
						 | 
					c859fc7821 | ||
| 
						 | 
					a4702fca86 | ||
| 
						 | 
					de5e0c958e | ||
| 
						 | 
					434a393f3b | ||
| 
						 | 
					cba6a35b6c | ||
| 
						 | 
					0de69cbfaf | ||
| 
						 | 
					d9cf812074 | ||
| 
						 | 
					252ddb832d | ||
| 
						 | 
					6dcb82855d | ||
| 
						 | 
					1247818033 | ||
| 
						 | 
					9439b3cb2d | ||
| 
						 | 
					a07b942738 | ||
| 
						 | 
					b7ae411f96 | ||
| 
						 | 
					315af75c45 | ||
| 
						 | 
					fd7e314ca3 | ||
| 
						 | 
					0b46140771 | ||
| 
						 | 
					547fbf564b | ||
| 
						 | 
					2aebeb8927 | ||
| 
						 | 
					93f7e1d45b | ||
| 
						 | 
					199219861c | ||
| 
						 | 
					8b66bcc3d5 | ||
| 
						 | 
					6759586ef3 | ||
| 
						 | 
					33806967c8 | ||
| 
						 | 
					24ac3ce45f | ||
| 
						 | 
					018ca84e2d | ||
| 
						 | 
					2851d7764c | ||
| 
						 | 
					c205219d47 | ||
| 
						 | 
					b0398e59b8 | ||
| 
						 | 
					9c3e978a41 | ||
| 
						 | 
					21f1347a60 | ||
| 
						 | 
					af857d6fae | ||
| 
						 | 
					acf7ecc4ae | ||
| 
						 | 
					6c9cf73848 | ||
| 
						 | 
					2222175d4e | ||
| 
						 | 
					a096dc4427 | ||
| 
						 | 
					358691aaa9 | ||
| 
						 | 
					20ce817b16 | ||
| 
						 | 
					ba067f050e | ||
| 
						 | 
					2a744a8610 | ||
| 
						 | 
					0e8058ab0d | ||
| 
						 | 
					655390b265 | ||
| 
						 | 
					985a5ca876 | ||
| 
						 | 
					55580bc11e | ||
| 
						 | 
					5ea8d8f870 | ||
| 
						 | 
					0a2c9d9c87 | ||
| 
						 | 
					208dc7f865 | ||
| 
						 | 
					fbf3a0bcf6 | ||
| 
						 | 
					66defee3ea | ||
| 
						 | 
					f8a4087e56 | ||
| 
						 | 
					94086505e6 | ||
| 
						 | 
					6c8843e5fc | ||
| 
						 | 
					0e8174aacd | ||
| 
						 | 
					0e3c4fcaf6 | ||
| 
						 | 
					58fe8914cf | ||
| 
						 | 
					f870af139e | ||
| 
						 | 
					7742358b8f | ||
| 
						 | 
					8de7ba14bd | ||
| 
						 | 
					8497dbb25c | ||
| 
						 | 
					f148c8dacb | ||
| 
						 | 
					2f018f8c9d | ||
| 
						 | 
					0ae61f3643 | ||
| 
						 | 
					b706efe463 | ||
| 
						 | 
					37dc535d6d | ||
| 
						 | 
					5ccbad8359 | ||
| 
						 | 
					c0cdb13130 | ||
| 
						 | 
					8434841ec5 | ||
| 
						 | 
					cadf981013 | ||
| 
						 | 
					efc2b6b0b0 | ||
| 
						 | 
					1fd7d76412 | ||
| 
						 | 
					51fbde23b9 | ||
| 
						 | 
					36f1a3f0d4 | ||
| 
						 | 
					cc04fa5555 | ||
| 
						 | 
					f38b9801d0 | ||
| 
						 | 
					43a22cbed4 | ||
| 
						 | 
					5c2df41640 | ||
| 
						 | 
					e50bff8e14 | ||
| 
						 | 
					b5586c647b | ||
| 
						 | 
					377397b319 | ||
| 
						 | 
					1abb40953f | ||
| 
						 | 
					f114265662 | ||
| 
						 | 
					7c369bd264 | ||
| 
						 | 
					b6453ce03d | ||
| 
						 | 
					59bfdbbfc7 | ||
| 
						 | 
					82aa0182e3 | ||
| 
						 | 
					23b775447c | ||
| 
						 | 
					d7b834d908 | ||
| 
						 | 
					dca655949e | ||
| 
						 | 
					932a546213 | ||
| 
						 | 
					d8127e8936 | ||
| 
						 | 
					8409ee4cc4 | ||
| 
						 | 
					9008baad3a | ||
| 
						 | 
					fd705adb05 | ||
| 
						 | 
					bd35e4e21e | ||
| 
						 | 
					72dcc93136 | ||
| 
						 | 
					cb38ceb2c6 | ||
| 
						 | 
					ad19b64b3a | ||
| 
						 | 
					2642ae3a1a | ||
| 
						 | 
					ad2cc22964 | ||
| 
						 | 
					ae629b55ad | ||
| 
						 | 
					a5e50e5de6 | ||
| 
						 | 
					9da8d49223 | ||
| 
						 | 
					aa66361ac7 | ||
| 
						 | 
					c14d37eaeb | ||
| 
						 | 
					e9cbc8e623 | ||
| 
						 | 
					9d8c588b78 | ||
| 
						 | 
					484560fe4b | ||
| 
						 | 
					9361f3f2f0 | ||
| 
						 | 
					e63219f7ad | ||
| 
						 | 
					0c0aed0234 | ||
| 
						 | 
					fb775de923 | ||
| 
						 | 
					b49db39080 | ||
| 
						 | 
					da1063862e | ||
| 
						 | 
					224ef5b2f0 | ||
| 
						 | 
					cbd36f110a | ||
| 
						 | 
					c9e68ca66b | ||
| 
						 | 
					20011db37e | ||
| 
						 | 
					5a91cac08d | ||
| 
						 | 
					fa9159bb28 | ||
| 
						 | 
					4549255198 | ||
| 
						 | 
					750bdcb2c5 | ||
| 
						 | 
					5c93301358 | ||
| 
						 | 
					b8a88eeda4 | ||
| 
						 | 
					d455c5c533 | ||
| 
						 | 
					f597b6dbd8 | ||
| 
						 | 
					3a4145e4d9 | ||
| 
						 | 
					54ce157019 | ||
| 
						 | 
					7c6bab88f4 | ||
| 
						 | 
					12ebf9d12a | ||
| 
						 | 
					76a6260b18 | ||
| 
						 | 
					7b3512c0be | ||
| 
						 | 
					0bfc3b9454 | ||
| 
						 | 
					84e8b02594 | ||
| 
						 | 
					09027ea35e | ||
| 
						 | 
					2f334e0707 | ||
| 
						 | 
					e163f86f69 | ||
| 
						 | 
					0335a47667 | ||
| 
						 | 
					b8d4fb9df1 | ||
| 
						 | 
					8cde94c5f5 | ||
| 
						 | 
					ab5d5a6e94 | ||
| 
						 | 
					4c29d855d2 | ||
| 
						 | 
					55bc288deb | ||
| 
						 | 
					fb5e2578af | ||
| 
						 | 
					0b6cb4ef19 | ||
| 
						 | 
					18bdc8044b | ||
| 
						 | 
					f0bca69825 | ||
| 
						 | 
					b2e1777fe0 | ||
| 
						 | 
					afb35d7ae0 | ||
| 
						 | 
					b212bf4093 | ||
| 
						 | 
					1735ba25a8 | ||
| 
						 | 
					a90eb2a6eb | ||
| 
						 | 
					f2ac0cd8cf | ||
| 
						 | 
					8d9e05929b | ||
| 
						 | 
					8d704d0730 | 
@@ -1,3 +1,5 @@
 | 
			
		||||
__pycache__
 | 
			
		||||
media
 | 
			
		||||
db.sqlite3
 | 
			
		||||
.tox
 | 
			
		||||
.coverage
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
DJANGO_APP_STAGE=prod
 | 
			
		||||
# Only used in dev mode, change to "postgresql" if you want to use PostgreSQL in dev
 | 
			
		||||
DJANGO_DEV_STORE_METHOD=sqllite
 | 
			
		||||
DJANGO_DEV_STORE_METHOD=sqlite
 | 
			
		||||
DJANGO_DB_HOST=localhost
 | 
			
		||||
DJANGO_DB_NAME=note_db
 | 
			
		||||
DJANGO_DB_USER=note
 | 
			
		||||
@@ -10,9 +10,14 @@ DJANGO_SECRET_KEY=CHANGE_ME
 | 
			
		||||
DJANGO_SETTINGS_MODULE=note_kfet.settings
 | 
			
		||||
CONTACT_EMAIL=tresorerie.bde@localhost
 | 
			
		||||
NOTE_URL=localhost
 | 
			
		||||
 | 
			
		||||
# Config for mails. Only used in production
 | 
			
		||||
NOTE_MAIL=notekfet@localhost
 | 
			
		||||
EMAIL_HOST=smtp.localhost
 | 
			
		||||
EMAIL_PORT=465
 | 
			
		||||
EMAIL_PORT=25
 | 
			
		||||
EMAIL_USER=notekfet@localhost
 | 
			
		||||
EMAIL_PASSWORD=CHANGE_ME
 | 
			
		||||
 | 
			
		||||
# Wiki configuration
 | 
			
		||||
WIKI_USER=NoteKfet2020
 | 
			
		||||
WIKI_PASSWORD=
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -39,12 +39,18 @@ secrets.py
 | 
			
		||||
.env
 | 
			
		||||
map.json
 | 
			
		||||
*.log
 | 
			
		||||
media/
 | 
			
		||||
backups/
 | 
			
		||||
/static/
 | 
			
		||||
/media/
 | 
			
		||||
/tmp/
 | 
			
		||||
 | 
			
		||||
# Virtualenv
 | 
			
		||||
env/
 | 
			
		||||
venv/
 | 
			
		||||
db.sqlite3
 | 
			
		||||
shell.nix
 | 
			
		||||
 | 
			
		||||
# Ignore migrations during first phase dev
 | 
			
		||||
migrations/
 | 
			
		||||
# ansibles customs host
 | 
			
		||||
ansible/host_vars/*.yaml
 | 
			
		||||
!ansible/host_vars/bde*
 | 
			
		||||
ansible/hosts
 | 
			
		||||
 
 | 
			
		||||
@@ -1,26 +1,64 @@
 | 
			
		||||
image: python:3.6
 | 
			
		||||
 | 
			
		||||
stages:
 | 
			
		||||
  - test
 | 
			
		||||
  - quality-assurance
 | 
			
		||||
  - docs
 | 
			
		||||
 | 
			
		||||
before_script:
 | 
			
		||||
  - pip install tox
 | 
			
		||||
# Also fetch submodules
 | 
			
		||||
variables:
 | 
			
		||||
  GIT_SUBMODULE_STRATEGY: recursive
 | 
			
		||||
 | 
			
		||||
py36-django22:
 | 
			
		||||
  image: python:3.6
 | 
			
		||||
# Ubuntu 22.04
 | 
			
		||||
py310-django42:
 | 
			
		||||
  stage: test
 | 
			
		||||
  script: tox -e py36-django22
 | 
			
		||||
  image: ubuntu:22.04
 | 
			
		||||
  before_script:
 | 
			
		||||
    # Fix tzdata prompt
 | 
			
		||||
    - ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
 | 
			
		||||
    - >
 | 
			
		||||
        apt-get update &&
 | 
			
		||||
        apt-get install --no-install-recommends -y
 | 
			
		||||
        python3-django python3-django-crispy-forms
 | 
			
		||||
        python3-django-extensions python3-django-filters python3-django-polymorphic
 | 
			
		||||
        python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
 | 
			
		||||
        python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
 | 
			
		||||
        python3-bs4 python3-setuptools tox texlive-xetex
 | 
			
		||||
  script: tox -e py310-django42
 | 
			
		||||
 | 
			
		||||
py37-django22:
 | 
			
		||||
  image: python:3.7
 | 
			
		||||
# Debian Bookworm
 | 
			
		||||
py311-django42:
 | 
			
		||||
  stage: test
 | 
			
		||||
  script: tox -e py37-django22
 | 
			
		||||
  image: debian:bookworm
 | 
			
		||||
  before_script:
 | 
			
		||||
    - >
 | 
			
		||||
        apt-get update &&
 | 
			
		||||
        apt-get install --no-install-recommends -y
 | 
			
		||||
        python3-django python3-django-crispy-forms
 | 
			
		||||
        python3-django-extensions python3-django-filters python3-django-polymorphic
 | 
			
		||||
        python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
 | 
			
		||||
        python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
 | 
			
		||||
        python3-bs4 python3-setuptools tox texlive-xetex
 | 
			
		||||
  script: tox -e py311-django42
 | 
			
		||||
 | 
			
		||||
linters:
 | 
			
		||||
  image: python:3.6
 | 
			
		||||
  stage: quality-assurance
 | 
			
		||||
  image: debian:bookworm
 | 
			
		||||
  before_script:
 | 
			
		||||
    - apt-get update && apt-get install -y tox
 | 
			
		||||
  script: tox -e linters
 | 
			
		||||
 | 
			
		||||
  # Be nice to new contributors, but please use `tox`
 | 
			
		||||
  allow_failure: true
 | 
			
		||||
 | 
			
		||||
# Compile documentation
 | 
			
		||||
documentation:
 | 
			
		||||
  stage: docs
 | 
			
		||||
  image: sphinxdoc/sphinx
 | 
			
		||||
  before_script:
 | 
			
		||||
    - pip install sphinx-rtd-theme
 | 
			
		||||
    - cd docs
 | 
			
		||||
  script:
 | 
			
		||||
    - make dirhtml
 | 
			
		||||
  artifacts:
 | 
			
		||||
    paths:
 | 
			
		||||
      - docs/_build
 | 
			
		||||
    expire_in: 1 day
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
								
							@@ -1,3 +1,3 @@
 | 
			
		||||
[submodule "apps/scripts"]
 | 
			
		||||
	path = apps/scripts
 | 
			
		||||
	url = https://gitlab.crans.org/bde/nk20-scripts.git
 | 
			
		||||
	url = https://gitlab.crans.org/bde/nk20-scripts
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										42
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										42
									
								
								Dockerfile
									
									
									
									
									
								
							@@ -1,27 +1,27 @@
 | 
			
		||||
FROM python:3-alpine
 | 
			
		||||
FROM debian:buster-backports
 | 
			
		||||
 | 
			
		||||
# Force the stdout and stderr streams to be unbuffered
 | 
			
		||||
ENV PYTHONUNBUFFERED 1
 | 
			
		||||
 | 
			
		||||
# Install LaTeX requirements
 | 
			
		||||
RUN apk add --no-cache gettext texlive texmf-dist-latexextra texmf-dist-fontsextra nginx gcc libc-dev libffi-dev postgresql-dev libxml2-dev libxslt-dev jpeg-dev
 | 
			
		||||
# Install Django, external apps, LaTeX and dependencies
 | 
			
		||||
RUN apt-get update && \
 | 
			
		||||
    apt-get install --no-install-recommends -t buster-backports -y \
 | 
			
		||||
    python3-django python3-django-crispy-forms \
 | 
			
		||||
    python3-django-extensions python3-django-filters python3-django-polymorphic \
 | 
			
		||||
    python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil \
 | 
			
		||||
    python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache ipython3 \
 | 
			
		||||
    python3-bs4 python3-setuptools \
 | 
			
		||||
    uwsgi uwsgi-plugin-python3 \
 | 
			
		||||
    texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome && \
 | 
			
		||||
    rm -rf /var/lib/apt/lists/*
 | 
			
		||||
 | 
			
		||||
RUN apk add --no-cache bash
 | 
			
		||||
# Instal PyPI requirements
 | 
			
		||||
COPY requirements.txt /var/www/note_kfet/
 | 
			
		||||
RUN pip3 install -r /var/www/note_kfet/requirements.txt --no-cache-dir
 | 
			
		||||
 | 
			
		||||
RUN mkdir /code
 | 
			
		||||
WORKDIR /code
 | 
			
		||||
COPY requirements /code/requirements
 | 
			
		||||
RUN pip install gunicorn ptpython --no-cache-dir
 | 
			
		||||
RUN pip install -r requirements/base.txt -r requirements/cas.txt -r requirements/production.txt --no-cache-dir
 | 
			
		||||
# Copy code
 | 
			
		||||
WORKDIR /var/www/note_kfet
 | 
			
		||||
COPY . /var/www/note_kfet/
 | 
			
		||||
 | 
			
		||||
COPY . /code/
 | 
			
		||||
 | 
			
		||||
# Configure nginx
 | 
			
		||||
RUN mkdir /run/nginx
 | 
			
		||||
RUN ln -sf /dev/stdout /var/log/nginx/access.log && ln -sf /dev/stderr /var/log/nginx/error.log
 | 
			
		||||
RUN ln -sf /code/nginx_note.conf_docker /etc/nginx/conf.d/nginx_note.conf
 | 
			
		||||
RUN rm /etc/nginx/conf.d/default.conf
 | 
			
		||||
 | 
			
		||||
ENTRYPOINT ["/code/entrypoint.sh"]
 | 
			
		||||
EXPOSE 80
 | 
			
		||||
 | 
			
		||||
CMD ["./manage.py", "shell_plus", "--ptpython"]
 | 
			
		||||
EXPOSE 8080
 | 
			
		||||
ENTRYPOINT ["/var/www/note_kfet/entrypoint.sh"]
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										674
									
								
								LICENSE
									
									
									
									
									
								
							
							
						
						
									
										674
									
								
								LICENSE
									
									
									
									
									
								
							@@ -1,674 +0,0 @@
 | 
			
		||||
                    GNU GENERAL PUBLIC LICENSE
 | 
			
		||||
                       Version 3, 29 June 2007
 | 
			
		||||
 | 
			
		||||
 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
 | 
			
		||||
 Everyone is permitted to copy and distribute verbatim copies
 | 
			
		||||
 of this license document, but changing it is not allowed.
 | 
			
		||||
 | 
			
		||||
                            Preamble
 | 
			
		||||
 | 
			
		||||
  The GNU General Public License is a free, copyleft license for
 | 
			
		||||
software and other kinds of works.
 | 
			
		||||
 | 
			
		||||
  The licenses for most software and other practical works are designed
 | 
			
		||||
to take away your freedom to share and change the works.  By contrast,
 | 
			
		||||
the GNU General Public License is intended to guarantee your freedom to
 | 
			
		||||
share and change all versions of a program--to make sure it remains free
 | 
			
		||||
software for all its users.  We, the Free Software Foundation, use the
 | 
			
		||||
GNU General Public License for most of our software; it applies also to
 | 
			
		||||
any other work released this way by its authors.  You can apply it to
 | 
			
		||||
your programs, too.
 | 
			
		||||
 | 
			
		||||
  When we speak of free software, we are referring to freedom, not
 | 
			
		||||
price.  Our General Public Licenses are designed to make sure that you
 | 
			
		||||
have the freedom to distribute copies of free software (and charge for
 | 
			
		||||
them if you wish), that you receive source code or can get it if you
 | 
			
		||||
want it, that you can change the software or use pieces of it in new
 | 
			
		||||
free programs, and that you know you can do these things.
 | 
			
		||||
 | 
			
		||||
  To protect your rights, we need to prevent others from denying you
 | 
			
		||||
these rights or asking you to surrender the rights.  Therefore, you have
 | 
			
		||||
certain responsibilities if you distribute copies of the software, or if
 | 
			
		||||
you modify it: responsibilities to respect the freedom of others.
 | 
			
		||||
 | 
			
		||||
  For example, if you distribute copies of such a program, whether
 | 
			
		||||
gratis or for a fee, you must pass on to the recipients the same
 | 
			
		||||
freedoms that you received.  You must make sure that they, too, receive
 | 
			
		||||
or can get the source code.  And you must show them these terms so they
 | 
			
		||||
know their rights.
 | 
			
		||||
 | 
			
		||||
  Developers that use the GNU GPL protect your rights with two steps:
 | 
			
		||||
(1) assert copyright on the software, and (2) offer you this License
 | 
			
		||||
giving you legal permission to copy, distribute and/or modify it.
 | 
			
		||||
 | 
			
		||||
  For the developers' and authors' protection, the GPL clearly explains
 | 
			
		||||
that there is no warranty for this free software.  For both users' and
 | 
			
		||||
authors' sake, the GPL requires that modified versions be marked as
 | 
			
		||||
changed, so that their problems will not be attributed erroneously to
 | 
			
		||||
authors of previous versions.
 | 
			
		||||
 | 
			
		||||
  Some devices are designed to deny users access to install or run
 | 
			
		||||
modified versions of the software inside them, although the manufacturer
 | 
			
		||||
can do so.  This is fundamentally incompatible with the aim of
 | 
			
		||||
protecting users' freedom to change the software.  The systematic
 | 
			
		||||
pattern of such abuse occurs in the area of products for individuals to
 | 
			
		||||
use, which is precisely where it is most unacceptable.  Therefore, we
 | 
			
		||||
have designed this version of the GPL to prohibit the practice for those
 | 
			
		||||
products.  If such problems arise substantially in other domains, we
 | 
			
		||||
stand ready to extend this provision to those domains in future versions
 | 
			
		||||
of the GPL, as needed to protect the freedom of users.
 | 
			
		||||
 | 
			
		||||
  Finally, every program is threatened constantly by software patents.
 | 
			
		||||
States should not allow patents to restrict development and use of
 | 
			
		||||
software on general-purpose computers, but in those that do, we wish to
 | 
			
		||||
avoid the special danger that patents applied to a free program could
 | 
			
		||||
make it effectively proprietary.  To prevent this, the GPL assures that
 | 
			
		||||
patents cannot be used to render the program non-free.
 | 
			
		||||
 | 
			
		||||
  The precise terms and conditions for copying, distribution and
 | 
			
		||||
modification follow.
 | 
			
		||||
 | 
			
		||||
                       TERMS AND CONDITIONS
 | 
			
		||||
 | 
			
		||||
  0. Definitions.
 | 
			
		||||
 | 
			
		||||
  "This License" refers to version 3 of the GNU General Public License.
 | 
			
		||||
 | 
			
		||||
  "Copyright" also means copyright-like laws that apply to other kinds of
 | 
			
		||||
works, such as semiconductor masks.
 | 
			
		||||
 | 
			
		||||
  "The Program" refers to any copyrightable work licensed under this
 | 
			
		||||
License.  Each licensee is addressed as "you".  "Licensees" and
 | 
			
		||||
"recipients" may be individuals or organizations.
 | 
			
		||||
 | 
			
		||||
  To "modify" a work means to copy from or adapt all or part of the work
 | 
			
		||||
in a fashion requiring copyright permission, other than the making of an
 | 
			
		||||
exact copy.  The resulting work is called a "modified version" of the
 | 
			
		||||
earlier work or a work "based on" the earlier work.
 | 
			
		||||
 | 
			
		||||
  A "covered work" means either the unmodified Program or a work based
 | 
			
		||||
on the Program.
 | 
			
		||||
 | 
			
		||||
  To "propagate" a work means to do anything with it that, without
 | 
			
		||||
permission, would make you directly or secondarily liable for
 | 
			
		||||
infringement under applicable copyright law, except executing it on a
 | 
			
		||||
computer or modifying a private copy.  Propagation includes copying,
 | 
			
		||||
distribution (with or without modification), making available to the
 | 
			
		||||
public, and in some countries other activities as well.
 | 
			
		||||
 | 
			
		||||
  To "convey" a work means any kind of propagation that enables other
 | 
			
		||||
parties to make or receive copies.  Mere interaction with a user through
 | 
			
		||||
a computer network, with no transfer of a copy, is not conveying.
 | 
			
		||||
 | 
			
		||||
  An interactive user interface displays "Appropriate Legal Notices"
 | 
			
		||||
to the extent that it includes a convenient and prominently visible
 | 
			
		||||
feature that (1) displays an appropriate copyright notice, and (2)
 | 
			
		||||
tells the user that there is no warranty for the work (except to the
 | 
			
		||||
extent that warranties are provided), that licensees may convey the
 | 
			
		||||
work under this License, and how to view a copy of this License.  If
 | 
			
		||||
the interface presents a list of user commands or options, such as a
 | 
			
		||||
menu, a prominent item in the list meets this criterion.
 | 
			
		||||
 | 
			
		||||
  1. Source Code.
 | 
			
		||||
 | 
			
		||||
  The "source code" for a work means the preferred form of the work
 | 
			
		||||
for making modifications to it.  "Object code" means any non-source
 | 
			
		||||
form of a work.
 | 
			
		||||
 | 
			
		||||
  A "Standard Interface" means an interface that either is an official
 | 
			
		||||
standard defined by a recognized standards body, or, in the case of
 | 
			
		||||
interfaces specified for a particular programming language, one that
 | 
			
		||||
is widely used among developers working in that language.
 | 
			
		||||
 | 
			
		||||
  The "System Libraries" of an executable work include anything, other
 | 
			
		||||
than the work as a whole, that (a) is included in the normal form of
 | 
			
		||||
packaging a Major Component, but which is not part of that Major
 | 
			
		||||
Component, and (b) serves only to enable use of the work with that
 | 
			
		||||
Major Component, or to implement a Standard Interface for which an
 | 
			
		||||
implementation is available to the public in source code form.  A
 | 
			
		||||
"Major Component", in this context, means a major essential component
 | 
			
		||||
(kernel, window system, and so on) of the specific operating system
 | 
			
		||||
(if any) on which the executable work runs, or a compiler used to
 | 
			
		||||
produce the work, or an object code interpreter used to run it.
 | 
			
		||||
 | 
			
		||||
  The "Corresponding Source" for a work in object code form means all
 | 
			
		||||
the source code needed to generate, install, and (for an executable
 | 
			
		||||
work) run the object code and to modify the work, including scripts to
 | 
			
		||||
control those activities.  However, it does not include the work's
 | 
			
		||||
System Libraries, or general-purpose tools or generally available free
 | 
			
		||||
programs which are used unmodified in performing those activities but
 | 
			
		||||
which are not part of the work.  For example, Corresponding Source
 | 
			
		||||
includes interface definition files associated with source files for
 | 
			
		||||
the work, and the source code for shared libraries and dynamically
 | 
			
		||||
linked subprograms that the work is specifically designed to require,
 | 
			
		||||
such as by intimate data communication or control flow between those
 | 
			
		||||
subprograms and other parts of the work.
 | 
			
		||||
 | 
			
		||||
  The Corresponding Source need not include anything that users
 | 
			
		||||
can regenerate automatically from other parts of the Corresponding
 | 
			
		||||
Source.
 | 
			
		||||
 | 
			
		||||
  The Corresponding Source for a work in source code form is that
 | 
			
		||||
same work.
 | 
			
		||||
 | 
			
		||||
  2. Basic Permissions.
 | 
			
		||||
 | 
			
		||||
  All rights granted under this License are granted for the term of
 | 
			
		||||
copyright on the Program, and are irrevocable provided the stated
 | 
			
		||||
conditions are met.  This License explicitly affirms your unlimited
 | 
			
		||||
permission to run the unmodified Program.  The output from running a
 | 
			
		||||
covered work is covered by this License only if the output, given its
 | 
			
		||||
content, constitutes a covered work.  This License acknowledges your
 | 
			
		||||
rights of fair use or other equivalent, as provided by copyright law.
 | 
			
		||||
 | 
			
		||||
  You may make, run and propagate covered works that you do not
 | 
			
		||||
convey, without conditions so long as your license otherwise remains
 | 
			
		||||
in force.  You may convey covered works to others for the sole purpose
 | 
			
		||||
of having them make modifications exclusively for you, or provide you
 | 
			
		||||
with facilities for running those works, provided that you comply with
 | 
			
		||||
the terms of this License in conveying all material for which you do
 | 
			
		||||
not control copyright.  Those thus making or running the covered works
 | 
			
		||||
for you must do so exclusively on your behalf, under your direction
 | 
			
		||||
and control, on terms that prohibit them from making any copies of
 | 
			
		||||
your copyrighted material outside their relationship with you.
 | 
			
		||||
 | 
			
		||||
  Conveying under any other circumstances is permitted solely under
 | 
			
		||||
the conditions stated below.  Sublicensing is not allowed; section 10
 | 
			
		||||
makes it unnecessary.
 | 
			
		||||
 | 
			
		||||
  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
 | 
			
		||||
 | 
			
		||||
  No covered work shall be deemed part of an effective technological
 | 
			
		||||
measure under any applicable law fulfilling obligations under article
 | 
			
		||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
 | 
			
		||||
similar laws prohibiting or restricting circumvention of such
 | 
			
		||||
measures.
 | 
			
		||||
 | 
			
		||||
  When you convey a covered work, you waive any legal power to forbid
 | 
			
		||||
circumvention of technological measures to the extent such circumvention
 | 
			
		||||
is effected by exercising rights under this License with respect to
 | 
			
		||||
the covered work, and you disclaim any intention to limit operation or
 | 
			
		||||
modification of the work as a means of enforcing, against the work's
 | 
			
		||||
users, your or third parties' legal rights to forbid circumvention of
 | 
			
		||||
technological measures.
 | 
			
		||||
 | 
			
		||||
  4. Conveying Verbatim Copies.
 | 
			
		||||
 | 
			
		||||
  You may convey verbatim copies of the Program's source code as you
 | 
			
		||||
receive it, in any medium, provided that you conspicuously and
 | 
			
		||||
appropriately publish on each copy an appropriate copyright notice;
 | 
			
		||||
keep intact all notices stating that this License and any
 | 
			
		||||
non-permissive terms added in accord with section 7 apply to the code;
 | 
			
		||||
keep intact all notices of the absence of any warranty; and give all
 | 
			
		||||
recipients a copy of this License along with the Program.
 | 
			
		||||
 | 
			
		||||
  You may charge any price or no price for each copy that you convey,
 | 
			
		||||
and you may offer support or warranty protection for a fee.
 | 
			
		||||
 | 
			
		||||
  5. Conveying Modified Source Versions.
 | 
			
		||||
 | 
			
		||||
  You may convey a work based on the Program, or the modifications to
 | 
			
		||||
produce it from the Program, in the form of source code under the
 | 
			
		||||
terms of section 4, provided that you also meet all of these conditions:
 | 
			
		||||
 | 
			
		||||
    a) The work must carry prominent notices stating that you modified
 | 
			
		||||
    it, and giving a relevant date.
 | 
			
		||||
 | 
			
		||||
    b) The work must carry prominent notices stating that it is
 | 
			
		||||
    released under this License and any conditions added under section
 | 
			
		||||
    7.  This requirement modifies the requirement in section 4 to
 | 
			
		||||
    "keep intact all notices".
 | 
			
		||||
 | 
			
		||||
    c) You must license the entire work, as a whole, under this
 | 
			
		||||
    License to anyone who comes into possession of a copy.  This
 | 
			
		||||
    License will therefore apply, along with any applicable section 7
 | 
			
		||||
    additional terms, to the whole of the work, and all its parts,
 | 
			
		||||
    regardless of how they are packaged.  This License gives no
 | 
			
		||||
    permission to license the work in any other way, but it does not
 | 
			
		||||
    invalidate such permission if you have separately received it.
 | 
			
		||||
 | 
			
		||||
    d) If the work has interactive user interfaces, each must display
 | 
			
		||||
    Appropriate Legal Notices; however, if the Program has interactive
 | 
			
		||||
    interfaces that do not display Appropriate Legal Notices, your
 | 
			
		||||
    work need not make them do so.
 | 
			
		||||
 | 
			
		||||
  A compilation of a covered work with other separate and independent
 | 
			
		||||
works, which are not by their nature extensions of the covered work,
 | 
			
		||||
and which are not combined with it such as to form a larger program,
 | 
			
		||||
in or on a volume of a storage or distribution medium, is called an
 | 
			
		||||
"aggregate" if the compilation and its resulting copyright are not
 | 
			
		||||
used to limit the access or legal rights of the compilation's users
 | 
			
		||||
beyond what the individual works permit.  Inclusion of a covered work
 | 
			
		||||
in an aggregate does not cause this License to apply to the other
 | 
			
		||||
parts of the aggregate.
 | 
			
		||||
 | 
			
		||||
  6. Conveying Non-Source Forms.
 | 
			
		||||
 | 
			
		||||
  You may convey a covered work in object code form under the terms
 | 
			
		||||
of sections 4 and 5, provided that you also convey the
 | 
			
		||||
machine-readable Corresponding Source under the terms of this License,
 | 
			
		||||
in one of these ways:
 | 
			
		||||
 | 
			
		||||
    a) Convey the object code in, or embodied in, a physical product
 | 
			
		||||
    (including a physical distribution medium), accompanied by the
 | 
			
		||||
    Corresponding Source fixed on a durable physical medium
 | 
			
		||||
    customarily used for software interchange.
 | 
			
		||||
 | 
			
		||||
    b) Convey the object code in, or embodied in, a physical product
 | 
			
		||||
    (including a physical distribution medium), accompanied by a
 | 
			
		||||
    written offer, valid for at least three years and valid for as
 | 
			
		||||
    long as you offer spare parts or customer support for that product
 | 
			
		||||
    model, to give anyone who possesses the object code either (1) a
 | 
			
		||||
    copy of the Corresponding Source for all the software in the
 | 
			
		||||
    product that is covered by this License, on a durable physical
 | 
			
		||||
    medium customarily used for software interchange, for a price no
 | 
			
		||||
    more than your reasonable cost of physically performing this
 | 
			
		||||
    conveying of source, or (2) access to copy the
 | 
			
		||||
    Corresponding Source from a network server at no charge.
 | 
			
		||||
 | 
			
		||||
    c) Convey individual copies of the object code with a copy of the
 | 
			
		||||
    written offer to provide the Corresponding Source.  This
 | 
			
		||||
    alternative is allowed only occasionally and noncommercially, and
 | 
			
		||||
    only if you received the object code with such an offer, in accord
 | 
			
		||||
    with subsection 6b.
 | 
			
		||||
 | 
			
		||||
    d) Convey the object code by offering access from a designated
 | 
			
		||||
    place (gratis or for a charge), and offer equivalent access to the
 | 
			
		||||
    Corresponding Source in the same way through the same place at no
 | 
			
		||||
    further charge.  You need not require recipients to copy the
 | 
			
		||||
    Corresponding Source along with the object code.  If the place to
 | 
			
		||||
    copy the object code is a network server, the Corresponding Source
 | 
			
		||||
    may be on a different server (operated by you or a third party)
 | 
			
		||||
    that supports equivalent copying facilities, provided you maintain
 | 
			
		||||
    clear directions next to the object code saying where to find the
 | 
			
		||||
    Corresponding Source.  Regardless of what server hosts the
 | 
			
		||||
    Corresponding Source, you remain obligated to ensure that it is
 | 
			
		||||
    available for as long as needed to satisfy these requirements.
 | 
			
		||||
 | 
			
		||||
    e) Convey the object code using peer-to-peer transmission, provided
 | 
			
		||||
    you inform other peers where the object code and Corresponding
 | 
			
		||||
    Source of the work are being offered to the general public at no
 | 
			
		||||
    charge under subsection 6d.
 | 
			
		||||
 | 
			
		||||
  A separable portion of the object code, whose source code is excluded
 | 
			
		||||
from the Corresponding Source as a System Library, need not be
 | 
			
		||||
included in conveying the object code work.
 | 
			
		||||
 | 
			
		||||
  A "User Product" is either (1) a "consumer product", which means any
 | 
			
		||||
tangible personal property which is normally used for personal, family,
 | 
			
		||||
or household purposes, or (2) anything designed or sold for incorporation
 | 
			
		||||
into a dwelling.  In determining whether a product is a consumer product,
 | 
			
		||||
doubtful cases shall be resolved in favor of coverage.  For a particular
 | 
			
		||||
product received by a particular user, "normally used" refers to a
 | 
			
		||||
typical or common use of that class of product, regardless of the status
 | 
			
		||||
of the particular user or of the way in which the particular user
 | 
			
		||||
actually uses, or expects or is expected to use, the product.  A product
 | 
			
		||||
is a consumer product regardless of whether the product has substantial
 | 
			
		||||
commercial, industrial or non-consumer uses, unless such uses represent
 | 
			
		||||
the only significant mode of use of the product.
 | 
			
		||||
 | 
			
		||||
  "Installation Information" for a User Product means any methods,
 | 
			
		||||
procedures, authorization keys, or other information required to install
 | 
			
		||||
and execute modified versions of a covered work in that User Product from
 | 
			
		||||
a modified version of its Corresponding Source.  The information must
 | 
			
		||||
suffice to ensure that the continued functioning of the modified object
 | 
			
		||||
code is in no case prevented or interfered with solely because
 | 
			
		||||
modification has been made.
 | 
			
		||||
 | 
			
		||||
  If you convey an object code work under this section in, or with, or
 | 
			
		||||
specifically for use in, a User Product, and the conveying occurs as
 | 
			
		||||
part of a transaction in which the right of possession and use of the
 | 
			
		||||
User Product is transferred to the recipient in perpetuity or for a
 | 
			
		||||
fixed term (regardless of how the transaction is characterized), the
 | 
			
		||||
Corresponding Source conveyed under this section must be accompanied
 | 
			
		||||
by the Installation Information.  But this requirement does not apply
 | 
			
		||||
if neither you nor any third party retains the ability to install
 | 
			
		||||
modified object code on the User Product (for example, the work has
 | 
			
		||||
been installed in ROM).
 | 
			
		||||
 | 
			
		||||
  The requirement to provide Installation Information does not include a
 | 
			
		||||
requirement to continue to provide support service, warranty, or updates
 | 
			
		||||
for a work that has been modified or installed by the recipient, or for
 | 
			
		||||
the User Product in which it has been modified or installed.  Access to a
 | 
			
		||||
network may be denied when the modification itself materially and
 | 
			
		||||
adversely affects the operation of the network or violates the rules and
 | 
			
		||||
protocols for communication across the network.
 | 
			
		||||
 | 
			
		||||
  Corresponding Source conveyed, and Installation Information provided,
 | 
			
		||||
in accord with this section must be in a format that is publicly
 | 
			
		||||
documented (and with an implementation available to the public in
 | 
			
		||||
source code form), and must require no special password or key for
 | 
			
		||||
unpacking, reading or copying.
 | 
			
		||||
 | 
			
		||||
  7. Additional Terms.
 | 
			
		||||
 | 
			
		||||
  "Additional permissions" are terms that supplement the terms of this
 | 
			
		||||
License by making exceptions from one or more of its conditions.
 | 
			
		||||
Additional permissions that are applicable to the entire Program shall
 | 
			
		||||
be treated as though they were included in this License, to the extent
 | 
			
		||||
that they are valid under applicable law.  If additional permissions
 | 
			
		||||
apply only to part of the Program, that part may be used separately
 | 
			
		||||
under those permissions, but the entire Program remains governed by
 | 
			
		||||
this License without regard to the additional permissions.
 | 
			
		||||
 | 
			
		||||
  When you convey a copy of a covered work, you may at your option
 | 
			
		||||
remove any additional permissions from that copy, or from any part of
 | 
			
		||||
it.  (Additional permissions may be written to require their own
 | 
			
		||||
removal in certain cases when you modify the work.)  You may place
 | 
			
		||||
additional permissions on material, added by you to a covered work,
 | 
			
		||||
for which you have or can give appropriate copyright permission.
 | 
			
		||||
 | 
			
		||||
  Notwithstanding any other provision of this License, for material you
 | 
			
		||||
add to a covered work, you may (if authorized by the copyright holders of
 | 
			
		||||
that material) supplement the terms of this License with terms:
 | 
			
		||||
 | 
			
		||||
    a) Disclaiming warranty or limiting liability differently from the
 | 
			
		||||
    terms of sections 15 and 16 of this License; or
 | 
			
		||||
 | 
			
		||||
    b) Requiring preservation of specified reasonable legal notices or
 | 
			
		||||
    author attributions in that material or in the Appropriate Legal
 | 
			
		||||
    Notices displayed by works containing it; or
 | 
			
		||||
 | 
			
		||||
    c) Prohibiting misrepresentation of the origin of that material, or
 | 
			
		||||
    requiring that modified versions of such material be marked in
 | 
			
		||||
    reasonable ways as different from the original version; or
 | 
			
		||||
 | 
			
		||||
    d) Limiting the use for publicity purposes of names of licensors or
 | 
			
		||||
    authors of the material; or
 | 
			
		||||
 | 
			
		||||
    e) Declining to grant rights under trademark law for use of some
 | 
			
		||||
    trade names, trademarks, or service marks; or
 | 
			
		||||
 | 
			
		||||
    f) Requiring indemnification of licensors and authors of that
 | 
			
		||||
    material by anyone who conveys the material (or modified versions of
 | 
			
		||||
    it) with contractual assumptions of liability to the recipient, for
 | 
			
		||||
    any liability that these contractual assumptions directly impose on
 | 
			
		||||
    those licensors and authors.
 | 
			
		||||
 | 
			
		||||
  All other non-permissive additional terms are considered "further
 | 
			
		||||
restrictions" within the meaning of section 10.  If the Program as you
 | 
			
		||||
received it, or any part of it, contains a notice stating that it is
 | 
			
		||||
governed by this License along with a term that is a further
 | 
			
		||||
restriction, you may remove that term.  If a license document contains
 | 
			
		||||
a further restriction but permits relicensing or conveying under this
 | 
			
		||||
License, you may add to a covered work material governed by the terms
 | 
			
		||||
of that license document, provided that the further restriction does
 | 
			
		||||
not survive such relicensing or conveying.
 | 
			
		||||
 | 
			
		||||
  If you add terms to a covered work in accord with this section, you
 | 
			
		||||
must place, in the relevant source files, a statement of the
 | 
			
		||||
additional terms that apply to those files, or a notice indicating
 | 
			
		||||
where to find the applicable terms.
 | 
			
		||||
 | 
			
		||||
  Additional terms, permissive or non-permissive, may be stated in the
 | 
			
		||||
form of a separately written license, or stated as exceptions;
 | 
			
		||||
the above requirements apply either way.
 | 
			
		||||
 | 
			
		||||
  8. Termination.
 | 
			
		||||
 | 
			
		||||
  You may not propagate or modify a covered work except as expressly
 | 
			
		||||
provided under this License.  Any attempt otherwise to propagate or
 | 
			
		||||
modify it is void, and will automatically terminate your rights under
 | 
			
		||||
this License (including any patent licenses granted under the third
 | 
			
		||||
paragraph of section 11).
 | 
			
		||||
 | 
			
		||||
  However, if you cease all violation of this License, then your
 | 
			
		||||
license from a particular copyright holder is reinstated (a)
 | 
			
		||||
provisionally, unless and until the copyright holder explicitly and
 | 
			
		||||
finally terminates your license, and (b) permanently, if the copyright
 | 
			
		||||
holder fails to notify you of the violation by some reasonable means
 | 
			
		||||
prior to 60 days after the cessation.
 | 
			
		||||
 | 
			
		||||
  Moreover, your license from a particular copyright holder is
 | 
			
		||||
reinstated permanently if the copyright holder notifies you of the
 | 
			
		||||
violation by some reasonable means, this is the first time you have
 | 
			
		||||
received notice of violation of this License (for any work) from that
 | 
			
		||||
copyright holder, and you cure the violation prior to 30 days after
 | 
			
		||||
your receipt of the notice.
 | 
			
		||||
 | 
			
		||||
  Termination of your rights under this section does not terminate the
 | 
			
		||||
licenses of parties who have received copies or rights from you under
 | 
			
		||||
this License.  If your rights have been terminated and not permanently
 | 
			
		||||
reinstated, you do not qualify to receive new licenses for the same
 | 
			
		||||
material under section 10.
 | 
			
		||||
 | 
			
		||||
  9. Acceptance Not Required for Having Copies.
 | 
			
		||||
 | 
			
		||||
  You are not required to accept this License in order to receive or
 | 
			
		||||
run a copy of the Program.  Ancillary propagation of a covered work
 | 
			
		||||
occurring solely as a consequence of using peer-to-peer transmission
 | 
			
		||||
to receive a copy likewise does not require acceptance.  However,
 | 
			
		||||
nothing other than this License grants you permission to propagate or
 | 
			
		||||
modify any covered work.  These actions infringe copyright if you do
 | 
			
		||||
not accept this License.  Therefore, by modifying or propagating a
 | 
			
		||||
covered work, you indicate your acceptance of this License to do so.
 | 
			
		||||
 | 
			
		||||
  10. Automatic Licensing of Downstream Recipients.
 | 
			
		||||
 | 
			
		||||
  Each time you convey a covered work, the recipient automatically
 | 
			
		||||
receives a license from the original licensors, to run, modify and
 | 
			
		||||
propagate that work, subject to this License.  You are not responsible
 | 
			
		||||
for enforcing compliance by third parties with this License.
 | 
			
		||||
 | 
			
		||||
  An "entity transaction" is a transaction transferring control of an
 | 
			
		||||
organization, or substantially all assets of one, or subdividing an
 | 
			
		||||
organization, or merging organizations.  If propagation of a covered
 | 
			
		||||
work results from an entity transaction, each party to that
 | 
			
		||||
transaction who receives a copy of the work also receives whatever
 | 
			
		||||
licenses to the work the party's predecessor in interest had or could
 | 
			
		||||
give under the previous paragraph, plus a right to possession of the
 | 
			
		||||
Corresponding Source of the work from the predecessor in interest, if
 | 
			
		||||
the predecessor has it or can get it with reasonable efforts.
 | 
			
		||||
 | 
			
		||||
  You may not impose any further restrictions on the exercise of the
 | 
			
		||||
rights granted or affirmed under this License.  For example, you may
 | 
			
		||||
not impose a license fee, royalty, or other charge for exercise of
 | 
			
		||||
rights granted under this License, and you may not initiate litigation
 | 
			
		||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
 | 
			
		||||
any patent claim is infringed by making, using, selling, offering for
 | 
			
		||||
sale, or importing the Program or any portion of it.
 | 
			
		||||
 | 
			
		||||
  11. Patents.
 | 
			
		||||
 | 
			
		||||
  A "contributor" is a copyright holder who authorizes use under this
 | 
			
		||||
License of the Program or a work on which the Program is based.  The
 | 
			
		||||
work thus licensed is called the contributor's "contributor version".
 | 
			
		||||
 | 
			
		||||
  A contributor's "essential patent claims" are all patent claims
 | 
			
		||||
owned or controlled by the contributor, whether already acquired or
 | 
			
		||||
hereafter acquired, that would be infringed by some manner, permitted
 | 
			
		||||
by this License, of making, using, or selling its contributor version,
 | 
			
		||||
but do not include claims that would be infringed only as a
 | 
			
		||||
consequence of further modification of the contributor version.  For
 | 
			
		||||
purposes of this definition, "control" includes the right to grant
 | 
			
		||||
patent sublicenses in a manner consistent with the requirements of
 | 
			
		||||
this License.
 | 
			
		||||
 | 
			
		||||
  Each contributor grants you a non-exclusive, worldwide, royalty-free
 | 
			
		||||
patent license under the contributor's essential patent claims, to
 | 
			
		||||
make, use, sell, offer for sale, import and otherwise run, modify and
 | 
			
		||||
propagate the contents of its contributor version.
 | 
			
		||||
 | 
			
		||||
  In the following three paragraphs, a "patent license" is any express
 | 
			
		||||
agreement or commitment, however denominated, not to enforce a patent
 | 
			
		||||
(such as an express permission to practice a patent or covenant not to
 | 
			
		||||
sue for patent infringement).  To "grant" such a patent license to a
 | 
			
		||||
party means to make such an agreement or commitment not to enforce a
 | 
			
		||||
patent against the party.
 | 
			
		||||
 | 
			
		||||
  If you convey a covered work, knowingly relying on a patent license,
 | 
			
		||||
and the Corresponding Source of the work is not available for anyone
 | 
			
		||||
to copy, free of charge and under the terms of this License, through a
 | 
			
		||||
publicly available network server or other readily accessible means,
 | 
			
		||||
then you must either (1) cause the Corresponding Source to be so
 | 
			
		||||
available, or (2) arrange to deprive yourself of the benefit of the
 | 
			
		||||
patent license for this particular work, or (3) arrange, in a manner
 | 
			
		||||
consistent with the requirements of this License, to extend the patent
 | 
			
		||||
license to downstream recipients.  "Knowingly relying" means you have
 | 
			
		||||
actual knowledge that, but for the patent license, your conveying the
 | 
			
		||||
covered work in a country, or your recipient's use of the covered work
 | 
			
		||||
in a country, would infringe one or more identifiable patents in that
 | 
			
		||||
country that you have reason to believe are valid.
 | 
			
		||||
 | 
			
		||||
  If, pursuant to or in connection with a single transaction or
 | 
			
		||||
arrangement, you convey, or propagate by procuring conveyance of, a
 | 
			
		||||
covered work, and grant a patent license to some of the parties
 | 
			
		||||
receiving the covered work authorizing them to use, propagate, modify
 | 
			
		||||
or convey a specific copy of the covered work, then the patent license
 | 
			
		||||
you grant is automatically extended to all recipients of the covered
 | 
			
		||||
work and works based on it.
 | 
			
		||||
 | 
			
		||||
  A patent license is "discriminatory" if it does not include within
 | 
			
		||||
the scope of its coverage, prohibits the exercise of, or is
 | 
			
		||||
conditioned on the non-exercise of one or more of the rights that are
 | 
			
		||||
specifically granted under this License.  You may not convey a covered
 | 
			
		||||
work if you are a party to an arrangement with a third party that is
 | 
			
		||||
in the business of distributing software, under which you make payment
 | 
			
		||||
to the third party based on the extent of your activity of conveying
 | 
			
		||||
the work, and under which the third party grants, to any of the
 | 
			
		||||
parties who would receive the covered work from you, a discriminatory
 | 
			
		||||
patent license (a) in connection with copies of the covered work
 | 
			
		||||
conveyed by you (or copies made from those copies), or (b) primarily
 | 
			
		||||
for and in connection with specific products or compilations that
 | 
			
		||||
contain the covered work, unless you entered into that arrangement,
 | 
			
		||||
or that patent license was granted, prior to 28 March 2007.
 | 
			
		||||
 | 
			
		||||
  Nothing in this License shall be construed as excluding or limiting
 | 
			
		||||
any implied license or other defenses to infringement that may
 | 
			
		||||
otherwise be available to you under applicable patent law.
 | 
			
		||||
 | 
			
		||||
  12. No Surrender of Others' Freedom.
 | 
			
		||||
 | 
			
		||||
  If conditions are imposed on you (whether by court order, agreement or
 | 
			
		||||
otherwise) that contradict the conditions of this License, they do not
 | 
			
		||||
excuse you from the conditions of this License.  If you cannot convey a
 | 
			
		||||
covered work so as to satisfy simultaneously your obligations under this
 | 
			
		||||
License and any other pertinent obligations, then as a consequence you may
 | 
			
		||||
not convey it at all.  For example, if you agree to terms that obligate you
 | 
			
		||||
to collect a royalty for further conveying from those to whom you convey
 | 
			
		||||
the Program, the only way you could satisfy both those terms and this
 | 
			
		||||
License would be to refrain entirely from conveying the Program.
 | 
			
		||||
 | 
			
		||||
  13. Use with the GNU Affero General Public License.
 | 
			
		||||
 | 
			
		||||
  Notwithstanding any other provision of this License, you have
 | 
			
		||||
permission to link or combine any covered work with a work licensed
 | 
			
		||||
under version 3 of the GNU Affero General Public License into a single
 | 
			
		||||
combined work, and to convey the resulting work.  The terms of this
 | 
			
		||||
License will continue to apply to the part which is the covered work,
 | 
			
		||||
but the special requirements of the GNU Affero General Public License,
 | 
			
		||||
section 13, concerning interaction through a network will apply to the
 | 
			
		||||
combination as such.
 | 
			
		||||
 | 
			
		||||
  14. Revised Versions of this License.
 | 
			
		||||
 | 
			
		||||
  The Free Software Foundation may publish revised and/or new versions of
 | 
			
		||||
the GNU General Public License from time to time.  Such new versions will
 | 
			
		||||
be similar in spirit to the present version, but may differ in detail to
 | 
			
		||||
address new problems or concerns.
 | 
			
		||||
 | 
			
		||||
  Each version is given a distinguishing version number.  If the
 | 
			
		||||
Program specifies that a certain numbered version of the GNU General
 | 
			
		||||
Public License "or any later version" applies to it, you have the
 | 
			
		||||
option of following the terms and conditions either of that numbered
 | 
			
		||||
version or of any later version published by the Free Software
 | 
			
		||||
Foundation.  If the Program does not specify a version number of the
 | 
			
		||||
GNU General Public License, you may choose any version ever published
 | 
			
		||||
by the Free Software Foundation.
 | 
			
		||||
 | 
			
		||||
  If the Program specifies that a proxy can decide which future
 | 
			
		||||
versions of the GNU General Public License can be used, that proxy's
 | 
			
		||||
public statement of acceptance of a version permanently authorizes you
 | 
			
		||||
to choose that version for the Program.
 | 
			
		||||
 | 
			
		||||
  Later license versions may give you additional or different
 | 
			
		||||
permissions.  However, no additional obligations are imposed on any
 | 
			
		||||
author or copyright holder as a result of your choosing to follow a
 | 
			
		||||
later version.
 | 
			
		||||
 | 
			
		||||
  15. Disclaimer of Warranty.
 | 
			
		||||
 | 
			
		||||
  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
 | 
			
		||||
APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
 | 
			
		||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
 | 
			
		||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
 | 
			
		||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 | 
			
		||||
PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
 | 
			
		||||
IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
 | 
			
		||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
 | 
			
		||||
 | 
			
		||||
  16. Limitation of Liability.
 | 
			
		||||
 | 
			
		||||
  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
 | 
			
		||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
 | 
			
		||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
 | 
			
		||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
 | 
			
		||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
 | 
			
		||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
 | 
			
		||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
 | 
			
		||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
 | 
			
		||||
SUCH DAMAGES.
 | 
			
		||||
 | 
			
		||||
  17. Interpretation of Sections 15 and 16.
 | 
			
		||||
 | 
			
		||||
  If the disclaimer of warranty and limitation of liability provided
 | 
			
		||||
above cannot be given local legal effect according to their terms,
 | 
			
		||||
reviewing courts shall apply local law that most closely approximates
 | 
			
		||||
an absolute waiver of all civil liability in connection with the
 | 
			
		||||
Program, unless a warranty or assumption of liability accompanies a
 | 
			
		||||
copy of the Program in return for a fee.
 | 
			
		||||
 | 
			
		||||
                     END OF TERMS AND CONDITIONS
 | 
			
		||||
 | 
			
		||||
            How to Apply These Terms to Your New Programs
 | 
			
		||||
 | 
			
		||||
  If you develop a new program, and you want it to be of the greatest
 | 
			
		||||
possible use to the public, the best way to achieve this is to make it
 | 
			
		||||
free software which everyone can redistribute and change under these terms.
 | 
			
		||||
 | 
			
		||||
  To do so, attach the following notices to the program.  It is safest
 | 
			
		||||
to attach them to the start of each source file to most effectively
 | 
			
		||||
state the exclusion of warranty; and each file should have at least
 | 
			
		||||
the "copyright" line and a pointer to where the full notice is found.
 | 
			
		||||
 | 
			
		||||
    <one line to give the program's name and a brief idea of what it does.>
 | 
			
		||||
    Copyright (C) <year>  <name of author>
 | 
			
		||||
 | 
			
		||||
    This program is free software: you can redistribute it and/or modify
 | 
			
		||||
    it under the terms of the GNU General Public License as published by
 | 
			
		||||
    the Free Software Foundation, either version 3 of the License, or
 | 
			
		||||
    (at your option) any later version.
 | 
			
		||||
 | 
			
		||||
    This program is distributed in the hope that it will be useful,
 | 
			
		||||
    but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
			
		||||
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
			
		||||
    GNU General Public License for more details.
 | 
			
		||||
 | 
			
		||||
    You should have received a copy of the GNU General Public License
 | 
			
		||||
    along with this program.  If not, see <https://www.gnu.org/licenses/>.
 | 
			
		||||
 | 
			
		||||
Also add information on how to contact you by electronic and paper mail.
 | 
			
		||||
 | 
			
		||||
  If the program does terminal interaction, make it output a short
 | 
			
		||||
notice like this when it starts in an interactive mode:
 | 
			
		||||
 | 
			
		||||
    <program>  Copyright (C) <year>  <name of author>
 | 
			
		||||
    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
 | 
			
		||||
    This is free software, and you are welcome to redistribute it
 | 
			
		||||
    under certain conditions; type `show c' for details.
 | 
			
		||||
 | 
			
		||||
The hypothetical commands `show w' and `show c' should show the appropriate
 | 
			
		||||
parts of the General Public License.  Of course, your program's commands
 | 
			
		||||
might be different; for a GUI interface, you would use an "about box".
 | 
			
		||||
 | 
			
		||||
  You should also get your employer (if you work as a programmer) or school,
 | 
			
		||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
 | 
			
		||||
For more information on this, and how to apply and follow the GNU GPL, see
 | 
			
		||||
<https://www.gnu.org/licenses/>.
 | 
			
		||||
 | 
			
		||||
  The GNU General Public License does not permit incorporating your program
 | 
			
		||||
into proprietary programs.  If your program is a subroutine library, you
 | 
			
		||||
may consider it more useful to permit linking proprietary applications with
 | 
			
		||||
the library.  If this is what you want to do, use the GNU Lesser General
 | 
			
		||||
Public License instead of this License.  But first, please read
 | 
			
		||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
 | 
			
		||||
							
								
								
									
										331
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										331
									
								
								README.md
									
									
									
									
									
								
							@@ -1,96 +1,191 @@
 | 
			
		||||
# NoteKfet 2020
 | 
			
		||||
 | 
			
		||||
[](https://www.gnu.org/licenses/gpl-3.0.txt)
 | 
			
		||||
[](https://gitlab.crans.org/bde/nk20/nk20/commits/master)
 | 
			
		||||
[](https://gitlab.crans.org/bde/nk20/commits/master)
 | 
			
		||||
[](https://gitlab.crans.org/bde/nk20/commits/main)
 | 
			
		||||
[](https://gitlab.crans.org/bde/nk20/commits/main)
 | 
			
		||||
 | 
			
		||||
## Installation sur un serveur
 | 
			
		||||
## Table des matières
 | 
			
		||||
 | 
			
		||||
On supposera pour la suite que vous utilisez Debian/Ubuntu sur un serveur tout nu ou bien configuré.
 | 
			
		||||
  - [Installation d'une instance de développement](#installation-dune-instance-de-développement)
 | 
			
		||||
  - [Installation d'une instance de production](#installation-dune-instance-de-production)
 | 
			
		||||
 | 
			
		||||
1. Paquets nécessaires
 | 
			
		||||
## Installation d'une instance de développement
 | 
			
		||||
 | 
			
		||||
        $ sudo apt install nginx python3 python3-pip python3-dev uwsgi
 | 
			
		||||
        $ sudo apt install uwsgi-plugin-python3 python3-venv git acl
 | 
			
		||||
L'instance de développement installe la majorité des dépendances dans un environnement Python isolé.
 | 
			
		||||
Bien que cela permette de créer une instance sur toutes les distributions,
 | 
			
		||||
**cela veut dire que vos dépendances ne seront pas mises à jour automatiquement.**
 | 
			
		||||
 | 
			
		||||
    La génération des factures de l'application trésorerie nécessite une installation de LaTeX suffisante :
 | 
			
		||||
1.  **Installation des dépendances de la distribution.**
 | 
			
		||||
    Il y a quelques dépendances qui ne sont pas trouvable dans PyPI.
 | 
			
		||||
    On donne ci-dessous l'exemple pour une distribution basée sur Debian, mais vous pouvez facilement adapter pour ArchLinux ou autre.
 | 
			
		||||
 | 
			
		||||
        $ sudo apt install texlive-latex-extra texlive-fonts-extra texlive-lang-french
 | 
			
		||||
    ```bash
 | 
			
		||||
    $ sudo apt update
 | 
			
		||||
    $ sudo apt install --no-install-recommends -y \
 | 
			
		||||
        ipython3 python3-setuptools python3-venv python3-dev \
 | 
			
		||||
        texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome git
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
2. Clonage du dépot
 | 
			
		||||
2.  **Clonage du dépot** là où vous voulez :
 | 
			
		||||
 | 
			
		||||
    on se met au bon endroit :
 | 
			
		||||
    ```bash
 | 
			
		||||
    $ git clone git@gitlab.crans.org:bde/nk20.git --recursive && cd nk20
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
        $ cd /var/www/
 | 
			
		||||
        $ mkdir note_kfet
 | 
			
		||||
        $ sudo chown www-data:www-data note_kfet
 | 
			
		||||
        $ sudo usermod -a -G www-data $USER
 | 
			
		||||
        $ sudo chmod g+ws note_kfet
 | 
			
		||||
        $ sudo setfacl -d -m "g::rwx" note_kfet
 | 
			
		||||
        $ cd note_kfet
 | 
			
		||||
        $ git clone git@gitlab.crans.org:bde/nk20.git .
 | 
			
		||||
3. Environment Virtuel
 | 
			
		||||
3.  **Création d'un environment de travail Python décorrélé du système.**
 | 
			
		||||
    On n'utilise pas `--system-site-packages` ici pour ne pas avoir des clashs de versions de modules avec le système.
 | 
			
		||||
 | 
			
		||||
   À la racine du projet:
 | 
			
		||||
    ```bash
 | 
			
		||||
    $ python3 -m venv env
 | 
			
		||||
    $ source env/bin/activate  # entrer dans l'environnement
 | 
			
		||||
    (env)$ pip3 install -r requirements.txt
 | 
			
		||||
    (env)$ deactivate  # sortir de l'environnement
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
        $ python3 -m venv env
 | 
			
		||||
        $ source env/bin/activate
 | 
			
		||||
        (env)$ pip3 install -r requirements/base.txt
 | 
			
		||||
        (env)$ pip3 install -r requirements/prod.txt # uniquement en prod, nécessite un base postgres
 | 
			
		||||
        (env)$ deactivate
 | 
			
		||||
4.  **Variable d'environnement.**
 | 
			
		||||
    Copier le fichier `.env_example` vers `.env` à la racine du projet et mettre à jour
 | 
			
		||||
    ce qu'il faut.
 | 
			
		||||
 | 
			
		||||
4. uwsgi  et Nginx
 | 
			
		||||
5.  **Migrations et chargement des données initiales.**
 | 
			
		||||
    Pour initialiser la base de données avec de quoi travailler.
 | 
			
		||||
 | 
			
		||||
    Un exemple de conf est disponible :
 | 
			
		||||
    ```bash
 | 
			
		||||
    (env)$ ./manage.py collectstatic --noinput
 | 
			
		||||
    (env)$ ./manage.py compilemessages
 | 
			
		||||
    (env)$ ./manage.py makemigrations
 | 
			
		||||
    (env)$ ./manage.py migrate
 | 
			
		||||
    (env)$ ./manage.py loaddata initial
 | 
			
		||||
    (env)$ ./manage.py createsuperuser  # Création d'un⋅e utilisateur⋅rice initial
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
        $ cp nginx_note.conf_example nginx_note.conf
 | 
			
		||||
6.  Enjoy :
 | 
			
		||||
 | 
			
		||||
    ***Modifier le fichier pour être en accord avec le reste de votre config***
 | 
			
		||||
    ```bash
 | 
			
		||||
    (env)$ ./manage.py runserver 0.0.0.0:8000
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
    On utilise uwsgi et Nginx pour gérer le coté serveur :
 | 
			
		||||
En mettant `0.0.0.0:8000` après `runserver`, vous rendez votre instance Django
 | 
			
		||||
accessible depuis l'ensemble de votre réseau, pratique pour tester le rendu
 | 
			
		||||
de la note sur un téléphone !
 | 
			
		||||
 | 
			
		||||
       $ sudo ln -sf /var/www/note_kfet/nginx_note.conf /etc/nginx/sites-enabled/
 | 
			
		||||
## Installation d'une instance de production
 | 
			
		||||
Pour déployer facilement la note il est possible d'utiliser le playbook Ansible (sinon vous pouvez toujours le faire a la main, voir plus bas).
 | 
			
		||||
### Avec ansible
 | 
			
		||||
Il vous faudra un serveur sous debian ou ubuntu connecté à internet et que vous souhaiterez accéder à cette instance de la note sur `note.nomdedomaine.tld`.
 | 
			
		||||
 | 
			
		||||
   Si l'on a un emperor (plusieurs instance uwsgi):
 | 
			
		||||
0. Installer Ansible sur votre machine personnelle.
 | 
			
		||||
 | 
			
		||||
        $ sudo ln -sf /var/www/note_kfet/uwsgi_note.ini /etc/uwsgi/sites/
 | 
			
		||||
0. (bis) cloner le dépot sur votre machine personelle.
 | 
			
		||||
 | 
			
		||||
    Sinon:
 | 
			
		||||
1.  Copier le fichier `ansible/host_example`
 | 
			
		||||
``` bash
 | 
			
		||||
$ cp ansible/hosts_example ansible/hosts
 | 
			
		||||
```
 | 
			
		||||
et ajouter sous [dev] et/ou [prod] les serveurs sur lesquels vous souhaitez installer la note.
 | 
			
		||||
2.  Créer un fichier `ansible/host_vars/<note.nomdedomaine.tld.yaml>` sur le modèle des fichiers existants dans `ansible/hosts` et compléter les variables nécessaires.
 | 
			
		||||
 | 
			
		||||
        $ sudo ln -sf /var/www/note_kfet/uwsgi_note.ini /etc/uwsgi/apps-enabled/
 | 
			
		||||
        
 | 
			
		||||
3. lancer `ansible/base.yaml -l <nomdedomaine.tld.yaml>`
 | 
			
		||||
4. Aller vous faire un café, ca peux durer un moment.
 | 
			
		||||
 | 
			
		||||
### Installation manuelle
 | 
			
		||||
 | 
			
		||||
**En production on souhaite absolument utiliser les modules Python packagées dans le gestionnaire de paquet.**
 | 
			
		||||
Cela permet de mettre à jour facilement les dépendances critiques telles que Django.
 | 
			
		||||
 | 
			
		||||
L'installation d'une instance de production néccessite **une installation de Debian Buster ou d'Ubuntu 20.04**.
 | 
			
		||||
 | 
			
		||||
Sinon vous pouvez suivre les étapes décrites ci-dessous.
 | 
			
		||||
 | 
			
		||||
0.  Sous Debian Buster, **activer Debian Backports.** En effet Django 2.2 LTS n'est que disponible dans les backports.
 | 
			
		||||
 | 
			
		||||
    ```bash
 | 
			
		||||
    $ echo "deb http://deb.debian.org/debian buster-backports main" | sudo tee /etc/apt/sources.list.d/deb_debian_org_debian.list
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
1.  **Installation des dépendances APT.**
 | 
			
		||||
    On tire les dépendances le plus possible à partir des dépôts de Debian.
 | 
			
		||||
    On a besoin d'un environnement LaTeX pour générer les factures.
 | 
			
		||||
 | 
			
		||||
    ```bash
 | 
			
		||||
    $ sudo apt update
 | 
			
		||||
    $ sudo apt install --no-install-recommends -t buster-backports -y \
 | 
			
		||||
        python3-django python3-django-crispy-forms \
 | 
			
		||||
        python3-django-extensions python3-django-filters python3-django-polymorphic \
 | 
			
		||||
        python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil \
 | 
			
		||||
        python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache ipython3 \
 | 
			
		||||
        python3-bs4 python3-setuptools python3-docutils \
 | 
			
		||||
        memcached uwsgi uwsgi-plugin-python3 \
 | 
			
		||||
        texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome \
 | 
			
		||||
        nginx python3-venv git acl
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
2.  **Clonage du dépot** dans `/var/www/note_kfet`,
 | 
			
		||||
 | 
			
		||||
    ```bash
 | 
			
		||||
    $ sudo mkdir -p /var/www/note_kfet && cd /var/www/note_kfet
 | 
			
		||||
    $ sudo chown www-data:www-data .
 | 
			
		||||
    $ sudo chmod g+rwx .
 | 
			
		||||
    $ sudo -u www-data git clone https://gitlab.crans.org/bde/nk20.git --recursive
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
3.  **Création d'un environment de travail Python décorrélé du système.**
 | 
			
		||||
 | 
			
		||||
    ```bash
 | 
			
		||||
    $ python3 -m venv env --system-site-packages
 | 
			
		||||
    $ source env/bin/activate  # entrer dans l'environnement
 | 
			
		||||
    (env)$ pip3 install -r requirements.txt
 | 
			
		||||
    (env)$ deactivate  # sortir de l'environnement
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
4.  **Pour configurer UWSGI et NGINX**, des exemples de conf sont disponibles.
 | 
			
		||||
    **_Modifier le fichier pour être en accord avec le reste de votre config_**
 | 
			
		||||
 | 
			
		||||
    ```bash
 | 
			
		||||
    $ cp nginx_note.conf_example nginx_note.conf
 | 
			
		||||
    $ sudo ln -sf /var/www/note_kfet/nginx_note.conf /etc/nginx/sites-enabled/
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
    Si l'on a un emperor (plusieurs instance uwsgi):
 | 
			
		||||
 | 
			
		||||
    ```bash
 | 
			
		||||
    $ sudo ln -sf /var/www/note_kfet/uwsgi_note.ini /etc/uwsgi/sites/
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
    Sinon si on est dans le cas habituel :
 | 
			
		||||
 | 
			
		||||
    ```bash
 | 
			
		||||
    $ sudo ln -sf /var/www/note_kfet/uwsgi_note.ini /etc/uwsgi/apps-enabled/
 | 
			
		||||
    ```
 | 
			
		||||
  
 | 
			
		||||
    Le touch-reload est activé par défault, pour redémarrer la note il suffit donc de faire `touch uwsgi_note.ini`.
 | 
			
		||||
 | 
			
		||||
5. Base de données
 | 
			
		||||
5.  **Base de données.** En production on utilise PostgreSQL. 
 | 
			
		||||
 | 
			
		||||
        $ sudo apt-get install postgresql postgresql-contrib
 | 
			
		||||
 | 
			
		||||
    En prod on utilise postgresql. 
 | 
			
		||||
        
 | 
			
		||||
        $ sudo apt-get install postgresql postgresql-contrib libpq-dev
 | 
			
		||||
        (env)$ pip3 install psycopg2
 | 
			
		||||
    
 | 
			
		||||
    La config de la base de donnée se fait comme suit:
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    a. On se connecte au shell de psql
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
        $ sudo su - postgres
 | 
			
		||||
        $ psql
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    b. On sécurise l'utilisateur postgres
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        postgres=# \password
 | 
			
		||||
        Enter new password:
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
     Conservez ce mot de passe de la meme manière que tous les autres.
 | 
			
		||||
     
 | 
			
		||||
 | 
			
		||||
    c. On créer la basse de donnée, et l'utilisateur associé
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
        postgres=# CREATE USER note WITH PASSWORD 'un_mot_de_passe_sur';
 | 
			
		||||
        CREATE ROLE
 | 
			
		||||
        postgres=# CREATE DATABASE note_db OWNER note;
 | 
			
		||||
        CREATE DATABASE
 | 
			
		||||
 | 
			
		||||
    Si tout va bien :
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        postgres=#\list
 | 
			
		||||
        List of databases
 | 
			
		||||
           Name    |  Owner   | Encoding |   Collate   |    Ctype    |   Access privileges   
 | 
			
		||||
@@ -100,14 +195,14 @@ On supposera pour la suite que vous utilisez Debian/Ubuntu sur un serveur tout n
 | 
			
		||||
         template0 | postgres | UTF8     | fr_FR.UTF-8 | fr_FR.UTF-8 | =c/postgres+postgres=CTc/postgres
 | 
			
		||||
         template1 | postgres | UTF8     | fr_FR.UTF-8 | fr_FR.UTF-8 | =c/postgres  +postgres=CTc/postgres
 | 
			
		||||
        (4 rows)
 | 
			
		||||
    
 | 
			
		||||
6. Variable d'environnement et Migrations
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
6.  Variable d'environnement et Migrations
 | 
			
		||||
 | 
			
		||||
    On copie le fichier `.env_example` vers le fichier `.env` à la racine du projet 
 | 
			
		||||
    et on renseigne des secrets et des paramètres :
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
        DJANGO_APP_STAGE=dev # ou "prod" 
 | 
			
		||||
        DJANGO_DEV_STORE_METHOD=sqllite # ou "postgres"
 | 
			
		||||
        DJANGO_DEV_STORE_METHOD=sqlite # ou "postgres"
 | 
			
		||||
        DJANGO_DB_HOST=localhost
 | 
			
		||||
        DJANGO_DB_NAME=note_db
 | 
			
		||||
        DJANGO_DB_USER=note
 | 
			
		||||
@@ -115,98 +210,94 @@ On supposera pour la suite que vous utilisez Debian/Ubuntu sur un serveur tout n
 | 
			
		||||
        DJANGO_DB_PORT=
 | 
			
		||||
        DJANGO_SECRET_KEY=CHANGE_ME
 | 
			
		||||
        DJANGO_SETTINGS_MODULE="note_kfet.settings
 | 
			
		||||
        DOMAIN=localhost # note.example.com
 | 
			
		||||
        CONTACT_EMAIL=tresorerie.bde@localhost
 | 
			
		||||
        NOTE_URL=localhost # URL où accéder à la note
 | 
			
		||||
        CONTACT_EMAIL=tresorerie.bde@localhost
 | 
			
		||||
        # Le reste n'est utile qu'en production, pour configurer l'envoi des mails
 | 
			
		||||
        NOTE_MAIL=notekfet@localhost
 | 
			
		||||
        EMAIL_HOST=smtp.localhost
 | 
			
		||||
        EMAIL_PORT=465
 | 
			
		||||
        EMAIL_PORT=25
 | 
			
		||||
        EMAIL_USER=notekfet@localhost
 | 
			
		||||
        EMAIL_PASSWORD=CHANGE_ME
 | 
			
		||||
        WIKI_USER=NoteKfet2020
 | 
			
		||||
        WIKI_PASSWORD=CHANGE_ME
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    Ensuite on (re)bascule dans l'environement virtuel et on lance les migrations
 | 
			
		||||
 | 
			
		||||
        $ source /env/bin/activate
 | 
			
		||||
        (env)$ ./manage.py check # pas de bêtise qui traine
 | 
			
		||||
        (env)$ ./manage.py makemigrations
 | 
			
		||||
        (env)$ ./manage.py migrate
 | 
			
		||||
 | 
			
		||||
7. Enjoy
 | 
			
		||||
7.  *Enjoy \o/*
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Installer avec Docker
 | 
			
		||||
### Installation avec Docker
 | 
			
		||||
 | 
			
		||||
Il est possible de travailler sur une instance Docker.
 | 
			
		||||
 | 
			
		||||
1. Cloner le dépôt là où vous voulez :
 | 
			
		||||
    
 | 
			
		||||
        $ git clone git@gitlab.crans.org:bde/nk20.git
 | 
			
		||||
Pour construire l'image Docker `nk20`,
 | 
			
		||||
 | 
			
		||||
2. Copiez le fichier `.env_example` à la racine du projet vers le fichier `.env`,
 | 
			
		||||
et  mettez à jour vos variables d'environnement
 | 
			
		||||
```
 | 
			
		||||
git clone https://gitlab.crans.org/bde/nk20/ --recursive && cd nk20
 | 
			
		||||
docker build . -t nk20
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
3. Dans le fichier `docker_compose.yml`, qu'on suppose déjà configuré,
 | 
			
		||||
   ajouter les lignes suivantes, en les adaptant à la configuration voulue :
 | 
			
		||||
Ensuite pour lancer la note Kfet en tant que vous (option `-u`),
 | 
			
		||||
l'exposer sur son port 80 (option `-p`) et monter le code en écriture (option `-v`),
 | 
			
		||||
 | 
			
		||||
        nk20:
 | 
			
		||||
          build: /chemin/vers/nk20
 | 
			
		||||
          volumes:
 | 
			
		||||
            - /chemin/vers/nk20:/code/
 | 
			
		||||
          env_file: /chemin/vers/nk20/.env
 | 
			
		||||
          restart: always
 | 
			
		||||
          labels:
 | 
			
		||||
            - traefik.domain=ndd.example.com
 | 
			
		||||
            - traefik.frontend.rule=Host:ndd.example.com
 | 
			
		||||
            - traefik.port=8000
 | 
			
		||||
```
 | 
			
		||||
docker run -it --rm -u $(id -u):$(id -g) -v "$(pwd):/var/www/note_kfet/" -p 80:8080 nk20
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
3. Enjoy :
 | 
			
		||||
Si vous souhaitez lancer une commande spéciale, vous pouvez l'ajouter à la fin, par exemple,
 | 
			
		||||
 | 
			
		||||
        $ docker-compose up -d nk20
 | 
			
		||||
```
 | 
			
		||||
docker run -it --rm -u $(id -u):$(id -g) -v "$(pwd):/var/www/note_kfet/" -p 80:8080 nk20 python3 ./manage.py createsuperuser
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Installer un serveur de développement
 | 
			
		||||
#### Avec Docker Compose
 | 
			
		||||
 | 
			
		||||
Avec `./manage.py runserver` il est très rapide de mettre en place
 | 
			
		||||
un serveur de développement par exemple sur son ordinateur.
 | 
			
		||||
On vous conseilles de faire un fichier d'environnement `.env` en prenant exemple sur `.env_example`.
 | 
			
		||||
 | 
			
		||||
1. Cloner le dépôt là où vous voulez :
 | 
			
		||||
Pour par exemple utiliser le Docker de la note Kfet avec Traefik pour réaliser le HTTPS,
 | 
			
		||||
 | 
			
		||||
        $ git clone git@gitlab.crans.org:bde/nk20.git && cd nk20
 | 
			
		||||
 | 
			
		||||
2. Créer un environnement Python isolé
 | 
			
		||||
   pour ne pas interférer avec les versions de paquets systèmes :
 | 
			
		||||
 | 
			
		||||
        $ python3 -m venv venv
 | 
			
		||||
        $ source venv/bin/activate
 | 
			
		||||
        (env)$ pip install -r requirements/base.txt
 | 
			
		||||
 | 
			
		||||
3. Copier le fichier `.env_example` vers `.env` à la racine du projet et mettre à jour
 | 
			
		||||
ce qu'il faut
 | 
			
		||||
 | 
			
		||||
4. Migrations et chargement des données initiales :
 | 
			
		||||
 | 
			
		||||
        (env)$ ./manage.py makemigrations
 | 
			
		||||
        (env)$ ./manage.py migrate
 | 
			
		||||
        (env)$ ./manage.py loaddata initial
 | 
			
		||||
 | 
			
		||||
5. Créer un super-utilisateur :
 | 
			
		||||
 | 
			
		||||
        (env)$ ./manage.py createsuperuser
 | 
			
		||||
 | 
			
		||||
6. Enjoy :
 | 
			
		||||
 | 
			
		||||
        (env)$ ./manage.py runserver 0.0.0.0:8000
 | 
			
		||||
 | 
			
		||||
En mettant `0.0.0.0:8000` après `runserver`, vous rendez votre instance Django
 | 
			
		||||
accessible depuis l'ensemble de votre réseau, pratique pour tester le rendu
 | 
			
		||||
de la note sur un téléphone !
 | 
			
		||||
 | 
			
		||||
## Cahier des Charges 
 | 
			
		||||
 | 
			
		||||
Il est disponible [ici](https://wiki.crans.org/NoteKfet/NoteKfet2018/CdC). 
 | 
			
		||||
```YAML
 | 
			
		||||
nk20:
 | 
			
		||||
  build: /chemin/vers/le/code/nk20
 | 
			
		||||
  volumes:
 | 
			
		||||
    - /chemin/vers/le/code/nk20:/var/www/note_kfet/
 | 
			
		||||
  env_file: /chemin/vers/le/code/nk20/.env
 | 
			
		||||
  restart: always
 | 
			
		||||
  labels:
 | 
			
		||||
    - "traefik.http.routers.nk20.rule=Host(`ndd.example.com`)"
 | 
			
		||||
    - "traefik.http.services.nk20.loadbalancer.server.port=8080"
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Documentation
 | 
			
		||||
 | 
			
		||||
La documentation est générée par django et son module admindocs.
 | 
			
		||||
Le cahier des charges initial est disponible [sur le Wiki Crans](https://wiki.crans.org/NoteKfet/NoteKfet2018/CdC).
 | 
			
		||||
 | 
			
		||||
La documentation des classes et fonctions est directement dans le code et est explorable à partir de la partie documentation de l'interface d'administration de Django.
 | 
			
		||||
**Commentez votre code !**
 | 
			
		||||
 | 
			
		||||
La documentation plus haut niveau sur le développement et sur l'utilisation
 | 
			
		||||
est disponible sur <https://note.crans.org/doc> et également dans le dossier `docs`.
 | 
			
		||||
 | 
			
		||||
## FAQ
 | 
			
		||||
 | 
			
		||||
### Regénérer les fichiers de traduction
 | 
			
		||||
 | 
			
		||||
Pour regénérer les traductions vous pouvez vous placer à la racine du projet et lancer le script `makemessages`.
 | 
			
		||||
Il faut penser à ignorer les dossiers ne contenant pas notre code, dont le virtualenv.
 | 
			
		||||
De plus, il faut aussi extraire les variables des fichiers JavaScript.
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
python3 manage.py makemessages -i env
 | 
			
		||||
python3 manage.py makemessages -i env -e js -d djangojs
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Une fois les fichiers édités, vous pouvez compiler les nouvelles traductions avec
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
python3 manage.py compilemessages
 | 
			
		||||
python3 manage.py compilejsmessages
 | 
			
		||||
```
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,19 @@
 | 
			
		||||
#!/usr/bin/env ansible-playbook
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
- hosts: bde-nk20-beta.adh.crans.org
 | 
			
		||||
- hosts: all
 | 
			
		||||
  vars_prompt:
 | 
			
		||||
    - name: DB_PASSWORD
 | 
			
		||||
      prompt: "Password of the database (leave it blank to skip database init)"
 | 
			
		||||
      private: yes
 | 
			
		||||
  vars:
 | 
			
		||||
    mirror: eclats.crans.org
 | 
			
		||||
  roles:
 | 
			
		||||
    - 1-apt-basic
 | 
			
		||||
    - 2-nk20
 | 
			
		||||
    - 3-pip
 | 
			
		||||
    - 4-nginx
 | 
			
		||||
    - 5-certbot
 | 
			
		||||
    - 4-certbot
 | 
			
		||||
    - 5-nginx
 | 
			
		||||
    - 6-psql
 | 
			
		||||
    - 7-postinstall
 | 
			
		||||
    - 8-docs
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										7
									
								
								ansible/host_vars/bde-note-dev.adh.crans.org.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								ansible/host_vars/bde-note-dev.adh.crans.org.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
---
 | 
			
		||||
note:
 | 
			
		||||
  server_name: note-dev.crans.org
 | 
			
		||||
  git_branch: beta
 | 
			
		||||
  serve_static: false
 | 
			
		||||
  cron_enabled: false
 | 
			
		||||
  email: notekfet2020@lists.crans.org
 | 
			
		||||
							
								
								
									
										7
									
								
								ansible/host_vars/bde-note.adh.crans.org.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								ansible/host_vars/bde-note.adh.crans.org.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
---
 | 
			
		||||
note:
 | 
			
		||||
  server_name: note.crans.org
 | 
			
		||||
  git_branch: main
 | 
			
		||||
  serve_static: true
 | 
			
		||||
  cron_enabled: true
 | 
			
		||||
  email: notekfet2020@lists.crans.org
 | 
			
		||||
@@ -1,5 +0,0 @@
 | 
			
		||||
[server]
 | 
			
		||||
bde-nk20-beta.adh.crans.org
 | 
			
		||||
 | 
			
		||||
[all:vars]
 | 
			
		||||
ansible_python_interpreter=/usr/bin/python3
 | 
			
		||||
							
								
								
									
										8
									
								
								ansible/hosts_example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								ansible/hosts_example
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
[dev]
 | 
			
		||||
bde-note-dev.adh.crans.org
 | 
			
		||||
 | 
			
		||||
[prod]
 | 
			
		||||
bde-note.adh.crans.org
 | 
			
		||||
 | 
			
		||||
[all:vars]
 | 
			
		||||
ansible_python_interpreter=/usr/bin/python3
 | 
			
		||||
@@ -1,21 +1,54 @@
 | 
			
		||||
---
 | 
			
		||||
- name: Install basic APT packages
 | 
			
		||||
- name: Add buster-backports to apt sources if needed
 | 
			
		||||
  apt_repository:
 | 
			
		||||
    repo: deb http://{{ mirror }}/debian buster-backports main
 | 
			
		||||
    state: present
 | 
			
		||||
  when:
 | 
			
		||||
    - ansible_distribution == "Debian"
 | 
			
		||||
    - ansible_distribution_major_version | int == 10
 | 
			
		||||
 | 
			
		||||
- name: Install note_kfet APT dependencies
 | 
			
		||||
  apt:
 | 
			
		||||
    update_cache: true
 | 
			
		||||
    install_recommends: false
 | 
			
		||||
    name:
 | 
			
		||||
      - nginx
 | 
			
		||||
      - python3
 | 
			
		||||
      # Common tools
 | 
			
		||||
      - gettext
 | 
			
		||||
      - git
 | 
			
		||||
      - ipython3
 | 
			
		||||
 | 
			
		||||
      # Front-end dependencies
 | 
			
		||||
      - fonts-font-awesome
 | 
			
		||||
      - libjs-bootstrap4
 | 
			
		||||
 | 
			
		||||
      # Python dependencies
 | 
			
		||||
      - python3-babel
 | 
			
		||||
      - python3-bs4
 | 
			
		||||
      - python3-django
 | 
			
		||||
      - python3-django-crispy-forms
 | 
			
		||||
      - python3-django-extensions
 | 
			
		||||
      - python3-django-filters
 | 
			
		||||
      - python3-django-oauth-toolkit
 | 
			
		||||
      - python3-django-polymorphic
 | 
			
		||||
      - python3-djangorestframework
 | 
			
		||||
      - python3-lockfile
 | 
			
		||||
      - python3-memcache
 | 
			
		||||
      - python3-phonenumbers
 | 
			
		||||
      - python3-pil
 | 
			
		||||
      - python3-pip
 | 
			
		||||
      - python3-dev
 | 
			
		||||
      - python3-psycopg2
 | 
			
		||||
      - python3-setuptools
 | 
			
		||||
      - python3-venv
 | 
			
		||||
 | 
			
		||||
      # LaTeX (PDF generation)
 | 
			
		||||
      - texlive-xetex
 | 
			
		||||
 | 
			
		||||
      # Cache server
 | 
			
		||||
      - memcached
 | 
			
		||||
 | 
			
		||||
      # WSGI server
 | 
			
		||||
      - uwsgi
 | 
			
		||||
      - uwsgi-plugin-python3
 | 
			
		||||
      - python3-venv
 | 
			
		||||
      - git
 | 
			
		||||
      - acl
 | 
			
		||||
      - gettext
 | 
			
		||||
      - texlive-latex-extra
 | 
			
		||||
      - texlive-fonts-extra
 | 
			
		||||
      - texlive-lang-french
 | 
			
		||||
  register: pkg_result
 | 
			
		||||
  retries: 3
 | 
			
		||||
  until: pkg_result is succeeded
 | 
			
		||||
 
 | 
			
		||||
@@ -11,14 +11,15 @@
 | 
			
		||||
  git:
 | 
			
		||||
    repo: https://gitlab.crans.org/bde/nk20.git
 | 
			
		||||
    dest: /var/www/note_kfet
 | 
			
		||||
    version: beta-soon
 | 
			
		||||
    version: "{{ note.git_branch }}"
 | 
			
		||||
    force: true
 | 
			
		||||
 | 
			
		||||
- name: Use default env vars (should be updated!)
 | 
			
		||||
  template:
 | 
			
		||||
    src: "env_example"
 | 
			
		||||
    src: "env.j2"
 | 
			
		||||
    dest: "/var/www/note_kfet/.env"
 | 
			
		||||
    mode: 0644
 | 
			
		||||
    force: false
 | 
			
		||||
 | 
			
		||||
- name: Update permissions for note_kfet dir
 | 
			
		||||
  file:
 | 
			
		||||
@@ -27,3 +28,21 @@
 | 
			
		||||
    recurse: yes
 | 
			
		||||
    owner: www-data
 | 
			
		||||
    group: www-data
 | 
			
		||||
 | 
			
		||||
- name: Setup cron jobs
 | 
			
		||||
  when: "note.cron_enabled"
 | 
			
		||||
  template:
 | 
			
		||||
    src: note.cron.j2
 | 
			
		||||
    dest: /etc/cron.d/note
 | 
			
		||||
    owner: root
 | 
			
		||||
    group: root
 | 
			
		||||
 | 
			
		||||
- name: Set default directory to /var/www/note_kfet
 | 
			
		||||
  lineinfile:
 | 
			
		||||
    path: /etc/skel/.bashrc
 | 
			
		||||
    line: 'cd /var/www/note_kfet'
 | 
			
		||||
 | 
			
		||||
- name: Automatically source Python virtual environment
 | 
			
		||||
  lineinfile:
 | 
			
		||||
    path: /etc/skel/.bashrc
 | 
			
		||||
    line: 'source /var/www/note_kfet/env/bin/activate'
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										23
									
								
								ansible/roles/2-nk20/templates/env.j2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								ansible/roles/2-nk20/templates/env.j2
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
DJANGO_APP_STAGE=prod
 | 
			
		||||
# Only used in dev mode, change to "postgresql" if you want to use PostgreSQL in dev
 | 
			
		||||
DJANGO_DEV_STORE_METHOD=sqlite
 | 
			
		||||
DJANGO_DB_HOST=localhost
 | 
			
		||||
DJANGO_DB_NAME=note_db
 | 
			
		||||
DJANGO_DB_USER=note
 | 
			
		||||
DJANGO_DB_PASSWORD={{ DB_PASSWORD }}
 | 
			
		||||
DJANGO_DB_PORT=
 | 
			
		||||
DJANGO_SECRET_KEY=CHANGE_ME
 | 
			
		||||
DJANGO_SETTINGS_MODULE=note_kfet.settings
 | 
			
		||||
CONTACT_EMAIL=tresorerie.bde@localhost
 | 
			
		||||
NOTE_URL= {{note.server_name}}
 | 
			
		||||
 | 
			
		||||
# Config for mails. Only used in production
 | 
			
		||||
NOTE_MAIL=notekfet@localhost
 | 
			
		||||
EMAIL_HOST=smtp.localhost
 | 
			
		||||
EMAIL_PORT=25
 | 
			
		||||
EMAIL_USER=notekfet@localhost
 | 
			
		||||
EMAIL_PASSWORD=CHANGE_ME
 | 
			
		||||
 | 
			
		||||
# Wiki configuration
 | 
			
		||||
WIKI_USER=NoteKfet2020
 | 
			
		||||
WIKI_PASSWORD=
 | 
			
		||||
							
								
								
									
										1
									
								
								ansible/roles/2-nk20/templates/note.cron.j2
									
									
									
									
									
										Symbolic link
									
								
							
							
						
						
									
										1
									
								
								ansible/roles/2-nk20/templates/note.cron.j2
									
									
									
									
									
										Symbolic link
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
../../../../note.cron
 | 
			
		||||
@@ -1,14 +1,8 @@
 | 
			
		||||
---
 | 
			
		||||
- name: Install PIP basic dependencies
 | 
			
		||||
  pip:
 | 
			
		||||
    requirements: /var/www/note_kfet/requirements/base.txt
 | 
			
		||||
    virtualenv: /var/www/note_kfet/env
 | 
			
		||||
    virtualenv_command: /usr/bin/python3 -m venv
 | 
			
		||||
  become_user: www-data
 | 
			
		||||
 | 
			
		||||
- name: Install PIP production dependencies
 | 
			
		||||
  pip:
 | 
			
		||||
    requirements: /var/www/note_kfet/requirements/production.txt
 | 
			
		||||
    requirements: /var/www/note_kfet/requirements.txt
 | 
			
		||||
    virtualenv: /var/www/note_kfet/env
 | 
			
		||||
    virtualenv_command: /usr/bin/python3 -m venv
 | 
			
		||||
    virtualenv_site_packages: true
 | 
			
		||||
  become_user: www-data
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										40
									
								
								ansible/roles/4-certbot/tasks/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								ansible/roles/4-certbot/tasks/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
			
		||||
---
 | 
			
		||||
- name: Install basic APT packages
 | 
			
		||||
  apt:
 | 
			
		||||
    update_cache: true
 | 
			
		||||
    name:
 | 
			
		||||
      - certbot
 | 
			
		||||
      - python3-certbot-nginx
 | 
			
		||||
  register: pkg_result
 | 
			
		||||
  retries: 3
 | 
			
		||||
  until: pkg_result is succeeded
 | 
			
		||||
 | 
			
		||||
- name: Check if certificate already exists.
 | 
			
		||||
  stat:
 | 
			
		||||
    path: /etc/letsencrypt/live/{{note.server_name}}/cert.pem
 | 
			
		||||
  register: letsencrypt_cert
 | 
			
		||||
 | 
			
		||||
- name: Create /etc/letsencrypt/conf.d
 | 
			
		||||
  file:
 | 
			
		||||
    path: /etc/letsencrypt/conf.d
 | 
			
		||||
    state: directory
 | 
			
		||||
 | 
			
		||||
- name: Add Certbot configuration
 | 
			
		||||
  template:
 | 
			
		||||
    src: "letsencrypt/conf.d/nk20.ini.j2"
 | 
			
		||||
    dest: "/etc/letsencrypt/conf.d/nk20.ini"
 | 
			
		||||
    mode: 0644
 | 
			
		||||
 | 
			
		||||
- name: Stop services to allow certbot to generate a cert.
 | 
			
		||||
  service:
 | 
			
		||||
    name: nginx
 | 
			
		||||
    state: stopped
 | 
			
		||||
 | 
			
		||||
- name: Generate new certificate if one doesn't exist.
 | 
			
		||||
  shell: "certbot certonly --non-interactive --agree-tos --config /etc/letsencrypt/conf.d/nk20.ini -d {{note.server_name}}"
 | 
			
		||||
  when: letsencrypt_cert.stat.exists == False
 | 
			
		||||
 | 
			
		||||
- name: Restart services to allow certbot to generate a cert.
 | 
			
		||||
  service:
 | 
			
		||||
    name: nginx
 | 
			
		||||
    state: started
 | 
			
		||||
@@ -10,7 +10,7 @@ rsa-key-size = 4096
 | 
			
		||||
# server = https://acme-staging.api.letsencrypt.org/directory
 | 
			
		||||
 | 
			
		||||
# Uncomment and update to register with the specified e-mail address
 | 
			
		||||
email = notekfet2020@lists.crans.org
 | 
			
		||||
email = {{ note.email }}
 | 
			
		||||
 | 
			
		||||
# Uncomment to use a text interface instead of ncurses
 | 
			
		||||
text = True
 | 
			
		||||
@@ -1,21 +0,0 @@
 | 
			
		||||
---
 | 
			
		||||
- name: Install basic APT packages
 | 
			
		||||
  apt:
 | 
			
		||||
    update_cache: true
 | 
			
		||||
    name:
 | 
			
		||||
      - certbot
 | 
			
		||||
      - python3-certbot-nginx
 | 
			
		||||
  register: pkg_result
 | 
			
		||||
  retries: 3
 | 
			
		||||
  until: pkg_result is succeeded
 | 
			
		||||
 | 
			
		||||
- name: Create /etc/letsencrypt/conf.d
 | 
			
		||||
  file:
 | 
			
		||||
    path: /etc/letsencrypt/conf.d
 | 
			
		||||
    state: directory
 | 
			
		||||
 | 
			
		||||
- name: Add Certbot configuration
 | 
			
		||||
  template:
 | 
			
		||||
    src: "letsencrypt/conf.d/nk20.ini.j2"
 | 
			
		||||
    dest: "/etc/letsencrypt/conf.d/nk20.ini"
 | 
			
		||||
    mode: 0644
 | 
			
		||||
@@ -1,4 +1,11 @@
 | 
			
		||||
---
 | 
			
		||||
- name: Install NGINX
 | 
			
		||||
  apt:
 | 
			
		||||
    name: nginx
 | 
			
		||||
  register: pkg_result
 | 
			
		||||
  retries: 3
 | 
			
		||||
  until: pkg_result is succeeded
 | 
			
		||||
 | 
			
		||||
- name: Copy conf of Nginx
 | 
			
		||||
  template:
 | 
			
		||||
    src: "nginx_note.conf"
 | 
			
		||||
@@ -15,6 +22,11 @@
 | 
			
		||||
    group: www-data
 | 
			
		||||
    state: link
 | 
			
		||||
 | 
			
		||||
- name: Disable default Nginx site
 | 
			
		||||
  file:
 | 
			
		||||
    dest: /etc/nginx/sites-enabled/default
 | 
			
		||||
    state: absent
 | 
			
		||||
 | 
			
		||||
- name: Copy conf of UWSGI
 | 
			
		||||
  file:
 | 
			
		||||
    src: /var/www/note_kfet/uwsgi_note.ini
 | 
			
		||||
							
								
								
									
										69
									
								
								ansible/roles/5-nginx/templates/nginx_note.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								ansible/roles/5-nginx/templates/nginx_note.conf
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,69 @@
 | 
			
		||||
# the upstream component nginx needs to connect to
 | 
			
		||||
upstream note {
 | 
			
		||||
    server unix:///var/www/note_kfet/note_kfet.sock; # file socket
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Redirect HTTP to nk20 HTTPS
 | 
			
		||||
server {
 | 
			
		||||
    listen 80 default_server;
 | 
			
		||||
    listen [::]:80 default_server;
 | 
			
		||||
 | 
			
		||||
    location / {
 | 
			
		||||
        return 301 https://{{ note.server_name }}$request_uri;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Redirect all HTTPS to nk20 HTTPS
 | 
			
		||||
server {
 | 
			
		||||
    listen 443 ssl default_server;
 | 
			
		||||
    listen [::]:443 ssl default_server;
 | 
			
		||||
 | 
			
		||||
    location / {
 | 
			
		||||
        return 301 https://{{ note.server_name }}$request_uri;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    ssl_certificate /etc/letsencrypt/live/{{ note.server_name }}/fullchain.pem;
 | 
			
		||||
    ssl_certificate_key /etc/letsencrypt/live/{{ note.server_name }}/privkey.pem;
 | 
			
		||||
    include /etc/letsencrypt/options-ssl-nginx.conf;
 | 
			
		||||
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# configuration of the server
 | 
			
		||||
server {
 | 
			
		||||
    listen 443 ssl;
 | 
			
		||||
    listen [::]:443 ssl;
 | 
			
		||||
 | 
			
		||||
    # the port your site will be served on
 | 
			
		||||
    # the domain name it will serve for
 | 
			
		||||
    server_name {{ note.server_name }}; # substitute your machine's IP address or FQDN
 | 
			
		||||
    charset     utf-8;
 | 
			
		||||
 | 
			
		||||
    # max upload size
 | 
			
		||||
    client_max_body_size 75M;   # adjust to taste
 | 
			
		||||
 | 
			
		||||
{% if note.serve_static %}
 | 
			
		||||
    # Django media
 | 
			
		||||
    location /media  {
 | 
			
		||||
        alias /var/www/note_kfet/media;  # your Django project's media files - amend as required
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    location /static {
 | 
			
		||||
        alias /var/www/note_kfet/static; # your Django project's static files - amend as required
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
{% endif %}
 | 
			
		||||
    location /doc {
 | 
			
		||||
        alias /var/www/documentation;    # The documentation of the project
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    # Finally, send all non-media requests to the Django server.
 | 
			
		||||
    location / {
 | 
			
		||||
        uwsgi_pass note;
 | 
			
		||||
        include /etc/nginx/uwsgi_params;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    ssl_certificate /etc/letsencrypt/live/{{ note.server_name }}/fullchain.pem;
 | 
			
		||||
    ssl_certificate_key /etc/letsencrypt/live/{{ note.server_name }}/privkey.pem;
 | 
			
		||||
    include /etc/letsencrypt/options-ssl-nginx.conf;
 | 
			
		||||
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
 | 
			
		||||
}
 | 
			
		||||
@@ -10,17 +10,15 @@
 | 
			
		||||
  retries: 3
 | 
			
		||||
  until: pkg_result is succeeded
 | 
			
		||||
 | 
			
		||||
- name: Install Psycopg2
 | 
			
		||||
  pip:
 | 
			
		||||
    name: psycopg2-binary
 | 
			
		||||
 | 
			
		||||
- name: Create role note
 | 
			
		||||
  when: DB_PASSWORD|length > 0 # If the password is not defined, skip the installation
 | 
			
		||||
  postgresql_user:
 | 
			
		||||
    name: note
 | 
			
		||||
    password: "CHANGE_ME"
 | 
			
		||||
    password: "{{ DB_PASSWORD }}"
 | 
			
		||||
  become_user: postgres
 | 
			
		||||
 | 
			
		||||
- name: Create NK20 database
 | 
			
		||||
  when: DB_PASSWORD|length >0
 | 
			
		||||
  postgresql_db:
 | 
			
		||||
    name: note_db
 | 
			
		||||
    owner: note
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
---
 | 
			
		||||
- name: Make Django migrations
 | 
			
		||||
  command: /var/www/note_kfet/env/bin/python manage.py makemigrations
 | 
			
		||||
- name: Collect static files
 | 
			
		||||
  command: /var/www/note_kfet/env/bin/python manage.py collectstatic --noinput
 | 
			
		||||
  args:
 | 
			
		||||
    chdir: /var/www/note_kfet
 | 
			
		||||
  become_user: www-data
 | 
			
		||||
@@ -9,7 +9,7 @@
 | 
			
		||||
  command: /var/www/note_kfet/env/bin/python manage.py migrate
 | 
			
		||||
  args:
 | 
			
		||||
    chdir: /var/www/note_kfet
 | 
			
		||||
  become_user: www-data
 | 
			
		||||
  become_user: postgres
 | 
			
		||||
 | 
			
		||||
- name: Compile messages
 | 
			
		||||
  command: /var/www/note_kfet/env/bin/python manage.py compilemessages
 | 
			
		||||
@@ -17,8 +17,14 @@
 | 
			
		||||
    chdir: /var/www/note_kfet
 | 
			
		||||
  become_user: www-data
 | 
			
		||||
 | 
			
		||||
- name: Compile JavaScript messages
 | 
			
		||||
  command: /var/www/note_kfet/env/bin/python manage.py compilejsmessages
 | 
			
		||||
  args:
 | 
			
		||||
    chdir: /var/www/note_kfet
 | 
			
		||||
  become_user: www-data
 | 
			
		||||
 | 
			
		||||
- name: Install initial fixtures
 | 
			
		||||
  command: /var/www/note_kfet/env/bin/python manage.py loaddata initial
 | 
			
		||||
  args:
 | 
			
		||||
    chdir: /var/www/note_kfet
 | 
			
		||||
  become_user: www-data
 | 
			
		||||
  become_user: postgres
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								ansible/roles/8-docs/tasks/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								ansible/roles/8-docs/tasks/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
---
 | 
			
		||||
- name: Install Sphinx and RTD theme
 | 
			
		||||
  pip:
 | 
			
		||||
    requirements: /var/www/note_kfet/docs/requirements.txt
 | 
			
		||||
    virtualenv: /var/www/note_kfet/env
 | 
			
		||||
    virtualenv_command: /usr/bin/python3 -m venv
 | 
			
		||||
    virtualenv_site_packages: true
 | 
			
		||||
  become_user: www-data
 | 
			
		||||
 | 
			
		||||
- name: Create documentation directory with good permissions
 | 
			
		||||
  file:
 | 
			
		||||
    path: /var/www/documentation
 | 
			
		||||
    state: directory
 | 
			
		||||
    owner: www-data
 | 
			
		||||
    group: www-data
 | 
			
		||||
    mode: u=rwx,g=rwxs,o=rx
 | 
			
		||||
 | 
			
		||||
- name: Build HTML documentation
 | 
			
		||||
  command: /var/www/note_kfet/env/bin/sphinx-build -b dirhtml /var/www/note_kfet/docs/ /var/www/documentation/
 | 
			
		||||
  become_user: www-data
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
default_app_config = 'activity.apps.ActivityConfig'
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,14 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
from note_kfet.admin import admin_site
 | 
			
		||||
 | 
			
		||||
from .models import Activity, ActivityType, Guest
 | 
			
		||||
from .forms import GuestForm
 | 
			
		||||
from .models import Activity, ActivityType, Entry, Guest, Opener
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Activity, site=admin_site)
 | 
			
		||||
class ActivityAdmin(admin.ModelAdmin):
 | 
			
		||||
    """
 | 
			
		||||
    Admin customisation for Activity
 | 
			
		||||
@@ -19,6 +22,7 @@ class ActivityAdmin(admin.ModelAdmin):
 | 
			
		||||
    ordering = ['-date_start']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(ActivityType, site=admin_site)
 | 
			
		||||
class ActivityTypeAdmin(admin.ModelAdmin):
 | 
			
		||||
    """
 | 
			
		||||
    Admin customisation for ActivityType
 | 
			
		||||
@@ -26,7 +30,26 @@ class ActivityTypeAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ('name', 'can_invite', 'guest_entry_fee')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Register your models here.
 | 
			
		||||
admin.site.register(Activity, ActivityAdmin)
 | 
			
		||||
admin.site.register(ActivityType, ActivityTypeAdmin)
 | 
			
		||||
admin.site.register(Guest)
 | 
			
		||||
@admin.register(Guest, site=admin_site)
 | 
			
		||||
class GuestAdmin(admin.ModelAdmin):
 | 
			
		||||
    """
 | 
			
		||||
    Admin customisation for Guest
 | 
			
		||||
    """
 | 
			
		||||
    list_display = ('last_name', 'first_name', 'activity', 'inviter')
 | 
			
		||||
    form = GuestForm
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Entry, site=admin_site)
 | 
			
		||||
class EntryAdmin(admin.ModelAdmin):
 | 
			
		||||
    """
 | 
			
		||||
    Admin customisation for Entry
 | 
			
		||||
    """
 | 
			
		||||
    list_display = ('note', 'activity', 'time', 'guest')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Opener, site=admin_site)
 | 
			
		||||
class OpenerAdmin(admin.ModelAdmin):
 | 
			
		||||
    """
 | 
			
		||||
    Admin customisation for Opener
 | 
			
		||||
    """
 | 
			
		||||
    list_display = ('activity', 'opener')
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,11 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from rest_framework import serializers
 | 
			
		||||
from rest_framework.validators import UniqueTogetherValidator
 | 
			
		||||
 | 
			
		||||
from ..models import ActivityType, Activity, Guest, Entry, GuestTransaction
 | 
			
		||||
from ..models import Activity, ActivityType, Entry, Guest, GuestTransaction, Opener
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ActivityTypeSerializer(serializers.ModelSerializer):
 | 
			
		||||
@@ -59,3 +61,17 @@ class GuestTransactionSerializer(serializers.ModelSerializer):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = GuestTransaction
 | 
			
		||||
        fields = '__all__'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OpenerSerializer(serializers.ModelSerializer):
 | 
			
		||||
    """
 | 
			
		||||
    REST API Serializer for Openers.
 | 
			
		||||
    The djangorestframework plugin will analyse the model `Opener` and parse all fields in the API.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Opener
 | 
			
		||||
        fields = '__all__'
 | 
			
		||||
        validators = [UniqueTogetherValidator(
 | 
			
		||||
            queryset=Opener.objects.all(), fields=("opener", "activity"),
 | 
			
		||||
            message=_("This opener already exists"))]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from .views import ActivityTypeViewSet, ActivityViewSet, GuestViewSet, EntryViewSet
 | 
			
		||||
from .views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet, OpenerViewSet
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def register_activity_urls(router, path):
 | 
			
		||||
@@ -12,3 +12,4 @@ def register_activity_urls(router, path):
 | 
			
		||||
    router.register(path + '/type', ActivityTypeViewSet)
 | 
			
		||||
    router.register(path + '/guest', GuestViewSet)
 | 
			
		||||
    router.register(path + '/entry', EntryViewSet)
 | 
			
		||||
    router.register(path + '/opener', OpenerViewSet)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,15 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django_filters.rest_framework import DjangoFilterBackend
 | 
			
		||||
from rest_framework.filters import SearchFilter
 | 
			
		||||
from api.filters import RegexSafeSearchFilter
 | 
			
		||||
from api.viewsets import ReadProtectedModelViewSet
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django_filters.rest_framework import DjangoFilterBackend
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework import status
 | 
			
		||||
 | 
			
		||||
from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer, EntrySerializer
 | 
			
		||||
from ..models import ActivityType, Activity, Guest, Entry
 | 
			
		||||
from .serializers import ActivitySerializer, ActivityTypeSerializer, EntrySerializer, GuestSerializer, OpenerSerializer
 | 
			
		||||
from ..models import Activity, ActivityType, Entry, Guest, Opener
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ActivityTypeViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
@@ -15,10 +18,10 @@ class ActivityTypeViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    The djangorestframework plugin will get all `ActivityType` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/activity/type/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = ActivityType.objects.all()
 | 
			
		||||
    queryset = ActivityType.objects.order_by('id')
 | 
			
		||||
    serializer_class = ActivityTypeSerializer
 | 
			
		||||
    filter_backends = [DjangoFilterBackend]
 | 
			
		||||
    filterset_fields = ['name', 'can_invite', ]
 | 
			
		||||
    filterset_fields = ['name', 'manage_entries', 'can_invite', 'guest_entry_fee', ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ActivityViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
@@ -27,10 +30,16 @@ class ActivityViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    The djangorestframework plugin will get all `Activity` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/activity/activity/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = Activity.objects.all()
 | 
			
		||||
    queryset = Activity.objects.order_by('id')
 | 
			
		||||
    serializer_class = ActivitySerializer
 | 
			
		||||
    filter_backends = [DjangoFilterBackend]
 | 
			
		||||
    filterset_fields = ['name', 'description', 'activity_type', ]
 | 
			
		||||
    filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
 | 
			
		||||
    filterset_fields = ['name', 'description', 'activity_type', 'location', 'creater', 'organizer', 'attendees_club',
 | 
			
		||||
                        'date_start', 'date_end', 'valid', 'open', ]
 | 
			
		||||
    search_fields = ['$name', '$description', '$location', '$creater__last_name', '$creater__first_name',
 | 
			
		||||
                     '$creater__email', '$creater__note__alias__name', '$creater__note__alias__normalized_name',
 | 
			
		||||
                     '$organizer__name', '$organizer__email', '$organizer__note__alias__name',
 | 
			
		||||
                     '$organizer__note__alias__normalized_name', '$attendees_club__name', '$attendees_club__email',
 | 
			
		||||
                     '$attendees_club__note__alias__name', '$attendees_club__note__alias__normalized_name', ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GuestViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
@@ -39,10 +48,13 @@ class GuestViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    The djangorestframework plugin will get all `Guest` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/activity/guest/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = Guest.objects.all()
 | 
			
		||||
    queryset = Guest.objects.order_by('id')
 | 
			
		||||
    serializer_class = GuestSerializer
 | 
			
		||||
    filter_backends = [SearchFilter]
 | 
			
		||||
    search_fields = ['$last_name', '$first_name', '$inviter__alias__name', '$inviter__alias__normalized_name', ]
 | 
			
		||||
    filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
 | 
			
		||||
    filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'inviter', 'inviter__alias__name',
 | 
			
		||||
                        'inviter__alias__normalized_name', ]
 | 
			
		||||
    search_fields = ['$activity__name', '$last_name', '$first_name', '$inviter__user__email', '$inviter__alias__name',
 | 
			
		||||
                     '$inviter__alias__normalized_name', ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EntryViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
@@ -51,7 +63,38 @@ class EntryViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    The djangorestframework plugin will get all `Entry` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/activity/entry/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = Entry.objects.all()
 | 
			
		||||
    queryset = Entry.objects.order_by('id')
 | 
			
		||||
    serializer_class = EntrySerializer
 | 
			
		||||
    filter_backends = [SearchFilter]
 | 
			
		||||
    search_fields = ['$last_name', '$first_name', '$inviter__alias__name', '$inviter__alias__normalized_name', ]
 | 
			
		||||
    filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
 | 
			
		||||
    filterset_fields = ['activity', 'time', 'note', 'guest', ]
 | 
			
		||||
    search_fields = ['$activity__name', '$note__user__email', '$note__alias__name', '$note__alias__normalized_name',
 | 
			
		||||
                     '$guest__last_name', '$guest__first_name', ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OpenerViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    """
 | 
			
		||||
    REST Opener View set.
 | 
			
		||||
    The djangorestframework plugin will get all `Opener` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/activity/opener/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = Opener.objects
 | 
			
		||||
    serializer_class = OpenerSerializer
 | 
			
		||||
    filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend]
 | 
			
		||||
    search_fields = ['$opener__alias__name', '$opener__alias__normalized_name',
 | 
			
		||||
                     '$activity__name']
 | 
			
		||||
    filterset_fields = ['opener', 'opener__noteuser__user', 'activity']
 | 
			
		||||
 | 
			
		||||
    def get_serializer_class(self):
 | 
			
		||||
        serializer_class = self.serializer_class
 | 
			
		||||
        if self.request.method in ['PUT', 'PATCH']:
 | 
			
		||||
            # opener-activity can't change
 | 
			
		||||
            serializer_class.Meta.read_only_fields = ('opener', 'acitivity',)
 | 
			
		||||
        return serializer_class
 | 
			
		||||
 | 
			
		||||
    def destroy(self, request, *args, **kwargs):
 | 
			
		||||
        instance = self.get_object()
 | 
			
		||||
        try:
 | 
			
		||||
            self.perform_destroy(instance)
 | 
			
		||||
        except ValidationError as e:
 | 
			
		||||
            return Response({e.code: str(e)}, status.HTTP_400_BAD_REQUEST)
 | 
			
		||||
        return Response(status=status.HTTP_204_NO_CONTENT)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
 
 | 
			
		||||
@@ -1,20 +1,52 @@
 | 
			
		||||
[
 | 
			
		||||
  {
 | 
			
		||||
    "model": "activity.activitytype",
 | 
			
		||||
    "pk": 1,
 | 
			
		||||
    "fields": {
 | 
			
		||||
      "name": "Pot",
 | 
			
		||||
      "can_invite": true,
 | 
			
		||||
      "guest_entry_fee": 500
 | 
			
		||||
    {
 | 
			
		||||
        "model": "activity.activitytype",
 | 
			
		||||
        "pk": 1,
 | 
			
		||||
        "fields": {
 | 
			
		||||
            "name": "Pot",
 | 
			
		||||
            "manage_entries": true,
 | 
			
		||||
            "can_invite": true,
 | 
			
		||||
            "guest_entry_fee": 1000
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "model": "activity.activitytype",
 | 
			
		||||
        "pk": 2,
 | 
			
		||||
        "fields": {
 | 
			
		||||
            "name": "Soir\u00e9e de club",
 | 
			
		||||
            "manage_entries": false,
 | 
			
		||||
            "can_invite": false,
 | 
			
		||||
            "guest_entry_fee": 0
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "model": "activity.activitytype",
 | 
			
		||||
        "pk": 3,
 | 
			
		||||
        "fields": {
 | 
			
		||||
            "name": "Autre",
 | 
			
		||||
            "manage_entries": false,
 | 
			
		||||
            "can_invite": false,
 | 
			
		||||
            "guest_entry_fee": 0
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "model": "activity.activitytype",
 | 
			
		||||
        "pk": 5,
 | 
			
		||||
        "fields": {
 | 
			
		||||
            "name": "Soir\u00e9e avec entrées",
 | 
			
		||||
            "manage_entries": true,
 | 
			
		||||
            "can_invite": false,
 | 
			
		||||
            "guest_entry_fee": 0
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        "model": "activity.activitytype",
 | 
			
		||||
        "pk": 7,
 | 
			
		||||
        "fields": {
 | 
			
		||||
            "name": "Soir\u00e9e avec invitations",
 | 
			
		||||
            "manage_entries": true,
 | 
			
		||||
            "can_invite": true,
 | 
			
		||||
            "guest_entry_fee": 0
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "model": "activity.activitytype",
 | 
			
		||||
    "pk": 2,
 | 
			
		||||
    "fields": {
 | 
			
		||||
      "name": "Soir\u00e9e de club",
 | 
			
		||||
      "can_invite": false,
 | 
			
		||||
      "guest_entry_fee": 0
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
]
 | 
			
		||||
]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,21 +1,50 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
from datetime import timedelta, datetime
 | 
			
		||||
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from random import shuffle
 | 
			
		||||
 | 
			
		||||
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.contrib.contenttypes.models import ContentType
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from member.models import Club
 | 
			
		||||
from note.models import NoteUser, Note
 | 
			
		||||
from note_kfet.inputs import DateTimePickerInput, Autocomplete
 | 
			
		||||
from note.models import Note, NoteUser
 | 
			
		||||
from note_kfet.inputs import Autocomplete
 | 
			
		||||
from note_kfet.middlewares import get_current_request
 | 
			
		||||
from permission.backends import PermissionBackend
 | 
			
		||||
 | 
			
		||||
from .models import Activity, Guest
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ActivityForm(forms.ModelForm):
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        # By default, the Kfet club is attended
 | 
			
		||||
        self.fields["attendees_club"].initial = Club.objects.get(name="Kfet")
 | 
			
		||||
        self.fields["attendees_club"].widget.attrs["placeholder"] = "Kfet"
 | 
			
		||||
        clubs = list(Club.objects.filter(PermissionBackend
 | 
			
		||||
                                         .filter_queryset(get_current_request(), Club, "view")).all())
 | 
			
		||||
        shuffle(clubs)
 | 
			
		||||
        self.fields["organizer"].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
 | 
			
		||||
 | 
			
		||||
    def clean_organizer(self):
 | 
			
		||||
        organizer = self.cleaned_data['organizer']
 | 
			
		||||
        if not organizer.note.is_active:
 | 
			
		||||
            self.add_error('organiser', _('The note of this club is inactive.'))
 | 
			
		||||
        return organizer
 | 
			
		||||
 | 
			
		||||
    def clean_date_end(self):
 | 
			
		||||
        date_end = self.cleaned_data["date_end"]
 | 
			
		||||
        date_start = self.cleaned_data["date_start"]
 | 
			
		||||
        if date_end < date_start:
 | 
			
		||||
            self.add_error("date_end", _("The end date must be after the start date."))
 | 
			
		||||
        return date_end
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Activity
 | 
			
		||||
        exclude = ('creater', 'valid', 'open', )
 | 
			
		||||
        exclude = ('creater', 'valid', 'open', 'opener', )
 | 
			
		||||
        widgets = {
 | 
			
		||||
            "organizer": Autocomplete(
 | 
			
		||||
                model=Club,
 | 
			
		||||
@@ -39,9 +68,18 @@ class ActivityForm(forms.ModelForm):
 | 
			
		||||
 | 
			
		||||
class GuestForm(forms.ModelForm):
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        """
 | 
			
		||||
        Someone can be invited as a Guest to an Activity if:
 | 
			
		||||
        - the activity has not already started.
 | 
			
		||||
        - the activity is validated.
 | 
			
		||||
        - the Guest has not already been invited more than 5 times.
 | 
			
		||||
        - the Guest is already invited.
 | 
			
		||||
        - the inviter already invited 3 peoples.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        cleaned_data = super().clean()
 | 
			
		||||
 | 
			
		||||
        if self.activity.date_start > datetime.now():
 | 
			
		||||
        if timezone.now() > timezone.localtime(self.activity.date_start):
 | 
			
		||||
            self.add_error("inviter", _("You can't invite someone once the activity is started."))
 | 
			
		||||
 | 
			
		||||
        if not self.activity.valid:
 | 
			
		||||
@@ -50,20 +88,20 @@ class GuestForm(forms.ModelForm):
 | 
			
		||||
        one_year = timedelta(days=365)
 | 
			
		||||
 | 
			
		||||
        qs = Guest.objects.filter(
 | 
			
		||||
            first_name=cleaned_data["first_name"],
 | 
			
		||||
            last_name=cleaned_data["last_name"],
 | 
			
		||||
            first_name__iexact=cleaned_data["first_name"],
 | 
			
		||||
            last_name__iexact=cleaned_data["last_name"],
 | 
			
		||||
            activity__date_start__gte=self.activity.date_start - one_year,
 | 
			
		||||
        )
 | 
			
		||||
        if len(qs) >= 5:
 | 
			
		||||
        if qs.filter(entry__isnull=False).count() >= 5:
 | 
			
		||||
            self.add_error("last_name", _("This person has been already invited 5 times this year."))
 | 
			
		||||
 | 
			
		||||
        qs = qs.filter(activity=self.activity)
 | 
			
		||||
        if qs.exists():
 | 
			
		||||
            self.add_error("last_name", _("This person is already invited."))
 | 
			
		||||
 | 
			
		||||
        qs = Guest.objects.filter(inviter=cleaned_data["inviter"], activity=self.activity)
 | 
			
		||||
        if len(qs) >= 3:
 | 
			
		||||
            self.add_error("inviter", _("You can't invite more than 3 people to this activity."))
 | 
			
		||||
        if "inviter" in cleaned_data:
 | 
			
		||||
            if Guest.objects.filter(inviter=cleaned_data["inviter"], activity=self.activity).count() >= 3:
 | 
			
		||||
                self.add_error("inviter", _("You can't invite more than 3 people to this activity."))
 | 
			
		||||
 | 
			
		||||
        return cleaned_data
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										69
									
								
								apps/activity/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								apps/activity/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,69 @@
 | 
			
		||||
# Generated by Django 2.2.16 on 2020-09-04 21:41
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
import django.utils.timezone
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    initial = True
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Activity',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('name', models.CharField(max_length=255, verbose_name='name')),
 | 
			
		||||
                ('description', models.TextField(verbose_name='description')),
 | 
			
		||||
                ('location', models.CharField(blank=True, default='', help_text='Place where the activity is organized, eg. Kfet.', max_length=255, verbose_name='location')),
 | 
			
		||||
                ('date_start', models.DateTimeField(verbose_name='start date')),
 | 
			
		||||
                ('date_end', models.DateTimeField(verbose_name='end date')),
 | 
			
		||||
                ('valid', models.BooleanField(default=False, verbose_name='valid')),
 | 
			
		||||
                ('open', models.BooleanField(default=False, verbose_name='open')),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'verbose_name': 'activity',
 | 
			
		||||
                'verbose_name_plural': 'activities',
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='ActivityType',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('name', models.CharField(max_length=255, verbose_name='name')),
 | 
			
		||||
                ('manage_entries', models.BooleanField(default=False, help_text='Enable the support of entries for this activity.', verbose_name='manage entries')),
 | 
			
		||||
                ('can_invite', models.BooleanField(default=False, verbose_name='can invite')),
 | 
			
		||||
                ('guest_entry_fee', models.PositiveIntegerField(default=0, verbose_name='guest entry fee')),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'verbose_name': 'activity type',
 | 
			
		||||
                'verbose_name_plural': 'activity types',
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Entry',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='entry time')),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'verbose_name': 'entry',
 | 
			
		||||
                'verbose_name_plural': 'entries',
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Guest',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('last_name', models.CharField(max_length=255, verbose_name='last name')),
 | 
			
		||||
                ('first_name', models.CharField(max_length=255, verbose_name='first name')),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'verbose_name': 'guest',
 | 
			
		||||
                'verbose_name_plural': 'guests',
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										89
									
								
								apps/activity/migrations/0002_auto_20200904_2341.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								apps/activity/migrations/0002_auto_20200904_2341.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,89 @@
 | 
			
		||||
# Generated by Django 2.2.16 on 2020-09-04 21:41
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    initial = True
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('activity', '0001_initial'),
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
        ('member', '0001_initial'),
 | 
			
		||||
        ('note', '0001_initial'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='GuestTransaction',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Transaction')),
 | 
			
		||||
                ('entry', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, to='activity.Entry')),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'abstract': False,
 | 
			
		||||
                'base_manager_name': 'objects',
 | 
			
		||||
            },
 | 
			
		||||
            bases=('note.transaction',),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='guest',
 | 
			
		||||
            name='activity',
 | 
			
		||||
            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='activity.Activity'),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='guest',
 | 
			
		||||
            name='inviter',
 | 
			
		||||
            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='guests', to='note.NoteUser', verbose_name='inviter'),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='entry',
 | 
			
		||||
            name='activity',
 | 
			
		||||
            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='entries', to='activity.Activity', verbose_name='activity'),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='entry',
 | 
			
		||||
            name='guest',
 | 
			
		||||
            field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.PROTECT, to='activity.Guest'),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='entry',
 | 
			
		||||
            name='note',
 | 
			
		||||
            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='note.NoteUser', verbose_name='note'),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='activity',
 | 
			
		||||
            name='activity_type',
 | 
			
		||||
            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='activity.ActivityType', verbose_name='type'),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='activity',
 | 
			
		||||
            name='attendees_club',
 | 
			
		||||
            field=models.ForeignKey(help_text='Club that is authorized to join the activity. Mostly the Kfet club.', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='member.Club', verbose_name='attendees club'),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='activity',
 | 
			
		||||
            name='creater',
 | 
			
		||||
            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='user'),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='activity',
 | 
			
		||||
            name='organizer',
 | 
			
		||||
            field=models.ForeignKey(help_text='Club that organizes the activity. The entry fees will go to this club.', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='member.Club', verbose_name='organizer'),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterUniqueTogether(
 | 
			
		||||
            name='guest',
 | 
			
		||||
            unique_together={('activity', 'last_name', 'first_name')},
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterUniqueTogether(
 | 
			
		||||
            name='entry',
 | 
			
		||||
            unique_together={('activity', 'note', 'guest')},
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterUniqueTogether(
 | 
			
		||||
            name='activity',
 | 
			
		||||
            unique_together={('name', 'date_start', 'date_end')},
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										18
									
								
								apps/activity/migrations/0003_auto_20240323_1422.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								apps/activity/migrations/0003_auto_20240323_1422.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 2.2.28 on 2024-03-23 13:22
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('activity', '0002_auto_20200904_2341'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='activity',
 | 
			
		||||
            name='description',
 | 
			
		||||
            field=models.TextField(blank=True, default='', verbose_name='description'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										28
									
								
								apps/activity/migrations/0004_opener.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								apps/activity/migrations/0004_opener.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
# Generated by Django 2.2.28 on 2024-08-01 12:36
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('note', '0006_trust'),
 | 
			
		||||
        ('activity', '0003_auto_20240323_1422'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Opener',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='opener', to='activity.Activity', verbose_name='activity')),
 | 
			
		||||
                ('opener', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_responsible', to='note.Note', verbose_name='opener')),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'verbose_name': 'opener',
 | 
			
		||||
                'verbose_name_plural': 'openers',
 | 
			
		||||
                'unique_together': {('opener', 'activity')},
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,24 @@
 | 
			
		||||
# Generated by Django 4.2.15 on 2024-08-28 08:00
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('note', '0006_trust'),
 | 
			
		||||
        ('activity', '0004_opener'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterModelOptions(
 | 
			
		||||
            name='opener',
 | 
			
		||||
            options={'verbose_name': 'Opener', 'verbose_name_plural': 'Openers'},
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='opener',
 | 
			
		||||
            name='opener',
 | 
			
		||||
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_responsible', to='note.note', verbose_name='Opener'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -1,13 +1,18 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
from datetime import timedelta, datetime
 | 
			
		||||
 | 
			
		||||
import os
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from threading import Thread
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.db import models, transaction
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from note.models import NoteUser, Transaction, Note
 | 
			
		||||
from rest_framework.exceptions import ValidationError
 | 
			
		||||
from note.models import NoteUser, Transaction
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ActivityType(models.Model):
 | 
			
		||||
@@ -23,11 +28,21 @@ class ActivityType(models.Model):
 | 
			
		||||
        verbose_name=_('name'),
 | 
			
		||||
        max_length=255,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    manage_entries = models.BooleanField(
 | 
			
		||||
        verbose_name=_('manage entries'),
 | 
			
		||||
        help_text=_('Enable the support of entries for this activity.'),
 | 
			
		||||
        default=False,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    can_invite = models.BooleanField(
 | 
			
		||||
        verbose_name=_('can invite'),
 | 
			
		||||
        default=False,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    guest_entry_fee = models.PositiveIntegerField(
 | 
			
		||||
        verbose_name=_('guest entry fee'),
 | 
			
		||||
        default=0,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
@@ -51,6 +66,16 @@ class Activity(models.Model):
 | 
			
		||||
 | 
			
		||||
    description = models.TextField(
 | 
			
		||||
        verbose_name=_('description'),
 | 
			
		||||
        blank=True,
 | 
			
		||||
        default="",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    location = models.CharField(
 | 
			
		||||
        verbose_name=_('location'),
 | 
			
		||||
        max_length=255,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        default="",
 | 
			
		||||
        help_text=_("Place where the activity is organized, eg. Kfet."),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    activity_type = models.ForeignKey(
 | 
			
		||||
@@ -71,6 +96,7 @@ class Activity(models.Model):
 | 
			
		||||
        on_delete=models.PROTECT,
 | 
			
		||||
        related_name='+',
 | 
			
		||||
        verbose_name=_('organizer'),
 | 
			
		||||
        help_text=_("Club that organizes the activity. The entry fees will go to this club."),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    attendees_club = models.ForeignKey(
 | 
			
		||||
@@ -78,6 +104,7 @@ class Activity(models.Model):
 | 
			
		||||
        on_delete=models.PROTECT,
 | 
			
		||||
        related_name='+',
 | 
			
		||||
        verbose_name=_('attendees club'),
 | 
			
		||||
        help_text=_("Club that is authorized to join the activity. Mostly the Kfet club."),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    date_start = models.DateTimeField(
 | 
			
		||||
@@ -101,6 +128,31 @@ class Activity(models.Model):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("activity")
 | 
			
		||||
        verbose_name_plural = _("activities")
 | 
			
		||||
        unique_together = ("name", "date_start", "date_end",)
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        """
 | 
			
		||||
        Update the activity wiki page each time the activity is updated (validation, change description, ...)
 | 
			
		||||
        """
 | 
			
		||||
        if self.date_end < self.date_start:
 | 
			
		||||
            raise ValidationError(_("The end date must be after the start date."))
 | 
			
		||||
 | 
			
		||||
        ret = super().save(*args, **kwargs)
 | 
			
		||||
        if not settings.DEBUG and self.pk and "scripts" in settings.INSTALLED_APPS:
 | 
			
		||||
            def refresh_activities():
 | 
			
		||||
                from scripts.management.commands.refresh_activities import Command as RefreshActivitiesCommand
 | 
			
		||||
                # Consider that we can update the wiki iff the WIKI_PASSWORD env var is not empty
 | 
			
		||||
                RefreshActivitiesCommand.refresh_human_readable_wiki_page("Modification de l'activité " + self.name,
 | 
			
		||||
                                                                          False, os.getenv("WIKI_PASSWORD"))
 | 
			
		||||
                RefreshActivitiesCommand.refresh_raw_wiki_page("Modification de l'activité " + self.name,
 | 
			
		||||
                                                               False, os.getenv("WIKI_PASSWORD"))
 | 
			
		||||
            Thread(daemon=True, target=refresh_activities).start()\
 | 
			
		||||
                if settings.DATABASES["default"]["ENGINE"] == 'django.db.backends.postgresql' else refresh_activities()
 | 
			
		||||
        return ret
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Entry(models.Model):
 | 
			
		||||
@@ -118,7 +170,7 @@ class Entry(models.Model):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    time = models.DateTimeField(
 | 
			
		||||
        auto_now_add=True,
 | 
			
		||||
        default=timezone.now,
 | 
			
		||||
        verbose_name=_("entry time"),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
@@ -139,8 +191,14 @@ class Entry(models.Model):
 | 
			
		||||
        verbose_name = _("entry")
 | 
			
		||||
        verbose_name_plural = _("entries")
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return _("Entry for {guest}, invited by {note} to the activity {activity}").format(
 | 
			
		||||
            guest=str(self.guest), note=str(self.note), activity=str(self.activity)) if self.guest \
 | 
			
		||||
            else _("Entry for {note} to the activity {activity}").format(
 | 
			
		||||
            guest=str(self.guest), note=str(self.note), activity=str(self.activity))
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        qs = Entry.objects.filter(~Q(pk=self.pk), activity=self.activity, note=self.note, guest=self.guest)
 | 
			
		||||
        if qs.exists():
 | 
			
		||||
            raise ValidationError(_("Already entered on ") + _("{:%Y-%m-%d %H:%M:%S}").format(qs.get().time, ))
 | 
			
		||||
@@ -196,6 +254,43 @@ class Guest(models.Model):
 | 
			
		||||
        verbose_name=_("inviter"),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("guest")
 | 
			
		||||
        verbose_name_plural = _("guests")
 | 
			
		||||
        unique_together = ("activity", "last_name", "first_name", )
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.first_name + " " + self.last_name
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
 | 
			
		||||
        one_year = timedelta(days=365)
 | 
			
		||||
 | 
			
		||||
        if not force_insert:
 | 
			
		||||
            if timezone.now() > timezone.localtime(self.activity.date_start):
 | 
			
		||||
                raise ValidationError(_("You can't invite someone once the activity is started."))
 | 
			
		||||
 | 
			
		||||
            if not self.activity.valid:
 | 
			
		||||
                raise ValidationError(_("This activity is not validated yet."))
 | 
			
		||||
 | 
			
		||||
            qs = Guest.objects.filter(
 | 
			
		||||
                first_name__iexact=self.first_name,
 | 
			
		||||
                last_name__iexact=self.last_name,
 | 
			
		||||
                activity__date_start__gte=self.activity.date_start - one_year,
 | 
			
		||||
            )
 | 
			
		||||
            if qs.filter(entry__isnull=False).count() >= 5:
 | 
			
		||||
                raise ValidationError(_("This person has been already invited 5 times this year."))
 | 
			
		||||
 | 
			
		||||
            qs = qs.filter(activity=self.activity)
 | 
			
		||||
            if qs.exists():
 | 
			
		||||
                raise ValidationError(_("This person is already invited."))
 | 
			
		||||
 | 
			
		||||
            qs = Guest.objects.filter(inviter=self.inviter, activity=self.activity)
 | 
			
		||||
            if qs.count() >= 3:
 | 
			
		||||
                raise ValidationError(_("You can't invite more than 3 people to this activity."))
 | 
			
		||||
 | 
			
		||||
        return super().save(force_insert, force_update, using, update_fields)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def has_entry(self):
 | 
			
		||||
        try:
 | 
			
		||||
@@ -205,39 +300,6 @@ class Guest(models.Model):
 | 
			
		||||
        except AttributeError:
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
    def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
 | 
			
		||||
        one_year = timedelta(days=365)
 | 
			
		||||
 | 
			
		||||
        if not force_insert:
 | 
			
		||||
            if self.activity.date_start > datetime.now():
 | 
			
		||||
                raise ValidationError(_("You can't invite someone once the activity is started."))
 | 
			
		||||
 | 
			
		||||
            if not self.activity.valid:
 | 
			
		||||
                raise ValidationError(_("This activity is not validated yet."))
 | 
			
		||||
 | 
			
		||||
            qs = Guest.objects.filter(
 | 
			
		||||
                first_name=self.first_name,
 | 
			
		||||
                last_name=self.last_name,
 | 
			
		||||
                activity__date_start__gte=self.activity.date_start - one_year,
 | 
			
		||||
            )
 | 
			
		||||
            if len(qs) >= 5:
 | 
			
		||||
                raise ValidationError(_("This person has been already invited 5 times this year."))
 | 
			
		||||
 | 
			
		||||
            qs = qs.filter(activity=self.activity)
 | 
			
		||||
            if qs.exists():
 | 
			
		||||
                raise ValidationError(_("This person is already invited."))
 | 
			
		||||
 | 
			
		||||
            qs = Guest.objects.filter(inviter=self.inviter, activity=self.activity)
 | 
			
		||||
            if len(qs) >= 3:
 | 
			
		||||
                raise ValidationError(_("You can't invite more than 3 people to this activity."))
 | 
			
		||||
 | 
			
		||||
        return super().save(force_insert, force_update, using, update_fields)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("guest")
 | 
			
		||||
        verbose_name_plural = _("guests")
 | 
			
		||||
        unique_together = ("activity", "last_name", "first_name", )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GuestTransaction(Transaction):
 | 
			
		||||
    entry = models.OneToOneField(
 | 
			
		||||
@@ -248,3 +310,31 @@ class GuestTransaction(Transaction):
 | 
			
		||||
    @property
 | 
			
		||||
    def type(self):
 | 
			
		||||
        return _('Invitation')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Opener(models.Model):
 | 
			
		||||
    """
 | 
			
		||||
    Allow the user to make activity entries without more rights
 | 
			
		||||
    """
 | 
			
		||||
    activity = models.ForeignKey(
 | 
			
		||||
        Activity,
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
        related_name='opener',
 | 
			
		||||
        verbose_name=_('activity')
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    opener = models.ForeignKey(
 | 
			
		||||
        Note,
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
        related_name='activity_responsible',
 | 
			
		||||
        verbose_name=_('Opener')
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Opener")
 | 
			
		||||
        verbose_name_plural = _("Openers")
 | 
			
		||||
        unique_together = ("opener", "activity")
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return _("{opener} is opener of activity {acivity}").format(
 | 
			
		||||
            opener=str(self.opener), acivity=str(self.activity))
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										57
									
								
								apps/activity/static/activity/js/opener.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								apps/activity/static/activity/js/opener.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,57 @@
 | 
			
		||||
/**
 | 
			
		||||
 * On form submit, add a new opener
 | 
			
		||||
 */
 | 
			
		||||
function form_create_opener (e) {
 | 
			
		||||
  // Do not submit HTML form
 | 
			
		||||
  e.preventDefault()
 | 
			
		||||
 | 
			
		||||
  // Get data and send to API
 | 
			
		||||
  const formData = new FormData(e.target)
 | 
			
		||||
  $.getJSON('/api/note/alias/'+formData.get('opener') + '/',
 | 
			
		||||
    function (opener_alias) {
 | 
			
		||||
      create_opener(formData.get('activity'), opener_alias.note)
 | 
			
		||||
    }).fail(function (xhr, _textStatus, _error) {
 | 
			
		||||
        errMsg(xhr.responseJSON)
 | 
			
		||||
    })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Add an opener between an activity and a user
 | 
			
		||||
 * @param activity:Integer activity id
 | 
			
		||||
 * @param opener:Integer user note id
 | 
			
		||||
 */
 | 
			
		||||
function create_opener(activity, opener) {
 | 
			
		||||
  $.post('/api/activity/opener/', {
 | 
			
		||||
      activity: activity,
 | 
			
		||||
      opener: opener,
 | 
			
		||||
      csrfmiddlewaretoken: CSRF_TOKEN
 | 
			
		||||
  }).done(function () {
 | 
			
		||||
  // Reload tables
 | 
			
		||||
  $('#opener_table').load(location.pathname + ' #opener_table')
 | 
			
		||||
    addMsg(gettext('Opener successfully added'), 'success')
 | 
			
		||||
  }).fail(function (xhr, _textStatus, _error) {
 | 
			
		||||
    errMsg(xhr.responseJSON)
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * On click of "delete", delete the opener
 | 
			
		||||
 * @param button_id:Integer Opener id to remove
 | 
			
		||||
 */
 | 
			
		||||
function delete_button (button_id) {
 | 
			
		||||
  $.ajax({
 | 
			
		||||
    url: '/api/activity/opener/' + button_id + '/',
 | 
			
		||||
    method: 'DELETE',
 | 
			
		||||
    headers: { 'X-CSRFTOKEN': CSRF_TOKEN }
 | 
			
		||||
  }).done(function () {
 | 
			
		||||
    addMsg(gettext('Opener successfully deleted'), 'success')
 | 
			
		||||
    $('#opener_table').load(location.pathname + ' #opener_table')
 | 
			
		||||
  }).fail(function (xhr, _textStatus, _error) {
 | 
			
		||||
    errMsg(xhr.responseJSON)
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
$(document).ready(function () {
 | 
			
		||||
  // Attach event
 | 
			
		||||
  document.getElementById('form_opener').addEventListener('submit', form_create_opener)
 | 
			
		||||
})
 | 
			
		||||
@@ -1,13 +1,17 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.utils.html import format_html
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.html import escape
 | 
			
		||||
from django.utils.safestring import mark_safe
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from note_kfet.middlewares import get_current_request
 | 
			
		||||
import django_tables2 as tables
 | 
			
		||||
from django_tables2 import A
 | 
			
		||||
from permission.backends import PermissionBackend
 | 
			
		||||
from note.templatetags.pretty_money import pretty_money
 | 
			
		||||
 | 
			
		||||
from .models import Activity, Guest, Entry
 | 
			
		||||
from .models import Activity, Entry, Guest, Opener
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ActivityTable(tables.Table):
 | 
			
		||||
@@ -20,6 +24,11 @@ class ActivityTable(tables.Table):
 | 
			
		||||
        attrs = {
 | 
			
		||||
            'class': 'table table-condensed table-striped table-hover'
 | 
			
		||||
        }
 | 
			
		||||
        row_attrs = {
 | 
			
		||||
            'class': lambda record: 'bg-success' if record.open else ('' if record.valid else 'bg-warning'),
 | 
			
		||||
            'title': lambda record: _("The activity is currently open.") if record.open else
 | 
			
		||||
            ('' if record.valid else _("The validation of the activity is pending.")),
 | 
			
		||||
        }
 | 
			
		||||
        model = Activity
 | 
			
		||||
        template_name = 'django_tables2/bootstrap4.html'
 | 
			
		||||
        fields = ('name', 'activity_type', 'organizer', 'attendees_club', 'date_start', 'date_end', )
 | 
			
		||||
@@ -28,22 +37,17 @@ class ActivityTable(tables.Table):
 | 
			
		||||
class GuestTable(tables.Table):
 | 
			
		||||
    inviter = tables.LinkColumn(
 | 
			
		||||
        'member:user_detail',
 | 
			
		||||
        args=[A('inviter.user.pk'), ],
 | 
			
		||||
        args=[A('inviter__user__pk'), ],
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    entry = tables.Column(
 | 
			
		||||
        empty_values=(),
 | 
			
		||||
        attrs={
 | 
			
		||||
            "td": {
 | 
			
		||||
                "class": lambda record: "" if record.has_entry else "validate btn btn-danger",
 | 
			
		||||
                "onclick": lambda record: "" if record.has_entry else "remove_guest(" + str(record.pk) + ")"
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        verbose_name=_("Remove"),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        attrs = {
 | 
			
		||||
            'class': 'table table-condensed table-striped table-hover'
 | 
			
		||||
            'class': 'table table-condensed table-striped'
 | 
			
		||||
        }
 | 
			
		||||
        model = Guest
 | 
			
		||||
        template_name = 'django_tables2/bootstrap4.html'
 | 
			
		||||
@@ -52,7 +56,8 @@ class GuestTable(tables.Table):
 | 
			
		||||
    def render_entry(self, record):
 | 
			
		||||
        if record.has_entry:
 | 
			
		||||
            return str(_("Entered on ") + str(_("{:%Y-%m-%d %H:%M:%S}").format(record.entry.time, )))
 | 
			
		||||
        return _("remove").capitalize()
 | 
			
		||||
        return mark_safe('<button id="{id}" class="btn btn-danger btn-sm" onclick="remove_guest(this.id)"> '
 | 
			
		||||
                         '{delete_trans}</button>'.format(id=record.id, delete_trans=_("remove").capitalize()))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_row_class(record):
 | 
			
		||||
@@ -66,6 +71,10 @@ def get_row_class(record):
 | 
			
		||||
        qs = Entry.objects.filter(note=record.note, activity=record.activity, guest=None)
 | 
			
		||||
        if qs.exists():
 | 
			
		||||
            c += " table-success"
 | 
			
		||||
        elif not record.note.user.memberships.filter(club=record.activity.attendees_club,
 | 
			
		||||
                                                     date_start__lte=timezone.now(),
 | 
			
		||||
                                                     date_end__gte=timezone.now()).exists():
 | 
			
		||||
            c += " table-info"
 | 
			
		||||
        elif record.note.balance < 0:
 | 
			
		||||
            c += " table-danger"
 | 
			
		||||
    return c
 | 
			
		||||
@@ -86,7 +95,7 @@ class EntryTable(tables.Table):
 | 
			
		||||
        if hasattr(record, 'username'):
 | 
			
		||||
            username = record.username
 | 
			
		||||
            if username != value:
 | 
			
		||||
                return format_html(value + " <em>aka.</em> " + username)
 | 
			
		||||
                return mark_safe(escape(value) + " <em>aka.</em> " + escape(username))
 | 
			
		||||
        return value
 | 
			
		||||
 | 
			
		||||
    def render_balance(self, value):
 | 
			
		||||
@@ -106,3 +115,34 @@ class EntryTable(tables.Table):
 | 
			
		||||
            'data-last-name': lambda record: record.last_name,
 | 
			
		||||
            'data-first-name': lambda record: record.first_name,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# function delete_button(id) provided in template file
 | 
			
		||||
DELETE_TEMPLATE = """
 | 
			
		||||
    <button id="{{ record.pk }}" class="btn btn-danger btn-sm" onclick="delete_button(this.id)"> {{ delete_trans }}</button>
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OpenerTable(tables.Table):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        attrs = {
 | 
			
		||||
            'class': 'table table condensed table-striped',
 | 
			
		||||
            'id': "opener_table"
 | 
			
		||||
        }
 | 
			
		||||
        model = Opener
 | 
			
		||||
        fields = ("opener",)
 | 
			
		||||
        template_name = 'django_tables2/bootstrap4.html'
 | 
			
		||||
 | 
			
		||||
    show_header = False
 | 
			
		||||
    opener = tables.Column(attrs={'td': {'class': 'text-center'}})
 | 
			
		||||
 | 
			
		||||
    delete_col = tables.TemplateColumn(
 | 
			
		||||
        template_code=DELETE_TEMPLATE,
 | 
			
		||||
        extra_context={"delete_trans": _('Delete')},
 | 
			
		||||
        attrs={
 | 
			
		||||
            'td': {
 | 
			
		||||
                'class': lambda record: 'col-sm-1'
 | 
			
		||||
                + (' d-none' if not PermissionBackend.check_perm(
 | 
			
		||||
                    get_current_request(), "activity.delete_opener", record)
 | 
			
		||||
                   else '')}},
 | 
			
		||||
        verbose_name=_("Delete"),)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										99
									
								
								apps/activity/templates/activity/activity_detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								apps/activity/templates/activity/activity_detail.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,99 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
{% comment %}
 | 
			
		||||
SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
{% endcomment %}
 | 
			
		||||
{% load i18n perms %}
 | 
			
		||||
{% load render_table from django_tables2 %}
 | 
			
		||||
{% load static django_tables2 i18n %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<h1 class="text-white">{{ title }}</h1>
 | 
			
		||||
{% include "activity/includes/activity_info.html" %}
 | 
			
		||||
 | 
			
		||||
{% if activity.activity_type.manage_entries and ".change__opener"|has_perm:activity %}
 | 
			
		||||
    <div class="card bg-white mb-3">
 | 
			
		||||
        <h3 class="card-header text-center">
 | 
			
		||||
            {% trans "Openers" %}
 | 
			
		||||
        </h3>
 | 
			
		||||
        <div class="card-body">
 | 
			
		||||
            <form class="input-group" method="POST" id="form_opener">
 | 
			
		||||
                {% csrf_token %}
 | 
			
		||||
                <input type="hidden" name="activity" value="{{ object.pk }}">
 | 
			
		||||
                {%include "autocomplete_model.html" %}
 | 
			
		||||
                <div class="input-group-append">
 | 
			
		||||
                    <input type="submit" class="btn btn-success" value="{% trans "Add" %}">
 | 
			
		||||
                </div>
 | 
			
		||||
            </form>
 | 
			
		||||
        </div>
 | 
			
		||||
        {% render_table opener %}
 | 
			
		||||
    </div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% if guests.data %}
 | 
			
		||||
<div class="card bg-white mb-3">
 | 
			
		||||
    <h3 class="card-header text-center">
 | 
			
		||||
        {% trans "Guests list" %}
 | 
			
		||||
    </h3>
 | 
			
		||||
    <div id="guests_table">
 | 
			
		||||
        {% render_table guests %}
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block extrajavascript %}
 | 
			
		||||
<script src="{% static "activity/js/opener.js" %}"></script>
 | 
			
		||||
<script src="{% static "js/autocomplete_model.js" %}"></script>
 | 
			
		||||
<script>
 | 
			
		||||
    function remove_guest(guest_id) {
 | 
			
		||||
        $.ajax({
 | 
			
		||||
         url:"/api/activity/guest/" + guest_id + "/",
 | 
			
		||||
         method:"DELETE",
 | 
			
		||||
         headers: {"X-CSRFTOKEN": CSRF_TOKEN}
 | 
			
		||||
     })
 | 
			
		||||
      .done(function() {
 | 
			
		||||
          addMsg('{% trans "Guest deleted" %}', 'success');
 | 
			
		||||
          $("#guests_table").load(location.pathname + " #guests_table");
 | 
			
		||||
      })
 | 
			
		||||
      .fail(function(xhr, textStatus, error) {
 | 
			
		||||
          errMsg(xhr.responseJSON);
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    $("#open_activity").click(function() {
 | 
			
		||||
        $.ajax({
 | 
			
		||||
            url: "/api/activity/activity/{{ activity.pk }}/",
 | 
			
		||||
            type: "PATCH",
 | 
			
		||||
            dataType: "json",
 | 
			
		||||
            headers: {
 | 
			
		||||
                "X-CSRFTOKEN": CSRF_TOKEN
 | 
			
		||||
            },
 | 
			
		||||
            data: {
 | 
			
		||||
                open: {{ activity.open|yesno:'false,true' }}
 | 
			
		||||
            }
 | 
			
		||||
        }).done(function () {
 | 
			
		||||
            reloadWithTurbolinks();
 | 
			
		||||
        }).fail(function (xhr) {
 | 
			
		||||
            errMsg(xhr.responseJSON);
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    $("#validate_activity").click(function () {
 | 
			
		||||
        $.ajax({
 | 
			
		||||
            url: "/api/activity/activity/{{ activity.pk }}/",
 | 
			
		||||
            type: "PATCH",
 | 
			
		||||
            dataType: "json",
 | 
			
		||||
            headers: {
 | 
			
		||||
                "X-CSRFTOKEN": CSRF_TOKEN
 | 
			
		||||
            },
 | 
			
		||||
            data: {
 | 
			
		||||
                valid: {{ activity.valid|yesno:'false,true' }}
 | 
			
		||||
            }
 | 
			
		||||
        }).done(function () {
 | 
			
		||||
            reloadWithTurbolinks();
 | 
			
		||||
        }).fail(function (xhr) {
 | 
			
		||||
            errMsg(xhr.responseJSON);
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
</script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										171
									
								
								apps/activity/templates/activity/activity_entry.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								apps/activity/templates/activity/activity_entry.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,171 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
{% comment %}
 | 
			
		||||
SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
{% endcomment %}
 | 
			
		||||
{% load static i18n pretty_money perms %}
 | 
			
		||||
{% load render_table from django_tables2 %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<h1 class="text-white">{{ title }}</h1>
 | 
			
		||||
<div class="row">
 | 
			
		||||
    <div class="col-xl-12">
 | 
			
		||||
        <div class="btn-group btn-group-toggle bg-light" style="width: 100%">
 | 
			
		||||
            <a href="{% url "note:transfer" %}#transfer" class="btn btn-sm btn-outline-primary">
 | 
			
		||||
                {% trans "Transfer" %}
 | 
			
		||||
            </a>
 | 
			
		||||
            {% if "note.notespecial"|not_empty_model_list %}
 | 
			
		||||
            <a href="{% url "note:transfer" %}#credit" class="btn btn-sm btn-outline-primary">
 | 
			
		||||
                {% trans "Credit" %}
 | 
			
		||||
            </a>
 | 
			
		||||
            <a href="{% url "note:transfer" %}#debit" class="btn btn-sm btn-outline-primary">
 | 
			
		||||
                {% trans "Debit" %}
 | 
			
		||||
            </a>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% for a in activities_open %}
 | 
			
		||||
            <a href="{% url "activity:activity_entry" pk=a.pk %}"
 | 
			
		||||
                class="btn btn-sm btn-outline-primary{% if a.pk == activity.pk %} active{% endif %}">
 | 
			
		||||
                {% trans "Entries" %} {{ a.name }}
 | 
			
		||||
            </a>
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<a href="{% url "activity:activity_detail" pk=activity.pk %}">
 | 
			
		||||
    <button class="btn btn-light">{% trans "Return to activity page" %}</button>
 | 
			
		||||
</a>
 | 
			
		||||
 | 
			
		||||
<input id="alias" type="text" class="form-control" placeholder="Nom/note ...">
 | 
			
		||||
 | 
			
		||||
<hr>
 | 
			
		||||
 | 
			
		||||
<div class="card" id="entry_table">
 | 
			
		||||
    <h2 class="text-center">{{ entries.count }}
 | 
			
		||||
        {% if entries.count >= 2 %}{% trans "entries" %}{% else %}{% trans "entry" %}{% endif %}</h2>
 | 
			
		||||
    {% render_table table %}
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block extrajavascript %}
 | 
			
		||||
<script>
 | 
			
		||||
    old_pattern = null;
 | 
			
		||||
    alias_obj = $("#alias");
 | 
			
		||||
 | 
			
		||||
    function reloadTable(force = false) {
 | 
			
		||||
        let pattern = alias_obj.val();
 | 
			
		||||
 | 
			
		||||
        if ((pattern === old_pattern || pattern === "") && !force)
 | 
			
		||||
            return;
 | 
			
		||||
 | 
			
		||||
        $("#entry_table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + " #entry_table", init);
 | 
			
		||||
        refreshBalance();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    alias_obj.keyup(function(event) {
 | 
			
		||||
        let code = event.originalEvent.keyCode
 | 
			
		||||
        if (65 <= code <= 122 || code === 13) {
 | 
			
		||||
            debounce(reloadTable)()
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    $(document).ready(init);
 | 
			
		||||
 | 
			
		||||
    function init() {
 | 
			
		||||
        $(".table-row").click(function (e) {
 | 
			
		||||
            let target = e.target.parentElement;
 | 
			
		||||
            target = $("#" + target.id);
 | 
			
		||||
 | 
			
		||||
            let type = target.attr("data-type");
 | 
			
		||||
            let id = target.attr("data-id");
 | 
			
		||||
            let last_name = target.attr("data-last-name");
 | 
			
		||||
            let first_name = target.attr("data-first-name");
 | 
			
		||||
 | 
			
		||||
            if (type === "membership") {
 | 
			
		||||
                $.post("/api/activity/entry/?format=json", {
 | 
			
		||||
                    csrfmiddlewaretoken: CSRF_TOKEN,
 | 
			
		||||
                    activity: {{ activity.id }},
 | 
			
		||||
                    note: id,
 | 
			
		||||
                    guest: null
 | 
			
		||||
                }).done(function () {
 | 
			
		||||
                    if (target.hasClass("table-info"))
 | 
			
		||||
                        addMsg(
 | 
			
		||||
                            "{% trans "Entry done, but caution: the user is not a Kfet member." %}",
 | 
			
		||||
                            "warning", 10000);
 | 
			
		||||
                    else
 | 
			
		||||
                        addMsg("Entry made!", "success", 4000);
 | 
			
		||||
                    reloadTable(true);
 | 
			
		||||
                }).fail(function (xhr) {
 | 
			
		||||
                    errMsg(xhr.responseJSON, 4000);
 | 
			
		||||
                });
 | 
			
		||||
            } else {
 | 
			
		||||
                let line_obj = $("#buttons_guest_" + id);
 | 
			
		||||
                if (line_obj.length || target.attr('class').includes("table-success")) {
 | 
			
		||||
                    line_obj.remove();
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                let tr = "<tr class='text-center'>" +
 | 
			
		||||
                    "<td id='buttons_guest_" + id + "' style='table-danger center' colspan='5'>" +
 | 
			
		||||
                    "<button id='transaction_guest_" + id +
 | 
			
		||||
                    "' class='btn btn-secondary'>Payer avec la note de l'hôte</button> " +
 | 
			
		||||
                    "<button id='transaction_guest_" + id +
 | 
			
		||||
                    "_especes' class='btn btn-secondary'>Payer en espèces</button> " +
 | 
			
		||||
                    "<button id='transaction_guest_" + id +
 | 
			
		||||
                    "_cb' class='btn btn-secondary'>Payer en CB</button></td>" +
 | 
			
		||||
                    "<tr>";
 | 
			
		||||
                $(tr).insertAfter(target);
 | 
			
		||||
 | 
			
		||||
                let makeTransaction = function () {
 | 
			
		||||
                    $.post("/api/activity/entry/?format=json", {
 | 
			
		||||
                        csrfmiddlewaretoken: CSRF_TOKEN,
 | 
			
		||||
                        activity: {{ activity.id }},
 | 
			
		||||
                        note: target.attr("data-inviter"),
 | 
			
		||||
                        guest: id
 | 
			
		||||
                    }).done(function () {
 | 
			
		||||
                        if (target.hasClass("table-info"))
 | 
			
		||||
                            addMsg(
 | 
			
		||||
                                "{% trans "Entry done, but caution: the user is not a Kfet member." %}",
 | 
			
		||||
                                "warning", 10000);
 | 
			
		||||
                        else
 | 
			
		||||
                            addMsg("{% trans "Entry done!" %}", "success", 4000);
 | 
			
		||||
                        reloadTable(true);
 | 
			
		||||
                    }).fail(function (xhr) {
 | 
			
		||||
                        errMsg(xhr.responseJSON, 4000);
 | 
			
		||||
                    });
 | 
			
		||||
                };
 | 
			
		||||
 | 
			
		||||
                let credit = function (credit_id, credit_name) {
 | 
			
		||||
                    return function () {
 | 
			
		||||
                        $.post("/api/note/transaction/transaction/", {
 | 
			
		||||
                            "csrfmiddlewaretoken": CSRF_TOKEN,
 | 
			
		||||
                            "quantity": 1,
 | 
			
		||||
                            "amount": {{ activity.activity_type.guest_entry_fee }},
 | 
			
		||||
                            "reason": "Crédit " + credit_name +
 | 
			
		||||
                                " (invitation {{ activity.name }})",
 | 
			
		||||
                            "valid": true,
 | 
			
		||||
                            "polymorphic_ctype": {{ notespecial_ctype }},
 | 
			
		||||
                            "resourcetype": "SpecialTransaction",
 | 
			
		||||
                            "source": credit_id,
 | 
			
		||||
                            "destination": target.attr('data-inviter'),
 | 
			
		||||
                            "last_name": last_name,
 | 
			
		||||
                            "first_name": first_name,
 | 
			
		||||
                            "bank": ""
 | 
			
		||||
                        }).done(function () {
 | 
			
		||||
                            makeTransaction();
 | 
			
		||||
                            reset();
 | 
			
		||||
                        }).fail(function (xhr) {
 | 
			
		||||
                            errMsg(xhr.responseJSON, 4000);
 | 
			
		||||
                        });
 | 
			
		||||
                    };
 | 
			
		||||
                };
 | 
			
		||||
 | 
			
		||||
                $("#transaction_guest_" + id).click(makeTransaction);
 | 
			
		||||
                $("#transaction_guest_" + id + "_especes").click(credit(1, "espèces"));
 | 
			
		||||
                $("#transaction_guest_" + id + "_cb").click(credit(2, "carte bancaire"));
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
</script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										43
									
								
								apps/activity/templates/activity/activity_form.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								apps/activity/templates/activity/activity_form.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,43 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
{% comment %}
 | 
			
		||||
SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
{% endcomment %}
 | 
			
		||||
{% load i18n crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<div class="card bg-white mb-3">
 | 
			
		||||
  <h3 class="card-header text-center">
 | 
			
		||||
    {{ title }}
 | 
			
		||||
  </h3>
 | 
			
		||||
  <div class="card-body">
 | 
			
		||||
    <form method="post">
 | 
			
		||||
      {% csrf_token %}
 | 
			
		||||
      {{ form|crispy }}
 | 
			
		||||
      <button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
 | 
			
		||||
    </form>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block extrajavascript %}
 | 
			
		||||
<script>
 | 
			
		||||
  var date_end = document.getElementById("id_date_end");
 | 
			
		||||
  var date_start = document.getElementById("id_date_start");
 | 
			
		||||
 | 
			
		||||
  function update_date_end (){
 | 
			
		||||
    if(date_end.value=="" || date_end.value<date_start.value){
 | 
			
		||||
      date_end.value = date_start.value;
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  function update_date_start (){
 | 
			
		||||
    if(date_start.value=="" || date_end.value<date_start.value){
 | 
			
		||||
      date_start.value = date_end.value;
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  date_start.addEventListener('focusout', update_date_end);
 | 
			
		||||
  date_end.addEventListener('focusout', update_date_start);
 | 
			
		||||
  
 | 
			
		||||
</script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										49
									
								
								apps/activity/templates/activity/activity_list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								apps/activity/templates/activity/activity_list.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,49 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
{% comment %}
 | 
			
		||||
SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
{% endcomment %}
 | 
			
		||||
{% load render_table from django_tables2 %}
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
{% if started_activities %}
 | 
			
		||||
<div class="card bg-secondary text-white mb-3">
 | 
			
		||||
    <h3 class="card-header text-center">
 | 
			
		||||
        {% trans "Current activity" %}
 | 
			
		||||
    </h3>
 | 
			
		||||
    <div class="card-body text-dark">
 | 
			
		||||
        {% for activity in started_activities %}
 | 
			
		||||
        {% include "activity/includes/activity_info.html" %}
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
<div class="card bg-light mb-3">
 | 
			
		||||
    <h3 class="card-header text-center">
 | 
			
		||||
        {% trans "Upcoming activities" %}
 | 
			
		||||
    </h3>
 | 
			
		||||
    {% if upcoming.data %}
 | 
			
		||||
    {% render_table upcoming %}
 | 
			
		||||
    {% else %}
 | 
			
		||||
    <div class="card-body">
 | 
			
		||||
        <div class="alert alert-warning">
 | 
			
		||||
            {% trans "There is no planned activity." %}
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <div class="card-footer">
 | 
			
		||||
        <a class="btn btn-sm btn-success" href="{% url 'activity:activity_create' %}" data-turbolinks="false">
 | 
			
		||||
            <i class="fa fa-calendar-plus-o" aria-hidden="true"></i>
 | 
			
		||||
            {% trans 'New activity' %}
 | 
			
		||||
        </a>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="card bg-light mb-3">
 | 
			
		||||
    <h3 class="card-header text-center">
 | 
			
		||||
        {% trans "All activities" %}
 | 
			
		||||
    </h3>
 | 
			
		||||
    {% render_table table %}
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										78
									
								
								apps/activity/templates/activity/includes/activity_info.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								apps/activity/templates/activity/includes/activity_info.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,78 @@
 | 
			
		||||
{% comment %}
 | 
			
		||||
SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
{% endcomment %}
 | 
			
		||||
{% load i18n perms pretty_money %}
 | 
			
		||||
{% url 'activity:activity_detail' activity.pk as activity_detail_url %}
 | 
			
		||||
 | 
			
		||||
<div id="activity_info" class="card bg-light shadow mb-3">
 | 
			
		||||
    <div class="card-header text-center">
 | 
			
		||||
        <h4>
 | 
			
		||||
            {% if request.path_info != activity_detail_url %}
 | 
			
		||||
                <a href="{{ activity_detail_url }}">{{ activity.name }}</a>
 | 
			
		||||
            {% else %}
 | 
			
		||||
                {{ activity.name }}
 | 
			
		||||
            {% endif %}
 | 
			
		||||
        </h4>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="card-body" id="profile_infos">
 | 
			
		||||
        <dl class="row">
 | 
			
		||||
            <dt class="col-xl-6">{% trans 'description'|capfirst %}</dt>
 | 
			
		||||
            <dd class="col-xl-6"> {{ activity.description|linebreaks }}</dd>
 | 
			
		||||
 | 
			
		||||
            <dt class="col-xl-6">{% trans 'type'|capfirst %}</dt>
 | 
			
		||||
            <dd class="col-xl-6"> {{ activity.activity_type }}</dd>
 | 
			
		||||
 | 
			
		||||
            <dt class="col-xl-6">{% trans 'start date'|capfirst %}</dt>
 | 
			
		||||
            <dd class="col-xl-6">{{ activity.date_start }}</dd>
 | 
			
		||||
 | 
			
		||||
            <dt class="col-xl-6">{% trans 'end date'|capfirst %}</dt>
 | 
			
		||||
            <dd class="col-xl-6">{{ activity.date_end }}</dd>
 | 
			
		||||
 | 
			
		||||
            {% if "activity.change_activity_valid"|has_perm:activity %}
 | 
			
		||||
                <dt class="col-xl-6">{% trans 'creater'|capfirst %}</dt>
 | 
			
		||||
                <dd class="col-xl-6"><a href="{% url "member:user_detail" pk=activity.creater.pk %}">{{ activity.creater }}</a></dd>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
 | 
			
		||||
            <dt class="col-xl-6">{% trans 'organizer'|capfirst %}</dt>
 | 
			
		||||
            <dd class="col-xl-6"><a href="{% url "member:club_detail" pk=activity.organizer.pk %}">{{ activity.organizer }}</a></dd>
 | 
			
		||||
 | 
			
		||||
            <dt class="col-xl-6">{% trans 'attendees club'|capfirst %}</dt>
 | 
			
		||||
            <dd class="col-xl-6"><a href="{% url "member:club_detail" pk=activity.attendees_club.pk %}">{{ activity.attendees_club }}</a></dd>
 | 
			
		||||
 | 
			
		||||
            <dt class="col-xl-6">{% trans 'can invite'|capfirst %}</dt>
 | 
			
		||||
            <dd class="col-xl-6">{{ activity.activity_type.can_invite|yesno }}</dd>
 | 
			
		||||
 | 
			
		||||
            {% if activity.activity_type.can_invite %}
 | 
			
		||||
                <dt class="col-xl-6">{% trans 'guest entry fee'|capfirst %}</dt>
 | 
			
		||||
                <dd class="col-xl-6">{{ activity.activity_type.guest_entry_fee|pretty_money }}</dd>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
 | 
			
		||||
            <dt class="col-xl-6">{% trans 'valid'|capfirst %}</dt>
 | 
			
		||||
            <dd class="col-xl-6">{{ activity.valid|yesno }}</dd>
 | 
			
		||||
 | 
			
		||||
            <dt class="col-xl-6">{% trans 'opened'|capfirst %}</dt>
 | 
			
		||||
            <dd class="col-xl-6">{{ activity.open|yesno }}</dd>
 | 
			
		||||
        </dl>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="card-footer text-center">
 | 
			
		||||
        {% if activity.open and activity.activity_type.manage_entries and ".change__open"|has_perm:activity %}
 | 
			
		||||
            <a class="btn btn-warning btn-sm my-1" href="{% url 'activity:activity_entry' pk=activity.pk %}"> {% trans "Entry page" %}</a>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
 | 
			
		||||
        {% if request.path_info == activity_detail_url %}
 | 
			
		||||
            {% if activity.valid and ".change__open"|has_perm:activity %}
 | 
			
		||||
                <a class="btn btn-warning btn-sm my-1" id="open_activity"> {% if activity.open %}{% trans "close"|capfirst %}{% else %}{% trans "open"|capfirst %}{% endif %}</a>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% if not activity.open and ".change__valid"|has_perm:activity %}
 | 
			
		||||
                <a class="btn btn-success btn-sm my-1" id="validate_activity"> {% if activity.valid %}{% trans "invalidate"|capfirst %}{% else %}{% trans "validate"|capfirst %}{% endif %}</a>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% if ".change_"|has_perm:activity %}
 | 
			
		||||
                <a class="btn btn-primary btn-sm my-1" href="{% url 'activity:activity_update' pk=activity.pk %}" data-turbolinks="false"> {% trans "edit"|capfirst %}</a>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% if activity.activity_type.can_invite and not activity_started %}
 | 
			
		||||
                <a class="btn btn-primary btn-sm my-1" href="{% url 'activity:activity_invite' pk=activity.pk %}" data-turbolinks="false"> {% trans "Invite" %}</a>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
        {% endif %}
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										233
									
								
								apps/activity/tests/test_activities.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										233
									
								
								apps/activity/tests/test_activities.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,233 @@
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
 | 
			
		||||
from api.tests import TestAPI
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from member.models import Club
 | 
			
		||||
 | 
			
		||||
from ..api.views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet
 | 
			
		||||
from ..models import Activity, ActivityType, Guest, Entry
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestActivities(TestCase):
 | 
			
		||||
    """
 | 
			
		||||
    Test activities
 | 
			
		||||
    """
 | 
			
		||||
    fixtures = ('initial',)
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.user = User.objects.create_superuser(
 | 
			
		||||
            username="admintoto",
 | 
			
		||||
            password="tototototo",
 | 
			
		||||
            email="toto@example.com"
 | 
			
		||||
        )
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
 | 
			
		||||
        sess = self.client.session
 | 
			
		||||
        sess["permission_mask"] = 42
 | 
			
		||||
        sess.save()
 | 
			
		||||
 | 
			
		||||
        self.activity = Activity.objects.create(
 | 
			
		||||
            name="Activity",
 | 
			
		||||
            description="This is a test activity\non two very very long lines\nbecause this is very important.",
 | 
			
		||||
            location="Earth",
 | 
			
		||||
            activity_type=ActivityType.objects.get(name="Pot"),
 | 
			
		||||
            creater=self.user,
 | 
			
		||||
            organizer=Club.objects.get(name="Kfet"),
 | 
			
		||||
            attendees_club=Club.objects.get(name="Kfet"),
 | 
			
		||||
            date_start=timezone.now(),
 | 
			
		||||
            date_end=timezone.now() + timedelta(days=2),
 | 
			
		||||
            valid=True,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.guest = Guest.objects.create(
 | 
			
		||||
            activity=self.activity,
 | 
			
		||||
            inviter=self.user.note,
 | 
			
		||||
            last_name="GUEST",
 | 
			
		||||
            first_name="Guest",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_activity_list(self):
 | 
			
		||||
        """
 | 
			
		||||
        Display the list of all activities
 | 
			
		||||
        """
 | 
			
		||||
        response = self.client.get(reverse("activity:activity_list"))
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
    def test_activity_create(self):
 | 
			
		||||
        """
 | 
			
		||||
        Create a new activity
 | 
			
		||||
        """
 | 
			
		||||
        response = self.client.get(reverse("activity:activity_create"))
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(reverse("activity:activity_create"), data=dict(
 | 
			
		||||
            name="Activity created",
 | 
			
		||||
            description="This activity was successfully created.",
 | 
			
		||||
            location="Earth",
 | 
			
		||||
            activity_type=ActivityType.objects.get(name="Soirée de club").id,
 | 
			
		||||
            creater=self.user.id,
 | 
			
		||||
            organizer=Club.objects.get(name="Kfet").id,
 | 
			
		||||
            attendees_club=Club.objects.get(name="Kfet").id,
 | 
			
		||||
            date_start="{:%Y-%m-%d %H:%M}".format(timezone.now()),
 | 
			
		||||
            date_end="{:%Y-%m-%d %H:%M}".format(timezone.now() + timedelta(days=2)),
 | 
			
		||||
            valid=True,
 | 
			
		||||
        ))
 | 
			
		||||
        self.assertTrue(Activity.objects.filter(name="Activity created").exists())
 | 
			
		||||
        activity = Activity.objects.get(name="Activity created")
 | 
			
		||||
        self.assertRedirects(response, reverse("activity:activity_detail", args=(activity.pk,)), 302, 200)
 | 
			
		||||
 | 
			
		||||
    def test_activity_detail(self):
 | 
			
		||||
        """
 | 
			
		||||
        Display the detail of an activity
 | 
			
		||||
        """
 | 
			
		||||
        response = self.client.get(reverse("activity:activity_detail", args=(self.activity.pk,)))
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
    def test_activity_update(self):
 | 
			
		||||
        """
 | 
			
		||||
        Update an activity
 | 
			
		||||
        """
 | 
			
		||||
        response = self.client.get(reverse("activity:activity_update", args=(self.activity.pk,)))
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(reverse("activity:activity_update", args=(self.activity.pk,)), data=dict(
 | 
			
		||||
            name=str(self.activity) + " updated",
 | 
			
		||||
            description="This activity was successfully updated.",
 | 
			
		||||
            location="Earth",
 | 
			
		||||
            activity_type=ActivityType.objects.get(name="Autre").id,
 | 
			
		||||
            creater=self.user.id,
 | 
			
		||||
            organizer=Club.objects.get(name="Kfet").id,
 | 
			
		||||
            attendees_club=Club.objects.get(name="Kfet").id,
 | 
			
		||||
            date_start="{:%Y-%m-%d %H:%M}".format(timezone.now()),
 | 
			
		||||
            date_end="{:%Y-%m-%d %H:%M}".format(timezone.now() + timedelta(days=2)),
 | 
			
		||||
            valid=True,
 | 
			
		||||
        ))
 | 
			
		||||
        self.assertTrue(Activity.objects.filter(name="Activity updated").exists())
 | 
			
		||||
        self.assertRedirects(response, reverse("activity:activity_detail", args=(self.activity.pk,)), 302, 200)
 | 
			
		||||
 | 
			
		||||
    def test_activity_entry(self):
 | 
			
		||||
        """
 | 
			
		||||
        Create some entries
 | 
			
		||||
        """
 | 
			
		||||
        self.activity.open = True
 | 
			
		||||
        self.activity.save()
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(reverse("activity:activity_entry", args=(self.activity.pk,)))
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        response = self.client.get(reverse("activity:activity_entry", args=(self.activity.pk,)) + "?search=guest")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        response = self.client.get(reverse("activity:activity_entry", args=(self.activity.pk,)) + "?search=admin")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        # User entry
 | 
			
		||||
        response = self.client.post("/api/activity/entry/", data=dict(
 | 
			
		||||
            activity=self.activity.id,
 | 
			
		||||
            note=self.user.note.id,
 | 
			
		||||
            guest="",
 | 
			
		||||
        ))
 | 
			
		||||
        self.assertEqual(response.status_code, 201)     # 201 = Created
 | 
			
		||||
        self.assertTrue(Entry.objects.filter(note=self.user.note, guest=None, activity=self.activity).exists())
 | 
			
		||||
 | 
			
		||||
        # Guest entry
 | 
			
		||||
        response = self.client.post("/api/activity/entry/", data=dict(
 | 
			
		||||
            activity=self.activity.id,
 | 
			
		||||
            note=self.user.note.id,
 | 
			
		||||
            guest=self.guest.id,
 | 
			
		||||
        ))
 | 
			
		||||
        self.assertEqual(response.status_code, 201)     # 201 = Created
 | 
			
		||||
        self.assertTrue(Entry.objects.filter(note=self.user.note, guest=self.guest.id, activity=self.activity).exists())
 | 
			
		||||
 | 
			
		||||
    def test_activity_invite(self):
 | 
			
		||||
        """
 | 
			
		||||
        Try to invite people to an activity
 | 
			
		||||
        """
 | 
			
		||||
        response = self.client.get(reverse("activity:activity_invite", args=(self.activity.pk,)))
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        # The activity is started, can't invite
 | 
			
		||||
        response = self.client.post(reverse("activity:activity_invite", args=(self.activity.pk,)), data=dict(
 | 
			
		||||
            activity=self.activity.id,
 | 
			
		||||
            inviter=self.user.note.id,
 | 
			
		||||
            last_name="GUEST2",
 | 
			
		||||
            first_name="Guest",
 | 
			
		||||
        ))
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        self.activity.date_start += timedelta(days=1)
 | 
			
		||||
        self.activity.save()
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(reverse("activity:activity_invite", args=(self.activity.pk,)), data=dict(
 | 
			
		||||
            activity=self.activity.id,
 | 
			
		||||
            inviter=self.user.note.id,
 | 
			
		||||
            last_name="GUEST2",
 | 
			
		||||
            first_name="Guest",
 | 
			
		||||
        ))
 | 
			
		||||
        self.assertRedirects(response, reverse("activity:activity_detail", args=(self.activity.pk,)), 302, 200)
 | 
			
		||||
 | 
			
		||||
    def test_activity_ics(self):
 | 
			
		||||
        """
 | 
			
		||||
        Render the ICS calendar
 | 
			
		||||
        """
 | 
			
		||||
        response = self.client.get(reverse("activity:calendar_ics"))
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestActivityAPI(TestAPI):
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        super().setUp()
 | 
			
		||||
 | 
			
		||||
        self.activity = Activity.objects.create(
 | 
			
		||||
            name="Activity",
 | 
			
		||||
            description="This is a test activity\non two very very long lines\nbecause this is very important.",
 | 
			
		||||
            location="Earth",
 | 
			
		||||
            activity_type=ActivityType.objects.get(name="Pot"),
 | 
			
		||||
            creater=self.user,
 | 
			
		||||
            organizer=Club.objects.get(name="Kfet"),
 | 
			
		||||
            attendees_club=Club.objects.get(name="Kfet"),
 | 
			
		||||
            date_start=timezone.now(),
 | 
			
		||||
            date_end=timezone.now() + timedelta(days=2),
 | 
			
		||||
            valid=True,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.guest = Guest.objects.create(
 | 
			
		||||
            activity=self.activity,
 | 
			
		||||
            inviter=self.user.note,
 | 
			
		||||
            last_name="GUEST",
 | 
			
		||||
            first_name="Guest",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.entry = Entry.objects.create(
 | 
			
		||||
            activity=self.activity,
 | 
			
		||||
            note=self.user.note,
 | 
			
		||||
            guest=self.guest,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_activity_api(self):
 | 
			
		||||
        """
 | 
			
		||||
        Load Activity API page and test all filters and permissions
 | 
			
		||||
        """
 | 
			
		||||
        self.check_viewset(ActivityViewSet, "/api/activity/activity/")
 | 
			
		||||
 | 
			
		||||
    def test_activity_type_api(self):
 | 
			
		||||
        """
 | 
			
		||||
        Load ActivityType API page and test all filters and permissions
 | 
			
		||||
        """
 | 
			
		||||
        self.check_viewset(ActivityTypeViewSet, "/api/activity/type/")
 | 
			
		||||
 | 
			
		||||
    def test_entry_api(self):
 | 
			
		||||
        """
 | 
			
		||||
        Load Entry API page and test all filters and permissions
 | 
			
		||||
        """
 | 
			
		||||
        self.check_viewset(EntryViewSet, "/api/activity/entry/")
 | 
			
		||||
 | 
			
		||||
    def test_guest_api(self):
 | 
			
		||||
        """
 | 
			
		||||
        Load Guest API page and test all filters and permissions
 | 
			
		||||
        """
 | 
			
		||||
        self.check_viewset(GuestViewSet, "/api/activity/guest/")
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.urls import path
 | 
			
		||||
@@ -14,4 +14,5 @@ urlpatterns = [
 | 
			
		||||
    path('<int:pk>/entry/', views.ActivityEntryView.as_view(), name='activity_entry'),
 | 
			
		||||
    path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'),
 | 
			
		||||
    path('new/', views.ActivityCreateView.as_view(), name='activity_create'),
 | 
			
		||||
    path('calendar.ics', views.CalendarView.as_view(), name='calendar_ics'),
 | 
			
		||||
]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,29 +1,55 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from datetime import datetime, timezone
 | 
			
		||||
from hashlib import md5
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.mixins import LoginRequiredMixin
 | 
			
		||||
from django.contrib.contenttypes.models import ContentType
 | 
			
		||||
from django.core.exceptions import PermissionDenied
 | 
			
		||||
from django.db import transaction
 | 
			
		||||
from django.db.models import F, Q
 | 
			
		||||
from django.http import HttpResponse
 | 
			
		||||
from django.urls import reverse_lazy
 | 
			
		||||
from django.views.generic import CreateView, DetailView, UpdateView, TemplateView
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.decorators import method_decorator
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django_tables2.views import SingleTableView
 | 
			
		||||
from note.models import NoteUser, Alias, NoteSpecial
 | 
			
		||||
from django.views import View
 | 
			
		||||
from django.views.decorators.cache import cache_page
 | 
			
		||||
from django.views.generic import DetailView, TemplateView, UpdateView
 | 
			
		||||
from django.views.generic.list import ListView
 | 
			
		||||
from django_tables2.views import MultiTableMixin, SingleTableMixin
 | 
			
		||||
from api.viewsets import is_regex
 | 
			
		||||
from note.models import Alias, NoteSpecial, NoteUser
 | 
			
		||||
from permission.backends import PermissionBackend
 | 
			
		||||
from permission.views import ProtectQuerysetMixin
 | 
			
		||||
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
 | 
			
		||||
 | 
			
		||||
from .forms import ActivityForm, GuestForm
 | 
			
		||||
from .models import Activity, Guest, Entry
 | 
			
		||||
from .tables import ActivityTable, GuestTable, EntryTable
 | 
			
		||||
from .models import Activity, Entry, Guest, Opener
 | 
			
		||||
from .tables import ActivityTable, EntryTable, GuestTable, OpenerTable
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ActivityCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
 | 
			
		||||
class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView):
 | 
			
		||||
    """
 | 
			
		||||
    View to create a new Activity
 | 
			
		||||
    """
 | 
			
		||||
    model = Activity
 | 
			
		||||
    form_class = ActivityForm
 | 
			
		||||
    extra_context = {"title": _("Create new activity")}
 | 
			
		||||
 | 
			
		||||
    def get_sample_object(self):
 | 
			
		||||
        return Activity(
 | 
			
		||||
            name="",
 | 
			
		||||
            description="",
 | 
			
		||||
            creater=self.request.user,
 | 
			
		||||
            activity_type_id=1,
 | 
			
		||||
            organizer_id=1,
 | 
			
		||||
            attendees_club_id=1,
 | 
			
		||||
            date_start=timezone.now(),
 | 
			
		||||
            date_end=timezone.now(),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        form.instance.creater = self.request.user
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
@@ -33,127 +59,269 @@ class ActivityCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
 | 
			
		||||
        return reverse_lazy('activity:activity_detail', kwargs={"pk": self.object.pk})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
 | 
			
		||||
class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
 | 
			
		||||
    """
 | 
			
		||||
    Displays all Activities, and classify if they are on-going or upcoming ones.
 | 
			
		||||
    """
 | 
			
		||||
    model = Activity
 | 
			
		||||
    table_class = ActivityTable
 | 
			
		||||
    ordering = ('-date_start',)
 | 
			
		||||
    tables = [
 | 
			
		||||
        lambda data: ActivityTable(data, prefix="all-"),
 | 
			
		||||
        lambda data: ActivityTable(data, prefix="upcoming-"),
 | 
			
		||||
    ]
 | 
			
		||||
    extra_context = {"title": _("Activities")}
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self, **kwargs):
 | 
			
		||||
        return super().get_queryset(**kwargs).distinct()
 | 
			
		||||
 | 
			
		||||
    def get_tables_data(self):
 | 
			
		||||
        # first table = all activities, second table = upcoming
 | 
			
		||||
        return [
 | 
			
		||||
            self.get_queryset().order_by("-date_start"),
 | 
			
		||||
            Activity.objects.filter(date_end__gt=timezone.now())
 | 
			
		||||
                            .filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))
 | 
			
		||||
                            .distinct()
 | 
			
		||||
                            .order_by("date_start")
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
        context['title'] = _("Activities")
 | 
			
		||||
        tables = context["tables"]
 | 
			
		||||
        for name, table in zip(["table", "upcoming"], tables):
 | 
			
		||||
            context[name] = table
 | 
			
		||||
 | 
			
		||||
        upcoming_activities = Activity.objects.filter(date_end__gt=datetime.now())
 | 
			
		||||
        context['upcoming'] = ActivityTable(
 | 
			
		||||
            data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")),
 | 
			
		||||
            prefix='upcoming-',
 | 
			
		||||
        )
 | 
			
		||||
        started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all()
 | 
			
		||||
        context["started_activities"] = started_activities
 | 
			
		||||
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
 | 
			
		||||
class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, DetailView):
 | 
			
		||||
    """
 | 
			
		||||
    Shows details about one activity. Add guest to context
 | 
			
		||||
    """
 | 
			
		||||
    model = Activity
 | 
			
		||||
    context_object_name = "activity"
 | 
			
		||||
    extra_context = {"title": _("Activity detail")}
 | 
			
		||||
 | 
			
		||||
    tables = [
 | 
			
		||||
        lambda data: GuestTable(data, prefix="guests-"),
 | 
			
		||||
        lambda data: OpenerTable(data, prefix="opener-"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    def get_tables_data(self):
 | 
			
		||||
        return [
 | 
			
		||||
            Guest.objects.filter(activity=self.object)
 | 
			
		||||
                         .filter(PermissionBackend.filter_queryset(self.request, Guest, "view")),
 | 
			
		||||
            self.object.opener.filter(activity=self.object)
 | 
			
		||||
                              .filter(PermissionBackend.filter_queryset(self.request, Opener, "view")),
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data()
 | 
			
		||||
 | 
			
		||||
        table = GuestTable(data=Guest.objects.filter(activity=self.object)
 | 
			
		||||
                           .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view")))
 | 
			
		||||
        context["guests"] = table
 | 
			
		||||
        tables = context["tables"]
 | 
			
		||||
        for name, table in zip(["guests", "opener"], tables):
 | 
			
		||||
            context[name] = table
 | 
			
		||||
 | 
			
		||||
        context["activity_started"] = datetime.now(timezone.utc) > self.object.date_start
 | 
			
		||||
        context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start)
 | 
			
		||||
 | 
			
		||||
        context["widget"] = {
 | 
			
		||||
            "name": "opener",
 | 
			
		||||
            "resetable": True,
 | 
			
		||||
            "attrs": {
 | 
			
		||||
                "class": "autocomplete form-control",
 | 
			
		||||
                "id": "opener",
 | 
			
		||||
                "api_url": "/api/note/alias/?note__polymorphic_ctype__model=noteuser",
 | 
			
		||||
                "name_field": "name",
 | 
			
		||||
                "placeholder": ""
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
 | 
			
		||||
    """
 | 
			
		||||
    Updates one Activity
 | 
			
		||||
    """
 | 
			
		||||
    model = Activity
 | 
			
		||||
    form_class = ActivityForm
 | 
			
		||||
    extra_context = {"title": _("Update activity")}
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self, **kwargs):
 | 
			
		||||
        return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ActivityInviteView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
 | 
			
		||||
class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
 | 
			
		||||
    """
 | 
			
		||||
    Invite a Guest, The rules to invites someone are defined in `forms:activity.GuestForm`
 | 
			
		||||
    """
 | 
			
		||||
    model = Guest
 | 
			
		||||
    form_class = GuestForm
 | 
			
		||||
    template_name = "activity/activity_invite.html"
 | 
			
		||||
    template_name = "activity/activity_form.html"
 | 
			
		||||
 | 
			
		||||
    def get_sample_object(self):
 | 
			
		||||
        """ Creates a standart Guest binds to the Activity"""
 | 
			
		||||
        activity = Activity.objects.get(pk=self.kwargs["pk"])
 | 
			
		||||
        return Guest(
 | 
			
		||||
            activity=activity,
 | 
			
		||||
            first_name="",
 | 
			
		||||
            last_name="",
 | 
			
		||||
            inviter=self.request.user.note,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
        activity = context["form"].activity
 | 
			
		||||
        context["title"] = _('Invite guest to the activity "{}"').format(activity.name)
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
    def get_form(self, form_class=None):
 | 
			
		||||
        form = super().get_form(form_class)
 | 
			
		||||
        form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
 | 
			
		||||
            .get(pk=self.kwargs["pk"])
 | 
			
		||||
        form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
 | 
			
		||||
            .filter(pk=self.kwargs["pk"]).first()
 | 
			
		||||
        form.fields["inviter"].initial = self.request.user.note
 | 
			
		||||
        return form
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        form.instance.activity = Activity.objects\
 | 
			
		||||
            .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")).get(pk=self.kwargs["pk"])
 | 
			
		||||
            .filter(PermissionBackend.filter_queryset(self.request, Activity, "view")).get(pk=self.kwargs["pk"])
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self, **kwargs):
 | 
			
		||||
        return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ActivityEntryView(LoginRequiredMixin, TemplateView):
 | 
			
		||||
class ActivityEntryView(LoginRequiredMixin, SingleTableMixin, TemplateView):
 | 
			
		||||
    """
 | 
			
		||||
    Manages entry to an activity
 | 
			
		||||
    """
 | 
			
		||||
    template_name = "activity/activity_entry.html"
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
    table_class = EntryTable
 | 
			
		||||
 | 
			
		||||
        activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
 | 
			
		||||
            .get(pk=self.kwargs["pk"])
 | 
			
		||||
        context["activity"] = activity
 | 
			
		||||
    def dispatch(self, request, *args, **kwargs):
 | 
			
		||||
        """
 | 
			
		||||
        Don't display the entry interface if the user has no right to see it (no right to add an entry for itself),
 | 
			
		||||
        it is closed or doesn't manage entries.
 | 
			
		||||
        """
 | 
			
		||||
        if not self.request.user.is_authenticated:
 | 
			
		||||
            return self.handle_no_permission()
 | 
			
		||||
 | 
			
		||||
        matched = []
 | 
			
		||||
        activity = Activity.objects.get(pk=self.kwargs["pk"])
 | 
			
		||||
 | 
			
		||||
        pattern = "^$"
 | 
			
		||||
        if "search" in self.request.GET:
 | 
			
		||||
            pattern = self.request.GET["search"]
 | 
			
		||||
        sample_entry = Entry(activity=activity, note=self.request.user.note)
 | 
			
		||||
        if not PermissionBackend.check_perm(self.request, "activity.add_entry", sample_entry):
 | 
			
		||||
            raise PermissionDenied(_("You are not allowed to display the entry interface for this activity."))
 | 
			
		||||
 | 
			
		||||
        if not pattern:
 | 
			
		||||
            pattern = "^$"
 | 
			
		||||
        if not activity.activity_type.manage_entries:
 | 
			
		||||
            raise PermissionDenied(_("This activity does not support activity entries."))
 | 
			
		||||
 | 
			
		||||
        if pattern[0] != "^":
 | 
			
		||||
            pattern = "^" + pattern
 | 
			
		||||
        if not activity.open:
 | 
			
		||||
            raise PermissionDenied(_("This activity is closed."))
 | 
			
		||||
        return super().dispatch(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def get_invited_guest(self, activity):
 | 
			
		||||
        """
 | 
			
		||||
        Retrieves all Guests to the activity
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        guest_qs = Guest.objects\
 | 
			
		||||
            .annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\
 | 
			
		||||
            .filter(Q(first_name__regex=pattern) | Q(last_name__regex=pattern)
 | 
			
		||||
                    | Q(inviter__alias__name__regex=pattern)
 | 
			
		||||
                    | Q(inviter__alias__normalized_name__regex=Alias.normalize(pattern))) \
 | 
			
		||||
            .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\
 | 
			
		||||
            .distinct()[:20]
 | 
			
		||||
        for guest in guest_qs:
 | 
			
		||||
            guest.type = "Invité"
 | 
			
		||||
            matched.append(guest)
 | 
			
		||||
            .filter(activity=activity)\
 | 
			
		||||
            .filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))\
 | 
			
		||||
            .order_by('last_name', 'first_name')
 | 
			
		||||
 | 
			
		||||
        if "search" in self.request.GET and self.request.GET["search"]:
 | 
			
		||||
            pattern = self.request.GET["search"]
 | 
			
		||||
 | 
			
		||||
            # Check if this is a valid regex. If not, we won't check regex
 | 
			
		||||
            valid_regex = is_regex(pattern)
 | 
			
		||||
            suffix = "__iregex" if valid_regex else "__istartswith"
 | 
			
		||||
            pattern = "^" + pattern if valid_regex and pattern[0] != "^" else pattern
 | 
			
		||||
            guest_qs = guest_qs.filter(
 | 
			
		||||
                Q(**{f"first_name{suffix}": pattern})
 | 
			
		||||
                | Q(**{f"last_name{suffix}": pattern})
 | 
			
		||||
                | Q(**{f"inviter__alias__name{suffix}": pattern})
 | 
			
		||||
                | Q(**{f"inviter__alias__normalized_name{suffix}": Alias.normalize(pattern)})
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            guest_qs = guest_qs.none()
 | 
			
		||||
        return guest_qs.distinct()
 | 
			
		||||
 | 
			
		||||
    def get_invited_note(self, activity):
 | 
			
		||||
        """
 | 
			
		||||
        Retrieves all Note that can attend the activity,
 | 
			
		||||
        they need to have an up-to-date membership in the attendees_club.
 | 
			
		||||
        """
 | 
			
		||||
        note_qs = Alias.objects.annotate(last_name=F("note__noteuser__user__last_name"),
 | 
			
		||||
                                         first_name=F("note__noteuser__user__first_name"),
 | 
			
		||||
                                         username=F("note__noteuser__user__username"),
 | 
			
		||||
                                         note_name=F("name"),
 | 
			
		||||
                                         balance=F("note__balance"))\
 | 
			
		||||
            .filter(Q(note__polymorphic_ctype__model="noteuser")
 | 
			
		||||
                    & (Q(note__noteuser__user__first_name__regex=pattern)
 | 
			
		||||
                    | Q(note__noteuser__user__last_name__regex=pattern)
 | 
			
		||||
                    | Q(name__regex=pattern)
 | 
			
		||||
                    | Q(normalized_name__regex=Alias.normalize(pattern)))) \
 | 
			
		||||
            .filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view"))
 | 
			
		||||
        if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql_psycopg2':
 | 
			
		||||
            note_qs = note_qs.distinct('note__pk')[:20]
 | 
			
		||||
                                         balance=F("note__balance"))
 | 
			
		||||
 | 
			
		||||
        # Keep only users that have a note
 | 
			
		||||
        note_qs = note_qs.filter(note__noteuser__isnull=False)
 | 
			
		||||
 | 
			
		||||
        # Keep only valid members
 | 
			
		||||
        note_qs = note_qs.filter(
 | 
			
		||||
            note__noteuser__user__memberships__club=activity.attendees_club,
 | 
			
		||||
            note__noteuser__user__memberships__date_start__lte=timezone.now(),
 | 
			
		||||
            note__noteuser__user__memberships__date_end__gte=timezone.now()).exclude(note__inactivity_reason='forced')
 | 
			
		||||
 | 
			
		||||
        # Filter with permission backend
 | 
			
		||||
        note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request, Alias, "view"))
 | 
			
		||||
 | 
			
		||||
        if "search" in self.request.GET and self.request.GET["search"]:
 | 
			
		||||
            pattern = self.request.GET["search"]
 | 
			
		||||
 | 
			
		||||
            # Check if this is a valid regex. If not, we won't check regex
 | 
			
		||||
            valid_regex = is_regex(pattern)
 | 
			
		||||
            suffix = "__iregex" if valid_regex else "__icontains"
 | 
			
		||||
            note_qs = note_qs.filter(
 | 
			
		||||
                Q(**{f"note__noteuser__user__first_name{suffix}": pattern})
 | 
			
		||||
                | Q(**{f"note__noteuser__user__last_name{suffix}": pattern})
 | 
			
		||||
                | Q(**{f"name{suffix}": pattern})
 | 
			
		||||
                | Q(**{f"normalized_name{suffix}": Alias.normalize(pattern)})
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            # SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only
 | 
			
		||||
            # have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page.
 | 
			
		||||
            # In production mode, please use PostgreSQL.
 | 
			
		||||
            note_qs = note_qs.distinct()[:20]
 | 
			
		||||
        for note in note_qs:
 | 
			
		||||
            note_qs = note_qs.none()
 | 
			
		||||
 | 
			
		||||
        # SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only
 | 
			
		||||
        # have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page.
 | 
			
		||||
        # In production mode, please use PostgreSQL.
 | 
			
		||||
        note_qs = note_qs.distinct('note__pk')[:20]\
 | 
			
		||||
            if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql' else note_qs.distinct()[:20]
 | 
			
		||||
        return note_qs
 | 
			
		||||
 | 
			
		||||
    def get_table_data(self):
 | 
			
		||||
        activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
 | 
			
		||||
            .distinct().get(pk=self.kwargs["pk"])
 | 
			
		||||
 | 
			
		||||
        matched = []
 | 
			
		||||
 | 
			
		||||
        for guest in self.get_invited_guest(activity):
 | 
			
		||||
            guest.type = "Invité"
 | 
			
		||||
            matched.append(guest)
 | 
			
		||||
 | 
			
		||||
        for note in self.get_invited_note(activity):
 | 
			
		||||
            note.type = "Adhérent"
 | 
			
		||||
            note.activity = activity
 | 
			
		||||
            matched.append(note)
 | 
			
		||||
 | 
			
		||||
        table = EntryTable(data=matched)
 | 
			
		||||
        context["table"] = table
 | 
			
		||||
        return matched
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        """
 | 
			
		||||
        Query the list of Guest and Note to the activity and add information to makes entry with JS.
 | 
			
		||||
        """
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
        activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
 | 
			
		||||
            .distinct().get(pk=self.kwargs["pk"])
 | 
			
		||||
        context["activity"] = activity
 | 
			
		||||
 | 
			
		||||
        context["entries"] = Entry.objects.filter(activity=activity)
 | 
			
		||||
 | 
			
		||||
@@ -161,8 +329,70 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
 | 
			
		||||
        context["noteuser_ctype"] = ContentType.objects.get_for_model(NoteUser).pk
 | 
			
		||||
        context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk
 | 
			
		||||
 | 
			
		||||
        context["activities_open"] = Activity.objects.filter(open=True).filter(
 | 
			
		||||
            PermissionBackend.filter_queryset(self.request.user, Activity, "view")).filter(
 | 
			
		||||
            PermissionBackend.filter_queryset(self.request.user, Activity, "change")).all()
 | 
			
		||||
        activities_open = Activity.objects.filter(open=True, activity_type__manage_entries=True).filter(
 | 
			
		||||
            PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().all()
 | 
			
		||||
        context["activities_open"] = [a for a in activities_open
 | 
			
		||||
                                      if PermissionBackend.check_perm(self.request,
 | 
			
		||||
                                                                      "activity.add_entry",
 | 
			
		||||
                                                                      Entry(activity=a, note=self.request.user.note,))]
 | 
			
		||||
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Cache for 1 hour
 | 
			
		||||
@method_decorator(cache_page(60 * 60), name='dispatch')
 | 
			
		||||
class CalendarView(View):
 | 
			
		||||
    """
 | 
			
		||||
    Render an ICS calendar with all valid activities.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def multilines(self, string, maxlength, offset=0):
 | 
			
		||||
        newstring = string[:maxlength - offset]
 | 
			
		||||
        string = string[maxlength - offset:]
 | 
			
		||||
        while string:
 | 
			
		||||
            newstring += "\r\n "
 | 
			
		||||
            newstring += string[:maxlength - 1]
 | 
			
		||||
            string = string[maxlength - 1:]
 | 
			
		||||
        return newstring
 | 
			
		||||
 | 
			
		||||
    def get(self, request, *args, **kwargs):
 | 
			
		||||
        ics = """BEGIN:VCALENDAR
 | 
			
		||||
VERSION: 2.0
 | 
			
		||||
PRODID:Note Kfet 2020
 | 
			
		||||
X-WR-CALNAME:Kfet Calendar
 | 
			
		||||
NAME:Kfet Calendar
 | 
			
		||||
CALSCALE:GREGORIAN
 | 
			
		||||
BEGIN:VTIMEZONE
 | 
			
		||||
TZID:Europe/Paris
 | 
			
		||||
X-LIC-LOCATION:Europe/Paris
 | 
			
		||||
BEGIN:DAYLIGHT
 | 
			
		||||
TZOFFSETFROM:+0100
 | 
			
		||||
TZOFFSETTO:+0200
 | 
			
		||||
TZNAME:CEST
 | 
			
		||||
DTSTART:19700329T020000
 | 
			
		||||
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
 | 
			
		||||
END:DAYLIGHT
 | 
			
		||||
BEGIN:STANDARD
 | 
			
		||||
TZOFFSETFROM:+0200
 | 
			
		||||
TZOFFSETTO:+0100
 | 
			
		||||
TZNAME:CET
 | 
			
		||||
DTSTART:19701025T030000
 | 
			
		||||
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
 | 
			
		||||
END:STANDARD
 | 
			
		||||
END:VTIMEZONE
 | 
			
		||||
"""
 | 
			
		||||
        for activity in Activity.objects.filter(valid=True).order_by("-date_start").all():
 | 
			
		||||
            ics += f"""BEGIN:VEVENT
 | 
			
		||||
DTSTAMP:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}Z
 | 
			
		||||
UID:{md5((activity.name + "$" + str(activity.id) + str(activity.date_start)).encode("UTF-8")).hexdigest()}
 | 
			
		||||
SUMMARY;CHARSET=UTF-8:{self.multilines(activity.name, 75, 22)}
 | 
			
		||||
DTSTART:{"{:%Y%m%dT%H%M%S}Z".format(activity.date_start)}
 | 
			
		||||
DTEND:{"{:%Y%m%dT%H%M%S}Z".format(activity.date_end)}
 | 
			
		||||
LOCATION:{self.multilines(activity.location, 75, 9) if activity.location else "Kfet"}
 | 
			
		||||
DESCRIPTION;CHARSET=UTF-8:""" + self.multilines(activity.description.replace("\n", "\\n"), 75, 26) + f"""
 | 
			
		||||
 -- {activity.organizer.name}
 | 
			
		||||
END:VEVENT
 | 
			
		||||
"""
 | 
			
		||||
        ics += "END:VCALENDAR"
 | 
			
		||||
        ics = ics.replace("\r", "").replace("\n", "\r\n")
 | 
			
		||||
        return HttpResponse(ics, content_type="text/calendar; charset=UTF-8")
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
default_app_config = 'api.apps.APIConfig'
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										42
									
								
								apps/api/filters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								apps/api/filters.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
			
		||||
import re
 | 
			
		||||
from functools import lru_cache
 | 
			
		||||
 | 
			
		||||
from rest_framework.filters import SearchFilter
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RegexSafeSearchFilter(SearchFilter):
 | 
			
		||||
    @lru_cache
 | 
			
		||||
    def validate_regex(self, search_term) -> bool:
 | 
			
		||||
        try:
 | 
			
		||||
            re.compile(search_term)
 | 
			
		||||
            return True
 | 
			
		||||
        except re.error:
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
    def get_search_fields(self, view, request):
 | 
			
		||||
        """
 | 
			
		||||
        Ensure that given regex are valid.
 | 
			
		||||
        If not, we consider that the user is trying to search by substring.
 | 
			
		||||
        """
 | 
			
		||||
        search_fields = super().get_search_fields(view, request)
 | 
			
		||||
        search_terms = self.get_search_terms(request)
 | 
			
		||||
 | 
			
		||||
        for search_term in search_terms:
 | 
			
		||||
            if not self.validate_regex(search_term):
 | 
			
		||||
                # Invalid regex. We assume we don't query by regex but by substring.
 | 
			
		||||
                search_fields = [f.replace('$', '') for f in search_fields]
 | 
			
		||||
                break
 | 
			
		||||
 | 
			
		||||
        return search_fields
 | 
			
		||||
 | 
			
		||||
    def get_search_terms(self, request):
 | 
			
		||||
        """
 | 
			
		||||
        Ensure that search field is a valid regex query. If not, we remove extra characters.
 | 
			
		||||
        """
 | 
			
		||||
        terms = super().get_search_terms(request)
 | 
			
		||||
        if not all(self.validate_regex(term) for term in terms):
 | 
			
		||||
            # Invalid regex. If a ^ is prefixed to the search term, we remove it.
 | 
			
		||||
            terms = [term[1:] if term[0] == '^' else term for term in terms]
 | 
			
		||||
            # Same for dollars.
 | 
			
		||||
            terms = [term[:-1] if term[-1] == '$' else term for term in terms]
 | 
			
		||||
        return terms
 | 
			
		||||
							
								
								
									
										5
									
								
								apps/api/pagination.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								apps/api/pagination.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
			
		||||
from rest_framework.pagination import PageNumberPagination
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CustomPagination(PageNumberPagination):
 | 
			
		||||
    page_size_query_param = 'page_size'
 | 
			
		||||
							
								
								
									
										91
									
								
								apps/api/serializers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								apps/api/serializers.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,91 @@
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
from django.contrib.contenttypes.models import ContentType
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from rest_framework import serializers
 | 
			
		||||
from member.api.serializers import ProfileSerializer, MembershipSerializer
 | 
			
		||||
from member.models import Membership
 | 
			
		||||
from note.api.serializers import NoteSerializer
 | 
			
		||||
from note.models import Alias
 | 
			
		||||
from note_kfet.middlewares import get_current_request
 | 
			
		||||
from permission.backends import PermissionBackend
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserSerializer(serializers.ModelSerializer):
 | 
			
		||||
    """
 | 
			
		||||
    REST API Serializer for Users.
 | 
			
		||||
    The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = User
 | 
			
		||||
        exclude = (
 | 
			
		||||
            'password',
 | 
			
		||||
            'groups',
 | 
			
		||||
            'user_permissions',
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ContentTypeSerializer(serializers.ModelSerializer):
 | 
			
		||||
    """
 | 
			
		||||
    REST API Serializer for Users.
 | 
			
		||||
    The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = ContentType
 | 
			
		||||
        fields = '__all__'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OAuthSerializer(serializers.ModelSerializer):
 | 
			
		||||
    """
 | 
			
		||||
    Informations that are transmitted by OAuth.
 | 
			
		||||
    For now, this includes user, profile and valid memberships.
 | 
			
		||||
    This should be better managed later.
 | 
			
		||||
    """
 | 
			
		||||
    normalized_name = serializers.SerializerMethodField()
 | 
			
		||||
 | 
			
		||||
    profile = serializers.SerializerMethodField()
 | 
			
		||||
 | 
			
		||||
    note = serializers.SerializerMethodField()
 | 
			
		||||
 | 
			
		||||
    memberships = serializers.SerializerMethodField()
 | 
			
		||||
 | 
			
		||||
    def get_normalized_name(self, obj):
 | 
			
		||||
        return Alias.normalize(obj.username)
 | 
			
		||||
 | 
			
		||||
    def get_profile(self, obj):
 | 
			
		||||
        # Display the profile of the user only if we have rights to see it.
 | 
			
		||||
        return ProfileSerializer().to_representation(obj.profile) \
 | 
			
		||||
            if PermissionBackend.check_perm(get_current_request(), 'member.view_profile', obj.profile) else None
 | 
			
		||||
 | 
			
		||||
    def get_note(self, obj):
 | 
			
		||||
        # Display the note of the user only if we have rights to see it.
 | 
			
		||||
        return NoteSerializer().to_representation(obj.note) \
 | 
			
		||||
            if PermissionBackend.check_perm(get_current_request(), 'note.view_note', obj.note) else None
 | 
			
		||||
 | 
			
		||||
    def get_memberships(self, obj):
 | 
			
		||||
        # Display only memberships that we are allowed to see.
 | 
			
		||||
        return serializers.ListSerializer(child=MembershipSerializer()).to_representation(
 | 
			
		||||
            obj.memberships.filter(date_start__lte=timezone.now(), date_end__gte=timezone.now())
 | 
			
		||||
                           .filter(PermissionBackend.filter_queryset(get_current_request(), Membership, 'view')))
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = User
 | 
			
		||||
        fields = (
 | 
			
		||||
            'id',
 | 
			
		||||
            'username',
 | 
			
		||||
            'normalized_name',
 | 
			
		||||
            'first_name',
 | 
			
		||||
            'last_name',
 | 
			
		||||
            'email',
 | 
			
		||||
            'is_superuser',
 | 
			
		||||
            'is_active',
 | 
			
		||||
            'is_staff',
 | 
			
		||||
            'profile',
 | 
			
		||||
            'note',
 | 
			
		||||
            'memberships',
 | 
			
		||||
        )
 | 
			
		||||
							
								
								
									
										241
									
								
								apps/api/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								apps/api/tests.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,241 @@
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
import json
 | 
			
		||||
from datetime import datetime, date
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
from urllib.parse import quote_plus
 | 
			
		||||
from warnings import warn
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.contrib.contenttypes.models import ContentType
 | 
			
		||||
from django.db.models.fields.files import ImageFieldFile
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django_filters.rest_framework import DjangoFilterBackend
 | 
			
		||||
from phonenumbers import PhoneNumber
 | 
			
		||||
from rest_framework.filters import OrderingFilter
 | 
			
		||||
from api.filters import RegexSafeSearchFilter
 | 
			
		||||
from member.models import Membership, Club
 | 
			
		||||
from note.models import NoteClub, NoteUser, Alias, Note
 | 
			
		||||
from permission.models import PermissionMask, Permission, Role
 | 
			
		||||
 | 
			
		||||
from .viewsets import ContentTypeViewSet, UserViewSet
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestAPI(TestCase):
 | 
			
		||||
    """
 | 
			
		||||
    Load API pages and check that filters are working.
 | 
			
		||||
    """
 | 
			
		||||
    fixtures = ('initial', )
 | 
			
		||||
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        self.user = User.objects.create_superuser(
 | 
			
		||||
            username="adminapi",
 | 
			
		||||
            password="adminapi",
 | 
			
		||||
            email="adminapi@example.com",
 | 
			
		||||
            last_name="Admin",
 | 
			
		||||
            first_name="Admin",
 | 
			
		||||
        )
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
 | 
			
		||||
        sess = self.client.session
 | 
			
		||||
        sess["permission_mask"] = 42
 | 
			
		||||
        sess.save()
 | 
			
		||||
 | 
			
		||||
    def check_viewset(self, viewset, url):
 | 
			
		||||
        """
 | 
			
		||||
        This function should be called inside a unit test.
 | 
			
		||||
        This loads the viewset and for each filter entry, it checks that the filter is running good.
 | 
			
		||||
        """
 | 
			
		||||
        resp = self.client.get(url + "?format=json")
 | 
			
		||||
        self.assertEqual(resp.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        model = viewset.serializer_class.Meta.model
 | 
			
		||||
 | 
			
		||||
        if not model.objects.exists():  # pragma: no cover
 | 
			
		||||
            warn(f"Warning: unable to test API filters for the model {model._meta.verbose_name} "
 | 
			
		||||
                 "since there is no instance of it.")
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        if hasattr(viewset, "filter_backends"):
 | 
			
		||||
            backends = viewset.filter_backends
 | 
			
		||||
            obj = model.objects.last()
 | 
			
		||||
 | 
			
		||||
            if DjangoFilterBackend in backends:
 | 
			
		||||
                # Specific search
 | 
			
		||||
                for field in viewset.filterset_fields:
 | 
			
		||||
                    obj = self.fix_note_object(obj, field)
 | 
			
		||||
 | 
			
		||||
                    value = self.get_value(obj, field)
 | 
			
		||||
                    if value is None:  # pragma: no cover
 | 
			
		||||
                        warn(f"Warning: the filter {field} for the model {model._meta.verbose_name} "
 | 
			
		||||
                             "has not been tested.")
 | 
			
		||||
                        continue
 | 
			
		||||
                    resp = self.client.get(url + f"?format=json&{field}={quote_plus(str(value))}")
 | 
			
		||||
                    self.assertEqual(resp.status_code, 200, f"The filter {field} for the model "
 | 
			
		||||
                                                            f"{model._meta.verbose_name} does not work. "
 | 
			
		||||
                                                            f"Given parameter: {value}")
 | 
			
		||||
                    content = json.loads(resp.content)
 | 
			
		||||
                    self.assertGreater(content["count"], 0, f"The filter {field} for the model "
 | 
			
		||||
                                                            f"{model._meta.verbose_name} does not work. "
 | 
			
		||||
                                                            f"Given parameter: {value}")
 | 
			
		||||
 | 
			
		||||
            if OrderingFilter in backends:
 | 
			
		||||
                # Ensure that ordering is working well
 | 
			
		||||
                for field in viewset.ordering_fields:
 | 
			
		||||
                    resp = self.client.get(url + f"?ordering={field}")
 | 
			
		||||
                    self.assertEqual(resp.status_code, 200)
 | 
			
		||||
                    resp = self.client.get(url + f"?ordering=-{field}")
 | 
			
		||||
                    self.assertEqual(resp.status_code, 200)
 | 
			
		||||
 | 
			
		||||
            if RegexSafeSearchFilter in backends:
 | 
			
		||||
                # Basic search
 | 
			
		||||
                for field in viewset.search_fields:
 | 
			
		||||
                    obj = self.fix_note_object(obj, field)
 | 
			
		||||
 | 
			
		||||
                    if field[0] == '$' or field[0] == '=':
 | 
			
		||||
                        field = field[1:]
 | 
			
		||||
                    value = self.get_value(obj, field)
 | 
			
		||||
                    if value is None:  # pragma: no cover
 | 
			
		||||
                        warn(f"Warning: the filter {field} for the model {model._meta.verbose_name} "
 | 
			
		||||
                             "has not been tested.")
 | 
			
		||||
                        continue
 | 
			
		||||
                    resp = self.client.get(url + f"?format=json&search={quote_plus(str(value))}")
 | 
			
		||||
                    self.assertEqual(resp.status_code, 200, f"The filter {field} for the model "
 | 
			
		||||
                                                            f"{model._meta.verbose_name} does not work. "
 | 
			
		||||
                                                            f"Given parameter: {value}")
 | 
			
		||||
                    content = json.loads(resp.content)
 | 
			
		||||
                    self.assertGreater(content["count"], 0, f"The filter {field} for the model "
 | 
			
		||||
                                                            f"{model._meta.verbose_name} does not work. "
 | 
			
		||||
                                                            f"Given parameter: {value}")
 | 
			
		||||
 | 
			
		||||
            self.check_permissions(url, obj)
 | 
			
		||||
 | 
			
		||||
    def check_permissions(self, url, obj):
 | 
			
		||||
        """
 | 
			
		||||
        Check that permissions are working
 | 
			
		||||
        """
 | 
			
		||||
        # Drop rights
 | 
			
		||||
        self.user.is_superuser = False
 | 
			
		||||
        self.user.save()
 | 
			
		||||
        sess = self.client.session
 | 
			
		||||
        sess["permission_mask"] = 0
 | 
			
		||||
        sess.save()
 | 
			
		||||
 | 
			
		||||
        # Delete user permissions
 | 
			
		||||
        for m in Membership.objects.filter(user=self.user).all():
 | 
			
		||||
            m.roles.clear()
 | 
			
		||||
            m.save()
 | 
			
		||||
 | 
			
		||||
        # Create a new role, which will have the checking permission
 | 
			
		||||
        role = Role.objects.get_or_create(name="β-tester")[0]
 | 
			
		||||
        role.permissions.clear()
 | 
			
		||||
        role.save()
 | 
			
		||||
        membership = Membership.objects.get_or_create(user=self.user, club=Club.objects.get(name="BDE"))[0]
 | 
			
		||||
        membership.roles.set([role])
 | 
			
		||||
        membership.save()
 | 
			
		||||
 | 
			
		||||
        # Ensure that the access to the object is forbidden without permission
 | 
			
		||||
        resp = self.client.get(url + f"{obj.pk}/")
 | 
			
		||||
        self.assertEqual(resp.status_code, 404, f"Mysterious access to {url}{obj.pk}/ for {obj}")
 | 
			
		||||
 | 
			
		||||
        obj.refresh_from_db()
 | 
			
		||||
 | 
			
		||||
        # There are problems with polymorphism
 | 
			
		||||
        if isinstance(obj, Note) and hasattr(obj, "note_ptr"):
 | 
			
		||||
            obj = obj.note_ptr
 | 
			
		||||
 | 
			
		||||
        mask = PermissionMask.objects.get(rank=0)
 | 
			
		||||
 | 
			
		||||
        for field in obj._meta.fields:
 | 
			
		||||
            # Build permission query
 | 
			
		||||
            value = self.get_value(obj, field.name)
 | 
			
		||||
            if isinstance(value, date) or isinstance(value, datetime):
 | 
			
		||||
                value = value.isoformat()
 | 
			
		||||
            elif isinstance(value, ImageFieldFile):
 | 
			
		||||
                value = value.name
 | 
			
		||||
            elif isinstance(value, Decimal):
 | 
			
		||||
                value = str(value)
 | 
			
		||||
            query = json.dumps({field.name: value})
 | 
			
		||||
 | 
			
		||||
            # Create sample permission
 | 
			
		||||
            permission = Permission.objects.get_or_create(
 | 
			
		||||
                model=ContentType.objects.get_for_model(obj._meta.model),
 | 
			
		||||
                query=query,
 | 
			
		||||
                mask=mask,
 | 
			
		||||
                type="view",
 | 
			
		||||
                permanent=False,
 | 
			
		||||
                description=f"Can view {obj._meta.verbose_name}",
 | 
			
		||||
            )[0]
 | 
			
		||||
            role.permissions.set([permission])
 | 
			
		||||
            role.save()
 | 
			
		||||
 | 
			
		||||
            # Check that the access is possible
 | 
			
		||||
            resp = self.client.get(url + f"{obj.pk}/")
 | 
			
		||||
            self.assertEqual(resp.status_code, 200, f"Permission {permission.query} is not working "
 | 
			
		||||
                                                    f"for the model {obj._meta.verbose_name}")
 | 
			
		||||
 | 
			
		||||
        # Restore rights
 | 
			
		||||
        self.user.is_superuser = True
 | 
			
		||||
        self.user.save()
 | 
			
		||||
        sess = self.client.session
 | 
			
		||||
        sess["permission_mask"] = 42
 | 
			
		||||
        sess.save()
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def get_value(obj, key: str):
 | 
			
		||||
        """
 | 
			
		||||
        Resolve the queryset filter to get the Python value of an object.
 | 
			
		||||
        """
 | 
			
		||||
        if hasattr(obj, "all"):
 | 
			
		||||
            # obj is a RelatedManager
 | 
			
		||||
            obj = obj.last()
 | 
			
		||||
 | 
			
		||||
        if obj is None:  # pragma: no cover
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        if '__' not in key:
 | 
			
		||||
            obj = getattr(obj, key)
 | 
			
		||||
            if hasattr(obj, "pk"):
 | 
			
		||||
                return obj.pk
 | 
			
		||||
            elif hasattr(obj, "all"):
 | 
			
		||||
                if not obj.exists():  # pragma: no cover
 | 
			
		||||
                    return None
 | 
			
		||||
                return obj.last().pk
 | 
			
		||||
            elif isinstance(obj, bool):
 | 
			
		||||
                return int(obj)
 | 
			
		||||
            elif isinstance(obj, datetime):
 | 
			
		||||
                return obj.isoformat()
 | 
			
		||||
            elif isinstance(obj, PhoneNumber):
 | 
			
		||||
                return obj.raw_input
 | 
			
		||||
            return obj
 | 
			
		||||
 | 
			
		||||
        key, remaining = key.split('__', 1)
 | 
			
		||||
        return TestAPI.get_value(getattr(obj, key), remaining)
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def fix_note_object(obj, field):
 | 
			
		||||
        """
 | 
			
		||||
        When querying an object that has a noteclub or a noteuser field,
 | 
			
		||||
        ensure that the object has a good value.
 | 
			
		||||
        """
 | 
			
		||||
        if isinstance(obj, Alias):
 | 
			
		||||
            if "noteuser" in field:
 | 
			
		||||
                return NoteUser.objects.last().alias.last()
 | 
			
		||||
            elif "noteclub" in field:
 | 
			
		||||
                return NoteClub.objects.last().alias.last()
 | 
			
		||||
        elif isinstance(obj, Note):
 | 
			
		||||
            if "noteuser" in field:
 | 
			
		||||
                return NoteUser.objects.last()
 | 
			
		||||
            elif "noteclub" in field:
 | 
			
		||||
                return NoteClub.objects.last()
 | 
			
		||||
        return obj
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestBasicAPI(TestAPI):
 | 
			
		||||
    def test_user_api(self):
 | 
			
		||||
        """
 | 
			
		||||
        Load the user page.
 | 
			
		||||
        """
 | 
			
		||||
        self.check_viewset(ContentTypeViewSet, "/api/models/")
 | 
			
		||||
        self.check_viewset(UserViewSet, "/api/user/")
 | 
			
		||||
							
								
								
									
										117
									
								
								apps/api/urls.py
									
									
									
									
									
								
							
							
						
						
									
										117
									
								
								apps/api/urls.py
									
									
									
									
									
								
							@@ -1,91 +1,58 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.conf.urls import url, include
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.contrib.contenttypes.models import ContentType
 | 
			
		||||
from django_filters.rest_framework import DjangoFilterBackend
 | 
			
		||||
from rest_framework import routers, serializers
 | 
			
		||||
from rest_framework.filters import SearchFilter
 | 
			
		||||
from rest_framework.viewsets import ReadOnlyModelViewSet
 | 
			
		||||
from activity.api.urls import register_activity_urls
 | 
			
		||||
from api.viewsets import ReadProtectedModelViewSet
 | 
			
		||||
from member.api.urls import register_members_urls
 | 
			
		||||
from note.api.urls import register_note_urls
 | 
			
		||||
from treasury.api.urls import register_treasury_urls
 | 
			
		||||
from logs.api.urls import register_logs_urls
 | 
			
		||||
from permission.api.urls import register_permission_urls
 | 
			
		||||
from wei.api.urls import register_wei_urls
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserSerializer(serializers.ModelSerializer):
 | 
			
		||||
    """
 | 
			
		||||
    REST API Serializer for Users.
 | 
			
		||||
    The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = User
 | 
			
		||||
        exclude = (
 | 
			
		||||
            'password',
 | 
			
		||||
            'groups',
 | 
			
		||||
            'user_permissions',
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ContentTypeSerializer(serializers.ModelSerializer):
 | 
			
		||||
    """
 | 
			
		||||
    REST API Serializer for Users.
 | 
			
		||||
    The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = ContentType
 | 
			
		||||
        fields = '__all__'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    """
 | 
			
		||||
    REST API View set.
 | 
			
		||||
    The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/users/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = User.objects.all()
 | 
			
		||||
    serializer_class = UserSerializer
 | 
			
		||||
    filter_backends = [DjangoFilterBackend, SearchFilter]
 | 
			
		||||
    filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', ]
 | 
			
		||||
    search_fields = ['$username', '$first_name', '$last_name', ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# This ViewSet is the only one that is accessible from all authenticated users!
 | 
			
		||||
class ContentTypeViewSet(ReadOnlyModelViewSet):
 | 
			
		||||
    """
 | 
			
		||||
    REST API View set.
 | 
			
		||||
    The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/users/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = ContentType.objects.all()
 | 
			
		||||
    serializer_class = ContentTypeSerializer
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.conf.urls import include
 | 
			
		||||
from django.urls import re_path
 | 
			
		||||
from rest_framework import routers
 | 
			
		||||
 | 
			
		||||
from .views import UserInformationView
 | 
			
		||||
from .viewsets import ContentTypeViewSet, UserViewSet
 | 
			
		||||
 | 
			
		||||
# Routers provide an easy way of automatically determining the URL conf.
 | 
			
		||||
# Register each app API router and user viewset
 | 
			
		||||
router = routers.DefaultRouter()
 | 
			
		||||
router.register('models', ContentTypeViewSet)
 | 
			
		||||
router.register('user', UserViewSet)
 | 
			
		||||
register_members_urls(router, 'members')
 | 
			
		||||
register_activity_urls(router, 'activity')
 | 
			
		||||
register_note_urls(router, 'note')
 | 
			
		||||
register_treasury_urls(router, 'treasury')
 | 
			
		||||
register_permission_urls(router, 'permission')
 | 
			
		||||
register_logs_urls(router, 'logs')
 | 
			
		||||
register_wei_urls(router, 'wei')
 | 
			
		||||
 | 
			
		||||
if "activity" in settings.INSTALLED_APPS:
 | 
			
		||||
    from activity.api.urls import register_activity_urls
 | 
			
		||||
    register_activity_urls(router, 'activity')
 | 
			
		||||
 | 
			
		||||
if "food" in settings.INSTALLED_APPS:
 | 
			
		||||
    from food.api.urls import register_food_urls
 | 
			
		||||
    register_food_urls(router, 'food')
 | 
			
		||||
 | 
			
		||||
if "logs" in settings.INSTALLED_APPS:
 | 
			
		||||
    from logs.api.urls import register_logs_urls
 | 
			
		||||
    register_logs_urls(router, 'logs')
 | 
			
		||||
 | 
			
		||||
if "member" in settings.INSTALLED_APPS:
 | 
			
		||||
    from member.api.urls import register_members_urls
 | 
			
		||||
    register_members_urls(router, 'members')
 | 
			
		||||
 | 
			
		||||
if "note" in settings.INSTALLED_APPS:
 | 
			
		||||
    from note.api.urls import register_note_urls
 | 
			
		||||
    register_note_urls(router, 'note')
 | 
			
		||||
 | 
			
		||||
if "permission" in settings.INSTALLED_APPS:
 | 
			
		||||
    from permission.api.urls import register_permission_urls
 | 
			
		||||
    register_permission_urls(router, 'permission')
 | 
			
		||||
 | 
			
		||||
if "treasury" in settings.INSTALLED_APPS:
 | 
			
		||||
    from treasury.api.urls import register_treasury_urls
 | 
			
		||||
    register_treasury_urls(router, 'treasury')
 | 
			
		||||
 | 
			
		||||
if "wei" in settings.INSTALLED_APPS:
 | 
			
		||||
    from wei.api.urls import register_wei_urls
 | 
			
		||||
    register_wei_urls(router, 'wei')
 | 
			
		||||
 | 
			
		||||
app_name = 'api'
 | 
			
		||||
 | 
			
		||||
# Wire up our API using automatic URL routing.
 | 
			
		||||
# Additionally, we include login URLs for the browsable API.
 | 
			
		||||
urlpatterns = [
 | 
			
		||||
    url('^', include(router.urls)),
 | 
			
		||||
    url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
 | 
			
		||||
    re_path('^', include(router.urls)),
 | 
			
		||||
    re_path('^me/', UserInformationView.as_view()),
 | 
			
		||||
    re_path('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
 | 
			
		||||
]
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								apps/api/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								apps/api/views.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from rest_framework.generics import RetrieveAPIView
 | 
			
		||||
 | 
			
		||||
from .serializers import OAuthSerializer
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserInformationView(RetrieveAPIView):
 | 
			
		||||
    """
 | 
			
		||||
    These fields are give to OAuth authenticators.
 | 
			
		||||
    """
 | 
			
		||||
    serializer_class = OAuthSerializer
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return User.objects.filter(pk=self.request.user.pk)
 | 
			
		||||
 | 
			
		||||
    def get_object(self):
 | 
			
		||||
        return self.request.user
 | 
			
		||||
@@ -1,31 +1,126 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
from django.contrib.contenttypes.models import ContentType
 | 
			
		||||
from django_filters.rest_framework import DjangoFilterBackend
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet
 | 
			
		||||
from permission.backends import PermissionBackend
 | 
			
		||||
from rest_framework import viewsets
 | 
			
		||||
from note_kfet.middlewares import get_current_authenticated_user
 | 
			
		||||
from note.models import Alias
 | 
			
		||||
 | 
			
		||||
from .filters import RegexSafeSearchFilter
 | 
			
		||||
from .serializers import UserSerializer, ContentTypeSerializer
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReadProtectedModelViewSet(viewsets.ModelViewSet):
 | 
			
		||||
def is_regex(pattern):
 | 
			
		||||
    try:
 | 
			
		||||
        re.compile(pattern)
 | 
			
		||||
        return True
 | 
			
		||||
    except (re.error, TypeError):
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReadProtectedModelViewSet(ModelViewSet):
 | 
			
		||||
    """
 | 
			
		||||
    Protect a ModelViewSet by filtering the objects that the user cannot see.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
 | 
			
		||||
        user = get_current_authenticated_user()
 | 
			
		||||
        self.queryset = model.objects.filter(PermissionBackend.filter_queryset(user, model, "view"))
 | 
			
		||||
        self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return self.queryset.filter(PermissionBackend.filter_queryset(self.request, self.model, "view")).distinct()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReadOnlyProtectedModelViewSet(viewsets.ReadOnlyModelViewSet):
 | 
			
		||||
class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
 | 
			
		||||
    """
 | 
			
		||||
    Protect a ReadOnlyModelViewSet by filtering the objects that the user cannot see.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
 | 
			
		||||
        user = get_current_authenticated_user()
 | 
			
		||||
        self.queryset = model.objects.filter(PermissionBackend.filter_queryset(user, model, "view"))
 | 
			
		||||
        self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return self.queryset.filter(PermissionBackend.filter_queryset(self.request, self.model, "view")).distinct()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    """
 | 
			
		||||
    REST API View set.
 | 
			
		||||
    The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/user/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = User.objects
 | 
			
		||||
    serializer_class = UserSerializer
 | 
			
		||||
    filter_backends = [DjangoFilterBackend]
 | 
			
		||||
    filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active',
 | 
			
		||||
                        'note__alias__name', 'note__alias__normalized_name', ]
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        queryset = super().get_queryset()
 | 
			
		||||
        # Sqlite doesn't support ORDER BY in subqueries
 | 
			
		||||
        queryset = queryset.order_by("username") \
 | 
			
		||||
            if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' else queryset
 | 
			
		||||
 | 
			
		||||
        if "search" in self.request.GET:
 | 
			
		||||
            pattern = self.request.GET["search"]
 | 
			
		||||
            # Check if this is a valid regex. If not, we won't check regex
 | 
			
		||||
            valid_regex = is_regex(pattern)
 | 
			
		||||
            suffix = "__iregex" if valid_regex else "__istartswith"
 | 
			
		||||
            prefix = "^" if valid_regex else ""
 | 
			
		||||
 | 
			
		||||
            # Filter with different rules
 | 
			
		||||
            # We use union-all to keep each filter rule sorted in result
 | 
			
		||||
            queryset = queryset.filter(
 | 
			
		||||
                # Match without normalization
 | 
			
		||||
                Q(**{f"note__alias__name{suffix}": prefix + pattern})
 | 
			
		||||
            ).union(
 | 
			
		||||
                queryset.filter(
 | 
			
		||||
                    # Match with normalization
 | 
			
		||||
                    Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
 | 
			
		||||
                    & ~Q(**{f"note__alias__name{suffix}": prefix + pattern})
 | 
			
		||||
                ),
 | 
			
		||||
                all=True,
 | 
			
		||||
            ).union(
 | 
			
		||||
                queryset.filter(
 | 
			
		||||
                    # Match on lower pattern
 | 
			
		||||
                    Q(**{f"note__alias__normalized_name{suffix}": prefix + pattern.lower()})
 | 
			
		||||
                    & ~Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
 | 
			
		||||
                    & ~Q(**{f"note__alias__name{suffix}": prefix + pattern})
 | 
			
		||||
                ),
 | 
			
		||||
                all=True,
 | 
			
		||||
            ).union(
 | 
			
		||||
                queryset.filter(
 | 
			
		||||
                    # Match on firstname or lastname
 | 
			
		||||
                    (Q(**{f"last_name{suffix}": prefix + pattern}) | Q(**{f"first_name{suffix}": prefix + pattern}))
 | 
			
		||||
                    & ~Q(**{f"note__alias__normalized_name{suffix}": prefix + pattern.lower()})
 | 
			
		||||
                    & ~Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
 | 
			
		||||
                    & ~Q(**{f"note__alias__name{suffix}": prefix + pattern})
 | 
			
		||||
                ),
 | 
			
		||||
                all=True,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        queryset = queryset if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' \
 | 
			
		||||
            else queryset.order_by("username")
 | 
			
		||||
 | 
			
		||||
        return queryset
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# This ViewSet is the only one that is accessible from all authenticated users!
 | 
			
		||||
class ContentTypeViewSet(ReadOnlyModelViewSet):
 | 
			
		||||
    """
 | 
			
		||||
    REST API View set.
 | 
			
		||||
    The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/models/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = ContentType.objects.order_by('id')
 | 
			
		||||
    serializer_class = ContentTypeSerializer
 | 
			
		||||
    filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
 | 
			
		||||
    filterset_fields = ['id', 'app_label', 'model', ]
 | 
			
		||||
    search_fields = ['$app_label', '$model', ]
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										0
									
								
								apps/food/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								apps/food/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										37
									
								
								apps/food/admin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								apps/food/admin.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
from django.db import transaction
 | 
			
		||||
from note_kfet.admin import admin_site
 | 
			
		||||
 | 
			
		||||
from .models import Allergen, BasicFood, QRCode, TransformedFood
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(QRCode, site=admin_site)
 | 
			
		||||
class QRCodeAdmin(admin.ModelAdmin):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(BasicFood, site=admin_site)
 | 
			
		||||
class BasicFoodAdmin(admin.ModelAdmin):
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def save_related(self, *args, **kwargs):
 | 
			
		||||
        ans = super().save_related(*args, **kwargs)
 | 
			
		||||
        args[1].instance.update()
 | 
			
		||||
        return ans
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(TransformedFood, site=admin_site)
 | 
			
		||||
class TransformedFoodAdmin(admin.ModelAdmin):
 | 
			
		||||
    exclude = ["allergens", "expiry_date"]
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def save_related(self, request, form, *args, **kwargs):
 | 
			
		||||
        super().save_related(request, form, *args, **kwargs)
 | 
			
		||||
        form.instance.update()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Allergen, site=admin_site)
 | 
			
		||||
class AllergenAdmin(admin.ModelAdmin):
 | 
			
		||||
    pass
 | 
			
		||||
							
								
								
									
										0
									
								
								apps/food/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								apps/food/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										50
									
								
								apps/food/api/serializers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								apps/food/api/serializers.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,50 @@
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from rest_framework import serializers
 | 
			
		||||
 | 
			
		||||
from ..models import Allergen, BasicFood, QRCode, TransformedFood
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AllergenSerializer(serializers.ModelSerializer):
 | 
			
		||||
    """
 | 
			
		||||
    REST API Serializer for Allergen.
 | 
			
		||||
    The djangorestframework plugin will analyse the model `Allergen` and parse all fields in the API.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Allergen
 | 
			
		||||
        fields = '__all__'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BasicFoodSerializer(serializers.ModelSerializer):
 | 
			
		||||
    """
 | 
			
		||||
    REST API Serializer for BasicFood.
 | 
			
		||||
    The djangorestframework plugin will analyse the model `BasicFood` and parse all fields in the API.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = BasicFood
 | 
			
		||||
        fields = '__all__'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QRCodeSerializer(serializers.ModelSerializer):
 | 
			
		||||
    """
 | 
			
		||||
    REST API Serializer for QRCode.
 | 
			
		||||
    The djangorestframework plugin will analyse the model `QRCode` and parse all fields in the API.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = QRCode
 | 
			
		||||
        fields = '__all__'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransformedFoodSerializer(serializers.ModelSerializer):
 | 
			
		||||
    """
 | 
			
		||||
    REST API Serializer for TransformedFood.
 | 
			
		||||
    The djangorestframework plugin will analyse the model `TransformedFood` and parse all fields in the API.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = TransformedFood
 | 
			
		||||
        fields = '__all__'
 | 
			
		||||
							
								
								
									
										14
									
								
								apps/food/api/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								apps/food/api/urls.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from .views import AllergenViewSet, BasicFoodViewSet, QRCodeViewSet, TransformedFoodViewSet
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def register_food_urls(router, path):
 | 
			
		||||
    """
 | 
			
		||||
    Configure router for Food REST API.
 | 
			
		||||
    """
 | 
			
		||||
    router.register(path + '/allergen', AllergenViewSet)
 | 
			
		||||
    router.register(path + '/basic_food', BasicFoodViewSet)
 | 
			
		||||
    router.register(path + '/qrcode', QRCodeViewSet)
 | 
			
		||||
    router.register(path + '/transformed_food', TransformedFoodViewSet)
 | 
			
		||||
							
								
								
									
										61
									
								
								apps/food/api/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								apps/food/api/views.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from api.viewsets import ReadProtectedModelViewSet
 | 
			
		||||
from django_filters.rest_framework import DjangoFilterBackend
 | 
			
		||||
from rest_framework.filters import SearchFilter
 | 
			
		||||
 | 
			
		||||
from .serializers import AllergenSerializer, BasicFoodSerializer, QRCodeSerializer, TransformedFoodSerializer
 | 
			
		||||
from ..models import Allergen, BasicFood, QRCode, TransformedFood
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AllergenViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    """
 | 
			
		||||
    REST API View set.
 | 
			
		||||
    The djangorestframework plugin will get all `Allergen` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/food/allergen/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = Allergen.objects.order_by('id')
 | 
			
		||||
    serializer_class = AllergenSerializer
 | 
			
		||||
    filter_backends = [DjangoFilterBackend, SearchFilter]
 | 
			
		||||
    filterset_fields = ['name', ]
 | 
			
		||||
    search_fields = ['$name', ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BasicFoodViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    """
 | 
			
		||||
    REST API View set.
 | 
			
		||||
    The djangorestframework plugin will get all `BasicFood` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/food/basic_food/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = BasicFood.objects.order_by('id')
 | 
			
		||||
    serializer_class = BasicFoodSerializer
 | 
			
		||||
    filter_backends = [DjangoFilterBackend, SearchFilter]
 | 
			
		||||
    filterset_fields = ['name', ]
 | 
			
		||||
    search_fields = ['$name', ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QRCodeViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    """
 | 
			
		||||
    REST API View set.
 | 
			
		||||
    The djangorestframework plugin will get all `QRCode` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/food/qrcode/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = QRCode.objects.order_by('id')
 | 
			
		||||
    serializer_class = QRCodeSerializer
 | 
			
		||||
    filter_backends = [DjangoFilterBackend, SearchFilter]
 | 
			
		||||
    filterset_fields = ['qr_code_number', ]
 | 
			
		||||
    search_fields = ['$qr_code_number', ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransformedFoodViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    """
 | 
			
		||||
    REST API View set.
 | 
			
		||||
    The djangorestframework plugin will get all `TransformedFood` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/food/transformed_food/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = TransformedFood.objects.order_by('id')
 | 
			
		||||
    serializer_class = TransformedFoodSerializer
 | 
			
		||||
    filter_backends = [DjangoFilterBackend, SearchFilter]
 | 
			
		||||
    filterset_fields = ['name', ]
 | 
			
		||||
    search_fields = ['$name', ]
 | 
			
		||||
							
								
								
									
										11
									
								
								apps/food/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								apps/food/apps.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FoodkfetConfig(AppConfig):
 | 
			
		||||
    name = 'food'
 | 
			
		||||
    verbose_name = _('food')
 | 
			
		||||
							
								
								
									
										114
									
								
								apps/food/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								apps/food/forms.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,114 @@
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from random import shuffle
 | 
			
		||||
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from member.models import Club
 | 
			
		||||
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
 | 
			
		||||
from note_kfet.inputs import Autocomplete
 | 
			
		||||
from note_kfet.middlewares import get_current_request
 | 
			
		||||
from permission.backends import PermissionBackend
 | 
			
		||||
 | 
			
		||||
from .models import BasicFood, QRCode, TransformedFood
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AddIngredientForms(forms.ModelForm):
 | 
			
		||||
    """
 | 
			
		||||
    Form for add an ingredient
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.fields['ingredient'].queryset = self.fields['ingredient'].queryset.filter(
 | 
			
		||||
            polymorphic_ctype__model='transformedfood',
 | 
			
		||||
            is_ready=False,
 | 
			
		||||
            is_active=True,
 | 
			
		||||
            was_eaten=False,
 | 
			
		||||
        )
 | 
			
		||||
        # Caution, the logic is inverted here, we flip the logic on saving in AddIngredientView
 | 
			
		||||
        self.fields['is_active'].initial = True
 | 
			
		||||
        self.fields['is_active'].label = _("Fully used")
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = TransformedFood
 | 
			
		||||
        fields = ('ingredient', 'is_active')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BasicFoodForms(forms.ModelForm):
 | 
			
		||||
    """
 | 
			
		||||
    Form for add non-transformed food
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.fields['name'].widget.attrs.update({"autofocus": "autofocus"})
 | 
			
		||||
        self.fields['name'].required = True
 | 
			
		||||
        self.fields['owner'].required = True
 | 
			
		||||
 | 
			
		||||
        # Some example
 | 
			
		||||
        self.fields['name'].widget.attrs.update({"placeholder": _("Pasta METRO 5kg")})
 | 
			
		||||
        clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all())
 | 
			
		||||
        shuffle(clubs)
 | 
			
		||||
        self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = BasicFood
 | 
			
		||||
        fields = ('name', 'owner', 'date_type', 'expiry_date', 'is_active', 'was_eaten', 'allergens',)
 | 
			
		||||
        widgets = {
 | 
			
		||||
            "owner": Autocomplete(
 | 
			
		||||
                model=Club,
 | 
			
		||||
                attrs={"api_url": "/api/members/club/"},
 | 
			
		||||
            ),
 | 
			
		||||
            'expiry_date': DateTimePickerInput(),
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QRCodeForms(forms.ModelForm):
 | 
			
		||||
    """
 | 
			
		||||
    Form for create QRCode
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.fields['food_container'].queryset = self.fields['food_container'].queryset.filter(
 | 
			
		||||
            is_active=True,
 | 
			
		||||
            was_eaten=False,
 | 
			
		||||
            polymorphic_ctype__model='transformedfood',
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = QRCode
 | 
			
		||||
        fields = ('food_container',)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransformedFoodForms(forms.ModelForm):
 | 
			
		||||
    """
 | 
			
		||||
    Form for add transformed food
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.fields['name'].widget.attrs.update({"autofocus": "autofocus"})
 | 
			
		||||
        self.fields['name'].required = True
 | 
			
		||||
        self.fields['owner'].required = True
 | 
			
		||||
        self.fields['creation_date'].required = True
 | 
			
		||||
        self.fields['creation_date'].initial = timezone.now
 | 
			
		||||
        self.fields['is_active'].initial = True
 | 
			
		||||
        self.fields['is_ready'].initial = False
 | 
			
		||||
        self.fields['was_eaten'].initial = False
 | 
			
		||||
 | 
			
		||||
        # Some example
 | 
			
		||||
        self.fields['name'].widget.attrs.update({"placeholder": _("Lasagna")})
 | 
			
		||||
        clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all())
 | 
			
		||||
        shuffle(clubs)
 | 
			
		||||
        self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = TransformedFood
 | 
			
		||||
        fields = ('name', 'creation_date', 'owner', 'is_active', 'is_ready', 'was_eaten', 'shelf_life')
 | 
			
		||||
        widgets = {
 | 
			
		||||
            "owner": Autocomplete(
 | 
			
		||||
                model=Club,
 | 
			
		||||
                attrs={"api_url": "/api/members/club/"},
 | 
			
		||||
            ),
 | 
			
		||||
            'creation_date': DateTimePickerInput(),
 | 
			
		||||
        }
 | 
			
		||||
							
								
								
									
										84
									
								
								apps/food/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								apps/food/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,84 @@
 | 
			
		||||
# Generated by Django 2.2.28 on 2024-07-05 08:57
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
import django.utils.timezone
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    initial = True
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('contenttypes', '0002_remove_content_type_name'),
 | 
			
		||||
        ('member', '0011_profile_vss_charter_read'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Allergen',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('name', models.CharField(max_length=255, verbose_name='name')),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'verbose_name': 'Allergen',
 | 
			
		||||
                'verbose_name_plural': 'Allergens',
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Food',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('name', models.CharField(max_length=255, verbose_name='name')),
 | 
			
		||||
                ('expiry_date', models.DateTimeField(verbose_name='expiry date')),
 | 
			
		||||
                ('was_eaten', models.BooleanField(default=False, verbose_name='was eaten')),
 | 
			
		||||
                ('is_ready', models.BooleanField(default=False, verbose_name='is ready')),
 | 
			
		||||
                ('allergens', models.ManyToManyField(blank=True, to='food.Allergen', verbose_name='allergen')),
 | 
			
		||||
                ('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='member.Club', verbose_name='owner')),
 | 
			
		||||
                ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_food.food_set+', to='contenttypes.ContentType')),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'verbose_name': 'foods',
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='BasicFood',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('food_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='food.Food')),
 | 
			
		||||
                ('date_type', models.CharField(choices=[('DLC', 'DLC'), ('DDM', 'DDM')], max_length=255)),
 | 
			
		||||
                ('arrival_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='arrival date')),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'verbose_name': 'Basic food',
 | 
			
		||||
                'verbose_name_plural': 'Basic foods',
 | 
			
		||||
            },
 | 
			
		||||
            bases=('food.food',),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='QRCode',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('qr_code_number', models.PositiveIntegerField(unique=True, verbose_name='QR-code number')),
 | 
			
		||||
                ('food_container', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='QR_code', to='food.Food', verbose_name='food container')),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'verbose_name': 'QR-code',
 | 
			
		||||
                'verbose_name_plural': 'QR-codes',
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='TransformedFood',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('food_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='food.Food')),
 | 
			
		||||
                ('creation_date', models.DateTimeField(verbose_name='creation date')),
 | 
			
		||||
                ('is_active', models.BooleanField(default=True, verbose_name='is active')),
 | 
			
		||||
                ('ingredient', models.ManyToManyField(blank=True, related_name='transformed_ingredient_inv', to='food.Food', verbose_name='transformed ingredient')),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'verbose_name': 'Transformed food',
 | 
			
		||||
                'verbose_name_plural': 'Transformed foods',
 | 
			
		||||
            },
 | 
			
		||||
            bases=('food.food',),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										19
									
								
								apps/food/migrations/0002_transformedfood_shelf_life.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								apps/food/migrations/0002_transformedfood_shelf_life.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
# Generated by Django 2.2.28 on 2024-07-06 20:37
 | 
			
		||||
 | 
			
		||||
import datetime
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('food', '0001_initial'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='transformedfood',
 | 
			
		||||
            name='shelf_life',
 | 
			
		||||
            field=models.DurationField(default=datetime.timedelta(days=3), verbose_name='shelf life'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										62
									
								
								apps/food/migrations/0003_create_14_allergens_mandatory.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								apps/food/migrations/0003_create_14_allergens_mandatory.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,62 @@
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
def create_14_mandatory_allergens(apps, schema_editor):
 | 
			
		||||
    """
 | 
			
		||||
    There are 14 mandatory allergens, they are pre-injected
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    Allergen = apps.get_model("food", "allergen")
 | 
			
		||||
    
 | 
			
		||||
    Allergen.objects.get_or_create(
 | 
			
		||||
        name="Gluten",
 | 
			
		||||
    ) 
 | 
			
		||||
    Allergen.objects.get_or_create(
 | 
			
		||||
        name="Fruits à coques",
 | 
			
		||||
    )
 | 
			
		||||
    Allergen.objects.get_or_create(
 | 
			
		||||
        name="Crustacés",
 | 
			
		||||
    )
 | 
			
		||||
    Allergen.objects.get_or_create(
 | 
			
		||||
        name="Céléri",
 | 
			
		||||
    )
 | 
			
		||||
    Allergen.objects.get_or_create(
 | 
			
		||||
        name="Oeufs",
 | 
			
		||||
    )
 | 
			
		||||
    Allergen.objects.get_or_create(
 | 
			
		||||
        name="Moutarde",
 | 
			
		||||
    )
 | 
			
		||||
    Allergen.objects.get_or_create(
 | 
			
		||||
        name="Poissons",
 | 
			
		||||
    )
 | 
			
		||||
    Allergen.objects.get_or_create(
 | 
			
		||||
        name="Soja",
 | 
			
		||||
    )
 | 
			
		||||
    Allergen.objects.get_or_create(
 | 
			
		||||
        name="Lait",
 | 
			
		||||
    )
 | 
			
		||||
    Allergen.objects.get_or_create(
 | 
			
		||||
        name="Sulfites",
 | 
			
		||||
    )
 | 
			
		||||
    Allergen.objects.get_or_create(
 | 
			
		||||
        name="Sésame",
 | 
			
		||||
    )
 | 
			
		||||
    Allergen.objects.get_or_create(
 | 
			
		||||
        name="Lupin",
 | 
			
		||||
    )
 | 
			
		||||
    Allergen.objects.get_or_create(
 | 
			
		||||
        name="Arachides",
 | 
			
		||||
    )
 | 
			
		||||
    Allergen.objects.get_or_create(
 | 
			
		||||
        name="Mollusques",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('food', '0002_transformedfood_shelf_life'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RunPython(create_14_mandatory_allergens),
 | 
			
		||||
    ]
 | 
			
		||||
    
 | 
			
		||||
    
 | 
			
		||||
							
								
								
									
										28
									
								
								apps/food/migrations/0004_auto_20240813_2358.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								apps/food/migrations/0004_auto_20240813_2358.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
# Generated by Django 2.2.28 on 2024-08-13 21:58
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('food', '0003_create_14_allergens_mandatory'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RemoveField(
 | 
			
		||||
            model_name='transformedfood',
 | 
			
		||||
            name='is_active',
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='food',
 | 
			
		||||
            name='is_active',
 | 
			
		||||
            field=models.BooleanField(default=True, verbose_name='is active'),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='qrcode',
 | 
			
		||||
            name='food_container',
 | 
			
		||||
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='QR_code', to='food.Food', verbose_name='food container'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										20
									
								
								apps/food/migrations/0005_alter_food_polymorphic_ctype.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								apps/food/migrations/0005_alter_food_polymorphic_ctype.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
# Generated by Django 4.2.15 on 2024-08-28 08:00
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('contenttypes', '0002_remove_content_type_name'),
 | 
			
		||||
        ('food', '0004_auto_20240813_2358'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='food',
 | 
			
		||||
            name='polymorphic_ctype',
 | 
			
		||||
            field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										0
									
								
								apps/food/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								apps/food/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										226
									
								
								apps/food/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										226
									
								
								apps/food/models.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,226 @@
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
 | 
			
		||||
from django.db import models, transaction
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from member.models import Club
 | 
			
		||||
from polymorphic.models import PolymorphicModel
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QRCode(models.Model):
 | 
			
		||||
    """
 | 
			
		||||
    An QRCode model
 | 
			
		||||
    """
 | 
			
		||||
    qr_code_number = models.PositiveIntegerField(
 | 
			
		||||
        verbose_name=_("QR-code number"),
 | 
			
		||||
        unique=True,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    food_container = models.ForeignKey(
 | 
			
		||||
        'Food',
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
        related_name='QR_code',
 | 
			
		||||
        verbose_name=_('food container'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("QR-code")
 | 
			
		||||
        verbose_name_plural = _("QR-codes")
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return _("QR-code number {qr_code_number}").format(qr_code_number=self.qr_code_number)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Allergen(models.Model):
 | 
			
		||||
    """
 | 
			
		||||
    A list of allergen and alimentary restrictions
 | 
			
		||||
    """
 | 
			
		||||
    name = models.CharField(
 | 
			
		||||
        verbose_name=_('name'),
 | 
			
		||||
        max_length=255,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _('Allergen')
 | 
			
		||||
        verbose_name_plural = _('Allergens')
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Food(PolymorphicModel):
 | 
			
		||||
    name = models.CharField(
 | 
			
		||||
        verbose_name=_('name'),
 | 
			
		||||
        max_length=255,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    owner = models.ForeignKey(
 | 
			
		||||
        Club,
 | 
			
		||||
        on_delete=models.PROTECT,
 | 
			
		||||
        related_name='+',
 | 
			
		||||
        verbose_name=_('owner'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    allergens = models.ManyToManyField(
 | 
			
		||||
        Allergen,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        verbose_name=_('allergen'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    expiry_date = models.DateTimeField(
 | 
			
		||||
        verbose_name=_('expiry date'),
 | 
			
		||||
        null=False,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    was_eaten = models.BooleanField(
 | 
			
		||||
        default=False,
 | 
			
		||||
        verbose_name=_('was eaten'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # is_ready != is_active : is_ready signifie que la nourriture est prête à être manger,
 | 
			
		||||
    #                         is_active signifie que la nourriture n'est pas encore archivé
 | 
			
		||||
    # il sert dans les cas où il est plus intéressant que de l'open soit conservé (confiture par ex)
 | 
			
		||||
 | 
			
		||||
    is_ready = models.BooleanField(
 | 
			
		||||
        default=False,
 | 
			
		||||
        verbose_name=_('is ready'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    is_active = models.BooleanField(
 | 
			
		||||
        default=True,
 | 
			
		||||
        verbose_name=_('is active'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
 | 
			
		||||
        return super().save(force_insert, force_update, using, update_fields)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _('food')
 | 
			
		||||
        verbose_name = _('foods')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BasicFood(Food):
 | 
			
		||||
    """
 | 
			
		||||
    Food which has been directly buy on supermarket
 | 
			
		||||
    """
 | 
			
		||||
    date_type = models.CharField(
 | 
			
		||||
        max_length=255,
 | 
			
		||||
        choices=(
 | 
			
		||||
            ("DLC", "DLC"),
 | 
			
		||||
            ("DDM", "DDM"),
 | 
			
		||||
        )
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    arrival_date = models.DateTimeField(
 | 
			
		||||
        verbose_name=_('arrival date'),
 | 
			
		||||
        default=timezone.now,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # label = models.ImageField(
 | 
			
		||||
    #     verbose_name=_('food label'),
 | 
			
		||||
    #     max_length=255,
 | 
			
		||||
    #     blank=False,
 | 
			
		||||
    #     null=False,
 | 
			
		||||
    #     upload_to='label/',
 | 
			
		||||
    # )
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def update_allergens(self):
 | 
			
		||||
        # update parents
 | 
			
		||||
        for parent in self.transformed_ingredient_inv.iterator():
 | 
			
		||||
            parent.update_allergens()
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def update_expiry_date(self):
 | 
			
		||||
        # update parents
 | 
			
		||||
        for parent in self.transformed_ingredient_inv.iterator():
 | 
			
		||||
            parent.update_expiry_date()
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def update(self):
 | 
			
		||||
        self.update_allergens()
 | 
			
		||||
        self.update_expiry_date()
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _('Basic food')
 | 
			
		||||
        verbose_name_plural = _('Basic foods')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransformedFood(Food):
 | 
			
		||||
    """
 | 
			
		||||
    Transformed food  are a mix between basic food and meal
 | 
			
		||||
    """
 | 
			
		||||
    creation_date = models.DateTimeField(
 | 
			
		||||
        verbose_name=_('creation date'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    ingredient = models.ManyToManyField(
 | 
			
		||||
        Food,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        symmetrical=False,
 | 
			
		||||
        related_name='transformed_ingredient_inv',
 | 
			
		||||
        verbose_name=_('transformed ingredient'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Without microbiological analyzes, the storage time is 3 days
 | 
			
		||||
    shelf_life = models.DurationField(
 | 
			
		||||
        verbose_name=_("shelf life"),
 | 
			
		||||
        default=timedelta(days=3),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def archive(self):
 | 
			
		||||
        # When a meal are archived, if it was eaten, update ingredient fully used for this meal
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def update_allergens(self):
 | 
			
		||||
        # When allergens are changed, simply update the parents' allergens
 | 
			
		||||
        old_allergens = list(self.allergens.all())
 | 
			
		||||
        self.allergens.clear()
 | 
			
		||||
        for ingredient in self.ingredient.iterator():
 | 
			
		||||
            self.allergens.set(self.allergens.union(ingredient.allergens.all()))
 | 
			
		||||
 | 
			
		||||
        if old_allergens == list(self.allergens.all()):
 | 
			
		||||
            return
 | 
			
		||||
        super().save()
 | 
			
		||||
 | 
			
		||||
        # update parents
 | 
			
		||||
        for parent in self.transformed_ingredient_inv.iterator():
 | 
			
		||||
            parent.update_allergens()
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def update_expiry_date(self):
 | 
			
		||||
        # When expiry_date is changed, simply update the parents' expiry_date
 | 
			
		||||
        old_expiry_date = self.expiry_date
 | 
			
		||||
        self.expiry_date = self.creation_date + self.shelf_life
 | 
			
		||||
        for ingredient in self.ingredient.iterator():
 | 
			
		||||
            self.expiry_date = min(self.expiry_date, ingredient.expiry_date)
 | 
			
		||||
 | 
			
		||||
        if old_expiry_date == self.expiry_date:
 | 
			
		||||
            return
 | 
			
		||||
        super().save()
 | 
			
		||||
 | 
			
		||||
        # update parents
 | 
			
		||||
        for parent in self.transformed_ingredient_inv.iterator():
 | 
			
		||||
            parent.update_expiry_date()
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def update(self):
 | 
			
		||||
        self.update_allergens()
 | 
			
		||||
        self.update_expiry_date()
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        super().save(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _('Transformed food')
 | 
			
		||||
        verbose_name_plural = _('Transformed foods')
 | 
			
		||||
							
								
								
									
										19
									
								
								apps/food/tables.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								apps/food/tables.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
import django_tables2 as tables
 | 
			
		||||
from django_tables2 import A
 | 
			
		||||
 | 
			
		||||
from .models import TransformedFood
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransformedFoodTable(tables.Table):
 | 
			
		||||
    name = tables.LinkColumn(
 | 
			
		||||
        'food:food_view',
 | 
			
		||||
        args=[A('pk'), ],
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = TransformedFood
 | 
			
		||||
        template_name = 'django_tables2/bootstrap4.html'
 | 
			
		||||
        fields = ('name', "owner", "allergens", "expiry_date")
 | 
			
		||||
							
								
								
									
										20
									
								
								apps/food/templates/food/add_ingredient_form.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								apps/food/templates/food/add_ingredient_form.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
{% comment %}
 | 
			
		||||
SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
{% endcomment %}
 | 
			
		||||
{% load i18n crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<div class="card bg-white mb-3">
 | 
			
		||||
  <h3 class="card-header text-center">
 | 
			
		||||
    {{ title }}
 | 
			
		||||
  </h3>
 | 
			
		||||
  <div class="card-body" id="form">
 | 
			
		||||
    <form method="post">
 | 
			
		||||
      {%  csrf_token %}
 | 
			
		||||
      {{ form|crispy }}
 | 
			
		||||
      <button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
 | 
			
		||||
    </form>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										37
									
								
								apps/food/templates/food/basicfood_detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								apps/food/templates/food/basicfood_detail.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
{% comment %}
 | 
			
		||||
SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
{% endcomment %}
 | 
			
		||||
{% load i18n crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<div class="card bg-white mb-3">
 | 
			
		||||
  <h3 class="card-header text-center">
 | 
			
		||||
    {{ title }} {{ food.name }}
 | 
			
		||||
  </h3>
 | 
			
		||||
  <div class="card-body">
 | 
			
		||||
    <ul>
 | 
			
		||||
      <li><p>{% trans 'Owner' %} : {{ food.owner }}</p></li>
 | 
			
		||||
      <li><p>{% trans 'Arrival date' %} : {{ food.arrival_date }}</p></li>
 | 
			
		||||
      <li><p>{% trans 'Expiry date' %} : {{ food.expiry_date }} ({{ food.date_type }})</p></li>
 | 
			
		||||
      <li>{% trans 'Allergens' %} :</li>
 | 
			
		||||
      <ul>
 | 
			
		||||
      {% for allergen in food.allergens.iterator %}
 | 
			
		||||
        <li>{{ allergen.name }}</li>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
      </ul>
 | 
			
		||||
	<p>
 | 
			
		||||
	<li><p>{% trans 'Active' %} : {{ food.is_active }}<p></li>
 | 
			
		||||
	<li><p>{% trans 'Eaten' %} : {{ food.was_eaten }}<p></li>
 | 
			
		||||
    </ul>
 | 
			
		||||
    {% if can_update %}
 | 
			
		||||
	<a class="btn btn-sm btn-warning" href="{% url "food:basic_update" pk=food.pk %}">{% trans 'Update' %}</a>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    {% if can_add_ingredient %}
 | 
			
		||||
    	<a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=food.pk %}">
 | 
			
		||||
		{% trans 'Add to a meal' %}
 | 
			
		||||
	</a>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										20
									
								
								apps/food/templates/food/basicfood_form.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								apps/food/templates/food/basicfood_form.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
{% comment %}
 | 
			
		||||
SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
{% endcomment %}
 | 
			
		||||
{% load i18n crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<div class="card bg-white mb-3">
 | 
			
		||||
  <h3 class="card-header text-center">
 | 
			
		||||
    {{ title }}
 | 
			
		||||
  </h3>
 | 
			
		||||
  <div class="card-body" id="form">
 | 
			
		||||
    <form method="post">
 | 
			
		||||
      {% csrf_token %}
 | 
			
		||||
      {{ form | crispy }}
 | 
			
		||||
      <button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
 | 
			
		||||
    </form>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										55
									
								
								apps/food/templates/food/create_qrcode_form.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								apps/food/templates/food/create_qrcode_form.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,55 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
{% comment %}
 | 
			
		||||
SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
{% endcomment %}
 | 
			
		||||
{% load render_table from django_tables2 %}
 | 
			
		||||
{% load i18n crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<div class="card bg-white mb-3">
 | 
			
		||||
  <h3 class="card-header text-center">
 | 
			
		||||
    {{ title }}
 | 
			
		||||
  </h3>
 | 
			
		||||
  <div class="card-body" id="form">
 | 
			
		||||
    <a class="btn btn-sm btn-success" href="{% url "food:qrcode_basic_create" slug=slug %}">
 | 
			
		||||
      {% trans 'New basic food' %}
 | 
			
		||||
    </a>
 | 
			
		||||
    <form method="post">
 | 
			
		||||
      {%  csrf_token %}
 | 
			
		||||
      {{ form|crispy }}
 | 
			
		||||
      <button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
 | 
			
		||||
    </form>
 | 
			
		||||
    <div class="card-body" id="profile_infos">
 | 
			
		||||
      <h4>{% trans "Copy constructor" %}</h4>
 | 
			
		||||
      <table class="table">
 | 
			
		||||
        <thead>
 | 
			
		||||
          <tr>
 | 
			
		||||
            <th class="orderable">
 | 
			
		||||
              {% trans "Name" %}
 | 
			
		||||
            </th>
 | 
			
		||||
            <th class="orderable">
 | 
			
		||||
              {% trans "Owner" %}
 | 
			
		||||
            </th>
 | 
			
		||||
            <th class="orderable">
 | 
			
		||||
              {% trans "Arrival date" %}
 | 
			
		||||
            </th>
 | 
			
		||||
            <th class="orderable">
 | 
			
		||||
              {% trans "Expiry date" %}
 | 
			
		||||
            </th>
 | 
			
		||||
          </tr>
 | 
			
		||||
        </thead>
 | 
			
		||||
        <tbody>
 | 
			
		||||
          {% for basic in last_basic %}
 | 
			
		||||
            <tr>
 | 
			
		||||
              <td><a href="{% url "food:qrcode_basic_create" slug=slug %}?copy={{ basic.pk }}">{{ basic.name }}</a></td>
 | 
			
		||||
              <td>{{ basic.owner }}</td>
 | 
			
		||||
              <td>{{ basic.arrival_date }}</td>
 | 
			
		||||
              <td>{{ basic.expiry_date }}</td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
        </tbody>
 | 
			
		||||
      </table>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										39
									
								
								apps/food/templates/food/qrcode_detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								apps/food/templates/food/qrcode_detail.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,39 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
{% comment %}
 | 
			
		||||
SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
{% endcomment %}
 | 
			
		||||
{% load i18n crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<div class="card bg-white mb-3">
 | 
			
		||||
    <h3 class="card-header text-center">
 | 
			
		||||
	{{ title }} {% trans 'number' %} {{ qrcode.qr_code_number }}
 | 
			
		||||
    </h3>
 | 
			
		||||
	<div class="card-body">
 | 
			
		||||
	    <ul>
 | 
			
		||||
		<li><p>{% trans 'Name' %} : {{ qrcode.food_container.name }}</p></li>
 | 
			
		||||
		<li><p>{% trans 'Owner' %} : {{ qrcode.food_container.owner }}</p></li>
 | 
			
		||||
		<li><p>{% trans 'Expiry date' %} : {{ qrcode.food_container.expiry_date  }}</p></li>
 | 
			
		||||
	    </ul>
 | 
			
		||||
	{% if qrcode.food_container.polymorphic_ctype.model == 'basicfood' and can_update_basic %}
 | 
			
		||||
	    <a class="btn btn-sm btn-warning" href="{% url "food:basic_update" pk=qrcode.food_container.pk %}" data-turbolinks="false">
 | 
			
		||||
		{% trans 'Update' %}
 | 
			
		||||
	    </a>
 | 
			
		||||
	{% elif can_update_transformed %}
 | 
			
		||||
	    <a class="btn btn-sm btn-warning" href="{% url "food:transformed_update" pk=qrcode.food_container.pk %}">
 | 
			
		||||
		{% trans 'Update' %}
 | 
			
		||||
	    </a>
 | 
			
		||||
	{% endif %}
 | 
			
		||||
	{% if can_view_detail %}
 | 
			
		||||
	    <a class="btn btn-sm btn-primary" href="{% url "food:food_view" pk=qrcode.food_container.pk %}">
 | 
			
		||||
		{% trans 'View details' %}
 | 
			
		||||
	    </a>
 | 
			
		||||
	{% endif %}
 | 
			
		||||
	{% if can_add_ingredient %}
 | 
			
		||||
	    <a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=qrcode.food_container.pk %}">
 | 
			
		||||
		{% trans 'Add to a meal' %}
 | 
			
		||||
	    </a>
 | 
			
		||||
	{% endif %}
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										51
									
								
								apps/food/templates/food/transformedfood_detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								apps/food/templates/food/transformedfood_detail.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,51 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
{% comment %}
 | 
			
		||||
SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
{% endcomment %}
 | 
			
		||||
{% load i18n crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<div class="card bg-white mb-3">
 | 
			
		||||
    <h3 class="card-header text-center">
 | 
			
		||||
	{{ title }} {{ food.name }}
 | 
			
		||||
    </h3>
 | 
			
		||||
	<div class="card-body">
 | 
			
		||||
	    <ul>
 | 
			
		||||
		<li><p>{% trans 'Owner' %} : {{ food.owner }}</p></li>
 | 
			
		||||
		{% if can_see_ready %}
 | 
			
		||||
		<li><p>{% trans 'Ready' %} : {{ food.is_ready }}</p></li>
 | 
			
		||||
		{% endif %}
 | 
			
		||||
		<li><p>{% trans 'Creation date' %} : {{ food.creation_date }}</p></li>
 | 
			
		||||
		<li><p>{% trans 'Expiry date' %} : {{ food.expiry_date }}</p></li>
 | 
			
		||||
		<li>{% trans 'Allergens' %} :</li>
 | 
			
		||||
		<ul>
 | 
			
		||||
		    {% for allergen in food.allergens.iterator %}
 | 
			
		||||
		    <li>{{ allergen.name }}</li>
 | 
			
		||||
		    {% endfor %}
 | 
			
		||||
	        </ul>
 | 
			
		||||
		<p>
 | 
			
		||||
		<li>{% trans 'Ingredients' %} :</li>
 | 
			
		||||
		<ul>
 | 
			
		||||
		    {% for ingredient in food.ingredient.iterator %}
 | 
			
		||||
		    <li><a href="{% url "food:food_view" pk=ingredient.pk %}">{{ ingredient.name }}</a></li>
 | 
			
		||||
		    {% endfor %}
 | 
			
		||||
		</ul>
 | 
			
		||||
		<p>
 | 
			
		||||
		<li><p>{% trans 'Shelf life' %} : {{ food.shelf_life }}</p></li>
 | 
			
		||||
		<li><p>{% trans 'Ready' %} : {{ food.is_ready }}</p></li>
 | 
			
		||||
		<li><p>{% trans 'Active' %} : {{ food.is_active }}</p></li>
 | 
			
		||||
		<li><p>{% trans 'Eaten' %} : {{ food.was_eaten }}</p></li>
 | 
			
		||||
	    </ul>
 | 
			
		||||
	    {% if can_update %}
 | 
			
		||||
	        <a class="btn btn-sm btn-warning" href="{% url "food:transformed_update" pk=food.pk %}">
 | 
			
		||||
		    {% trans 'Update' %}
 | 
			
		||||
		</a>
 | 
			
		||||
	    {% endif %}
 | 
			
		||||
	    {% if can_add_ingredient %}
 | 
			
		||||
	        <a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=food.pk %}">
 | 
			
		||||
		    {% trans 'Add to a meal' %}
 | 
			
		||||
		</a>
 | 
			
		||||
	    {% endif %}
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										20
									
								
								apps/food/templates/food/transformedfood_form.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								apps/food/templates/food/transformedfood_form.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
{% comment %}
 | 
			
		||||
SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
{% endcomment %}
 | 
			
		||||
{% load i18n crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<div class="card bg-white mb-3">
 | 
			
		||||
  <h3 class="card-header text-center">
 | 
			
		||||
    {{ title }}
 | 
			
		||||
  </h3>
 | 
			
		||||
  <div class="card-body" id="form">
 | 
			
		||||
    <form method="post">
 | 
			
		||||
      {%  csrf_token %}
 | 
			
		||||
      {{ form|crispy }}
 | 
			
		||||
      <button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
 | 
			
		||||
    </form>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										60
									
								
								apps/food/templates/food/transformedfood_list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								apps/food/templates/food/transformedfood_list.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,60 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
{% comment %}
 | 
			
		||||
SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
{% endcomment %}
 | 
			
		||||
{% load render_table from django_tables2 %}
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<div class="card bg-light mb-3">
 | 
			
		||||
    <h3 class="card-header text-center">
 | 
			
		||||
	{% trans "Meal served" %}
 | 
			
		||||
    </h3>
 | 
			
		||||
    {% if can_create_meal %}
 | 
			
		||||
    <div class="card-footer">
 | 
			
		||||
	<a class="btn btn-sm btn-success" href="{% url 'food:transformed_create' %}" data-turbolinks="false">
 | 
			
		||||
	    {% trans 'New meal' %}
 | 
			
		||||
	</a>
 | 
			
		||||
    </div>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    {% if served.data %}
 | 
			
		||||
    {% render_table served %}
 | 
			
		||||
    {% else %}
 | 
			
		||||
    <div class="card-body">
 | 
			
		||||
	<div class="alert alert-warning">
 | 
			
		||||
	    {% trans "There is no meal served." %}
 | 
			
		||||
	</div>
 | 
			
		||||
    </div>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="card bg-light mb-3">
 | 
			
		||||
    <h3 class="card-header text-center">
 | 
			
		||||
	{% trans "Open" %}
 | 
			
		||||
    </h3>
 | 
			
		||||
    {% if open.data %}
 | 
			
		||||
    {% render_table open %}
 | 
			
		||||
    {% else %}
 | 
			
		||||
    <div class="card-body">
 | 
			
		||||
	<div class="alert alert-warning">
 | 
			
		||||
	    {% trans "There is no free meal." %}
 | 
			
		||||
	</div>
 | 
			
		||||
    </div>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="card bg-light mb-3">
 | 
			
		||||
    <h3 class="card-header text-center">
 | 
			
		||||
        {% trans "All meals" %}
 | 
			
		||||
    </h3>
 | 
			
		||||
    {% if table.data %}
 | 
			
		||||
    {% render_table table %}
 | 
			
		||||
    {% else %}
 | 
			
		||||
    <div class="card-body">
 | 
			
		||||
        <div class="alert alert-warning">
 | 
			
		||||
            {% trans "There is no meal." %}
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										3
									
								
								apps/food/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								apps/food/tests.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
# from django.test import TestCase
 | 
			
		||||
 | 
			
		||||
# Create your tests here.
 | 
			
		||||
							
								
								
									
										21
									
								
								apps/food/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								apps/food/urls.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.urls import path
 | 
			
		||||
 | 
			
		||||
from . import views
 | 
			
		||||
 | 
			
		||||
app_name = 'food'
 | 
			
		||||
 | 
			
		||||
urlpatterns = [
 | 
			
		||||
    path('', views.TransformedListView.as_view(), name='food_list'),
 | 
			
		||||
    path('<int:slug>', views.QRCodeView.as_view(), name='qrcode_view'),
 | 
			
		||||
    path('detail/<int:pk>', views.FoodView.as_view(), name='food_view'),
 | 
			
		||||
 | 
			
		||||
    path('<int:slug>/create_qrcode', views.QRCodeCreateView.as_view(), name='qrcode_create'),
 | 
			
		||||
    path('<int:slug>/create_qrcode/basic', views.QRCodeBasicFoodCreateView.as_view(), name='qrcode_basic_create'),
 | 
			
		||||
    path('create/transformed', views.TransformedFoodCreateView.as_view(), name='transformed_create'),
 | 
			
		||||
    path('update/basic/<int:pk>', views.BasicFoodUpdateView.as_view(), name='basic_update'),
 | 
			
		||||
    path('update/transformed/<int:pk>', views.TransformedFoodUpdateView.as_view(), name='transformed_update'),
 | 
			
		||||
    path('add/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
 | 
			
		||||
]
 | 
			
		||||
							
								
								
									
										421
									
								
								apps/food/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										421
									
								
								apps/food/views.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,421 @@
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.db import transaction
 | 
			
		||||
from django.contrib.auth.mixins import LoginRequiredMixin
 | 
			
		||||
from django.http import HttpResponseRedirect
 | 
			
		||||
from django_tables2.views import MultiTableMixin
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.views.generic import DetailView, UpdateView
 | 
			
		||||
from django.views.generic.list import ListView
 | 
			
		||||
from django.forms import HiddenInput
 | 
			
		||||
from permission.backends import PermissionBackend
 | 
			
		||||
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
 | 
			
		||||
 | 
			
		||||
from .forms import AddIngredientForms, BasicFoodForms, QRCodeForms, TransformedFoodForms
 | 
			
		||||
from .models import BasicFood, Food, QRCode, TransformedFood
 | 
			
		||||
from .tables import TransformedFoodTable
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AddIngredientView(ProtectQuerysetMixin, UpdateView):
 | 
			
		||||
    """
 | 
			
		||||
    A view to add an ingredient
 | 
			
		||||
    """
 | 
			
		||||
    model = Food
 | 
			
		||||
    template_name = 'food/add_ingredient_form.html'
 | 
			
		||||
    extra_context = {"title": _("Add the ingredient")}
 | 
			
		||||
    form_class = AddIngredientForms
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
        context["pk"] = self.kwargs["pk"]
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        form.instance.creater = self.request.user
 | 
			
		||||
        food = Food.objects.get(pk=self.kwargs['pk'])
 | 
			
		||||
        add_ingredient_form = AddIngredientForms(data=self.request.POST)
 | 
			
		||||
        if food.is_ready:
 | 
			
		||||
            form.add_error(None, _("The product is already prepared"))
 | 
			
		||||
            return self.form_invalid(form)
 | 
			
		||||
        if not add_ingredient_form.is_valid():
 | 
			
		||||
            return self.form_invalid(form)
 | 
			
		||||
 | 
			
		||||
        # We flip logic ""fully used = not is_active""
 | 
			
		||||
        food.is_active = not food.is_active
 | 
			
		||||
        # Save the aliment and the allergens associed
 | 
			
		||||
        for transformed_pk in self.request.POST.getlist('ingredient'):
 | 
			
		||||
            transformed = TransformedFood.objects.get(pk=transformed_pk)
 | 
			
		||||
            if not transformed.is_ready:
 | 
			
		||||
                transformed.ingredient.add(food)
 | 
			
		||||
                transformed.update()
 | 
			
		||||
        food.save()
 | 
			
		||||
 | 
			
		||||
        return HttpResponseRedirect(self.get_success_url())
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self, **kwargs):
 | 
			
		||||
        return reverse('food:food_list')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BasicFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
 | 
			
		||||
    """
 | 
			
		||||
    A view to update a basic food
 | 
			
		||||
    """
 | 
			
		||||
    model = BasicFood
 | 
			
		||||
    form_class = BasicFoodForms
 | 
			
		||||
    template_name = 'food/basicfood_form.html'
 | 
			
		||||
    extra_context = {"title": _("Update an aliment")}
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        form.instance.creater = self.request.user
 | 
			
		||||
        basic_food_form = BasicFoodForms(data=self.request.POST)
 | 
			
		||||
        if not basic_food_form.is_valid():
 | 
			
		||||
            return self.form_invalid(form)
 | 
			
		||||
 | 
			
		||||
        ans = super().form_valid(form)
 | 
			
		||||
        form.instance.update()
 | 
			
		||||
        return ans
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self, **kwargs):
 | 
			
		||||
        self.object.refresh_from_db()
 | 
			
		||||
        return reverse('food:food_view', kwargs={"pk": self.object.pk})
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FoodView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
 | 
			
		||||
    """
 | 
			
		||||
    A view to see a food
 | 
			
		||||
    """
 | 
			
		||||
    model = Food
 | 
			
		||||
    extra_context = {"title": _("Details of:")}
 | 
			
		||||
    context_object_name = "food"
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
        context["can_update"] = PermissionBackend.check_perm(self.request, "food.change_food")
 | 
			
		||||
        context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QRCodeBasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
 | 
			
		||||
    #####################################################################
 | 
			
		||||
    # TO DO
 | 
			
		||||
    # - this feature is very pratical for meat or fish, nevertheless we can implement this later
 | 
			
		||||
    # - fix picture save
 | 
			
		||||
    # - implement solution crop and convert image (reuse or recode ImageForm from members apps)
 | 
			
		||||
    #####################################################################
 | 
			
		||||
    """
 | 
			
		||||
    A view to add a basic food with a qrcode
 | 
			
		||||
    """
 | 
			
		||||
    model = BasicFood
 | 
			
		||||
    form_class = BasicFoodForms
 | 
			
		||||
    template_name = 'food/basicfood_form.html'
 | 
			
		||||
    extra_context = {"title": _("Add a new basic food with QRCode")}
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        form.instance.creater = self.request.user
 | 
			
		||||
        basic_food_form = BasicFoodForms(data=self.request.POST)
 | 
			
		||||
        if not basic_food_form.is_valid():
 | 
			
		||||
            return self.form_invalid(form)
 | 
			
		||||
 | 
			
		||||
        # Save the aliment and the allergens associed
 | 
			
		||||
        basic_food = form.save(commit=False)
 | 
			
		||||
        # We assume the date of labeling and the same as the date of arrival
 | 
			
		||||
        basic_food.arrival_date = timezone.now()
 | 
			
		||||
        basic_food.is_ready = False
 | 
			
		||||
        basic_food.is_active = True
 | 
			
		||||
        basic_food.was_eaten = False
 | 
			
		||||
        basic_food._force_save = True
 | 
			
		||||
        basic_food.save()
 | 
			
		||||
        basic_food.refresh_from_db()
 | 
			
		||||
 | 
			
		||||
        qrcode = QRCode()
 | 
			
		||||
        qrcode.qr_code_number = self.kwargs['slug']
 | 
			
		||||
        qrcode.food_container = basic_food
 | 
			
		||||
        qrcode.save()
 | 
			
		||||
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self, **kwargs):
 | 
			
		||||
        self.object.refresh_from_db()
 | 
			
		||||
        return reverse('food:qrcode_view', kwargs={"slug": self.kwargs['slug']})
 | 
			
		||||
 | 
			
		||||
    def get_sample_object(self):
 | 
			
		||||
 | 
			
		||||
        # We choose a club which may work or BDE else
 | 
			
		||||
        owner_id = 1
 | 
			
		||||
        for membership in self.request.user.memberships.all():
 | 
			
		||||
            club_id = membership.club.id
 | 
			
		||||
            food = BasicFood(name="", expiry_date=timezone.now(), owner_id=club_id)
 | 
			
		||||
            if PermissionBackend.check_perm(self.request, "food.add_basicfood", food):
 | 
			
		||||
                owner_id = club_id
 | 
			
		||||
 | 
			
		||||
        return BasicFood(
 | 
			
		||||
            name="",
 | 
			
		||||
            expiry_date=timezone.now(),
 | 
			
		||||
            owner_id=owner_id,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        # Some field are hidden on create
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
        form = context['form']
 | 
			
		||||
        form.fields['is_active'].widget = HiddenInput()
 | 
			
		||||
        form.fields['was_eaten'].widget = HiddenInput()
 | 
			
		||||
 | 
			
		||||
        copy = self.request.GET.get('copy', None)
 | 
			
		||||
        if copy is not None:
 | 
			
		||||
            basic = BasicFood.objects.get(pk=copy)
 | 
			
		||||
            for field in ['date_type', 'expiry_date', 'name', 'owner']:
 | 
			
		||||
                form.fields[field].initial = getattr(basic, field)
 | 
			
		||||
            for field in ['allergens']:
 | 
			
		||||
                form.fields[field].initial = getattr(basic, field).all()
 | 
			
		||||
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QRCodeCreateView(ProtectQuerysetMixin, ProtectedCreateView):
 | 
			
		||||
    """
 | 
			
		||||
    A view to add a new qrcode
 | 
			
		||||
    """
 | 
			
		||||
    model = QRCode
 | 
			
		||||
    template_name = 'food/create_qrcode_form.html'
 | 
			
		||||
    form_class = QRCodeForms
 | 
			
		||||
    extra_context = {"title": _("Add a new QRCode")}
 | 
			
		||||
 | 
			
		||||
    def get(self, *args, **kwargs):
 | 
			
		||||
        qrcode = kwargs["slug"]
 | 
			
		||||
        if self.model.objects.filter(qr_code_number=qrcode).count() > 0:
 | 
			
		||||
            return HttpResponseRedirect(reverse("food:qrcode_view", kwargs=kwargs))
 | 
			
		||||
        else:
 | 
			
		||||
            return super().get(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
        context["slug"] = self.kwargs["slug"]
 | 
			
		||||
 | 
			
		||||
        context["last_basic"] = BasicFood.objects.order_by('-pk').all()[:10]
 | 
			
		||||
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        form.instance.creater = self.request.user
 | 
			
		||||
        qrcode_food_form = QRCodeForms(data=self.request.POST)
 | 
			
		||||
        if not qrcode_food_form.is_valid():
 | 
			
		||||
            return self.form_invalid(form)
 | 
			
		||||
 | 
			
		||||
        # Save the qrcode
 | 
			
		||||
        qrcode = form.save(commit=False)
 | 
			
		||||
        qrcode.qr_code_number = self.kwargs["slug"]
 | 
			
		||||
        qrcode._force_save = True
 | 
			
		||||
        qrcode.save()
 | 
			
		||||
        qrcode.refresh_from_db()
 | 
			
		||||
 | 
			
		||||
        qrcode.food_container.save()
 | 
			
		||||
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self, **kwargs):
 | 
			
		||||
        self.object.refresh_from_db()
 | 
			
		||||
        return reverse('food:qrcode_view', kwargs={"slug": self.kwargs['slug']})
 | 
			
		||||
 | 
			
		||||
    def get_sample_object(self):
 | 
			
		||||
        return QRCode(
 | 
			
		||||
            qr_code_number=self.kwargs["slug"],
 | 
			
		||||
            food_container_id=1
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QRCodeView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
 | 
			
		||||
    """
 | 
			
		||||
    A view to see a qrcode
 | 
			
		||||
    """
 | 
			
		||||
    model = QRCode
 | 
			
		||||
    extra_context = {"title": _("QRCode")}
 | 
			
		||||
    context_object_name = "qrcode"
 | 
			
		||||
    slug_field = "qr_code_number"
 | 
			
		||||
 | 
			
		||||
    def get(self, *args, **kwargs):
 | 
			
		||||
        qrcode = kwargs["slug"]
 | 
			
		||||
        if self.model.objects.filter(qr_code_number=qrcode).count() > 0:
 | 
			
		||||
            return super().get(*args, **kwargs)
 | 
			
		||||
        else:
 | 
			
		||||
            return HttpResponseRedirect(reverse("food:qrcode_create", kwargs=kwargs))
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
        qr_code_number = self.kwargs['slug']
 | 
			
		||||
        qrcode = self.model.objects.get(qr_code_number=qr_code_number)
 | 
			
		||||
 | 
			
		||||
        model = qrcode.food_container.polymorphic_ctype.model
 | 
			
		||||
 | 
			
		||||
        if model == "basicfood":
 | 
			
		||||
            context["can_update_basic"] = PermissionBackend.check_perm(self.request, "food.change_basicfood")
 | 
			
		||||
            context["can_view_detail"] = PermissionBackend.check_perm(self.request, "food.view_basicfood")
 | 
			
		||||
        if model == "transformedfood":
 | 
			
		||||
            context["can_update_transformed"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
 | 
			
		||||
            context["can_view_detail"] = PermissionBackend.check_perm(self.request, "food.view_transformedfood")
 | 
			
		||||
        context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
 | 
			
		||||
    """
 | 
			
		||||
    A view to add a tranformed food
 | 
			
		||||
    """
 | 
			
		||||
    model = TransformedFood
 | 
			
		||||
    template_name = 'food/transformedfood_form.html'
 | 
			
		||||
    form_class = TransformedFoodForms
 | 
			
		||||
    extra_context = {"title": _("Add a new meal")}
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        form.instance.creater = self.request.user
 | 
			
		||||
        transformed_food_form = TransformedFoodForms(data=self.request.POST)
 | 
			
		||||
        if not transformed_food_form.is_valid():
 | 
			
		||||
            return self.form_invalid(form)
 | 
			
		||||
 | 
			
		||||
        # Save the aliment and allergens associated
 | 
			
		||||
        transformed_food = form.save(commit=False)
 | 
			
		||||
        transformed_food.expiry_date = transformed_food.creation_date
 | 
			
		||||
        transformed_food.is_active = True
 | 
			
		||||
        transformed_food.is_ready = False
 | 
			
		||||
        transformed_food.was_eaten = False
 | 
			
		||||
        transformed_food._force_save = True
 | 
			
		||||
        transformed_food.save()
 | 
			
		||||
        transformed_food.refresh_from_db()
 | 
			
		||||
        ans = super().form_valid(form)
 | 
			
		||||
        transformed_food.update()
 | 
			
		||||
        return ans
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self, **kwargs):
 | 
			
		||||
        self.object.refresh_from_db()
 | 
			
		||||
        return reverse('food:food_view', kwargs={"pk": self.object.pk})
 | 
			
		||||
 | 
			
		||||
    def get_sample_object(self):
 | 
			
		||||
        # We choose a club which may work or BDE else
 | 
			
		||||
        owner_id = 1
 | 
			
		||||
        for membership in self.request.user.memberships.all():
 | 
			
		||||
            club_id = membership.club.id
 | 
			
		||||
            food = TransformedFood(name="",
 | 
			
		||||
                                   creation_date=timezone.now(),
 | 
			
		||||
                                   expiry_date=timezone.now(),
 | 
			
		||||
                                   owner_id=club_id)
 | 
			
		||||
            if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
 | 
			
		||||
                owner_id = club_id
 | 
			
		||||
                break
 | 
			
		||||
 | 
			
		||||
        return TransformedFood(
 | 
			
		||||
            name="",
 | 
			
		||||
            owner_id=owner_id,
 | 
			
		||||
            creation_date=timezone.now(),
 | 
			
		||||
            expiry_date=timezone.now(),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
        # Some field are hidden on create
 | 
			
		||||
        form = context['form']
 | 
			
		||||
        form.fields['is_active'].widget = HiddenInput()
 | 
			
		||||
        form.fields['is_ready'].widget = HiddenInput()
 | 
			
		||||
        form.fields['was_eaten'].widget = HiddenInput()
 | 
			
		||||
        form.fields['shelf_life'].widget = HiddenInput()
 | 
			
		||||
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransformedFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
 | 
			
		||||
    """
 | 
			
		||||
    A view to update transformed product
 | 
			
		||||
    """
 | 
			
		||||
    model = TransformedFood
 | 
			
		||||
    template_name = 'food/transformedfood_form.html'
 | 
			
		||||
    form_class = TransformedFoodForms
 | 
			
		||||
    extra_context = {'title': _('Update a meal')}
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        form.instance.creater = self.request.user
 | 
			
		||||
        transformedfood_form = TransformedFoodForms(data=self.request.POST)
 | 
			
		||||
        if not transformedfood_form.is_valid():
 | 
			
		||||
            return self.form_invalid(form)
 | 
			
		||||
 | 
			
		||||
        ans = super().form_valid(form)
 | 
			
		||||
        form.instance.update()
 | 
			
		||||
        return ans
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self, **kwargs):
 | 
			
		||||
        self.object.refresh_from_db()
 | 
			
		||||
        return reverse('food:food_view', kwargs={"pk": self.object.pk})
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransformedListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
 | 
			
		||||
    """
 | 
			
		||||
    Displays ready TransformedFood
 | 
			
		||||
    """
 | 
			
		||||
    model = TransformedFood
 | 
			
		||||
    tables = [TransformedFoodTable, TransformedFoodTable, TransformedFoodTable]
 | 
			
		||||
    extra_context = {"title": _("Transformed food")}
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self, **kwargs):
 | 
			
		||||
        return super().get_queryset(**kwargs).distinct()
 | 
			
		||||
 | 
			
		||||
    def get_tables(self):
 | 
			
		||||
        tables = super().get_tables()
 | 
			
		||||
 | 
			
		||||
        tables[0].prefix = "all-"
 | 
			
		||||
        tables[1].prefix = "open-"
 | 
			
		||||
        tables[2].prefix = "served-"
 | 
			
		||||
        return tables
 | 
			
		||||
 | 
			
		||||
    def get_tables_data(self):
 | 
			
		||||
        # first table = all transformed food, second table = free, third = served
 | 
			
		||||
        return [
 | 
			
		||||
            self.get_queryset().order_by("-creation_date"),
 | 
			
		||||
            TransformedFood.objects.filter(is_ready=True, is_active=True, was_eaten=False, expiry_date__lt=timezone.now())
 | 
			
		||||
                                   .filter(PermissionBackend.filter_queryset(self.request, TransformedFood, "view"))
 | 
			
		||||
                                   .distinct()
 | 
			
		||||
                                   .order_by("-creation_date"),
 | 
			
		||||
            TransformedFood.objects.filter(is_ready=True, is_active=True, was_eaten=False, expiry_date__gte=timezone.now())
 | 
			
		||||
                                   .filter(PermissionBackend.filter_queryset(self.request, TransformedFood, "view"))
 | 
			
		||||
                                   .distinct()
 | 
			
		||||
                                   .order_by("-creation_date")
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
        # We choose a club which should work
 | 
			
		||||
        for membership in self.request.user.memberships.all():
 | 
			
		||||
            club_id = membership.club.id
 | 
			
		||||
            food = TransformedFood(
 | 
			
		||||
                name="",
 | 
			
		||||
                owner_id=club_id,
 | 
			
		||||
                creation_date=timezone.now(),
 | 
			
		||||
                expiry_date=timezone.now(),
 | 
			
		||||
            )
 | 
			
		||||
            if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
 | 
			
		||||
                context['can_create_meal'] = True
 | 
			
		||||
                break
 | 
			
		||||
 | 
			
		||||
        tables = context["tables"]
 | 
			
		||||
        for name, table in zip(["table", "open", "served"], tables):
 | 
			
		||||
            context[name] = table
 | 
			
		||||
        return context
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
default_app_config = 'logs.apps.LogsConfig'
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from rest_framework import serializers
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from .views import ChangelogViewSet
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django_filters.rest_framework import DjangoFilterBackend
 | 
			
		||||
@@ -15,9 +15,9 @@ class ChangelogViewSet(ReadOnlyProtectedModelViewSet):
 | 
			
		||||
    The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/logs/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = Changelog.objects.all()
 | 
			
		||||
    queryset = Changelog.objects.order_by('id')
 | 
			
		||||
    serializer_class = ChangelogSerializer
 | 
			
		||||
    filter_backends = [DjangoFilterBackend, OrderingFilter]
 | 
			
		||||
    filterset_fields = ['model', 'action', "instance_pk", 'user', 'ip', ]
 | 
			
		||||
    ordering_fields = ['timestamp', ]
 | 
			
		||||
    ordering = ['-timestamp', ]
 | 
			
		||||
    ordering_fields = ['timestamp', 'id', ]
 | 
			
		||||
    ordering = ['-id', ]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										37
									
								
								apps/logs/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								apps/logs/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
# Generated by Django 2.2.16 on 2020-09-04 21:41
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
import django.utils.timezone
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    initial = True
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
        ('contenttypes', '0002_remove_content_type_name'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Changelog',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('ip', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP Address')),
 | 
			
		||||
                ('instance_pk', models.CharField(max_length=255, verbose_name='identifier')),
 | 
			
		||||
                ('previous', models.TextField(null=True, verbose_name='previous data')),
 | 
			
		||||
                ('data', models.TextField(null=True, verbose_name='new data')),
 | 
			
		||||
                ('action', models.CharField(choices=[('create', 'create'), ('edit', 'edit'), ('delete', 'delete')], default='edit', max_length=16, verbose_name='action')),
 | 
			
		||||
                ('timestamp', models.DateTimeField(default=django.utils.timezone.now, verbose_name='timestamp')),
 | 
			
		||||
                ('model', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.ContentType', verbose_name='model')),
 | 
			
		||||
                ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='user')),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'verbose_name': 'changelog',
 | 
			
		||||
                'verbose_name_plural': 'changelogs',
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										17
									
								
								apps/logs/migrations/0002_replace_null_by_blank.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								apps/logs/migrations/0002_replace_null_by_blank.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('logs', '0001_initial'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RunSQL(
 | 
			
		||||
            "UPDATE logs_changelog SET previous = '' WHERE previous IS NULL;"
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunSQL(
 | 
			
		||||
            "UPDATE logs_changelog SET data = '' WHERE data IS NULL;"
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										23
									
								
								apps/logs/migrations/0003_remove_null_tag_on_charfields.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								apps/logs/migrations/0003_remove_null_tag_on_charfields.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
# Generated by Django 2.2.16 on 2020-09-06 19:17
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('logs', '0002_replace_null_by_blank'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='changelog',
 | 
			
		||||
            name='data',
 | 
			
		||||
            field=models.TextField(blank=True, default='', verbose_name='new data'),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='changelog',
 | 
			
		||||
            name='previous',
 | 
			
		||||
            field=models.TextField(blank=True, default='', verbose_name='previous data'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -1,10 +1,11 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.contenttypes.models import ContentType
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -43,12 +44,14 @@ class Changelog(models.Model):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    previous = models.TextField(
 | 
			
		||||
        null=True,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        default="",
 | 
			
		||||
        verbose_name=_('previous data'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    data = models.TextField(
 | 
			
		||||
        null=True,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        default="",
 | 
			
		||||
        verbose_name=_('new data'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
@@ -68,14 +71,18 @@ class Changelog(models.Model):
 | 
			
		||||
    timestamp = models.DateTimeField(
 | 
			
		||||
        null=False,
 | 
			
		||||
        blank=False,
 | 
			
		||||
        auto_now_add=True,
 | 
			
		||||
        default=timezone.now,
 | 
			
		||||
        name='timestamp',
 | 
			
		||||
        verbose_name=_('timestamp'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def delete(self, using=None, keep_parents=False):
 | 
			
		||||
        raise ValidationError(_("Logs cannot be destroyed."))
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("changelog")
 | 
			
		||||
        verbose_name_plural = _("changelogs")
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return _("Changelog of type \"{action}\" for model {model} at {timestamp}").format(
 | 
			
		||||
            action=self.get_action_display(), model=str(self.model), timestamp=str(self.timestamp))
 | 
			
		||||
 | 
			
		||||
    def delete(self, using=None, keep_parents=False):
 | 
			
		||||
        raise ValidationError(_("Logs cannot be destroyed."))
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,11 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.contrib.contenttypes.models import ContentType
 | 
			
		||||
from rest_framework.renderers import JSONRenderer
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from note.models import NoteUser, Alias
 | 
			
		||||
from note_kfet.middlewares import get_current_authenticated_user, get_current_ip
 | 
			
		||||
from note_kfet.middlewares import get_current_request
 | 
			
		||||
 | 
			
		||||
from .models import Changelog
 | 
			
		||||
 | 
			
		||||
@@ -23,6 +23,9 @@ EXCLUDED = [
 | 
			
		||||
    'cas_server.userattributes',
 | 
			
		||||
    'contenttypes.contenttype',
 | 
			
		||||
    'logs.changelog',  # Never remove this line
 | 
			
		||||
    'mailer.dontsendentry',
 | 
			
		||||
    'mailer.message',
 | 
			
		||||
    'mailer.messagelog',
 | 
			
		||||
    'migrations.migration',
 | 
			
		||||
    'note.note'  # We only store the subclasses
 | 
			
		||||
    'note.transaction',
 | 
			
		||||
@@ -47,22 +50,19 @@ def save_object(sender, instance, **kwargs):
 | 
			
		||||
    in order to store each modification made
 | 
			
		||||
    """
 | 
			
		||||
    # noinspection PyProtectedMember
 | 
			
		||||
    if instance._meta.label_lower in EXCLUDED:
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    if hasattr(instance, "_no_log"):
 | 
			
		||||
    if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"):
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # noinspection PyProtectedMember
 | 
			
		||||
    previous = instance._previous
 | 
			
		||||
 | 
			
		||||
    # Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
 | 
			
		||||
    user, ip = get_current_authenticated_user(), get_current_ip()
 | 
			
		||||
    # Si un⋅e utilisateur⋅rice est connecté⋅e, on récupère l'utilisateur⋅rice courant⋅e ainsi que son adresse IP
 | 
			
		||||
    request = get_current_request()
 | 
			
		||||
 | 
			
		||||
    if user is None:
 | 
			
		||||
    if request is None:
 | 
			
		||||
        # Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
 | 
			
		||||
        # On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
 | 
			
		||||
        # IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info
 | 
			
		||||
        # IMPORTANT : l'utilisateur⋅rice dans la VM doit être un des alias note du respo info
 | 
			
		||||
        ip = "127.0.0.1"
 | 
			
		||||
        username = Alias.normalize(getpass.getuser())
 | 
			
		||||
        note = NoteUser.objects.filter(alias__normalized_name=username)
 | 
			
		||||
@@ -71,26 +71,51 @@ def save_object(sender, instance, **kwargs):
 | 
			
		||||
        # else:
 | 
			
		||||
        if note.exists():
 | 
			
		||||
            user = note.get().user
 | 
			
		||||
        else:
 | 
			
		||||
            user = None
 | 
			
		||||
    else:
 | 
			
		||||
        user = request.user
 | 
			
		||||
        if 'HTTP_X_REAL_IP' in request.META:
 | 
			
		||||
            ip = request.META.get('HTTP_X_REAL_IP')
 | 
			
		||||
        elif 'HTTP_X_FORWARDED_FOR' in request.META:
 | 
			
		||||
            ip = request.META.get('HTTP_X_FORWARDED_FOR').split(', ')[0]
 | 
			
		||||
        else:
 | 
			
		||||
            ip = request.META.get('REMOTE_ADDR')
 | 
			
		||||
 | 
			
		||||
        if not user.is_authenticated:
 | 
			
		||||
            # For registration and OAuth2 purposes
 | 
			
		||||
            user = None
 | 
			
		||||
 | 
			
		||||
    # noinspection PyProtectedMember
 | 
			
		||||
    if user is not None and instance._meta.label_lower == "auth.user" and previous:
 | 
			
		||||
    if request is not None and instance._meta.label_lower == "auth.user" and previous:
 | 
			
		||||
        # On n'enregistre pas les connexions
 | 
			
		||||
        if instance.last_login != previous.last_login:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
    # On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles
 | 
			
		||||
    changed_fields = '__all__'
 | 
			
		||||
    if previous:
 | 
			
		||||
        # On ne garde que les champs modifiés
 | 
			
		||||
        changed_fields = []
 | 
			
		||||
        for field in instance._meta.fields:
 | 
			
		||||
            if field.name.endswith("_ptr"):
 | 
			
		||||
                # A field ending with _ptr is a OneToOneRel with a subclass, e.g. NoteClub.note_ptr -> Note
 | 
			
		||||
                continue
 | 
			
		||||
            if getattr(instance, field.name) != getattr(previous, field.name):
 | 
			
		||||
                changed_fields.append(field.name)
 | 
			
		||||
 | 
			
		||||
    if len(changed_fields) == 0:
 | 
			
		||||
        # Pas de log s'il n'y a pas de modification
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles avec uniquement les champs modifiés
 | 
			
		||||
    class CustomSerializer(ModelSerializer):
 | 
			
		||||
        class Meta:
 | 
			
		||||
            model = instance.__class__
 | 
			
		||||
            fields = '__all__'
 | 
			
		||||
            fields = changed_fields
 | 
			
		||||
 | 
			
		||||
    previous_json = JSONRenderer().render(CustomSerializer(previous).data).decode("UTF-8") if previous else None
 | 
			
		||||
    previous_json = JSONRenderer().render(CustomSerializer(previous).data).decode("UTF-8") if previous else ""
 | 
			
		||||
    instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8")
 | 
			
		||||
 | 
			
		||||
    if previous_json == instance_json:
 | 
			
		||||
        # Pas de log s'il n'y a pas de modification
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    Changelog.objects.create(user=user,
 | 
			
		||||
                             ip=ip,
 | 
			
		||||
                             model=ContentType.objects.get_for_model(instance),
 | 
			
		||||
@@ -106,19 +131,16 @@ def delete_object(sender, instance, **kwargs):
 | 
			
		||||
    Each time a model is deleted, an entry in the table `Changelog` is added in the database
 | 
			
		||||
    """
 | 
			
		||||
    # noinspection PyProtectedMember
 | 
			
		||||
    if instance._meta.label_lower in EXCLUDED:
 | 
			
		||||
    if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"):
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    if hasattr(instance, "_no_log"):
 | 
			
		||||
        return
 | 
			
		||||
    # Si un⋅e utilisateur⋅rice est connecté⋅e, on récupère l'utilisateur⋅rice courant⋅e ainsi que son adresse IP
 | 
			
		||||
    request = get_current_request()
 | 
			
		||||
 | 
			
		||||
    # Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
 | 
			
		||||
    user, ip = get_current_authenticated_user(), get_current_ip()
 | 
			
		||||
 | 
			
		||||
    if user is None:
 | 
			
		||||
    if request is None:
 | 
			
		||||
        # Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
 | 
			
		||||
        # On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
 | 
			
		||||
        # IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info
 | 
			
		||||
        # IMPORTANT : l'utilisateur⋅rice dans la VM doit être un des alias note du respo info
 | 
			
		||||
        ip = "127.0.0.1"
 | 
			
		||||
        username = Alias.normalize(getpass.getuser())
 | 
			
		||||
        note = NoteUser.objects.filter(alias__normalized_name=username)
 | 
			
		||||
@@ -127,6 +149,20 @@ def delete_object(sender, instance, **kwargs):
 | 
			
		||||
        # else:
 | 
			
		||||
        if note.exists():
 | 
			
		||||
            user = note.get().user
 | 
			
		||||
        else:
 | 
			
		||||
            user = None
 | 
			
		||||
    else:
 | 
			
		||||
        user = request.user
 | 
			
		||||
        if 'HTTP_X_REAL_IP' in request.META:
 | 
			
		||||
            ip = request.META.get('HTTP_X_REAL_IP')
 | 
			
		||||
        elif 'HTTP_X_FORWARDED_FOR' in request.META:
 | 
			
		||||
            ip = request.META.get('HTTP_X_FORWARDED_FOR').split(', ')[0]
 | 
			
		||||
        else:
 | 
			
		||||
            ip = request.META.get('REMOTE_ADDR')
 | 
			
		||||
 | 
			
		||||
        if not user.is_authenticated:
 | 
			
		||||
            # For registration and OAuth2 purposes
 | 
			
		||||
            user = None
 | 
			
		||||
 | 
			
		||||
    # On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles
 | 
			
		||||
    class CustomSerializer(ModelSerializer):
 | 
			
		||||
@@ -141,6 +177,6 @@ def delete_object(sender, instance, **kwargs):
 | 
			
		||||
                             model=ContentType.objects.get_for_model(instance),
 | 
			
		||||
                             instance_pk=instance.pk,
 | 
			
		||||
                             previous=instance_json,
 | 
			
		||||
                             data=None,
 | 
			
		||||
                             data="",
 | 
			
		||||
                             action="delete"
 | 
			
		||||
                             ).save()
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
default_app_config = 'member.apps.MemberConfig'
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,15 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
from django.contrib.auth.admin import UserAdmin
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from note.templatetags.pretty_money import pretty_money
 | 
			
		||||
from note_kfet.admin import admin_site
 | 
			
		||||
 | 
			
		||||
from .forms import ProfileForm
 | 
			
		||||
from .models import Club, Membership, Profile, Role
 | 
			
		||||
from .models import Club, Membership, Profile
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProfileInline(admin.StackedInline):
 | 
			
		||||
@@ -14,29 +17,50 @@ class ProfileInline(admin.StackedInline):
 | 
			
		||||
    Inline user profile in user admin
 | 
			
		||||
    """
 | 
			
		||||
    model = Profile
 | 
			
		||||
    form = ProfileForm
 | 
			
		||||
    can_delete = False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(User, site=admin_site)
 | 
			
		||||
class CustomUserAdmin(UserAdmin):
 | 
			
		||||
    inlines = (ProfileInline,)
 | 
			
		||||
    list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff')
 | 
			
		||||
    list_select_related = ('profile',)
 | 
			
		||||
    form = ProfileForm
 | 
			
		||||
 | 
			
		||||
    def get_inline_instances(self, request, obj=None):
 | 
			
		||||
        """
 | 
			
		||||
        When creating a new user don't show profile one the first step
 | 
			
		||||
        """
 | 
			
		||||
        if not obj:
 | 
			
		||||
            return list()
 | 
			
		||||
        return super().get_inline_instances(request, obj)
 | 
			
		||||
        return super().get_inline_instances(request, obj) if obj else []
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Update Django User with profile
 | 
			
		||||
admin.site.unregister(User)
 | 
			
		||||
admin.site.register(User, CustomUserAdmin)
 | 
			
		||||
@admin.register(Club, site=admin_site)
 | 
			
		||||
class ClubAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ('name', 'parent_club', 'email', 'require_memberships', 'pretty_fee_paid',
 | 
			
		||||
                    'pretty_fee_unpaid', 'membership_start', 'membership_end',)
 | 
			
		||||
    ordering = ('name',)
 | 
			
		||||
    search_fields = ('name', 'email',)
 | 
			
		||||
 | 
			
		||||
# Add other models
 | 
			
		||||
admin.site.register(Club)
 | 
			
		||||
admin.site.register(Membership)
 | 
			
		||||
admin.site.register(Role)
 | 
			
		||||
    def pretty_fee_paid(self, obj):
 | 
			
		||||
        return pretty_money(obj.membership_fee_paid)
 | 
			
		||||
 | 
			
		||||
    def pretty_fee_unpaid(self, obj):
 | 
			
		||||
        return pretty_money(obj.membership_fee_unpaid)
 | 
			
		||||
 | 
			
		||||
    pretty_fee_paid.short_description = _("membership fee (paid students)")
 | 
			
		||||
    pretty_fee_unpaid.short_description = _("membership fee (unpaid students)")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Membership, site=admin_site)
 | 
			
		||||
class MembershipAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ('user', 'club', 'date_start', 'date_end', 'view_roles', 'pretty_fee',)
 | 
			
		||||
    ordering = ('-date_start', 'club')
 | 
			
		||||
 | 
			
		||||
    def view_roles(self, obj):
 | 
			
		||||
        return ", ".join(role.name for role in obj.roles.all())
 | 
			
		||||
 | 
			
		||||
    def pretty_fee(self, obj):
 | 
			
		||||
        return pretty_money(obj.fee)
 | 
			
		||||
 | 
			
		||||
    view_roles.short_description = _("roles")
 | 
			
		||||
    pretty_fee.short_description = _("fee")
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,9 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from rest_framework import serializers
 | 
			
		||||
 | 
			
		||||
from ..models import Profile, Club, Role, Membership
 | 
			
		||||
from ..models import Profile, Club, Membership
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProfileSerializer(serializers.ModelSerializer):
 | 
			
		||||
@@ -29,17 +29,6 @@ class ClubSerializer(serializers.ModelSerializer):
 | 
			
		||||
        fields = '__all__'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RoleSerializer(serializers.ModelSerializer):
 | 
			
		||||
    """
 | 
			
		||||
    REST API Serializer for Roles.
 | 
			
		||||
    The djangorestframework plugin will analyse the model `Role` and parse all fields in the API.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Role
 | 
			
		||||
        fields = '__all__'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MembershipSerializer(serializers.ModelSerializer):
 | 
			
		||||
    """
 | 
			
		||||
    REST API Serializer for Memberships.
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from .views import ProfileViewSet, ClubViewSet, RoleViewSet, MembershipViewSet
 | 
			
		||||
from .views import ProfileViewSet, ClubViewSet, MembershipViewSet
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def register_members_urls(router, path):
 | 
			
		||||
@@ -10,5 +10,4 @@ def register_members_urls(router, path):
 | 
			
		||||
    """
 | 
			
		||||
    router.register(path + '/profile', ProfileViewSet)
 | 
			
		||||
    router.register(path + '/club', ClubViewSet)
 | 
			
		||||
    router.register(path + '/role', RoleViewSet)
 | 
			
		||||
    router.register(path + '/membership', MembershipViewSet)
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user