mirror of
				https://gitlab.crans.org/bde/nk20
				synced 2025-11-04 01:12:08 +01:00 
			
		
		
		
	Compare commits
	
		
			353 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					466cbd9878 | ||
| 
						 | 
					0bd447b608 | ||
| 
						 | 
					3f3c93d928 | ||
| 
						 | 
					340c90f5d3 | ||
| 
						
						
							
						
						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 | 
@@ -10,7 +10,6 @@ DJANGO_SECRET_KEY=CHANGE_ME
 | 
			
		||||
DJANGO_SETTINGS_MODULE=note_kfet.settings
 | 
			
		||||
CONTACT_EMAIL=tresorerie.bde@localhost
 | 
			
		||||
NOTE_URL=localhost
 | 
			
		||||
DOMAIN=localhost
 | 
			
		||||
 | 
			
		||||
# Config for mails. Only used in production
 | 
			
		||||
NOTE_MAIL=notekfet@localhost
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -47,3 +47,9 @@ backups/
 | 
			
		||||
env/
 | 
			
		||||
venv/
 | 
			
		||||
db.sqlite3
 | 
			
		||||
shell.nix
 | 
			
		||||
 | 
			
		||||
# ansibles customs host
 | 
			
		||||
ansible/host_vars/*.yaml
 | 
			
		||||
!ansible/host_vars/bde*
 | 
			
		||||
ansible/hosts
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
stages:
 | 
			
		||||
  - test
 | 
			
		||||
  - quality-assurance
 | 
			
		||||
  - docs
 | 
			
		||||
 | 
			
		||||
# Also fetch submodules
 | 
			
		||||
variables:
 | 
			
		||||
@@ -16,8 +17,8 @@ py37-django22:
 | 
			
		||||
        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-cas-server python3-psycopg2 python3-pil
 | 
			
		||||
        python3-babel python3-lockfile python3-pip python3-phonenumbers
 | 
			
		||||
        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 py37-django22
 | 
			
		||||
 | 
			
		||||
@@ -33,11 +34,26 @@ py38-django22:
 | 
			
		||||
        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-cas-server python3-psycopg2 python3-pil
 | 
			
		||||
        python3-babel python3-lockfile python3-pip python3-phonenumbers
 | 
			
		||||
        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 py38-django22
 | 
			
		||||
 | 
			
		||||
# Debian Bullseye
 | 
			
		||||
py39-django22:
 | 
			
		||||
  stage: test
 | 
			
		||||
  image: debian:bullseye
 | 
			
		||||
  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 py39-django22
 | 
			
		||||
 | 
			
		||||
linters:
 | 
			
		||||
  stage: quality-assurance
 | 
			
		||||
  image: debian:buster-backports
 | 
			
		||||
@@ -47,3 +63,17 @@ 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
 | 
			
		||||
 
 | 
			
		||||
@@ -8,11 +8,11 @@ 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-cas-server python3-psycopg2 python3-pil \
 | 
			
		||||
    python3-babel python3-lockfile python3-pip python3-phonenumbers ipython3 \
 | 
			
		||||
    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 && \
 | 
			
		||||
    texlive-xetex gettext libjs-bootstrap4 && \
 | 
			
		||||
    rm -rf /var/lib/apt/lists/*
 | 
			
		||||
 | 
			
		||||
# Instal PyPI requirements
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										45
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										45
									
								
								README.md
									
									
									
									
									
								
							@@ -23,7 +23,7 @@ Bien que cela permette de créer une instance sur toutes les distributions,
 | 
			
		||||
    $ 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
 | 
			
		||||
        texlive-xetex gettext libjs-bootstrap4 git
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
2.  **Clonage du dépot** là où vous voulez :
 | 
			
		||||
@@ -69,13 +69,31 @@ accessible depuis l'ensemble de votre réseau, pratique pour tester le rendu
 | 
			
		||||
de la note sur un téléphone !
 | 
			
		||||
 | 
			
		||||
## 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`.
 | 
			
		||||
 | 
			
		||||
0. Installer Ansible sur votre machine personnelle.
 | 
			
		||||
 | 
			
		||||
0. (bis) cloner le dépot sur votre machine personelle.
 | 
			
		||||
 | 
			
		||||
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.
 | 
			
		||||
 | 
			
		||||
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**.
 | 
			
		||||
 | 
			
		||||
Pour aller vite vous pouvez lancer le Playbook Ansible fournit dans ce dépôt en l'adaptant.
 | 
			
		||||
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.
 | 
			
		||||
@@ -93,11 +111,11 @@ Sinon vous pouvez suivre les étapes décrites ci-dessous.
 | 
			
		||||
    $ 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-cas-server python3-psycopg2 python3-pil \
 | 
			
		||||
        python3-babel python3-lockfile python3-pip python3-phonenumbers ipython3 \
 | 
			
		||||
        python3-bs4 python3-setuptools \
 | 
			
		||||
        uwsgi uwsgi-plugin-python3 \
 | 
			
		||||
        texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome \
 | 
			
		||||
        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 \
 | 
			
		||||
        nginx python3-venv git acl
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
@@ -261,20 +279,25 @@ Le cahier des charges initial est disponible [sur le Wiki Crans](https://wiki.cr
 | 
			
		||||
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 est disponible sur [le Wiki associé au dépôt Git](https://gitlab.crans.org/bde/nk20/-/wikis/home).
 | 
			
		||||
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.
 | 
			
		||||
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
 | 
			
		||||
django-admin makemessages -i env
 | 
			
		||||
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
 | 
			
		||||
django-admin compilemessages
 | 
			
		||||
python3 manage.py compilemessages
 | 
			
		||||
python3 manage.py compilejsmessages
 | 
			
		||||
```
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@
 | 
			
		||||
      prompt: "Password of the database (leave it blank to skip database init)"
 | 
			
		||||
      private: yes
 | 
			
		||||
  vars:
 | 
			
		||||
    mirror: deb.debian.org
 | 
			
		||||
    mirror: mirror.crans.org
 | 
			
		||||
  roles:
 | 
			
		||||
    - 1-apt-basic
 | 
			
		||||
    - 2-nk20
 | 
			
		||||
@@ -16,3 +16,4 @@
 | 
			
		||||
    - 5-nginx
 | 
			
		||||
    - 6-psql
 | 
			
		||||
    - 7-postinstall
 | 
			
		||||
    - 8-docs
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +0,0 @@
 | 
			
		||||
---
 | 
			
		||||
note:
 | 
			
		||||
  server_name: note-beta.crans.org
 | 
			
		||||
  git_branch: beta
 | 
			
		||||
  cron_enabled: false
 | 
			
		||||
@@ -2,4 +2,6 @@
 | 
			
		||||
note:
 | 
			
		||||
  server_name: note-dev.crans.org
 | 
			
		||||
  git_branch: beta
 | 
			
		||||
  serve_static: false
 | 
			
		||||
  cron_enabled: false
 | 
			
		||||
  email: notekfet2020@lists.crans.org
 | 
			
		||||
@@ -2,4 +2,6 @@
 | 
			
		||||
note:
 | 
			
		||||
  server_name: note.crans.org
 | 
			
		||||
  git_branch: master
 | 
			
		||||
  serve_static: true
 | 
			
		||||
  cron_enabled: true
 | 
			
		||||
  email: notekfet2020@lists.crans.org
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
[dev]
 | 
			
		||||
bde3-virt.adh.crans.org
 | 
			
		||||
bde-nk20-beta.adh.crans.org
 | 
			
		||||
bde-note-dev.adh.crans.org
 | 
			
		||||
 | 
			
		||||
[prod]
 | 
			
		||||
bde-note.adh.crans.org
 | 
			
		||||
@@ -3,11 +3,12 @@
 | 
			
		||||
  apt_repository:
 | 
			
		||||
    repo: deb http://{{ mirror }}/debian buster-backports main
 | 
			
		||||
    state: present
 | 
			
		||||
  when: ansible_facts['distribution'] == "Debian"
 | 
			
		||||
 | 
			
		||||
- name: Install note_kfet APT dependencies
 | 
			
		||||
  apt:
 | 
			
		||||
    update_cache: true
 | 
			
		||||
    default_release: buster-backports
 | 
			
		||||
    default_release: "{{ 'buster-backports' if ansible_facts['distribution'] == 'Debian' }}"
 | 
			
		||||
    install_recommends: false
 | 
			
		||||
    name:
 | 
			
		||||
      # Common tools
 | 
			
		||||
@@ -16,20 +17,20 @@
 | 
			
		||||
      - ipython3
 | 
			
		||||
 | 
			
		||||
      # Front-end dependencies
 | 
			
		||||
      - fonts-font-awesome
 | 
			
		||||
      - libjs-bootstrap4
 | 
			
		||||
 | 
			
		||||
      # Python dependencies
 | 
			
		||||
      - python3-babel
 | 
			
		||||
      - python3-bs4
 | 
			
		||||
      - python3-django
 | 
			
		||||
      - python3-django-cas-server
 | 
			
		||||
      - 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
 | 
			
		||||
@@ -40,6 +41,9 @@
 | 
			
		||||
      # LaTeX (PDF generation)
 | 
			
		||||
      - texlive-xetex
 | 
			
		||||
 | 
			
		||||
      # Cache server
 | 
			
		||||
      - memcached
 | 
			
		||||
 | 
			
		||||
      # WSGI server
 | 
			
		||||
      - uwsgi
 | 
			
		||||
      - uwsgi-plugin-python3
 | 
			
		||||
 
 | 
			
		||||
@@ -16,7 +16,7 @@
 | 
			
		||||
 | 
			
		||||
- 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
 | 
			
		||||
@@ -36,3 +36,13 @@
 | 
			
		||||
    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=
 | 
			
		||||
@@ -9,6 +9,11 @@
 | 
			
		||||
  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
 | 
			
		||||
@@ -19,3 +24,17 @@
 | 
			
		||||
    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,5 +1,5 @@
 | 
			
		||||
# the upstream component nginx needs to connect to
 | 
			
		||||
upstream note{
 | 
			
		||||
upstream note {
 | 
			
		||||
    server unix:///var/www/note_kfet/note_kfet.sock; # file socket
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -41,6 +41,7 @@ server {
 | 
			
		||||
    # 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
 | 
			
		||||
@@ -50,6 +51,11 @@ server {
 | 
			
		||||
        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;
 | 
			
		||||
 
 | 
			
		||||
@@ -11,14 +11,14 @@
 | 
			
		||||
  until: pkg_result is succeeded
 | 
			
		||||
 | 
			
		||||
- name: Create role note
 | 
			
		||||
  when: "DB_PASSWORD|bool"    # If the password is not defined, skip the installation
 | 
			
		||||
  when: DB_PASSWORD|length > 0 # If the password is not defined, skip the installation
 | 
			
		||||
  postgresql_user:
 | 
			
		||||
    name: note
 | 
			
		||||
    password: "{{ DB_PASSWORD }}"
 | 
			
		||||
  become_user: postgres
 | 
			
		||||
 | 
			
		||||
- name: Create NK20 database
 | 
			
		||||
  when: "DB_PASSWORD|bool"
 | 
			
		||||
  when: DB_PASSWORD|length >0
 | 
			
		||||
  postgresql_db:
 | 
			
		||||
    name: note_db
 | 
			
		||||
    owner: note
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,10 @@
 | 
			
		||||
---
 | 
			
		||||
- 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
 | 
			
		||||
 | 
			
		||||
- name: Migrate Django database
 | 
			
		||||
  command: /var/www/note_kfet/env/bin/python manage.py migrate
 | 
			
		||||
  args:
 | 
			
		||||
@@ -11,14 +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: postgres
 | 
			
		||||
 | 
			
		||||
- 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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
default_app_config = 'activity.apps.ActivityConfig'
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 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-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from .views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from api.viewsets import ReadProtectedModelViewSet
 | 
			
		||||
@@ -15,10 +15,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 +27,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, SearchFilter]
 | 
			
		||||
    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 +45,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, SearchFilter]
 | 
			
		||||
    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 +60,9 @@ 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, SearchFilter]
 | 
			
		||||
    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', ]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
@@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from member.models import Club
 | 
			
		||||
from note.models import Note, NoteUser
 | 
			
		||||
from note_kfet.inputs import Autocomplete, DateTimePickerInput
 | 
			
		||||
from note_kfet.middlewares import get_current_authenticated_user
 | 
			
		||||
from note_kfet.middlewares import get_current_request
 | 
			
		||||
from permission.backends import PermissionBackend
 | 
			
		||||
 | 
			
		||||
from .models import Activity, Guest
 | 
			
		||||
@@ -24,10 +24,16 @@ class ActivityForm(forms.ModelForm):
 | 
			
		||||
        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_authenticated_user(), Club, "view")).all())
 | 
			
		||||
                                         .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"]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
import os
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,9 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.html import format_html
 | 
			
		||||
from django.utils.html import escape
 | 
			
		||||
from django.utils.safestring import mark_safe
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
import django_tables2 as tables
 | 
			
		||||
from django_tables2 import A
 | 
			
		||||
@@ -52,8 +54,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 format_html('<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()))
 | 
			
		||||
        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):
 | 
			
		||||
@@ -91,7 +93,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):
 | 
			
		||||
 
 | 
			
		||||
@@ -30,7 +30,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
         headers: {"X-CSRFTOKEN": CSRF_TOKEN}
 | 
			
		||||
     })
 | 
			
		||||
      .done(function() {
 | 
			
		||||
          addMsg('Invité supprimé','success');
 | 
			
		||||
          addMsg('{% trans "Guest deleted" %}', 'success');
 | 
			
		||||
          $("#guests_table").load(location.pathname + " #guests_table");
 | 
			
		||||
      })
 | 
			
		||||
      .fail(function(xhr, textStatus, error) {
 | 
			
		||||
 
 | 
			
		||||
@@ -63,7 +63,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
        refreshBalance();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    alias_obj.keyup(reloadTable);
 | 
			
		||||
    alias_obj.keyup(function(event) {
 | 
			
		||||
        let code = event.originalEvent.keyCode
 | 
			
		||||
        if (65 <= code <= 122 || code === 13) {
 | 
			
		||||
            debounce(reloadTable)()
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    $(document).ready(init);
 | 
			
		||||
 | 
			
		||||
@@ -86,10 +91,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
                }).done(function () {
 | 
			
		||||
                    if (target.hasClass("table-info"))
 | 
			
		||||
                        addMsg(
 | 
			
		||||
                            "Entrée effectuée, mais attention : la personne n'est plus adhérente Kfet.",
 | 
			
		||||
                            "{% trans "Entry done, but caution: the user is not a Kfet member." %}",
 | 
			
		||||
                            "warning", 10000);
 | 
			
		||||
                    else
 | 
			
		||||
                        addMsg("Entrée effectuée !", "success", 4000);
 | 
			
		||||
                        addMsg("Entry made!", "success", 4000);
 | 
			
		||||
                    reloadTable(true);
 | 
			
		||||
                }).fail(function (xhr) {
 | 
			
		||||
                    errMsg(xhr.responseJSON, 4000);
 | 
			
		||||
@@ -121,10 +126,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
                    }).done(function () {
 | 
			
		||||
                        if (target.hasClass("table-info"))
 | 
			
		||||
                            addMsg(
 | 
			
		||||
                                "Entrée effectuée, mais attention : la personne n'est plus adhérente Kfet.",
 | 
			
		||||
                                "{% trans "Entry done, but caution: the user is not a Kfet member." %}",
 | 
			
		||||
                                "warning", 10000);
 | 
			
		||||
                        else
 | 
			
		||||
                            addMsg("Entrée effectuée !", "success", 4000);
 | 
			
		||||
                            addMsg("{% trans "Entry done!" %}", "success", 4000);
 | 
			
		||||
                        reloadTable(true);
 | 
			
		||||
                    }).fail(function (xhr) {
 | 
			
		||||
                        errMsg(xhr.responseJSON, 4000);
 | 
			
		||||
 
 | 
			
		||||
@@ -34,7 +34,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
    {% 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>
 | 
			
		||||
            <svg class="bi bi-calendar-plus" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
 | 
			
		||||
                <path d="M4 .5a.5.5 0 0 0-1 0V1H2a2 2 0 0 0-2 2v1h16V3a2 2 0 0 0-2-2h-1V.5a.5.5 0 0 0-1 0V1H4V.5zM16 14V5H0v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2zM8.5 8.5V10H10a.5.5 0 0 1 0 1H8.5v1.5a.5.5 0 0 1-1 0V11H6a.5.5 0 0 1 0-1h1.5V8.5a.5.5 0 0 1 1 0z"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
            {% trans 'New activity' %}
 | 
			
		||||
        </a>
 | 
			
		||||
    </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +1,18 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 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 activity.models import Activity, ActivityType, Guest, Entry
 | 
			
		||||
from member.models import Club
 | 
			
		||||
 | 
			
		||||
from ..api.views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet
 | 
			
		||||
from ..models import Activity, ActivityType, Guest, Entry
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestActivities(TestCase):
 | 
			
		||||
    """
 | 
			
		||||
@@ -173,3 +176,58 @@ class TestActivities(TestCase):
 | 
			
		||||
        """
 | 
			
		||||
        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-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.urls import path
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from hashlib import md5
 | 
			
		||||
@@ -12,8 +12,10 @@ from django.db.models import F, Q
 | 
			
		||||
from django.http import HttpResponse
 | 
			
		||||
from django.urls import reverse_lazy
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.decorators import method_decorator
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django.views import View
 | 
			
		||||
from django.views.decorators.cache import cache_page
 | 
			
		||||
from django.views.generic import DetailView, TemplateView, UpdateView
 | 
			
		||||
from django_tables2.views import SingleTableView
 | 
			
		||||
from note.models import Alias, NoteSpecial, NoteUser
 | 
			
		||||
@@ -64,21 +66,19 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView
 | 
			
		||||
    ordering = ('-date_start',)
 | 
			
		||||
    extra_context = {"title": _("Activities")}
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return super().get_queryset().distinct()
 | 
			
		||||
    def get_queryset(self, **kwargs):
 | 
			
		||||
        return super().get_queryset(**kwargs).distinct()
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
        upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now())
 | 
			
		||||
        context['upcoming'] = ActivityTable(
 | 
			
		||||
            data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")),
 | 
			
		||||
            data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request, Activity, "view")),
 | 
			
		||||
            prefix='upcoming-',
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        started_activities = Activity.objects\
 | 
			
		||||
            .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
 | 
			
		||||
            .filter(open=True, valid=True).all()
 | 
			
		||||
        started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all()
 | 
			
		||||
        context["started_activities"] = started_activities
 | 
			
		||||
 | 
			
		||||
        return context
 | 
			
		||||
@@ -96,7 +96,7 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
 | 
			
		||||
        context = super().get_context_data()
 | 
			
		||||
 | 
			
		||||
        table = GuestTable(data=Guest.objects.filter(activity=self.object)
 | 
			
		||||
                           .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view")))
 | 
			
		||||
                           .filter(PermissionBackend.filter_queryset(self.request, Guest, "view")))
 | 
			
		||||
        context["guests"] = table
 | 
			
		||||
 | 
			
		||||
        context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start)
 | 
			
		||||
@@ -142,15 +142,15 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
 | 
			
		||||
 | 
			
		||||
    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):
 | 
			
		||||
@@ -171,7 +171,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
 | 
			
		||||
        activity = Activity.objects.get(pk=self.kwargs["pk"])
 | 
			
		||||
 | 
			
		||||
        sample_entry = Entry(activity=activity, note=self.request.user.note)
 | 
			
		||||
        if not PermissionBackend.check_perm(self.request.user, "activity.add_entry", sample_entry):
 | 
			
		||||
        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 activity.activity_type.manage_entries:
 | 
			
		||||
@@ -189,8 +189,8 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
 | 
			
		||||
        guest_qs = Guest.objects\
 | 
			
		||||
            .annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\
 | 
			
		||||
            .filter(activity=activity)\
 | 
			
		||||
            .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\
 | 
			
		||||
            .order_by('last_name', 'first_name').distinct()
 | 
			
		||||
            .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"]
 | 
			
		||||
@@ -204,7 +204,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            guest_qs = guest_qs.none()
 | 
			
		||||
        return guest_qs
 | 
			
		||||
        return guest_qs.distinct()
 | 
			
		||||
 | 
			
		||||
    def get_invited_note(self, activity):
 | 
			
		||||
        """
 | 
			
		||||
@@ -228,7 +228,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Filter with permission backend
 | 
			
		||||
        note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view"))
 | 
			
		||||
        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"]
 | 
			
		||||
@@ -254,7 +254,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
 | 
			
		||||
        """
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
        activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
 | 
			
		||||
        activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
 | 
			
		||||
            .distinct().get(pk=self.kwargs["pk"])
 | 
			
		||||
        context["activity"] = activity
 | 
			
		||||
 | 
			
		||||
@@ -279,15 +279,17 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
 | 
			
		||||
        context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk
 | 
			
		||||
 | 
			
		||||
        activities_open = Activity.objects.filter(open=True).filter(
 | 
			
		||||
            PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all()
 | 
			
		||||
            PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().all()
 | 
			
		||||
        context["activities_open"] = [a for a in activities_open
 | 
			
		||||
                                      if PermissionBackend.check_perm(self.request.user,
 | 
			
		||||
                                      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.
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 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-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,17 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 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 rest_framework.serializers import ModelSerializer
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from rest_framework import serializers
 | 
			
		||||
from member.api.serializers import ProfileSerializer, MembershipSerializer
 | 
			
		||||
from note.api.serializers import NoteSerializer
 | 
			
		||||
from note.models import Alias
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserSerializer(ModelSerializer):
 | 
			
		||||
class UserSerializer(serializers.ModelSerializer):
 | 
			
		||||
    """
 | 
			
		||||
    REST API Serializer for Users.
 | 
			
		||||
    The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
 | 
			
		||||
@@ -22,7 +26,7 @@ class UserSerializer(ModelSerializer):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ContentTypeSerializer(ModelSerializer):
 | 
			
		||||
class ContentTypeSerializer(serializers.ModelSerializer):
 | 
			
		||||
    """
 | 
			
		||||
    REST API Serializer for Users.
 | 
			
		||||
    The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
 | 
			
		||||
@@ -31,3 +35,42 @@ class ContentTypeSerializer(ModelSerializer):
 | 
			
		||||
    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 = ProfileSerializer()
 | 
			
		||||
 | 
			
		||||
    note = NoteSerializer()
 | 
			
		||||
 | 
			
		||||
    memberships = serializers.SerializerMethodField()
 | 
			
		||||
 | 
			
		||||
    def get_normalized_name(self, obj):
 | 
			
		||||
        return Alias.normalize(obj.username)
 | 
			
		||||
 | 
			
		||||
    def get_memberships(self, obj):
 | 
			
		||||
        return serializers.ListSerializer(child=MembershipSerializer()).to_representation(
 | 
			
		||||
            obj.memberships.filter(date_start__lte=timezone.now(), date_end__gte=timezone.now()))
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = User
 | 
			
		||||
        fields = (
 | 
			
		||||
            'id',
 | 
			
		||||
            'username',
 | 
			
		||||
            'normalized_name',
 | 
			
		||||
            'first_name',
 | 
			
		||||
            'last_name',
 | 
			
		||||
            'email',
 | 
			
		||||
            'is_superuser',
 | 
			
		||||
            'is_active',
 | 
			
		||||
            'is_staff',
 | 
			
		||||
            'profile',
 | 
			
		||||
            'note',
 | 
			
		||||
            'memberships',
 | 
			
		||||
        )
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										240
									
								
								apps/api/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										240
									
								
								apps/api/tests.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,240 @@
 | 
			
		||||
# Copyright (C) 2018-2021 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 member.models import Membership, Club
 | 
			
		||||
from note.models import NoteClub, NoteUser, Alias, Note
 | 
			
		||||
from permission.models import PermissionMask, Permission, Role
 | 
			
		||||
from phonenumbers import PhoneNumber
 | 
			
		||||
from rest_framework.filters import SearchFilter, OrderingFilter
 | 
			
		||||
 | 
			
		||||
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 SearchFilter 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/")
 | 
			
		||||
@@ -1,10 +1,11 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.conf.urls import url, include
 | 
			
		||||
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.
 | 
			
		||||
@@ -47,5 +48,6 @@ app_name = 'api'
 | 
			
		||||
# Additionally, we include login URLs for the browsable API.
 | 
			
		||||
urlpatterns = [
 | 
			
		||||
    url('^', include(router.urls)),
 | 
			
		||||
    url('^me/', UserInformationView.as_view()),
 | 
			
		||||
    url('^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-2021 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,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.contrib.contenttypes.models import ContentType
 | 
			
		||||
@@ -6,9 +6,9 @@ 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.filters import SearchFilter
 | 
			
		||||
from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet
 | 
			
		||||
from permission.backends import PermissionBackend
 | 
			
		||||
from note_kfet.middlewares import get_current_session
 | 
			
		||||
from note.models import Alias
 | 
			
		||||
 | 
			
		||||
from .serializers import UserSerializer, ContentTypeSerializer
 | 
			
		||||
@@ -24,9 +24,7 @@ class ReadProtectedModelViewSet(ModelViewSet):
 | 
			
		||||
        self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        user = self.request.user
 | 
			
		||||
        get_current_session().setdefault("permission_mask", 42)
 | 
			
		||||
        return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
 | 
			
		||||
        return self.queryset.filter(PermissionBackend.filter_queryset(self.request, self.model, "view")).distinct()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
 | 
			
		||||
@@ -39,21 +37,20 @@ class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
 | 
			
		||||
        self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        user = self.request.user
 | 
			
		||||
        get_current_session().setdefault("permission_mask", 42)
 | 
			
		||||
        return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
 | 
			
		||||
        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/users/
 | 
			
		||||
    then render it on /api/user/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = User.objects.all()
 | 
			
		||||
    queryset = User.objects
 | 
			
		||||
    serializer_class = UserSerializer
 | 
			
		||||
    filter_backends = [DjangoFilterBackend]
 | 
			
		||||
    filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', ]
 | 
			
		||||
    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()
 | 
			
		||||
@@ -106,7 +103,10 @@ 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/
 | 
			
		||||
    then render it on /api/models/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = ContentType.objects.all()
 | 
			
		||||
    queryset = ContentType.objects.order_by('id')
 | 
			
		||||
    serializer_class = ContentTypeSerializer
 | 
			
		||||
    filter_backends = [DjangoFilterBackend, SearchFilter]
 | 
			
		||||
    filterset_fields = ['id', 'app_label', 'model', ]
 | 
			
		||||
    search_fields = ['$app_label', '$model', ]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 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-2021 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-2021 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-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django_filters.rest_framework import DjangoFilterBackend
 | 
			
		||||
@@ -15,7 +15,7 @@ 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', ]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,11 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 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
 | 
			
		||||
 | 
			
		||||
@@ -57,9 +57,9 @@ def save_object(sender, instance, **kwargs):
 | 
			
		||||
    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()
 | 
			
		||||
    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
 | 
			
		||||
@@ -71,9 +71,23 @@ 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
 | 
			
		||||
@@ -121,9 +135,9 @@ def delete_object(sender, instance, **kwargs):
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # 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()
 | 
			
		||||
    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
 | 
			
		||||
@@ -135,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):
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
default_app_config = 'member.apps.MemberConfig'
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 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-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from .views import ProfileViewSet, ClubViewSet, MembershipViewSet
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,8 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from rest_framework.filters import SearchFilter
 | 
			
		||||
from django_filters.rest_framework import DjangoFilterBackend
 | 
			
		||||
from rest_framework.filters import OrderingFilter, SearchFilter
 | 
			
		||||
from api.viewsets import ReadProtectedModelViewSet
 | 
			
		||||
 | 
			
		||||
from .serializers import ProfileSerializer, ClubSerializer, MembershipSerializer
 | 
			
		||||
@@ -14,8 +15,15 @@ class ProfileViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    The djangorestframework plugin will get all `Profile` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/members/profile/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = Profile.objects.all()
 | 
			
		||||
    queryset = Profile.objects.order_by('id')
 | 
			
		||||
    serializer_class = ProfileSerializer
 | 
			
		||||
    filter_backends = [DjangoFilterBackend, SearchFilter]
 | 
			
		||||
    filterset_fields = ['user', 'user__first_name', 'user__last_name', 'user__username', 'user__email',
 | 
			
		||||
                        'user__note__alias__name', 'user__note__alias__normalized_name', 'phone_number', "section",
 | 
			
		||||
                        'department', 'promotion', 'address', 'paid', 'ml_events_registration', 'ml_sport_registration',
 | 
			
		||||
                        'ml_art_registration', 'report_frequency', 'email_confirmed', 'registration_valid', ]
 | 
			
		||||
    search_fields = ['$user__first_name', '$user__last_name', '$user__username', '$user__email',
 | 
			
		||||
                     '$user__note__alias__name', '$user__note__alias__normalized_name', ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ClubViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
@@ -24,10 +32,13 @@ class ClubViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    The djangorestframework plugin will get all `Club` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/members/club/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = Club.objects.all()
 | 
			
		||||
    queryset = Club.objects.order_by('id')
 | 
			
		||||
    serializer_class = ClubSerializer
 | 
			
		||||
    filter_backends = [SearchFilter]
 | 
			
		||||
    search_fields = ['$name', ]
 | 
			
		||||
    filter_backends = [DjangoFilterBackend, SearchFilter]
 | 
			
		||||
    filterset_fields = ['name', 'email', 'note__alias__name', 'note__alias__normalized_name', 'parent_club',
 | 
			
		||||
                        'parent_club__name', 'require_memberships', 'membership_fee_paid', 'membership_fee_unpaid',
 | 
			
		||||
                        'membership_duration', 'membership_start', 'membership_end', ]
 | 
			
		||||
    search_fields = ['$name', '$email', '$note__alias__name', '$note__alias__normalized_name', ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MembershipViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
@@ -36,5 +47,14 @@ class MembershipViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    The djangorestframework plugin will get all `Membership` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/members/membership/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = Membership.objects.all()
 | 
			
		||||
    queryset = Membership.objects.order_by('id')
 | 
			
		||||
    serializer_class = MembershipSerializer
 | 
			
		||||
    filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
 | 
			
		||||
    filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', 'club__note__alias__normalized_name',
 | 
			
		||||
                        'user__username', 'user__last_name', 'user__first_name', 'user__email',
 | 
			
		||||
                        'user__note__alias__name', 'user__note__alias__normalized_name',
 | 
			
		||||
                        'date_start', 'date_end', 'fee', 'roles', ]
 | 
			
		||||
    ordering_fields = ['id', 'date_start', 'date_end', ]
 | 
			
		||||
    search_fields = ['$club__name', '$club__email', '$club__note__alias__name', '$club__note__alias__normalized_name',
 | 
			
		||||
                     '$user__username', '$user__last_name', '$user__first_name', '$user__email',
 | 
			
		||||
                     '$user__note__alias__name', '$user__note__alias__normalized_name', '$roles__name', ]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										17
									
								
								apps/member/auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								apps/member/auth.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from cas_server.auth import DjangoAuthUser  # pragma: no cover
 | 
			
		||||
from note.models import Alias
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CustomAuthUser(DjangoAuthUser):  # pragma: no cover
 | 
			
		||||
    """
 | 
			
		||||
    Override Django Auth User model to define a custom Matrix username.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def attributs(self):
 | 
			
		||||
        d = super().attributs()
 | 
			
		||||
        if self.user:
 | 
			
		||||
            d["normalized_name"] = Alias.normalize(self.user.username)
 | 
			
		||||
        return d
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
import io
 | 
			
		||||
@@ -150,6 +150,7 @@ class ClubForm(forms.ModelForm):
 | 
			
		||||
            "membership_fee_unpaid": AmountInput(),
 | 
			
		||||
            "parent_club": Autocomplete(
 | 
			
		||||
                Club,
 | 
			
		||||
                resetable=True,
 | 
			
		||||
                attrs={
 | 
			
		||||
                    'api_url': '/api/members/club/',
 | 
			
		||||
                }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,14 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
import hashlib
 | 
			
		||||
from collections import OrderedDict
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.hashers import PBKDF2PasswordHasher
 | 
			
		||||
from django.contrib.auth.hashers import PBKDF2PasswordHasher, mask_hash
 | 
			
		||||
from django.utils.crypto import constant_time_compare
 | 
			
		||||
from note_kfet.middlewares import get_current_authenticated_user, get_current_session
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from note_kfet.middlewares import get_current_request
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CustomNK15Hasher(PBKDF2PasswordHasher):
 | 
			
		||||
@@ -24,16 +26,22 @@ class CustomNK15Hasher(PBKDF2PasswordHasher):
 | 
			
		||||
 | 
			
		||||
    def must_update(self, encoded):
 | 
			
		||||
        if settings.DEBUG:
 | 
			
		||||
            current_user = get_current_authenticated_user()
 | 
			
		||||
            # Small hack to let superusers to impersonate people.
 | 
			
		||||
            # Don't change their password.
 | 
			
		||||
            request = get_current_request()
 | 
			
		||||
            current_user = request.user
 | 
			
		||||
            if current_user is not None and current_user.is_superuser:
 | 
			
		||||
                return False
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    def verify(self, password, encoded):
 | 
			
		||||
        if settings.DEBUG:
 | 
			
		||||
            current_user = get_current_authenticated_user()
 | 
			
		||||
            # Small hack to let superusers to impersonate people.
 | 
			
		||||
            # If a superuser is already connected, let him/her log in as another person.
 | 
			
		||||
            request = get_current_request()
 | 
			
		||||
            current_user = request.user
 | 
			
		||||
            if current_user is not None and current_user.is_superuser\
 | 
			
		||||
                    and get_current_session().get("permission_mask", -1) >= 42:
 | 
			
		||||
                    and request.session.get("permission_mask", -1) >= 42:
 | 
			
		||||
                return True
 | 
			
		||||
 | 
			
		||||
        if '|' in encoded:
 | 
			
		||||
@@ -41,6 +49,18 @@ class CustomNK15Hasher(PBKDF2PasswordHasher):
 | 
			
		||||
            return constant_time_compare(hashlib.sha256((salt + password).encode("utf-8")).hexdigest(), db_hashed_pass)
 | 
			
		||||
        return super().verify(password, encoded)
 | 
			
		||||
 | 
			
		||||
    def safe_summary(self, encoded):
 | 
			
		||||
        # Displayed information in Django Admin.
 | 
			
		||||
        if '|' in encoded:
 | 
			
		||||
            salt, db_hashed_pass = encoded.split('$')[2].split('|')
 | 
			
		||||
            return OrderedDict([
 | 
			
		||||
                (_('algorithm'), 'custom_nk15'),
 | 
			
		||||
                (_('iterations'), '1'),
 | 
			
		||||
                (_('salt'), mask_hash(salt)),
 | 
			
		||||
                (_('hash'), mask_hash(db_hashed_pass)),
 | 
			
		||||
            ])
 | 
			
		||||
        return super().safe_summary(encoded)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DebugSuperuserBackdoor(PBKDF2PasswordHasher):
 | 
			
		||||
    """
 | 
			
		||||
@@ -51,8 +71,11 @@ class DebugSuperuserBackdoor(PBKDF2PasswordHasher):
 | 
			
		||||
 | 
			
		||||
    def verify(self, password, encoded):
 | 
			
		||||
        if settings.DEBUG:
 | 
			
		||||
            current_user = get_current_authenticated_user()
 | 
			
		||||
            # Small hack to let superusers to impersonate people.
 | 
			
		||||
            # If a superuser is already connected, let him/her log in as another person.
 | 
			
		||||
            request = get_current_request()
 | 
			
		||||
            current_user = request.user
 | 
			
		||||
            if current_user is not None and current_user.is_superuser\
 | 
			
		||||
                    and get_current_session().get("permission_mask", -1) >= 42:
 | 
			
		||||
                    and request.session.get("permission_mask", -1) >= 42:
 | 
			
		||||
                return True
 | 
			
		||||
        return super().verify(password, encoded)
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ def create_bde_and_kfet(apps, schema_editor):
 | 
			
		||||
    """
 | 
			
		||||
    Club = apps.get_model("member", "club")
 | 
			
		||||
    NoteClub = apps.get_model("note", "noteclub")
 | 
			
		||||
    Alias = apps.get_model("note", "alias")
 | 
			
		||||
    ContentType = apps.get_model('contenttypes', 'ContentType')
 | 
			
		||||
    polymorphic_ctype_id = ContentType.objects.get_for_model(NoteClub).id
 | 
			
		||||
 | 
			
		||||
@@ -18,8 +19,8 @@ def create_bde_and_kfet(apps, schema_editor):
 | 
			
		||||
        membership_fee_paid=500,
 | 
			
		||||
        membership_fee_unpaid=500,
 | 
			
		||||
        membership_duration=396,
 | 
			
		||||
        membership_start="2020-08-01",
 | 
			
		||||
        membership_end="2021-09-30",
 | 
			
		||||
        membership_start="2021-08-01",
 | 
			
		||||
        membership_end="2022-09-30",
 | 
			
		||||
    )
 | 
			
		||||
    Club.objects.get_or_create(
 | 
			
		||||
        id=2,
 | 
			
		||||
@@ -30,8 +31,8 @@ def create_bde_and_kfet(apps, schema_editor):
 | 
			
		||||
        membership_fee_paid=3500,
 | 
			
		||||
        membership_fee_unpaid=3500,
 | 
			
		||||
        membership_duration=396,
 | 
			
		||||
        membership_start="2020-08-01",
 | 
			
		||||
        membership_end="2021-09-30",
 | 
			
		||||
        membership_start="2021-08-01",
 | 
			
		||||
        membership_end="2022-09-30",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    NoteClub.objects.get_or_create(
 | 
			
		||||
@@ -45,6 +46,19 @@ def create_bde_and_kfet(apps, schema_editor):
 | 
			
		||||
        polymorphic_ctype_id=polymorphic_ctype_id,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    Alias.objects.get_or_create(
 | 
			
		||||
        id=5,
 | 
			
		||||
        note_id=5,
 | 
			
		||||
        name="BDE",
 | 
			
		||||
        normalized_name="bde",
 | 
			
		||||
    )
 | 
			
		||||
    Alias.objects.get_or_create(
 | 
			
		||||
        id=6,
 | 
			
		||||
        note_id=6,
 | 
			
		||||
        name="Kfet",
 | 
			
		||||
        normalized_name="kfet",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,50 @@
 | 
			
		||||
import sys
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def give_note_account_permissions(apps, schema_editor):
 | 
			
		||||
    """
 | 
			
		||||
    Automatically manage the membership of the Note account.
 | 
			
		||||
    """
 | 
			
		||||
    User = apps.get_model("auth", "user")
 | 
			
		||||
    Membership = apps.get_model("member", "membership")
 | 
			
		||||
    Role = apps.get_model("permission", "role")
 | 
			
		||||
 | 
			
		||||
    note = User.objects.filter(username="note")
 | 
			
		||||
    if not note.exists():
 | 
			
		||||
        # We are in a test environment, don't log error message
 | 
			
		||||
        if len(sys.argv) > 1 and sys.argv[1] == 'test':
 | 
			
		||||
            return
 | 
			
		||||
        print("Warning: Note account was not found. The note account was not imported.")
 | 
			
		||||
        print("Make sure you have imported the NK15 database. The new import script handles correctly the permissions.")
 | 
			
		||||
        print("This migration will be ignored, you can re-run it if you forgot the note account or ignore it if you "
 | 
			
		||||
              "don't want this account.")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    note = note.get()
 | 
			
		||||
 | 
			
		||||
    # Set for the two clubs a large expiration date and the correct role.
 | 
			
		||||
    for m in Membership.objects.filter(user_id=note.id).all():
 | 
			
		||||
        m.date_end = "3142-12-12"
 | 
			
		||||
        m.roles.set(Role.objects.filter(name="PC Kfet").all())
 | 
			
		||||
        m.save()
 | 
			
		||||
    # By default, the note account is only authorized to be logged from localhost.
 | 
			
		||||
    note.password = "ipbased$127.0.0.1"
 | 
			
		||||
    note.is_active = True
 | 
			
		||||
    note.save()
 | 
			
		||||
    # Ensure that the note of the account is disabled
 | 
			
		||||
    note.note.inactivity_reason = 'forced'
 | 
			
		||||
    note.note.is_active = False
 | 
			
		||||
    note.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('member', '0005_remove_null_tag_on_charfields'),
 | 
			
		||||
        ('permission', '0001_initial'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RunPython(give_note_account_permissions),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										23
									
								
								apps/member/migrations/0007_auto_20210313_1235.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								apps/member/migrations/0007_auto_20210313_1235.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
# Generated by Django 2.2.19 on 2021-03-13 11:35
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('member', '0006_create_note_account_bde_membership'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='membership',
 | 
			
		||||
            name='roles',
 | 
			
		||||
            field=models.ManyToManyField(related_name='memberships', to='permission.Role', verbose_name='roles'),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='profile',
 | 
			
		||||
            name='promotion',
 | 
			
		||||
            field=models.PositiveSmallIntegerField(default=2021, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
import datetime
 | 
			
		||||
@@ -57,7 +57,7 @@ class Profile(models.Model):
 | 
			
		||||
            ('A1', _("Mathematics (A1)")),
 | 
			
		||||
            ('A2', _("Physics (A2)")),
 | 
			
		||||
            ("A'2", _("Applied physics (A'2)")),
 | 
			
		||||
            ('A''2', _("Chemistry (A''2)")),
 | 
			
		||||
            ("A''2", _("Chemistry (A''2)")),
 | 
			
		||||
            ('A3', _("Biology (A3)")),
 | 
			
		||||
            ('B1234', _("SAPHIRE (B1234)")),
 | 
			
		||||
            ('B1', _("Mechanics (B1)")),
 | 
			
		||||
@@ -74,7 +74,7 @@ class Profile(models.Model):
 | 
			
		||||
 | 
			
		||||
    promotion = models.PositiveSmallIntegerField(
 | 
			
		||||
        null=True,
 | 
			
		||||
        default=datetime.date.today().year,
 | 
			
		||||
        default=datetime.date.today().year if datetime.date.today().month >= 8 else datetime.date.today().year - 1,
 | 
			
		||||
        verbose_name=_("promotion"),
 | 
			
		||||
        help_text=_("Year of entry to the school (None if not ENS student)"),
 | 
			
		||||
    )
 | 
			
		||||
@@ -313,6 +313,7 @@ class Membership(models.Model):
 | 
			
		||||
 | 
			
		||||
    roles = models.ManyToManyField(
 | 
			
		||||
        "permission.Role",
 | 
			
		||||
        related_name="memberships",
 | 
			
		||||
        verbose_name=_("roles"),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
@@ -412,6 +413,12 @@ class Membership(models.Model):
 | 
			
		||||
        """
 | 
			
		||||
        Calculate fee and end date before saving the membership and creating the transaction if needed.
 | 
			
		||||
        """
 | 
			
		||||
        # Ensure that club membership dates are valid
 | 
			
		||||
        old_membership_start = self.club.membership_start
 | 
			
		||||
        self.club.update_membership_dates()
 | 
			
		||||
        if self.club.membership_start != old_membership_start:
 | 
			
		||||
            self.club.save()
 | 
			
		||||
 | 
			
		||||
        created = not self.pk
 | 
			
		||||
        if not created:
 | 
			
		||||
            for role in self.roles.all():
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,7 @@ function create_alias (e) {
 | 
			
		||||
  }).done(function () {
 | 
			
		||||
    // Reload table
 | 
			
		||||
    $('#alias_table').load(location.pathname + ' #alias_table')
 | 
			
		||||
    addMsg('Alias ajouté', 'success')
 | 
			
		||||
    addMsg(gettext('Alias successfully added'), 'success')
 | 
			
		||||
  }).fail(function (xhr, _textStatus, _error) {
 | 
			
		||||
    errMsg(xhr.responseJSON)
 | 
			
		||||
  })
 | 
			
		||||
@@ -22,7 +22,7 @@ function create_alias (e) {
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * On click of "delete", delete the alias
 | 
			
		||||
 * @param Integer button_id Alias id to remove
 | 
			
		||||
 * @param button_id:Integer Alias id to remove
 | 
			
		||||
 */
 | 
			
		||||
function delete_button (button_id) {
 | 
			
		||||
  $.ajax({
 | 
			
		||||
@@ -30,7 +30,7 @@ function delete_button (button_id) {
 | 
			
		||||
    method: 'DELETE',
 | 
			
		||||
    headers: { 'X-CSRFTOKEN': CSRF_TOKEN }
 | 
			
		||||
  }).done(function () {
 | 
			
		||||
    addMsg('Alias supprimé', 'success')
 | 
			
		||||
    addMsg(gettext('Alias successfully deleted'), 'success')
 | 
			
		||||
    $('#alias_table').load(location.pathname + ' #alias_table')
 | 
			
		||||
  }).fail(function (xhr, _textStatus, _error) {
 | 
			
		||||
    errMsg(xhr.responseJSON)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from datetime import date
 | 
			
		||||
@@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django.urls import reverse_lazy
 | 
			
		||||
from django.utils.html import format_html
 | 
			
		||||
from note.templatetags.pretty_money import pretty_money
 | 
			
		||||
from note_kfet.middlewares import get_current_authenticated_user
 | 
			
		||||
from note_kfet.middlewares import get_current_request
 | 
			
		||||
from permission.backends import PermissionBackend
 | 
			
		||||
 | 
			
		||||
from .models import Club, Membership
 | 
			
		||||
@@ -31,7 +31,8 @@ class ClubTable(tables.Table):
 | 
			
		||||
        row_attrs = {
 | 
			
		||||
            'class': 'table-row',
 | 
			
		||||
            'id': lambda record: "row-" + str(record.pk),
 | 
			
		||||
            'data-href': lambda record: record.pk
 | 
			
		||||
            'data-href': lambda record: record.pk,
 | 
			
		||||
            'style': 'cursor:pointer',
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -43,11 +44,27 @@ class UserTable(tables.Table):
 | 
			
		||||
 | 
			
		||||
    section = tables.Column(accessor='profile__section')
 | 
			
		||||
 | 
			
		||||
    # Override the column to let replace the URL
 | 
			
		||||
    email = tables.EmailColumn(linkify=lambda record: "mailto:{}".format(record.email))
 | 
			
		||||
 | 
			
		||||
    balance = tables.Column(accessor='note__balance', verbose_name=_("Balance"))
 | 
			
		||||
 | 
			
		||||
    def render_email(self, record, value):
 | 
			
		||||
        # Replace the email by a dash if the user can't see the profile detail
 | 
			
		||||
        # Replace also the URL
 | 
			
		||||
        if not PermissionBackend.check_perm(get_current_request(), "member.view_profile", record.profile):
 | 
			
		||||
            value = "—"
 | 
			
		||||
            record.email = value
 | 
			
		||||
        return value
 | 
			
		||||
 | 
			
		||||
    def render_section(self, record, value):
 | 
			
		||||
        return value \
 | 
			
		||||
            if PermissionBackend.check_perm(get_current_request(), "member.view_profile", record.profile) \
 | 
			
		||||
            else "—"
 | 
			
		||||
 | 
			
		||||
    def render_balance(self, record, value):
 | 
			
		||||
        return pretty_money(value)\
 | 
			
		||||
            if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", record.note) else "—"
 | 
			
		||||
            if PermissionBackend.check_perm(get_current_request(), "note.view_note", record.note) else "—"
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        attrs = {
 | 
			
		||||
@@ -58,7 +75,8 @@ class UserTable(tables.Table):
 | 
			
		||||
        model = User
 | 
			
		||||
        row_attrs = {
 | 
			
		||||
            'class': 'table-row',
 | 
			
		||||
            'data-href': lambda record: record.pk
 | 
			
		||||
            'data-href': lambda record: record.pk,
 | 
			
		||||
            'style': 'cursor:pointer',
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -77,7 +95,7 @@ class MembershipTable(tables.Table):
 | 
			
		||||
    def render_user(self, value):
 | 
			
		||||
        # If the user has the right, link the displayed user with the page of its detail.
 | 
			
		||||
        s = value.username
 | 
			
		||||
        if PermissionBackend.check_perm(get_current_authenticated_user(), "auth.view_user", value):
 | 
			
		||||
        if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value):
 | 
			
		||||
            s = format_html("<a href={url}>{name}</a>",
 | 
			
		||||
                            url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)
 | 
			
		||||
 | 
			
		||||
@@ -86,7 +104,7 @@ class MembershipTable(tables.Table):
 | 
			
		||||
    def render_club(self, value):
 | 
			
		||||
        # If the user has the right, link the displayed club with the page of its detail.
 | 
			
		||||
        s = value.name
 | 
			
		||||
        if PermissionBackend.check_perm(get_current_authenticated_user(), "member.view_club", value):
 | 
			
		||||
        if PermissionBackend.check_perm(get_current_request(), "member.view_club", value):
 | 
			
		||||
            s = format_html("<a href={url}>{name}</a>",
 | 
			
		||||
                            url=reverse_lazy('member:club_detail', kwargs={"pk": value.pk}), name=s)
 | 
			
		||||
 | 
			
		||||
@@ -111,8 +129,8 @@ class MembershipTable(tables.Table):
 | 
			
		||||
                    date_end=date.today(),
 | 
			
		||||
                    fee=0,
 | 
			
		||||
                )
 | 
			
		||||
                if PermissionBackend.check_perm(get_current_authenticated_user(),
 | 
			
		||||
                                                "member:add_membership", empty_membership):  # If the user has right
 | 
			
		||||
                if PermissionBackend.check_perm(get_current_request(),
 | 
			
		||||
                                                "member.add_membership", empty_membership):  # If the user has right
 | 
			
		||||
                    renew_url = reverse_lazy('member:club_renew_membership',
 | 
			
		||||
                                             kwargs={"pk": record.pk})
 | 
			
		||||
                    t = format_html(
 | 
			
		||||
@@ -126,7 +144,7 @@ class MembershipTable(tables.Table):
 | 
			
		||||
        # If the user has the right to manage the roles, display the link to manage them
 | 
			
		||||
        roles = record.roles.all()
 | 
			
		||||
        s = ", ".join(str(role) for role in roles)
 | 
			
		||||
        if PermissionBackend.check_perm(get_current_authenticated_user(), "member.change_membership_roles", record):
 | 
			
		||||
        if PermissionBackend.check_perm(get_current_request(), "member.change_membership_roles", record):
 | 
			
		||||
            s = format_html("<a href='" + str(reverse_lazy("member:club_manage_roles", kwargs={"pk": record.pk}))
 | 
			
		||||
                            + "'>" + s + "</a>")
 | 
			
		||||
        return s
 | 
			
		||||
@@ -149,7 +167,7 @@ class ClubManagerTable(tables.Table):
 | 
			
		||||
    def render_user(self, value):
 | 
			
		||||
        # If the user has the right, link the displayed user with the page of its detail.
 | 
			
		||||
        s = value.username
 | 
			
		||||
        if PermissionBackend.check_perm(get_current_authenticated_user(), "auth.view_user", value):
 | 
			
		||||
        if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value):
 | 
			
		||||
            s = format_html("<a href={url}>{name}</a>",
 | 
			
		||||
                            url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -13,15 +13,29 @@ SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
        {% if additional_fee_renewal %}
 | 
			
		||||
        <div class="alert alert-warning">
 | 
			
		||||
            {% if renewal %}
 | 
			
		||||
            {% blocktrans trimmed with clubs=clubs_renewal|join:", " pretty_fee=additional_fee_renewal|pretty_money %}
 | 
			
		||||
            The user is not a member of the club·s {{ clubs }}. An additional fee of {{ pretty_fee }}
 | 
			
		||||
            will be charged to renew automatically the membership in this/these club·s.
 | 
			
		||||
            {% endblocktrans %}
 | 
			
		||||
                {% if club.name == "Kfet" %} {# Auto-renewal #}
 | 
			
		||||
                    {% blocktrans trimmed with clubs=clubs_renewal|join:", " pretty_fee=additional_fee_renewal|pretty_money %}
 | 
			
		||||
                    The user is not a member of the club·s {{ clubs }}. An additional fee of {{ pretty_fee }}
 | 
			
		||||
                    will be charged to renew automatically the membership in this/these club·s.
 | 
			
		||||
                    {% endblocktrans %}
 | 
			
		||||
                {% else %}
 | 
			
		||||
                    {% blocktrans trimmed with clubs=clubs_renewal|join:", " pretty_fee=additional_fee_renewal|pretty_money %}
 | 
			
		||||
                        The user is not a member of the club·s {{ clubs }}. Please create the required memberships,
 | 
			
		||||
                        otherwise it will fail.
 | 
			
		||||
                    {% endblocktrans %}
 | 
			
		||||
                {% endif %}
 | 
			
		||||
            {% else %}
 | 
			
		||||
            {% blocktrans trimmed with clubs=clubs_renewal|join:", " pretty_fee=additional_fee_renewal|pretty_money %}
 | 
			
		||||
            This club has parents {{ clubs }}. An additional fee of {{ pretty_fee }}
 | 
			
		||||
            will be charged to adhere automatically to this/these club·s.
 | 
			
		||||
            {% endblocktrans %}
 | 
			
		||||
                {% if club.name == "Kfet" %}
 | 
			
		||||
                    {% blocktrans trimmed with clubs=clubs_renewal|join:", " pretty_fee=additional_fee_renewal|pretty_money %}
 | 
			
		||||
                    This club has parents {{ clubs }}. An additional fee of {{ pretty_fee }}
 | 
			
		||||
                    will be charged to adhere automatically to this/these club·s.
 | 
			
		||||
                    {% endblocktrans %}
 | 
			
		||||
                {% else %}
 | 
			
		||||
                    {% blocktrans trimmed with clubs=clubs_renewal|join:", " pretty_fee=additional_fee_renewal|pretty_money %}
 | 
			
		||||
                        This club has parents {{ clubs }}. Please make sure that the user is a member of this or these club·s,
 | 
			
		||||
                        otherwise the creation of this membership will fail.
 | 
			
		||||
                    {% endblocktrans %}
 | 
			
		||||
                {% endif %}
 | 
			
		||||
            {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
 
 | 
			
		||||
@@ -45,7 +45,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
            <div class="card-footer">
 | 
			
		||||
                {% if user_object %}
 | 
			
		||||
                <a class="btn btn-sm btn-secondary" href="{% url 'member:user_update_profile' user_object.pk %}">
 | 
			
		||||
                    <i class="fa fa-edit"></i> {% trans 'Update Profile' %}
 | 
			
		||||
                    <svg class="bi bi-edit" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
 | 
			
		||||
                        <path d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708l-3-3zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207l6.5-6.5zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.499.499 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11l.178-.178z"/>
 | 
			
		||||
                    </svg>
 | 
			
		||||
                    {% trans 'Update Profile' %}
 | 
			
		||||
                </a>
 | 
			
		||||
                {% url 'member:user_detail' user_object.pk as user_profile_url %}
 | 
			
		||||
                {% if request.path_info != user_profile_url %}
 | 
			
		||||
@@ -59,7 +62,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
                {% if ".change_"|has_perm:club %}
 | 
			
		||||
                <a class="btn btn-sm btn-secondary" href="{% url 'member:club_update' pk=club.pk %}"
 | 
			
		||||
                   data-turbolinks="false">
 | 
			
		||||
                    <i class="fa fa-edit"></i> {% trans 'Update Profile' %}
 | 
			
		||||
                    <svg class="bi bi-edit" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
 | 
			
		||||
                        <path d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708l-3-3zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207l6.5-6.5zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.499.499 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11l.178-.178z"/>
 | 
			
		||||
                    </svg>
 | 
			
		||||
                    {% trans 'Update Profile' %}
 | 
			
		||||
                </a>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                {% url 'member:club_detail' club.pk as club_detail_url %}
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
<div class="card">
 | 
			
		||||
    <div class="card-header position-relative" id="clubListHeading">
 | 
			
		||||
        <a class="font-weight-bold">
 | 
			
		||||
            <i class="fa fa-users"></i> {% trans "Club managers" %}
 | 
			
		||||
            <svg class="bi bi-users" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
 | 
			
		||||
                <path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H3zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
            {% trans "Club managers" %}
 | 
			
		||||
        </a>
 | 
			
		||||
    </div>
 | 
			
		||||
    {% render_table managers %}
 | 
			
		||||
@@ -23,7 +26,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
<div class="card">
 | 
			
		||||
    <div class="card-header position-relative" id="clubListHeading">
 | 
			
		||||
        <a class="stretched-link font-weight-bold" href="{% url 'member:club_members' pk=club.pk %}">
 | 
			
		||||
            <i class="fa fa-users"></i> {% trans "Club members" %}
 | 
			
		||||
            <svg class="bi bi-users" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
 | 
			
		||||
                <path d="M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1H7zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
 | 
			
		||||
                <path fill-rule="evenodd" d="M5.216 14A2.238 2.238 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.325 6.325 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1h4.216z"/>
 | 
			
		||||
                <path d="M4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
            {% trans "Club members" %}
 | 
			
		||||
        </a>
 | 
			
		||||
    </div>
 | 
			
		||||
    {% render_table member_list %}
 | 
			
		||||
@@ -37,7 +45,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
    <div class="card-header position-relative" id="historyListHeading">
 | 
			
		||||
        <a class="stretched-link font-weight-bold" {% if "note.view_note"|has_perm:club.note %}
 | 
			
		||||
            href="{% url 'note:transactions' pk=club.note.pk %}" {% endif %}>
 | 
			
		||||
            <i class="fa fa-euro"></i> {% trans "Transaction history" %}
 | 
			
		||||
            <svg class="bi bi-euro" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
 | 
			
		||||
                <path d="M4 9.42h1.063C5.4 12.323 7.317 14 10.34 14c.622 0 1.167-.068 1.659-.185v-1.3c-.484.119-1.045.17-1.659.17-2.1 0-3.455-1.198-3.775-3.264h4.017v-.928H6.497v-.936c0-.11 0-.219.008-.329h4.078v-.927H6.618c.388-1.898 1.719-2.985 3.723-2.985.614 0 1.175.05 1.659.177V2.194A6.617 6.617 0 0 0 10.341 2c-2.928 0-4.82 1.569-5.244 4.3H4v.928h1.01v1.265H4v.928z"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
            {% trans "Transaction history" %}
 | 
			
		||||
        </a>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div id="history_list">
 | 
			
		||||
 
 | 
			
		||||
@@ -47,8 +47,10 @@
 | 
			
		||||
    <dt class="col-xl-6">{% trans 'aliases'|capfirst %}</dt>
 | 
			
		||||
    <dd class="col-xl-6">
 | 
			
		||||
        <a class="badge badge-secondary" href="{% url 'member:club_alias' club.pk %}">
 | 
			
		||||
            <i class="fa fa-edit"></i>
 | 
			
		||||
            {% trans 'Manage aliases' %} ({{ club.note.alias_set.all|length }})
 | 
			
		||||
            <svg class="bi bi-edit" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
 | 
			
		||||
                <path d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708l-3-3zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207l6.5-6.5zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.499.499 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11l.178-.178z"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
            {% trans 'Manage aliases' %} ({{ club.note.alias.all|length }})
 | 
			
		||||
        </a>
 | 
			
		||||
    </dd>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,9 @@
 | 
			
		||||
    <dt class="col-xl-6">{% trans 'password'|capfirst %}</dt>
 | 
			
		||||
    <dd class="col-xl-6">
 | 
			
		||||
        <a class="badge badge-secondary" href="{% url 'password_change' %}">
 | 
			
		||||
            <i class="fa fa-lock"></i>
 | 
			
		||||
            <svg class="bi bi-lock" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
 | 
			
		||||
                <path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
            {% trans 'Change password' %}
 | 
			
		||||
        </a>
 | 
			
		||||
    </dd>
 | 
			
		||||
@@ -20,37 +22,44 @@
 | 
			
		||||
    <dt class="col-xl-6">{% trans 'aliases'|capfirst %}</dt>
 | 
			
		||||
    <dd class="col-xl-6">
 | 
			
		||||
        <a class="badge badge-secondary" href="{% url 'member:user_alias' user_object.pk %}">
 | 
			
		||||
            <i class="fa fa-edit"></i>
 | 
			
		||||
            {% trans 'Manage aliases' %} ({{ user_object.note.alias_set.all|length }})
 | 
			
		||||
            <svg class="bi bi-edit" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
 | 
			
		||||
                <path d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708l-3-3zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207l6.5-6.5zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.499.499 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11l.178-.178z"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
            {% trans 'Manage aliases' %} ({{ user_object.note.alias.all|length }})
 | 
			
		||||
        </a>
 | 
			
		||||
    </dd>
 | 
			
		||||
 | 
			
		||||
    <dt class="col-xl-6">{% trans 'section'|capfirst %}</dt>
 | 
			
		||||
    <dd class="col-xl-6">{{ user_object.profile.section }}</dd>
 | 
			
		||||
    {% if "member.view_profile"|has_perm:user_object.profile %}
 | 
			
		||||
        <dt class="col-xl-6">{% trans 'section'|capfirst %}</dt>
 | 
			
		||||
        <dd class="col-xl-6">{{ user_object.profile.section }}</dd>
 | 
			
		||||
 | 
			
		||||
    <dt class="col-xl-6">{% trans 'email'|capfirst %}</dt>
 | 
			
		||||
    <dd class="col-xl-6"><a href="mailto:{{ user_object.email }}">{{ user_object.email }}</a></dd>
 | 
			
		||||
        <dt class="col-xl-6">{% trans 'email'|capfirst %}</dt>
 | 
			
		||||
        <dd class="col-xl-6"><a href="mailto:{{ user_object.email }}">{{ user_object.email }}</a></dd>
 | 
			
		||||
 | 
			
		||||
    <dt class="col-xl-6">{% trans 'phone number'|capfirst %}</dt>
 | 
			
		||||
    <dd class="col-xl-6"><a href="tel:{{ user_object.profile.phone_number }}">{{ user_object.profile.phone_number }}</a>
 | 
			
		||||
    </dd>
 | 
			
		||||
        <dt class="col-xl-6">{% trans 'phone number'|capfirst %}</dt>
 | 
			
		||||
        <dd class="col-xl-6"><a href="tel:{{ user_object.profile.phone_number }}">{{ user_object.profile.phone_number }}</a>
 | 
			
		||||
        </dd>
 | 
			
		||||
 | 
			
		||||
    <dt class="col-xl-6">{% trans 'address'|capfirst %}</dt>
 | 
			
		||||
    <dd class="col-xl-6">{{ user_object.profile.address }}</dd>
 | 
			
		||||
        <dt class="col-xl-6">{% trans 'address'|capfirst %}</dt>
 | 
			
		||||
        <dd class="col-xl-6">{{ user_object.profile.address }}</dd>
 | 
			
		||||
 | 
			
		||||
        <dt class="col-xl-6">{% trans 'paid'|capfirst %}</dt>
 | 
			
		||||
        <dd class="col-xl-6">{{ user_object.profile.paid|yesno }}</dd>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
    {% if user_object.note and "note.view_note"|has_perm:user_object.note %}
 | 
			
		||||
    <dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
 | 
			
		||||
    <dd class="col-xl-6">{{ user_object.note.balance | pretty_money }}</dd>
 | 
			
		||||
 | 
			
		||||
    <dt class="col-xl-6">{% trans 'paid'|capfirst %}</dt>
 | 
			
		||||
    <dd class="col-xl-6">{{ user_object.profile.paid|yesno }}</dd>
 | 
			
		||||
        <dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
 | 
			
		||||
        <dd class="col-xl-6">{{ user_object.note.balance | pretty_money }}</dd>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
</dl>
 | 
			
		||||
 | 
			
		||||
{% if user_object.pk == user.pk %}
 | 
			
		||||
    <div class="text-center">
 | 
			
		||||
        <a class="small badge badge-secondary" href="{% url 'member:auth_token' %}">
 | 
			
		||||
            <i class="fa fa-cogs"></i>{% trans 'API token' %}
 | 
			
		||||
            <svg class="bi bi-cogs" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
 | 
			
		||||
                <path d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872l-.1-.34zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
            {% trans 'API token' %}
 | 
			
		||||
        </a>
 | 
			
		||||
    </div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 
 | 
			
		||||
@@ -5,32 +5,98 @@ SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<div class="alert alert-info">
 | 
			
		||||
    <h4>À quoi sert un jeton d'authentification ?</h4>
 | 
			
		||||
<div class="row mt-4">
 | 
			
		||||
    <div class="col-xl-6">
 | 
			
		||||
        <div class="card">
 | 
			
		||||
            <div class="card-header text-center">
 | 
			
		||||
                <h3>{% trans "Token authentication" %}</h3>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="card-body">
 | 
			
		||||
                <div class="alert alert-info">
 | 
			
		||||
                    <h4>À quoi sert un jeton d'authentification ?</h4>
 | 
			
		||||
 | 
			
		||||
    Un jeton vous permet de vous connecter à <a href="/api/">l'API de la Note Kfet</a>.<br />
 | 
			
		||||
    Il suffit pour cela d'ajouter en en-tête de vos requêtes <code>Authorization: Token <TOKEN></code>
 | 
			
		||||
    pour pouvoir vous identifier.<br /><br />
 | 
			
		||||
                    Un jeton vous permet de vous connecter à <a href="/api/">l'API de la Note Kfet</a> via votre propre compte
 | 
			
		||||
                    depuis un client externe.<br />
 | 
			
		||||
                    Il suffit pour cela d'ajouter en en-tête de vos requêtes <code>Authorization: Token <TOKEN></code>
 | 
			
		||||
                    pour pouvoir vous identifier.<br /><br />
 | 
			
		||||
 | 
			
		||||
    Une documentation de l'API arrivera ultérieurement.
 | 
			
		||||
                    La documentation de l'API est disponible ici :
 | 
			
		||||
                    <a href="/doc/api/">{{ request.scheme }}://{{ request.get_host }}/doc/api/</a>.
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
                <div class="alert alert-info">
 | 
			
		||||
                    <strong>{%trans  'Token' %} :</strong>
 | 
			
		||||
                    {% if 'show' in request.GET %}
 | 
			
		||||
                    {{ token.key }} (<a href="?">cacher</a>)
 | 
			
		||||
                    {% else %}
 | 
			
		||||
                    <em>caché</em> (<a href="?show">montrer</a>)
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                    <br />
 | 
			
		||||
                    <strong>{%trans  'Created' %} :</strong> {{ token.created }}
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
                <div class="alert alert-warning">
 | 
			
		||||
                    <strong>{% trans "Warning" %} :</strong> regénérer le jeton va révoquer tout accès autorisé à l'API via ce jeton !
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="card-footer text-center">
 | 
			
		||||
                <a href="?regenerate">
 | 
			
		||||
                    <button class="btn btn-primary">{% trans 'Regenerate token' %}</button>
 | 
			
		||||
                </a>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="col-xl-6">
 | 
			
		||||
        <div class="card">
 | 
			
		||||
            <div class="card-header text-center">
 | 
			
		||||
                <h3>{% trans "OAuth2 authentication" %}</h3>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="card-header">
 | 
			
		||||
                <div class="alert alert-info">
 | 
			
		||||
                    <p>
 | 
			
		||||
                        La Note Kfet implémente également le protocole <a href="https://oauth.net/2/">OAuth2</a>, afin de
 | 
			
		||||
                        permettre à des applications tierces d'interagir avec la Note en récoltant des informations
 | 
			
		||||
                        (de connexion par exemple) voir en permettant des modifications à distance, par exemple lorsqu'il
 | 
			
		||||
                        s'agit d'avoir un site marchand sur lequel faire des transactions via la Note Kfet.
 | 
			
		||||
                    </p>
 | 
			
		||||
 | 
			
		||||
                    <p>
 | 
			
		||||
                        L'usage de ce protocole est recommandé pour tout usage non personnel, car permet de mieux cibler
 | 
			
		||||
                        les droits dont on a besoin, en restreignant leur usage par jeton généré.
 | 
			
		||||
                    </p>
 | 
			
		||||
 | 
			
		||||
                    <p>
 | 
			
		||||
                        La documentation vis-à-vis de l'usage de ce protocole est disponible ici :
 | 
			
		||||
                        <a href="/doc/external_services/oauth2/">{{ request.scheme }}://{{ request.get_host }}/doc/external_services/oauth2/</a>.
 | 
			
		||||
                    </p>
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
                Liste des URL à communiquer à votre application :
 | 
			
		||||
 | 
			
		||||
                <ul>
 | 
			
		||||
                    <li>
 | 
			
		||||
                        {% trans "Authorization:" %}
 | 
			
		||||
                        <a href="{% url 'oauth2_provider:authorize' %}">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:authorize' %}</a>
 | 
			
		||||
                    </li>
 | 
			
		||||
                    <li>
 | 
			
		||||
                        {% trans "Token:" %}
 | 
			
		||||
                        <a href="{% url 'oauth2_provider:authorize' %}">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:token' %}</a>
 | 
			
		||||
                    </li>
 | 
			
		||||
                    <li>
 | 
			
		||||
                        {% trans "Revoke Token:" %}
 | 
			
		||||
                        <a href="{% url 'oauth2_provider:authorize' %}">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:revoke-token' %}</a>
 | 
			
		||||
                    </li>
 | 
			
		||||
                    <li>
 | 
			
		||||
                        {% trans "Introspect Token:" %}
 | 
			
		||||
                        <a href="{% url 'oauth2_provider:authorize' %}">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:introspect' %}</a>
 | 
			
		||||
                    </li>
 | 
			
		||||
                </ul>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="card-footer text-center">
 | 
			
		||||
                <a class="btn btn-primary" href="{% url 'oauth2_provider:list' %}">{% trans "Show my applications" %}</a>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="alert alert-info">
 | 
			
		||||
    <strong>{%trans  'Token' %} :</strong>
 | 
			
		||||
    {% if 'show' in request.GET %}
 | 
			
		||||
    {{ token.key }} (<a href="?">cacher</a>)
 | 
			
		||||
    {% else %}
 | 
			
		||||
    <em>caché</em> (<a href="?show">montrer</a>)
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <br />
 | 
			
		||||
    <strong>{%trans  'Created' %} :</strong> {{ token.created }}
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="alert alert-warning">
 | 
			
		||||
    <strong>Attention :</strong> regénérer le jeton va révoquer tout accès autorisé à l'API via ce jeton !
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<a href="?regenerate">
 | 
			
		||||
    <button class="btn btn-primary">{% trans 'Regenerate token' %}</button>
 | 
			
		||||
</a>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -18,7 +18,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
<div class="card bg-light mb-3">
 | 
			
		||||
    <div class="card-header position-relative" id="clubListHeading">
 | 
			
		||||
        <a class="font-weight-bold">
 | 
			
		||||
            <i class="fa fa-users"></i> {% trans "View my memberships" %}
 | 
			
		||||
            <svg class="bi bi-users" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
 | 
			
		||||
                <path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H3zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
            {% trans "View my memberships" %}
 | 
			
		||||
        </a>
 | 
			
		||||
    </div>
 | 
			
		||||
    {% render_table club_list %}
 | 
			
		||||
@@ -29,7 +32,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
        <a class="stretched-link font-weight-bold text-decoration-none"
 | 
			
		||||
            {% if "note.view_note"|has_perm:user_object.note %}
 | 
			
		||||
            href="{% url 'note:transactions' pk=user_object.note.pk %}" {% endif %}>
 | 
			
		||||
            <i class="fa fa-euro"></i> {% trans "Transaction history" %}
 | 
			
		||||
            <svg class="bi bi-euro" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
 | 
			
		||||
                <path d="M4 9.42h1.063C5.4 12.323 7.317 14 10.34 14c.622 0 1.167-.068 1.659-.185v-1.3c-.484.119-1.045.17-1.659.17-2.1 0-3.455-1.198-3.775-3.264h4.017v-.928H6.497v-.936c0-.11 0-.219.008-.329h4.078v-.927H6.618c.388-1.898 1.719-2.985 3.723-2.985.614 0 1.175.05 1.659.177V2.194A6.617 6.617 0 0 0 10.341 2c-2.928 0-4.82 1.569-5.244 4.3H4v.928h1.01v1.265H4v.928z"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
            {% trans "Transaction history" %}
 | 
			
		||||
        </a>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div id="history_list">
 | 
			
		||||
 
 | 
			
		||||
@@ -5,9 +5,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
{% load i18n perms %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
{% if "member.change_profile_registration_valid"|has_perm:user %}
 | 
			
		||||
{% if can_manage_registrations %}
 | 
			
		||||
<a class="btn btn-block btn-secondary mb-3" href="{% url 'registration:future_user_list' %}">
 | 
			
		||||
    <i class="fa fa-user-plus"></i> {% trans "Registrations" %}
 | 
			
		||||
    <svg class="bi bi-user-plus" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
 | 
			
		||||
        <path d="M1 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
 | 
			
		||||
        <path fill-rule="evenodd" d="M13.5 5a.5.5 0 0 1 .5.5V7h1.5a.5.5 0 0 1 0 1H14v1.5a.5.5 0 0 1-1 0V8h-1.5a.5.5 0 0 1 0-1H13V5.5a.5.5 0 0 1 .5-.5z"/>
 | 
			
		||||
    </svg>
 | 
			
		||||
    {% trans "Registrations" %}
 | 
			
		||||
</a>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										0
									
								
								apps/member/templatetags/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								apps/member/templatetags/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										22
									
								
								apps/member/templatetags/memberinfo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								apps/member/templatetags/memberinfo.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from datetime import date
 | 
			
		||||
 | 
			
		||||
from django import template
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
 | 
			
		||||
from ..models import Club, Membership
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def is_member(user, club):
 | 
			
		||||
    if isinstance(user, str):
 | 
			
		||||
        club = User.objects.get(username=user)
 | 
			
		||||
    if isinstance(club, str):
 | 
			
		||||
        club = Club.objects.get(name=club)
 | 
			
		||||
    return Membership.objects\
 | 
			
		||||
        .filter(user=user, club=club, date_start__lte=date.today(), date_end__gte=date.today()).exists()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
register = template.Library()
 | 
			
		||||
register.filter("is_member", is_member)
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
@@ -41,7 +41,7 @@ class TemplateLoggedInTests(TestCase):
 | 
			
		||||
            password="adminadmin",
 | 
			
		||||
            permission_mask=3,
 | 
			
		||||
        ))
 | 
			
		||||
        self.assertRedirects(response, settings.LOGIN_REDIRECT_URL, 302, 200)
 | 
			
		||||
        self.assertRedirects(response, settings.LOGIN_REDIRECT_URL, 302, 302)
 | 
			
		||||
 | 
			
		||||
    def test_logout(self):
 | 
			
		||||
        response = self.client.get(reverse("logout"))
 | 
			
		||||
 
 | 
			
		||||
@@ -1,21 +1,24 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
import hashlib
 | 
			
		||||
import os
 | 
			
		||||
from datetime import date, timedelta
 | 
			
		||||
 | 
			
		||||
from api.tests import TestAPI
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.core.files.uploadedfile import SimpleUploadedFile
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from member.models import Club, Membership, Profile
 | 
			
		||||
from note.models import Alias, NoteSpecial
 | 
			
		||||
from permission.models import Role
 | 
			
		||||
from treasury.models import SogeCredit
 | 
			
		||||
 | 
			
		||||
from ..api.views import ClubViewSet, MembershipViewSet, ProfileViewSet
 | 
			
		||||
from ..models import Club, Membership, Profile
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
Create some users and clubs and test that all pages are rendering properly
 | 
			
		||||
and that memberships are working.
 | 
			
		||||
@@ -205,7 +208,7 @@ class TestMemberships(TestCase):
 | 
			
		||||
                first_name="Toto",
 | 
			
		||||
                bank="Le matelas",
 | 
			
		||||
            ))
 | 
			
		||||
            self.assertRedirects(response, club.get_absolute_url(), 302, 200)
 | 
			
		||||
            self.assertRedirects(response, user.profile.get_absolute_url(), 302, 200)
 | 
			
		||||
 | 
			
		||||
            self.assertTrue(Membership.objects.filter(user=user, club=club).exists())
 | 
			
		||||
 | 
			
		||||
@@ -244,9 +247,9 @@ class TestMemberships(TestCase):
 | 
			
		||||
                first_name="Toto",
 | 
			
		||||
                bank="Bank",
 | 
			
		||||
            ))
 | 
			
		||||
            self.assertRedirects(response, club.get_absolute_url(), 302, 200)
 | 
			
		||||
            self.assertRedirects(response, user.profile.get_absolute_url(), 302, 200)
 | 
			
		||||
 | 
			
		||||
            response = self.client.get(user.profile.get_absolute_url())
 | 
			
		||||
            response = self.client.get(club.get_absolute_url())
 | 
			
		||||
            self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
    def test_auto_join_kfet_when_join_bde_with_soge(self):
 | 
			
		||||
@@ -273,7 +276,7 @@ class TestMemberships(TestCase):
 | 
			
		||||
            first_name="Toto",
 | 
			
		||||
            bank="Société générale",
 | 
			
		||||
        ))
 | 
			
		||||
        self.assertRedirects(response, bde.get_absolute_url(), 302, 200)
 | 
			
		||||
        self.assertRedirects(response, user.profile.get_absolute_url(), 302, 200)
 | 
			
		||||
 | 
			
		||||
        self.assertTrue(Membership.objects.filter(user=user, club=bde).exists())
 | 
			
		||||
        self.assertTrue(Membership.objects.filter(user=user, club=kfet).exists())
 | 
			
		||||
@@ -403,3 +406,46 @@ class TestMemberships(TestCase):
 | 
			
		||||
        self.user.password = "custom_nk15$1$" + salt + "|" + hashed
 | 
			
		||||
        self.user.save()
 | 
			
		||||
        self.assertTrue(self.user.check_password(password))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestMemberAPI(TestAPI):
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        super().setUp()
 | 
			
		||||
 | 
			
		||||
        self.user.profile.registration_valid = True
 | 
			
		||||
        self.user.profile.email_confirmed = True
 | 
			
		||||
        self.user.profile.phone_number = "0600000000"
 | 
			
		||||
        self.user.profile.section = "1A0"
 | 
			
		||||
        self.user.profile.department = "A0"
 | 
			
		||||
        self.user.profile.address = "Earth"
 | 
			
		||||
        self.user.profile.save()
 | 
			
		||||
 | 
			
		||||
        self.club = Club.objects.create(
 | 
			
		||||
            name="totoclub",
 | 
			
		||||
            parent_club=Club.objects.get(name="BDE"),
 | 
			
		||||
            membership_start=date(year=1970, month=1, day=1),
 | 
			
		||||
            membership_end=date(year=2040, month=1, day=1),
 | 
			
		||||
            membership_duration=365 * 10,
 | 
			
		||||
        )
 | 
			
		||||
        self.bde_membership = Membership.objects.create(user=self.user, club=Club.objects.get(name="BDE"))
 | 
			
		||||
        self.membership = Membership.objects.create(user=self.user, club=self.club)
 | 
			
		||||
        self.membership.roles.add(Role.objects.get(name="Bureau de club"))
 | 
			
		||||
        self.membership.save()
 | 
			
		||||
 | 
			
		||||
    def test_club_api(self):
 | 
			
		||||
        """
 | 
			
		||||
        Load Club API page and test all filters and permissions
 | 
			
		||||
        """
 | 
			
		||||
        self.check_viewset(ClubViewSet, "/api/members/club/")
 | 
			
		||||
 | 
			
		||||
    def test_profile_api(self):
 | 
			
		||||
        """
 | 
			
		||||
        Load Profile API page and test all filters and permissions
 | 
			
		||||
        """
 | 
			
		||||
        self.check_viewset(ProfileViewSet, "/api/members/profile/")
 | 
			
		||||
 | 
			
		||||
    def test_membership_api(self):
 | 
			
		||||
        """
 | 
			
		||||
        Load Membership API page and test all filters and permissions
 | 
			
		||||
        """
 | 
			
		||||
        self.check_viewset(MembershipViewSet, "/api/members/membership/")
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.urls import path
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from datetime import timedelta, date
 | 
			
		||||
@@ -21,7 +21,7 @@ from rest_framework.authtoken.models import Token
 | 
			
		||||
from note.models import Alias, NoteUser
 | 
			
		||||
from note.models.transactions import Transaction, SpecialTransaction
 | 
			
		||||
from note.tables import HistoryTable, AliasTable
 | 
			
		||||
from note_kfet.middlewares import _set_current_user_and_ip
 | 
			
		||||
from note_kfet.middlewares import _set_current_request
 | 
			
		||||
from permission.backends import PermissionBackend
 | 
			
		||||
from permission.models import Role
 | 
			
		||||
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
 | 
			
		||||
@@ -41,7 +41,8 @@ class CustomLoginView(LoginView):
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        logout(self.request)
 | 
			
		||||
        _set_current_user_and_ip(form.get_user(), self.request.session, None)
 | 
			
		||||
        self.request.user = form.get_user()
 | 
			
		||||
        _set_current_request(self.request)
 | 
			
		||||
        self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
@@ -70,10 +71,11 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
 | 
			
		||||
        form.fields['email'].required = True
 | 
			
		||||
        form.fields['email'].help_text = _("This address must be valid.")
 | 
			
		||||
 | 
			
		||||
        context['profile_form'] = self.profile_form(instance=context['user_object'].profile,
 | 
			
		||||
                                                    data=self.request.POST if self.request.POST else None)
 | 
			
		||||
        if not self.object.profile.report_frequency:
 | 
			
		||||
            del context['profile_form'].fields["last_report"]
 | 
			
		||||
        if PermissionBackend.check_perm(self.request, "member.change_profile", context['user_object'].profile):
 | 
			
		||||
            context['profile_form'] = self.profile_form(instance=context['user_object'].profile,
 | 
			
		||||
                                                        data=self.request.POST if self.request.POST else None)
 | 
			
		||||
            if not self.object.profile.report_frequency:
 | 
			
		||||
                del context['profile_form'].fields["last_report"]
 | 
			
		||||
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
@@ -152,13 +154,17 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
 | 
			
		||||
        history_list = \
 | 
			
		||||
            Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note))\
 | 
			
		||||
            .order_by("-created_at")\
 | 
			
		||||
            .filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view"))
 | 
			
		||||
            .filter(PermissionBackend.filter_queryset(self.request, Transaction, "view"))
 | 
			
		||||
        history_table = HistoryTable(history_list, prefix='transaction-')
 | 
			
		||||
        history_table.paginate(per_page=20, page=self.request.GET.get("transaction-page", 1))
 | 
			
		||||
        context['history_list'] = history_table
 | 
			
		||||
 | 
			
		||||
        club_list = Membership.objects.filter(user=user, date_end__gte=date.today())\
 | 
			
		||||
            .filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view"))
 | 
			
		||||
        club_list = Membership.objects.filter(user=user, date_end__gte=date.today() - timedelta(days=15))\
 | 
			
		||||
            .filter(PermissionBackend.filter_queryset(self.request, Membership, "view"))\
 | 
			
		||||
            .order_by("club__name", "-date_start")
 | 
			
		||||
        # Display only the most recent membership
 | 
			
		||||
        club_list = club_list.distinct("club__name")\
 | 
			
		||||
            if settings.DATABASES["default"]["ENGINE"] == 'django.db.backends.postgresql' else club_list
 | 
			
		||||
        membership_table = MembershipTable(data=club_list, prefix='membership-')
 | 
			
		||||
        membership_table.paginate(per_page=10, page=self.request.GET.get("membership-page", 1))
 | 
			
		||||
        context['club_list'] = membership_table
 | 
			
		||||
@@ -166,23 +172,25 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
 | 
			
		||||
        # Check permissions to see if the authenticated user can lock/unlock the note
 | 
			
		||||
        with transaction.atomic():
 | 
			
		||||
            modified_note = NoteUser.objects.get(pk=user.note.pk)
 | 
			
		||||
            # Don't log these tests
 | 
			
		||||
            modified_note._no_signal = True
 | 
			
		||||
            modified_note.is_active = True
 | 
			
		||||
            modified_note.inactivity_reason = 'manual'
 | 
			
		||||
            context["can_lock_note"] = user.note.is_active and PermissionBackend\
 | 
			
		||||
                                           .check_perm(self.request.user, "note.change_noteuser_is_active",
 | 
			
		||||
                                                       modified_note)
 | 
			
		||||
                                           .check_perm(self.request, "note.change_noteuser_is_active", modified_note)
 | 
			
		||||
            old_note = NoteUser.objects.select_for_update().get(pk=user.note.pk)
 | 
			
		||||
            modified_note.inactivity_reason = 'forced'
 | 
			
		||||
            modified_note._force_save = True
 | 
			
		||||
            modified_note.save()
 | 
			
		||||
            context["can_force_lock"] = user.note.is_active and PermissionBackend\
 | 
			
		||||
                .check_perm(self.request.user, "note.change_note_is_active", modified_note)
 | 
			
		||||
                .check_perm(self.request, "note.change_note_is_active", modified_note)
 | 
			
		||||
            old_note._force_save = True
 | 
			
		||||
            old_note._no_signal = True
 | 
			
		||||
            old_note.save()
 | 
			
		||||
            modified_note.refresh_from_db()
 | 
			
		||||
            modified_note.is_active = True
 | 
			
		||||
            context["can_unlock_note"] = not user.note.is_active and PermissionBackend\
 | 
			
		||||
                .check_perm(self.request.user, "note.change_note_is_active", modified_note)
 | 
			
		||||
                .check_perm(self.request, "note.change_note_is_active", modified_note)
 | 
			
		||||
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
@@ -227,6 +235,13 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
 | 
			
		||||
 | 
			
		||||
        return qs
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
        pre_registered_users = User.objects.filter(PermissionBackend.filter_queryset(self.request, User, "view"))\
 | 
			
		||||
            .filter(profile__registration_valid=False)
 | 
			
		||||
        context["can_manage_registrations"] = pre_registered_users.exists()
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
 | 
			
		||||
    """
 | 
			
		||||
@@ -240,9 +255,9 @@ class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
        note = context['object'].note
 | 
			
		||||
        context["aliases"] = AliasTable(note.alias_set.filter(PermissionBackend
 | 
			
		||||
                                                              .filter_queryset(self.request.user, Alias, "view")).all())
 | 
			
		||||
        context["can_create"] = PermissionBackend.check_perm(self.request.user, "note.add_alias", Alias(
 | 
			
		||||
        context["aliases"] = AliasTable(
 | 
			
		||||
            note.alias.filter(PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct().all())
 | 
			
		||||
        context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
 | 
			
		||||
            note=context["object"].note,
 | 
			
		||||
            name="",
 | 
			
		||||
            normalized_name="",
 | 
			
		||||
@@ -367,7 +382,7 @@ class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
        context["can_add_club"] = PermissionBackend.check_perm(self.request.user, "member.add_club", Club(
 | 
			
		||||
        context["can_add_club"] = PermissionBackend.check_perm(self.request, "member.add_club", Club(
 | 
			
		||||
            name="",
 | 
			
		||||
            email="club@example.com",
 | 
			
		||||
        ))
 | 
			
		||||
@@ -389,15 +404,16 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
        club = context["club"]
 | 
			
		||||
        if PermissionBackend.check_perm(self.request.user, "member.change_club_membership_start", club):
 | 
			
		||||
        if PermissionBackend.check_perm(self.request, "member.change_club_membership_start", club):
 | 
			
		||||
            club.update_membership_dates()
 | 
			
		||||
        # managers list
 | 
			
		||||
        managers = Membership.objects.filter(club=self.object, roles__name="Bureau de club")\
 | 
			
		||||
        managers = Membership.objects.filter(club=self.object, roles__name="Bureau de club",
 | 
			
		||||
                                             date_start__lte=date.today(), date_end__gte=date.today())\
 | 
			
		||||
            .order_by('user__last_name').all()
 | 
			
		||||
        context["managers"] = ClubManagerTable(data=managers, prefix="managers-")
 | 
			
		||||
        # transaction history
 | 
			
		||||
        club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note))\
 | 
			
		||||
            .filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view"))\
 | 
			
		||||
            .filter(PermissionBackend.filter_queryset(self.request, Transaction, "view"))\
 | 
			
		||||
            .order_by('-created_at')
 | 
			
		||||
        history_table = HistoryTable(club_transactions, prefix="history-")
 | 
			
		||||
        history_table.paginate(per_page=20, page=self.request.GET.get('history-page', 1))
 | 
			
		||||
@@ -405,8 +421,12 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
 | 
			
		||||
        # member list
 | 
			
		||||
        club_member = Membership.objects.filter(
 | 
			
		||||
            club=club,
 | 
			
		||||
            date_end__gte=date.today(),
 | 
			
		||||
        ).filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view"))
 | 
			
		||||
            date_end__gte=date.today() - timedelta(days=15),
 | 
			
		||||
        ).filter(PermissionBackend.filter_queryset(self.request, Membership, "view"))\
 | 
			
		||||
            .order_by("user__username", "-date_start")
 | 
			
		||||
        # Display only the most recent membership
 | 
			
		||||
        club_member = club_member.distinct("user__username")\
 | 
			
		||||
            if settings.DATABASES["default"]["ENGINE"] == 'django.db.backends.postgresql' else club_member
 | 
			
		||||
 | 
			
		||||
        membership_table = MembershipTable(data=club_member, prefix="membership-")
 | 
			
		||||
        membership_table.paginate(per_page=5, page=self.request.GET.get('membership-page', 1))
 | 
			
		||||
@@ -438,9 +458,9 @@ class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
        note = context['object'].note
 | 
			
		||||
        context["aliases"] = AliasTable(note.alias_set.filter(PermissionBackend
 | 
			
		||||
                                                              .filter_queryset(self.request.user, Alias, "view")).all())
 | 
			
		||||
        context["can_create"] = PermissionBackend.check_perm(self.request.user, "note.add_alias", Alias(
 | 
			
		||||
        context["aliases"] = AliasTable(note.alias.filter(
 | 
			
		||||
            PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct().all())
 | 
			
		||||
        context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
 | 
			
		||||
            note=context["object"].note,
 | 
			
		||||
            name="",
 | 
			
		||||
            normalized_name="",
 | 
			
		||||
@@ -515,7 +535,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
 | 
			
		||||
        form = context['form']
 | 
			
		||||
 | 
			
		||||
        if "club_pk" in self.kwargs:  # We create a new membership.
 | 
			
		||||
            club = Club.objects.filter(PermissionBackend.filter_queryset(self.request.user, Club, "view"))\
 | 
			
		||||
            club = Club.objects.filter(PermissionBackend.filter_queryset(self.request, Club, "view"))\
 | 
			
		||||
                .get(pk=self.kwargs["club_pk"], weiclub=None)
 | 
			
		||||
            form.fields['credit_amount'].initial = club.membership_fee_paid
 | 
			
		||||
            # Ensure that the user is member of the parent club and all its the family tree.
 | 
			
		||||
@@ -605,11 +625,11 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
 | 
			
		||||
        # Retrieve form data
 | 
			
		||||
        credit_type = form.cleaned_data["credit_type"]
 | 
			
		||||
        credit_amount = form.cleaned_data["credit_amount"]
 | 
			
		||||
        last_name = form.cleaned_data["last_name"]
 | 
			
		||||
        first_name = form.cleaned_data["first_name"]
 | 
			
		||||
        bank = form.cleaned_data["bank"]
 | 
			
		||||
        soge = form.cleaned_data["soge"] and not user.profile.soge and (club.name == "BDE" or club.name == "Kfet")
 | 
			
		||||
 | 
			
		||||
        if not credit_type:
 | 
			
		||||
            credit_amount = 0
 | 
			
		||||
 | 
			
		||||
        if not soge and user.note.balance + credit_amount < fee and not Membership.objects.filter(
 | 
			
		||||
                club__name="Kfet",
 | 
			
		||||
                user=user,
 | 
			
		||||
@@ -631,6 +651,15 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
 | 
			
		||||
            form.add_error('user', _('User is already a member of the club'))
 | 
			
		||||
            error = True
 | 
			
		||||
 | 
			
		||||
        # Must join the parent club before joining this club, except for the Kfet club where it can be at the same time.
 | 
			
		||||
        if club.name != "Kfet" and club.parent_club and not Membership.objects.filter(
 | 
			
		||||
                user=form.instance.user,
 | 
			
		||||
                club=club.parent_club,
 | 
			
		||||
                date_start__gte=club.parent_club.membership_start,
 | 
			
		||||
        ).exists():
 | 
			
		||||
            form.add_error('user', _('User is not a member of the parent club') + ' ' + club.parent_club.name)
 | 
			
		||||
            error = True
 | 
			
		||||
 | 
			
		||||
        if club.membership_start and form.instance.date_start < club.membership_start:
 | 
			
		||||
            form.add_error('user', _("The membership must start after {:%m-%d-%Y}.")
 | 
			
		||||
                           .format(form.instance.club.membership_start))
 | 
			
		||||
@@ -641,15 +670,9 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
 | 
			
		||||
                           .format(form.instance.club.membership_end))
 | 
			
		||||
            error = True
 | 
			
		||||
 | 
			
		||||
        if credit_amount:
 | 
			
		||||
            if not last_name or not first_name or (not bank and credit_type.special_type == "Chèque"):
 | 
			
		||||
                if not last_name:
 | 
			
		||||
                    form.add_error('last_name', _("This field is required."))
 | 
			
		||||
                if not first_name:
 | 
			
		||||
                    form.add_error('first_name', _("This field is required."))
 | 
			
		||||
                if not bank and credit_type.special_type == "Chèque":
 | 
			
		||||
                    form.add_error('bank', _("This field is required."))
 | 
			
		||||
                return self.form_invalid(form)
 | 
			
		||||
        if credit_amount and not SpecialTransaction.validate_payment_form(form):
 | 
			
		||||
            # Check that special information for payment are filled
 | 
			
		||||
            error = True
 | 
			
		||||
 | 
			
		||||
        return not error
 | 
			
		||||
 | 
			
		||||
@@ -660,9 +683,10 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
 | 
			
		||||
        """
 | 
			
		||||
        # Get the club that is concerned by the membership
 | 
			
		||||
        if "club_pk" in self.kwargs:  # get from url of new membership
 | 
			
		||||
            club = Club.objects.filter(PermissionBackend.filter_queryset(self.request.user, Club, "view")) \
 | 
			
		||||
            club = Club.objects.filter(PermissionBackend.filter_queryset(self.request, Club, "view")) \
 | 
			
		||||
                .get(pk=self.kwargs["club_pk"])
 | 
			
		||||
            user = form.instance.user
 | 
			
		||||
            old_membership = None
 | 
			
		||||
        else:  # get from url for renewal
 | 
			
		||||
            old_membership = self.get_queryset().get(pk=self.kwargs["pk"])
 | 
			
		||||
            club = old_membership.club
 | 
			
		||||
@@ -710,6 +734,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
 | 
			
		||||
            # When we renew the BDE membership, we update the profile section
 | 
			
		||||
            # that should happens at least once a year.
 | 
			
		||||
            user.profile.section = user.profile.section_generated
 | 
			
		||||
            user.profile._force_save = True
 | 
			
		||||
            user.profile.save()
 | 
			
		||||
 | 
			
		||||
        # Credit note before the membership is created.
 | 
			
		||||
@@ -737,6 +762,9 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
 | 
			
		||||
        member_role = Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all() \
 | 
			
		||||
            if club.name == "BDE" else Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all() \
 | 
			
		||||
            if club.name == "Kfet"else Role.objects.filter(name="Membre de club").all()
 | 
			
		||||
        # Set the same roles as before
 | 
			
		||||
        if old_membership:
 | 
			
		||||
            member_role = member_role.union(old_membership.roles.all())
 | 
			
		||||
        form.instance.roles.set(member_role)
 | 
			
		||||
        form.instance._force_save = True
 | 
			
		||||
        form.instance.save()
 | 
			
		||||
@@ -774,7 +802,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
 | 
			
		||||
        return ret
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self):
 | 
			
		||||
        return reverse_lazy('member:club_detail', kwargs={'pk': self.object.club.id})
 | 
			
		||||
        return reverse_lazy('member:user_detail', kwargs={'pk': self.object.user.id})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ClubManageRolesView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
 | 
			
		||||
@@ -839,7 +867,7 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
        club = Club.objects.filter(
 | 
			
		||||
            PermissionBackend.filter_queryset(self.request.user, Club, "view")
 | 
			
		||||
            PermissionBackend.filter_queryset(self.request, Club, "view")
 | 
			
		||||
        ).get(pk=self.kwargs["pk"])
 | 
			
		||||
        context["club"] = club
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
default_app_config = 'note.apps.NoteConfig'
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
@@ -8,7 +8,7 @@ from rest_framework.exceptions import ValidationError
 | 
			
		||||
from rest_polymorphic.serializers import PolymorphicSerializer
 | 
			
		||||
from member.api.serializers import MembershipSerializer
 | 
			
		||||
from member.models import Membership
 | 
			
		||||
from note_kfet.middlewares import get_current_authenticated_user
 | 
			
		||||
from note_kfet.middlewares import get_current_request
 | 
			
		||||
from permission.backends import PermissionBackend
 | 
			
		||||
from rest_framework.utils import model_meta
 | 
			
		||||
 | 
			
		||||
@@ -126,7 +126,7 @@ class ConsumerSerializer(serializers.ModelSerializer):
 | 
			
		||||
        """
 | 
			
		||||
        # If the user has no right to see the note, then we only display the note identifier
 | 
			
		||||
        return NotePolymorphicSerializer().to_representation(obj.note)\
 | 
			
		||||
            if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", obj.note)\
 | 
			
		||||
            if PermissionBackend.check_perm(get_current_request(), "note.view_note", obj.note)\
 | 
			
		||||
            else dict(
 | 
			
		||||
            id=obj.note.id,
 | 
			
		||||
            name=str(obj.note),
 | 
			
		||||
@@ -142,7 +142,7 @@ class ConsumerSerializer(serializers.ModelSerializer):
 | 
			
		||||
    def get_membership(self, obj):
 | 
			
		||||
        if isinstance(obj.note, NoteUser):
 | 
			
		||||
            memberships = Membership.objects.filter(
 | 
			
		||||
                PermissionBackend.filter_queryset(get_current_authenticated_user(), Membership, "view")).filter(
 | 
			
		||||
                PermissionBackend.filter_queryset(get_current_request(), Membership, "view")).filter(
 | 
			
		||||
                user=obj.note.user,
 | 
			
		||||
                club=2,  # Kfet
 | 
			
		||||
            ).order_by("-date_start")
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from .views import NotePolymorphicViewSet, AliasViewSet, ConsumerViewSet, \
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
@@ -10,34 +11,40 @@ from rest_framework import viewsets
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework import status
 | 
			
		||||
from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet
 | 
			
		||||
from note_kfet.middlewares import get_current_session
 | 
			
		||||
from permission.backends import PermissionBackend
 | 
			
		||||
 | 
			
		||||
from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\
 | 
			
		||||
    TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer
 | 
			
		||||
from ..models.notes import Note, Alias
 | 
			
		||||
from ..models.notes import Note, Alias, NoteUser, NoteClub, NoteSpecial
 | 
			
		||||
from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NotePolymorphicViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    """
 | 
			
		||||
    REST API View set.
 | 
			
		||||
    The djangorestframework plugin will get all `Note` objects (with polymorhism), serialize it to JSON with the given serializer,
 | 
			
		||||
    The djangorestframework plugin will get all `Note` objects (with polymorhism),
 | 
			
		||||
    serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/note/note/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = Note.objects.all()
 | 
			
		||||
    queryset = Note.objects.order_by('id')
 | 
			
		||||
    serializer_class = NotePolymorphicSerializer
 | 
			
		||||
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
 | 
			
		||||
    filterset_fields = ['polymorphic_ctype', 'is_active', ]
 | 
			
		||||
    search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model', ]
 | 
			
		||||
    ordering_fields = ['alias__name', 'alias__normalized_name']
 | 
			
		||||
    filterset_fields = ['alias__name', 'polymorphic_ctype', 'is_active', 'balance', 'last_negative', 'created_at', ]
 | 
			
		||||
    search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model',
 | 
			
		||||
                     '$noteuser__user__last_name', '$noteuser__user__first_name', '$noteuser__user__email',
 | 
			
		||||
                     '$noteuser__user__email', '$noteclub__club__email', ]
 | 
			
		||||
    ordering_fields = ['alias__name', 'alias__normalized_name', 'balance', 'created_at', ]
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        """
 | 
			
		||||
        Parse query and apply filters.
 | 
			
		||||
        :return: The filtered set of requested notes
 | 
			
		||||
        """
 | 
			
		||||
        queryset = super().get_queryset().distinct()
 | 
			
		||||
        queryset = self.queryset.filter(PermissionBackend.filter_queryset(self.request, Note, "view")
 | 
			
		||||
                                        | PermissionBackend.filter_queryset(self.request, NoteUser, "view")
 | 
			
		||||
                                        | PermissionBackend.filter_queryset(self.request, NoteClub, "view")
 | 
			
		||||
                                        | PermissionBackend.filter_queryset(self.request, NoteSpecial, "view"))\
 | 
			
		||||
            .distinct()
 | 
			
		||||
 | 
			
		||||
        alias = self.request.query_params.get("alias", ".*")
 | 
			
		||||
        queryset = queryset.filter(
 | 
			
		||||
@@ -55,18 +62,19 @@ class AliasViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/aliases/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = Alias.objects.all()
 | 
			
		||||
    queryset = Alias.objects
 | 
			
		||||
    serializer_class = AliasSerializer
 | 
			
		||||
    filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
 | 
			
		||||
    search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
 | 
			
		||||
    filterset_fields = ['note']
 | 
			
		||||
    ordering_fields = ['name', 'normalized_name']
 | 
			
		||||
    filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user',
 | 
			
		||||
                        'note__noteclub__club', 'note__polymorphic_ctype__model', ]
 | 
			
		||||
    ordering_fields = ['name', 'normalized_name', ]
 | 
			
		||||
 | 
			
		||||
    def get_serializer_class(self):
 | 
			
		||||
        serializer_class = self.serializer_class
 | 
			
		||||
        if self.request.method in ['PUT', 'PATCH']:
 | 
			
		||||
            # alias owner cannot be change once establish
 | 
			
		||||
            setattr(serializer_class.Meta, 'read_only_fields', ('note',))
 | 
			
		||||
            serializer_class.Meta.read_only_fields = ('note',)
 | 
			
		||||
        return serializer_class
 | 
			
		||||
 | 
			
		||||
    def destroy(self, request, *args, **kwargs):
 | 
			
		||||
@@ -74,7 +82,7 @@ class AliasViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
        try:
 | 
			
		||||
            self.perform_destroy(instance)
 | 
			
		||||
        except ValidationError as e:
 | 
			
		||||
            return Response({e.code: e.message}, status.HTTP_400_BAD_REQUEST)
 | 
			
		||||
            return Response({e.code: str(e)}, status.HTTP_400_BAD_REQUEST)
 | 
			
		||||
        return Response(status=status.HTTP_204_NO_CONTENT)
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
@@ -106,12 +114,13 @@ class AliasViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
 | 
			
		||||
    queryset = Alias.objects.all()
 | 
			
		||||
    queryset = Alias.objects
 | 
			
		||||
    serializer_class = ConsumerSerializer
 | 
			
		||||
    filter_backends = [SearchFilter, OrderingFilter, DjangoFilterBackend]
 | 
			
		||||
    search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
 | 
			
		||||
    filterset_fields = ['note']
 | 
			
		||||
    ordering_fields = ['name', 'normalized_name']
 | 
			
		||||
    filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user',
 | 
			
		||||
                        'note__noteclub__club', 'note__polymorphic_ctype__model', ]
 | 
			
		||||
    ordering_fields = ['name', 'normalized_name', ]
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        """
 | 
			
		||||
@@ -125,23 +134,31 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
 | 
			
		||||
            if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' else queryset
 | 
			
		||||
 | 
			
		||||
        alias = self.request.query_params.get("alias", None)
 | 
			
		||||
        # Check if this is a valid regex. If not, we won't check regex
 | 
			
		||||
        try:
 | 
			
		||||
            re.compile(alias)
 | 
			
		||||
            valid_regex = True
 | 
			
		||||
        except (re.error, TypeError):
 | 
			
		||||
            valid_regex = False
 | 
			
		||||
        suffix = '__iregex' if valid_regex else '__istartswith'
 | 
			
		||||
        alias_prefix = '^' if valid_regex else ''
 | 
			
		||||
        queryset = queryset.prefetch_related('note')
 | 
			
		||||
 | 
			
		||||
        if alias:
 | 
			
		||||
            # We match first an alias if it is matched without normalization,
 | 
			
		||||
            # then if the normalized pattern matches a normalized alias.
 | 
			
		||||
            queryset = queryset.filter(
 | 
			
		||||
                name__iregex="^" + alias
 | 
			
		||||
                **{f'name{suffix}': alias_prefix + alias}
 | 
			
		||||
            ).union(
 | 
			
		||||
                queryset.filter(
 | 
			
		||||
                    Q(normalized_name__iregex="^" + Alias.normalize(alias))
 | 
			
		||||
                    & ~Q(name__iregex="^" + alias)
 | 
			
		||||
                    Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
 | 
			
		||||
                    & ~Q(**{f'name{suffix}': alias_prefix + alias})
 | 
			
		||||
                ),
 | 
			
		||||
                all=True).union(
 | 
			
		||||
                queryset.filter(
 | 
			
		||||
                    Q(normalized_name__iregex="^" + alias.lower())
 | 
			
		||||
                    & ~Q(normalized_name__iregex="^" + Alias.normalize(alias))
 | 
			
		||||
                    & ~Q(name__iregex="^" + alias)
 | 
			
		||||
                    Q(**{f'normalized_name{suffix}': alias_prefix + alias.lower()})
 | 
			
		||||
                    & ~Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
 | 
			
		||||
                    & ~Q(**{f'name{suffix}': alias_prefix + alias})
 | 
			
		||||
                ),
 | 
			
		||||
                all=True)
 | 
			
		||||
 | 
			
		||||
@@ -157,10 +174,11 @@ class TemplateCategoryViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    The djangorestframework plugin will get all `TemplateCategory` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/note/transaction/category/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = TemplateCategory.objects.order_by("name").all()
 | 
			
		||||
    queryset = TemplateCategory.objects.order_by('name')
 | 
			
		||||
    serializer_class = TemplateCategorySerializer
 | 
			
		||||
    filter_backends = [SearchFilter]
 | 
			
		||||
    search_fields = ['$name', ]
 | 
			
		||||
    filter_backends = [DjangoFilterBackend, SearchFilter]
 | 
			
		||||
    filterset_fields = ['name', 'templates', 'templates__name']
 | 
			
		||||
    search_fields = ['$name', '$templates__name', ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransactionTemplateViewSet(viewsets.ModelViewSet):
 | 
			
		||||
@@ -169,11 +187,12 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet):
 | 
			
		||||
    The djangorestframework plugin will get all `TransactionTemplate` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/note/transaction/template/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = TransactionTemplate.objects.order_by("name").all()
 | 
			
		||||
    queryset = TransactionTemplate.objects.order_by('name')
 | 
			
		||||
    serializer_class = TransactionTemplateSerializer
 | 
			
		||||
    filter_backends = [SearchFilter, DjangoFilterBackend]
 | 
			
		||||
    filterset_fields = ['name', 'amount', 'display', 'category', ]
 | 
			
		||||
    search_fields = ['$name', ]
 | 
			
		||||
    filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
 | 
			
		||||
    filterset_fields = ['name', 'amount', 'display', 'category', 'category__name', ]
 | 
			
		||||
    search_fields = ['$name', '$category__name', ]
 | 
			
		||||
    ordering_fields = ['amount', ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransactionViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
@@ -182,16 +201,18 @@ class TransactionViewSet(ReadProtectedModelViewSet):
 | 
			
		||||
    The djangorestframework plugin will get all `Transaction` objects, serialize it to JSON with the given serializer,
 | 
			
		||||
    then render it on /api/note/transaction/transaction/
 | 
			
		||||
    """
 | 
			
		||||
    queryset = Transaction.objects.order_by("-created_at").all()
 | 
			
		||||
    queryset = Transaction.objects.order_by('-created_at')
 | 
			
		||||
    serializer_class = TransactionPolymorphicSerializer
 | 
			
		||||
    filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
 | 
			
		||||
    filterset_fields = ["source", "source_alias", "destination", "destination_alias", "quantity",
 | 
			
		||||
                        "polymorphic_ctype", "amount", "created_at", ]
 | 
			
		||||
    search_fields = ['$reason', ]
 | 
			
		||||
    ordering_fields = ['created_at', 'amount']
 | 
			
		||||
    filterset_fields = ['source', 'source_alias', 'source__alias__name', 'source__alias__normalized_name',
 | 
			
		||||
                        'destination', 'destination_alias', 'destination__alias__name',
 | 
			
		||||
                        'destination__alias__normalized_name', 'quantity', 'polymorphic_ctype', 'amount',
 | 
			
		||||
                        'created_at', 'valid', 'invalidity_reason', ]
 | 
			
		||||
    search_fields = ['$reason', '$source_alias', '$source__alias__name', '$source__alias__normalized_name',
 | 
			
		||||
                     '$destination_alias', '$destination__alias__name', '$destination__alias__normalized_name',
 | 
			
		||||
                     '$invalidity_reason', ]
 | 
			
		||||
    ordering_fields = ['created_at', 'amount', ]
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        user = self.request.user
 | 
			
		||||
        get_current_session().setdefault("permission_mask", 42)
 | 
			
		||||
        return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view"))\
 | 
			
		||||
        return self.model.objects.filter(PermissionBackend.filter_queryset(self.request, self.model, "view"))\
 | 
			
		||||
            .order_by("created_at", "id")
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,9 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db.models.signals import post_save, pre_delete
 | 
			
		||||
from django.db.models.signals import pre_delete, pre_save, post_save
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
from . import signals
 | 
			
		||||
@@ -17,6 +17,15 @@ class NoteConfig(AppConfig):
 | 
			
		||||
        """
 | 
			
		||||
        Define app internal signals to interact with other apps
 | 
			
		||||
        """
 | 
			
		||||
        pre_save.connect(
 | 
			
		||||
            signals.pre_save_note,
 | 
			
		||||
            sender="note.noteuser",
 | 
			
		||||
        )
 | 
			
		||||
        pre_save.connect(
 | 
			
		||||
            signals.pre_save_note,
 | 
			
		||||
            sender="note.noteclub",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        post_save.connect(
 | 
			
		||||
            signals.save_user_note,
 | 
			
		||||
            sender=settings.AUTH_USER_MODEL,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										19
									
								
								apps/note/migrations/0005_auto_20210313_1235.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								apps/note/migrations/0005_auto_20210313_1235.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
# Generated by Django 2.2.19 on 2021-03-13 11:35
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('note', '0004_remove_null_tag_on_charfields'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='alias',
 | 
			
		||||
            name='note',
 | 
			
		||||
            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='alias', to='note.Note'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from .notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,9 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
import unicodedata
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.conf.global_settings import DEFAULT_FROM_EMAIL
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django.core.mail import send_mail
 | 
			
		||||
from django.core.validators import RegexValidator
 | 
			
		||||
@@ -159,20 +158,6 @@ class NoteUser(Note):
 | 
			
		||||
    def pretty(self):
 | 
			
		||||
        return _("%(user)s's note") % {'user': str(self.user)}
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        if self.pk and self.balance < 0:
 | 
			
		||||
            old_note = NoteUser.objects.get(pk=self.pk)
 | 
			
		||||
            super().save(*args, **kwargs)
 | 
			
		||||
            if old_note.balance >= 0:
 | 
			
		||||
                # Passage en négatif
 | 
			
		||||
                self.last_negative = timezone.now()
 | 
			
		||||
                self._force_save = True
 | 
			
		||||
                self.save(*args, **kwargs)
 | 
			
		||||
                self.send_mail_negative_balance()
 | 
			
		||||
        else:
 | 
			
		||||
            super().save(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def send_mail_negative_balance(self):
 | 
			
		||||
        plain_text = render_to_string("note/mails/negative_balance.txt", dict(note=self))
 | 
			
		||||
        html = render_to_string("note/mails/negative_balance.html", dict(note=self))
 | 
			
		||||
@@ -201,25 +186,11 @@ class NoteClub(Note):
 | 
			
		||||
    def pretty(self):
 | 
			
		||||
        return _("Note of %(club)s club") % {'club': str(self.club)}
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        if self.pk and self.balance < 0:
 | 
			
		||||
            old_note = NoteClub.objects.get(pk=self.pk)
 | 
			
		||||
            super().save(*args, **kwargs)
 | 
			
		||||
            if old_note.balance >= 0:
 | 
			
		||||
                # Passage en négatif
 | 
			
		||||
                self.last_negative = timezone.now()
 | 
			
		||||
                self._force_save = True
 | 
			
		||||
                self.save(*args, **kwargs)
 | 
			
		||||
                self.send_mail_negative_balance()
 | 
			
		||||
        else:
 | 
			
		||||
            super().save(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def send_mail_negative_balance(self):
 | 
			
		||||
        plain_text = render_to_string("note/mails/negative_balance.txt", dict(note=self))
 | 
			
		||||
        html = render_to_string("note/mails/negative_balance.html", dict(note=self))
 | 
			
		||||
        send_mail("[Note Kfet] Passage en négatif (club {})".format(self.club.name), plain_text, DEFAULT_FROM_EMAIL,
 | 
			
		||||
                  [self.club.email], html_message=html)
 | 
			
		||||
        send_mail("[Note Kfet] Passage en négatif (club {})".format(self.club.name), plain_text,
 | 
			
		||||
                  settings.DEFAULT_FROM_EMAIL, [self.club.email], html_message=html)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NoteSpecial(Note):
 | 
			
		||||
@@ -276,6 +247,7 @@ class Alias(models.Model):
 | 
			
		||||
    note = models.ForeignKey(
 | 
			
		||||
        Note,
 | 
			
		||||
        on_delete=models.PROTECT,
 | 
			
		||||
        related_name="alias",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
@@ -217,10 +217,14 @@ class Transaction(PolymorphicModel):
 | 
			
		||||
            # When source == destination, no money is transferred and no transaction is created
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        self.source = Note.objects.select_for_update().get(pk=self.source_id)
 | 
			
		||||
        self.destination = Note.objects.select_for_update().get(pk=self.destination_id)
 | 
			
		||||
 | 
			
		||||
        # Check that the amounts stay between big integer bounds
 | 
			
		||||
        diff_source, diff_dest = self.validate()
 | 
			
		||||
 | 
			
		||||
        if not self.source.is_active or not self.destination.is_active:
 | 
			
		||||
        if not (hasattr(self, '_force_save') and self._force_save) \
 | 
			
		||||
                and (not self.source.is_active or not self.destination.is_active):
 | 
			
		||||
            raise ValidationError(_("The transaction can't be saved since the source note "
 | 
			
		||||
                                    "or the destination note is not active."))
 | 
			
		||||
 | 
			
		||||
@@ -268,7 +272,7 @@ class RecurrentTransaction(Transaction):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        if self.template.destination != self.destination:
 | 
			
		||||
        if self.template.destination != self.destination and not (hasattr(self, '_force_save') and self._force_save):
 | 
			
		||||
            raise ValidationError(
 | 
			
		||||
                _("The destination of this transaction must equal to the destination of the template."))
 | 
			
		||||
        return super().clean()
 | 
			
		||||
@@ -329,6 +333,36 @@ class SpecialTransaction(Transaction):
 | 
			
		||||
        self.clean()
 | 
			
		||||
        super().save(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def validate_payment_form(form):
 | 
			
		||||
        """
 | 
			
		||||
        Ensure that last name and first name are filled for a form that creates a SpecialTransaction,
 | 
			
		||||
        and check that if the user pays with a check, then the bank field is filled.
 | 
			
		||||
 | 
			
		||||
        Return True iff there is no error.
 | 
			
		||||
        Whenever there is an error, they are inserted in the form errors.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        credit_type = form.cleaned_data["credit_type"]
 | 
			
		||||
        last_name = form.cleaned_data["last_name"]
 | 
			
		||||
        first_name = form.cleaned_data["first_name"]
 | 
			
		||||
        bank = form.cleaned_data["bank"]
 | 
			
		||||
 | 
			
		||||
        error = False
 | 
			
		||||
 | 
			
		||||
        if not last_name or not first_name or (not bank and credit_type.special_type == "Chèque"):
 | 
			
		||||
            if not last_name:
 | 
			
		||||
                form.add_error('last_name', _("This field is required."))
 | 
			
		||||
                error = True
 | 
			
		||||
            if not first_name:
 | 
			
		||||
                form.add_error('first_name', _("This field is required."))
 | 
			
		||||
                error = True
 | 
			
		||||
            if not bank and credit_type.special_type == "Chèque":
 | 
			
		||||
                form.add_error('bank', _("This field is required."))
 | 
			
		||||
                error = True
 | 
			
		||||
 | 
			
		||||
        return not error
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Special transaction")
 | 
			
		||||
        verbose_name_plural = _("Special transactions")
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,8 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def save_user_note(instance, raw, **_kwargs):
 | 
			
		||||
    """
 | 
			
		||||
@@ -25,10 +27,21 @@ def save_club_note(instance, raw, **_kwargs):
 | 
			
		||||
        instance.note.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def pre_save_note(instance, raw, **_kwargs):
 | 
			
		||||
    if not raw and instance.pk and not hasattr(instance, "_no_signal") and instance.balance < 0:
 | 
			
		||||
        from note.models import Note
 | 
			
		||||
        old_note = Note.objects.get(pk=instance.pk)
 | 
			
		||||
        if old_note.balance >= 0:
 | 
			
		||||
            # Passage en négatif
 | 
			
		||||
            instance.last_negative = timezone.now()
 | 
			
		||||
            instance.send_mail_negative_balance()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def delete_transaction(instance, **_kwargs):
 | 
			
		||||
    """
 | 
			
		||||
    Whenever we want to delete a transaction (caution with this), we ensure the transaction is invalid first.
 | 
			
		||||
    """
 | 
			
		||||
    if not hasattr(instance, "_no_signal"):
 | 
			
		||||
        instance.valid = False
 | 
			
		||||
        instance._force_save = True
 | 
			
		||||
        instance.save()
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
// Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
// Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
// SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
// When a transaction is performed, lock the interface to prevent spam clicks.
 | 
			
		||||
@@ -28,7 +28,7 @@ $(document).ready(function () {
 | 
			
		||||
 | 
			
		||||
  // Switching in double consumptions mode should update the layout
 | 
			
		||||
  $('#double_conso').change(function () {
 | 
			
		||||
    $('#consos_list_div').removeClass('d-none')
 | 
			
		||||
    document.getElementById('consos_list_div').classList.remove('d-none')
 | 
			
		||||
    $('#infos_div').attr('class', 'col-sm-5 col-xl-6')
 | 
			
		||||
 | 
			
		||||
    const note_list_obj = $('#note_list')
 | 
			
		||||
@@ -37,7 +37,7 @@ $(document).ready(function () {
 | 
			
		||||
      note_list_obj.html('')
 | 
			
		||||
 | 
			
		||||
      buttons.forEach(function (button) {
 | 
			
		||||
        $('#conso_button_' + button.id).click(function () {
 | 
			
		||||
        document.getElementById(`conso_button_${button.id}`).addEventListener('click', () => {
 | 
			
		||||
          if (LOCK) { return }
 | 
			
		||||
          removeNote(button, 'conso_button', buttons, 'consos_list')()
 | 
			
		||||
        })
 | 
			
		||||
@@ -46,7 +46,7 @@ $(document).ready(function () {
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  $('#single_conso').change(function () {
 | 
			
		||||
    $('#consos_list_div').addClass('d-none')
 | 
			
		||||
    document.getElementById('consos_list_div').classList.add('d-none')
 | 
			
		||||
    $('#infos_div').attr('class', 'col-sm-5 col-md-4')
 | 
			
		||||
 | 
			
		||||
    const consos_list_obj = $('#consos_list')
 | 
			
		||||
@@ -68,9 +68,9 @@ $(document).ready(function () {
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  // Ensure we begin in single consumption. Fix issue with TurboLinks and BootstrapJS
 | 
			
		||||
  $("label[for='double_conso']").removeClass('active')
 | 
			
		||||
  document.querySelector("label[for='double_conso']").classList.remove('active')
 | 
			
		||||
 | 
			
		||||
  $('#consume_all').click(consumeAll)
 | 
			
		||||
  document.getElementById("consume_all").addEventListener('click', consumeAll)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
notes = []
 | 
			
		||||
@@ -127,11 +127,10 @@ function addConso (dest, amount, type, category_id, category_name, template_id,
 | 
			
		||||
      html += li('conso_button_' + button.id, button.name +
 | 
			
		||||
                '<span class="badge badge-dark badge-pill">' + button.quantity + '</span>')
 | 
			
		||||
    })
 | 
			
		||||
    document.getElementById(list).innerHTML = html
 | 
			
		||||
 | 
			
		||||
    $('#' + list).html(html)
 | 
			
		||||
 | 
			
		||||
    buttons.forEach(function (button) {
 | 
			
		||||
      $('#conso_button_' + button.id).click(function () {
 | 
			
		||||
    buttons.forEach((button) => {
 | 
			
		||||
      document.getElementById(`conso_button_${button.id}`).addEventListener('click', () => {
 | 
			
		||||
        if (LOCK) { return }
 | 
			
		||||
        removeNote(button, 'conso_button', buttons, list)()
 | 
			
		||||
      })
 | 
			
		||||
@@ -146,12 +145,13 @@ function reset () {
 | 
			
		||||
  notes_display.length = 0
 | 
			
		||||
  notes.length = 0
 | 
			
		||||
  buttons.length = 0
 | 
			
		||||
  $('#note_list').html('')
 | 
			
		||||
  $('#consos_list').html('')
 | 
			
		||||
  $('#note').val('')
 | 
			
		||||
  $('#note').attr('data-original-title', '').tooltip('hide')
 | 
			
		||||
  $('#profile_pic').attr('src', '/static/member/img/default_picture.png')
 | 
			
		||||
  $('#profile_pic_link').attr('href', '#')
 | 
			
		||||
  document.getElementById('note_list').innerHTML = ''
 | 
			
		||||
  document.getElementById('consos_list').innerHTML = ''
 | 
			
		||||
  document.getElementById('note').value = ''
 | 
			
		||||
  document.getElementById('note').dataset.originTitle = ''
 | 
			
		||||
  $('#note').tooltip('hide')
 | 
			
		||||
  document.getElementById('profile_pic').src = '/static/member/img/default_picture.png'
 | 
			
		||||
  document.getElementById('profile_pic_link').href = '#'
 | 
			
		||||
  refreshHistory()
 | 
			
		||||
  refreshBalance()
 | 
			
		||||
  LOCK = false
 | 
			
		||||
@@ -168,7 +168,7 @@ function consumeAll () {
 | 
			
		||||
  let error = false
 | 
			
		||||
 | 
			
		||||
  if (notes_display.length === 0) {
 | 
			
		||||
    $('#note').addClass('is-invalid')
 | 
			
		||||
    document.getElementById('note').classList.add('is-invalid')
 | 
			
		||||
    $('#note_list').html(li('', '<strong>Ajoutez des émetteurs.</strong>', 'text-danger'))
 | 
			
		||||
    error = true
 | 
			
		||||
  }
 | 
			
		||||
@@ -222,17 +222,15 @@ function consume (source, source_alias, dest, quantity, amount, reason, type, ca
 | 
			
		||||
      if (!isNaN(source.balance)) {
 | 
			
		||||
        const newBalance = source.balance - quantity * amount
 | 
			
		||||
        if (newBalance <= -5000) {
 | 
			
		||||
          addMsg('Attention, La transaction depuis la note ' + source_alias + ' a été réalisée avec ' +
 | 
			
		||||
                        'succès, mais la note émettrice ' + source_alias + ' est en négatif sévère.',
 | 
			
		||||
          'danger', 30000)
 | 
			
		||||
          addMsg(interpolate(gettext('Warning, the transaction from the note %s succeed, ' +
 | 
			
		||||
              'but the emitter note %s is very negative.'), [source_alias, source_alias]), 'danger', 30000)
 | 
			
		||||
        } else if (newBalance < 0) {
 | 
			
		||||
          addMsg('Attention, La transaction depuis la note ' + source_alias + ' a été réalisée avec ' +
 | 
			
		||||
                        'succès, mais la note émettrice ' + source_alias + ' est en négatif.',
 | 
			
		||||
          'warning', 30000)
 | 
			
		||||
          addMsg(interpolate(gettext('Warning, the transaction from the note %s succeed, ' +
 | 
			
		||||
              'but the emitter note %s is negative.'), [source_alias, source_alias]), 'warning', 30000)
 | 
			
		||||
        }
 | 
			
		||||
        if (source.membership && source.membership.date_end < new Date().toISOString()) {
 | 
			
		||||
          addMsg('Attention : la note émettrice ' + source.name + " n'est plus adhérente.",
 | 
			
		||||
            'danger', 30000)
 | 
			
		||||
          addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source_alias]),
 | 
			
		||||
              'danger', 30000)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      reset()
 | 
			
		||||
@@ -253,7 +251,7 @@ function consume (source, source_alias, dest, quantity, amount, reason, type, ca
 | 
			
		||||
          template: template
 | 
			
		||||
        }).done(function () {
 | 
			
		||||
        reset()
 | 
			
		||||
        addMsg("La transaction n'a pas pu être validée pour cause de solde insuffisant.", 'danger', 10000)
 | 
			
		||||
        addMsg(gettext("The transaction couldn't be validated because of insufficient balance."), 'danger', 10000)
 | 
			
		||||
      }).fail(function () {
 | 
			
		||||
        reset()
 | 
			
		||||
        errMsg(e.responseJSON)
 | 
			
		||||
 
 | 
			
		||||
@@ -67,7 +67,11 @@ $(document).ready(function () {
 | 
			
		||||
 | 
			
		||||
      last.quantity = 1
 | 
			
		||||
 | 
			
		||||
      if (!last.note.user) {
 | 
			
		||||
      if (last.note.club) {
 | 
			
		||||
        $('#last_name').val(last.note.name)
 | 
			
		||||
        $('#first_name').val(last.note.name)
 | 
			
		||||
      }
 | 
			
		||||
      else if (!last.note.user) {
 | 
			
		||||
        $.getJSON('/api/note/note/' + last.note.id + '/?format=json', function (note) {
 | 
			
		||||
          last.note.user = note.user
 | 
			
		||||
          $.getJSON('/api/user/' + last.note.user + '/', function (user) {
 | 
			
		||||
@@ -218,6 +222,13 @@ $(document).ready(function () {
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
// Make transfer when pressing Enter on the amount section
 | 
			
		||||
$('#amount, #reason, #last_name, #first_name, #bank').keypress((event) => {
 | 
			
		||||
  if (event.originalEvent.charCode === 13) {
 | 
			
		||||
    $('#btn_transfer').click()
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
$('#btn_transfer').click(function () {
 | 
			
		||||
  if (LOCK) { return }
 | 
			
		||||
 | 
			
		||||
@@ -235,20 +246,20 @@ $('#btn_transfer').click(function () {
 | 
			
		||||
 | 
			
		||||
  if (!amount_field.val() || isNaN(amount_field.val()) || amount_field.val() <= 0) {
 | 
			
		||||
    amount_field.addClass('is-invalid')
 | 
			
		||||
    $('#amount-required').html('<strong>Ce champ est requis et doit comporter un nombre décimal strictement positif.</strong>')
 | 
			
		||||
    $('#amount-required').html('<strong>' + gettext('This field is required and must contain a decimal positive number.') + '</strong>')
 | 
			
		||||
    error = true
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const amount = Math.floor(100 * amount_field.val())
 | 
			
		||||
  const amount = Math.round(100 * amount_field.val())
 | 
			
		||||
  if (amount > 2147483647) {
 | 
			
		||||
    amount_field.addClass('is-invalid')
 | 
			
		||||
    $('#amount-required').html('<strong>Le montant ne doit pas excéder 21474836.47 €.</strong>')
 | 
			
		||||
    $('#amount-required').html('<strong>' + gettext('The amount must stay under 21,474,836.47 €.') + '</strong>')
 | 
			
		||||
    error = true
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!reason_field.val()) {
 | 
			
		||||
  if (!reason_field.val() && $('#type_transfer').is(':checked')) {
 | 
			
		||||
    reason_field.addClass('is-invalid')
 | 
			
		||||
    $('#reason-required').html('<strong>Ce champ est requis.</strong>')
 | 
			
		||||
    $('#reason-required').html('<strong>' + gettext('This field is required.') + '</strong>')
 | 
			
		||||
    error = true
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -274,9 +285,8 @@ $('#btn_transfer').click(function () {
 | 
			
		||||
    [...sources_notes_display].forEach(function (source) {
 | 
			
		||||
      [...dests_notes_display].forEach(function (dest) {
 | 
			
		||||
        if (source.note.id === dest.note.id) {
 | 
			
		||||
          addMsg('Attention : la transaction de ' + pretty_money(amount) + ' de la note ' + source.name +
 | 
			
		||||
                        ' vers la note ' + dest.name + " n'a pas été faite car il s'agit de la même note au départ" +
 | 
			
		||||
                        " et à l'arrivée.", 'warning', 10000)
 | 
			
		||||
          addMsg(interpolate(gettext('Warning: the transaction of %s from %s to %s was not made because ' +
 | 
			
		||||
              'it is the same source and destination note.'), [pretty_money(amount), source.name, dest.name]), 'warning', 10000)
 | 
			
		||||
          LOCK = false
 | 
			
		||||
          return
 | 
			
		||||
        }
 | 
			
		||||
@@ -296,43 +306,35 @@ $('#btn_transfer').click(function () {
 | 
			
		||||
            destination_alias: dest.name
 | 
			
		||||
          }).done(function () {
 | 
			
		||||
          if (source.note.membership && source.note.membership.date_end < new Date().toISOString()) {
 | 
			
		||||
            addMsg('Attention : la note émettrice ' + source.name + " n'est plus adhérente.",
 | 
			
		||||
              'danger', 30000)
 | 
			
		||||
            addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source.name]), 'danger', 30000)
 | 
			
		||||
          }
 | 
			
		||||
          if (dest.note.membership && dest.note.membership.date_end < new Date().toISOString()) {
 | 
			
		||||
            addMsg('Attention : la note destination ' + dest.name + " n'est plus adhérente.",
 | 
			
		||||
              'danger', 30000)
 | 
			
		||||
            addMsg(interpolate(gettext('Warning, the destination note %s is no more a BDE member.'), [dest.name]), 'danger', 30000)
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (!isNaN(source.note.balance)) {
 | 
			
		||||
            const newBalance = source.note.balance - source.quantity * dest.quantity * amount
 | 
			
		||||
            if (newBalance <= -5000) {
 | 
			
		||||
              addMsg('Le transfert de ' +
 | 
			
		||||
                                    pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' +
 | 
			
		||||
                                    source.name + ' vers la note ' + dest.name + ' a été fait avec succès, ' +
 | 
			
		||||
                                    'mais la note émettrice est en négatif sévère.', 'danger', 10000)
 | 
			
		||||
              addMsg(interpolate(gettext('Warning, the transaction of %s from the note %s to the note %s succeed, but the emitter note %s is very negative.'),
 | 
			
		||||
                  [pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name, source.name]), 'danger', 10000)
 | 
			
		||||
              reset()
 | 
			
		||||
              return
 | 
			
		||||
            } else if (newBalance < 0) {
 | 
			
		||||
              addMsg('Le transfert de ' +
 | 
			
		||||
                                    pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' +
 | 
			
		||||
                                    source.name + ' vers la note ' + dest.name + ' a été fait avec succès, ' +
 | 
			
		||||
                                    'mais la note émettrice est en négatif.', 'warning', 10000)
 | 
			
		||||
              addMsg(interpolate(gettext('Warning, the transaction of %s from the note %s to the note %s succeed, but the emitter note %s is negative.'),
 | 
			
		||||
                  [pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name, source.name]), 'danger', 10000)
 | 
			
		||||
              reset()
 | 
			
		||||
              return
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
          addMsg('Le transfert de ' +
 | 
			
		||||
                            pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + source.name +
 | 
			
		||||
                            ' vers la note ' + dest.name + ' a été fait avec succès !', 'success', 10000)
 | 
			
		||||
          addMsg(interpolate(gettext('Transfer of %s from %s to %s succeed!'),
 | 
			
		||||
              [pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name]), 'success', 10000)
 | 
			
		||||
 | 
			
		||||
          reset()
 | 
			
		||||
        }).fail(function (err) { // do it again but valid = false
 | 
			
		||||
          const errObj = JSON.parse(err.responseText)
 | 
			
		||||
          if (errObj.non_field_errors) {
 | 
			
		||||
            addMsg('Le transfert de ' +
 | 
			
		||||
                                pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + source.name +
 | 
			
		||||
                                ' vers la note ' + dest.name + ' a échoué : ' + errObj.non_field_errors, 'danger')
 | 
			
		||||
            addMsg(interpolate(gettext('Transfer of %s from %s to %s failed: %s'),
 | 
			
		||||
                [pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name, errObj.non_field_errors]), 'danger')
 | 
			
		||||
            LOCK = false
 | 
			
		||||
            return
 | 
			
		||||
          }
 | 
			
		||||
@@ -352,17 +354,15 @@ $('#btn_transfer').click(function () {
 | 
			
		||||
              destination: dest.note.id,
 | 
			
		||||
              destination_alias: dest.name
 | 
			
		||||
            }).done(function () {
 | 
			
		||||
            addMsg('Le transfert de ' +
 | 
			
		||||
                                pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + source.name +
 | 
			
		||||
                                ' vers la note ' + dest.name + ' a échoué : Solde insuffisant', 'danger', 10000)
 | 
			
		||||
            addMsg(interpolate(gettext('Transfer of %s from %s to %s failed: %s'),
 | 
			
		||||
                [pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name, gettext('insufficient funds')]), 'danger', 10000)
 | 
			
		||||
            reset()
 | 
			
		||||
          }).fail(function (err) {
 | 
			
		||||
            const errObj = JSON.parse(err.responseText)
 | 
			
		||||
            let error = errObj.detail ? errObj.detail : errObj.non_field_errors
 | 
			
		||||
            if (!error) { error = err.responseText }
 | 
			
		||||
            addMsg('Le transfert de ' +
 | 
			
		||||
                                pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + source.name +
 | 
			
		||||
                                ' vers la note ' + dest.name + ' a échoué : ' + error, 'danger')
 | 
			
		||||
            addMsg(interpolate(gettext('Transfer of %s from %s to %s failed: %s'),
 | 
			
		||||
                [pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name, error]), 'danger')
 | 
			
		||||
            LOCK = false
 | 
			
		||||
          })
 | 
			
		||||
        })
 | 
			
		||||
@@ -388,7 +388,7 @@ $('#btn_transfer').click(function () {
 | 
			
		||||
      alias = sources_notes_display[0].name
 | 
			
		||||
      source_id = user_note.id
 | 
			
		||||
      dest_id = special_note
 | 
			
		||||
      reason = 'Retrait ' + $('#credit_type option:selected').text().toLowerCase()
 | 
			
		||||
      reason = 'Retrait ' + $('#debit_type option:selected').text().toLowerCase()
 | 
			
		||||
      if (given_reason.length > 0) { reason += ' (' + given_reason + ')' }
 | 
			
		||||
    }
 | 
			
		||||
    $.post('/api/note/transaction/transaction/',
 | 
			
		||||
@@ -408,14 +408,14 @@ $('#btn_transfer').click(function () {
 | 
			
		||||
        first_name: $('#first_name').val(),
 | 
			
		||||
        bank: $('#bank').val()
 | 
			
		||||
      }).done(function () {
 | 
			
		||||
      addMsg('Le crédit/retrait a bien été effectué !', 'success', 10000)
 | 
			
		||||
      if (user_note.membership && user_note.membership.date_end < new Date().toISOString()) { addMsg('Attention : la note ' + alias + " n'est plus adhérente.", 'danger', 10000) }
 | 
			
		||||
      addMsg(gettext('Credit/debit succeed!'), 'success', 10000)
 | 
			
		||||
      if (user_note.membership && user_note.membership.date_end < new Date().toISOString()) { addMsg(gettext('Warning, the emitter note %s is no more a BDE member.'), 'danger', 10000) }
 | 
			
		||||
      reset()
 | 
			
		||||
    }).fail(function (err) {
 | 
			
		||||
      const errObj = JSON.parse(err.responseText)
 | 
			
		||||
      let error = errObj.detail ? errObj.detail : errObj.non_field_errors
 | 
			
		||||
      if (!error) { error = err.responseText }
 | 
			
		||||
      addMsg('Le crédit/retrait a échoué : ' + error, 'danger', 10000)
 | 
			
		||||
      addMsg(interpolate(gettext('Credit/debit failed: %s'), [error]), 'danger', 10000)
 | 
			
		||||
      LOCK = false
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
import html
 | 
			
		||||
@@ -7,7 +7,7 @@ import django_tables2 as tables
 | 
			
		||||
from django.utils.html import format_html
 | 
			
		||||
from django_tables2.utils import A
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from note_kfet.middlewares import get_current_authenticated_user
 | 
			
		||||
from note_kfet.middlewares import get_current_request
 | 
			
		||||
from permission.backends import PermissionBackend
 | 
			
		||||
 | 
			
		||||
from .models.notes import Alias
 | 
			
		||||
@@ -88,16 +88,16 @@ class HistoryTable(tables.Table):
 | 
			
		||||
                "class": lambda record:
 | 
			
		||||
                str(record.valid).lower()
 | 
			
		||||
                + (' validate' if record.source.is_active and record.destination.is_active and PermissionBackend
 | 
			
		||||
                   .check_perm(get_current_authenticated_user(), "note.change_transaction_invalidity_reason", record)
 | 
			
		||||
                   .check_perm(get_current_request(), "note.change_transaction_invalidity_reason", record)
 | 
			
		||||
                   else ''),
 | 
			
		||||
                "data-toggle": "tooltip",
 | 
			
		||||
                "title": lambda record: (_("Click to invalidate") if record.valid else _("Click to validate"))
 | 
			
		||||
                if PermissionBackend.check_perm(get_current_authenticated_user(),
 | 
			
		||||
                if PermissionBackend.check_perm(get_current_request(),
 | 
			
		||||
                                                "note.change_transaction_invalidity_reason", record)
 | 
			
		||||
                and record.source.is_active and record.destination.is_active else None,
 | 
			
		||||
                "onclick": lambda record: 'de_validate(' + str(record.id) + ', ' + str(record.valid).lower()
 | 
			
		||||
                                          + ', "' + str(record.__class__.__name__) + '")'
 | 
			
		||||
                if PermissionBackend.check_perm(get_current_authenticated_user(),
 | 
			
		||||
                if PermissionBackend.check_perm(get_current_request(),
 | 
			
		||||
                                                "note.change_transaction_invalidity_reason", record)
 | 
			
		||||
                and record.source.is_active and record.destination.is_active else None,
 | 
			
		||||
                "onmouseover": lambda record: '$("#invalidity_reason_'
 | 
			
		||||
@@ -126,7 +126,7 @@ class HistoryTable(tables.Table):
 | 
			
		||||
        When the validation status is hovered, an input field is displayed to let the user specify an invalidity reason
 | 
			
		||||
        """
 | 
			
		||||
        has_perm = PermissionBackend \
 | 
			
		||||
            .check_perm(get_current_authenticated_user(), "note.change_transaction_invalidity_reason", record)
 | 
			
		||||
            .check_perm(get_current_request(), "note.change_transaction_invalidity_reason", record)
 | 
			
		||||
 | 
			
		||||
        val = "✔" if value else "✖"
 | 
			
		||||
 | 
			
		||||
@@ -165,7 +165,7 @@ class AliasTable(tables.Table):
 | 
			
		||||
                                       extra_context={"delete_trans": _('delete')},
 | 
			
		||||
                                       attrs={'td': {'class': lambda record: 'col-sm-1' + (
 | 
			
		||||
                                           ' d-none' if not PermissionBackend.check_perm(
 | 
			
		||||
                                               get_current_authenticated_user(), "note.delete_alias",
 | 
			
		||||
                                               get_current_request(), "note.delete_alias",
 | 
			
		||||
                                               record) else '')}}, verbose_name=_("Delete"), )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -129,7 +129,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
                {# Mode switch #}
 | 
			
		||||
                <div class="card-footer border-primary">
 | 
			
		||||
                    <a class="btn btn-sm btn-secondary float-left" href="{% url 'note:template_list' %}">
 | 
			
		||||
                        <i class="fa fa-edit"></i> {% trans "Edit" %}
 | 
			
		||||
                        <svg class="bi bi-edit" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
 | 
			
		||||
                            <path d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708l-3-3zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207l6.5-6.5zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.499.499 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11l.178-.178z"/>
 | 
			
		||||
                        </svg>
 | 
			
		||||
                        {% trans "Edit" %}
 | 
			
		||||
                    </a>
 | 
			
		||||
                    <div class="btn-group btn-group-toggle float-right" data-toggle="buttons">
 | 
			
		||||
                        <label for="single_conso" class="btn btn-sm btn-outline-primary active">
 | 
			
		||||
 
 | 
			
		||||
@@ -10,21 +10,25 @@ SPDX-License-Identifier: GPL-2.0-or-later
 | 
			
		||||
{# bandeau transfert/crédit/débit/activité #}
 | 
			
		||||
    <div class="row">
 | 
			
		||||
        <div class="col-xl-12">
 | 
			
		||||
            <div class="btn-group btn-group-toggle btn-block" data-toggle="buttons">
 | 
			
		||||
                <label for="type_transfer" class="btn btn-sm btn-outline-primary active">
 | 
			
		||||
                    <input type="radio" name="transaction_type" id="type_transfer">
 | 
			
		||||
                    {% trans "Transfer" %}
 | 
			
		||||
                </label>
 | 
			
		||||
                {% if "note.notespecial"|not_empty_model_list %}
 | 
			
		||||
                    <label for="type_credit" class="btn btn-sm btn-outline-primary">
 | 
			
		||||
                        <input type="radio" name="transaction_type" id="type_credit">
 | 
			
		||||
                        {% trans "Credit" %}
 | 
			
		||||
            <div class="btn-group btn-block">
 | 
			
		||||
                <div class="btn-group btn-group-toggle btn-block" data-toggle="buttons">
 | 
			
		||||
                    <label for="type_transfer" class="btn btn-sm btn-outline-primary active">
 | 
			
		||||
                        <input type="radio" name="transaction_type" id="type_transfer">
 | 
			
		||||
                        {% trans "Transfer" %}
 | 
			
		||||
                    </label>
 | 
			
		||||
                    <label for="type_debit" class="btn btn-sm btn-outline-primary">
 | 
			
		||||
                        <input type="radio" name="transaction_type" id="type_debit">
 | 
			
		||||
                        {% trans "Debit" %}
 | 
			
		||||
                    </label>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                    {% if "note.notespecial"|not_empty_model_list %}
 | 
			
		||||
                        <label for="type_credit" class="btn btn-sm btn-outline-primary">
 | 
			
		||||
                            <input type="radio" name="transaction_type" id="type_credit">
 | 
			
		||||
                            {% trans "Credit" %}
 | 
			
		||||
                        </label>
 | 
			
		||||
                        <label for="type_debit" class="btn btn-sm btn-outline-primary">
 | 
			
		||||
                            <input type="radio" name="transaction_type" id="type_debit">
 | 
			
		||||
                            {% trans "Debit" %}
 | 
			
		||||
                        </label>
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
                {# Add shortcuts for opened activites if necessary #}
 | 
			
		||||
                {% for activity in activities_open %}
 | 
			
		||||
                    <a href="{% url "activity:activity_entry" pk=activity.pk %}" class="btn btn-sm btn-outline-primary">
 | 
			
		||||
                        {% trans "Entries" %} {{ activity.name }}
 | 
			
		||||
@@ -57,7 +61,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
 | 
			
		||||
                <ul class="list-group list-group-flush" id="source_note_list">
 | 
			
		||||
                </ul>
 | 
			
		||||
                <div class="card-body">
 | 
			
		||||
                    <select id="credit_type" class="custom-select d-none">
 | 
			
		||||
                    <select id="credit_type" class="form-control custom-select d-none">
 | 
			
		||||
                        {% for special_type in special_types %}
 | 
			
		||||
                            <option value="{{ special_type.id }}">{{ special_type.special_type }}</option>
 | 
			
		||||
                        {% endfor %}
 | 
			
		||||
@@ -84,7 +88,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
 | 
			
		||||
                <ul class="list-group list-group-flush" id="dest_note_list">
 | 
			
		||||
                </ul>
 | 
			
		||||
                <div class="card-body">
 | 
			
		||||
                    <select id="debit_type" class="custom-select d-none">
 | 
			
		||||
                    <select id="debit_type" class="form-control custom-select d-none">
 | 
			
		||||
                        {% for special_type in special_types %}
 | 
			
		||||
                            <option value="{{ special_type.id }}">{{ special_type.special_type }}</option>
 | 
			
		||||
                        {% endfor %}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django import template
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django import template
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +1,20 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from api.tests import TestAPI
 | 
			
		||||
from member.models import Club, Membership
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.contrib.contenttypes.models import ContentType
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from member.models import Club, Membership
 | 
			
		||||
from note.models import NoteUser, Transaction, TemplateCategory, TransactionTemplate, RecurrentTransaction, \
 | 
			
		||||
    MembershipTransaction, SpecialTransaction, NoteSpecial, Alias
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from permission.models import Role
 | 
			
		||||
 | 
			
		||||
from ..api.views import AliasViewSet, ConsumerViewSet, NotePolymorphicViewSet, TemplateCategoryViewSet,\
 | 
			
		||||
    TransactionTemplateViewSet, TransactionViewSet
 | 
			
		||||
from ..models import NoteUser, Transaction, TemplateCategory, TransactionTemplate, RecurrentTransaction, \
 | 
			
		||||
    MembershipTransaction, SpecialTransaction, NoteSpecial, Alias, Note
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestTransactions(TestCase):
 | 
			
		||||
    fixtures = ('initial', )
 | 
			
		||||
@@ -297,8 +302,8 @@ class TestTransactions(TestCase):
 | 
			
		||||
 | 
			
		||||
    def test_render_search_transactions(self):
 | 
			
		||||
        response = self.client.get(reverse("note:transactions", args=(self.user.note.pk,)), data=dict(
 | 
			
		||||
            source=self.second_user.note.alias_set.first().id,
 | 
			
		||||
            destination=self.user.note.alias_set.first().id,
 | 
			
		||||
            source=self.second_user.note.alias.first().id,
 | 
			
		||||
            destination=self.user.note.alias.first().id,
 | 
			
		||||
            type=[ContentType.objects.get_for_model(Transaction).id],
 | 
			
		||||
            reason="test",
 | 
			
		||||
            valid=True,
 | 
			
		||||
@@ -363,3 +368,69 @@ class TestTransactions(TestCase):
 | 
			
		||||
        self.assertTrue(Alias.objects.filter(name="test_updated_alias").exists())
 | 
			
		||||
        response = self.client.delete("/api/note/alias/" + str(alias.pk) + "/")
 | 
			
		||||
        self.assertEqual(response.status_code, 204)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestNoteAPI(TestAPI):
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        super().setUp()
 | 
			
		||||
 | 
			
		||||
        membership = Membership.objects.create(club=Club.objects.get(name="BDE"), user=self.user)
 | 
			
		||||
        membership.roles.add(Role.objects.get(name="Respo info"))
 | 
			
		||||
        membership.save()
 | 
			
		||||
        Membership.objects.create(club=Club.objects.get(name="Kfet"), user=self.user)
 | 
			
		||||
        self.user.note.last_negative = timezone.now()
 | 
			
		||||
        self.user.note.save()
 | 
			
		||||
 | 
			
		||||
        self.transaction = Transaction.objects.create(
 | 
			
		||||
            source=Note.objects.first(),
 | 
			
		||||
            destination=self.user.note,
 | 
			
		||||
            amount=4200,
 | 
			
		||||
            reason="Test transaction",
 | 
			
		||||
        )
 | 
			
		||||
        self.user.note.refresh_from_db()
 | 
			
		||||
        Alias.objects.create(note=self.user.note, name="I am a ¢omplex alias")
 | 
			
		||||
 | 
			
		||||
        self.category = TemplateCategory.objects.create(name="Test")
 | 
			
		||||
        self.template = TransactionTemplate.objects.create(
 | 
			
		||||
            name="Test",
 | 
			
		||||
            destination=Club.objects.get(name="BDE").note,
 | 
			
		||||
            category=self.category,
 | 
			
		||||
            amount=100,
 | 
			
		||||
            description="Test template",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_alias_api(self):
 | 
			
		||||
        """
 | 
			
		||||
        Load Alias API page and test all filters and permissions
 | 
			
		||||
        """
 | 
			
		||||
        self.check_viewset(AliasViewSet, "/api/note/alias/")
 | 
			
		||||
 | 
			
		||||
    def test_consumer_api(self):
 | 
			
		||||
        """
 | 
			
		||||
        Load Consumer API page and test all filters and permissions
 | 
			
		||||
        """
 | 
			
		||||
        self.check_viewset(ConsumerViewSet, "/api/note/consumer/")
 | 
			
		||||
 | 
			
		||||
    def test_note_api(self):
 | 
			
		||||
        """
 | 
			
		||||
        Load Note API page and test all filters and permissions
 | 
			
		||||
        """
 | 
			
		||||
        self.check_viewset(NotePolymorphicViewSet, "/api/note/note/")
 | 
			
		||||
 | 
			
		||||
    def test_template_category_api(self):
 | 
			
		||||
        """
 | 
			
		||||
        Load TemplateCategory API page and test all filters and permissions
 | 
			
		||||
        """
 | 
			
		||||
        self.check_viewset(TemplateCategoryViewSet, "/api/note/transaction/category/")
 | 
			
		||||
 | 
			
		||||
    def test_transaction_template_api(self):
 | 
			
		||||
        """
 | 
			
		||||
        Load TemplateTemplate API page and test all filters and permissions
 | 
			
		||||
        """
 | 
			
		||||
        self.check_viewset(TransactionTemplateViewSet, "/api/note/transaction/template/")
 | 
			
		||||
 | 
			
		||||
    def test_transaction_api(self):
 | 
			
		||||
        """
 | 
			
		||||
        Load Transaction API page and test all filters and permissions
 | 
			
		||||
        """
 | 
			
		||||
        self.check_viewset(TransactionViewSet, "/api/note/transaction/transaction/")
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
from django.urls import path
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 | 
			
		||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
import json
 | 
			
		||||
@@ -38,7 +38,7 @@ class TransactionCreateView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTabl
 | 
			
		||||
    def get_queryset(self, **kwargs):
 | 
			
		||||
        # retrieves only Transaction that user has the right to see.
 | 
			
		||||
        return Transaction.objects.filter(
 | 
			
		||||
            PermissionBackend.filter_queryset(self.request.user, Transaction, "view")
 | 
			
		||||
            PermissionBackend.filter_queryset(self.request, Transaction, "view")
 | 
			
		||||
        ).order_by("-created_at").all()[:20]
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
@@ -47,16 +47,16 @@ class TransactionCreateView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTabl
 | 
			
		||||
        context['polymorphic_ctype'] = ContentType.objects.get_for_model(Transaction).pk
 | 
			
		||||
        context['special_polymorphic_ctype'] = ContentType.objects.get_for_model(SpecialTransaction).pk
 | 
			
		||||
        context['special_types'] = NoteSpecial.objects\
 | 
			
		||||
            .filter(PermissionBackend.filter_queryset(self.request.user, NoteSpecial, "view"))\
 | 
			
		||||
            .filter(PermissionBackend.filter_queryset(self.request, NoteSpecial, "view"))\
 | 
			
		||||
            .order_by("special_type").all()
 | 
			
		||||
 | 
			
		||||
        # Add a shortcut for entry page for open activities
 | 
			
		||||
        if "activity" in settings.INSTALLED_APPS:
 | 
			
		||||
            from activity.models import Activity
 | 
			
		||||
            activities_open = Activity.objects.filter(open=True).filter(
 | 
			
		||||
                PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().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.user,
 | 
			
		||||
                                          if PermissionBackend.check_perm(self.request,
 | 
			
		||||
                                                                          "activity.add_entry",
 | 
			
		||||
                                                                          Entry(activity=a,
 | 
			
		||||
                                                                                note=self.request.user.note, ))]
 | 
			
		||||
@@ -159,7 +159,7 @@ class ConsoView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
 | 
			
		||||
            return self.handle_no_permission()
 | 
			
		||||
 | 
			
		||||
        templates = TransactionTemplate.objects.filter(
 | 
			
		||||
            PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view")
 | 
			
		||||
            PermissionBackend().filter_queryset(self.request, TransactionTemplate, "view")
 | 
			
		||||
        )
 | 
			
		||||
        if not templates.exists():
 | 
			
		||||
            raise PermissionDenied(_("You can't see any button."))
 | 
			
		||||
@@ -170,7 +170,7 @@ class ConsoView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
 | 
			
		||||
        restrict to the transaction history the user can see.
 | 
			
		||||
        """
 | 
			
		||||
        return Transaction.objects.filter(
 | 
			
		||||
            PermissionBackend.filter_queryset(self.request.user, Transaction, "view")
 | 
			
		||||
            PermissionBackend.filter_queryset(self.request, Transaction, "view")
 | 
			
		||||
        ).order_by("-created_at").all()[:20]
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
@@ -180,13 +180,13 @@ class ConsoView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
 | 
			
		||||
        # for each category, find which transaction templates the user can see.
 | 
			
		||||
        for category in categories:
 | 
			
		||||
            category.templates_filtered = category.templates.filter(
 | 
			
		||||
                PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view")
 | 
			
		||||
                PermissionBackend().filter_queryset(self.request, TransactionTemplate, "view")
 | 
			
		||||
            ).filter(display=True).order_by('name').all()
 | 
			
		||||
 | 
			
		||||
        context['categories'] = [cat for cat in categories if cat.templates_filtered]
 | 
			
		||||
        # some transactiontemplate are put forward to find them easily
 | 
			
		||||
        context['highlighted'] = TransactionTemplate.objects.filter(highlighted=True).filter(
 | 
			
		||||
            PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view")
 | 
			
		||||
            PermissionBackend().filter_queryset(self.request, TransactionTemplate, "view")
 | 
			
		||||
        ).order_by('name').all()
 | 
			
		||||
        context['polymorphic_ctype'] = ContentType.objects.get_for_model(RecurrentTransaction).pk
 | 
			
		||||
 | 
			
		||||
@@ -209,7 +209,7 @@ class TransactionSearchView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView
 | 
			
		||||
        data = form.cleaned_data if form.is_valid() else {}
 | 
			
		||||
 | 
			
		||||
        transactions = Transaction.objects.annotate(total_amount=F("quantity") * F("amount")).filter(
 | 
			
		||||
            PermissionBackend.filter_queryset(self.request.user, Transaction, "view"))\
 | 
			
		||||
            PermissionBackend.filter_queryset(self.request, Transaction, "view"))\
 | 
			
		||||
            .filter(Q(source=self.object) | Q(destination=self.object)).order_by('-created_at')
 | 
			
		||||
 | 
			
		||||
        if "source" in data and data["source"]:
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user