mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-11-04 02:53:06 +00:00 
			
		
		
		
	Compare commits
	
		
			392 Commits
		
	
	
		
			counter-ac
			...
			openapi
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 106dc32a3d | |||
| 05edf33062 | |||
| 
						 | 
					98175e397c | ||
| 
						 | 
					62246f342d | ||
| 
						 | 
					bff6513192 | ||
| 
						 | 
					bf0779a096 | ||
| 
						 | 
					9991507297 | ||
| 
						 | 
					222ff762da | ||
| 6b27a97e7b | |||
| 
						 | 
					87f790a044 | ||
| 75c4c55a32 | |||
| 728ad157e9 | |||
| e542fe11b9 | |||
| aa66fc61ab | |||
| 7f8304e407 | |||
| 3b80b36ed6 | |||
| ba6e2a6402 | |||
| 6841d96455 | |||
| 8528820d89 | |||
| 
						 | 
					2c9b72fe1d | ||
| fe417b0c29 | |||
| 
						 | 
					b3f67657d7 | ||
| 
						 | 
					602c57c001 | ||
| 
						 | 
					6a17e4480e | ||
| 
						 | 
					1f1cd2ce0f | ||
| a653f98fc1 | |||
| a01ea13f5b | |||
| 10701ccdfa | |||
| 07028c8dd8 | |||
| 4890fcf0e1 | |||
| 2e71275f5b | |||
| 
						 | 
					be87af5e06 | ||
| f9c36c8f99 | |||
| 92d282f4ba | |||
| a1bf86dabf | |||
| 
						 | 
					21284546c4 | ||
| 
						 | 
					e936f0d285 | ||
| 
						 | 
					6af03240a1 | ||
| 
						 | 
					01c92feb40 | ||
| 
						 | 
					94d2c5660a | ||
| 
						 | 
					71b3588577 | ||
| 
						 | 
					2def57d82c | ||
| 
						 | 
					0e88260c31 | ||
| 
						 | 
					86c2ea7fd9 | ||
| 
						 | 
					fc3b82c35c | ||
| 
						 | 
					1d177412c3 | ||
| 
						 | 
					c272cad2ea | ||
| 
						 | 
					e757fb43a1 | ||
| 
						 | 
					8705fbe4b2 | ||
| 
						 | 
					aa60462653 | ||
| 
						 | 
					9c0d89de83 | ||
| 
						 | 
					809febc353 | ||
| f4ff247862 | |||
| 1978658b9c | |||
| 219700f0bc | |||
| 2918048b16 | |||
| a87016a23f | |||
| f7ff77b88f | |||
| e8db68b960 | |||
| 93a5c3a02a | |||
| e46cba7a06 | |||
| 
						 | 
					ba21738bd9 | ||
| 
						 | 
					b1db52d2b6 | ||
| 
						 | 
					2bed89aaba | ||
| 
						 | 
					86c68eeb32 | ||
| 
						 | 
					8cb53ceba2 | ||
| 
						 | 
					a96b374ad7 | ||
| 
						 | 
					9945993f0b | ||
| 
						 | 
					59e90ec754 | ||
| 
						 | 
					41bff53853 | ||
| 
						 | 
					88b3f7c322 | ||
| 
						 | 
					b31445fefb | ||
| 
						 | 
					2dc32f8b20 | ||
| 
						 | 
					b43b531c3b | ||
| 
						 | 
					bf388e68f0 | ||
| 
						 | 
					5252d450a9 | ||
| 
						 | 
					8f17c3d830 | ||
| 
						 | 
					6627ea417c | ||
| 
						 | 
					92b2befd55 | ||
| 
						 | 
					43207455b8 | ||
| 
						 | 
					5fa431e29b | ||
| 
						 | 
					78f3caa455 | ||
| 
						 | 
					6d519e3a07 | ||
| 
						 | 
					85c8b7d11c | ||
| 
						 | 
					fa02f4b5f0 | ||
| 
						 | 
					3df33261ce | ||
| 
						 | 
					ee1bcf2011 | ||
| 
						 | 
					571b3a4e02 | ||
| 
						 | 
					6bf02cecd9 | ||
| 
						 | 
					05d4a09f8c | ||
| 
						 | 
					169e9ea55a | ||
| 9b916f6204 | |||
| 
						 | 
					2123e83010 | ||
| 
						 | 
					294b59b4d6 | ||
| 
						 | 
					820ceb48dd | ||
| 
						 | 
					73ce681307 | ||
| 
						 | 
					faa757b54f | ||
| 
						 | 
					36076aefcc | ||
| 
						 | 
					b9482a6f08 | ||
| d573182f4b | |||
| 
						 | 
					75be6454eb | ||
| 
						 | 
					428fe68cdb | ||
| 
						 | 
					18967cf3d6 | ||
| 
						 | 
					14ed43aaa5 | ||
| 
						 | 
					c555d5c78c | ||
| 5db9819560 | |||
| dd2cd0a18d | |||
| 
						 | 
					c7ae70972f | ||
| 
						 | 
					17cf0c67b3 | ||
| 7d40387f43 | |||
| 
						 | 
					20a535429c | ||
| 
						 | 
					5ff7bb3259 | ||
| 
						 | 
					0d95c3b9c9 | ||
| 
						 | 
					170f9dde61 | ||
| 
						 | 
					61170c0918 | ||
| 
						 | 
					80940765fe | ||
| 
						 | 
					9d98a20e40 | ||
| 6f9f1ac1e7 | |||
| 
						 | 
					71b096f9ef | ||
| 
						 | 
					9272f53bea | ||
| 
						 | 
					9b5f08e13c | ||
| 
						 | 
					d0b1a49300 | ||
| 
						 | 
					e500cf92ee | ||
| 
						 | 
					551091f650 | ||
| 
						 | 
					0c01ad1770 | ||
| 
						 | 
					cba915c34d | ||
| 
						 | 
					7ac41ac5cb | ||
| 
						 | 
					c6bb509fc3 | ||
| 
						 | 
					4d0d7adce1 | ||
| 
						 | 
					6bcc420af1 | ||
| 
						 | 
					9f35f5356b | ||
| 
						 | 
					8d73ec797b | ||
| 
						 | 
					c3fc8538cc | ||
| 
						 | 
					600657b1a8 | ||
| 
						 | 
					d3f21c8f16 | ||
| 
						 | 
					895d51586e | ||
| 
						 | 
					e200f28267 | ||
| a4c6439981 | |||
| 
						 | 
					6ee2e8c5da | ||
| f4af29acb4 | |||
| 
						 | 
					a8810816f0 | ||
| 
						 | 
					b7bf3fd375 | ||
| 
						 | 
					b26e85ebb2 | ||
| 
						 | 
					8b8a295e16 | ||
| 
						 | 
					894690a97f | ||
| 
						 | 
					843ce2e3a7 | ||
| 9f33ddd883 | |||
| a2dc4f1964 | |||
| 
						 | 
					cca486f2b9 | ||
| 
						 | 
					b9e27ef191 | ||
| 29e875bcde | |||
| 
						 | 
					686d67410a | ||
| 
						 | 
					4226ba88ae | ||
| 672bc91e36 | |||
| 
						 | 
					bc9cb9b36c | ||
| edafc06c3f | |||
| 
						 | 
					134f8a7989 | ||
| 771cbdbd77 | |||
| a491baddb9 | |||
| 8d10a5e0ab | |||
| cbe42d3a60 | |||
| 0c4d72e17a | |||
| 2db3290bed | |||
| 
						 | 
					429df81ec9 | ||
| bb24516474 | |||
| 
						 | 
					16de128fdb | ||
| 
						 | 
					8e339c3d4b | ||
| 25298518bc | |||
| 
						 | 
					2e26ff2cde | ||
| a8702d4f5e | |||
| 
						 | 
					7f4cc5fb0f | ||
| 
						 | 
					e7215be00e | ||
| 
						 | 
					4f35cc00bc | ||
| 
						 | 
					af47587116 | ||
| 
						 | 
					3c4daeadb0 | ||
| 
						 | 
					348ab19ac6 | ||
| 
						 | 
					ada74a3e42 | ||
| 
						 | 
					785ac9bdab | ||
| 
						 | 
					d1e604e7a5 | ||
| 2749a88704 | |||
| eb3db134f8 | |||
| fa7f5d24b0 | |||
| ba76015c71 | |||
| 1887a2790f | |||
| 5d0fc38107 | |||
| 65df55a635 | |||
| a60e1f1fdc | |||
| 0a0f44607e | |||
| 007080ee48 | |||
| a13e3e95b7 | |||
| 169938e1da | |||
| e5fb875968 | |||
| 9bd14f1b4e | |||
| fd2295119d | |||
| eac2709e86 | |||
| 48f6d134bf | |||
| 6d7467e746 | |||
| 0d1629495b | |||
| 63839dc22b | |||
| 
						 | 
					c627944bd1 | ||
| 
						 | 
					f0be4b270b | ||
| 
						 | 
					728065e771 | ||
| 
						 | 
					849fac490d | ||
| 
						 | 
					5752229312 | ||
| 
						 | 
					6eb860579a | ||
| 
						 | 
					d08d54b4c9 | ||
| 
						 | 
					bb210f8d47 | ||
| 
						 | 
					efca10e252 | ||
| 
						 | 
					b8f851b009 | ||
| 
						 | 
					1e29ae4171 | ||
| 
						 | 
					0ae1e850f4 | ||
| 
						 | 
					d380668c0f | ||
| 
						 | 
					9a72c5eb72 | ||
| 
						 | 
					407cfbe02b | ||
| 
						 | 
					6400b2c2c2 | ||
| 
						 | 
					0d3fd954a3 | ||
| 
						 | 
					cce7ecbe73 | ||
| 
						 | 
					d200c1e381 | ||
| 
						 | 
					673c427485 | ||
| 
						 | 
					2f9e5bfee1 | ||
| 
						 | 
					11702d3d7c | ||
| 43f47e2087 | |||
| 
						 | 
					4b881903f0 | ||
| 
						 | 
					761e37ade6 | ||
| 
						 | 
					10ed2f7404 | ||
| 43768f1691 | |||
| 280d27343d | |||
| 138e1662c7 | |||
| c80fe094a2 | |||
| 139221dd22 | |||
| 
						 | 
					72c2981d66 | ||
| 6f003ffa53 | |||
| 7f6fd7dc47 | |||
| ccf5118c9d | |||
| 022c19c020 | |||
| 2e5e217842 | |||
| 9c93c004ec | |||
| 472800eff6 | |||
| b8d43a629b | |||
| f6693e12cf | |||
| 38f491cf57 | |||
| 3464d5d860 | |||
| 
						 | 
					81773dc800 | ||
| 
						 | 
					da400155eb | ||
| 5079938a5b | |||
| b8430adc50 | |||
| eed434aeb2 | |||
| 372470b44b | |||
| 7071553c3b | |||
| eea237b813 | |||
| c37288c285 | |||
| ccf5767a01 | |||
| ffe6fc8c2a | |||
| 5f0b4d2050 | |||
| f9d7dc7d3a | |||
| 8ebea00896 | |||
| a548f4744e | |||
| a383f3e717 | |||
| 60f18669c8 | |||
| a36946529b | |||
| 
						 | 
					eaac0c728f | ||
| 
						 | 
					9ca95774a3 | ||
| 
						 | 
					fa66851889 | ||
| 
						 | 
					ab81f11199 | ||
| 
						 | 
					bea7741d35 | ||
| 
						 | 
					81e163812e | ||
| 
						 | 
					4f233538e0 | ||
| 4ac09ac08b | |||
| 
						 | 
					6d02970676 | ||
| 
						 | 
					b773a05bb5 | ||
| 
						 | 
					accf1befce | ||
| 
						 | 
					6953eaa9d0 | ||
| 
						 | 
					180bae59c8 | ||
| 
						 | 
					9cafc163e8 | ||
| 
						 | 
					8f8eef4107 | ||
| 
						 | 
					7af745087e | ||
| 
						 | 
					aab093200b | ||
| 
						 | 
					1a9556f811 | ||
| 
						 | 
					39b36aa509 | ||
| 
						 | 
					3fc260a12c | ||
| 
						 | 
					1696a2f579 | ||
| 
						 | 
					baebc0b690 | ||
| 
						 | 
					9f3a10ca71 | ||
| 
						 | 
					38ceaf3106 | ||
| 87b619794d | |||
| 
						 | 
					29c4a36479 | ||
| 
						 | 
					ddeb12f08c | ||
| 
						 | 
					a7b1406e06 | ||
| 
						 | 
					871ef60cf6 | ||
| 
						 | 
					7e9071a533 | ||
| 
						 | 
					8c660e9856 | ||
| 
						 | 
					6ca641ab7f | ||
| 
						 | 
					8d6609566f | ||
| 
						 | 
					17e4c63737 | ||
| 
						 | 
					fad470b670 | ||
| 
						 | 
					c5646b1e59 | ||
| 
						 | 
					5da27bb266 | ||
| 
						 | 
					be6a077c8e | ||
| 
						 | 
					8d643fc6b4 | ||
| 
						 | 
					47876e3971 | ||
| 
						 | 
					c79c251ba7 | ||
| 
						 | 
					483670e798 | ||
| 
						 | 
					6c8a6008d5 | ||
| 
						 | 
					e680124d7b | ||
| 
						 | 
					b06a06f50c | ||
| 
						 | 
					c1be55a719 | ||
| 
						 | 
					6416de237f | ||
| ad44fd52a4 | |||
| 03c27b10e5 | |||
| fc0ef29738 | |||
| a0eb53a607 | |||
| 66e5ef64fd | |||
| 
						 | 
					f5d5cc18a8 | ||
| 4c65939bbe | |||
| 379527cd58 | |||
| f63fb59cbf | |||
| cde864fdc7 | |||
| e9361697f7 | |||
| 
						 | 
					830c752971 | ||
| 6bdc1b73ae | |||
| 
						 | 
					0f003870bb | ||
| 0631c77a1c | |||
| 2cc4308a58 | |||
| 4975475e85 | |||
| 
						 | 
					466fe58763 | ||
| 
						 | 
					3b7e338808 | ||
| 
						 | 
					53b13e7aef | ||
| fa60ecb25a | |||
| 
						 | 
					a975824481 | ||
| 
						 | 
					c51e5eb6cb | ||
| 
						 | 
					f0bc502ec9 | ||
| 
						 | 
					902cafc5e4 | ||
| 
						 | 
					b2f54aa23e | ||
| 29a5425259 | |||
| 
						 | 
					e2a34c75ea | ||
| de7aa6f6a6 | |||
| 
						 | 
					9acb421b2e | ||
| 66d2dc74e7 | |||
| 2f613607af | |||
| d4b9c3afb1 | |||
| b81cf49d0a | |||
| 
						 | 
					1da45fdffc | ||
| 
						 | 
					10dde3f002 | ||
| 
						 | 
					c2d6af12ab | ||
| 
						 | 
					6e48f88c06 | ||
| 
						 | 
					7a91a71565 | ||
| 
						 | 
					c4764110d8 | ||
| 
						 | 
					ff68e65250 | ||
| 
						 | 
					c9d83e5916 | ||
| 
						 | 
					5dc99dbfcb | ||
| 
						 | 
					8dbec85c8e | ||
| 
						 | 
					84d7e40e66 | ||
| 
						 | 
					0b509f2200 | ||
| 
						 | 
					9591162cc9 | ||
| 
						 | 
					007e17fd8b | ||
| 
						 | 
					35c5f96672 | ||
| 
						 | 
					95f8e7517c | ||
| 
						 | 
					9667c79162 | ||
| 
						 | 
					1c79c25262 | ||
| 
						 | 
					04b4b34bfe | ||
| 
						 | 
					fc0e689d4e | ||
| 
						 | 
					83bb4b3b12 | ||
| 
						 | 
					8dcfc604a0 | ||
| 
						 | 
					d2d639e5f6 | ||
| 
						 | 
					b3eb7693e3 | ||
| 
						 | 
					10f42b1522 | ||
| 
						 | 
					76e9f3b1dc | ||
| 
						 | 
					d0ff9bc16c | ||
| 
						 | 
					5e4ebd16f9 | ||
| 
						 | 
					d2b19424ff | ||
| 
						 | 
					08286254cd | ||
| 
						 | 
					4805c39b45 | ||
| 
						 | 
					f845bbf20a | ||
| 
						 | 
					71c7158124 | ||
| 
						 | 
					c4643ee52c | ||
| 
						 | 
					b46b0882f3 | ||
| 
						 | 
					1c4efc9431 | ||
| 
						 | 
					4133e0ccdd | ||
| 
						 | 
					de415e7e75 | ||
| 
						 | 
					9d17524f45 | ||
| 
						 | 
					68ad9650af | ||
| 
						 | 
					8d4d8a3abc | ||
| 
						 | 
					9617e29ed5 | ||
| 
						 | 
					75406f7b58 | ||
| 
						 | 
					70f5ae4f9c | ||
| 
						 | 
					0a5ddcea68 | ||
| 
						 | 
					e6f25fb707 | ||
| 
						 | 
					19e21c80df | ||
| 
						 | 
					cbcdc6171f | ||
| 
						 | 
					444a2936e2 | ||
| 
						 | 
					6a31f38ceb | ||
| 
						 | 
					819cd257a8 | 
							
								
								
									
										17
									
								
								.env.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								.env.example
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
HTTPS=off
 | 
			
		||||
SITH_DEBUG=true
 | 
			
		||||
 | 
			
		||||
# This is not the real key used in prod
 | 
			
		||||
SECRET_KEY=(4sjxvhz@m5$0a$j0_pqicnc$s!vbve)z+&++m%g%bjhlz4+g2
 | 
			
		||||
 | 
			
		||||
# comment the sqlite line and uncomment the postgres one to switch the dbms
 | 
			
		||||
DATABASE_URL=sqlite:///db.sqlite3
 | 
			
		||||
#DATABASE_URL=postgres://user:password@127.0.0.1:5432/sith
 | 
			
		||||
 | 
			
		||||
REDIS_PORT=7963
 | 
			
		||||
CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0
 | 
			
		||||
 | 
			
		||||
# Used to select which other services to run alongside
 | 
			
		||||
# manage.py, pytest and runserver
 | 
			
		||||
PROCFILE_STATIC=Procfile.static
 | 
			
		||||
PROCFILE_SERVICE=Procfile.service
 | 
			
		||||
							
								
								
									
										14
									
								
								.envrc
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								.envrc
									
									
									
									
									
								
							@@ -1,14 +1,6 @@
 | 
			
		||||
if [[ ! -f pyproject.toml ]]; then
 | 
			
		||||
  log_error 'No pyproject.toml found. Use `poetry new` or `poetry init` to create one first.'
 | 
			
		||||
if [[ ! -d .venv ]]; then
 | 
			
		||||
  log_error 'No .venv folder found. Use `uv sync` to create one first.'
 | 
			
		||||
  exit 2
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
local VENV=$(poetry env list --full-path | cut -d' ' -f1)
 | 
			
		||||
if [[ -z $VENV || ! -d $VENV/bin ]]; then
 | 
			
		||||
  log_error 'No poetry virtual environment found. Use `poetry install` to create one first.'
 | 
			
		||||
  exit 2
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
export VIRTUAL_ENV=$VENV
 | 
			
		||||
export POETRY_ACTIVE=1
 | 
			
		||||
PATH_add "$VENV/bin"
 | 
			
		||||
. .venv/bin/activate
 | 
			
		||||
							
								
								
									
										8
									
								
								.github/actions/compile_messages/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/actions/compile_messages/action.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,8 +0,0 @@
 | 
			
		||||
name: "Compile messages"
 | 
			
		||||
description: "Compile the gettext translation messages"
 | 
			
		||||
runs:
 | 
			
		||||
  using: composite
 | 
			
		||||
  steps:
 | 
			
		||||
      - name: Setup project
 | 
			
		||||
        run: poetry run ./manage.py compilemessages
 | 
			
		||||
        shell: bash
 | 
			
		||||
							
								
								
									
										58
									
								
								.github/actions/setup_project/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										58
									
								
								.github/actions/setup_project/action.yml
									
									
									
									
										vendored
									
									
								
							@@ -4,48 +4,48 @@ runs:
 | 
			
		||||
  using: composite
 | 
			
		||||
  steps:
 | 
			
		||||
    - name: Install apt packages
 | 
			
		||||
      uses: awalsh128/cache-apt-pkgs-action@latest
 | 
			
		||||
      uses: awalsh128/cache-apt-pkgs-action@v1.4.3
 | 
			
		||||
      with:
 | 
			
		||||
        packages: gettext
 | 
			
		||||
        version: 1.0  # increment to reset cache
 | 
			
		||||
 | 
			
		||||
    - name: Set up python
 | 
			
		||||
    - name: Install Redis
 | 
			
		||||
      uses: shogo82148/actions-setup-redis@v1
 | 
			
		||||
      with:
 | 
			
		||||
        redis-version: "7.x"
 | 
			
		||||
 | 
			
		||||
    - name: Install uv
 | 
			
		||||
      uses: astral-sh/setup-uv@v5
 | 
			
		||||
      with:
 | 
			
		||||
        version: "0.5.14"
 | 
			
		||||
        enable-cache: true
 | 
			
		||||
        cache-dependency-glob: "uv.lock"
 | 
			
		||||
 | 
			
		||||
    - name: "Set up Python"
 | 
			
		||||
      uses: actions/setup-python@v5
 | 
			
		||||
      with:
 | 
			
		||||
        python-version: "3.12"
 | 
			
		||||
        python-version-file: ".python-version"
 | 
			
		||||
 | 
			
		||||
    - name: Load cached Poetry installation
 | 
			
		||||
      id: cached-poetry
 | 
			
		||||
      uses: actions/cache@v3
 | 
			
		||||
    - name: Restore cached virtualenv
 | 
			
		||||
      uses: actions/cache/restore@v4
 | 
			
		||||
      with:
 | 
			
		||||
        path: ~/.local
 | 
			
		||||
        key: poetry-3  # increment to reset cache
 | 
			
		||||
 | 
			
		||||
    - name: Install Poetry
 | 
			
		||||
      if: steps.cached-poetry.outputs.cache-hit != 'true'
 | 
			
		||||
      shell: bash
 | 
			
		||||
      run: curl -sSL https://install.python-poetry.org | python3 -
 | 
			
		||||
 | 
			
		||||
    - name: Check pyproject.toml syntax
 | 
			
		||||
      shell: bash
 | 
			
		||||
      run: poetry check
 | 
			
		||||
 | 
			
		||||
    - name: Load cached dependencies
 | 
			
		||||
      uses: actions/cache@v3
 | 
			
		||||
      with:
 | 
			
		||||
        path: ~/.cache/pypoetry
 | 
			
		||||
        key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
 | 
			
		||||
        restore-keys: |
 | 
			
		||||
          ${{ runner.os }}-poetry-
 | 
			
		||||
        key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
 | 
			
		||||
        path: .venv
 | 
			
		||||
 | 
			
		||||
    - name: Install dependencies
 | 
			
		||||
      run: poetry install --with docs,tests
 | 
			
		||||
      run: uv sync
 | 
			
		||||
      shell: bash
 | 
			
		||||
 | 
			
		||||
    - name: Install xapian
 | 
			
		||||
      run: poetry run ./manage.py install_xapian
 | 
			
		||||
    - name: Install Xapian
 | 
			
		||||
      run: uv run ./manage.py install_xapian
 | 
			
		||||
      shell: bash
 | 
			
		||||
 | 
			
		||||
    - name: Save cached virtualenv
 | 
			
		||||
      uses: actions/cache/save@v4
 | 
			
		||||
      with:
 | 
			
		||||
        key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
 | 
			
		||||
        path: .venv
 | 
			
		||||
 | 
			
		||||
    - name: Compile gettext messages
 | 
			
		||||
      run: poetry run ./manage.py compilemessages
 | 
			
		||||
      run: uv run ./manage.py compilemessages
 | 
			
		||||
      shell: bash
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10
									
								
								.github/actions/setup_xapian/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/actions/setup_xapian/action.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,10 +0,0 @@
 | 
			
		||||
name: "Setup xapian"
 | 
			
		||||
description: "Setup the xapian indexes"
 | 
			
		||||
runs:
 | 
			
		||||
  using: composite
 | 
			
		||||
  steps:
 | 
			
		||||
    - name: Setup xapian index
 | 
			
		||||
      run: |
 | 
			
		||||
        mkdir -p /dev/shm/search_indexes
 | 
			
		||||
        ln -s /dev/shm/search_indexes sith/search_indexes
 | 
			
		||||
      shell: bash
 | 
			
		||||
							
								
								
									
										22
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										22
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							@@ -7,6 +7,11 @@ on:
 | 
			
		||||
    branches: [master, taiste]
 | 
			
		||||
  workflow_dispatch:
 | 
			
		||||
 | 
			
		||||
env:
 | 
			
		||||
  SECRET_KEY: notTheRealOne
 | 
			
		||||
  DATABASE_URL: sqlite:///db.sqlite3
 | 
			
		||||
  CACHE_URL: redis://127.0.0.1:6379/0
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  pre-commit:
 | 
			
		||||
    name: Launch pre-commits checks (ruff)
 | 
			
		||||
@@ -14,6 +19,8 @@ jobs:
 | 
			
		||||
    steps:
 | 
			
		||||
    - uses: actions/checkout@v4
 | 
			
		||||
    - uses: actions/setup-python@v5
 | 
			
		||||
      with:
 | 
			
		||||
        python-version-file: ".python-version"
 | 
			
		||||
    - uses: pre-commit/action@v3.0.1
 | 
			
		||||
      with:
 | 
			
		||||
        extra_args: --all-files
 | 
			
		||||
@@ -29,16 +36,17 @@ jobs:
 | 
			
		||||
      - name: Check out repository
 | 
			
		||||
        uses: actions/checkout@v4
 | 
			
		||||
      - uses: ./.github/actions/setup_project
 | 
			
		||||
      - uses: ./.github/actions/setup_xapian
 | 
			
		||||
      - uses: ./.github/actions/compile_messages
 | 
			
		||||
        env:
 | 
			
		||||
          # To avoid race conditions on environment cache
 | 
			
		||||
          CACHE_SUFFIX: ${{ matrix.pytest-mark }}
 | 
			
		||||
      - name: Run tests
 | 
			
		||||
        run: poetry run coverage run -m pytest -m "${{ matrix.pytest-mark }}"
 | 
			
		||||
        run: uv run coverage run -m pytest -m "${{ matrix.pytest-mark }}"
 | 
			
		||||
      - name: Generate coverage report
 | 
			
		||||
        run: |
 | 
			
		||||
          poetry run coverage report
 | 
			
		||||
          poetry run coverage html
 | 
			
		||||
          uv run coverage report
 | 
			
		||||
          uv run coverage html
 | 
			
		||||
      - name: Archive code coverage results
 | 
			
		||||
        uses: actions/upload-artifact@v3
 | 
			
		||||
        uses: actions/upload-artifact@v4
 | 
			
		||||
        with:
 | 
			
		||||
          name: coverage-report
 | 
			
		||||
          name: coverage-report-${{ matrix.pytest-mark }}
 | 
			
		||||
          path: coverage_report
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										28
									
								
								.github/workflows/deploy.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										28
									
								
								.github/workflows/deploy.yml
									
									
									
									
										vendored
									
									
								
							@@ -37,11 +37,29 @@ jobs:
 | 
			
		||||
 | 
			
		||||
          git fetch
 | 
			
		||||
          git reset --hard origin/master
 | 
			
		||||
          poetry install --with prod --without docs,tests
 | 
			
		||||
          uv sync --group prod
 | 
			
		||||
          npm install
 | 
			
		||||
          poetry run ./manage.py install_xapian
 | 
			
		||||
          poetry run ./manage.py migrate
 | 
			
		||||
          poetry run ./manage.py collectstatic --clear --noinput
 | 
			
		||||
          poetry run ./manage.py compilemessages
 | 
			
		||||
          uv run ./manage.py install_xapian
 | 
			
		||||
          uv run ./manage.py migrate
 | 
			
		||||
          uv run ./manage.py collectstatic --clear --noinput
 | 
			
		||||
          uv run ./manage.py compilemessages
 | 
			
		||||
 | 
			
		||||
          sudo systemctl restart uwsgi
 | 
			
		||||
  
 | 
			
		||||
  sentry:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    environment: production
 | 
			
		||||
    timeout-minutes: 30
 | 
			
		||||
    needs: deployment
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Sentry Release
 | 
			
		||||
        uses: getsentry/action-release@v1.7.0
 | 
			
		||||
        env:
 | 
			
		||||
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
 | 
			
		||||
          SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
 | 
			
		||||
          SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
 | 
			
		||||
          SENTRY_URL: ${{ secrets.SENTRY_URL }}
 | 
			
		||||
        with:
 | 
			
		||||
          environment: production
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/deploy_docs.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/deploy_docs.yml
									
									
									
									
										vendored
									
									
								
							@@ -18,4 +18,4 @@ jobs:
 | 
			
		||||
          path: .cache
 | 
			
		||||
          restore-keys: |
 | 
			
		||||
            mkdocs-material-
 | 
			
		||||
      - run: poetry run mkdocs gh-deploy --force
 | 
			
		||||
      - run: uv run mkdocs gh-deploy --force
 | 
			
		||||
							
								
								
									
										10
									
								
								.github/workflows/taiste.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/taiste.yml
									
									
									
									
										vendored
									
									
								
							@@ -36,11 +36,11 @@ jobs:
 | 
			
		||||
 | 
			
		||||
          git fetch
 | 
			
		||||
          git reset --hard origin/taiste
 | 
			
		||||
          poetry install --with prod --without docs,tests
 | 
			
		||||
          uv sync --group prod
 | 
			
		||||
          npm install
 | 
			
		||||
          poetry run ./manage.py install_xapian
 | 
			
		||||
          poetry run ./manage.py migrate
 | 
			
		||||
          poetry run ./manage.py collectstatic --clear --noinput
 | 
			
		||||
          poetry run ./manage.py compilemessages
 | 
			
		||||
          uv run ./manage.py install_xapian
 | 
			
		||||
          uv run ./manage.py migrate
 | 
			
		||||
          uv run ./manage.py collectstatic --clear --noinput
 | 
			
		||||
          uv run ./manage.py compilemessages
 | 
			
		||||
 | 
			
		||||
          sudo systemctl restart uwsgi
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -8,7 +8,7 @@ pyrightconfig.json
 | 
			
		||||
dist/
 | 
			
		||||
.vscode/
 | 
			
		||||
.idea/
 | 
			
		||||
env/
 | 
			
		||||
.venv/
 | 
			
		||||
doc/html
 | 
			
		||||
data/
 | 
			
		||||
galaxy/test_galaxy_state.json
 | 
			
		||||
@@ -18,6 +18,14 @@ sith/search_indexes/
 | 
			
		||||
.coverage
 | 
			
		||||
coverage_report/
 | 
			
		||||
node_modules/
 | 
			
		||||
.env
 | 
			
		||||
*.pid
 | 
			
		||||
 | 
			
		||||
# compiled documentation
 | 
			
		||||
site/
 | 
			
		||||
 | 
			
		||||
### Redis ###
 | 
			
		||||
 | 
			
		||||
# Ignore redis binary dump (dump.rdb) files
 | 
			
		||||
 | 
			
		||||
*.rdb
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
repos:
 | 
			
		||||
  - repo: https://github.com/astral-sh/ruff-pre-commit
 | 
			
		||||
    # Ruff version.
 | 
			
		||||
    rev: v0.6.9
 | 
			
		||||
    rev: v0.8.3
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: ruff  # just check the code, and print the errors
 | 
			
		||||
      - id: ruff  # actually fix the fixable errors, but print nothing
 | 
			
		||||
@@ -12,9 +12,9 @@ repos:
 | 
			
		||||
    rev: "v0.1.0"  # Use the sha / tag you want to point at
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: biome-check
 | 
			
		||||
        additional_dependencies: ["@biomejs/biome@1.9.3"]
 | 
			
		||||
        additional_dependencies: ["@biomejs/biome@1.9.4"]
 | 
			
		||||
  - repo: https://github.com/rtts/djhtml
 | 
			
		||||
    rev: 3.0.6
 | 
			
		||||
    rev: 3.0.7
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: djhtml
 | 
			
		||||
        name: format templates
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								.python-version
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.python-version
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
3.12
 | 
			
		||||
							
								
								
									
										1
									
								
								Procfile.service
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								Procfile.service
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
redis: redis-server --port $REDIS_PORT
 | 
			
		||||
							
								
								
									
										1
									
								
								Procfile.static
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								Procfile.static
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
bundler: npm run serve
 | 
			
		||||
@@ -7,7 +7,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
 | 
			
		||||
 | 
			
		||||
from accounting.models import ClubAccount, Company
 | 
			
		||||
from accounting.schemas import ClubAccountSchema, CompanySchema
 | 
			
		||||
from core.api_permissions import CanAccessLookup
 | 
			
		||||
from core.auth.api_permissions import CanAccessLookup
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@api_controller("/lookup", permissions=[CanAccessLookup])
 | 
			
		||||
 
 | 
			
		||||
@@ -216,7 +216,7 @@ class TestOperation(TestCase):
 | 
			
		||||
            self.journal.operations.filter(target_label="Le fantome du jour").exists()
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test__operation_simple_accounting(self):
 | 
			
		||||
    def test_operation_simple_accounting(self):
 | 
			
		||||
        sat = SimplifiedAccountingType.objects.all().first()
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("accounting:op_new", args=[self.journal.id]),
 | 
			
		||||
@@ -237,15 +237,14 @@ class TestOperation(TestCase):
 | 
			
		||||
                "done": False,
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.assertFalse(response.status_code == 403)
 | 
			
		||||
        self.assertTrue(self.journal.operations.filter(amount=23).exists())
 | 
			
		||||
        assert response.status_code != 403
 | 
			
		||||
        assert self.journal.operations.filter(amount=23).exists()
 | 
			
		||||
        response_get = self.client.get(
 | 
			
		||||
            reverse("accounting:journal_details", args=[self.journal.id])
 | 
			
		||||
        )
 | 
			
		||||
        self.assertTrue(
 | 
			
		||||
            "<td>Le fantome de l'aurore</td>" in str(response_get.content)
 | 
			
		||||
        )
 | 
			
		||||
        self.assertTrue(
 | 
			
		||||
        assert "<td>Le fantome de l'aurore</td>" in str(response_get.content)
 | 
			
		||||
 | 
			
		||||
        assert (
 | 
			
		||||
            self.journal.operations.filter(amount=23)
 | 
			
		||||
            .values("accounting_type")
 | 
			
		||||
            .first()["accounting_type"]
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,7 @@ import collections
 | 
			
		||||
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.mixins import PermissionRequiredMixin
 | 
			
		||||
from django.core.exceptions import PermissionDenied, ValidationError
 | 
			
		||||
from django.db import transaction
 | 
			
		||||
from django.db.models import Sum
 | 
			
		||||
@@ -44,15 +45,15 @@ from accounting.widgets.select import (
 | 
			
		||||
)
 | 
			
		||||
from club.models import Club
 | 
			
		||||
from club.widgets.select import AutoCompleteSelectClub
 | 
			
		||||
from core.models import User
 | 
			
		||||
from core.views import (
 | 
			
		||||
from core.auth.mixins import (
 | 
			
		||||
    CanCreateMixin,
 | 
			
		||||
    CanEditMixin,
 | 
			
		||||
    CanEditPropMixin,
 | 
			
		||||
    CanViewMixin,
 | 
			
		||||
    TabedViewMixin,
 | 
			
		||||
)
 | 
			
		||||
from core.models import User
 | 
			
		||||
from core.views.forms import SelectDate, SelectFile
 | 
			
		||||
from core.views.mixins import TabedViewMixin
 | 
			
		||||
from core.views.widgets.select import AutoCompleteSelectUser
 | 
			
		||||
from counter.models import Counter, Product, Selling
 | 
			
		||||
 | 
			
		||||
@@ -86,12 +87,13 @@ class SimplifiedAccountingTypeEditView(CanViewMixin, UpdateView):
 | 
			
		||||
    template_name = "core/edit.jinja"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SimplifiedAccountingTypeCreateView(CanCreateMixin, CreateView):
 | 
			
		||||
class SimplifiedAccountingTypeCreateView(PermissionRequiredMixin, CreateView):
 | 
			
		||||
    """Create an accounting type (for the admins)."""
 | 
			
		||||
 | 
			
		||||
    model = SimplifiedAccountingType
 | 
			
		||||
    fields = ["label", "accounting_type"]
 | 
			
		||||
    template_name = "core/create.jinja"
 | 
			
		||||
    permission_required = "accounting.add_simplifiedaccountingtype"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Accounting types
 | 
			
		||||
@@ -113,12 +115,13 @@ class AccountingTypeEditView(CanViewMixin, UpdateView):
 | 
			
		||||
    template_name = "core/edit.jinja"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccountingTypeCreateView(CanCreateMixin, CreateView):
 | 
			
		||||
class AccountingTypeCreateView(PermissionRequiredMixin, CreateView):
 | 
			
		||||
    """Create an accounting type (for the admins)."""
 | 
			
		||||
 | 
			
		||||
    model = AccountingType
 | 
			
		||||
    fields = ["code", "label", "movement_type"]
 | 
			
		||||
    template_name = "core/create.jinja"
 | 
			
		||||
    permission_required = "accounting.add_accountingtype"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# BankAccount views
 | 
			
		||||
@@ -215,17 +218,14 @@ class JournalTabsMixin(TabedViewMixin):
 | 
			
		||||
        return _("Journal")
 | 
			
		||||
 | 
			
		||||
    def get_list_of_tabs(self):
 | 
			
		||||
        tab_list = []
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
        return [
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse(
 | 
			
		||||
                    "accounting:journal_details", kwargs={"j_id": self.object.id}
 | 
			
		||||
                ),
 | 
			
		||||
                "slug": "journal",
 | 
			
		||||
                "name": _("Journal"),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse(
 | 
			
		||||
                    "accounting:journal_nature_statement",
 | 
			
		||||
@@ -233,9 +233,7 @@ class JournalTabsMixin(TabedViewMixin):
 | 
			
		||||
                ),
 | 
			
		||||
                "slug": "nature_statement",
 | 
			
		||||
                "name": _("Statement by nature"),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse(
 | 
			
		||||
                    "accounting:journal_person_statement",
 | 
			
		||||
@@ -243,9 +241,7 @@ class JournalTabsMixin(TabedViewMixin):
 | 
			
		||||
                ),
 | 
			
		||||
                "slug": "person_statement",
 | 
			
		||||
                "name": _("Statement by person"),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse(
 | 
			
		||||
                    "accounting:journal_accounting_statement",
 | 
			
		||||
@@ -253,9 +249,8 @@ class JournalTabsMixin(TabedViewMixin):
 | 
			
		||||
                ),
 | 
			
		||||
                "slug": "accounting_statement",
 | 
			
		||||
                "name": _("Accounting statement"),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        return tab_list
 | 
			
		||||
            },
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class JournalCreateView(CanCreateMixin, CreateView):
 | 
			
		||||
 
 | 
			
		||||
@@ -20,6 +20,14 @@ from club.models import Club, Membership
 | 
			
		||||
@admin.register(Club)
 | 
			
		||||
class ClubAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ("name", "unix_name", "parent", "is_active")
 | 
			
		||||
    search_fields = ("name", "unix_name")
 | 
			
		||||
    autocomplete_fields = (
 | 
			
		||||
        "parent",
 | 
			
		||||
        "board_group",
 | 
			
		||||
        "members_group",
 | 
			
		||||
        "home",
 | 
			
		||||
        "page",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Membership)
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
 | 
			
		||||
 | 
			
		||||
from club.models import Club
 | 
			
		||||
from club.schemas import ClubSchema
 | 
			
		||||
from core.api_permissions import CanAccessLookup
 | 
			
		||||
from core.auth.api_permissions import CanAccessLookup
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@api_controller("/club")
 | 
			
		||||
 
 | 
			
		||||
@@ -3,19 +3,6 @@ from __future__ import unicode_literals
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
from club.models import Club
 | 
			
		||||
from core.operations import PsqlRunOnly
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def generate_club_pages(apps, schema_editor):
 | 
			
		||||
    def recursive_generate_club_page(club):
 | 
			
		||||
        club.make_page()
 | 
			
		||||
        for child in Club.objects.filter(parent=club).all():
 | 
			
		||||
            recursive_generate_club_page(child)
 | 
			
		||||
 | 
			
		||||
    for club in Club.objects.filter(parent=None).all():
 | 
			
		||||
        recursive_generate_club_page(club)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [("core", "0024_auto_20170906_1317"), ("club", "0010_club_logo")]
 | 
			
		||||
@@ -48,11 +35,4 @@ class Migration(migrations.Migration):
 | 
			
		||||
                null=True,
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        PsqlRunOnly(
 | 
			
		||||
            "SET CONSTRAINTS ALL IMMEDIATE", reverse_sql=migrations.RunSQL.noop
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(generate_club_pages),
 | 
			
		||||
        PsqlRunOnly(
 | 
			
		||||
            migrations.RunSQL.noop, reverse_sql="SET CONSTRAINTS ALL IMMEDIATE"
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										106
									
								
								club/migrations/0012_club_board_group_club_members_group.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								club/migrations/0012_club_board_group_club_members_group.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,106 @@
 | 
			
		||||
# Generated by Django 4.2.16 on 2024-11-20 17:08
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
import django.db.models.functions.datetime
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
from django.db.migrations.state import StateApps
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.utils.timezone import localdate
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def migrate_meta_groups(apps: StateApps, schema_editor):
 | 
			
		||||
    """Attach the existing meta groups to the clubs.
 | 
			
		||||
 | 
			
		||||
    Until now, the meta groups were not attached to the clubs,
 | 
			
		||||
    nor to the users.
 | 
			
		||||
    This creates actual foreign relationships between the clubs
 | 
			
		||||
    and theirs groups and the users and theirs groups.
 | 
			
		||||
 | 
			
		||||
    Warnings:
 | 
			
		||||
        When the meta groups associated with the clubs aren't found,
 | 
			
		||||
        they are created.
 | 
			
		||||
        Thus the migration shouldn't fail, and all the clubs will
 | 
			
		||||
        have their groups.
 | 
			
		||||
        However, there will probably be some groups that have
 | 
			
		||||
        not been found but exist nonetheless,
 | 
			
		||||
        so there will be duplicates and dangling groups.
 | 
			
		||||
        There must be a manual cleanup after this migration.
 | 
			
		||||
    """
 | 
			
		||||
    Group = apps.get_model("core", "Group")
 | 
			
		||||
    Club = apps.get_model("club", "Club")
 | 
			
		||||
 | 
			
		||||
    meta_groups = Group.objects.filter(is_meta=True)
 | 
			
		||||
    clubs = list(Club.objects.all())
 | 
			
		||||
    for club in clubs:
 | 
			
		||||
        club.board_group = meta_groups.get_or_create(
 | 
			
		||||
            name=club.unix_name + settings.SITH_BOARD_SUFFIX,
 | 
			
		||||
            defaults={"is_meta": True},
 | 
			
		||||
        )[0]
 | 
			
		||||
        club.members_group = meta_groups.get_or_create(
 | 
			
		||||
            name=club.unix_name + settings.SITH_MEMBER_SUFFIX,
 | 
			
		||||
            defaults={"is_meta": True},
 | 
			
		||||
        )[0]
 | 
			
		||||
        club.save()
 | 
			
		||||
        club.refresh_from_db()
 | 
			
		||||
        memberships = club.members.filter(
 | 
			
		||||
            Q(end_date=None) | Q(end_date__gt=localdate())
 | 
			
		||||
        ).select_related("user")
 | 
			
		||||
        club.members_group.users.set([m.user for m in memberships])
 | 
			
		||||
        club.board_group.users.set(
 | 
			
		||||
            [
 | 
			
		||||
                m.user
 | 
			
		||||
                for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# steps of the migration :
 | 
			
		||||
# - Create a nullable field for the board group and the member group
 | 
			
		||||
# - Edit those new fields to make them point to currently existing meta groups
 | 
			
		||||
# - When this data migration is done, make the fields non-nullable
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("core", "0040_alter_user_options_user_user_permissions_and_more"),
 | 
			
		||||
        ("club", "0011_auto_20180426_2013"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RemoveField(
 | 
			
		||||
            model_name="club",
 | 
			
		||||
            name="edit_groups",
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RemoveField(
 | 
			
		||||
            model_name="club",
 | 
			
		||||
            name="owner_group",
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RemoveField(
 | 
			
		||||
            model_name="club",
 | 
			
		||||
            name="view_groups",
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="club",
 | 
			
		||||
            name="board_group",
 | 
			
		||||
            field=models.OneToOneField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.PROTECT,
 | 
			
		||||
                related_name="club_board",
 | 
			
		||||
                to="core.group",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="club",
 | 
			
		||||
            name="members_group",
 | 
			
		||||
            field=models.OneToOneField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.PROTECT,
 | 
			
		||||
                related_name="club",
 | 
			
		||||
                to="core.group",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(
 | 
			
		||||
            migrate_meta_groups, reverse_code=migrations.RunPython.noop, elidable=True
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,36 @@
 | 
			
		||||
# Generated by Django 4.2.17 on 2025-01-04 16:46
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [("club", "0012_club_board_group_club_members_group")]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="club",
 | 
			
		||||
            name="board_group",
 | 
			
		||||
            field=models.OneToOneField(
 | 
			
		||||
                on_delete=django.db.models.deletion.PROTECT,
 | 
			
		||||
                related_name="club_board",
 | 
			
		||||
                to="core.group",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="club",
 | 
			
		||||
            name="members_group",
 | 
			
		||||
            field=models.OneToOneField(
 | 
			
		||||
                on_delete=django.db.models.deletion.PROTECT,
 | 
			
		||||
                related_name="club",
 | 
			
		||||
                to="core.group",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddConstraint(
 | 
			
		||||
            model_name="membership",
 | 
			
		||||
            constraint=models.CheckConstraint(
 | 
			
		||||
                check=models.Q(("end_date__gte", models.F("start_date"))),
 | 
			
		||||
                name="end_after_start",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										302
									
								
								club/models.py
									
									
									
									
									
								
							
							
						
						
									
										302
									
								
								club/models.py
									
									
									
									
									
								
							@@ -23,7 +23,7 @@
 | 
			
		||||
#
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from typing import Self
 | 
			
		||||
from typing import Iterable, Self
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.core import validators
 | 
			
		||||
@@ -31,14 +31,14 @@ from django.core.cache import cache
 | 
			
		||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
 | 
			
		||||
from django.core.validators import RegexValidator, validate_email
 | 
			
		||||
from django.db import models, transaction
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.db.models import Exists, F, OuterRef, Q
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.functional import cached_property
 | 
			
		||||
from django.utils.timezone import localdate
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
from core.models import Group, MetaGroup, Notification, Page, RealGroup, SithFile, User
 | 
			
		||||
from core.models import Group, Notification, Page, SithFile, User
 | 
			
		||||
 | 
			
		||||
# Create your models here.
 | 
			
		||||
 | 
			
		||||
@@ -79,19 +79,6 @@ class Club(models.Model):
 | 
			
		||||
        _("short description"), max_length=1000, default="", blank=True, null=True
 | 
			
		||||
    )
 | 
			
		||||
    address = models.CharField(_("address"), max_length=254)
 | 
			
		||||
 | 
			
		||||
    owner_group = models.ForeignKey(
 | 
			
		||||
        Group,
 | 
			
		||||
        related_name="owned_club",
 | 
			
		||||
        default=get_default_owner_group,
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
    edit_groups = models.ManyToManyField(
 | 
			
		||||
        Group, related_name="editable_club", blank=True
 | 
			
		||||
    )
 | 
			
		||||
    view_groups = models.ManyToManyField(
 | 
			
		||||
        Group, related_name="viewable_club", blank=True
 | 
			
		||||
    )
 | 
			
		||||
    home = models.OneToOneField(
 | 
			
		||||
        SithFile,
 | 
			
		||||
        related_name="home_of_club",
 | 
			
		||||
@@ -103,6 +90,12 @@ class Club(models.Model):
 | 
			
		||||
    page = models.OneToOneField(
 | 
			
		||||
        Page, related_name="club", blank=True, null=True, on_delete=models.CASCADE
 | 
			
		||||
    )
 | 
			
		||||
    members_group = models.OneToOneField(
 | 
			
		||||
        Group, related_name="club", on_delete=models.PROTECT
 | 
			
		||||
    )
 | 
			
		||||
    board_group = models.OneToOneField(
 | 
			
		||||
        Group, related_name="club_board", on_delete=models.PROTECT
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        ordering = ["name", "unix_name"]
 | 
			
		||||
@@ -112,23 +105,27 @@ class Club(models.Model):
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic()
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        old = Club.objects.filter(id=self.id).first()
 | 
			
		||||
        creation = old is None
 | 
			
		||||
        if not creation and old.unix_name != self.unix_name:
 | 
			
		||||
            self._change_unixname(self.unix_name)
 | 
			
		||||
        creation = self._state.adding
 | 
			
		||||
        if not creation:
 | 
			
		||||
            db_club = Club.objects.get(id=self.id)
 | 
			
		||||
            if self.unix_name != db_club.unix_name:
 | 
			
		||||
                self.home.name = self.unix_name
 | 
			
		||||
                self.home.save()
 | 
			
		||||
            if self.name != db_club.name:
 | 
			
		||||
                self.board_group.name = f"{self.name} - Bureau"
 | 
			
		||||
                self.board_group.save()
 | 
			
		||||
                self.members_group.name = f"{self.name} - Membres"
 | 
			
		||||
                self.members_group.save()
 | 
			
		||||
        if creation:
 | 
			
		||||
            self.board_group = Group.objects.create(
 | 
			
		||||
                name=f"{self.name} - Bureau", is_manually_manageable=False
 | 
			
		||||
            )
 | 
			
		||||
            self.members_group = Group.objects.create(
 | 
			
		||||
                name=f"{self.name} - Membres", is_manually_manageable=False
 | 
			
		||||
            )
 | 
			
		||||
        super().save(*args, **kwargs)
 | 
			
		||||
        if creation:
 | 
			
		||||
            board = MetaGroup(name=self.unix_name + settings.SITH_BOARD_SUFFIX)
 | 
			
		||||
            board.save()
 | 
			
		||||
            member = MetaGroup(name=self.unix_name + settings.SITH_MEMBER_SUFFIX)
 | 
			
		||||
            member.save()
 | 
			
		||||
            subscribers = Group.objects.filter(
 | 
			
		||||
                name=settings.SITH_MAIN_MEMBERS_GROUP
 | 
			
		||||
            ).first()
 | 
			
		||||
            self.make_home()
 | 
			
		||||
            self.home.edit_groups.set([board])
 | 
			
		||||
            self.home.view_groups.set([member, subscribers])
 | 
			
		||||
            self.home.save()
 | 
			
		||||
        self.make_page()
 | 
			
		||||
        cache.set(f"sith_club_{self.unix_name}", self)
 | 
			
		||||
 | 
			
		||||
@@ -136,7 +133,8 @@ class Club(models.Model):
 | 
			
		||||
        return reverse("club:club_view", kwargs={"club_id": self.id})
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def president(self):
 | 
			
		||||
    def president(self) -> Membership | None:
 | 
			
		||||
        """Fetch the membership of the current president of this club."""
 | 
			
		||||
        return self.members.filter(
 | 
			
		||||
            role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
 | 
			
		||||
        ).first()
 | 
			
		||||
@@ -154,27 +152,9 @@ class Club(models.Model):
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        self.check_loop()
 | 
			
		||||
 | 
			
		||||
    def _change_unixname(self, old_name, new_name):
 | 
			
		||||
        c = Club.objects.filter(unix_name=new_name).first()
 | 
			
		||||
        if c is None:
 | 
			
		||||
            # Update all the groups names
 | 
			
		||||
            Group.objects.filter(name=old_name).update(name=new_name)
 | 
			
		||||
            Group.objects.filter(name=old_name + settings.SITH_BOARD_SUFFIX).update(
 | 
			
		||||
                name=new_name + settings.SITH_BOARD_SUFFIX
 | 
			
		||||
            )
 | 
			
		||||
            Group.objects.filter(name=old_name + settings.SITH_MEMBER_SUFFIX).update(
 | 
			
		||||
                name=new_name + settings.SITH_MEMBER_SUFFIX
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def make_home(self) -> None:
 | 
			
		||||
        if self.home:
 | 
			
		||||
                self.home.name = new_name
 | 
			
		||||
                self.home.save()
 | 
			
		||||
 | 
			
		||||
        else:
 | 
			
		||||
            raise ValidationError(_("A club with that unix_name already exists"))
 | 
			
		||||
 | 
			
		||||
    def make_home(self):
 | 
			
		||||
        if not self.home:
 | 
			
		||||
            return
 | 
			
		||||
        home_root = SithFile.objects.filter(parent=None, name="clubs").first()
 | 
			
		||||
        root = User.objects.filter(username="root").first()
 | 
			
		||||
        if home_root and root:
 | 
			
		||||
@@ -183,7 +163,7 @@ class Club(models.Model):
 | 
			
		||||
            self.home = home
 | 
			
		||||
            self.save()
 | 
			
		||||
 | 
			
		||||
    def make_page(self):
 | 
			
		||||
    def make_page(self) -> None:
 | 
			
		||||
        root = User.objects.filter(username="root").first()
 | 
			
		||||
        if not self.page:
 | 
			
		||||
            club_root = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
 | 
			
		||||
@@ -213,35 +193,34 @@ class Club(models.Model):
 | 
			
		||||
            self.page.parent = self.parent.page
 | 
			
		||||
            self.page.save(force_lock=True)
 | 
			
		||||
 | 
			
		||||
    def delete(self, *args, **kwargs):
 | 
			
		||||
    def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
 | 
			
		||||
        # Invalidate the cache of this club and of its memberships
 | 
			
		||||
        for membership in self.members.ongoing().select_related("user"):
 | 
			
		||||
            cache.delete(f"membership_{self.id}_{membership.user.id}")
 | 
			
		||||
        cache.delete(f"sith_club_{self.unix_name}")
 | 
			
		||||
        super().delete(*args, **kwargs)
 | 
			
		||||
        self.board_group.delete()
 | 
			
		||||
        self.members_group.delete()
 | 
			
		||||
        return super().delete(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def get_display_name(self):
 | 
			
		||||
    def get_display_name(self) -> str:
 | 
			
		||||
        return self.name
 | 
			
		||||
 | 
			
		||||
    def is_owned_by(self, user):
 | 
			
		||||
    def is_owned_by(self, user: User) -> bool:
 | 
			
		||||
        """Method to see if that object can be super edited by the given user."""
 | 
			
		||||
        if user.is_anonymous:
 | 
			
		||||
            return False
 | 
			
		||||
        return user.is_board_member
 | 
			
		||||
        return user.is_root or user.is_board_member
 | 
			
		||||
 | 
			
		||||
    def get_full_logo_url(self):
 | 
			
		||||
        return "https://%s%s" % (settings.SITH_URL, self.logo.url)
 | 
			
		||||
    def get_full_logo_url(self) -> str:
 | 
			
		||||
        return f"https://{settings.SITH_URL}{self.logo.url}"
 | 
			
		||||
 | 
			
		||||
    def can_be_edited_by(self, user):
 | 
			
		||||
    def can_be_edited_by(self, user: User) -> bool:
 | 
			
		||||
        """Method to see if that object can be edited by the given user."""
 | 
			
		||||
        return self.has_rights_in_club(user)
 | 
			
		||||
 | 
			
		||||
    def can_be_viewed_by(self, user):
 | 
			
		||||
    def can_be_viewed_by(self, user: User) -> bool:
 | 
			
		||||
        """Method to see if that object can be seen by the given user."""
 | 
			
		||||
        sub = User.objects.filter(pk=user.pk).first()
 | 
			
		||||
        if sub is None:
 | 
			
		||||
            return False
 | 
			
		||||
        return sub.was_subscribed
 | 
			
		||||
        return user.was_subscribed
 | 
			
		||||
 | 
			
		||||
    def get_membership_for(self, user: User) -> Membership | None:
 | 
			
		||||
        """Return the current membership the given user.
 | 
			
		||||
@@ -262,9 +241,8 @@ class Club(models.Model):
 | 
			
		||||
                cache.set(f"membership_{self.id}_{user.id}", membership)
 | 
			
		||||
        return membership
 | 
			
		||||
 | 
			
		||||
    def has_rights_in_club(self, user):
 | 
			
		||||
        m = self.get_membership_for(user)
 | 
			
		||||
        return m is not None and m.role > settings.SITH_MAXIMUM_FREE_ROLE
 | 
			
		||||
    def has_rights_in_club(self, user: User) -> bool:
 | 
			
		||||
        return user.is_in_group(pk=self.board_group_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MembershipQuerySet(models.QuerySet):
 | 
			
		||||
@@ -283,42 +261,65 @@ class MembershipQuerySet(models.QuerySet):
 | 
			
		||||
        """
 | 
			
		||||
        return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
 | 
			
		||||
 | 
			
		||||
    def update(self, **kwargs):
 | 
			
		||||
        """Refresh the cache for the elements of the queryset.
 | 
			
		||||
    def update(self, **kwargs) -> int:
 | 
			
		||||
        """Refresh the cache and edit group ownership.
 | 
			
		||||
 | 
			
		||||
        Besides that, does the same job as a regular update method.
 | 
			
		||||
        Update the cache, when necessary, remove
 | 
			
		||||
        users from club groups they are no more in
 | 
			
		||||
        and add them in the club groups they should be in.
 | 
			
		||||
 | 
			
		||||
        Be aware that this adds a db query to retrieve the updated objects
 | 
			
		||||
        Be aware that this adds three db queries :
 | 
			
		||||
        one to retrieve the updated memberships,
 | 
			
		||||
        one to perform group removal and one to perform
 | 
			
		||||
        group attribution.
 | 
			
		||||
        """
 | 
			
		||||
        nb_rows = super().update(**kwargs)
 | 
			
		||||
        if nb_rows > 0:
 | 
			
		||||
            # if at least a row was affected, refresh the cache
 | 
			
		||||
            for membership in self.all():
 | 
			
		||||
                if membership.end_date is not None:
 | 
			
		||||
                    cache.set(
 | 
			
		||||
                        f"membership_{membership.club_id}_{membership.user_id}",
 | 
			
		||||
                        "not_member",
 | 
			
		||||
                    )
 | 
			
		||||
                else:
 | 
			
		||||
                    cache.set(
 | 
			
		||||
                        f"membership_{membership.club_id}_{membership.user_id}",
 | 
			
		||||
                        membership,
 | 
			
		||||
                    )
 | 
			
		||||
        if nb_rows == 0:
 | 
			
		||||
            # if no row was affected, no need to refresh the cache
 | 
			
		||||
            return 0
 | 
			
		||||
 | 
			
		||||
    def delete(self):
 | 
			
		||||
        cache_memberships = {}
 | 
			
		||||
        memberships = set(self.select_related("club"))
 | 
			
		||||
        # delete all User-Group relations and recreate the necessary ones
 | 
			
		||||
        # It's more concise to write and more reliable
 | 
			
		||||
        Membership._remove_club_groups(memberships)
 | 
			
		||||
        Membership._add_club_groups(memberships)
 | 
			
		||||
        for member in memberships:
 | 
			
		||||
            cache_key = f"membership_{member.club_id}_{member.user_id}"
 | 
			
		||||
            if member.end_date is None:
 | 
			
		||||
                cache_memberships[cache_key] = member
 | 
			
		||||
            else:
 | 
			
		||||
                cache_memberships[cache_key] = "not_member"
 | 
			
		||||
        cache.set_many(cache_memberships)
 | 
			
		||||
        return nb_rows
 | 
			
		||||
 | 
			
		||||
    def delete(self) -> tuple[int, dict[str, int]]:
 | 
			
		||||
        """Work just like the default Django's delete() method,
 | 
			
		||||
        but add a cache invalidation for the elements of the queryset
 | 
			
		||||
        before the deletion.
 | 
			
		||||
        before the deletion,
 | 
			
		||||
        and a removal of the user from the club groups.
 | 
			
		||||
 | 
			
		||||
        Be aware that this adds a db query to retrieve the deleted element.
 | 
			
		||||
        As this first query take place before the deletion operation,
 | 
			
		||||
        it will be performed even if the deletion fails.
 | 
			
		||||
        Be aware that this adds some db queries :
 | 
			
		||||
 | 
			
		||||
        - 1 to retrieve the deleted elements in order to perform
 | 
			
		||||
          post-delete operations.
 | 
			
		||||
          As we can't know if a delete will affect rows or not,
 | 
			
		||||
          this query will always happen
 | 
			
		||||
        - 1 query to remove the users from the club groups.
 | 
			
		||||
          If the delete operation affected no row,
 | 
			
		||||
          this query won't happen.
 | 
			
		||||
        """
 | 
			
		||||
        ids = list(self.values_list("club_id", "user_id"))
 | 
			
		||||
        nb_rows, _ = super().delete()
 | 
			
		||||
        memberships = set(self.all())
 | 
			
		||||
        nb_rows, rows_counts = super().delete()
 | 
			
		||||
        if nb_rows > 0:
 | 
			
		||||
            for club_id, user_id in ids:
 | 
			
		||||
                cache.set(f"membership_{club_id}_{user_id}", "not_member")
 | 
			
		||||
            Membership._remove_club_groups(memberships)
 | 
			
		||||
            cache.set_many(
 | 
			
		||||
                {
 | 
			
		||||
                    f"membership_{m.club_id}_{m.user_id}": "not_member"
 | 
			
		||||
                    for m in memberships
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
        return nb_rows, rows_counts
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Membership(models.Model):
 | 
			
		||||
@@ -361,6 +362,13 @@ class Membership(models.Model):
 | 
			
		||||
 | 
			
		||||
    objects = MembershipQuerySet.as_manager()
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        constraints = [
 | 
			
		||||
            models.CheckConstraint(
 | 
			
		||||
                check=Q(end_date__gte=F("start_date")), name="end_after_start"
 | 
			
		||||
            ),
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return (
 | 
			
		||||
            f"{self.club.name} - {self.user.username} "
 | 
			
		||||
@@ -370,7 +378,14 @@ class Membership(models.Model):
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        super().save(*args, **kwargs)
 | 
			
		||||
        # a save may either be an update or a creation
 | 
			
		||||
        # and may result in either an ongoing or an ended membership.
 | 
			
		||||
        # It could also be a retrogradation from the board to being a simple member.
 | 
			
		||||
        # To avoid problems, the user is removed from the club groups beforehand ;
 | 
			
		||||
        # he will be added back if necessary
 | 
			
		||||
        self._remove_club_groups([self])
 | 
			
		||||
        if self.end_date is None:
 | 
			
		||||
            self._add_club_groups([self])
 | 
			
		||||
            cache.set(f"membership_{self.club_id}_{self.user_id}", self)
 | 
			
		||||
        else:
 | 
			
		||||
            cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
 | 
			
		||||
@@ -378,11 +393,11 @@ class Membership(models.Model):
 | 
			
		||||
    def get_absolute_url(self):
 | 
			
		||||
        return reverse("club:club_members", kwargs={"club_id": self.club_id})
 | 
			
		||||
 | 
			
		||||
    def is_owned_by(self, user):
 | 
			
		||||
    def is_owned_by(self, user: User) -> bool:
 | 
			
		||||
        """Method to see if that object can be super edited by the given user."""
 | 
			
		||||
        if user.is_anonymous:
 | 
			
		||||
            return False
 | 
			
		||||
        return user.is_board_member
 | 
			
		||||
        return user.is_root or user.is_board_member
 | 
			
		||||
 | 
			
		||||
    def can_be_edited_by(self, user: User) -> bool:
 | 
			
		||||
        """Check if that object can be edited by the given user."""
 | 
			
		||||
@@ -392,9 +407,91 @@ class Membership(models.Model):
 | 
			
		||||
        return membership is not None and membership.role >= self.role
 | 
			
		||||
 | 
			
		||||
    def delete(self, *args, **kwargs):
 | 
			
		||||
        self._remove_club_groups([self])
 | 
			
		||||
        super().delete(*args, **kwargs)
 | 
			
		||||
        cache.delete(f"membership_{self.club_id}_{self.user_id}")
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _remove_club_groups(
 | 
			
		||||
        memberships: Iterable[Membership],
 | 
			
		||||
    ) -> tuple[int, dict[str, int]]:
 | 
			
		||||
        """Remove users of those memberships from the club groups.
 | 
			
		||||
 | 
			
		||||
        For example, if a user is in the Troll club board,
 | 
			
		||||
        he is in the board group and the members group of the Troll.
 | 
			
		||||
        After calling this function, he will be in neither.
 | 
			
		||||
 | 
			
		||||
        Returns:
 | 
			
		||||
            The result of the deletion queryset.
 | 
			
		||||
 | 
			
		||||
        Warnings:
 | 
			
		||||
            If this function isn't used in combination
 | 
			
		||||
            with an actual deletion of the memberships,
 | 
			
		||||
            it will result in an inconsistent state,
 | 
			
		||||
            where users will be in the clubs, without
 | 
			
		||||
            having the associated rights.
 | 
			
		||||
        """
 | 
			
		||||
        clubs = {m.club_id for m in memberships}
 | 
			
		||||
        users = {m.user_id for m in memberships}
 | 
			
		||||
        groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs))
 | 
			
		||||
        return User.groups.through.objects.filter(
 | 
			
		||||
            Q(group__in=groups) & Q(user__in=users)
 | 
			
		||||
        ).delete()
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _add_club_groups(
 | 
			
		||||
        memberships: Iterable[Membership],
 | 
			
		||||
    ) -> list[User.groups.through]:
 | 
			
		||||
        """Add users of those memberships to the club groups.
 | 
			
		||||
 | 
			
		||||
        For example, if a user just joined the Troll club board,
 | 
			
		||||
        he will be added in both the members group and the board group
 | 
			
		||||
        of the club.
 | 
			
		||||
 | 
			
		||||
        Returns:
 | 
			
		||||
            The created User-Group relations.
 | 
			
		||||
 | 
			
		||||
        Warnings:
 | 
			
		||||
            If this function isn't used in combination
 | 
			
		||||
            with an actual update/creation of the memberships,
 | 
			
		||||
            it will result in an inconsistent state,
 | 
			
		||||
            where users will have the rights associated to the
 | 
			
		||||
            club, without actually being part of it.
 | 
			
		||||
        """
 | 
			
		||||
        # only active membership (i.e. `end_date=None`)
 | 
			
		||||
        # grant the attribution of club groups.
 | 
			
		||||
        memberships = [m for m in memberships if m.end_date is None]
 | 
			
		||||
        if not memberships:
 | 
			
		||||
            return []
 | 
			
		||||
 | 
			
		||||
        if sum(1 for m in memberships if not hasattr(m, "club")) > 1:
 | 
			
		||||
            # if more than one membership hasn't its `club` attribute set
 | 
			
		||||
            # it's less expensive to reload the whole query with
 | 
			
		||||
            # a select_related than perform a distinct query
 | 
			
		||||
            # to fetch each club.
 | 
			
		||||
            ids = {m.id for m in memberships}
 | 
			
		||||
            memberships = list(
 | 
			
		||||
                Membership.objects.filter(id__in=ids).select_related("club")
 | 
			
		||||
            )
 | 
			
		||||
        club_groups = []
 | 
			
		||||
        for membership in memberships:
 | 
			
		||||
            club_groups.append(
 | 
			
		||||
                User.groups.through(
 | 
			
		||||
                    user_id=membership.user_id,
 | 
			
		||||
                    group_id=membership.club.members_group_id,
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
            if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
 | 
			
		||||
                club_groups.append(
 | 
			
		||||
                    User.groups.through(
 | 
			
		||||
                        user_id=membership.user_id,
 | 
			
		||||
                        group_id=membership.club.board_group_id,
 | 
			
		||||
                    )
 | 
			
		||||
                )
 | 
			
		||||
        return User.groups.through.objects.bulk_create(
 | 
			
		||||
            club_groups, ignore_conflicts=True
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Mailing(models.Model):
 | 
			
		||||
    """A Mailing list for a club.
 | 
			
		||||
@@ -438,14 +535,13 @@ class Mailing(models.Model):
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        if not self.is_moderated:
 | 
			
		||||
            for user in (
 | 
			
		||||
                RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
			
		||||
                .first()
 | 
			
		||||
                .users.all()
 | 
			
		||||
            unread_notif_subquery = Notification.objects.filter(
 | 
			
		||||
                user=OuterRef("pk"), type="MAILING_MODERATION", viewed=False
 | 
			
		||||
            )
 | 
			
		||||
            for user in User.objects.filter(
 | 
			
		||||
                ~Exists(unread_notif_subquery),
 | 
			
		||||
                groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
 | 
			
		||||
            ):
 | 
			
		||||
                if not user.notifications.filter(
 | 
			
		||||
                    type="MAILING_MODERATION", viewed=False
 | 
			
		||||
                ).exists():
 | 
			
		||||
                Notification(
 | 
			
		||||
                    user=user,
 | 
			
		||||
                    url=reverse("com:mailing_admin"),
 | 
			
		||||
 
 | 
			
		||||
@@ -7,3 +7,17 @@ class ClubSchema(ModelSchema):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Club
 | 
			
		||||
        fields = ["id", "name"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ClubProfileSchema(ModelSchema):
 | 
			
		||||
    """The infos needed to display a simple club profile."""
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Club
 | 
			
		||||
        fields = ["id", "name", "logo"]
 | 
			
		||||
 | 
			
		||||
    url: str
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def resolve_url(obj: Club) -> str:
 | 
			
		||||
        return obj.get_absolute_url()
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										117
									
								
								club/tests.py
									
									
									
									
									
								
							
							
						
						
									
										117
									
								
								club/tests.py
									
									
									
									
									
								
							@@ -21,6 +21,7 @@ from django.urls import reverse
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.timezone import localdate, localtime, now
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from model_bakery import baker
 | 
			
		||||
 | 
			
		||||
from club.forms import MailingForm
 | 
			
		||||
from club.models import Club, Mailing, Membership
 | 
			
		||||
@@ -164,6 +165,27 @@ class TestMembershipQuerySet(TestClub):
 | 
			
		||||
        assert new_mem != "not_member"
 | 
			
		||||
        assert new_mem.role == 5
 | 
			
		||||
 | 
			
		||||
    def test_update_change_club_groups(self):
 | 
			
		||||
        """Test that `update` set the user groups accordingly."""
 | 
			
		||||
        user = baker.make(User)
 | 
			
		||||
        membership = baker.make(Membership, end_date=None, user=user, role=5)
 | 
			
		||||
        members_group = membership.club.members_group
 | 
			
		||||
        board_group = membership.club.board_group
 | 
			
		||||
        assert user.groups.contains(members_group)
 | 
			
		||||
        assert user.groups.contains(board_group)
 | 
			
		||||
 | 
			
		||||
        user.memberships.update(role=1)  # from board to simple member
 | 
			
		||||
        assert user.groups.contains(members_group)
 | 
			
		||||
        assert not user.groups.contains(board_group)
 | 
			
		||||
 | 
			
		||||
        user.memberships.update(role=5)  # from member to board
 | 
			
		||||
        assert user.groups.contains(members_group)
 | 
			
		||||
        assert user.groups.contains(board_group)
 | 
			
		||||
 | 
			
		||||
        user.memberships.update(end_date=localdate())  # end the membership
 | 
			
		||||
        assert not user.groups.contains(members_group)
 | 
			
		||||
        assert not user.groups.contains(board_group)
 | 
			
		||||
 | 
			
		||||
    def test_delete_invalidate_cache(self):
 | 
			
		||||
        """Test that the `delete` queryset properly invalidate cache."""
 | 
			
		||||
        mem_skia = self.skia.memberships.get(club=self.club)
 | 
			
		||||
@@ -182,6 +204,19 @@ class TestMembershipQuerySet(TestClub):
 | 
			
		||||
            )
 | 
			
		||||
            assert cached_mem == "not_member"
 | 
			
		||||
 | 
			
		||||
    def test_delete_remove_from_groups(self):
 | 
			
		||||
        """Test that `delete` removes from club groups"""
 | 
			
		||||
        user = baker.make(User)
 | 
			
		||||
        memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
 | 
			
		||||
        club_groups = {
 | 
			
		||||
            memberships[0].club.members_group,
 | 
			
		||||
            memberships[1].club.members_group,
 | 
			
		||||
            memberships[1].club.board_group,
 | 
			
		||||
        }
 | 
			
		||||
        assert set(user.groups.all()).issuperset(club_groups)
 | 
			
		||||
        user.memberships.all().delete()
 | 
			
		||||
        assert set(user.groups.all()).isdisjoint(club_groups)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestClubModel(TestClub):
 | 
			
		||||
    def assert_membership_started_today(self, user: User, role: int):
 | 
			
		||||
@@ -192,10 +227,8 @@ class TestClubModel(TestClub):
 | 
			
		||||
        assert membership.end_date is None
 | 
			
		||||
        assert membership.role == role
 | 
			
		||||
        assert membership.club.get_membership_for(user) == membership
 | 
			
		||||
        member_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
 | 
			
		||||
        board_group = self.club.unix_name + settings.SITH_BOARD_SUFFIX
 | 
			
		||||
        assert user.is_in_group(name=member_group)
 | 
			
		||||
        assert user.is_in_group(name=board_group)
 | 
			
		||||
        assert user.is_in_group(pk=self.club.members_group_id)
 | 
			
		||||
        assert user.is_in_group(pk=self.club.board_group_id)
 | 
			
		||||
 | 
			
		||||
    def assert_membership_ended_today(self, user: User):
 | 
			
		||||
        """Assert that the given user have a membership which ended today."""
 | 
			
		||||
@@ -474,37 +507,35 @@ class TestClubModel(TestClub):
 | 
			
		||||
        assert self.club.members.count() == nb_memberships
 | 
			
		||||
        assert membership == new_mem
 | 
			
		||||
 | 
			
		||||
    def test_delete_remove_from_meta_group(self):
 | 
			
		||||
        """Test that when a club is deleted, all its members are removed from the
 | 
			
		||||
        associated metagroup.
 | 
			
		||||
        """
 | 
			
		||||
        memberships = self.club.members.select_related("user")
 | 
			
		||||
        users = [membership.user for membership in memberships]
 | 
			
		||||
        meta_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
 | 
			
		||||
    def test_remove_from_club_group(self):
 | 
			
		||||
        """Test that when a membership ends, the user is removed from club groups."""
 | 
			
		||||
        user = baker.make(User)
 | 
			
		||||
        baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
 | 
			
		||||
        assert user.groups.contains(self.club.members_group)
 | 
			
		||||
        assert user.groups.contains(self.club.board_group)
 | 
			
		||||
        user.memberships.update(end_date=localdate())
 | 
			
		||||
        assert not user.groups.contains(self.club.members_group)
 | 
			
		||||
        assert not user.groups.contains(self.club.board_group)
 | 
			
		||||
 | 
			
		||||
        self.club.delete()
 | 
			
		||||
        for user in users:
 | 
			
		||||
            assert not user.is_in_group(name=meta_group)
 | 
			
		||||
    def test_add_to_club_group(self):
 | 
			
		||||
        """Test that when a membership begins, the user is added to the club group."""
 | 
			
		||||
        assert not self.subscriber.groups.contains(self.club.members_group)
 | 
			
		||||
        assert not self.subscriber.groups.contains(self.club.board_group)
 | 
			
		||||
        baker.make(Membership, club=self.club, user=self.subscriber, role=3)
 | 
			
		||||
        assert self.subscriber.groups.contains(self.club.members_group)
 | 
			
		||||
        assert self.subscriber.groups.contains(self.club.board_group)
 | 
			
		||||
 | 
			
		||||
    def test_add_to_meta_group(self):
 | 
			
		||||
        """Test that when a membership begins, the user is added to the meta group."""
 | 
			
		||||
        group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
 | 
			
		||||
        board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX
 | 
			
		||||
        assert not self.subscriber.is_in_group(name=group_members)
 | 
			
		||||
        assert not self.subscriber.is_in_group(name=board_members)
 | 
			
		||||
        Membership.objects.create(club=self.club, user=self.subscriber, role=3)
 | 
			
		||||
        assert self.subscriber.is_in_group(name=group_members)
 | 
			
		||||
        assert self.subscriber.is_in_group(name=board_members)
 | 
			
		||||
 | 
			
		||||
    def test_remove_from_meta_group(self):
 | 
			
		||||
        """Test that when a membership ends, the user is removed from meta group."""
 | 
			
		||||
        group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
 | 
			
		||||
        board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX
 | 
			
		||||
        assert self.comptable.is_in_group(name=group_members)
 | 
			
		||||
        assert self.comptable.is_in_group(name=board_members)
 | 
			
		||||
        self.comptable.memberships.update(end_date=localtime(now()))
 | 
			
		||||
        assert not self.comptable.is_in_group(name=group_members)
 | 
			
		||||
        assert not self.comptable.is_in_group(name=board_members)
 | 
			
		||||
    def test_change_position_in_club(self):
 | 
			
		||||
        """Test that when moving from board to members, club group change"""
 | 
			
		||||
        membership = baker.make(
 | 
			
		||||
            Membership, club=self.club, user=self.subscriber, role=3
 | 
			
		||||
        )
 | 
			
		||||
        assert self.subscriber.groups.contains(self.club.members_group)
 | 
			
		||||
        assert self.subscriber.groups.contains(self.club.board_group)
 | 
			
		||||
        membership.role = 1
 | 
			
		||||
        membership.save()
 | 
			
		||||
        assert self.subscriber.groups.contains(self.club.members_group)
 | 
			
		||||
        assert not self.subscriber.groups.contains(self.club.board_group)
 | 
			
		||||
 | 
			
		||||
    def test_club_owner(self):
 | 
			
		||||
        """Test that a club is owned only by board members of the main club."""
 | 
			
		||||
@@ -517,6 +548,26 @@ class TestClubModel(TestClub):
 | 
			
		||||
        Membership(club=self.ae, user=self.sli, role=3).save()
 | 
			
		||||
        assert self.club.is_owned_by(self.sli)
 | 
			
		||||
 | 
			
		||||
    def test_change_club_name(self):
 | 
			
		||||
        """Test that changing the club name doesn't break things."""
 | 
			
		||||
        members_group = self.club.members_group
 | 
			
		||||
        board_group = self.club.board_group
 | 
			
		||||
        initial_members = set(members_group.users.values_list("id", flat=True))
 | 
			
		||||
        initial_board = set(board_group.users.values_list("id", flat=True))
 | 
			
		||||
        self.club.name = "something else"
 | 
			
		||||
        self.club.save()
 | 
			
		||||
        self.club.refresh_from_db()
 | 
			
		||||
 | 
			
		||||
        # The names should have changed, but not the ids nor the group members
 | 
			
		||||
        assert self.club.members_group.name == "something else - Membres"
 | 
			
		||||
        assert self.club.board_group.name == "something else - Bureau"
 | 
			
		||||
        assert self.club.members_group.id == members_group.id
 | 
			
		||||
        assert self.club.board_group.id == board_group.id
 | 
			
		||||
        new_members = set(self.club.members_group.users.values_list("id", flat=True))
 | 
			
		||||
        new_board = set(self.club.board_group.users.values_list("id", flat=True))
 | 
			
		||||
        assert new_members == initial_members
 | 
			
		||||
        assert new_board == initial_board
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestMailingForm(TestCase):
 | 
			
		||||
    """Perform validation tests for MailingForm."""
 | 
			
		||||
 
 | 
			
		||||
@@ -25,6 +25,7 @@
 | 
			
		||||
import csv
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.mixins import PermissionRequiredMixin
 | 
			
		||||
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
 | 
			
		||||
from django.core.paginator import InvalidPage, Paginator
 | 
			
		||||
from django.db.models import Sum
 | 
			
		||||
@@ -49,17 +50,15 @@ from com.views import (
 | 
			
		||||
    PosterEditBaseView,
 | 
			
		||||
    PosterListBaseView,
 | 
			
		||||
)
 | 
			
		||||
from core.models import PageRev
 | 
			
		||||
from core.views import (
 | 
			
		||||
from core.auth.mixins import (
 | 
			
		||||
    CanCreateMixin,
 | 
			
		||||
    CanEditMixin,
 | 
			
		||||
    CanEditPropMixin,
 | 
			
		||||
    CanViewMixin,
 | 
			
		||||
    DetailFormView,
 | 
			
		||||
    PageEditViewBase,
 | 
			
		||||
    TabedViewMixin,
 | 
			
		||||
    UserIsRootMixin,
 | 
			
		||||
)
 | 
			
		||||
from core.models import PageRev
 | 
			
		||||
from core.views import DetailFormView, PageEditViewBase
 | 
			
		||||
from core.views.mixins import TabedViewMixin
 | 
			
		||||
from counter.models import Selling
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -71,14 +70,13 @@ class ClubTabsMixin(TabedViewMixin):
 | 
			
		||||
        return self.object.get_display_name()
 | 
			
		||||
 | 
			
		||||
    def get_list_of_tabs(self):
 | 
			
		||||
        tab_list = []
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
        tab_list = [
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
 | 
			
		||||
                "slug": "infos",
 | 
			
		||||
                "name": _("Infos"),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        ]
 | 
			
		||||
        if self.request.user.can_view(self.object):
 | 
			
		||||
            tab_list.append(
 | 
			
		||||
                {
 | 
			
		||||
@@ -258,7 +256,7 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
 | 
			
		||||
    def get_form_kwargs(self):
 | 
			
		||||
        kwargs = super().get_form_kwargs()
 | 
			
		||||
        kwargs["request_user"] = self.request.user
 | 
			
		||||
        kwargs["club"] = self.get_object()
 | 
			
		||||
        kwargs["club"] = self.object
 | 
			
		||||
        kwargs["club_members"] = self.members
 | 
			
		||||
        return kwargs
 | 
			
		||||
 | 
			
		||||
@@ -275,9 +273,9 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
 | 
			
		||||
        users = data.pop("users", [])
 | 
			
		||||
        users_old = data.pop("users_old", [])
 | 
			
		||||
        for user in users:
 | 
			
		||||
            Membership(club=self.get_object(), user=user, **data).save()
 | 
			
		||||
            Membership(club=self.object, user=user, **data).save()
 | 
			
		||||
        for user in users_old:
 | 
			
		||||
            membership = self.get_object().get_membership_for(user)
 | 
			
		||||
            membership = self.object.get_membership_for(user)
 | 
			
		||||
            membership.end_date = timezone.now()
 | 
			
		||||
            membership.save()
 | 
			
		||||
        return resp
 | 
			
		||||
@@ -287,9 +285,7 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
 | 
			
		||||
        return super().dispatch(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self, **kwargs):
 | 
			
		||||
        return reverse_lazy(
 | 
			
		||||
            "club:club_members", kwargs={"club_id": self.get_object().id}
 | 
			
		||||
        )
 | 
			
		||||
        return reverse_lazy("club:club_members", kwargs={"club_id": self.object.id})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ClubOldMembersView(ClubTabsMixin, CanViewMixin, DetailView):
 | 
			
		||||
@@ -475,13 +471,14 @@ class ClubEditPropView(ClubTabsMixin, CanEditPropMixin, UpdateView):
 | 
			
		||||
    current_tab = "props"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ClubCreateView(CanCreateMixin, CreateView):
 | 
			
		||||
class ClubCreateView(PermissionRequiredMixin, CreateView):
 | 
			
		||||
    """Create a club (for the Sith admin)."""
 | 
			
		||||
 | 
			
		||||
    model = Club
 | 
			
		||||
    pk_url_kwarg = "club_id"
 | 
			
		||||
    fields = ["name", "unix_name", "parent"]
 | 
			
		||||
    template_name = "core/edit.jinja"
 | 
			
		||||
    permission_required = "club.add_club"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MembershipSetOldView(CanEditMixin, DetailView):
 | 
			
		||||
@@ -513,12 +510,13 @@ class MembershipSetOldView(CanEditMixin, DetailView):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MembershipDeleteView(UserIsRootMixin, DeleteView):
 | 
			
		||||
class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
 | 
			
		||||
    """Delete a membership (for admins only)."""
 | 
			
		||||
 | 
			
		||||
    model = Membership
 | 
			
		||||
    pk_url_kwarg = "membership_id"
 | 
			
		||||
    template_name = "core/delete_confirm.jinja"
 | 
			
		||||
    permission_required = "club.delete_membership"
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self):
 | 
			
		||||
        return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id})
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12
									
								
								com/admin.py
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								com/admin.py
									
									
									
									
									
								
							@@ -13,17 +13,25 @@
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
from django.contrib.admin import TabularInline
 | 
			
		||||
from haystack.admin import SearchModelAdmin
 | 
			
		||||
 | 
			
		||||
from com.models import News, Poster, Screen, Sith, Weekmail
 | 
			
		||||
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsDateInline(TabularInline):
 | 
			
		||||
    model = NewsDate
 | 
			
		||||
    extra = 0
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(News)
 | 
			
		||||
class NewsAdmin(SearchModelAdmin):
 | 
			
		||||
    list_display = ("title", "type", "club", "author")
 | 
			
		||||
    list_display = ("title", "club", "author")
 | 
			
		||||
    search_fields = ("title", "summary", "content")
 | 
			
		||||
    autocomplete_fields = ("author", "moderator")
 | 
			
		||||
 | 
			
		||||
    inlines = [NewsDateInline]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Poster)
 | 
			
		||||
class PosterAdmin(SearchModelAdmin):
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										104
									
								
								com/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								com/api.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,104 @@
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from typing import Literal
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.http import Http404, HttpResponse
 | 
			
		||||
from ninja import Query
 | 
			
		||||
from ninja_extra import ControllerBase, api_controller, paginate, route
 | 
			
		||||
from ninja_extra.pagination import PageNumberPaginationExtra
 | 
			
		||||
from ninja_extra.permissions import IsAuthenticated
 | 
			
		||||
from ninja_extra.schemas import PaginatedResponseSchema
 | 
			
		||||
 | 
			
		||||
from com.calendar import IcsCalendar
 | 
			
		||||
from com.models import News, NewsDate
 | 
			
		||||
from com.schemas import NewsDateFilterSchema, NewsDateSchema
 | 
			
		||||
from core.auth.api_permissions import HasPerm
 | 
			
		||||
from core.views.files import send_raw_file
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@api_controller("/calendar")
 | 
			
		||||
class CalendarController(ControllerBase):
 | 
			
		||||
    CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars"
 | 
			
		||||
 | 
			
		||||
    @route.get("/external.ics", url_name="calendar_external")
 | 
			
		||||
    def calendar_external(self):
 | 
			
		||||
        """Return the ICS file of the AE Google Calendar
 | 
			
		||||
 | 
			
		||||
        Because of Google's cors rules, we can't just do a request to google ics
 | 
			
		||||
        from the frontend. Google is blocking CORS request in its responses headers.
 | 
			
		||||
        The only way to do it from the frontend is to use Google Calendar API with an API key
 | 
			
		||||
        This is not especially desirable as your API key is going to be provided to the frontend.
 | 
			
		||||
 | 
			
		||||
        This is why we have this backend based solution.
 | 
			
		||||
        """
 | 
			
		||||
        if (calendar := IcsCalendar.get_external()) is not None:
 | 
			
		||||
            return send_raw_file(calendar)
 | 
			
		||||
        raise Http404
 | 
			
		||||
 | 
			
		||||
    @route.get("/internal.ics", url_name="calendar_internal")
 | 
			
		||||
    def calendar_internal(self):
 | 
			
		||||
        return send_raw_file(IcsCalendar.get_internal())
 | 
			
		||||
 | 
			
		||||
    @route.get(
 | 
			
		||||
        "/unpublished.ics",
 | 
			
		||||
        permissions=[IsAuthenticated],
 | 
			
		||||
        url_name="calendar_unpublished",
 | 
			
		||||
    )
 | 
			
		||||
    def calendar_unpublished(self):
 | 
			
		||||
        return HttpResponse(
 | 
			
		||||
            IcsCalendar.get_unpublished(self.context.request.user),
 | 
			
		||||
            content_type="text/calendar",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@api_controller("/news")
 | 
			
		||||
class NewsController(ControllerBase):
 | 
			
		||||
    @route.patch(
 | 
			
		||||
        "/{int:news_id}/publish",
 | 
			
		||||
        permissions=[HasPerm("com.moderate_news")],
 | 
			
		||||
        url_name="moderate_news",
 | 
			
		||||
    )
 | 
			
		||||
    def publish_news(self, news_id: int):
 | 
			
		||||
        news = self.get_object_or_exception(News, id=news_id)
 | 
			
		||||
        if not news.is_published:
 | 
			
		||||
            news.is_published = True
 | 
			
		||||
            news.moderator = self.context.request.user
 | 
			
		||||
            news.save()
 | 
			
		||||
 | 
			
		||||
    @route.patch(
 | 
			
		||||
        "/{int:news_id}/unpublish",
 | 
			
		||||
        permissions=[HasPerm("com.moderate_news")],
 | 
			
		||||
        url_name="unpublish_news",
 | 
			
		||||
    )
 | 
			
		||||
    def unpublish_news(self, news_id: int):
 | 
			
		||||
        news = self.get_object_or_exception(News, id=news_id)
 | 
			
		||||
        if news.is_published:
 | 
			
		||||
            news.is_published = False
 | 
			
		||||
            news.moderator = self.context.request.user
 | 
			
		||||
            news.save()
 | 
			
		||||
 | 
			
		||||
    @route.delete(
 | 
			
		||||
        "/{int:news_id}",
 | 
			
		||||
        permissions=[HasPerm("com.delete_news")],
 | 
			
		||||
        url_name="delete_news",
 | 
			
		||||
    )
 | 
			
		||||
    def delete_news(self, news_id: int):
 | 
			
		||||
        news = self.get_object_or_exception(News, id=news_id)
 | 
			
		||||
        news.delete()
 | 
			
		||||
 | 
			
		||||
    @route.get(
 | 
			
		||||
        "/date",
 | 
			
		||||
        url_name="fetch_news_dates",
 | 
			
		||||
        response=PaginatedResponseSchema[NewsDateSchema],
 | 
			
		||||
    )
 | 
			
		||||
    @paginate(PageNumberPaginationExtra, page_size=50)
 | 
			
		||||
    def fetch_news_dates(
 | 
			
		||||
        self,
 | 
			
		||||
        filters: Query[NewsDateFilterSchema],
 | 
			
		||||
        text_format: Literal["md", "html"] = "md",
 | 
			
		||||
    ):
 | 
			
		||||
        return filters.filter(
 | 
			
		||||
            NewsDate.objects.viewable_by(self.context.request.user)
 | 
			
		||||
            .order_by("start_date")
 | 
			
		||||
            .select_related("news", "news__club")
 | 
			
		||||
        )
 | 
			
		||||
							
								
								
									
										9
									
								
								com/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								com/apps.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ComConfig(AppConfig):
 | 
			
		||||
    name = "com"
 | 
			
		||||
    verbose_name = "News and communication"
 | 
			
		||||
 | 
			
		||||
    def ready(self):
 | 
			
		||||
        import com.signals  # noqa F401
 | 
			
		||||
							
								
								
									
										94
									
								
								com/calendar.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								com/calendar.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,94 @@
 | 
			
		||||
from datetime import datetime, timedelta
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from typing import final
 | 
			
		||||
 | 
			
		||||
import requests
 | 
			
		||||
from dateutil.relativedelta import relativedelta
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db.models import F, QuerySet
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from ical.calendar import Calendar
 | 
			
		||||
from ical.calendar_stream import IcsCalendarStream
 | 
			
		||||
from ical.event import Event
 | 
			
		||||
 | 
			
		||||
from com.models import NewsDate
 | 
			
		||||
from core.models import User
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@final
 | 
			
		||||
class IcsCalendar:
 | 
			
		||||
    _CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars"
 | 
			
		||||
    _EXTERNAL_CALENDAR = _CACHE_FOLDER / "external.ics"
 | 
			
		||||
    _INTERNAL_CALENDAR = _CACHE_FOLDER / "internal.ics"
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def get_external(cls, expiration: timedelta = timedelta(hours=1)) -> Path | None:
 | 
			
		||||
        if (
 | 
			
		||||
            cls._EXTERNAL_CALENDAR.exists()
 | 
			
		||||
            and timezone.make_aware(
 | 
			
		||||
                datetime.fromtimestamp(cls._EXTERNAL_CALENDAR.stat().st_mtime)
 | 
			
		||||
            )
 | 
			
		||||
            + expiration
 | 
			
		||||
            > timezone.now()
 | 
			
		||||
        ):
 | 
			
		||||
            return cls._EXTERNAL_CALENDAR
 | 
			
		||||
        return cls.make_external()
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def make_external(cls) -> Path | None:
 | 
			
		||||
        calendar = requests.get(
 | 
			
		||||
            "https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics"
 | 
			
		||||
        )
 | 
			
		||||
        if not calendar.ok:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
 | 
			
		||||
        with open(cls._EXTERNAL_CALENDAR, "wb") as f:
 | 
			
		||||
            _ = f.write(calendar.content)
 | 
			
		||||
        return cls._EXTERNAL_CALENDAR
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def get_internal(cls) -> Path:
 | 
			
		||||
        if not cls._INTERNAL_CALENDAR.exists():
 | 
			
		||||
            return cls.make_internal()
 | 
			
		||||
        return cls._INTERNAL_CALENDAR
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def make_internal(cls) -> Path:
 | 
			
		||||
        # Updated through a post_save signal on News in com.signals
 | 
			
		||||
        # Create a file so we can offload the download to the reverse proxy if available
 | 
			
		||||
        cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
 | 
			
		||||
        with open(cls._INTERNAL_CALENDAR, "wb") as f:
 | 
			
		||||
            _ = f.write(
 | 
			
		||||
                cls.ics_from_queryset(
 | 
			
		||||
                    NewsDate.objects.filter(
 | 
			
		||||
                        news__is_published=True,
 | 
			
		||||
                        end_date__gte=timezone.now() - (relativedelta(months=6)),
 | 
			
		||||
                    )
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
        return cls._INTERNAL_CALENDAR
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def get_unpublished(cls, user: User) -> bytes:
 | 
			
		||||
        return cls.ics_from_queryset(
 | 
			
		||||
            NewsDate.objects.viewable_by(user).filter(
 | 
			
		||||
                news__is_published=False,
 | 
			
		||||
                end_date__gte=timezone.now() - (relativedelta(months=6)),
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def ics_from_queryset(cls, queryset: QuerySet[NewsDate]) -> bytes:
 | 
			
		||||
        calendar = Calendar()
 | 
			
		||||
        for news_date in queryset.annotate(news_title=F("news__title")):
 | 
			
		||||
            event = Event(
 | 
			
		||||
                summary=news_date.news_title,
 | 
			
		||||
                start=news_date.start_date,
 | 
			
		||||
                end=news_date.end_date,
 | 
			
		||||
                url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}),
 | 
			
		||||
            )
 | 
			
		||||
            calendar.events.append(event)
 | 
			
		||||
 | 
			
		||||
        return IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8")
 | 
			
		||||
							
								
								
									
										193
									
								
								com/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								com/forms.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,193 @@
 | 
			
		||||
from datetime import date
 | 
			
		||||
 | 
			
		||||
from dateutil.relativedelta import relativedelta
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.db.models import Exists, OuterRef
 | 
			
		||||
from django.forms import CheckboxInput
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
from club.models import Club
 | 
			
		||||
from club.widgets.select import AutoCompleteSelectClub
 | 
			
		||||
from com.models import News, NewsDate, Poster
 | 
			
		||||
from core.models import User
 | 
			
		||||
from core.utils import get_end_of_semester
 | 
			
		||||
from core.views.forms import SelectDateTime
 | 
			
		||||
from core.views.widgets.markdown import MarkdownInput
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PosterForm(forms.ModelForm):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Poster
 | 
			
		||||
        fields = [
 | 
			
		||||
            "name",
 | 
			
		||||
            "file",
 | 
			
		||||
            "club",
 | 
			
		||||
            "screens",
 | 
			
		||||
            "date_begin",
 | 
			
		||||
            "date_end",
 | 
			
		||||
            "display_time",
 | 
			
		||||
        ]
 | 
			
		||||
        widgets = {"screens": forms.CheckboxSelectMultiple}
 | 
			
		||||
        help_texts = {"file": _("Format: 16:9 | Resolution: 1920x1080")}
 | 
			
		||||
 | 
			
		||||
    date_begin = forms.DateTimeField(
 | 
			
		||||
        label=_("Start date"),
 | 
			
		||||
        widget=SelectDateTime,
 | 
			
		||||
        required=True,
 | 
			
		||||
        initial=timezone.now().strftime("%Y-%m-%d %H:%M:%S"),
 | 
			
		||||
    )
 | 
			
		||||
    date_end = forms.DateTimeField(
 | 
			
		||||
        label=_("End date"), widget=SelectDateTime, required=False
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        self.user = kwargs.pop("user", None)
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        if self.user and not self.user.is_com_admin:
 | 
			
		||||
            self.fields["club"].queryset = Club.objects.filter(
 | 
			
		||||
                id__in=self.user.clubs_with_rights
 | 
			
		||||
            )
 | 
			
		||||
            self.fields.pop("display_time")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsDateForm(forms.ModelForm):
 | 
			
		||||
    """Form to select the dates of an event."""
 | 
			
		||||
 | 
			
		||||
    required_css_class = "required"
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = NewsDate
 | 
			
		||||
        fields = ["start_date", "end_date"]
 | 
			
		||||
        widgets = {"start_date": SelectDateTime, "end_date": SelectDateTime}
 | 
			
		||||
 | 
			
		||||
    is_weekly = forms.BooleanField(
 | 
			
		||||
        label=_("Weekly event"),
 | 
			
		||||
        help_text=_("Weekly events will occur each week for a specified timespan."),
 | 
			
		||||
        widget=CheckboxInput(attrs={"class": "switch"}),
 | 
			
		||||
        initial=False,
 | 
			
		||||
        required=False,
 | 
			
		||||
    )
 | 
			
		||||
    occurrence_choices = [
 | 
			
		||||
        *[(str(i), _("%d times") % i) for i in range(2, 7)],
 | 
			
		||||
        ("SEMESTER_END", _("Until the end of the semester")),
 | 
			
		||||
    ]
 | 
			
		||||
    occurrences = forms.ChoiceField(
 | 
			
		||||
        label=_("Occurrences"),
 | 
			
		||||
        help_text=_("How much times should the event occur (including the first one)"),
 | 
			
		||||
        choices=occurrence_choices,
 | 
			
		||||
        initial="SEMESTER_END",
 | 
			
		||||
        required=False,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.label_suffix = ""
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def get_occurrences(cls, number: int) -> tuple[str, str] | None:
 | 
			
		||||
        """Find the occurrence choice corresponding to numeric number of occurrences."""
 | 
			
		||||
        if number < 2:
 | 
			
		||||
            # If only 0 or 1 date, there cannot be weekly events
 | 
			
		||||
            return None
 | 
			
		||||
        # occurrences have all a numeric value, except "SEMESTER_END"
 | 
			
		||||
        str_num = str(number)
 | 
			
		||||
        occurrences = next((c for c in cls.occurrence_choices if c[0] == str_num), None)
 | 
			
		||||
        if occurrences:
 | 
			
		||||
            return occurrences
 | 
			
		||||
        return next((c for c in cls.occurrence_choices if c[0] == "SEMESTER_END"), None)
 | 
			
		||||
 | 
			
		||||
    def save(self, commit: bool = True, *, news: News):  # noqa FBT001
 | 
			
		||||
        # the base save method contains some checks we want to run
 | 
			
		||||
        # before doing our own logic
 | 
			
		||||
        super().save(commit=False)
 | 
			
		||||
        # delete existing dates before creating new ones
 | 
			
		||||
        news.dates.all().delete()
 | 
			
		||||
        if not self.cleaned_data.get("is_weekly"):
 | 
			
		||||
            self.instance.news = news
 | 
			
		||||
            return super().save(commit=commit)
 | 
			
		||||
 | 
			
		||||
        dates: list[NewsDate] = [self.instance]
 | 
			
		||||
        occurrences = self.cleaned_data.get("occurrences")
 | 
			
		||||
        start = self.instance.start_date
 | 
			
		||||
        end = self.instance.end_date
 | 
			
		||||
        if occurrences[0].isdigit():
 | 
			
		||||
            nb_occurrences = int(occurrences[0])
 | 
			
		||||
        else:  # to the end of the semester
 | 
			
		||||
            start_date = date(start.year, start.month, start.day)
 | 
			
		||||
            nb_occurrences = (get_end_of_semester(start_date) - start_date).days // 7
 | 
			
		||||
        dates.extend(
 | 
			
		||||
            [
 | 
			
		||||
                NewsDate(
 | 
			
		||||
                    start_date=start + relativedelta(weeks=i),
 | 
			
		||||
                    end_date=end + relativedelta(weeks=i),
 | 
			
		||||
                )
 | 
			
		||||
                for i in range(1, nb_occurrences)
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
        for d in dates:
 | 
			
		||||
            d.news = news
 | 
			
		||||
        if not commit:
 | 
			
		||||
            return dates
 | 
			
		||||
        return NewsDate.objects.bulk_create(dates)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsForm(forms.ModelForm):
 | 
			
		||||
    """Form to create or edit news."""
 | 
			
		||||
 | 
			
		||||
    error_css_class = "error"
 | 
			
		||||
    required_css_class = "required"
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = News
 | 
			
		||||
        fields = ["title", "club", "summary", "content"]
 | 
			
		||||
        widgets = {
 | 
			
		||||
            "author": forms.HiddenInput,
 | 
			
		||||
            "summary": MarkdownInput,
 | 
			
		||||
            "content": MarkdownInput,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    auto_publish = forms.BooleanField(
 | 
			
		||||
        label=_("Auto publication"),
 | 
			
		||||
        widget=CheckboxInput(attrs={"class": "switch"}),
 | 
			
		||||
        required=False,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, author: User, date_form: NewsDateForm, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.author = author
 | 
			
		||||
        self.date_form = date_form
 | 
			
		||||
        self.label_suffix = ""
 | 
			
		||||
        # if the author is an admin, he/she can choose any club,
 | 
			
		||||
        # otherwise, only clubs for which he/she is a board member can be selected
 | 
			
		||||
        if author.is_root or author.is_com_admin:
 | 
			
		||||
            self.fields["club"] = forms.ModelChoiceField(
 | 
			
		||||
                queryset=Club.objects.all(), widget=AutoCompleteSelectClub
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            active_memberships = author.memberships.board().ongoing()
 | 
			
		||||
            self.fields["club"] = forms.ModelChoiceField(
 | 
			
		||||
                queryset=Club.objects.filter(
 | 
			
		||||
                    Exists(active_memberships.filter(club=OuterRef("pk")))
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def is_valid(self):
 | 
			
		||||
        return super().is_valid() and self.date_form.is_valid()
 | 
			
		||||
 | 
			
		||||
    def full_clean(self):
 | 
			
		||||
        super().full_clean()
 | 
			
		||||
        self.date_form.full_clean()
 | 
			
		||||
 | 
			
		||||
    def save(self, commit: bool = True):  # noqa FBT001
 | 
			
		||||
        self.instance.author = self.author
 | 
			
		||||
        if (self.author.is_com_admin or self.author.is_root) and (
 | 
			
		||||
            self.cleaned_data.get("auto_publish") is True
 | 
			
		||||
        ):
 | 
			
		||||
            self.instance.is_published = True
 | 
			
		||||
            self.instance.moderator = self.author
 | 
			
		||||
        else:
 | 
			
		||||
            self.instance.is_published = False
 | 
			
		||||
        created_news = super().save(commit=commit)
 | 
			
		||||
        self.date_form.save(commit=commit, news=created_news)
 | 
			
		||||
        return created_news
 | 
			
		||||
@@ -0,0 +1,56 @@
 | 
			
		||||
# Generated by Django 4.2.17 on 2024-12-16 14:51
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("club", "0011_auto_20180426_2013"),
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
        ("com", "0006_remove_sith_index_page"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="news",
 | 
			
		||||
            name="club",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                help_text="The club which organizes the event.",
 | 
			
		||||
                on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                related_name="news",
 | 
			
		||||
                to="club.club",
 | 
			
		||||
                verbose_name="club",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="news",
 | 
			
		||||
            name="content",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                default="",
 | 
			
		||||
                help_text="A more detailed and exhaustive description of the event.",
 | 
			
		||||
                verbose_name="content",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="news",
 | 
			
		||||
            name="moderator",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.SET_NULL,
 | 
			
		||||
                related_name="moderated_news",
 | 
			
		||||
                to=settings.AUTH_USER_MODEL,
 | 
			
		||||
                verbose_name="moderator",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="news",
 | 
			
		||||
            name="summary",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                help_text="A description of the event (what is the activity ? is there an associated clic ? is there a inscription form ?)",
 | 
			
		||||
                verbose_name="summary",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,61 @@
 | 
			
		||||
# Generated by Django 4.2.17 on 2025-01-06 21:52
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
        ("com", "0007_alter_news_club_alter_news_content_and_more"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterModelOptions(
 | 
			
		||||
            name="news",
 | 
			
		||||
            options={
 | 
			
		||||
                "verbose_name": "news",
 | 
			
		||||
                "permissions": [
 | 
			
		||||
                    ("moderate_news", "Can moderate news"),
 | 
			
		||||
                    ("view_unmoderated_news", "Can view non-moderated news"),
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterModelOptions(
 | 
			
		||||
            name="newsdate",
 | 
			
		||||
            options={"verbose_name": "news date", "verbose_name_plural": "news dates"},
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterModelOptions(
 | 
			
		||||
            name="poster",
 | 
			
		||||
            options={"permissions": [("moderate_poster", "Can moderate poster")]},
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RemoveField(model_name="news", name="type"),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="news",
 | 
			
		||||
            name="author",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                on_delete=django.db.models.deletion.PROTECT,
 | 
			
		||||
                related_name="owned_news",
 | 
			
		||||
                to=settings.AUTH_USER_MODEL,
 | 
			
		||||
                verbose_name="author",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="newsdate",
 | 
			
		||||
            name="end_date",
 | 
			
		||||
            field=models.DateTimeField(verbose_name="end_date"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="newsdate",
 | 
			
		||||
            name="start_date",
 | 
			
		||||
            field=models.DateTimeField(verbose_name="start_date"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddConstraint(
 | 
			
		||||
            model_name="newsdate",
 | 
			
		||||
            constraint=models.CheckConstraint(
 | 
			
		||||
                check=models.Q(("end_date__gte", models.F("start_date"))),
 | 
			
		||||
                name="news_date_end_date_after_start_date",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,16 @@
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [("com", "0008_alter_news_options_alter_newsdate_options_and_more")]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name="news", old_name="is_moderated", new_name="is_published"
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="news",
 | 
			
		||||
            name="is_published",
 | 
			
		||||
            field=models.BooleanField(default=False, verbose_name="is published"),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										172
									
								
								com/models.py
									
									
									
									
									
								
							
							
						
						
									
										172
									
								
								com/models.py
									
									
									
									
									
								
							@@ -17,16 +17,17 @@
 | 
			
		||||
# details.
 | 
			
		||||
#
 | 
			
		||||
# You should have received a copy of the GNU General Public License along with
 | 
			
		||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
 | 
			
		||||
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
 | 
			
		||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
from typing import Self
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django.core.mail import EmailMultiAlternatives
 | 
			
		||||
from django.db import models, transaction
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.db.models import F, Q
 | 
			
		||||
from django.shortcuts import render
 | 
			
		||||
from django.templatetags.static import static
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
@@ -34,7 +35,7 @@ from django.utils import timezone
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
from club.models import Club
 | 
			
		||||
from core.models import Notification, Preferences, RealGroup, User
 | 
			
		||||
from core.models import Notification, Preferences, User
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Sith(models.Model):
 | 
			
		||||
@@ -53,57 +54,87 @@ class Sith(models.Model):
 | 
			
		||||
        return user.is_com_admin
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
NEWS_TYPES = [
 | 
			
		||||
    ("NOTICE", _("Notice")),
 | 
			
		||||
    ("EVENT", _("Event")),
 | 
			
		||||
    ("WEEKLY", _("Weekly")),
 | 
			
		||||
    ("CALL", _("Call")),
 | 
			
		||||
]
 | 
			
		||||
class NewsQuerySet(models.QuerySet):
 | 
			
		||||
    def moderated(self) -> Self:
 | 
			
		||||
        return self.filter(is_published=True)
 | 
			
		||||
 | 
			
		||||
    def viewable_by(self, user: User) -> Self:
 | 
			
		||||
        """Filter news that the given user can view.
 | 
			
		||||
 | 
			
		||||
        If the user has the `com.view_unmoderated_news` permission,
 | 
			
		||||
        all news are viewable.
 | 
			
		||||
        Else the viewable news are those that are either moderated
 | 
			
		||||
        or authored by the user.
 | 
			
		||||
        """
 | 
			
		||||
        if user.has_perm("com.view_unmoderated_news"):
 | 
			
		||||
            return self
 | 
			
		||||
        q_filter = Q(is_published=True)
 | 
			
		||||
        if user.is_authenticated:
 | 
			
		||||
            q_filter |= Q(author_id=user.id)
 | 
			
		||||
        return self.filter(q_filter)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class News(models.Model):
 | 
			
		||||
    """The news class."""
 | 
			
		||||
    """News about club events."""
 | 
			
		||||
 | 
			
		||||
    title = models.CharField(_("title"), max_length=64)
 | 
			
		||||
    summary = models.TextField(_("summary"))
 | 
			
		||||
    content = models.TextField(_("content"))
 | 
			
		||||
    type = models.CharField(
 | 
			
		||||
        _("type"), max_length=16, choices=NEWS_TYPES, default="EVENT"
 | 
			
		||||
    summary = models.TextField(
 | 
			
		||||
        _("summary"),
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            "A description of the event (what is the activity ? "
 | 
			
		||||
            "is there an associated clic ? is there a inscription form ?)"
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
    content = models.TextField(
 | 
			
		||||
        _("content"),
 | 
			
		||||
        blank=True,
 | 
			
		||||
        default="",
 | 
			
		||||
        help_text=_("A more detailed and exhaustive description of the event."),
 | 
			
		||||
    )
 | 
			
		||||
    club = models.ForeignKey(
 | 
			
		||||
        Club, related_name="news", verbose_name=_("club"), on_delete=models.CASCADE
 | 
			
		||||
        Club,
 | 
			
		||||
        related_name="news",
 | 
			
		||||
        verbose_name=_("club"),
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
        help_text=_("The club which organizes the event."),
 | 
			
		||||
    )
 | 
			
		||||
    author = models.ForeignKey(
 | 
			
		||||
        User,
 | 
			
		||||
        related_name="owned_news",
 | 
			
		||||
        verbose_name=_("author"),
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
        on_delete=models.PROTECT,
 | 
			
		||||
    )
 | 
			
		||||
    is_moderated = models.BooleanField(_("is moderated"), default=False)
 | 
			
		||||
    is_published = models.BooleanField(_("is published"), default=False)
 | 
			
		||||
    moderator = models.ForeignKey(
 | 
			
		||||
        User,
 | 
			
		||||
        related_name="moderated_news",
 | 
			
		||||
        verbose_name=_("moderator"),
 | 
			
		||||
        null=True,
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
        on_delete=models.SET_NULL,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    objects = NewsQuerySet.as_manager()
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("news")
 | 
			
		||||
        permissions = [
 | 
			
		||||
            ("moderate_news", "Can moderate news"),
 | 
			
		||||
            ("view_unmoderated_news", "Can view non-moderated news"),
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return "%s: %s" % (self.type, self.title)
 | 
			
		||||
        return self.title
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        super().save(*args, **kwargs)
 | 
			
		||||
        for u in (
 | 
			
		||||
            RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
			
		||||
            .first()
 | 
			
		||||
            .users.all()
 | 
			
		||||
        if self.is_published:
 | 
			
		||||
            return
 | 
			
		||||
        for user in User.objects.filter(
 | 
			
		||||
            groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
 | 
			
		||||
        ):
 | 
			
		||||
            Notification(
 | 
			
		||||
                user=u,
 | 
			
		||||
                url=reverse("com:news_admin_list"),
 | 
			
		||||
                type="NEWS_MODERATION",
 | 
			
		||||
                param="1",
 | 
			
		||||
            ).save()
 | 
			
		||||
            Notification.objects.create(
 | 
			
		||||
                user=user, url=reverse("com:news_admin_list"), type="NEWS_MODERATION"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def get_absolute_url(self):
 | 
			
		||||
        return reverse("com:news_detail", kwargs={"news_id": self.id})
 | 
			
		||||
@@ -116,35 +147,51 @@ class News(models.Model):
 | 
			
		||||
            return False
 | 
			
		||||
        return user.is_com_admin or user == self.author
 | 
			
		||||
 | 
			
		||||
    def can_be_edited_by(self, user):
 | 
			
		||||
        return user.is_com_admin
 | 
			
		||||
    def can_be_edited_by(self, user: User):
 | 
			
		||||
        return user.is_authenticated and (
 | 
			
		||||
            self.author_id == user.id or user.has_perm("com.change_news")
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def can_be_viewed_by(self, user):
 | 
			
		||||
        return self.is_moderated or user.is_com_admin
 | 
			
		||||
    def can_be_viewed_by(self, user: User):
 | 
			
		||||
        return (
 | 
			
		||||
            self.is_published
 | 
			
		||||
            or user.has_perm("com.view_unmoderated_news")
 | 
			
		||||
            or (user.is_authenticated and self.author_id == user.id)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def news_notification_callback(notif):
 | 
			
		||||
    count = (
 | 
			
		||||
        News.objects.filter(
 | 
			
		||||
            Q(dates__start_date__gt=timezone.now(), is_moderated=False)
 | 
			
		||||
            | Q(type="NOTICE", is_moderated=False)
 | 
			
		||||
        )
 | 
			
		||||
        .distinct()
 | 
			
		||||
        .count()
 | 
			
		||||
    )
 | 
			
		||||
    count = News.objects.filter(
 | 
			
		||||
        dates__start_date__gt=timezone.now(), is_published=False
 | 
			
		||||
    ).count()
 | 
			
		||||
    if count:
 | 
			
		||||
        notif.viewed = False
 | 
			
		||||
        notif.param = "%s" % count
 | 
			
		||||
        notif.param = str(count)
 | 
			
		||||
        notif.date = timezone.now()
 | 
			
		||||
    else:
 | 
			
		||||
        notif.viewed = True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsDate(models.Model):
 | 
			
		||||
    """A date class, useful for weekly events, or for events that just have no date.
 | 
			
		||||
class NewsDateQuerySet(models.QuerySet):
 | 
			
		||||
    def viewable_by(self, user: User) -> Self:
 | 
			
		||||
        """Filter the event dates that the given user can view.
 | 
			
		||||
 | 
			
		||||
    This class allows more flexibilty managing the dates related to a news, particularly when this news is weekly, since
 | 
			
		||||
    we don't have to make copies
 | 
			
		||||
        - If the can view non moderated news, he can view all news dates
 | 
			
		||||
        - else, he can view the dates of news that are either
 | 
			
		||||
          authored by him or moderated.
 | 
			
		||||
        """
 | 
			
		||||
        if user.has_perm("com.view_unmoderated_news"):
 | 
			
		||||
            return self
 | 
			
		||||
        q_filter = Q(news__is_published=True)
 | 
			
		||||
        if user.is_authenticated:
 | 
			
		||||
            q_filter |= Q(news__author_id=user.id)
 | 
			
		||||
        return self.filter(q_filter)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsDate(models.Model):
 | 
			
		||||
    """A date associated with news.
 | 
			
		||||
 | 
			
		||||
    A [News][] can have multiple dates, for example if it is a recurring event.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    news = models.ForeignKey(
 | 
			
		||||
@@ -153,11 +200,23 @@ class NewsDate(models.Model):
 | 
			
		||||
        verbose_name=_("news_date"),
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
    start_date = models.DateTimeField(_("start_date"), null=True, blank=True)
 | 
			
		||||
    end_date = models.DateTimeField(_("end_date"), null=True, blank=True)
 | 
			
		||||
    start_date = models.DateTimeField(_("start_date"))
 | 
			
		||||
    end_date = models.DateTimeField(_("end_date"))
 | 
			
		||||
 | 
			
		||||
    objects = NewsDateQuerySet.as_manager()
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("news date")
 | 
			
		||||
        verbose_name_plural = _("news dates")
 | 
			
		||||
        constraints = [
 | 
			
		||||
            models.CheckConstraint(
 | 
			
		||||
                check=Q(end_date__gte=F("start_date")),
 | 
			
		||||
                name="news_date_end_date_after_start_date",
 | 
			
		||||
            )
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return "%s: %s - %s" % (self.news.title, self.start_date, self.end_date)
 | 
			
		||||
        return f"{self.news.title}: {self.start_date} - {self.end_date}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Weekmail(models.Model):
 | 
			
		||||
@@ -316,21 +375,22 @@ class Poster(models.Model):
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        permissions = [("moderate_poster", "Can moderate poster")]
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        if not self.is_moderated:
 | 
			
		||||
            for u in (
 | 
			
		||||
                RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
			
		||||
                .first()
 | 
			
		||||
                .users.all()
 | 
			
		||||
            for user in User.objects.filter(
 | 
			
		||||
                groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
 | 
			
		||||
            ):
 | 
			
		||||
                Notification(
 | 
			
		||||
                    user=u,
 | 
			
		||||
                Notification.objects.create(
 | 
			
		||||
                    user=user,
 | 
			
		||||
                    url=reverse("com:poster_moderate_list"),
 | 
			
		||||
                    type="POSTER_MODERATION",
 | 
			
		||||
                ).save()
 | 
			
		||||
                )
 | 
			
		||||
        return super().save(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def clean(self, *args, **kwargs):
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										58
									
								
								com/schemas.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								com/schemas.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,58 @@
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
 | 
			
		||||
from ninja import FilterSchema, ModelSchema
 | 
			
		||||
from ninja_extra import service_resolver
 | 
			
		||||
from ninja_extra.controllers import RouteContext
 | 
			
		||||
from pydantic import Field
 | 
			
		||||
 | 
			
		||||
from club.schemas import ClubProfileSchema
 | 
			
		||||
from com.models import News, NewsDate
 | 
			
		||||
from core.markdown import markdown
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsDateFilterSchema(FilterSchema):
 | 
			
		||||
    before: datetime | None = Field(None, q="end_date__lt")
 | 
			
		||||
    after: datetime | None = Field(None, q="start_date__gt")
 | 
			
		||||
    club_id: int | None = Field(None, q="news__club_id")
 | 
			
		||||
    news_id: int | None = None
 | 
			
		||||
    is_published: bool | None = Field(None, q="news__is_published")
 | 
			
		||||
    title: str | None = Field(None, q="news__title__icontains")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsSchema(ModelSchema):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = News
 | 
			
		||||
        fields = ["id", "title", "summary", "is_published"]
 | 
			
		||||
 | 
			
		||||
    club: ClubProfileSchema
 | 
			
		||||
    url: str
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def resolve_summary(obj: News) -> str:
 | 
			
		||||
        # if this is returned from a route that allows the
 | 
			
		||||
        # user to choose the text format (md or html)
 | 
			
		||||
        # and the user chose "html", convert the markdown to html
 | 
			
		||||
        context: RouteContext = service_resolver(RouteContext)
 | 
			
		||||
        if context.kwargs.get("text_format", "") == "html":
 | 
			
		||||
            return markdown(obj.summary)
 | 
			
		||||
        return obj.summary
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def resolve_url(obj: News) -> str:
 | 
			
		||||
        return obj.get_absolute_url()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsDateSchema(ModelSchema):
 | 
			
		||||
    """Basic infos about an event occurrence.
 | 
			
		||||
 | 
			
		||||
    Warning:
 | 
			
		||||
        This uses [NewsSchema][], which itself
 | 
			
		||||
        uses [ClubProfileSchema][club.schemas.ClubProfileSchema].
 | 
			
		||||
        Don't forget the appropriated `select_related`.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = NewsDate
 | 
			
		||||
        fields = ["id", "start_date", "end_date"]
 | 
			
		||||
 | 
			
		||||
    news: NewsSchema
 | 
			
		||||
							
								
								
									
										10
									
								
								com/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								com/signals.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
from django.db.models.signals import post_delete, post_save
 | 
			
		||||
from django.dispatch import receiver
 | 
			
		||||
 | 
			
		||||
from com.calendar import IcsCalendar
 | 
			
		||||
from com.models import News
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver([post_save, post_delete], sender=News, dispatch_uid="update_internal_ics")
 | 
			
		||||
def update_internal_ics(*args, **kwargs):
 | 
			
		||||
    _ = IcsCalendar.make_internal()
 | 
			
		||||
							
								
								
									
										342
									
								
								com/static/bundled/com/components/ics-calendar-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										342
									
								
								com/static/bundled/com/components/ics-calendar-index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,342 @@
 | 
			
		||||
import { makeUrl } from "#core:utils/api";
 | 
			
		||||
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
 | 
			
		||||
import { Calendar, type EventClickArg } from "@fullcalendar/core";
 | 
			
		||||
import type { EventImpl } from "@fullcalendar/core/internal";
 | 
			
		||||
import enLocale from "@fullcalendar/core/locales/en-gb";
 | 
			
		||||
import frLocale from "@fullcalendar/core/locales/fr";
 | 
			
		||||
import dayGridPlugin from "@fullcalendar/daygrid";
 | 
			
		||||
import iCalendarPlugin from "@fullcalendar/icalendar";
 | 
			
		||||
import listPlugin from "@fullcalendar/list";
 | 
			
		||||
import {
 | 
			
		||||
  calendarCalendarExternal,
 | 
			
		||||
  calendarCalendarInternal,
 | 
			
		||||
  calendarCalendarUnpublished,
 | 
			
		||||
  newsDeleteNews,
 | 
			
		||||
  newsPublishNews,
 | 
			
		||||
  newsUnpublishNews,
 | 
			
		||||
} from "#openapi";
 | 
			
		||||
 | 
			
		||||
@registerComponent("ics-calendar")
 | 
			
		||||
export class IcsCalendar extends inheritHtmlElement("div") {
 | 
			
		||||
  static observedAttributes = ["locale", "can_moderate", "can_delete"];
 | 
			
		||||
  private calendar: Calendar;
 | 
			
		||||
  private locale = "en";
 | 
			
		||||
  private canModerate = false;
 | 
			
		||||
  private canDelete = false;
 | 
			
		||||
 | 
			
		||||
  attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
 | 
			
		||||
    if (name === "locale") {
 | 
			
		||||
      this.locale = newValue;
 | 
			
		||||
    }
 | 
			
		||||
    if (name === "can_moderate") {
 | 
			
		||||
      this.canModerate = newValue.toLowerCase() === "true";
 | 
			
		||||
    }
 | 
			
		||||
    if (name === "can_delete") {
 | 
			
		||||
      this.canDelete = newValue.toLowerCase() === "true";
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isMobile() {
 | 
			
		||||
    return window.innerWidth < 765;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  currentView() {
 | 
			
		||||
    // Get view type based on viewport
 | 
			
		||||
    return this.isMobile() ? "listMonth" : "dayGridMonth";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  currentToolbar() {
 | 
			
		||||
    if (this.isMobile()) {
 | 
			
		||||
      return {
 | 
			
		||||
        left: "prev,next",
 | 
			
		||||
        center: "title",
 | 
			
		||||
        right: "",
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
      left: "prev,next today",
 | 
			
		||||
      center: "title",
 | 
			
		||||
      right: "dayGridMonth,dayGridWeek,dayGridDay",
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  formatDate(date: Date) {
 | 
			
		||||
    return new Intl.DateTimeFormat(this.locale, {
 | 
			
		||||
      dateStyle: "medium",
 | 
			
		||||
      timeStyle: "short",
 | 
			
		||||
    }).format(date);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getNewsId(event: EventImpl) {
 | 
			
		||||
    return Number.parseInt(
 | 
			
		||||
      event.url
 | 
			
		||||
        .toString()
 | 
			
		||||
        .split("/")
 | 
			
		||||
        .filter((s) => s) // Remove blank characters
 | 
			
		||||
        .pop(),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async refreshEvents() {
 | 
			
		||||
    this.click(); // Remove focus from popup
 | 
			
		||||
    // We can't just refresh events because some ics files are in
 | 
			
		||||
    // local browser cache (especially internal.ics)
 | 
			
		||||
    // To invalidate the cache, we need to remove the source and add it again
 | 
			
		||||
    this.calendar.removeAllEventSources();
 | 
			
		||||
    for (const source of await this.getEventSources()) {
 | 
			
		||||
      this.calendar.addEventSource(source);
 | 
			
		||||
    }
 | 
			
		||||
    this.calendar.refetchEvents();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async publishNews(id: number) {
 | 
			
		||||
    await newsPublishNews({
 | 
			
		||||
      path: {
 | 
			
		||||
        // biome-ignore lint/style/useNamingConvention: python API
 | 
			
		||||
        news_id: id,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    this.dispatchEvent(
 | 
			
		||||
      new CustomEvent("calendar-publish", {
 | 
			
		||||
        bubbles: true,
 | 
			
		||||
        detail: {
 | 
			
		||||
          id: id,
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
    await this.refreshEvents();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async unpublishNews(id: number) {
 | 
			
		||||
    await newsUnpublishNews({
 | 
			
		||||
      path: {
 | 
			
		||||
        // biome-ignore lint/style/useNamingConvention: python API
 | 
			
		||||
        news_id: id,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    this.dispatchEvent(
 | 
			
		||||
      new CustomEvent("calendar-unpublish", {
 | 
			
		||||
        bubbles: true,
 | 
			
		||||
        detail: {
 | 
			
		||||
          id: id,
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
    await this.refreshEvents();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async deleteNews(id: number) {
 | 
			
		||||
    await newsDeleteNews({
 | 
			
		||||
      path: {
 | 
			
		||||
        // biome-ignore lint/style/useNamingConvention: python API
 | 
			
		||||
        news_id: id,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    this.dispatchEvent(
 | 
			
		||||
      new CustomEvent("calendar-delete", {
 | 
			
		||||
        bubbles: true,
 | 
			
		||||
        detail: {
 | 
			
		||||
          id: id,
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
    await this.refreshEvents();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getEventSources() {
 | 
			
		||||
    const cacheInvalidate = `?invalidate=${Date.now()}`;
 | 
			
		||||
    return [
 | 
			
		||||
      {
 | 
			
		||||
        url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`,
 | 
			
		||||
        format: "ics",
 | 
			
		||||
        className: "internal",
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        url: `${await makeUrl(calendarCalendarExternal)}${cacheInvalidate}`,
 | 
			
		||||
        format: "ics",
 | 
			
		||||
        className: "external",
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        url: `${await makeUrl(calendarCalendarUnpublished)}${cacheInvalidate}`,
 | 
			
		||||
        format: "ics",
 | 
			
		||||
        color: "red",
 | 
			
		||||
        className: "unpublished",
 | 
			
		||||
      },
 | 
			
		||||
    ];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  createEventDetailPopup(event: EventClickArg) {
 | 
			
		||||
    // Delete previous popup
 | 
			
		||||
    const oldPopup = document.getElementById("event-details");
 | 
			
		||||
    if (oldPopup !== null) {
 | 
			
		||||
      oldPopup.remove();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const makePopupInfo = (info: HTMLElement, iconClass: string) => {
 | 
			
		||||
      const row = document.createElement("div");
 | 
			
		||||
      const icon = document.createElement("i");
 | 
			
		||||
 | 
			
		||||
      row.setAttribute("class", "event-details-row");
 | 
			
		||||
 | 
			
		||||
      icon.setAttribute("class", `event-detail-row-icon fa-xl ${iconClass}`);
 | 
			
		||||
 | 
			
		||||
      row.appendChild(icon);
 | 
			
		||||
      row.appendChild(info);
 | 
			
		||||
 | 
			
		||||
      return row;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const makePopupTitle = (event: EventImpl) => {
 | 
			
		||||
      const row = document.createElement("div");
 | 
			
		||||
      row.innerHTML = `
 | 
			
		||||
        <h4 class="event-details-row-content">
 | 
			
		||||
          ${event.title}
 | 
			
		||||
        </h4>
 | 
			
		||||
        <span class="event-details-row-content">
 | 
			
		||||
          ${this.formatDate(event.start)} - ${this.formatDate(event.end)}
 | 
			
		||||
        </span>
 | 
			
		||||
      `;
 | 
			
		||||
      return makePopupInfo(
 | 
			
		||||
        row,
 | 
			
		||||
        "fa-solid fa-calendar-days fa-xl event-detail-row-icon",
 | 
			
		||||
      );
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const makePopupLocation = (event: EventImpl) => {
 | 
			
		||||
      if (event.extendedProps.location === null) {
 | 
			
		||||
        return null;
 | 
			
		||||
      }
 | 
			
		||||
      const info = document.createElement("div");
 | 
			
		||||
      info.innerText = event.extendedProps.location;
 | 
			
		||||
 | 
			
		||||
      return makePopupInfo(info, "fa-solid fa-location-dot");
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const makePopupUrl = (event: EventImpl) => {
 | 
			
		||||
      if (event.url === "") {
 | 
			
		||||
        return null;
 | 
			
		||||
      }
 | 
			
		||||
      const url = document.createElement("a");
 | 
			
		||||
      url.href = event.url;
 | 
			
		||||
      url.textContent = gettext("More info");
 | 
			
		||||
 | 
			
		||||
      return makePopupInfo(url, "fa-solid fa-link");
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const makePopupTools = (event: EventImpl) => {
 | 
			
		||||
      if (event.source.internalEventSource.ui.classNames.includes("external")) {
 | 
			
		||||
        return null;
 | 
			
		||||
      }
 | 
			
		||||
      if (!(this.canDelete || this.canModerate)) {
 | 
			
		||||
        return null;
 | 
			
		||||
      }
 | 
			
		||||
      const newsId = this.getNewsId(event);
 | 
			
		||||
      const div = document.createElement("div");
 | 
			
		||||
      if (this.canModerate) {
 | 
			
		||||
        if (event.source.internalEventSource.ui.classNames.includes("unpublished")) {
 | 
			
		||||
          const button = document.createElement("button");
 | 
			
		||||
          button.innerHTML = `<i class="fa fa-check"></i>${gettext("Publish")}`;
 | 
			
		||||
          button.setAttribute("class", "btn btn-green");
 | 
			
		||||
          button.onclick = () => {
 | 
			
		||||
            this.publishNews(newsId);
 | 
			
		||||
          };
 | 
			
		||||
          div.appendChild(button);
 | 
			
		||||
        } else {
 | 
			
		||||
          const button = document.createElement("button");
 | 
			
		||||
          button.innerHTML = `<i class="fa fa-times"></i>${gettext("Unpublish")}`;
 | 
			
		||||
          button.setAttribute("class", "btn btn-orange");
 | 
			
		||||
          button.onclick = () => {
 | 
			
		||||
            this.unpublishNews(newsId);
 | 
			
		||||
          };
 | 
			
		||||
          div.appendChild(button);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      if (this.canDelete) {
 | 
			
		||||
        const button = document.createElement("button");
 | 
			
		||||
        button.innerHTML = `<i class="fa fa-trash-can"></i>${gettext("Delete")}`;
 | 
			
		||||
        button.setAttribute("class", "btn btn-red");
 | 
			
		||||
        button.onclick = () => {
 | 
			
		||||
          this.deleteNews(newsId);
 | 
			
		||||
        };
 | 
			
		||||
        div.appendChild(button);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return makePopupInfo(div, "fa-solid fa-toolbox");
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Create new popup
 | 
			
		||||
    const popup = document.createElement("div");
 | 
			
		||||
    const popupContainer = document.createElement("div");
 | 
			
		||||
 | 
			
		||||
    popup.setAttribute("id", "event-details");
 | 
			
		||||
    popupContainer.setAttribute("class", "event-details-container");
 | 
			
		||||
 | 
			
		||||
    popupContainer.appendChild(makePopupTitle(event.event));
 | 
			
		||||
 | 
			
		||||
    const location = makePopupLocation(event.event);
 | 
			
		||||
    if (location !== null) {
 | 
			
		||||
      popupContainer.appendChild(location);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const url = makePopupUrl(event.event);
 | 
			
		||||
    if (url !== null) {
 | 
			
		||||
      popupContainer.appendChild(url);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const tools = makePopupTools(event.event);
 | 
			
		||||
    if (tools !== null) {
 | 
			
		||||
      popupContainer.appendChild(tools);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    popup.appendChild(popupContainer);
 | 
			
		||||
 | 
			
		||||
    // We can't just add the element relative to the one we want to appear under
 | 
			
		||||
    // Otherwise, it either gets clipped by the boundaries of the calendar or resize cells
 | 
			
		||||
    // Here, we create a popup outside the calendar that follows the clicked element
 | 
			
		||||
    this.node.appendChild(popup);
 | 
			
		||||
    const follow = (node: HTMLElement) => {
 | 
			
		||||
      const rect = node.getBoundingClientRect();
 | 
			
		||||
      popup.setAttribute(
 | 
			
		||||
        "style",
 | 
			
		||||
        `top: calc(${rect.top + window.scrollY}px + ${rect.height}px); left: ${rect.left + window.scrollX}px;`,
 | 
			
		||||
      );
 | 
			
		||||
    };
 | 
			
		||||
    follow(event.el);
 | 
			
		||||
    window.addEventListener("resize", () => {
 | 
			
		||||
      follow(event.el);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async connectedCallback() {
 | 
			
		||||
    super.connectedCallback();
 | 
			
		||||
    this.calendar = new Calendar(this.node, {
 | 
			
		||||
      plugins: [dayGridPlugin, iCalendarPlugin, listPlugin],
 | 
			
		||||
      locales: [frLocale, enLocale],
 | 
			
		||||
      height: "auto",
 | 
			
		||||
      locale: this.locale,
 | 
			
		||||
      initialView: this.currentView(),
 | 
			
		||||
      headerToolbar: this.currentToolbar(),
 | 
			
		||||
      eventSources: await this.getEventSources(),
 | 
			
		||||
      windowResize: () => {
 | 
			
		||||
        this.calendar.changeView(this.currentView());
 | 
			
		||||
        this.calendar.setOption("headerToolbar", this.currentToolbar());
 | 
			
		||||
      },
 | 
			
		||||
      eventClick: (event) => {
 | 
			
		||||
        // Avoid our popup to be deleted because we clicked outside of it
 | 
			
		||||
        event.jsEvent.stopPropagation();
 | 
			
		||||
        // Don't auto-follow events URLs
 | 
			
		||||
        event.jsEvent.preventDefault();
 | 
			
		||||
        this.createEventDetailPopup(event);
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    this.calendar.render();
 | 
			
		||||
 | 
			
		||||
    window.addEventListener("click", (event: MouseEvent) => {
 | 
			
		||||
      // Auto close popups when clicking outside of it
 | 
			
		||||
      const popup = document.getElementById("event-details");
 | 
			
		||||
      if (popup !== null && !popup.contains(event.target as Node)) {
 | 
			
		||||
        popup.remove();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										81
									
								
								com/static/bundled/com/components/moderation-alert-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								com/static/bundled/com/components/moderation-alert-index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,81 @@
 | 
			
		||||
import { exportToHtml } from "#core:utils/globals";
 | 
			
		||||
import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi";
 | 
			
		||||
 | 
			
		||||
// This will be used in jinja templates,
 | 
			
		||||
// so we cannot use real enums as those are purely an abstraction of Typescript
 | 
			
		||||
const AlertState = {
 | 
			
		||||
  // biome-ignore lint/style/useNamingConvention: this feels more like an enum
 | 
			
		||||
  PENDING: 1,
 | 
			
		||||
  // biome-ignore lint/style/useNamingConvention: this feels more like an enum
 | 
			
		||||
  PUBLISHED: 2,
 | 
			
		||||
  // biome-ignore lint/style/useNamingConvention: this feels more like an enum
 | 
			
		||||
  DELETED: 3,
 | 
			
		||||
  // biome-ignore lint/style/useNamingConvention: this feels more like an enum
 | 
			
		||||
  DISPLAYED: 4, // When published at page generation
 | 
			
		||||
};
 | 
			
		||||
exportToHtml("AlertState", AlertState);
 | 
			
		||||
 | 
			
		||||
document.addEventListener("alpine:init", () => {
 | 
			
		||||
  Alpine.data("moderationAlert", (newsId: number) => ({
 | 
			
		||||
    state: AlertState.PENDING,
 | 
			
		||||
    newsId: newsId as number,
 | 
			
		||||
    loading: false,
 | 
			
		||||
 | 
			
		||||
    async publishNews() {
 | 
			
		||||
      this.loading = true;
 | 
			
		||||
      // biome-ignore lint/style/useNamingConvention: api is snake case
 | 
			
		||||
      await newsPublishNews({ path: { news_id: this.newsId } });
 | 
			
		||||
      this.state = AlertState.PUBLISHED;
 | 
			
		||||
      this.$dispatch("news-moderated", { newsId: this.newsId, state: this.state });
 | 
			
		||||
      this.loading = false;
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async deleteNews() {
 | 
			
		||||
      this.loading = true;
 | 
			
		||||
      // biome-ignore lint/style/useNamingConvention: api is snake case
 | 
			
		||||
      await newsDeleteNews({ path: { news_id: this.newsId } });
 | 
			
		||||
      this.state = AlertState.DELETED;
 | 
			
		||||
      this.$dispatch("news-moderated", { newsId: this.newsId, state: this.state });
 | 
			
		||||
      this.loading = false;
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Event receiver for when news dates are moderated.
 | 
			
		||||
     *
 | 
			
		||||
     * If the moderated date is linked to the same news
 | 
			
		||||
     * as the one this moderation alert is attached to,
 | 
			
		||||
     * then set the alert state to the same as the moderated one.
 | 
			
		||||
     */
 | 
			
		||||
    dispatchModeration(event: CustomEvent) {
 | 
			
		||||
      if (event.detail.newsId === this.newsId) {
 | 
			
		||||
        this.state = event.detail.state;
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Query the server to know the number of news dates that would be moderated
 | 
			
		||||
     * if this one is moderated.
 | 
			
		||||
     */
 | 
			
		||||
    async nbToPublish(): Promise<number> {
 | 
			
		||||
      // What we want here is the count attribute of the response.
 | 
			
		||||
      // We don't care about the actual results,
 | 
			
		||||
      // so we ask for the minimum page size possible.
 | 
			
		||||
      const response = await newsFetchNewsDates({
 | 
			
		||||
        // biome-ignore lint/style/useNamingConvention: api is snake-case
 | 
			
		||||
        query: { news_id: this.newsId, page: 1, page_size: 1 },
 | 
			
		||||
      });
 | 
			
		||||
      return response.data.count;
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    weeklyEventWarningMessage(nbEvents: number): string {
 | 
			
		||||
      return interpolate(
 | 
			
		||||
        gettext(
 | 
			
		||||
          "This event will take place every week for %s weeks. " +
 | 
			
		||||
            "If you publish or delete this event, " +
 | 
			
		||||
            "it will also be published (or deleted) for the following weeks.",
 | 
			
		||||
        ),
 | 
			
		||||
        [nbEvents],
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
  }));
 | 
			
		||||
});
 | 
			
		||||
@@ -0,0 +1,67 @@
 | 
			
		||||
import { type NewsDateSchema, newsFetchNewsDates } from "#openapi";
 | 
			
		||||
 | 
			
		||||
interface ParsedNewsDateSchema extends Omit<NewsDateSchema, "start_date" | "end_date"> {
 | 
			
		||||
  // biome-ignore lint/style/useNamingConvention: api is snake_case
 | 
			
		||||
  start_date: Date;
 | 
			
		||||
  // biome-ignore lint/style/useNamingConvention: api is snake_case
 | 
			
		||||
  end_date: Date;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
document.addEventListener("alpine:init", () => {
 | 
			
		||||
  Alpine.data("upcomingNewsLoader", (startDate: Date) => ({
 | 
			
		||||
    startDate: startDate,
 | 
			
		||||
    currentPage: 1,
 | 
			
		||||
    pageSize: 6,
 | 
			
		||||
    hasNext: true,
 | 
			
		||||
    loading: false,
 | 
			
		||||
    newsDates: [] as NewsDateSchema[],
 | 
			
		||||
 | 
			
		||||
    async loadMore() {
 | 
			
		||||
      this.loading = true;
 | 
			
		||||
      const response = await newsFetchNewsDates({
 | 
			
		||||
        query: {
 | 
			
		||||
          after: this.startDate.toISOString(),
 | 
			
		||||
          // biome-ignore lint/style/useNamingConvention: api is snake_case
 | 
			
		||||
          text_format: "html",
 | 
			
		||||
          page: this.currentPage,
 | 
			
		||||
          // biome-ignore lint/style/useNamingConvention: api is snake_case
 | 
			
		||||
          page_size: this.pageSize,
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
      if (response.response.status === 404) {
 | 
			
		||||
        this.hasNext = false;
 | 
			
		||||
      } else if (response.data.next === null) {
 | 
			
		||||
        this.newsDates.push(...response.data.results);
 | 
			
		||||
        this.hasNext = false;
 | 
			
		||||
      } else {
 | 
			
		||||
        this.newsDates.push(...response.data.results);
 | 
			
		||||
        this.currentPage += 1;
 | 
			
		||||
      }
 | 
			
		||||
      this.loading = false;
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    groupedDates(): Record<string, NewsDateSchema[]> {
 | 
			
		||||
      return this.newsDates
 | 
			
		||||
        .map(
 | 
			
		||||
          (date: NewsDateSchema): ParsedNewsDateSchema => ({
 | 
			
		||||
            ...date,
 | 
			
		||||
            // biome-ignore lint/style/useNamingConvention: api is snake_case
 | 
			
		||||
            start_date: new Date(date.start_date),
 | 
			
		||||
            // biome-ignore lint/style/useNamingConvention: api is snake_case
 | 
			
		||||
            end_date: new Date(date.end_date),
 | 
			
		||||
          }),
 | 
			
		||||
        )
 | 
			
		||||
        .reduce(
 | 
			
		||||
          (acc: Record<string, ParsedNewsDateSchema[]>, date: ParsedNewsDateSchema) => {
 | 
			
		||||
            const key = date.start_date.toDateString();
 | 
			
		||||
            if (!acc[key]) {
 | 
			
		||||
              acc[key] = [];
 | 
			
		||||
            }
 | 
			
		||||
            acc[key].push(date);
 | 
			
		||||
            return acc;
 | 
			
		||||
          },
 | 
			
		||||
          {},
 | 
			
		||||
        );
 | 
			
		||||
    },
 | 
			
		||||
  }));
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										101
									
								
								com/static/com/components/ics-calendar.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								com/static/com/components/ics-calendar.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,101 @@
 | 
			
		||||
@import "core/static/core/colors";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
:root {
 | 
			
		||||
  --fc-button-border-color: #fff;
 | 
			
		||||
  --fc-button-hover-border-color: #fff;
 | 
			
		||||
  --fc-button-active-border-color: #fff;
 | 
			
		||||
  --fc-button-text-color: #fff;
 | 
			
		||||
  --fc-button-bg-color: #1a78b3;
 | 
			
		||||
  --fc-button-active-bg-color: #15608F;
 | 
			
		||||
  --fc-button-hover-bg-color: #15608F;
 | 
			
		||||
  --fc-today-bg-color: rgba(26, 120, 179, 0.1);
 | 
			
		||||
  --fc-border-color: #DDDDDD;
 | 
			
		||||
  --event-details-background-color: white;
 | 
			
		||||
  --event-details-padding: 20px;
 | 
			
		||||
  --event-details-border: 1px solid #EEEEEE;
 | 
			
		||||
  --event-details-border-radius: 4px;
 | 
			
		||||
  --event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
 | 
			
		||||
  --event-details-max-width: 600px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ics-calendar {
 | 
			
		||||
  border: none;
 | 
			
		||||
  box-shadow: none;
 | 
			
		||||
 | 
			
		||||
  #event-details {
 | 
			
		||||
    z-index: 10;
 | 
			
		||||
    max-width: 1151px;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
 | 
			
		||||
    .event-details-container {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      color: black;
 | 
			
		||||
      flex-direction: column;
 | 
			
		||||
      min-width: 200px;
 | 
			
		||||
      max-width: var(--event-details-max-width);
 | 
			
		||||
      padding: var(--event-details-padding);
 | 
			
		||||
      border: var(--event-details-border);
 | 
			
		||||
      border-radius: var(--event-details-border-radius);
 | 
			
		||||
      background-color: var(--event-details-background-color);
 | 
			
		||||
      box-shadow: var(--event-details-box-shadow);
 | 
			
		||||
      gap: 20px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .event-detail-row-icon {
 | 
			
		||||
      margin-left: 10px;
 | 
			
		||||
      margin-right: 20px;
 | 
			
		||||
      align-content: center;
 | 
			
		||||
      align-self: center;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .event-details-row {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: start;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .event-details-row-content {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: start;
 | 
			
		||||
      flex-direction: row;
 | 
			
		||||
      background-color: var(--event-details-background-color);
 | 
			
		||||
      margin-top: 0px;
 | 
			
		||||
      margin-bottom: 4px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  a.fc-col-header-cell-cushion,
 | 
			
		||||
  a.fc-col-header-cell-cushion:hover {
 | 
			
		||||
    color: black;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  a.fc-daygrid-day-number,
 | 
			
		||||
  a.fc-daygrid-day-number:hover {
 | 
			
		||||
    color: rgb(34, 34, 34);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  td {
 | 
			
		||||
    overflow: visible; // Show events on multiple days
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  //Reset from style.scss
 | 
			
		||||
  table {
 | 
			
		||||
    box-shadow: none;
 | 
			
		||||
    border-radius: 0px;
 | 
			
		||||
    -moz-border-radius: 0px;
 | 
			
		||||
    margin: 0px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Reset from style.scss
 | 
			
		||||
  thead {
 | 
			
		||||
    background-color: white;
 | 
			
		||||
    color: black;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Reset from style.scss
 | 
			
		||||
  tbody>tr {
 | 
			
		||||
    &:nth-child(even):not(.highlight) {
 | 
			
		||||
      background: white;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										61
									
								
								com/static/com/css/news-detail.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								com/static/com/css/news-detail.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
			
		||||
@import "core/static/core/colors";
 | 
			
		||||
 | 
			
		||||
#news_details {
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  margin-top: 20px;
 | 
			
		||||
  padding: 0.4em;
 | 
			
		||||
  width: 80%;
 | 
			
		||||
  background: $white-color;
 | 
			
		||||
 | 
			
		||||
  h4 {
 | 
			
		||||
    margin-top: 1em;
 | 
			
		||||
    text-transform: uppercase;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .club_logo {
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    width: 19%;
 | 
			
		||||
    float: left;
 | 
			
		||||
    min-width: 15em;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
 | 
			
		||||
    img {
 | 
			
		||||
      max-height: 15em;
 | 
			
		||||
      max-width: 12em;
 | 
			
		||||
      display: block;
 | 
			
		||||
      margin: 0 auto;
 | 
			
		||||
      margin-bottom: 10px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .share_button {
 | 
			
		||||
    border: none;
 | 
			
		||||
    color: white;
 | 
			
		||||
    padding: 0.5em 1em;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    font-size: 1.2em;
 | 
			
		||||
    border-radius: 2px;
 | 
			
		||||
    float: right;
 | 
			
		||||
    display: block;
 | 
			
		||||
    margin-left: 0.3em;
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      color: lightgrey;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .facebook {
 | 
			
		||||
    background: $faceblue;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .twitter {
 | 
			
		||||
    background: $twitblue;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .news_meta {
 | 
			
		||||
    margin-top: 10em;
 | 
			
		||||
    font-size: small;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										222
									
								
								com/static/com/css/news-list.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										222
									
								
								com/static/com/css/news-list.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,222 @@
 | 
			
		||||
@import "core/static/core/colors";
 | 
			
		||||
@import "core/static/core/devices";
 | 
			
		||||
 | 
			
		||||
#news {
 | 
			
		||||
  display: flex;
 | 
			
		||||
 | 
			
		||||
  @media (max-width: 800px) {
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #news_admin {
 | 
			
		||||
    margin-bottom: 1em;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #right_column {
 | 
			
		||||
    flex: 20%;
 | 
			
		||||
    margin: 3.2px;
 | 
			
		||||
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    vertical-align: top;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #left_column {
 | 
			
		||||
    flex: 79%;
 | 
			
		||||
    margin: 0.2em;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  h3 {
 | 
			
		||||
    background: $second-color;
 | 
			
		||||
    box-shadow: $shadow-color 1px 1px 1px;
 | 
			
		||||
    padding: 0.4em;
 | 
			
		||||
    margin: 0 0 0.5em 0;
 | 
			
		||||
    text-transform: uppercase;
 | 
			
		||||
    font-size: 17px;
 | 
			
		||||
 | 
			
		||||
    &:not(:first-of-type) {
 | 
			
		||||
      margin: 2em 0 1em 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .feed {
 | 
			
		||||
      float: right;
 | 
			
		||||
      color: #f26522;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media screen and (max-width: $small-devices) {
 | 
			
		||||
 | 
			
		||||
    #left_column,
 | 
			
		||||
    #right_column {
 | 
			
		||||
      flex: 100%;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /* UPCOMING EVENTS */
 | 
			
		||||
 | 
			
		||||
  #upcoming-events {
 | 
			
		||||
    max-height: 600px;
 | 
			
		||||
    overflow-y: scroll;
 | 
			
		||||
 | 
			
		||||
    #load-more-news-button {
 | 
			
		||||
      text-align: center;
 | 
			
		||||
      button {
 | 
			
		||||
        width: 150px;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /* LINKS/BIRTHDAYS */
 | 
			
		||||
  #links,
 | 
			
		||||
  #birthdays {
 | 
			
		||||
    display: block;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    background: white;
 | 
			
		||||
    font-size: 70%;
 | 
			
		||||
    margin-bottom: 1em;
 | 
			
		||||
 | 
			
		||||
    h3 {
 | 
			
		||||
      margin-bottom: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #links_content {
 | 
			
		||||
      overflow: auto;
 | 
			
		||||
      box-shadow: $shadow-color 1px 1px 1px;
 | 
			
		||||
      height: 20em;
 | 
			
		||||
 | 
			
		||||
      h4 {
 | 
			
		||||
        margin-left: 5px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      ul {
 | 
			
		||||
        list-style: none;
 | 
			
		||||
        margin-left: 0;
 | 
			
		||||
 | 
			
		||||
        li {
 | 
			
		||||
          margin: 10px;
 | 
			
		||||
 | 
			
		||||
          .fa-facebook {
 | 
			
		||||
            color: $faceblue;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          .fa-discord {
 | 
			
		||||
            color: $discordblurple;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          .fa-square-instagram::before {
 | 
			
		||||
            background: $instagradient;
 | 
			
		||||
            background-clip: text;
 | 
			
		||||
            -webkit-text-fill-color: transparent;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          i {
 | 
			
		||||
            width: 25px;
 | 
			
		||||
            text-align: center;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #birthdays_content {
 | 
			
		||||
      ul.birthdays_year {
 | 
			
		||||
        margin: 0;
 | 
			
		||||
        list-style-type: none;
 | 
			
		||||
        font-weight: bold;
 | 
			
		||||
 | 
			
		||||
        >li {
 | 
			
		||||
          padding: 0.5em;
 | 
			
		||||
 | 
			
		||||
          &:nth-child(even) {
 | 
			
		||||
            background: $secondary-neutral-light-color;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ul {
 | 
			
		||||
          margin: 0;
 | 
			
		||||
          margin-left: 1em;
 | 
			
		||||
          list-style-type: square;
 | 
			
		||||
          list-style-position: inside;
 | 
			
		||||
          font-weight: normal;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /* END AGENDA/BIRTHDAYS */
 | 
			
		||||
 | 
			
		||||
  /* EVENTS TODAY AND NEXT FEW DAYS */
 | 
			
		||||
  .news_events_group {
 | 
			
		||||
    box-shadow: $shadow-color 1px 1px 1px;
 | 
			
		||||
    margin-left: 1em;
 | 
			
		||||
    margin-bottom: 0.5em;
 | 
			
		||||
 | 
			
		||||
    .news_events_group_date {
 | 
			
		||||
      display: table-cell;
 | 
			
		||||
      padding: 0.6em;
 | 
			
		||||
      vertical-align: middle;
 | 
			
		||||
      background: $primary-neutral-dark-color;
 | 
			
		||||
      color: $white-color;
 | 
			
		||||
      text-transform: uppercase;
 | 
			
		||||
      text-align: center;
 | 
			
		||||
      font-weight: bold;
 | 
			
		||||
      font-family: monospace;
 | 
			
		||||
      font-size: 1.4em;
 | 
			
		||||
      border-radius: 7px 0 0 7px;
 | 
			
		||||
 | 
			
		||||
      div {
 | 
			
		||||
        margin: 0 auto;
 | 
			
		||||
 | 
			
		||||
        .day {
 | 
			
		||||
          font-size: 1.5em;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .news_events_group_items {
 | 
			
		||||
      display: table-cell;
 | 
			
		||||
      width: 100%;
 | 
			
		||||
 | 
			
		||||
      .news_event:nth-of-type(odd) {
 | 
			
		||||
        background: white;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .news_event:nth-of-type(even) {
 | 
			
		||||
        background: $primary-neutral-light-color;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .news_event {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
        gap: .5em;
 | 
			
		||||
        padding: 1em;
 | 
			
		||||
 | 
			
		||||
        header {
 | 
			
		||||
          img {
 | 
			
		||||
            height: 75px;
 | 
			
		||||
          }
 | 
			
		||||
          .header_content {
 | 
			
		||||
            display: flex;
 | 
			
		||||
            flex-direction: column;
 | 
			
		||||
            justify-content: center;
 | 
			
		||||
            gap: .2rem;
 | 
			
		||||
 | 
			
		||||
            h4 {
 | 
			
		||||
              margin-top: 0;
 | 
			
		||||
              text-transform: uppercase;
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /* END EVENTS TODAY AND NEXT FEW DAYS */
 | 
			
		||||
 | 
			
		||||
  .news_empty {
 | 
			
		||||
    margin-left: 1em;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .news_date {
 | 
			
		||||
    color: grey;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										230
									
								
								com/static/com/css/posters.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										230
									
								
								com/static/com/css/posters.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,230 @@
 | 
			
		||||
#poster_list,
 | 
			
		||||
#screen_list,
 | 
			
		||||
#poster_edit,
 | 
			
		||||
#screen_edit {
 | 
			
		||||
  position: relative;
 | 
			
		||||
 | 
			
		||||
  #title {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    padding: 10px;
 | 
			
		||||
    margin: 10px;
 | 
			
		||||
    border-bottom: 2px solid black;
 | 
			
		||||
 | 
			
		||||
    h3 {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      justify-content: center;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #links {
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      display: flex;
 | 
			
		||||
      bottom: 5px;
 | 
			
		||||
 | 
			
		||||
      &.left {
 | 
			
		||||
        left: 0;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &.right {
 | 
			
		||||
        right: 0;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .link {
 | 
			
		||||
        padding: 5px;
 | 
			
		||||
        padding-left: 20px;
 | 
			
		||||
        padding-right: 20px;
 | 
			
		||||
        margin-left: 5px;
 | 
			
		||||
        border-radius: 20px;
 | 
			
		||||
        background-color: hsl(40, 100%, 50%);
 | 
			
		||||
        color: black;
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          color: black;
 | 
			
		||||
          background-color: hsl(40, 58%, 50%);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &.delete {
 | 
			
		||||
          background-color: hsl(0, 100%, 40%);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #posters,
 | 
			
		||||
  #screens {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
 | 
			
		||||
    #no-posters,
 | 
			
		||||
    #no-screens {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      justify-content: center;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .poster,
 | 
			
		||||
    .screen {
 | 
			
		||||
      min-width: 10%;
 | 
			
		||||
      max-width: 20%;
 | 
			
		||||
      display: flex;
 | 
			
		||||
      flex-direction: column;
 | 
			
		||||
      margin: 10px;
 | 
			
		||||
      border: 2px solid darkgrey;
 | 
			
		||||
      border-radius: 4px;
 | 
			
		||||
      padding: 10px;
 | 
			
		||||
      background-color: lightgrey;
 | 
			
		||||
 | 
			
		||||
      * {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        justify-content: center;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .name {
 | 
			
		||||
        padding-bottom: 5px;
 | 
			
		||||
        margin-bottom: 5px;
 | 
			
		||||
        border-bottom: 1px solid whitesmoke;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .image {
 | 
			
		||||
        flex-grow: 1;
 | 
			
		||||
        position: relative;
 | 
			
		||||
        padding-bottom: 5px;
 | 
			
		||||
        margin-bottom: 5px;
 | 
			
		||||
        border-bottom: 1px solid whitesmoke;
 | 
			
		||||
 | 
			
		||||
        img {
 | 
			
		||||
          max-height: 20vw;
 | 
			
		||||
          max-width: 100%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          &::before {
 | 
			
		||||
            position: absolute;
 | 
			
		||||
            width: 100%;
 | 
			
		||||
            height: 100%;
 | 
			
		||||
            display: flex;
 | 
			
		||||
            justify-content: center;
 | 
			
		||||
            align-items: center;
 | 
			
		||||
            flex-wrap: wrap;
 | 
			
		||||
            top: 0;
 | 
			
		||||
            left: 0;
 | 
			
		||||
            z-index: 10;
 | 
			
		||||
            content: "Click to expand";
 | 
			
		||||
            color: white;
 | 
			
		||||
            background-color: rgba(black, 0.5);
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .dates {
 | 
			
		||||
        padding-bottom: 5px;
 | 
			
		||||
        margin-bottom: 5px;
 | 
			
		||||
        border-bottom: 1px solid whitesmoke;
 | 
			
		||||
 | 
			
		||||
        * {
 | 
			
		||||
          display: flex;
 | 
			
		||||
          justify-content: center;
 | 
			
		||||
          align-items: center;
 | 
			
		||||
          flex-wrap: wrap;
 | 
			
		||||
          margin-left: 5px;
 | 
			
		||||
          margin-right: 5px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .begin,
 | 
			
		||||
        .end {
 | 
			
		||||
          width: 48%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .begin {
 | 
			
		||||
          border-right: 1px solid whitesmoke;
 | 
			
		||||
          padding-right: 2%;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .edit,
 | 
			
		||||
      .moderate,
 | 
			
		||||
      .slideshow {
 | 
			
		||||
        padding: 5px;
 | 
			
		||||
        border-radius: 20px;
 | 
			
		||||
        background-color: hsl(40, 100%, 50%);
 | 
			
		||||
        color: black;
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          color: black;
 | 
			
		||||
          background-color: hsl(40, 58%, 50%);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &:nth-child(2n) {
 | 
			
		||||
          margin-top: 5px;
 | 
			
		||||
          margin-bottom: 5px;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .tooltip {
 | 
			
		||||
        visibility: hidden;
 | 
			
		||||
        width: 120px;
 | 
			
		||||
        background-color: hsl(210, 20%, 98%);
 | 
			
		||||
        color: hsl(0, 0%, 0%);
 | 
			
		||||
        text-align: center;
 | 
			
		||||
        padding: 5px 0;
 | 
			
		||||
        border-radius: 6px;
 | 
			
		||||
        position: absolute;
 | 
			
		||||
        z-index: 10;
 | 
			
		||||
 | 
			
		||||
        ul {
 | 
			
		||||
          margin-left: 0;
 | 
			
		||||
          display: inline-block;
 | 
			
		||||
 | 
			
		||||
          li {
 | 
			
		||||
            display: list-item;
 | 
			
		||||
            list-style-type: none;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &.not_moderated {
 | 
			
		||||
        border: 1px solid red;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &:hover .tooltip {
 | 
			
		||||
        visibility: visible;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #view {
 | 
			
		||||
    position: fixed;
 | 
			
		||||
    width: 100vw;
 | 
			
		||||
    height: 100vh;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    top: 0;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    z-index: 10;
 | 
			
		||||
    visibility: hidden;
 | 
			
		||||
    background-color: rgba(10, 10, 10, 0.9);
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
 | 
			
		||||
    &.active {
 | 
			
		||||
      visibility: visible;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #placeholder {
 | 
			
		||||
      width: 80vw;
 | 
			
		||||
      height: 80vh;
 | 
			
		||||
      display: flex;
 | 
			
		||||
      justify-content: center;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      top: 0;
 | 
			
		||||
      left: 0;
 | 
			
		||||
 | 
			
		||||
      img {
 | 
			
		||||
        max-width: 100%;
 | 
			
		||||
        max-height: 100%;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										127
									
								
								com/templates/com/macros.jinja
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								com/templates/com/macros.jinja
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,127 @@
 | 
			
		||||
{% macro news_moderation_alert(news, user, alpineState = None) %}
 | 
			
		||||
    {# An alert to display on top of unpublished news,
 | 
			
		||||
    with actions to either publish or delete them.
 | 
			
		||||
 | 
			
		||||
    The current state of the alert is accessible through
 | 
			
		||||
    the given `alpineState` variable.
 | 
			
		||||
    This state is a `AlertState`, as defined in `moderation-alert-index.ts`
 | 
			
		||||
 | 
			
		||||
    This comes in three flavours :
 | 
			
		||||
    - You can pass the `News` object itself to the macro.
 | 
			
		||||
      In this case, if `request.user` can publish news,
 | 
			
		||||
      it will perform an additional db query to know if it is a recurring event.
 | 
			
		||||
    - You can also give only the news id.
 | 
			
		||||
      In this case, a server request will be issued to know
 | 
			
		||||
      if it is a recurring event.
 | 
			
		||||
    - Finally, you can pass the name of an alpine variable, which value is the id.
 | 
			
		||||
      In this case, a server request will be issued to know
 | 
			
		||||
      if it is a recurring event.
 | 
			
		||||
 | 
			
		||||
    Example with full `News` object :
 | 
			
		||||
    ```jinja
 | 
			
		||||
    <div x-data="{state: AlertState.PENDING}">
 | 
			
		||||
      {{ news_moderation_alert(news, user, "state") }}
 | 
			
		||||
    </div>
 | 
			
		||||
    ```
 | 
			
		||||
    With an id :
 | 
			
		||||
    ```jinja
 | 
			
		||||
    <div x-data="{state: AlertState.PENDING}">
 | 
			
		||||
      {{ news_moderation_alert(news.id, user, "state") }}
 | 
			
		||||
    </div>
 | 
			
		||||
    ```
 | 
			
		||||
    An with an alpine variable
 | 
			
		||||
    ```jinja
 | 
			
		||||
    <div x-data="{state: AlertState.PENDING, newsId: {{ news.id }}">
 | 
			
		||||
      {{ news_moderation_alert("newsId", user, "state") }}
 | 
			
		||||
    </div>
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        news: (News | int | string)
 | 
			
		||||
          Either the `News` object to which this alert is related,
 | 
			
		||||
          or its id, or the name of an Alpine which value is its id
 | 
			
		||||
        user: The request.user
 | 
			
		||||
        alpineState: An alpine variable name
 | 
			
		||||
 | 
			
		||||
    Warning:
 | 
			
		||||
        If you use this macro, you must also include `moderation-alert-index.ts`
 | 
			
		||||
        in your template.
 | 
			
		||||
    #}
 | 
			
		||||
  <div
 | 
			
		||||
    {% if news is integer or news is string %}
 | 
			
		||||
      x-data="moderationAlert({{ news }})"
 | 
			
		||||
    {% else %}
 | 
			
		||||
      x-data="moderationAlert({{ news.id }})"
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    {# the news-moderated is received when a moderation alert is deleted or moderated #}
 | 
			
		||||
    @news-moderated.window="dispatchModeration($event)"
 | 
			
		||||
    {% if alpineState %}
 | 
			
		||||
      x-model="{{ alpineState }}"
 | 
			
		||||
      x-modelable="state"
 | 
			
		||||
    {% endif %}
 | 
			
		||||
  >
 | 
			
		||||
    <template x-if="state === AlertState.PENDING">
 | 
			
		||||
      <div class="alert alert-yellow">
 | 
			
		||||
        <div class="alert-main">
 | 
			
		||||
          <strong>{% trans %}Waiting publication{% endtrans %}</strong>
 | 
			
		||||
          <p>
 | 
			
		||||
            {% trans trimmed %}
 | 
			
		||||
              This news isn't published and is visible
 | 
			
		||||
              only by its author and the communication admins.
 | 
			
		||||
            {% endtrans %}
 | 
			
		||||
          </p>
 | 
			
		||||
          <p>
 | 
			
		||||
            {% trans trimmed %}
 | 
			
		||||
              It will stay hidden for other users until it has been published.
 | 
			
		||||
            {% endtrans %}
 | 
			
		||||
          </p>
 | 
			
		||||
          {% if user.has_perm("com.moderate_news") %}
 | 
			
		||||
            {# This is an additional query for each non-moderated news,
 | 
			
		||||
            but it will be executed only for admin users, and only one time
 | 
			
		||||
            (if they do their job and moderated news as soon as they see them),
 | 
			
		||||
            so it's still reasonable #}
 | 
			
		||||
            <div
 | 
			
		||||
              {% if news is integer or news is string %}
 | 
			
		||||
                x-data="{ nbEvents: 0 }"
 | 
			
		||||
                x-init="nbEvents = await nbToPublish()"
 | 
			
		||||
              {% else %}
 | 
			
		||||
                x-data="{ nbEvents: {{ news.dates.count() }} }"
 | 
			
		||||
              {% endif %}
 | 
			
		||||
            >
 | 
			
		||||
              <template x-if="nbEvents > 1">
 | 
			
		||||
                <div>
 | 
			
		||||
                  <br>
 | 
			
		||||
                  <strong>{% trans %}Weekly event{% endtrans %}</strong>
 | 
			
		||||
                  <p x-text="weeklyEventWarningMessage(nbEvents)"></p>
 | 
			
		||||
                </div>
 | 
			
		||||
              </template>
 | 
			
		||||
            </div>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
        {% if user.has_perm("com.moderate_news") %}
 | 
			
		||||
          <span class="alert-aside" :aria-busy="loading">
 | 
			
		||||
            <button class="btn btn-green" @click="publishNews()" :disabled="loading">
 | 
			
		||||
              <i class="fa fa-check"></i> {% trans %}Publish{% endtrans %}
 | 
			
		||||
            </button>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if user.has_perm("com.delete_news") %}
 | 
			
		||||
          <button class="btn btn-red" @click="deleteNews()" :disabled="loading">
 | 
			
		||||
            <i class="fa fa-trash-can"></i> {% trans %}Delete{% endtrans %}
 | 
			
		||||
          </button>
 | 
			
		||||
          </span>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </div>
 | 
			
		||||
    </template>
 | 
			
		||||
    <template x-if="state === AlertState.PUBLISHED">
 | 
			
		||||
      <div class="alert alert-green">
 | 
			
		||||
        {% trans %}News published{% endtrans %}
 | 
			
		||||
      </div>
 | 
			
		||||
    </template>
 | 
			
		||||
    <template x-if="state === AlertState.DELETED">
 | 
			
		||||
      <div class="alert alert-red">
 | 
			
		||||
        {% trans %}News deleted{% endtrans %}
 | 
			
		||||
      </div>
 | 
			
		||||
    </template>
 | 
			
		||||
  </div>
 | 
			
		||||
{% endmacro %}
 | 
			
		||||
@@ -10,78 +10,13 @@
 | 
			
		||||
 | 
			
		||||
  <p><a href="{{ url('com:news_new') }}">{% trans %}Create news{% endtrans %}</a></p>
 | 
			
		||||
 | 
			
		||||
  <hr />
 | 
			
		||||
  <h4>{% trans %}Notices{% endtrans %}</h4>
 | 
			
		||||
  {% set notices = object_list.filter(type="NOTICE").distinct().order_by('id') %}
 | 
			
		||||
  <h5>{% trans %}Displayed notices{% endtrans %}</h5>
 | 
			
		||||
  <table>
 | 
			
		||||
    <thead>
 | 
			
		||||
      <tr>
 | 
			
		||||
        <td>{% trans %}Type{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Title{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Summary{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Club{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Author{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Moderator{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Actions{% endtrans %}</td>
 | 
			
		||||
      </tr>
 | 
			
		||||
    </thead>
 | 
			
		||||
    <tbody>
 | 
			
		||||
      {% for news in notices.filter(is_moderated=True) %}
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td>{{ news.get_type_display() }}</td>
 | 
			
		||||
          <td>{{ news.title }}</td>
 | 
			
		||||
          <td>{{ news.summary|markdown }}</td>
 | 
			
		||||
          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
			
		||||
          <td>{{ user_profile_link(news.author) }}</td>
 | 
			
		||||
          <td>{{ user_profile_link(news.moderator) }}</td>
 | 
			
		||||
          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
			
		||||
          </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </tbody>
 | 
			
		||||
  </table>
 | 
			
		||||
  <h5>{% trans %}Notices to moderate{% endtrans %}</h5>
 | 
			
		||||
  <table>
 | 
			
		||||
    <thead>
 | 
			
		||||
      <tr>
 | 
			
		||||
        <td>{% trans %}Type{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Title{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Summary{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Club{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Author{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Actions{% endtrans %}</td>
 | 
			
		||||
      </tr>
 | 
			
		||||
    </thead>
 | 
			
		||||
    <tbody>
 | 
			
		||||
      {% for news in notices.filter(is_moderated=False) %}
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td>{{ news.get_type_display() }}</td>
 | 
			
		||||
          <td>{{ news.title }}</td>
 | 
			
		||||
          <td>{{ news.summary|markdown }}</td>
 | 
			
		||||
          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
			
		||||
          <td>{{ user_profile_link(news.author) }}</td>
 | 
			
		||||
          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
			
		||||
          </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </tbody>
 | 
			
		||||
  </table>
 | 
			
		||||
 | 
			
		||||
  <hr />
 | 
			
		||||
  <h4>{% trans %}Weeklies{% endtrans %}</h4>
 | 
			
		||||
  {% set weeklies = object_list.filter(type="WEEKLY", dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
 | 
			
		||||
  {% set weeklies = object_list.filter(dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
 | 
			
		||||
  <h5>{% trans %}Displayed weeklies{% endtrans %}</h5>
 | 
			
		||||
  <table>
 | 
			
		||||
    <thead>
 | 
			
		||||
      <tr>
 | 
			
		||||
        <td>{% trans %}Type{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Title{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Summary{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Club{% endtrans %}</td>
 | 
			
		||||
@@ -92,9 +27,8 @@
 | 
			
		||||
      </tr>
 | 
			
		||||
    </thead>
 | 
			
		||||
    <tbody>
 | 
			
		||||
      {% for news in weeklies.filter(is_moderated=True) %}
 | 
			
		||||
      {% for news in weeklies.filter(is_published=True) %}
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td>{{ news.get_type_display() }}</td>
 | 
			
		||||
          <td>{{ news.title }}</td>
 | 
			
		||||
          <td>{{ news.summary|markdown }}</td>
 | 
			
		||||
          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
			
		||||
@@ -113,7 +47,7 @@
 | 
			
		||||
          </td>
 | 
			
		||||
          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Unpublish{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
			
		||||
          </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
@@ -124,7 +58,6 @@
 | 
			
		||||
  <table>
 | 
			
		||||
    <thead>
 | 
			
		||||
      <tr>
 | 
			
		||||
        <td>{% trans %}Type{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Title{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Summary{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Club{% endtrans %}</td>
 | 
			
		||||
@@ -134,9 +67,8 @@
 | 
			
		||||
      </tr>
 | 
			
		||||
    </thead>
 | 
			
		||||
    <tbody>
 | 
			
		||||
      {% for news in weeklies.filter(is_moderated=False) %}
 | 
			
		||||
      {% for news in weeklies.filter(is_published=False) %}
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td>{{ news.get_type_display() }}</td>
 | 
			
		||||
          <td>{{ news.title }}</td>
 | 
			
		||||
          <td>{{ news.summary|markdown }}</td>
 | 
			
		||||
          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
			
		||||
@@ -154,98 +86,20 @@
 | 
			
		||||
          </td>
 | 
			
		||||
          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
			
		||||
          </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </tbody>
 | 
			
		||||
  </table>
 | 
			
		||||
 | 
			
		||||
  <hr />
 | 
			
		||||
  <h4>{% trans %}Calls{% endtrans %}</h4>
 | 
			
		||||
  {% set calls = object_list.filter(type="CALL", dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
 | 
			
		||||
  <h5>{% trans %}Displayed calls{% endtrans %}</h5>
 | 
			
		||||
  <table>
 | 
			
		||||
    <thead>
 | 
			
		||||
      <tr>
 | 
			
		||||
        <td>{% trans %}Type{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Title{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Summary{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Club{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Author{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Moderator{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Start{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}End{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Actions{% endtrans %}</td>
 | 
			
		||||
      </tr>
 | 
			
		||||
    </thead>
 | 
			
		||||
    <tbody>
 | 
			
		||||
      {% for news in calls.filter(is_moderated=True) %}
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td>{{ news.get_type_display() }}</td>
 | 
			
		||||
          <td>{{ news.title }}</td>
 | 
			
		||||
          <td>{{ news.summary|markdown }}</td>
 | 
			
		||||
          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
			
		||||
          <td>{{ user_profile_link(news.author) }}</td>
 | 
			
		||||
          <td>{{ user_profile_link(news.moderator) }}</td>
 | 
			
		||||
          <td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
            {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
			
		||||
          <td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
            {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
			
		||||
          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
			
		||||
          </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </tbody>
 | 
			
		||||
  </table>
 | 
			
		||||
  <h5>{% trans %}Calls to moderate{% endtrans %}</h5>
 | 
			
		||||
  <table>
 | 
			
		||||
    <thead>
 | 
			
		||||
      <tr>
 | 
			
		||||
        <td>{% trans %}Type{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Title{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Summary{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Club{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Author{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Start{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}End{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Actions{% endtrans %}</td>
 | 
			
		||||
      </tr>
 | 
			
		||||
    </thead>
 | 
			
		||||
    <tbody>
 | 
			
		||||
      {% for news in calls.filter(is_moderated=False) %}
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td>{{ news.get_type_display() }}</td>
 | 
			
		||||
          <td>{{ news.title }}</td>
 | 
			
		||||
          <td>{{ news.summary|markdown }}</td>
 | 
			
		||||
          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
			
		||||
          <td>{{ user_profile_link(news.author) }}</td>
 | 
			
		||||
          <td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
            {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
			
		||||
          <td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
            {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
			
		||||
          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
			
		||||
          </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </tbody>
 | 
			
		||||
  </table>
 | 
			
		||||
 | 
			
		||||
  <hr />
 | 
			
		||||
  <h4>{% trans %}Events{% endtrans %}</h4>
 | 
			
		||||
  {% set events = object_list.filter(type="EVENT", dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
 | 
			
		||||
  {% set events = object_list.filter(dates__end_date__gte=timezone.now()).order_by('id') %}
 | 
			
		||||
  <h5>{% trans %}Displayed events{% endtrans %}</h5>
 | 
			
		||||
  <table>
 | 
			
		||||
    <thead>
 | 
			
		||||
      <tr>
 | 
			
		||||
        <td>{% trans %}Type{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Title{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Summary{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Club{% endtrans %}</td>
 | 
			
		||||
@@ -257,21 +111,20 @@
 | 
			
		||||
      </tr>
 | 
			
		||||
    </thead>
 | 
			
		||||
    <tbody>
 | 
			
		||||
      {% for news in events.filter(is_moderated=True) %}
 | 
			
		||||
      {% for news in events.filter(is_published=True) %}
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td>{{ news.get_type_display() }}</td>
 | 
			
		||||
          <td>{{ news.title }}</td>
 | 
			
		||||
          <td>{{ news.summary|markdown }}</td>
 | 
			
		||||
          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
			
		||||
          <td>{{ user_profile_link(news.author) }}</td>
 | 
			
		||||
          <td>{{ user_profile_link(news.moderator) }}</td>
 | 
			
		||||
          <td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
            {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
			
		||||
          <td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
            {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
			
		||||
          <td>{{ news.dates.all()[0].start_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
            {{ news.dates.all()[0].start_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
			
		||||
          <td>{{ news.dates.all()[0].end_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
            {{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
			
		||||
          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Unpublish{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
			
		||||
          </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
@@ -282,7 +135,6 @@
 | 
			
		||||
  <table>
 | 
			
		||||
    <thead>
 | 
			
		||||
      <tr>
 | 
			
		||||
        <td>{% trans %}Type{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Title{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Summary{% endtrans %}</td>
 | 
			
		||||
        <td>{% trans %}Club{% endtrans %}</td>
 | 
			
		||||
@@ -293,20 +145,19 @@
 | 
			
		||||
      </tr>
 | 
			
		||||
    </thead>
 | 
			
		||||
    <tbody>
 | 
			
		||||
      {% for news in events.filter(is_moderated=False) %}
 | 
			
		||||
      {% for news in events.filter(is_published=False) %}
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td>{{ news.get_type_display() }}</td>
 | 
			
		||||
          <td>{{ news.title }}</td>
 | 
			
		||||
          <td>{{ news.summary|markdown }}</td>
 | 
			
		||||
          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
			
		||||
          <td>{{ user_profile_link(news.author) }}</td>
 | 
			
		||||
          <td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
            {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
			
		||||
          <td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
            {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
			
		||||
          <td>{{ news.dates.all()[0].start_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
            {{ news.dates.all()[0].start_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
			
		||||
          <td>{{ news.dates.all()[0].end_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
            {{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
			
		||||
          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
			
		||||
          </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
{% extends "core/base.jinja" %}
 | 
			
		||||
{% from 'core/macros.jinja' import user_profile_link, facebook_share, tweet, link_news_logo, gen_news_metatags %}
 | 
			
		||||
{% from "com/macros.jinja" import news_moderation_alert %}
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
  {% trans %}News{% endtrans %} -
 | 
			
		||||
@@ -11,19 +12,33 @@
 | 
			
		||||
  {{ gen_news_metatags(news) }}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% block additional_css %}
 | 
			
		||||
  <link rel="stylesheet" href="{{ static('com/css/news-detail.scss') }}">
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block additional_js %}
 | 
			
		||||
  <script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  <p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
 | 
			
		||||
  <section id="news_details">
 | 
			
		||||
  <div x-data="{newsState: AlertState.PENDING}">
 | 
			
		||||
 | 
			
		||||
    {% if not news.is_published %}
 | 
			
		||||
      {{ news_moderation_alert(news, user, "newsState") }}
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <article id="news_details" x-show="newsState !== AlertState.DELETED">
 | 
			
		||||
      <div class="club_logo">
 | 
			
		||||
        <img src="{{ link_news_logo(news)}}" alt="{{ news.club }}" />
 | 
			
		||||
        <a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a>
 | 
			
		||||
      </div>
 | 
			
		||||
      <h4>{{ news.title }}</h4>
 | 
			
		||||
      <p class="date">
 | 
			
		||||
      <span>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
        {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
 | 
			
		||||
      <span>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
        {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
 | 
			
		||||
        <time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
          {{ date.start_date|localtime|time(DATETIME_FORMAT) }}</time> -
 | 
			
		||||
        <time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
          {{ date.end_date|localtime|time(DATETIME_FORMAT) }}</time>
 | 
			
		||||
      </p>
 | 
			
		||||
      <div class="news_content">
 | 
			
		||||
        <div><em>{{ news.summary|markdown }}</em></div>
 | 
			
		||||
@@ -36,14 +51,15 @@
 | 
			
		||||
          {% if news.moderator %}
 | 
			
		||||
            <p>{% trans %}Moderator: {% endtrans %}{{ user_profile_link(news.moderator) }}</p>
 | 
			
		||||
          {% elif user.is_com_admin %}
 | 
			
		||||
          <p> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a></p>
 | 
			
		||||
            <p> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a></p>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
          {% if user.can_edit(news) %}
 | 
			
		||||
            <p> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit (will be moderated again){% endtrans %}</a></p>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
  </section>
 | 
			
		||||
    </article>
 | 
			
		||||
  </div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -10,21 +10,6 @@
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  {% if 'preview' in request.POST.keys() %}
 | 
			
		||||
    <section class="news_event">
 | 
			
		||||
      <h4>{{ form.instance.title }}</h4>
 | 
			
		||||
      <p class="date">
 | 
			
		||||
        <span>{{ form.instance.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
          {{ form.instance.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
 | 
			
		||||
        <span>{{ form.instance.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
          {{ form.instance.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
 | 
			
		||||
      </p>
 | 
			
		||||
      <p><a href="#">{{ form.instance.club or "Club" }}</a></p>
 | 
			
		||||
      <div>{{ form.instance.summary|markdown }}</div>
 | 
			
		||||
      <div>{{ form.instance.content|markdown }}</div>
 | 
			
		||||
      <p>{% trans %}Author: {% endtrans %} {{ user_profile_link(form.instance.author) }}</p>
 | 
			
		||||
    </section>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  {% if object %}
 | 
			
		||||
    <h2>{% trans %}Edit news{% endtrans %}</h2>
 | 
			
		||||
  {% else %}
 | 
			
		||||
@@ -33,55 +18,73 @@
 | 
			
		||||
  <form action="" method="post">
 | 
			
		||||
    {% csrf_token %}
 | 
			
		||||
    {{ form.non_field_errors() }}
 | 
			
		||||
    {{ form.author }}
 | 
			
		||||
    <p>{{ form.type.errors }}<label for="{{ form.type.name }}">{{ form.type.label }}</label>
 | 
			
		||||
      <ul>
 | 
			
		||||
        <li>{% trans %}Notice: Information, election result - no date{% endtrans %}</li>
 | 
			
		||||
        <li>{% trans %}Event: punctual event, associated with one date{% endtrans %}</li>
 | 
			
		||||
        <li>{% trans %}Weekly: recurrent event, associated with many dates (specify the first one, and a deadline){% endtrans %}</li>
 | 
			
		||||
        <li>{% trans %}Call: long time event, associated with a long date (election appliance, ...){% endtrans %}</li>
 | 
			
		||||
      </ul>
 | 
			
		||||
      {{ form.type }}</p>
 | 
			
		||||
    <p class="date">{{ form.start_date.errors }}<label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label> {{ form.start_date }}</p>
 | 
			
		||||
    <p class="date">{{ form.end_date.errors }}<label for="{{ form.end_date.name }}">{{ form.end_date.label }}</label> {{ form.end_date }}</p>
 | 
			
		||||
    <p class="until">{{ form.until.errors }}<label for="{{ form.until.name }}">{{ form.until.label }}</label> {{ form.until }}</p>
 | 
			
		||||
    <p>{{ form.title.errors }}<label for="{{ form.title.name }}">{{ form.title.label }}</label> {{ form.title }}</p>
 | 
			
		||||
    <p>{{ form.club.errors }}<label for="{{ form.club.name }}">{{ form.club.label }}</label> {{ form.club }}</p>
 | 
			
		||||
    <p>{{ form.summary.errors }}<label for="{{ form.summary.name }}">{{ form.summary.label }}</label> {{ form.summary }}</p>
 | 
			
		||||
    <p>{{ form.content.errors }}<label for="{{ form.content.name }}">{{ form.content.label }}</label> {{ form.content }}</p>
 | 
			
		||||
    {% if user.is_com_admin %}
 | 
			
		||||
      <p>{{ form.automoderation.errors }}<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
 | 
			
		||||
        {{ form.automoderation }}</p>
 | 
			
		||||
    <fieldset>
 | 
			
		||||
      {{ form.title.errors }}
 | 
			
		||||
      {{ form.title.label_tag() }}
 | 
			
		||||
      {{ form.title }}
 | 
			
		||||
    </fieldset>
 | 
			
		||||
    <fieldset>
 | 
			
		||||
      {{ form.club.errors }}
 | 
			
		||||
      {{ form.club.label_tag() }}
 | 
			
		||||
      <span class="helptext">{{ form.club.help_text }}</span>
 | 
			
		||||
      {{ form.club }}
 | 
			
		||||
    </fieldset>
 | 
			
		||||
    {{ form.date_form.non_field_errors() }}
 | 
			
		||||
    <div
 | 
			
		||||
      class="row gap-2x"
 | 
			
		||||
      x-data="{startDate: '{{ form.date_form.start_date.value() }}'}"
 | 
			
		||||
    >
 | 
			
		||||
        {# startDate is used to dynamically ensure end_date >= start_date,
 | 
			
		||||
        whatever the value of start_date #}
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        {{ form.date_form.start_date.errors }}
 | 
			
		||||
        {{ form.date_form.start_date.label_tag() }}
 | 
			
		||||
        <span class="helptext">{{ form.date_form.start_date.help_text }}</span>
 | 
			
		||||
        {{ form.date_form.start_date|add_attr("x-model=startDate") }}
 | 
			
		||||
      </fieldset>
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        {{ form.date_form.end_date.errors }}
 | 
			
		||||
        {{ form.date_form.end_date.label_tag() }}
 | 
			
		||||
        <span class="helptext">{{ form.date_form.end_date.help_text }}</span>
 | 
			
		||||
        {{ form.date_form.end_date|add_attr(":min=startDate") }}
 | 
			
		||||
      </fieldset>
 | 
			
		||||
    </div>
 | 
			
		||||
    {# lower to convert True and False to true and false #}
 | 
			
		||||
    <div x-data="{isWeekly: {{ form.date_form.is_weekly.value()|lower }}}">
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <div class="row gap">
 | 
			
		||||
          {{ form.date_form.is_weekly|add_attr("x-model=isWeekly") }}
 | 
			
		||||
          <div>
 | 
			
		||||
            {{ form.date_form.is_weekly.label_tag() }}
 | 
			
		||||
            <span class="helptext">{{ form.date_form.is_weekly.help_text }}</span>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
      <fieldset x-show="isWeekly" x-transition x-cloak>
 | 
			
		||||
        {{ form.date_form.occurrences.label_tag() }}
 | 
			
		||||
        <span class="helptext">{{ form.date_form.occurrences.help_text }}</span>
 | 
			
		||||
        {{ form.date_form.occurrences }}
 | 
			
		||||
      </fieldset>
 | 
			
		||||
    </div>
 | 
			
		||||
    <fieldset>
 | 
			
		||||
      {{ form.summary.errors }}
 | 
			
		||||
      {{ form.summary.label_tag() }}
 | 
			
		||||
      <span class="helptext">{{ form.summary.help_text }}</span>
 | 
			
		||||
      {{ form.summary }}
 | 
			
		||||
    </fieldset>
 | 
			
		||||
    <fieldset>
 | 
			
		||||
      {{ form.content.errors }}
 | 
			
		||||
      {{ form.content.label_tag() }}
 | 
			
		||||
      <span class="helptext">{{ form.content.help_text }}</span>
 | 
			
		||||
      {{ form.content }}
 | 
			
		||||
    </fieldset>
 | 
			
		||||
    {% if user.is_root or user.is_com_admin %}
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        {{ form.auto_publish.errors }}
 | 
			
		||||
        {{ form.auto_publish }}
 | 
			
		||||
        {{ form.auto_publish.label_tag() }}
 | 
			
		||||
      </fieldset>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}" /></p>
 | 
			
		||||
    <p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
 | 
			
		||||
    <p><input type="submit" value="{% trans %}Save{% endtrans %}" class="btn btn-blue"/></p>
 | 
			
		||||
  </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block script %}
 | 
			
		||||
  {{ super() }}
 | 
			
		||||
  <script>
 | 
			
		||||
    $( function() {
 | 
			
		||||
      var type = $('input[name=type]');
 | 
			
		||||
      var dates = $('.date');
 | 
			
		||||
      var until = $('.until');
 | 
			
		||||
      function update_targets () {
 | 
			
		||||
        type_checked = $('input[name=type]:checked');
 | 
			
		||||
        if (type_checked.val() == "EVENT" || type_checked.val() == "CALL") {
 | 
			
		||||
          dates.show();
 | 
			
		||||
          until.hide();
 | 
			
		||||
        } else if (type_checked.val() == "WEEKLY") {
 | 
			
		||||
          dates.show();
 | 
			
		||||
          until.show();
 | 
			
		||||
        } else {
 | 
			
		||||
          dates.hide();
 | 
			
		||||
          until.hide();
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      update_targets();
 | 
			
		||||
      type.change(update_targets);
 | 
			
		||||
    } );
 | 
			
		||||
  </script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,166 +1,278 @@
 | 
			
		||||
{% extends "core/base.jinja" %}
 | 
			
		||||
{% from 'core/macros.jinja' import tweet_quick, fb_quick %}
 | 
			
		||||
{% from "com/macros.jinja" import news_moderation_alert %}
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
  {% trans %}News{% endtrans %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  {% if user.is_com_admin %}
 | 
			
		||||
    <div id="news_admin">
 | 
			
		||||
      <a class="button" href="{{ url('com:news_admin_list') }}">{% trans %}Administrate news{% endtrans %}</a>
 | 
			
		||||
    </div>
 | 
			
		||||
    <br>
 | 
			
		||||
  {% endif  %}
 | 
			
		||||
{% block additional_css %}
 | 
			
		||||
  <link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
 | 
			
		||||
  <link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
 | 
			
		||||
 | 
			
		||||
  {# Atom feed discovery, not really css but also goes there #}
 | 
			
		||||
  <link rel="alternate" type="application/rss+xml" title="{% trans %}News feed{% endtrans %}" href="{{ url("com:news_feed") }}">
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block additional_js %}
 | 
			
		||||
  <script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script>
 | 
			
		||||
  <script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
 | 
			
		||||
  <script type="module" src={{ static("bundled/com/components/upcoming-news-loader-index.ts") }}></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  <div id="news">
 | 
			
		||||
    <div id="left_column" class="news_column">
 | 
			
		||||
      {% for news in object_list.filter(type="NOTICE") %}
 | 
			
		||||
        <section class="news_notice">
 | 
			
		||||
          <h4><a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
 | 
			
		||||
          <div class="news_content">{{ news.summary|markdown }}</div>
 | 
			
		||||
        </section>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
 | 
			
		||||
      {% for news in object_list.filter(dates__start_date__lte=timezone.now(), dates__end_date__gte=timezone.now(), type="CALL") %}
 | 
			
		||||
        <section class="news_call">
 | 
			
		||||
          <h4> <a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
 | 
			
		||||
          <div class="news_date">
 | 
			
		||||
            <span>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
              {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
 | 
			
		||||
            <span>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
              {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="news_content">{{ news.summary|markdown }}</div>
 | 
			
		||||
        </section>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
 | 
			
		||||
      {% set events_dates = NewsDate.objects.filter(end_date__gte=timezone.now(), start_date__lte=timezone.now()+timedelta(days=5), news__type="EVENT", news__is_moderated=True).datetimes('start_date', 'day') %}
 | 
			
		||||
      <h3>{% trans %}Events today and the next few days{% endtrans %}</h3>
 | 
			
		||||
      {% if events_dates %}
 | 
			
		||||
        {% for d in events_dates %}
 | 
			
		||||
          <div class="news_events_group">
 | 
			
		||||
            <div class="news_events_group_date">
 | 
			
		||||
              <div>
 | 
			
		||||
                <div>{{ d|localtime|date('D') }}</div>
 | 
			
		||||
                <div class="day">{{ d|localtime|date('d') }}</div>
 | 
			
		||||
                <div>{{ d|localtime|date('b') }}</div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="news_events_group_items">
 | 
			
		||||
              {% for news in object_list.filter(dates__start_date__gte=d,
 | 
			
		||||
              dates__start_date__lte=d+timedelta(days=1),
 | 
			
		||||
              type="EVENT").exclude(dates__end_date__lt=timezone.now())
 | 
			
		||||
              .order_by('dates__start_date') %}
 | 
			
		||||
              <section class="news_event">
 | 
			
		||||
                <div class="club_logo">
 | 
			
		||||
                  {% if news.club.logo %}
 | 
			
		||||
                    <img src="{{ news.club.logo.url }}" alt="{{ news.club }}" />
 | 
			
		||||
                  {% else %}
 | 
			
		||||
                    <img src="{{ static("com/img/news.png") }}" alt="{{ news.club }}" />
 | 
			
		||||
      <h3>
 | 
			
		||||
        {% trans %}Events today and the next few days{% endtrans %}
 | 
			
		||||
        <a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
 | 
			
		||||
      </h3>
 | 
			
		||||
      {% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
 | 
			
		||||
        <a class="btn btn-blue margin-bottom" href="{{ url("com:news_new") }}">
 | 
			
		||||
          <i class="fa fa-plus"></i>
 | 
			
		||||
          {% trans %}Create news{% endtrans %}
 | 
			
		||||
        </a>
 | 
			
		||||
      {% endif %}
 | 
			
		||||
                </div>
 | 
			
		||||
                <h4> <a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
 | 
			
		||||
                <div><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></div>
 | 
			
		||||
                <div class="news_date">
 | 
			
		||||
                  <span>{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
 | 
			
		||||
                  <span>{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="news_content">{{ news.summary|markdown }}
 | 
			
		||||
                  <div class="button_bar">
 | 
			
		||||
                    {{ fb_quick(news) }}
 | 
			
		||||
                    {{ tweet_quick(news) }}
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </section>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
        </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
{% else %}
 | 
			
		||||
      {% if user.is_com_admin %}
 | 
			
		||||
        <a class="btn btn-blue" href="{{ url('com:news_admin_list') }}">
 | 
			
		||||
          {% trans %}Administrate news{% endtrans %}
 | 
			
		||||
        </a>
 | 
			
		||||
        <br>
 | 
			
		||||
      {% endif %}
 | 
			
		||||
      <section id="upcoming-events">
 | 
			
		||||
        {% if not news_dates %}
 | 
			
		||||
          <div class="news_empty">
 | 
			
		||||
            <em>{% trans %}Nothing to come...{% endtrans %}</em>
 | 
			
		||||
          </div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% set coming_soon = object_list.filter(dates__start_date__gte=timezone.now()+timedelta(days=5),
 | 
			
		||||
type="EVENT").order_by('dates__start_date') %}
 | 
			
		||||
{% if coming_soon %}
 | 
			
		||||
  <h3>{% trans %}Coming soon... don't miss!{% endtrans %}</h3>
 | 
			
		||||
  {% for news in coming_soon %}
 | 
			
		||||
    <section class="news_coming_soon">
 | 
			
		||||
      <a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a>
 | 
			
		||||
      <span class="news_date">{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
        {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} -
 | 
			
		||||
        {{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
        {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
 | 
			
		||||
    </section>
 | 
			
		||||
  {% endfor %}
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
<h3>{% trans %}All coming events{% endtrans %}</h3>
 | 
			
		||||
<iframe
 | 
			
		||||
  src="https://embed.styledcalendar.com/#2mF2is8CEXhr4ADcX6qN"
 | 
			
		||||
  title="Styled Calendar"
 | 
			
		||||
  class="styled-calendar-container"
 | 
			
		||||
  style="width: 100%; border: none; height: 1060px"
 | 
			
		||||
  data-cy="calendar-embed-iframe">
 | 
			
		||||
</iframe>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div id="right_column" class="news_column">
 | 
			
		||||
  <div id="agenda">
 | 
			
		||||
    <div id="agenda_title">{% trans %}Agenda{% endtrans %}</div>
 | 
			
		||||
    <div id="agenda_content">
 | 
			
		||||
      {% for d in NewsDate.objects.filter(end_date__gte=timezone.now(),
 | 
			
		||||
      news__is_moderated=True, news__type__in=["WEEKLY",
 | 
			
		||||
      "EVENT"]).order_by('start_date', 'end_date') %}
 | 
			
		||||
      <div class="agenda_item">
 | 
			
		||||
        <div class="agenda_date">
 | 
			
		||||
          <strong>{{ d.start_date|localtime|date('D d M Y') }}</strong>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="agenda_time">
 | 
			
		||||
          <span>{{ d.start_date|localtime|time(DATETIME_FORMAT) }}</span> -
 | 
			
		||||
          <span>{{ d.end_date|localtime|time(DATETIME_FORMAT) }}</span>
 | 
			
		||||
        </div>
 | 
			
		||||
        {% else %}
 | 
			
		||||
          {% for day, dates_group in news_dates.items() %}
 | 
			
		||||
            <div class="news_events_group">
 | 
			
		||||
              <div class="news_events_group_date">
 | 
			
		||||
                <div>
 | 
			
		||||
          <strong><a href="{{ url('com:news_detail', news_id=d.news.id) }}">{{ d.news.title }}</a></strong>
 | 
			
		||||
          <a href="{{ d.news.club.get_absolute_url() }}">{{ d.news.club }}</a>
 | 
			
		||||
                  <div>{{ day|date('D') }}</div>
 | 
			
		||||
                  <div class="day">{{ day|date('d') }}</div>
 | 
			
		||||
                  <div>{{ day|date('b') }}</div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div class="news_events_group_items">
 | 
			
		||||
                {% for date in dates_group %}
 | 
			
		||||
                  <article
 | 
			
		||||
                    class="news_event"
 | 
			
		||||
                    {%- if not date.news.is_published -%}
 | 
			
		||||
                      x-data="{newsState: AlertState.PENDING}"
 | 
			
		||||
                    {% else %}
 | 
			
		||||
                      x-data="{newsState: AlertState.DISPLAYED}"
 | 
			
		||||
                    {%- endif -%}
 | 
			
		||||
                  >
 | 
			
		||||
                    {# if a non published news is in the object list,
 | 
			
		||||
                    the logged user is either an admin or the news author #}
 | 
			
		||||
                    {{ news_moderation_alert(date.news, user, "newsState") }}
 | 
			
		||||
                    <div
 | 
			
		||||
                      x-show="newsState !== AlertState.DELETED"
 | 
			
		||||
                    >
 | 
			
		||||
                      <header class="row gap">
 | 
			
		||||
                        {% if date.news.club.logo %}
 | 
			
		||||
                          <img src="{{ date.news.club.logo.url }}" alt="{{ date.news.club }}"/>
 | 
			
		||||
                        {% else %}
 | 
			
		||||
                          <img src="{{ static("com/img/news.png") }}" alt="{{ date.news.club }}"/>
 | 
			
		||||
                        {% endif %}
 | 
			
		||||
                        <div class="header_content">
 | 
			
		||||
                          <h4>
 | 
			
		||||
                            <a href="{{ url('com:news_detail', news_id=date.news_id) }}">
 | 
			
		||||
                              {{ date.news.title }}
 | 
			
		||||
                            </a>
 | 
			
		||||
                          </h4>
 | 
			
		||||
                          <a href="{{ date.news.club.get_absolute_url() }}">{{ date.news.club }}</a>
 | 
			
		||||
                          <div class="news_date">
 | 
			
		||||
                            <time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">
 | 
			
		||||
                              {{ date.start_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
                              {{ date.start_date|localtime|time(DATETIME_FORMAT) }}
 | 
			
		||||
                            </time> -
 | 
			
		||||
                            <time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">
 | 
			
		||||
                              {{ date.end_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
                              {{ date.end_date|localtime|time(DATETIME_FORMAT) }}
 | 
			
		||||
                            </time>
 | 
			
		||||
                          </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                      </header>
 | 
			
		||||
                      <div class="news_content markdown">
 | 
			
		||||
                        {{ date.news.summary|markdown }}
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </article>
 | 
			
		||||
                {% endfor %}
 | 
			
		||||
              </div>
 | 
			
		||||
        <div class="agenda_item_content">{{ d.news.summary|markdown }}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
          <div x-data="upcomingNewsLoader(new Date('{{ last_day + timedelta(days=1) }}'))">
 | 
			
		||||
            <template x-for="newsList in Object.values(groupedDates())">
 | 
			
		||||
              <div class="news_events_group">
 | 
			
		||||
                <div class="news_events_group_date">
 | 
			
		||||
                  <div x-data="{day: newsList[0].start_date}">
 | 
			
		||||
                    <div x-text="day.toLocaleString('{{ get_language() }}', { weekday: 'short' }).substring(0, 3)"></div>
 | 
			
		||||
                    <div
 | 
			
		||||
                      class="day"
 | 
			
		||||
                      x-text="day.toLocaleString('{{ get_language() }}', { day: 'numeric' })"
 | 
			
		||||
                    ></div>
 | 
			
		||||
                    <div x-text="day.toLocaleString('{{ get_language() }}', { month: 'short' }).substring(0, 3)"></div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="news_events_group_items">
 | 
			
		||||
                  <template x-for="newsDate in newsList" :key="newsDate.id">
 | 
			
		||||
                    <article
 | 
			
		||||
                      class="news_event"
 | 
			
		||||
                      x-data="{ newsState: newsDate.news.is_published ? AlertState.PUBLISHED : AlertState.PENDING }"
 | 
			
		||||
                    >
 | 
			
		||||
                      <template x-if="!newsDate.news.is_published">
 | 
			
		||||
                        {{ news_moderation_alert("newsDate.news.id", user, "newsState") }}
 | 
			
		||||
                      </template>
 | 
			
		||||
                      <div x-show="newsState !== AlertState.DELETED">
 | 
			
		||||
                        <header class="row gap">
 | 
			
		||||
                          <img
 | 
			
		||||
                            :src="newsDate.news.club.logo || '{{ static("com/img/news.png") }}'"
 | 
			
		||||
                            :alt="newsDate.news.club.name"
 | 
			
		||||
                          />
 | 
			
		||||
                          <div class="header_content">
 | 
			
		||||
                            <h4>
 | 
			
		||||
                              <a :href="newsDate.news.url" x-text="newsDate.news.title"></a>
 | 
			
		||||
                            </h4>
 | 
			
		||||
                            <a :href="newsDate.news.club.url" x-text="newsDate.news.club.name"></a>
 | 
			
		||||
                            <div class="news_date">
 | 
			
		||||
                              <time
 | 
			
		||||
                                :datetime="newsDate.start_date.toISOString()"
 | 
			
		||||
                                x-text="`${newsDate.start_date.getHours()}:${newsDate.start_date.getMinutes()}`"
 | 
			
		||||
                              ></time> -
 | 
			
		||||
                              <time
 | 
			
		||||
                                :datetime="newsDate.end_date.toISOString()"
 | 
			
		||||
                                x-text="`${newsDate.end_date.getHours()}:${newsDate.end_date.getMinutes()}`"
 | 
			
		||||
                              ></time>
 | 
			
		||||
                            </div>
 | 
			
		||||
                          </div>
 | 
			
		||||
                        </header>
 | 
			
		||||
                        {# The API returns a summary in html.
 | 
			
		||||
                           It's generated from our markdown subset, so it should be safe #}
 | 
			
		||||
                        <div class="news_content markdown" x-html="newsDate.news.summary"></div>
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </article>
 | 
			
		||||
                  </template>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </template>
 | 
			
		||||
 | 
			
		||||
            <div id="load-more-news-button" :aria-busy="loading">
 | 
			
		||||
              <button class="btn btn-grey" x-show="!loading && hasNext" @click="loadMore()">
 | 
			
		||||
                {% trans %}See more{% endtrans %}  <i class="fa fa-arrow-down"></i>
 | 
			
		||||
              </button>
 | 
			
		||||
              <p x-show="!loading && !hasNext">
 | 
			
		||||
                <em>
 | 
			
		||||
                  {% trans trimmed %}
 | 
			
		||||
                    It was too short.
 | 
			
		||||
                    You already reached the end of the upcoming events list.
 | 
			
		||||
                  {% endtrans %}
 | 
			
		||||
                </em>
 | 
			
		||||
              </p>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </section>
 | 
			
		||||
 | 
			
		||||
      <h3>
 | 
			
		||||
        {% trans %}All coming events{% endtrans %}
 | 
			
		||||
        <a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
 | 
			
		||||
      </h3>
 | 
			
		||||
      <ics-calendar
 | 
			
		||||
        x-data
 | 
			
		||||
        x-ref="calendar"
 | 
			
		||||
        @news-moderated.window="
 | 
			
		||||
                                if ($event.target !== $refs.calendar){
 | 
			
		||||
                                // Avoid triggering a refresh with a dispatch
 | 
			
		||||
                                // from the calendar itself
 | 
			
		||||
                                $refs.calendar.refreshEvents($event);
 | 
			
		||||
                                }
 | 
			
		||||
                               "
 | 
			
		||||
        @calendar-delete="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.DELETED})"
 | 
			
		||||
        @calendar-unpublish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PENDING})"
 | 
			
		||||
        @calendar-publish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PUBLISHED})"
 | 
			
		||||
        locale="{{ get_language() }}"
 | 
			
		||||
        can_moderate="{{ user.has_perm("com.moderate_news") }}"
 | 
			
		||||
        can_delete="{{ user.has_perm("com.delete_news") }}"
 | 
			
		||||
      ></ics-calendar>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div id="right_column">
 | 
			
		||||
      <div id="links">
 | 
			
		||||
        <h3>{% trans %}Links{% endtrans %}</h3>
 | 
			
		||||
        <div id="links_content">
 | 
			
		||||
          <h4>{% trans %}Our services{% endtrans %}</h4>
 | 
			
		||||
          <ul>
 | 
			
		||||
            <li>
 | 
			
		||||
              <i class="fa-solid fa-graduation-cap fa-xl"></i>
 | 
			
		||||
              <a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
 | 
			
		||||
            </li>
 | 
			
		||||
            <li>
 | 
			
		||||
              <i class="fa-solid fa-magnifying-glass fa-xl"></i>
 | 
			
		||||
              <a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
 | 
			
		||||
            </li>
 | 
			
		||||
            <li>
 | 
			
		||||
              <i class="fa-solid fa-check-to-slot fa-xl"></i>
 | 
			
		||||
              <a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
 | 
			
		||||
            </li>
 | 
			
		||||
          </ul>
 | 
			
		||||
          <br>
 | 
			
		||||
          <h4>{% trans %}Social media{% endtrans %}</h4>
 | 
			
		||||
          <ul>
 | 
			
		||||
            <li>
 | 
			
		||||
              <i class="fa-brands fa-discord fa-xl"></i>
 | 
			
		||||
              <a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">
 | 
			
		||||
                {% trans %}Discord AE{% endtrans %}
 | 
			
		||||
              </a>
 | 
			
		||||
              {% if user.was_subscribed %}
 | 
			
		||||
                - <a rel="nofollow" target="#" href="https://discord.gg/u6EuMfyGaJ">
 | 
			
		||||
                  {% trans %}Dev Team{% endtrans %}
 | 
			
		||||
                </a>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
            </li>
 | 
			
		||||
            <li>
 | 
			
		||||
              <i class="fa-brands fa-facebook fa-xl"></i>
 | 
			
		||||
              <a rel="nofollow" target="#" href="https://www.facebook.com/@AEUTBM/">
 | 
			
		||||
                Facebook
 | 
			
		||||
              </a>
 | 
			
		||||
            </li>
 | 
			
		||||
            <li>
 | 
			
		||||
              <i class="fa-brands fa-square-instagram fa-xl"></i>
 | 
			
		||||
              <a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">
 | 
			
		||||
                Instagram
 | 
			
		||||
              </a>
 | 
			
		||||
            </li>
 | 
			
		||||
          </ul>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div id="birthdays">
 | 
			
		||||
    <div id="birthdays_title">{% trans %}Birthdays{% endtrans %}</div>
 | 
			
		||||
        <h3>{% trans %}Birthdays{% endtrans %}</h3>
 | 
			
		||||
        <div id="birthdays_content">
 | 
			
		||||
      {% if user.is_subscribed %}
 | 
			
		||||
                    {# Cache request for 1 hour #}
 | 
			
		||||
        {% cache 3600 "birthdays" %}
 | 
			
		||||
          {%- if user.has_perm("core.view_user") -%}
 | 
			
		||||
            <ul class="birthdays_year">
 | 
			
		||||
            {% for d in birthdays.dates('date_of_birth', 'year', 'DESC') %}
 | 
			
		||||
              {%- for year, users in birthdays -%}
 | 
			
		||||
                <li>
 | 
			
		||||
                {% trans age=timezone.now().year - d.year %}{{ age }} year old{% endtrans %}
 | 
			
		||||
                  {% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %}
 | 
			
		||||
                  <ul>
 | 
			
		||||
                  {% for u in birthdays.filter(date_of_birth__year=d.year) %}
 | 
			
		||||
                    {%- for u in users -%}
 | 
			
		||||
                      <li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
 | 
			
		||||
                  {% endfor %}
 | 
			
		||||
                    {%- endfor -%}
 | 
			
		||||
                  </ul>
 | 
			
		||||
                </li>
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
              {%- endfor -%}
 | 
			
		||||
            </ul>
 | 
			
		||||
        {% endcache %}
 | 
			
		||||
      {% else %}
 | 
			
		||||
        <p>{% trans %}You need an up to date subscription to access this content{% endtrans %}</p>
 | 
			
		||||
      {% endif %}
 | 
			
		||||
          {%- elif not user.was_subscribed -%}
 | 
			
		||||
            {# The user cannot view birthdays, because he never subscribed #}
 | 
			
		||||
            <p>{% trans %}You need to subscribe to access this content{% endtrans %}</p>
 | 
			
		||||
          {%- else -%}
 | 
			
		||||
            {# There is another reason why user cannot view birthdays (maybe he is banned)
 | 
			
		||||
               but we cannot know exactly what is this reason #}
 | 
			
		||||
            <p>{% trans %}You cannot access this content{% endtrans %}</p>
 | 
			
		||||
          {%- endif -%}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,10 @@
 | 
			
		||||
  {% trans %}Poster{% endtrans %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block additional_css %}
 | 
			
		||||
  <link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  <div id="poster_list">
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,10 @@
 | 
			
		||||
  <script src="{{ static('com/js/poster_list.js') }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block additional_css %}
 | 
			
		||||
  <link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  <div id="poster_list">
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@
 | 
			
		||||
  <head>
 | 
			
		||||
    <title>{% trans %}Slideshow{% endtrans %}</title>
 | 
			
		||||
    <link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
 | 
			
		||||
    <script type="module" src="{{ static('bundled/jquery-index.js') }}"></script>
 | 
			
		||||
    <script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
 | 
			
		||||
    <script src="{{ static('com/js/slideshow.js') }}"></script>
 | 
			
		||||
  </head>
 | 
			
		||||
  <body>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										250
									
								
								com/tests/test_api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										250
									
								
								com/tests/test_api.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,250 @@
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
from datetime import datetime, timedelta
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from typing import Callable
 | 
			
		||||
from unittest.mock import MagicMock, patch
 | 
			
		||||
from urllib.parse import quote
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.models import Permission
 | 
			
		||||
from django.http import HttpResponse
 | 
			
		||||
from django.test import Client, TestCase
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.timezone import now
 | 
			
		||||
from model_bakery import baker, seq
 | 
			
		||||
from pytest_django.asserts import assertNumQueries
 | 
			
		||||
 | 
			
		||||
from com.calendar import IcsCalendar
 | 
			
		||||
from com.models import News, NewsDate
 | 
			
		||||
from core.markdown import markdown
 | 
			
		||||
from core.models import User
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class MockResponse:
 | 
			
		||||
    ok: bool
 | 
			
		||||
    value: str
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def content(self):
 | 
			
		||||
        return self.value.encode("utf8")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def accel_redirect_to_file(response: HttpResponse) -> Path | None:
 | 
			
		||||
    redirect = Path(response.headers.get("X-Accel-Redirect", ""))
 | 
			
		||||
    if not redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem):
 | 
			
		||||
        return None
 | 
			
		||||
    return settings.MEDIA_ROOT / redirect.relative_to(
 | 
			
		||||
        Path("/") / settings.MEDIA_ROOT.stem
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
class TestExternalCalendar:
 | 
			
		||||
    @pytest.fixture
 | 
			
		||||
    def mock_request(self):
 | 
			
		||||
        mock = MagicMock()
 | 
			
		||||
        with patch("requests.get", mock):
 | 
			
		||||
            yield mock
 | 
			
		||||
 | 
			
		||||
    @pytest.fixture
 | 
			
		||||
    def mock_current_time(self):
 | 
			
		||||
        mock = MagicMock()
 | 
			
		||||
        original = timezone.now
 | 
			
		||||
        with patch("django.utils.timezone.now", mock):
 | 
			
		||||
            yield mock, original
 | 
			
		||||
 | 
			
		||||
    @pytest.fixture(autouse=True)
 | 
			
		||||
    def clear_cache(self):
 | 
			
		||||
        IcsCalendar._EXTERNAL_CALENDAR.unlink(missing_ok=True)
 | 
			
		||||
 | 
			
		||||
    def test_fetch_error(self, client: Client, mock_request: MagicMock):
 | 
			
		||||
        mock_request.return_value = MockResponse(ok=False, value="not allowed")
 | 
			
		||||
        assert client.get(reverse("api:calendar_external")).status_code == 404
 | 
			
		||||
 | 
			
		||||
    def test_fetch_success(self, client: Client, mock_request: MagicMock):
 | 
			
		||||
        external_response = MockResponse(ok=True, value="Definitely an ICS")
 | 
			
		||||
        mock_request.return_value = external_response
 | 
			
		||||
        response = client.get(reverse("api:calendar_external"))
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        out_file = accel_redirect_to_file(response)
 | 
			
		||||
        assert out_file is not None
 | 
			
		||||
        assert out_file.exists()
 | 
			
		||||
        with open(out_file, "r") as f:
 | 
			
		||||
            assert f.read() == external_response.value
 | 
			
		||||
 | 
			
		||||
    def test_fetch_caching(
 | 
			
		||||
        self,
 | 
			
		||||
        client: Client,
 | 
			
		||||
        mock_request: MagicMock,
 | 
			
		||||
        mock_current_time: tuple[MagicMock, Callable[[], datetime]],
 | 
			
		||||
    ):
 | 
			
		||||
        fake_current_time, original_timezone = mock_current_time
 | 
			
		||||
        start_time = original_timezone()
 | 
			
		||||
 | 
			
		||||
        fake_current_time.return_value = start_time
 | 
			
		||||
        external_response = MockResponse(200, "Definitely an ICS")
 | 
			
		||||
        mock_request.return_value = external_response
 | 
			
		||||
 | 
			
		||||
        with open(
 | 
			
		||||
            accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r"
 | 
			
		||||
        ) as f:
 | 
			
		||||
            assert f.read() == external_response.value
 | 
			
		||||
 | 
			
		||||
        mock_request.return_value = MockResponse(200, "This should be ignored")
 | 
			
		||||
        with open(
 | 
			
		||||
            accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r"
 | 
			
		||||
        ) as f:
 | 
			
		||||
            assert f.read() == external_response.value
 | 
			
		||||
 | 
			
		||||
        mock_request.assert_called_once()
 | 
			
		||||
 | 
			
		||||
        fake_current_time.return_value = start_time + timedelta(hours=1, seconds=1)
 | 
			
		||||
        external_response = MockResponse(200, "This won't be ignored")
 | 
			
		||||
        mock_request.return_value = external_response
 | 
			
		||||
 | 
			
		||||
        with open(
 | 
			
		||||
            accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r"
 | 
			
		||||
        ) as f:
 | 
			
		||||
            assert f.read() == external_response.value
 | 
			
		||||
 | 
			
		||||
        assert mock_request.call_count == 2
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
class TestInternalCalendar:
 | 
			
		||||
    @pytest.fixture(autouse=True)
 | 
			
		||||
    def clear_cache(self):
 | 
			
		||||
        IcsCalendar._INTERNAL_CALENDAR.unlink(missing_ok=True)
 | 
			
		||||
 | 
			
		||||
    def test_fetch_success(self, client: Client):
 | 
			
		||||
        response = client.get(reverse("api:calendar_internal"))
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        out_file = accel_redirect_to_file(response)
 | 
			
		||||
        assert out_file is not None
 | 
			
		||||
        assert out_file.exists()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
class TestModerateNews:
 | 
			
		||||
    @pytest.mark.parametrize("news_is_published", [True, False])
 | 
			
		||||
    def test_moderation_ok(self, client: Client, news_is_published: bool):  # noqa FBT
 | 
			
		||||
        user = baker.make(
 | 
			
		||||
            User, user_permissions=[Permission.objects.get(codename="moderate_news")]
 | 
			
		||||
        )
 | 
			
		||||
        # The API call should work even if the news is initially moderated.
 | 
			
		||||
        # In the latter case, the result should be a noop, rather than an error.
 | 
			
		||||
        news = baker.make(News, is_published=news_is_published)
 | 
			
		||||
        initial_moderator = news.moderator
 | 
			
		||||
        client.force_login(user)
 | 
			
		||||
        response = client.patch(
 | 
			
		||||
            reverse("api:moderate_news", kwargs={"news_id": news.id})
 | 
			
		||||
        )
 | 
			
		||||
        # if it wasn't moderated, it should now be moderated and the moderator should
 | 
			
		||||
        # be the user that made the request.
 | 
			
		||||
        # If it was already moderated, it should be a no-op, but not an error
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        news.refresh_from_db()
 | 
			
		||||
        assert news.is_published
 | 
			
		||||
        if not news_is_published:
 | 
			
		||||
            assert news.moderator == user
 | 
			
		||||
        else:
 | 
			
		||||
            assert news.moderator == initial_moderator
 | 
			
		||||
 | 
			
		||||
    def test_moderation_forbidden(self, client: Client):
 | 
			
		||||
        user = baker.make(User)
 | 
			
		||||
        news = baker.make(News, is_published=False)
 | 
			
		||||
        client.force_login(user)
 | 
			
		||||
        response = client.patch(
 | 
			
		||||
            reverse("api:moderate_news", kwargs={"news_id": news.id})
 | 
			
		||||
        )
 | 
			
		||||
        assert response.status_code == 403
 | 
			
		||||
        news.refresh_from_db()
 | 
			
		||||
        assert not news.is_published
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
class TestDeleteNews:
 | 
			
		||||
    def test_delete_news_ok(self, client: Client):
 | 
			
		||||
        user = baker.make(
 | 
			
		||||
            User, user_permissions=[Permission.objects.get(codename="delete_news")]
 | 
			
		||||
        )
 | 
			
		||||
        news = baker.make(News)
 | 
			
		||||
        client.force_login(user)
 | 
			
		||||
        response = client.delete(
 | 
			
		||||
            reverse("api:delete_news", kwargs={"news_id": news.id})
 | 
			
		||||
        )
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        assert not News.objects.filter(id=news.id).exists()
 | 
			
		||||
 | 
			
		||||
    def test_delete_news_forbidden(self, client: Client):
 | 
			
		||||
        user = baker.make(User)
 | 
			
		||||
        news = baker.make(News)
 | 
			
		||||
        client.force_login(user)
 | 
			
		||||
        response = client.delete(
 | 
			
		||||
            reverse("api:delete_news", kwargs={"news_id": news.id})
 | 
			
		||||
        )
 | 
			
		||||
        assert response.status_code == 403
 | 
			
		||||
        assert News.objects.filter(id=news.id).exists()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestFetchNewsDates(TestCase):
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def setUpTestData(cls):
 | 
			
		||||
        News.objects.all().delete()
 | 
			
		||||
        cls.dates = baker.make(
 | 
			
		||||
            NewsDate,
 | 
			
		||||
            _quantity=5,
 | 
			
		||||
            _bulk_create=True,
 | 
			
		||||
            start_date=seq(value=now(), increment_by=timedelta(days=1)),
 | 
			
		||||
            end_date=seq(
 | 
			
		||||
                value=now() + timedelta(hours=2), increment_by=timedelta(days=1)
 | 
			
		||||
            ),
 | 
			
		||||
            news=iter(
 | 
			
		||||
                baker.make(News, is_published=True, _quantity=5, _bulk_create=True)
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
        cls.dates.append(
 | 
			
		||||
            baker.make(
 | 
			
		||||
                NewsDate,
 | 
			
		||||
                start_date=now() + timedelta(days=2, hours=1),
 | 
			
		||||
                end_date=now() + timedelta(days=2, hours=5),
 | 
			
		||||
                news=baker.make(News, is_published=True),
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        cls.dates.sort(key=lambda d: d.start_date)
 | 
			
		||||
 | 
			
		||||
    def test_num_queries(self):
 | 
			
		||||
        with assertNumQueries(2):
 | 
			
		||||
            self.client.get(reverse("api:fetch_news_dates"))
 | 
			
		||||
 | 
			
		||||
    def test_html_format(self):
 | 
			
		||||
        """Test that when the summary is asked in html, the summary is in html."""
 | 
			
		||||
        summary_1 = "# First event\nThere is something happening.\n"
 | 
			
		||||
        self.dates[0].news.summary = summary_1
 | 
			
		||||
        self.dates[0].news.save()
 | 
			
		||||
        summary_2 = (
 | 
			
		||||
            "# Second event\n"
 | 
			
		||||
            "There is something happening **for real**.\n"
 | 
			
		||||
            "Everything is [here](https://youtu.be/dQw4w9WgXcQ)\n"
 | 
			
		||||
        )
 | 
			
		||||
        self.dates[1].news.summary = summary_2
 | 
			
		||||
        self.dates[1].news.save()
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            reverse("api:fetch_news_dates") + "?page_size=2&text_format=html"
 | 
			
		||||
        )
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        dates = response.json()["results"]
 | 
			
		||||
        assert dates[0]["news"]["summary"] == markdown(summary_1)
 | 
			
		||||
        assert dates[1]["news"]["summary"] == markdown(summary_2)
 | 
			
		||||
 | 
			
		||||
    def test_fetch(self):
 | 
			
		||||
        after = quote((now() + timedelta(days=1)).isoformat())
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            reverse("api:fetch_news_dates") + f"?page_size=3&after={after}"
 | 
			
		||||
        )
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        dates = response.json()["results"]
 | 
			
		||||
        assert [d["id"] for d in dates] == [d.id for d in self.dates[1:4]]
 | 
			
		||||
							
								
								
									
										42
									
								
								com/tests/test_models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								com/tests/test_models.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
			
		||||
import itertools
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.models import Permission
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from model_bakery import baker
 | 
			
		||||
 | 
			
		||||
from com.models import News
 | 
			
		||||
from core.models import User
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestNewsViewableBy(TestCase):
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def setUpTestData(cls):
 | 
			
		||||
        News.objects.all().delete()
 | 
			
		||||
        cls.users = baker.make(User, _quantity=3, _bulk_create=True)
 | 
			
		||||
        # There are six news and six authors.
 | 
			
		||||
        # Each author has one moderated and one non-moderated news
 | 
			
		||||
        cls.news = baker.make(
 | 
			
		||||
            News,
 | 
			
		||||
            author=itertools.cycle(cls.users),
 | 
			
		||||
            is_published=iter([True, True, True, False, False, False]),
 | 
			
		||||
            _quantity=6,
 | 
			
		||||
            _bulk_create=True,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_admin_can_view_everything(self):
 | 
			
		||||
        """Test with a user that can view non moderated news."""
 | 
			
		||||
        user = baker.make(
 | 
			
		||||
            User,
 | 
			
		||||
            user_permissions=[Permission.objects.get(codename="view_unmoderated_news")],
 | 
			
		||||
        )
 | 
			
		||||
        assert set(News.objects.viewable_by(user)) == set(self.news)
 | 
			
		||||
 | 
			
		||||
    def test_normal_user_can_view_moderated_and_self_news(self):
 | 
			
		||||
        """Test that basic users can view moderated news and news they authored."""
 | 
			
		||||
        user = self.news[0].author
 | 
			
		||||
        assert set(News.objects.viewable_by(user)) == {
 | 
			
		||||
            self.news[0],
 | 
			
		||||
            self.news[1],
 | 
			
		||||
            self.news[2],
 | 
			
		||||
            self.news[3],
 | 
			
		||||
        }
 | 
			
		||||
@@ -12,18 +12,25 @@
 | 
			
		||||
# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from unittest.mock import patch
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.sites.models import Site
 | 
			
		||||
from django.core.files.uploadedfile import SimpleUploadedFile
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils import html
 | 
			
		||||
from django.utils.timezone import localtime, now
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from model_bakery import baker
 | 
			
		||||
from pytest_django.asserts import assertNumQueries, assertRedirects
 | 
			
		||||
 | 
			
		||||
from club.models import Club, Membership
 | 
			
		||||
from com.models import News, Poster, Sith, Weekmail, WeekmailArticle
 | 
			
		||||
from core.models import AnonymousUser, RealGroup, User
 | 
			
		||||
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
 | 
			
		||||
from core.baker_recipes import subscriber_user
 | 
			
		||||
from core.models import AnonymousUser, Group, User
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.fixture()
 | 
			
		||||
@@ -49,9 +56,7 @@ class TestCom(TestCase):
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def setUpTestData(cls):
 | 
			
		||||
        cls.skia = User.objects.get(username="skia")
 | 
			
		||||
        cls.com_group = RealGroup.objects.filter(
 | 
			
		||||
            id=settings.SITH_GROUP_COM_ADMIN_ID
 | 
			
		||||
        ).first()
 | 
			
		||||
        cls.com_group = Group.objects.get(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
			
		||||
        cls.skia.groups.set([cls.com_group])
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
@@ -99,9 +104,7 @@ class TestCom(TestCase):
 | 
			
		||||
        response = self.client.get(reverse("core:index"))
 | 
			
		||||
        self.assertContains(
 | 
			
		||||
            response,
 | 
			
		||||
            text=html.escape(
 | 
			
		||||
                _("You need an up to date subscription to access this content")
 | 
			
		||||
            ),
 | 
			
		||||
            text=html.escape(_("You need to subscribe to access this content")),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_birthday_subscibed_user(self):
 | 
			
		||||
@@ -109,9 +112,16 @@ class TestCom(TestCase):
 | 
			
		||||
 | 
			
		||||
        self.assertNotContains(
 | 
			
		||||
            response,
 | 
			
		||||
            text=html.escape(
 | 
			
		||||
                _("You need an up to date subscription to access this content")
 | 
			
		||||
            ),
 | 
			
		||||
            text=html.escape(_("You need to subscribe to access this content")),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_birthday_old_subscibed_user(self):
 | 
			
		||||
        self.client.force_login(User.objects.get(username="old_subscriber"))
 | 
			
		||||
        response = self.client.get(reverse("core:index"))
 | 
			
		||||
 | 
			
		||||
        self.assertNotContains(
 | 
			
		||||
            response,
 | 
			
		||||
            text=html.escape(_("You need to subscribe to access this content")),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -134,15 +144,8 @@ class TestNews(TestCase):
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def setUpTestData(cls):
 | 
			
		||||
        cls.com_admin = User.objects.get(username="comunity")
 | 
			
		||||
        new = News.objects.create(
 | 
			
		||||
            title="dummy new",
 | 
			
		||||
            summary="This is a dummy new",
 | 
			
		||||
            content="Look at that beautiful dummy new",
 | 
			
		||||
            author=User.objects.get(username="subscriber"),
 | 
			
		||||
            club=Club.objects.first(),
 | 
			
		||||
        )
 | 
			
		||||
        cls.new = new
 | 
			
		||||
        cls.author = new.author
 | 
			
		||||
        cls.new = baker.make(News)
 | 
			
		||||
        cls.author = cls.new.author
 | 
			
		||||
        cls.sli = User.objects.get(username="sli")
 | 
			
		||||
        cls.anonymous = AnonymousUser()
 | 
			
		||||
 | 
			
		||||
@@ -157,15 +160,15 @@ class TestNews(TestCase):
 | 
			
		||||
 | 
			
		||||
    def test_news_viewer(self):
 | 
			
		||||
        """Test that moderated news can be viewed by anyone
 | 
			
		||||
        and not moderated news only by com admins.
 | 
			
		||||
        and not moderated news only by com admins and by their author.
 | 
			
		||||
        """
 | 
			
		||||
        # by default a news isn't moderated
 | 
			
		||||
        # by default news aren't moderated
 | 
			
		||||
        assert self.new.can_be_viewed_by(self.com_admin)
 | 
			
		||||
        assert self.new.can_be_viewed_by(self.author)
 | 
			
		||||
        assert not self.new.can_be_viewed_by(self.sli)
 | 
			
		||||
        assert not self.new.can_be_viewed_by(self.anonymous)
 | 
			
		||||
        assert not self.new.can_be_viewed_by(self.author)
 | 
			
		||||
 | 
			
		||||
        self.new.is_moderated = True
 | 
			
		||||
        self.new.is_published = True
 | 
			
		||||
        self.new.save()
 | 
			
		||||
        assert self.new.can_be_viewed_by(self.com_admin)
 | 
			
		||||
        assert self.new.can_be_viewed_by(self.sli)
 | 
			
		||||
@@ -173,11 +176,11 @@ class TestNews(TestCase):
 | 
			
		||||
        assert self.new.can_be_viewed_by(self.author)
 | 
			
		||||
 | 
			
		||||
    def test_news_editor(self):
 | 
			
		||||
        """Test that only com admins can edit news."""
 | 
			
		||||
        """Test that only com admins and the original author can edit news."""
 | 
			
		||||
        assert self.new.can_be_edited_by(self.com_admin)
 | 
			
		||||
        assert self.new.can_be_edited_by(self.author)
 | 
			
		||||
        assert not self.new.can_be_edited_by(self.sli)
 | 
			
		||||
        assert not self.new.can_be_edited_by(self.anonymous)
 | 
			
		||||
        assert not self.new.can_be_edited_by(self.author)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestWeekmailArticle(TestCase):
 | 
			
		||||
@@ -227,3 +230,105 @@ class TestPoster(TestCase):
 | 
			
		||||
 | 
			
		||||
        assert not self.poster.is_owned_by(self.susbcriber)
 | 
			
		||||
        assert self.poster.is_owned_by(self.sli)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestNewsCreation(TestCase):
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def setUpTestData(cls):
 | 
			
		||||
        cls.club = baker.make(Club)
 | 
			
		||||
        cls.user = subscriber_user.make()
 | 
			
		||||
        baker.make(Membership, user=cls.user, club=cls.club, role=5)
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
        self.start = now() + timedelta(days=1)
 | 
			
		||||
        self.end = self.start + timedelta(hours=5)
 | 
			
		||||
        self.valid_payload = {
 | 
			
		||||
            "title": "Test news",
 | 
			
		||||
            "summary": "This is a test news",
 | 
			
		||||
            "content": "This is a test news",
 | 
			
		||||
            "club": self.club.pk,
 | 
			
		||||
            "is_weekly": False,
 | 
			
		||||
            "start_date": self.start,
 | 
			
		||||
            "end_date": self.end,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def test_create_news(self):
 | 
			
		||||
        response = self.client.post(reverse("com:news_new"), self.valid_payload)
 | 
			
		||||
        created = News.objects.order_by("id").last()
 | 
			
		||||
        assertRedirects(response, created.get_absolute_url())
 | 
			
		||||
        assert created.title == "Test news"
 | 
			
		||||
        assert not created.is_published
 | 
			
		||||
        dates = list(created.dates.values("start_date", "end_date"))
 | 
			
		||||
        assert dates == [{"start_date": self.start, "end_date": self.end}]
 | 
			
		||||
 | 
			
		||||
    def test_create_news_multiple_dates(self):
 | 
			
		||||
        self.valid_payload["is_weekly"] = True
 | 
			
		||||
        self.valid_payload["occurrences"] = 2
 | 
			
		||||
        response = self.client.post(reverse("com:news_new"), self.valid_payload)
 | 
			
		||||
        created = News.objects.order_by("id").last()
 | 
			
		||||
 | 
			
		||||
        assertRedirects(response, created.get_absolute_url())
 | 
			
		||||
        dates = list(
 | 
			
		||||
            created.dates.values("start_date", "end_date").order_by("start_date")
 | 
			
		||||
        )
 | 
			
		||||
        assert dates == [
 | 
			
		||||
            {"start_date": self.start, "end_date": self.end},
 | 
			
		||||
            {
 | 
			
		||||
                "start_date": self.start + timedelta(days=7),
 | 
			
		||||
                "end_date": self.end + timedelta(days=7),
 | 
			
		||||
            },
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    def test_edit_news(self):
 | 
			
		||||
        news = baker.make(News, author=self.user, is_published=True)
 | 
			
		||||
        baker.make(
 | 
			
		||||
            NewsDate,
 | 
			
		||||
            news=news,
 | 
			
		||||
            start_date=self.start + timedelta(hours=1),
 | 
			
		||||
            end_date=self.end + timedelta(hours=1),
 | 
			
		||||
            _quantity=2,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("com:news_edit", kwargs={"news_id": news.id}), self.valid_payload
 | 
			
		||||
        )
 | 
			
		||||
        created = News.objects.order_by("id").last()
 | 
			
		||||
        assertRedirects(response, created.get_absolute_url())
 | 
			
		||||
        assert created.title == "Test news"
 | 
			
		||||
        assert not created.is_published
 | 
			
		||||
        dates = list(created.dates.values("start_date", "end_date"))
 | 
			
		||||
        assert dates == [{"start_date": self.start, "end_date": self.end}]
 | 
			
		||||
 | 
			
		||||
    def test_ics_updated(self):
 | 
			
		||||
        """Test that the internal ICS is updated when news are created"""
 | 
			
		||||
 | 
			
		||||
        # we will just test that the ICS is modified.
 | 
			
		||||
        # Checking that the ICS is *well* modified is up to the ICS tests
 | 
			
		||||
        with patch("com.calendar.IcsCalendar.make_internal") as mocked:
 | 
			
		||||
            self.client.post(reverse("com:news_new"), self.valid_payload)
 | 
			
		||||
            mocked.assert_called()
 | 
			
		||||
 | 
			
		||||
        # The ICS file should also change after an update
 | 
			
		||||
        self.valid_payload["is_weekly"] = True
 | 
			
		||||
        self.valid_payload["occurrences"] = 2
 | 
			
		||||
        last_news = News.objects.order_by("id").last()
 | 
			
		||||
 | 
			
		||||
        with patch("com.calendar.IcsCalendar.make_internal") as mocked:
 | 
			
		||||
            self.client.post(
 | 
			
		||||
                reverse("com:news_edit", kwargs={"news_id": last_news.id}),
 | 
			
		||||
                self.valid_payload,
 | 
			
		||||
            )
 | 
			
		||||
            mocked.assert_called()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
def test_feed(client):
 | 
			
		||||
    """Smoke test that checks that the atom feed is working"""
 | 
			
		||||
    Site.objects.clear_cache()
 | 
			
		||||
    with assertNumQueries(2):
 | 
			
		||||
        # get sith domain with Site api: 1 request
 | 
			
		||||
        # get all news and related info: 1 request
 | 
			
		||||
        resp = client.get(reverse("com:news_feed"))
 | 
			
		||||
        assert resp.status_code == 200
 | 
			
		||||
        assert resp.headers["Content-Type"] == "application/rss+xml; charset=utf-8"
 | 
			
		||||
@@ -25,9 +25,10 @@ from com.views import (
 | 
			
		||||
    NewsCreateView,
 | 
			
		||||
    NewsDeleteView,
 | 
			
		||||
    NewsDetailView,
 | 
			
		||||
    NewsEditView,
 | 
			
		||||
    NewsFeed,
 | 
			
		||||
    NewsListView,
 | 
			
		||||
    NewsModerateView,
 | 
			
		||||
    NewsUpdateView,
 | 
			
		||||
    PosterCreateView,
 | 
			
		||||
    PosterDeleteView,
 | 
			
		||||
    PosterEditView,
 | 
			
		||||
@@ -73,13 +74,14 @@ urlpatterns = [
 | 
			
		||||
        name="weekmail_article_edit",
 | 
			
		||||
    ),
 | 
			
		||||
    path("news/", NewsListView.as_view(), name="news_list"),
 | 
			
		||||
    path("news/feed/", NewsFeed(), name="news_feed"),
 | 
			
		||||
    path("news/admin/", NewsAdminListView.as_view(), name="news_admin_list"),
 | 
			
		||||
    path("news/create/", NewsCreateView.as_view(), name="news_new"),
 | 
			
		||||
    path("news/<int:news_id>/edit/", NewsUpdateView.as_view(), name="news_edit"),
 | 
			
		||||
    path("news/<int:news_id>/delete/", NewsDeleteView.as_view(), name="news_delete"),
 | 
			
		||||
    path(
 | 
			
		||||
        "news/<int:news_id>/moderate/", NewsModerateView.as_view(), name="news_moderate"
 | 
			
		||||
    ),
 | 
			
		||||
    path("news/<int:news_id>/edit/", NewsEditView.as_view(), name="news_edit"),
 | 
			
		||||
    path("news/<int:news_id>/", NewsDetailView.as_view(), name="news_detail"),
 | 
			
		||||
    path("mailings/", MailingListAdminView.as_view(), name="mailing_admin"),
 | 
			
		||||
    path(
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										410
									
								
								com/views.py
									
									
									
									
									
								
							
							
						
						
									
										410
									
								
								com/views.py
									
									
									
									
									
								
							@@ -21,12 +21,15 @@
 | 
			
		||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
import itertools
 | 
			
		||||
from datetime import date, timedelta
 | 
			
		||||
from smtplib import SMTPRecipientsRefused
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from django import forms
 | 
			
		||||
from dateutil.relativedelta import relativedelta
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
 | 
			
		||||
from django.contrib.syndication.views import Feed
 | 
			
		||||
from django.core.exceptions import PermissionDenied, ValidationError
 | 
			
		||||
from django.db.models import Max
 | 
			
		||||
from django.forms.models import modelform_factory
 | 
			
		||||
@@ -34,24 +37,22 @@ from django.http import HttpResponseRedirect
 | 
			
		||||
from django.shortcuts import get_object_or_404, redirect
 | 
			
		||||
from django.urls import reverse, reverse_lazy
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.timezone import localdate
 | 
			
		||||
from django.utils.timezone import localdate, now
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django.views.generic import DetailView, ListView, View
 | 
			
		||||
from django.views.generic.detail import SingleObjectMixin
 | 
			
		||||
from django.views.generic import DetailView, ListView, TemplateView, View
 | 
			
		||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
 | 
			
		||||
 | 
			
		||||
from club.models import Club, Mailing
 | 
			
		||||
from com.calendar import IcsCalendar
 | 
			
		||||
from com.forms import NewsDateForm, NewsForm, PosterForm
 | 
			
		||||
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
 | 
			
		||||
from core.models import Notification, RealGroup, User
 | 
			
		||||
from core.views import (
 | 
			
		||||
    CanCreateMixin,
 | 
			
		||||
    CanEditMixin,
 | 
			
		||||
from core.auth.mixins import (
 | 
			
		||||
    CanEditPropMixin,
 | 
			
		||||
    CanViewMixin,
 | 
			
		||||
    QuickNotifMixin,
 | 
			
		||||
    TabedViewMixin,
 | 
			
		||||
    PermissionOrAuthorRequiredMixin,
 | 
			
		||||
)
 | 
			
		||||
from core.views.forms import SelectDateTime
 | 
			
		||||
from core.models import User
 | 
			
		||||
from core.views.mixins import QuickNotifMixin, TabedViewMixin
 | 
			
		||||
from core.views.widgets.markdown import MarkdownInput
 | 
			
		||||
 | 
			
		||||
# Sith object
 | 
			
		||||
@@ -59,92 +60,47 @@ from core.views.widgets.markdown import MarkdownInput
 | 
			
		||||
sith = Sith.objects.first
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PosterForm(forms.ModelForm):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Poster
 | 
			
		||||
        fields = [
 | 
			
		||||
            "name",
 | 
			
		||||
            "file",
 | 
			
		||||
            "club",
 | 
			
		||||
            "screens",
 | 
			
		||||
            "date_begin",
 | 
			
		||||
            "date_end",
 | 
			
		||||
            "display_time",
 | 
			
		||||
        ]
 | 
			
		||||
        widgets = {"screens": forms.CheckboxSelectMultiple}
 | 
			
		||||
        help_texts = {"file": _("Format: 16:9 | Resolution: 1920x1080")}
 | 
			
		||||
 | 
			
		||||
    date_begin = forms.DateTimeField(
 | 
			
		||||
        label=_("Start date"),
 | 
			
		||||
        widget=SelectDateTime,
 | 
			
		||||
        required=True,
 | 
			
		||||
        initial=timezone.now().strftime("%Y-%m-%d %H:%M:%S"),
 | 
			
		||||
    )
 | 
			
		||||
    date_end = forms.DateTimeField(
 | 
			
		||||
        label=_("End date"), widget=SelectDateTime, required=False
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        self.user = kwargs.pop("user", None)
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        if self.user and not self.user.is_com_admin:
 | 
			
		||||
            self.fields["club"].queryset = Club.objects.filter(
 | 
			
		||||
                id__in=self.user.clubs_with_rights
 | 
			
		||||
            )
 | 
			
		||||
            self.fields.pop("display_time")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ComTabsMixin(TabedViewMixin):
 | 
			
		||||
    def get_tabs_title(self):
 | 
			
		||||
        return _("Communication administration")
 | 
			
		||||
 | 
			
		||||
    def get_list_of_tabs(self):
 | 
			
		||||
        tab_list = []
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
            {"url": reverse("com:weekmail"), "slug": "weekmail", "name": _("Weekmail")}
 | 
			
		||||
        )
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
        return [
 | 
			
		||||
            {"url": reverse("com:weekmail"), "slug": "weekmail", "name": _("Weekmail")},
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse("com:weekmail_destinations"),
 | 
			
		||||
                "slug": "weekmail_destinations",
 | 
			
		||||
                "name": _("Weekmail destinations"),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
            {"url": reverse("com:info_edit"), "slug": "info", "name": _("Info message")}
 | 
			
		||||
        )
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse("com:info_edit"),
 | 
			
		||||
                "slug": "info",
 | 
			
		||||
                "name": _("Info message"),
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse("com:alert_edit"),
 | 
			
		||||
                "slug": "alert",
 | 
			
		||||
                "name": _("Alert message"),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse("com:mailing_admin"),
 | 
			
		||||
                "slug": "mailings",
 | 
			
		||||
                "name": _("Mailing lists administration"),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse("com:poster_list"),
 | 
			
		||||
                "slug": "posters",
 | 
			
		||||
                "name": _("Posters list"),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse("com:screen_list"),
 | 
			
		||||
                "slug": "screens",
 | 
			
		||||
                "name": _("Screens list"),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        return tab_list
 | 
			
		||||
            },
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IsComAdminMixin(View):
 | 
			
		||||
class IsComAdminMixin(AccessMixin):
 | 
			
		||||
    def dispatch(self, request, *args, **kwargs):
 | 
			
		||||
        if not request.user.is_com_admin:
 | 
			
		||||
            raise PermissionDenied
 | 
			
		||||
@@ -184,180 +140,86 @@ class WeekmailDestinationEditView(ComEditView):
 | 
			
		||||
# News
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsForm(forms.ModelForm):
 | 
			
		||||
    class Meta:
 | 
			
		||||
class NewsCreateView(PermissionRequiredMixin, CreateView):
 | 
			
		||||
    """View to either create or update News."""
 | 
			
		||||
 | 
			
		||||
    model = News
 | 
			
		||||
        fields = ["type", "title", "club", "summary", "content", "author"]
 | 
			
		||||
        widgets = {
 | 
			
		||||
            "author": forms.HiddenInput,
 | 
			
		||||
            "type": forms.RadioSelect,
 | 
			
		||||
            "summary": MarkdownInput,
 | 
			
		||||
            "content": MarkdownInput,
 | 
			
		||||
    form_class = NewsForm
 | 
			
		||||
    template_name = "com/news_edit.jinja"
 | 
			
		||||
    permission_required = "com.add_news"
 | 
			
		||||
 | 
			
		||||
    def get_date_form_kwargs(self) -> dict[str, Any]:
 | 
			
		||||
        """Get initial data for NewsDateForm"""
 | 
			
		||||
        if self.request.method == "POST":
 | 
			
		||||
            return {"data": self.request.POST}
 | 
			
		||||
        return {}
 | 
			
		||||
 | 
			
		||||
    def get_form_kwargs(self):
 | 
			
		||||
        return super().get_form_kwargs() | {
 | 
			
		||||
            "author": self.request.user,
 | 
			
		||||
            "date_form": NewsDateForm(**self.get_date_form_kwargs()),
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    start_date = forms.DateTimeField(
 | 
			
		||||
        label=_("Start date"), widget=SelectDateTime, required=False
 | 
			
		||||
    )
 | 
			
		||||
    end_date = forms.DateTimeField(
 | 
			
		||||
        label=_("End date"), widget=SelectDateTime, required=False
 | 
			
		||||
    )
 | 
			
		||||
    until = forms.DateTimeField(label=_("Until"), widget=SelectDateTime, required=False)
 | 
			
		||||
 | 
			
		||||
    automoderation = forms.BooleanField(label=_("Automoderation"), required=False)
 | 
			
		||||
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        self.cleaned_data = super().clean()
 | 
			
		||||
        if self.cleaned_data["type"] != "NOTICE":
 | 
			
		||||
            if not self.cleaned_data["start_date"]:
 | 
			
		||||
                self.add_error(
 | 
			
		||||
                    "start_date", ValidationError(_("This field is required."))
 | 
			
		||||
                )
 | 
			
		||||
            if not self.cleaned_data["end_date"]:
 | 
			
		||||
                self.add_error(
 | 
			
		||||
                    "end_date", ValidationError(_("This field is required."))
 | 
			
		||||
                )
 | 
			
		||||
            if (
 | 
			
		||||
                not self.has_error("start_date")
 | 
			
		||||
                and not self.has_error("end_date")
 | 
			
		||||
                and self.cleaned_data["start_date"] > self.cleaned_data["end_date"]
 | 
			
		||||
            ):
 | 
			
		||||
                self.add_error(
 | 
			
		||||
                    "end_date",
 | 
			
		||||
                    ValidationError(
 | 
			
		||||
                        _("You crazy? You can not finish an event before starting it.")
 | 
			
		||||
                    ),
 | 
			
		||||
                )
 | 
			
		||||
            if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
 | 
			
		||||
                self.add_error("until", ValidationError(_("This field is required.")))
 | 
			
		||||
        return self.cleaned_data
 | 
			
		||||
 | 
			
		||||
    def save(self):
 | 
			
		||||
        ret = super().save()
 | 
			
		||||
        self.instance.dates.all().delete()
 | 
			
		||||
        if self.instance.type == "EVENT" or self.instance.type == "CALL":
 | 
			
		||||
            NewsDate(
 | 
			
		||||
                start_date=self.cleaned_data["start_date"],
 | 
			
		||||
                end_date=self.cleaned_data["end_date"],
 | 
			
		||||
                news=self.instance,
 | 
			
		||||
            ).save()
 | 
			
		||||
        elif self.instance.type == "WEEKLY":
 | 
			
		||||
            start_date = self.cleaned_data["start_date"]
 | 
			
		||||
            end_date = self.cleaned_data["end_date"]
 | 
			
		||||
            while start_date <= self.cleaned_data["until"]:
 | 
			
		||||
                NewsDate(
 | 
			
		||||
                    start_date=start_date, end_date=end_date, news=self.instance
 | 
			
		||||
                ).save()
 | 
			
		||||
                start_date += timedelta(days=7)
 | 
			
		||||
                end_date += timedelta(days=7)
 | 
			
		||||
        return ret
 | 
			
		||||
    def get_initial(self):
 | 
			
		||||
        init = super().get_initial()
 | 
			
		||||
        # if the id of a club is provided, select it by default
 | 
			
		||||
        if club_id := self.request.GET.get("club"):
 | 
			
		||||
            init["club"] = Club.objects.filter(id=club_id).first()
 | 
			
		||||
        return init
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsEditView(CanEditMixin, UpdateView):
 | 
			
		||||
class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
 | 
			
		||||
    model = News
 | 
			
		||||
    form_class = NewsForm
 | 
			
		||||
    template_name = "com/news_edit.jinja"
 | 
			
		||||
    pk_url_kwarg = "news_id"
 | 
			
		||||
 | 
			
		||||
    def get_initial(self):
 | 
			
		||||
        news_date: NewsDate = self.object.dates.order_by("id").first()
 | 
			
		||||
        if news_date is None:
 | 
			
		||||
            return {"start_date": None, "end_date": None}
 | 
			
		||||
        return {"start_date": news_date.start_date, "end_date": news_date.end_date}
 | 
			
		||||
 | 
			
		||||
    def post(self, request, *args, **kwargs):
 | 
			
		||||
        form = self.get_form()
 | 
			
		||||
        if form.is_valid() and "preview" not in request.POST:
 | 
			
		||||
            return self.form_valid(form)
 | 
			
		||||
        else:
 | 
			
		||||
            return self.form_invalid(form)
 | 
			
		||||
    permission_required = "com.edit_news"
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        self.object = form.save()
 | 
			
		||||
        if form.cleaned_data["automoderation"] and self.request.user.is_com_admin:
 | 
			
		||||
            self.object.moderator = self.request.user
 | 
			
		||||
            self.object.is_moderated = True
 | 
			
		||||
            self.object.save()
 | 
			
		||||
        else:
 | 
			
		||||
            self.object.is_moderated = False
 | 
			
		||||
            self.object.save()
 | 
			
		||||
            for u in (
 | 
			
		||||
                RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
			
		||||
                .first()
 | 
			
		||||
                .users.all()
 | 
			
		||||
            ):
 | 
			
		||||
                if not u.notifications.filter(
 | 
			
		||||
                    type="NEWS_MODERATION", viewed=False
 | 
			
		||||
                ).exists():
 | 
			
		||||
                    Notification(
 | 
			
		||||
                        user=u,
 | 
			
		||||
                        url=reverse(
 | 
			
		||||
                            "com:news_detail", kwargs={"news_id": self.object.id}
 | 
			
		||||
                        ),
 | 
			
		||||
                        type="NEWS_MODERATION",
 | 
			
		||||
                    ).save()
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
        response = super().form_valid(form)  # Does the saving part
 | 
			
		||||
        IcsCalendar.make_internal()
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
    def get_date_form_kwargs(self) -> dict[str, Any]:
 | 
			
		||||
        """Get initial data for NewsDateForm"""
 | 
			
		||||
        response = {}
 | 
			
		||||
        if self.request.method == "POST":
 | 
			
		||||
            response["data"] = self.request.POST
 | 
			
		||||
        dates = list(self.object.dates.order_by("id"))
 | 
			
		||||
        if len(dates) == 0:
 | 
			
		||||
            return {}
 | 
			
		||||
        response["instance"] = dates[0]
 | 
			
		||||
        occurrences = NewsDateForm.get_occurrences(len(dates))
 | 
			
		||||
        if occurrences is not None:
 | 
			
		||||
            response["initial"] = {"is_weekly": True, "occurrences": occurrences}
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
    def get_form_kwargs(self):
 | 
			
		||||
        return super().get_form_kwargs() | {
 | 
			
		||||
            "author": self.request.user,
 | 
			
		||||
            "date_form": NewsDateForm(**self.get_date_form_kwargs()),
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsCreateView(CanCreateMixin, CreateView):
 | 
			
		||||
    model = News
 | 
			
		||||
    form_class = NewsForm
 | 
			
		||||
    template_name = "com/news_edit.jinja"
 | 
			
		||||
 | 
			
		||||
    def get_initial(self):
 | 
			
		||||
        init = {"author": self.request.user}
 | 
			
		||||
        if "club" not in self.request.GET:
 | 
			
		||||
            return init
 | 
			
		||||
        init["club"] = Club.objects.filter(id=self.request.GET["club"]).first()
 | 
			
		||||
        return init
 | 
			
		||||
 | 
			
		||||
    def post(self, request, *args, **kwargs):
 | 
			
		||||
        form = self.get_form()
 | 
			
		||||
        if form.is_valid() and "preview" not in request.POST:
 | 
			
		||||
            return self.form_valid(form)
 | 
			
		||||
        else:
 | 
			
		||||
            self.object = form.instance
 | 
			
		||||
            return self.form_invalid(form)
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        self.object = form.save()
 | 
			
		||||
        if form.cleaned_data["automoderation"] and self.request.user.is_com_admin:
 | 
			
		||||
            self.object.moderator = self.request.user
 | 
			
		||||
            self.object.is_moderated = True
 | 
			
		||||
            self.object.save()
 | 
			
		||||
        else:
 | 
			
		||||
            for u in (
 | 
			
		||||
                RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
			
		||||
                .first()
 | 
			
		||||
                .users.all()
 | 
			
		||||
            ):
 | 
			
		||||
                if not u.notifications.filter(
 | 
			
		||||
                    type="NEWS_MODERATION", viewed=False
 | 
			
		||||
                ).exists():
 | 
			
		||||
                    Notification(
 | 
			
		||||
                        user=u,
 | 
			
		||||
                        url=reverse("com:news_admin_list"),
 | 
			
		||||
                        type="NEWS_MODERATION",
 | 
			
		||||
                    ).save()
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsDeleteView(CanEditMixin, DeleteView):
 | 
			
		||||
class NewsDeleteView(PermissionOrAuthorRequiredMixin, DeleteView):
 | 
			
		||||
    model = News
 | 
			
		||||
    pk_url_kwarg = "news_id"
 | 
			
		||||
    template_name = "core/delete_confirm.jinja"
 | 
			
		||||
    success_url = reverse_lazy("com:news_admin_list")
 | 
			
		||||
    permission_required = "com.delete_news"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsModerateView(CanEditMixin, SingleObjectMixin):
 | 
			
		||||
class NewsModerateView(PermissionRequiredMixin, DetailView):
 | 
			
		||||
    model = News
 | 
			
		||||
    pk_url_kwarg = "news_id"
 | 
			
		||||
    permission_required = "com.moderate_news"
 | 
			
		||||
 | 
			
		||||
    def get(self, request, *args, **kwargs):
 | 
			
		||||
        self.object = self.get_object()
 | 
			
		||||
        if "remove" in request.GET:
 | 
			
		||||
            self.object.is_moderated = False
 | 
			
		||||
            self.object.is_published = False
 | 
			
		||||
        else:
 | 
			
		||||
            self.object.is_moderated = True
 | 
			
		||||
            self.object.is_published = True
 | 
			
		||||
        self.object.moderator = request.user
 | 
			
		||||
        self.object.save()
 | 
			
		||||
        if "next" in self.request.GET:
 | 
			
		||||
@@ -365,36 +227,112 @@ class NewsModerateView(CanEditMixin, SingleObjectMixin):
 | 
			
		||||
        return redirect("com:news_admin_list")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsAdminListView(CanEditMixin, ListView):
 | 
			
		||||
class NewsAdminListView(PermissionRequiredMixin, ListView):
 | 
			
		||||
    model = News
 | 
			
		||||
    template_name = "com/news_admin_list.jinja"
 | 
			
		||||
    queryset = News.objects.all()
 | 
			
		||||
    queryset = News.objects.select_related(
 | 
			
		||||
        "club", "author", "moderator"
 | 
			
		||||
    ).prefetch_related("dates")
 | 
			
		||||
    permission_required = ["com.moderate_news", "com.delete_news"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsListView(CanViewMixin, ListView):
 | 
			
		||||
    model = News
 | 
			
		||||
class NewsListView(TemplateView):
 | 
			
		||||
    template_name = "com/news_list.jinja"
 | 
			
		||||
    queryset = News.objects.filter(is_moderated=True)
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        kwargs = super().get_context_data(**kwargs)
 | 
			
		||||
        kwargs["NewsDate"] = NewsDate
 | 
			
		||||
        kwargs["timedelta"] = timedelta
 | 
			
		||||
        kwargs["birthdays"] = (
 | 
			
		||||
    def get_birthdays(self):
 | 
			
		||||
        if not self.request.user.has_perm("core.view_user"):
 | 
			
		||||
            return []
 | 
			
		||||
        return itertools.groupby(
 | 
			
		||||
            User.objects.filter(
 | 
			
		||||
                date_of_birth__month=localdate().month,
 | 
			
		||||
                date_of_birth__day=localdate().day,
 | 
			
		||||
                is_subscriber_viewable=True,
 | 
			
		||||
            )
 | 
			
		||||
            .filter(role__in=["STUDENT", "FORMER STUDENT"])
 | 
			
		||||
            .order_by("-date_of_birth")
 | 
			
		||||
            .order_by("-date_of_birth"),
 | 
			
		||||
            key=lambda u: u.date_of_birth.year,
 | 
			
		||||
        )
 | 
			
		||||
        return kwargs
 | 
			
		||||
 | 
			
		||||
    def get_last_day(self) -> date | None:
 | 
			
		||||
        """Get the last day when news will be displayed
 | 
			
		||||
 | 
			
		||||
        The returned day is the third one where something happen.
 | 
			
		||||
        For example, if there are 6 events : A on 15/03, B and C on 17/03,
 | 
			
		||||
        D on 20/03, E on 21/03 and F on 22/03 ;
 | 
			
		||||
        then the result is 20/03.
 | 
			
		||||
        """
 | 
			
		||||
        dates = list(
 | 
			
		||||
            NewsDate.objects.filter(end_date__gt=now())
 | 
			
		||||
            .order_by("start_date")
 | 
			
		||||
            .values_list("start_date__date", flat=True)
 | 
			
		||||
            .distinct()[:4]
 | 
			
		||||
        )
 | 
			
		||||
        return dates[-1] if len(dates) > 0 else None
 | 
			
		||||
 | 
			
		||||
    def get_news_dates(self, until: date) -> dict[date, list[date]]:
 | 
			
		||||
        """Return the event dates to display.
 | 
			
		||||
 | 
			
		||||
        The selected events are the ones that happens between
 | 
			
		||||
        right now and the given day (included).
 | 
			
		||||
        """
 | 
			
		||||
        return {
 | 
			
		||||
            date: list(dates)
 | 
			
		||||
            for date, dates in itertools.groupby(
 | 
			
		||||
                NewsDate.objects.viewable_by(self.request.user)
 | 
			
		||||
                .filter(end_date__gt=now(), start_date__date__lte=until)
 | 
			
		||||
                .order_by("start_date")
 | 
			
		||||
                .select_related("news", "news__club"),
 | 
			
		||||
                key=lambda d: d.start_date.date(),
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        last_day = self.get_last_day()
 | 
			
		||||
        return super().get_context_data(**kwargs) | {
 | 
			
		||||
            "news_dates": self.get_news_dates(until=last_day)
 | 
			
		||||
            if last_day is not None
 | 
			
		||||
            else {},
 | 
			
		||||
            "birthdays": self.get_birthdays(),
 | 
			
		||||
            "last_day": last_day,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsDetailView(CanViewMixin, DetailView):
 | 
			
		||||
    model = News
 | 
			
		||||
    template_name = "com/news_detail.jinja"
 | 
			
		||||
    pk_url_kwarg = "news_id"
 | 
			
		||||
    queryset = News.objects.select_related("club", "author", "moderator")
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        return super().get_context_data(**kwargs) | {"date": self.object.dates.first()}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewsFeed(Feed):
 | 
			
		||||
    title = _("News")
 | 
			
		||||
    link = reverse_lazy("com:news_list")
 | 
			
		||||
    description = _("All incoming events")
 | 
			
		||||
 | 
			
		||||
    def items(self):
 | 
			
		||||
        return (
 | 
			
		||||
            NewsDate.objects.filter(
 | 
			
		||||
                news__is_published=True,
 | 
			
		||||
                end_date__gte=timezone.now() - (relativedelta(months=6)),
 | 
			
		||||
            )
 | 
			
		||||
            .select_related("news", "news__author")
 | 
			
		||||
            .order_by("-start_date")
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def item_title(self, item: NewsDate):
 | 
			
		||||
        return item.news.title
 | 
			
		||||
 | 
			
		||||
    def item_description(self, item: NewsDate):
 | 
			
		||||
        return item.news.summary
 | 
			
		||||
 | 
			
		||||
    def item_link(self, item: NewsDate):
 | 
			
		||||
        return item.news.get_absolute_url()
 | 
			
		||||
 | 
			
		||||
    def item_author_name(self, item: NewsDate):
 | 
			
		||||
        return item.news.author.get_display_name()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Weekmail
 | 
			
		||||
@@ -690,8 +628,12 @@ class PosterEditBaseView(UpdateView):
 | 
			
		||||
 | 
			
		||||
    def get_initial(self):
 | 
			
		||||
        return {
 | 
			
		||||
            "date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S"),
 | 
			
		||||
            "date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S"),
 | 
			
		||||
            "date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S")
 | 
			
		||||
            if self.object.date_begin
 | 
			
		||||
            else None,
 | 
			
		||||
            "date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S")
 | 
			
		||||
            if self.object.date_end
 | 
			
		||||
            else None,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def dispatch(self, request, *args, **kwargs):
 | 
			
		||||
 
 | 
			
		||||
@@ -15,17 +15,32 @@
 | 
			
		||||
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
from django.contrib.auth.models import Group as AuthGroup
 | 
			
		||||
from django.contrib.auth.models import Permission
 | 
			
		||||
 | 
			
		||||
from core.models import Group, OperationLog, Page, SithFile, User
 | 
			
		||||
from core.models import BanGroup, Group, OperationLog, Page, SithFile, User, UserBan
 | 
			
		||||
 | 
			
		||||
admin.site.unregister(AuthGroup)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Group)
 | 
			
		||||
class GroupAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ("name", "description", "is_meta")
 | 
			
		||||
    list_filter = ("is_meta",)
 | 
			
		||||
    list_display = ("name", "description", "is_manually_manageable")
 | 
			
		||||
    list_filter = ("is_manually_manageable",)
 | 
			
		||||
    search_fields = ("name",)
 | 
			
		||||
    autocomplete_fields = ("permissions",)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(BanGroup)
 | 
			
		||||
class BanGroupAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ("name", "description")
 | 
			
		||||
    search_fields = ("name",)
 | 
			
		||||
    autocomplete_fields = ("permissions",)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserBanInline(admin.TabularInline):
 | 
			
		||||
    model = UserBan
 | 
			
		||||
    extra = 0
 | 
			
		||||
    autocomplete_fields = ("ban_group",)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(User)
 | 
			
		||||
@@ -37,10 +52,24 @@ class UserAdmin(admin.ModelAdmin):
 | 
			
		||||
        "profile_pict",
 | 
			
		||||
        "avatar_pict",
 | 
			
		||||
        "scrub_pict",
 | 
			
		||||
        "user_permissions",
 | 
			
		||||
        "groups",
 | 
			
		||||
    )
 | 
			
		||||
    inlines = (UserBanInline,)
 | 
			
		||||
    search_fields = ["first_name", "last_name", "username"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(UserBan)
 | 
			
		||||
class UserBanAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ("user", "ban_group", "created_at", "expires_at")
 | 
			
		||||
    autocomplete_fields = ("user", "ban_group")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Permission)
 | 
			
		||||
class PermissionAdmin(admin.ModelAdmin):
 | 
			
		||||
    search_fields = ("codename",)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Page)
 | 
			
		||||
class PageAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ("name", "_full_name", "owner_group")
 | 
			
		||||
 
 | 
			
		||||
@@ -11,10 +11,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra
 | 
			
		||||
from ninja_extra.schemas import PaginatedResponseSchema
 | 
			
		||||
 | 
			
		||||
from club.models import Mailing
 | 
			
		||||
from core.api_permissions import (
 | 
			
		||||
    CanAccessLookup,
 | 
			
		||||
    CanView,
 | 
			
		||||
)
 | 
			
		||||
from core.auth.api_permissions import CanAccessLookup, CanView
 | 
			
		||||
from core.models import Group, SithFile, User
 | 
			
		||||
from core.schemas import (
 | 
			
		||||
    FamilyGodfatherSchema,
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,8 @@
 | 
			
		||||
Some permissions are global (like `IsInGroup` or `IsRoot`),
 | 
			
		||||
and some others are per-object (like `CanView` or `CanEdit`).
 | 
			
		||||
 | 
			
		||||
Examples:
 | 
			
		||||
Example:
 | 
			
		||||
    ```python
 | 
			
		||||
    # restrict all the routes of this controller
 | 
			
		||||
    # to subscribed users
 | 
			
		||||
    @api_controller("/foo", permissions=[IsSubscriber])
 | 
			
		||||
@@ -33,10 +34,14 @@ Examples:
 | 
			
		||||
        ]
 | 
			
		||||
        def bar_delete(self, bar_id: int):
 | 
			
		||||
            # ...
 | 
			
		||||
    ```
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
import operator
 | 
			
		||||
from functools import reduce
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.models import Permission
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
from ninja_extra import ControllerBase
 | 
			
		||||
from ninja_extra.permissions import BasePermission
 | 
			
		||||
@@ -54,6 +59,46 @@ class IsInGroup(BasePermission):
 | 
			
		||||
        return request.user.is_in_group(pk=self._group_pk)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HasPerm(BasePermission):
 | 
			
		||||
    """Check that the user has the required perm.
 | 
			
		||||
 | 
			
		||||
    If multiple perms are given, a comparer function can also be passed,
 | 
			
		||||
    in order to change the way perms are checked.
 | 
			
		||||
 | 
			
		||||
    Example:
 | 
			
		||||
        ```python
 | 
			
		||||
        # this route will require both permissions
 | 
			
		||||
        @route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])]
 | 
			
		||||
        def foo(self): ...
 | 
			
		||||
 | 
			
		||||
        # This route will require at least one of the perm,
 | 
			
		||||
        # but it's not mandatory to have all of them
 | 
			
		||||
        @route.put(
 | 
			
		||||
            "/bar",
 | 
			
		||||
            permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)],
 | 
			
		||||
        )
 | 
			
		||||
        def bar(self): ...
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self, perms: str | Permission | list[str | Permission], op=operator.and_
 | 
			
		||||
    ):
 | 
			
		||||
        """
 | 
			
		||||
        Args:
 | 
			
		||||
            perms: a permission or a list of permissions the user must have
 | 
			
		||||
            op: An operator to combine multiple permissions (in most cases,
 | 
			
		||||
                it will be either `operator.and_` or `operator.or_`)
 | 
			
		||||
        """
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        if not isinstance(perms, (list, tuple, set)):
 | 
			
		||||
            perms = [perms]
 | 
			
		||||
        self._operator = op
 | 
			
		||||
        self._perms = perms
 | 
			
		||||
 | 
			
		||||
    def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
 | 
			
		||||
        return reduce(self._operator, (request.user.has_perm(p) for p in self._perms))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IsRoot(BasePermission):
 | 
			
		||||
    """Check that the user is root."""
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										42
									
								
								core/auth/backends.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								core/auth/backends.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from typing import TYPE_CHECKING
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.backends import ModelBackend
 | 
			
		||||
from django.contrib.auth.models import Permission
 | 
			
		||||
 | 
			
		||||
from core.models import Group
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from core.models import User
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SithModelBackend(ModelBackend):
 | 
			
		||||
    """Custom auth backend for the Sith.
 | 
			
		||||
 | 
			
		||||
    In fact, it's the exact same backend as `django.contrib.auth.backend.ModelBackend`,
 | 
			
		||||
    with the exception that group permissions are fetched slightly differently.
 | 
			
		||||
    Indeed, django tries by default to fetch the permissions associated
 | 
			
		||||
    with all the `django.contrib.auth.models.Group` of a user ;
 | 
			
		||||
    however, our User model overrides that, so the actual linked group model
 | 
			
		||||
    is [core.models.Group][].
 | 
			
		||||
    Instead of having the relation `auth_perm --> auth_group <-- core_user`,
 | 
			
		||||
    we have `auth_perm --> auth_group <-- core_group <-- core_user`.
 | 
			
		||||
 | 
			
		||||
    Thus, this backend make the small tweaks necessary to make
 | 
			
		||||
    our custom models interact with the django auth.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def _get_group_permissions(self, user_obj: User):
 | 
			
		||||
        # union of querysets doesn't work if the queryset is ordered.
 | 
			
		||||
        # The empty `order_by` here are actually there to *remove*
 | 
			
		||||
        # any default ordering defined in managers or model Meta
 | 
			
		||||
        groups = user_obj.groups.order_by()
 | 
			
		||||
        if user_obj.is_subscribed:
 | 
			
		||||
            groups = groups.union(
 | 
			
		||||
                Group.objects.filter(pk=settings.SITH_GROUP_SUBSCRIBERS_ID).order_by()
 | 
			
		||||
            )
 | 
			
		||||
        return Permission.objects.filter(
 | 
			
		||||
            group__group__in=groups.values_list("pk", flat=True)
 | 
			
		||||
        )
 | 
			
		||||
							
								
								
									
										287
									
								
								core/auth/mixins.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								core/auth/mixins.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,287 @@
 | 
			
		||||
#
 | 
			
		||||
# Copyright 2016,2017
 | 
			
		||||
# - Skia <skia@libskia.so>
 | 
			
		||||
# - Sli <antoine@bartuccio.fr>
 | 
			
		||||
#
 | 
			
		||||
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
 | 
			
		||||
# http://ae.utbm.fr.
 | 
			
		||||
#
 | 
			
		||||
# This program is free software; you can redistribute it and/or modify it under
 | 
			
		||||
# the terms of the GNU General Public License a published by the Free Software
 | 
			
		||||
# Foundation; either version 3 of the License, or (at your option) any later
 | 
			
		||||
# version.
 | 
			
		||||
#
 | 
			
		||||
# This program is distributed in the hope that it will be useful, but WITHOUT
 | 
			
		||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 | 
			
		||||
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
 | 
			
		||||
# details.
 | 
			
		||||
#
 | 
			
		||||
# You should have received a copy of the GNU General Public License along with
 | 
			
		||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
 | 
			
		||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
import types
 | 
			
		||||
import warnings
 | 
			
		||||
from typing import TYPE_CHECKING, Any, LiteralString
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
 | 
			
		||||
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
 | 
			
		||||
from django.views.generic.base import View
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from django.db.models import Model
 | 
			
		||||
 | 
			
		||||
    from core.models import User
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def can_edit_prop(obj: Any, user: User) -> bool:
 | 
			
		||||
    """Can the user edit the properties of the object.
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        obj: Object to test for permission
 | 
			
		||||
        user: core.models.User to test permissions against
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        True if user is authorized to edit object properties else False
 | 
			
		||||
 | 
			
		||||
    Example:
 | 
			
		||||
        ```python
 | 
			
		||||
        if not can_edit_prop(self.object ,request.user):
 | 
			
		||||
            raise PermissionDenied
 | 
			
		||||
        ```
 | 
			
		||||
    """
 | 
			
		||||
    return obj is None or user.is_owner(obj)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def can_edit(obj: Any, user: User) -> bool:
 | 
			
		||||
    """Can the user edit the object.
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        obj: Object to test for permission
 | 
			
		||||
        user: core.models.User to test permissions against
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        True if user is authorized to edit object else False
 | 
			
		||||
 | 
			
		||||
    Example:
 | 
			
		||||
        ```python
 | 
			
		||||
        if not can_edit(self.object, request.user):
 | 
			
		||||
            raise PermissionDenied
 | 
			
		||||
        ```
 | 
			
		||||
    """
 | 
			
		||||
    if obj is None or user.can_edit(obj):
 | 
			
		||||
        return True
 | 
			
		||||
    return can_edit_prop(obj, user)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def can_view(obj: Any, user: User) -> bool:
 | 
			
		||||
    """Can the user see the object.
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        obj: Object to test for permission
 | 
			
		||||
        user: core.models.User to test permissions against
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        True if user is authorized to see object else False
 | 
			
		||||
 | 
			
		||||
    Example:
 | 
			
		||||
        ```python
 | 
			
		||||
        if not can_view(self.object ,request.user):
 | 
			
		||||
            raise PermissionDenied
 | 
			
		||||
        ```
 | 
			
		||||
    """
 | 
			
		||||
    if obj is None or user.can_view(obj):
 | 
			
		||||
        return True
 | 
			
		||||
    return can_edit(obj, user)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GenericContentPermissionMixinBuilder(View):
 | 
			
		||||
    """Used to build permission mixins.
 | 
			
		||||
 | 
			
		||||
    This view protect any child view that would be showing an object that is restricted based
 | 
			
		||||
      on two properties.
 | 
			
		||||
 | 
			
		||||
    Attributes:
 | 
			
		||||
        raised_error: permission to be raised
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    raised_error = PermissionDenied
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def permission_function(obj: Any, user: User) -> bool:
 | 
			
		||||
        """Function to test permission with."""
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def get_permission_function(cls, obj, user):
 | 
			
		||||
        return cls.permission_function(obj, user)
 | 
			
		||||
 | 
			
		||||
    def dispatch(self, request, *arg, **kwargs):
 | 
			
		||||
        if hasattr(self, "get_object") and callable(self.get_object):
 | 
			
		||||
            self.object = self.get_object()
 | 
			
		||||
            if not self.get_permission_function(self.object, request.user):
 | 
			
		||||
                raise self.raised_error
 | 
			
		||||
            return super().dispatch(request, *arg, **kwargs)
 | 
			
		||||
 | 
			
		||||
        # If we get here, it's a ListView
 | 
			
		||||
 | 
			
		||||
        queryset = self.get_queryset()
 | 
			
		||||
        l_id = [o.id for o in queryset if self.get_permission_function(o, request.user)]
 | 
			
		||||
        if not l_id and queryset.count() != 0:
 | 
			
		||||
            raise self.raised_error
 | 
			
		||||
        self._get_queryset = self.get_queryset
 | 
			
		||||
 | 
			
		||||
        def get_qs(self2):
 | 
			
		||||
            return self2._get_queryset().filter(id__in=l_id)
 | 
			
		||||
 | 
			
		||||
        self.get_queryset = types.MethodType(get_qs, self)
 | 
			
		||||
        return super().dispatch(request, *arg, **kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CanCreateMixin(View):
 | 
			
		||||
    """Protect any child view that would create an object.
 | 
			
		||||
 | 
			
		||||
    Raises:
 | 
			
		||||
        PermissionDenied:
 | 
			
		||||
            If the user has not the necessary permission
 | 
			
		||||
            to create the object of the view.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init_subclass__(cls, **kwargs):
 | 
			
		||||
        warnings.warn(
 | 
			
		||||
            f"{cls.__name__} is deprecated and should be replaced "
 | 
			
		||||
            "by other permission verification mecanism.",
 | 
			
		||||
            DeprecationWarning,
 | 
			
		||||
            stacklevel=2,
 | 
			
		||||
        )
 | 
			
		||||
        super().__init_subclass__(**kwargs)
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        warnings.warn(
 | 
			
		||||
            f"{self.__class__.__name__} is deprecated and should be replaced "
 | 
			
		||||
            "by other permission verification mecanism.",
 | 
			
		||||
            DeprecationWarning,
 | 
			
		||||
            stacklevel=2,
 | 
			
		||||
        )
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def dispatch(self, request, *arg, **kwargs):
 | 
			
		||||
        res = super().dispatch(request, *arg, **kwargs)
 | 
			
		||||
        if not request.user.is_authenticated:
 | 
			
		||||
            raise PermissionDenied
 | 
			
		||||
        return res
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        obj = form.instance
 | 
			
		||||
        if can_edit_prop(obj, self.request.user):
 | 
			
		||||
            return super().form_valid(form)
 | 
			
		||||
        raise PermissionDenied
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CanEditPropMixin(GenericContentPermissionMixinBuilder):
 | 
			
		||||
    """Ensure the user has owner permissions on the child view object.
 | 
			
		||||
 | 
			
		||||
    In other word, you can make a view with this view as parent,
 | 
			
		||||
    and it will be retricted to the users that are in the
 | 
			
		||||
    object's owner_group or that pass the `obj.can_be_viewed_by` test.
 | 
			
		||||
 | 
			
		||||
    Raises:
 | 
			
		||||
        PermissionDenied: If the user cannot see the object
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    permission_function = can_edit_prop
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CanEditMixin(GenericContentPermissionMixinBuilder):
 | 
			
		||||
    """Ensure the user has permission to edit this view's object.
 | 
			
		||||
 | 
			
		||||
    Raises:
 | 
			
		||||
        PermissionDenied: if the user cannot edit this view's object.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    permission_function = can_edit
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CanViewMixin(GenericContentPermissionMixinBuilder):
 | 
			
		||||
    """Ensure the user has permission to view this view's object.
 | 
			
		||||
 | 
			
		||||
    Raises:
 | 
			
		||||
        PermissionDenied: if the user cannot edit this view's object.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    permission_function = can_view
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FormerSubscriberMixin(AccessMixin):
 | 
			
		||||
    """Check if the user was at least an old subscriber.
 | 
			
		||||
 | 
			
		||||
    Raises:
 | 
			
		||||
        PermissionDenied: if the user never subscribed.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def dispatch(self, request, *args, **kwargs):
 | 
			
		||||
        if not request.user.was_subscribed:
 | 
			
		||||
            raise PermissionDenied
 | 
			
		||||
        return super().dispatch(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PermissionOrAuthorRequiredMixin(PermissionRequiredMixin):
 | 
			
		||||
    """Require that the user has the required perm or is the object author.
 | 
			
		||||
 | 
			
		||||
    This mixin can be used in combination with `DetailView`,
 | 
			
		||||
    or another base class that implements the `get_object` method.
 | 
			
		||||
 | 
			
		||||
    Example:
 | 
			
		||||
        In the following code, a user will be able
 | 
			
		||||
        to edit news if he has the `com.change_news` permission
 | 
			
		||||
        or if he tries to edit his own news :
 | 
			
		||||
 | 
			
		||||
        ```python
 | 
			
		||||
        class NewsEditView(PermissionOrAuthorRequiredMixin, DetailView):
 | 
			
		||||
            model = News
 | 
			
		||||
            author_field = "author"
 | 
			
		||||
            permission_required = "com.change_news"
 | 
			
		||||
        ```
 | 
			
		||||
 | 
			
		||||
        This is more or less equivalent to :
 | 
			
		||||
 | 
			
		||||
        ```python
 | 
			
		||||
        class NewsEditView(PermissionOrAuthorRequiredMixin, DetailView):
 | 
			
		||||
            model = News
 | 
			
		||||
 | 
			
		||||
            def dispatch(self, request, *args, **kwargs):
 | 
			
		||||
                self.object = self.get_object()
 | 
			
		||||
                if not (
 | 
			
		||||
                    user.has_perm("com.change_news")
 | 
			
		||||
                    or self.object.author == request.user
 | 
			
		||||
                ):
 | 
			
		||||
                    raise PermissionDenied
 | 
			
		||||
                return super().dispatch(request, *args, **kwargs)
 | 
			
		||||
        ```
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    author_field: LiteralString = "author"
 | 
			
		||||
 | 
			
		||||
    def has_permission(self):
 | 
			
		||||
        if not hasattr(self, "get_object"):
 | 
			
		||||
            raise ImproperlyConfigured(
 | 
			
		||||
                f"{self.__class__.__name__} is missing the "
 | 
			
		||||
                "get_object attribute. "
 | 
			
		||||
                f"Define {self.__class__.__name__}.get_object, "
 | 
			
		||||
                "or inherit from a class that implement it (like DetailView)"
 | 
			
		||||
            )
 | 
			
		||||
        if super().has_permission():
 | 
			
		||||
            return True
 | 
			
		||||
        if self.request.user.is_anonymous:
 | 
			
		||||
            return False
 | 
			
		||||
        obj: Model = self.get_object()
 | 
			
		||||
        if not self.author_field.endswith("_id"):
 | 
			
		||||
            # getting the related model could trigger a db query
 | 
			
		||||
            # so we will rather get the foreign value than
 | 
			
		||||
            # the object itself.
 | 
			
		||||
            self.author_field += "_id"
 | 
			
		||||
        author_id = getattr(obj, self.author_field, None)
 | 
			
		||||
        return author_id == self.request.user.id
 | 
			
		||||
@@ -7,7 +7,7 @@ from model_bakery import seq
 | 
			
		||||
from model_bakery.recipe import Recipe, related
 | 
			
		||||
 | 
			
		||||
from club.models import Membership
 | 
			
		||||
from core.models import User
 | 
			
		||||
from core.models import Group, User
 | 
			
		||||
from subscription.models import Subscription
 | 
			
		||||
 | 
			
		||||
active_subscription = Recipe(
 | 
			
		||||
@@ -60,5 +60,6 @@ board_user = Recipe(
 | 
			
		||||
    first_name="AE",
 | 
			
		||||
    last_name=seq("member "),
 | 
			
		||||
    memberships=related(ae_board_membership),
 | 
			
		||||
    groups=lambda: [Group.objects.get(club_board=settings.SITH_MAIN_CLUB_ID)],
 | 
			
		||||
)
 | 
			
		||||
"""A user which is in the board of the AE."""
 | 
			
		||||
 
 | 
			
		||||
@@ -23,7 +23,7 @@
 | 
			
		||||
from datetime import date, timedelta
 | 
			
		||||
from io import StringIO
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from typing import ClassVar
 | 
			
		||||
from typing import ClassVar, NamedTuple
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.models import Permission
 | 
			
		||||
@@ -31,6 +31,7 @@ from django.contrib.sites.models import Site
 | 
			
		||||
from django.core.management import call_command
 | 
			
		||||
from django.core.management.base import BaseCommand
 | 
			
		||||
from django.db import connection
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.timezone import localdate
 | 
			
		||||
from PIL import Image
 | 
			
		||||
@@ -45,8 +46,9 @@ from accounting.models import (
 | 
			
		||||
    SimplifiedAccountingType,
 | 
			
		||||
)
 | 
			
		||||
from club.models import Club, Membership
 | 
			
		||||
from com.calendar import IcsCalendar
 | 
			
		||||
from com.models import News, NewsDate, Sith, Weekmail
 | 
			
		||||
from core.models import Group, Page, PageRev, RealGroup, SithFile, User
 | 
			
		||||
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
 | 
			
		||||
from core.utils import resize_image
 | 
			
		||||
from counter.models import Counter, Product, ProductType, StudentCard
 | 
			
		||||
from election.models import Candidature, Election, ElectionList, Role
 | 
			
		||||
@@ -56,6 +58,18 @@ from sas.models import Album, PeoplePictureRelation, Picture
 | 
			
		||||
from subscription.models import Subscription
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PopulatedGroups(NamedTuple):
 | 
			
		||||
    root: Group
 | 
			
		||||
    public: Group
 | 
			
		||||
    subscribers: Group
 | 
			
		||||
    old_subscribers: Group
 | 
			
		||||
    sas_admin: Group
 | 
			
		||||
    com_admin: Group
 | 
			
		||||
    counter_admin: Group
 | 
			
		||||
    accounting_admin: Group
 | 
			
		||||
    pedagogy_admin: Group
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Command(BaseCommand):
 | 
			
		||||
    ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
 | 
			
		||||
    SAS_FIXTURE_PATH: ClassVar[Path] = (
 | 
			
		||||
@@ -69,7 +83,7 @@ class Command(BaseCommand):
 | 
			
		||||
            # sqlite doesn't support this operation
 | 
			
		||||
            return
 | 
			
		||||
        sqlcmd = StringIO()
 | 
			
		||||
        call_command("sqlsequencereset", *args, stdout=sqlcmd)
 | 
			
		||||
        call_command("sqlsequencereset", "--no-color", *args, stdout=sqlcmd)
 | 
			
		||||
        cursor = connection.cursor()
 | 
			
		||||
        cursor.execute(sqlcmd.getvalue())
 | 
			
		||||
 | 
			
		||||
@@ -78,26 +92,14 @@ class Command(BaseCommand):
 | 
			
		||||
            raise Exception("Never call this command in prod. Never.")
 | 
			
		||||
 | 
			
		||||
        Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
 | 
			
		||||
        Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
 | 
			
		||||
 | 
			
		||||
        root_group = Group.objects.create(name="Root")
 | 
			
		||||
        public_group = Group.objects.create(name="Public")
 | 
			
		||||
        subscribers = Group.objects.create(name="Subscribers")
 | 
			
		||||
        old_subscribers = Group.objects.create(name="Old subscribers")
 | 
			
		||||
        Group.objects.create(name="Accounting admin")
 | 
			
		||||
        Group.objects.create(name="Communication admin")
 | 
			
		||||
        Group.objects.create(name="Counter admin")
 | 
			
		||||
        Group.objects.create(name="Banned from buying alcohol")
 | 
			
		||||
        Group.objects.create(name="Banned from counters")
 | 
			
		||||
        Group.objects.create(name="Banned to subscribe")
 | 
			
		||||
        Group.objects.create(name="SAS admin")
 | 
			
		||||
        Group.objects.create(name="Forum admin")
 | 
			
		||||
        Group.objects.create(name="Pedagogy admin")
 | 
			
		||||
        self.reset_index("core", "auth")
 | 
			
		||||
        site = Site.objects.get_current()
 | 
			
		||||
        site.domain = settings.SITH_URL
 | 
			
		||||
        site.name = settings.SITH_NAME
 | 
			
		||||
        site.save()
 | 
			
		||||
 | 
			
		||||
        change_billing = Permission.objects.get(codename="change_billinginfo")
 | 
			
		||||
        add_billing = Permission.objects.get(codename="add_billinginfo")
 | 
			
		||||
        root_group.permissions.add(change_billing, add_billing)
 | 
			
		||||
        groups = self._create_groups()
 | 
			
		||||
        self._create_ban_groups()
 | 
			
		||||
 | 
			
		||||
        root = User.objects.create_superuser(
 | 
			
		||||
            id=0,
 | 
			
		||||
@@ -123,6 +125,11 @@ class Command(BaseCommand):
 | 
			
		||||
            unix_name=settings.SITH_MAIN_CLUB["unix_name"],
 | 
			
		||||
            address=settings.SITH_MAIN_CLUB["address"],
 | 
			
		||||
        )
 | 
			
		||||
        main_club.board_group.permissions.add(
 | 
			
		||||
            *Permission.objects.filter(
 | 
			
		||||
                codename__in=["view_subscription", "add_subscription"]
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        bar_club = Club.objects.create(
 | 
			
		||||
            id=2,
 | 
			
		||||
            name=settings.SITH_BAR_MANAGER["name"],
 | 
			
		||||
@@ -137,11 +144,10 @@ class Command(BaseCommand):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.reset_index("club")
 | 
			
		||||
        for bar_id, bar_name in settings.SITH_COUNTER_BARS:
 | 
			
		||||
            Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
 | 
			
		||||
        self.reset_index("counter")
 | 
			
		||||
        counters = [
 | 
			
		||||
            *[
 | 
			
		||||
                Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR")
 | 
			
		||||
                for bar_id, bar_name in settings.SITH_COUNTER_BARS
 | 
			
		||||
            ],
 | 
			
		||||
            Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
 | 
			
		||||
            Counter(name="AE", club=main_club, type="OFFICE"),
 | 
			
		||||
            Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
 | 
			
		||||
@@ -149,19 +155,21 @@ class Command(BaseCommand):
 | 
			
		||||
        Counter.objects.bulk_create(counters)
 | 
			
		||||
        bar_groups = []
 | 
			
		||||
        for bar_id, bar_name in settings.SITH_COUNTER_BARS:
 | 
			
		||||
            group = RealGroup.objects.create(name=f"{bar_name} admin")
 | 
			
		||||
            group = Group.objects.create(
 | 
			
		||||
                name=f"{bar_name} admin", is_manually_manageable=True
 | 
			
		||||
            )
 | 
			
		||||
            bar_groups.append(
 | 
			
		||||
                Counter.edit_groups.through(counter_id=bar_id, group=group)
 | 
			
		||||
            )
 | 
			
		||||
        Counter.edit_groups.through.objects.bulk_create(bar_groups)
 | 
			
		||||
        self.reset_index("counter")
 | 
			
		||||
 | 
			
		||||
        subscribers.viewable_files.add(home_root, club_root)
 | 
			
		||||
        groups.subscribers.viewable_files.add(home_root, club_root)
 | 
			
		||||
 | 
			
		||||
        Weekmail().save()
 | 
			
		||||
 | 
			
		||||
        # Here we add a lot of test datas, that are not necessary for the Sith, but that provide a basic development environment
 | 
			
		||||
        self.now = timezone.now().replace(hour=12)
 | 
			
		||||
        self.now = timezone.now().replace(hour=12, second=0)
 | 
			
		||||
 | 
			
		||||
        skia = User.objects.create_user(
 | 
			
		||||
            username="skia",
 | 
			
		||||
@@ -261,21 +269,11 @@ class Command(BaseCommand):
 | 
			
		||||
        )
 | 
			
		||||
        User.groups.through.objects.bulk_create(
 | 
			
		||||
            [
 | 
			
		||||
                User.groups.through(
 | 
			
		||||
                    realgroup_id=settings.SITH_GROUP_COUNTER_ADMIN_ID, user=counter
 | 
			
		||||
                ),
 | 
			
		||||
                User.groups.through(
 | 
			
		||||
                    realgroup_id=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID, user=comptable
 | 
			
		||||
                ),
 | 
			
		||||
                User.groups.through(
 | 
			
		||||
                    realgroup_id=settings.SITH_GROUP_COM_ADMIN_ID, user=comunity
 | 
			
		||||
                ),
 | 
			
		||||
                User.groups.through(
 | 
			
		||||
                    realgroup_id=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID, user=tutu
 | 
			
		||||
                ),
 | 
			
		||||
                User.groups.through(
 | 
			
		||||
                    realgroup_id=settings.SITH_GROUP_SAS_ADMIN_ID, user=skia
 | 
			
		||||
                ),
 | 
			
		||||
                User.groups.through(group=groups.counter_admin, user=counter),
 | 
			
		||||
                User.groups.through(group=groups.accounting_admin, user=comptable),
 | 
			
		||||
                User.groups.through(group=groups.com_admin, user=comunity),
 | 
			
		||||
                User.groups.through(group=groups.pedagogy_admin, user=tutu),
 | 
			
		||||
                User.groups.through(group=groups.sas_admin, user=skia),
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
        for user in richard, sli, krophil, skia:
 | 
			
		||||
@@ -336,7 +334,7 @@ Welcome to the wiki page!
 | 
			
		||||
            content="Fonctionnement de la laverie",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        public_group.viewable_page.set(
 | 
			
		||||
        groups.public.viewable_page.set(
 | 
			
		||||
            [syntax_page, services_page, index_page, laundry_page]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@@ -382,46 +380,42 @@ Welcome to the wiki page!
 | 
			
		||||
            parent=main_club,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        Membership.objects.bulk_create(
 | 
			
		||||
            [
 | 
			
		||||
                Membership(user=skia, club=main_club, role=3),
 | 
			
		||||
                Membership(
 | 
			
		||||
        Membership.objects.create(user=skia, club=main_club, role=3)
 | 
			
		||||
        Membership.objects.create(
 | 
			
		||||
            user=comunity,
 | 
			
		||||
            club=bar_club,
 | 
			
		||||
            start_date=localdate(),
 | 
			
		||||
            role=settings.SITH_CLUB_ROLES_ID["Board member"],
 | 
			
		||||
                ),
 | 
			
		||||
                Membership(
 | 
			
		||||
        )
 | 
			
		||||
        Membership.objects.create(
 | 
			
		||||
            user=sli,
 | 
			
		||||
            club=troll,
 | 
			
		||||
            role=9,
 | 
			
		||||
            description="Padawan Troll",
 | 
			
		||||
            start_date=localdate() - timedelta(days=17),
 | 
			
		||||
                ),
 | 
			
		||||
                Membership(
 | 
			
		||||
        )
 | 
			
		||||
        Membership.objects.create(
 | 
			
		||||
            user=krophil,
 | 
			
		||||
            club=troll,
 | 
			
		||||
            role=10,
 | 
			
		||||
            description="Maitre Troll",
 | 
			
		||||
            start_date=localdate() - timedelta(days=200),
 | 
			
		||||
                ),
 | 
			
		||||
                Membership(
 | 
			
		||||
        )
 | 
			
		||||
        Membership.objects.create(
 | 
			
		||||
            user=skia,
 | 
			
		||||
            club=troll,
 | 
			
		||||
            role=2,
 | 
			
		||||
            description="Grand Ancien Troll",
 | 
			
		||||
            start_date=localdate() - timedelta(days=400),
 | 
			
		||||
            end_date=localdate() - timedelta(days=86),
 | 
			
		||||
                ),
 | 
			
		||||
                Membership(
 | 
			
		||||
        )
 | 
			
		||||
        Membership.objects.create(
 | 
			
		||||
            user=richard,
 | 
			
		||||
            club=troll,
 | 
			
		||||
            role=2,
 | 
			
		||||
            description="",
 | 
			
		||||
            start_date=localdate() - timedelta(days=200),
 | 
			
		||||
            end_date=localdate() - timedelta(days=100),
 | 
			
		||||
                ),
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        p = ProductType.objects.create(name="Bières bouteilles")
 | 
			
		||||
@@ -476,6 +470,7 @@ Welcome to the wiki page!
 | 
			
		||||
            limit_age=18,
 | 
			
		||||
        )
 | 
			
		||||
        cons = Product.objects.create(
 | 
			
		||||
            id=settings.SITH_ECOCUP_CONS,
 | 
			
		||||
            name="Consigne Eco-cup",
 | 
			
		||||
            code="CONS",
 | 
			
		||||
            product_type=verre,
 | 
			
		||||
@@ -485,6 +480,7 @@ Welcome to the wiki page!
 | 
			
		||||
            club=main_club,
 | 
			
		||||
        )
 | 
			
		||||
        dcons = Product.objects.create(
 | 
			
		||||
            id=settings.SITH_ECOCUP_DECO,
 | 
			
		||||
            name="Déconsigne Eco-cup",
 | 
			
		||||
            code="DECO",
 | 
			
		||||
            product_type=verre,
 | 
			
		||||
@@ -513,8 +509,10 @@ Welcome to the wiki page!
 | 
			
		||||
            club=main_club,
 | 
			
		||||
            limit_age=18,
 | 
			
		||||
        )
 | 
			
		||||
        subscribers.products.add(cotis, cotis2, refill, barb, cble, cors, carolus)
 | 
			
		||||
        old_subscribers.products.add(cotis, cotis2)
 | 
			
		||||
        groups.subscribers.products.add(
 | 
			
		||||
            cotis, cotis2, refill, barb, cble, cors, carolus
 | 
			
		||||
        )
 | 
			
		||||
        groups.old_subscribers.products.add(cotis, cotis2)
 | 
			
		||||
 | 
			
		||||
        mde = Counter.objects.get(name="MDE")
 | 
			
		||||
        mde.products.add(barb, cble, cons, dcons)
 | 
			
		||||
@@ -608,7 +606,6 @@ Welcome to the wiki page!
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Create an election
 | 
			
		||||
        ae_board_group = Group.objects.get(name=settings.SITH_MAIN_BOARD_GROUP)
 | 
			
		||||
        el = Election.objects.create(
 | 
			
		||||
            title="Élection 2017",
 | 
			
		||||
            description="La roue tourne",
 | 
			
		||||
@@ -617,10 +614,10 @@ Welcome to the wiki page!
 | 
			
		||||
            start_date="1942-06-12 10:28:45+01",
 | 
			
		||||
            end_date="7942-06-12 10:28:45+01",
 | 
			
		||||
        )
 | 
			
		||||
        el.view_groups.add(public_group)
 | 
			
		||||
        el.edit_groups.add(ae_board_group)
 | 
			
		||||
        el.candidature_groups.add(subscribers)
 | 
			
		||||
        el.vote_groups.add(subscribers)
 | 
			
		||||
        el.view_groups.add(groups.public)
 | 
			
		||||
        el.edit_groups.add(main_club.board_group)
 | 
			
		||||
        el.candidature_groups.add(groups.subscribers)
 | 
			
		||||
        el.vote_groups.add(groups.subscribers)
 | 
			
		||||
        liste = ElectionList.objects.create(title="Candidature Libre", election=el)
 | 
			
		||||
        listeT = ElectionList.objects.create(title="Troll", election=el)
 | 
			
		||||
        pres = Role.objects.create(
 | 
			
		||||
@@ -684,17 +681,16 @@ Welcome to the wiki page!
 | 
			
		||||
        friday = self.now
 | 
			
		||||
        while friday.weekday() != 4:
 | 
			
		||||
            friday += timedelta(hours=6)
 | 
			
		||||
        friday.replace(hour=20, minute=0, second=0)
 | 
			
		||||
        friday.replace(hour=20, minute=0)
 | 
			
		||||
        # Event
 | 
			
		||||
        news_dates = []
 | 
			
		||||
        n = News.objects.create(
 | 
			
		||||
            title="Apero barman",
 | 
			
		||||
            summary="Viens boire un coup avec les barmans",
 | 
			
		||||
            content="Glou glou glou glou glou glou glou",
 | 
			
		||||
            type="EVENT",
 | 
			
		||||
            club=bar_club,
 | 
			
		||||
            author=subscriber,
 | 
			
		||||
            is_moderated=True,
 | 
			
		||||
            is_published=True,
 | 
			
		||||
            moderator=skia,
 | 
			
		||||
        )
 | 
			
		||||
        news_dates.append(
 | 
			
		||||
@@ -708,13 +704,11 @@ Welcome to the wiki page!
 | 
			
		||||
            title="Repas barman",
 | 
			
		||||
            summary="Enjoy la fin du semestre!",
 | 
			
		||||
            content=(
 | 
			
		||||
                "Viens donc t'enjailler avec les autres barmans aux "
 | 
			
		||||
                "frais du BdF! \\o/"
 | 
			
		||||
                "Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
 | 
			
		||||
            ),
 | 
			
		||||
            type="EVENT",
 | 
			
		||||
            club=bar_club,
 | 
			
		||||
            author=subscriber,
 | 
			
		||||
            is_moderated=True,
 | 
			
		||||
            is_published=True,
 | 
			
		||||
            moderator=skia,
 | 
			
		||||
        )
 | 
			
		||||
        news_dates.append(
 | 
			
		||||
@@ -728,10 +722,9 @@ Welcome to the wiki page!
 | 
			
		||||
            title="Repas fromager",
 | 
			
		||||
            summary="Wien manger du l'bon fromeug'",
 | 
			
		||||
            content="Fô viendre mangey d'la bonne fondue!",
 | 
			
		||||
            type="EVENT",
 | 
			
		||||
            club=bar_club,
 | 
			
		||||
            author=subscriber,
 | 
			
		||||
            is_moderated=True,
 | 
			
		||||
            is_published=True,
 | 
			
		||||
            moderator=skia,
 | 
			
		||||
        )
 | 
			
		||||
        news_dates.append(
 | 
			
		||||
@@ -745,17 +738,16 @@ Welcome to the wiki page!
 | 
			
		||||
            title="SdF",
 | 
			
		||||
            summary="Enjoy la fin des finaux!",
 | 
			
		||||
            content="Viens faire la fête avec tout plein de gens!",
 | 
			
		||||
            type="EVENT",
 | 
			
		||||
            club=bar_club,
 | 
			
		||||
            author=subscriber,
 | 
			
		||||
            is_moderated=True,
 | 
			
		||||
            is_published=True,
 | 
			
		||||
            moderator=skia,
 | 
			
		||||
        )
 | 
			
		||||
        news_dates.append(
 | 
			
		||||
            NewsDate(
 | 
			
		||||
                news=n,
 | 
			
		||||
                start_date=friday + timedelta(hours=24 * 7 + 1),
 | 
			
		||||
                end_date=self.now + timedelta(hours=24 * 7 + 9),
 | 
			
		||||
                end_date=friday + timedelta(hours=24 * 7 + 9),
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        # Weekly
 | 
			
		||||
@@ -764,10 +756,9 @@ Welcome to the wiki page!
 | 
			
		||||
            summary="Viens jouer!",
 | 
			
		||||
            content="Rejoins la fine équipe du Troll Penché et viens "
 | 
			
		||||
            "t'amuser le Vendredi soir!",
 | 
			
		||||
            type="WEEKLY",
 | 
			
		||||
            club=troll,
 | 
			
		||||
            author=subscriber,
 | 
			
		||||
            is_moderated=True,
 | 
			
		||||
            is_published=True,
 | 
			
		||||
            moderator=skia,
 | 
			
		||||
        )
 | 
			
		||||
        news_dates.extend(
 | 
			
		||||
@@ -781,8 +772,9 @@ Welcome to the wiki page!
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
        NewsDate.objects.bulk_create(news_dates)
 | 
			
		||||
        IcsCalendar.make_internal()  # Force refresh of the calendar after a bulk_create
 | 
			
		||||
 | 
			
		||||
        # Create som data for pedagogy
 | 
			
		||||
        # Create some data for pedagogy
 | 
			
		||||
 | 
			
		||||
        UV(
 | 
			
		||||
            code="PA00",
 | 
			
		||||
@@ -899,3 +891,120 @@ Welcome to the wiki page!
 | 
			
		||||
            start=s.subscription_start,
 | 
			
		||||
        )
 | 
			
		||||
        s.save()
 | 
			
		||||
 | 
			
		||||
    def _create_groups(self) -> PopulatedGroups:
 | 
			
		||||
        perms = Permission.objects.all()
 | 
			
		||||
 | 
			
		||||
        root_group = Group.objects.create(name="Root", is_manually_manageable=True)
 | 
			
		||||
        root_group.permissions.add(*list(perms.values_list("pk", flat=True)))
 | 
			
		||||
        # public has no permission.
 | 
			
		||||
        # Its purpose is not to link users to permissions,
 | 
			
		||||
        # but to other objects (like products)
 | 
			
		||||
        public_group = Group.objects.create(name="Public")
 | 
			
		||||
 | 
			
		||||
        subscribers = Group.objects.create(name="Subscribers")
 | 
			
		||||
        subscribers.permissions.add(
 | 
			
		||||
            *list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
 | 
			
		||||
        )
 | 
			
		||||
        old_subscribers = Group.objects.create(name="Old subscribers")
 | 
			
		||||
        old_subscribers.permissions.add(
 | 
			
		||||
            *list(
 | 
			
		||||
                perms.filter(
 | 
			
		||||
                    codename__in=[
 | 
			
		||||
                        "view_uv",
 | 
			
		||||
                        "view_uvcomment",
 | 
			
		||||
                        "add_uvcommentreport",
 | 
			
		||||
                        "view_user",
 | 
			
		||||
                        "view_picture",
 | 
			
		||||
                        "view_album",
 | 
			
		||||
                        "view_peoplepicturerelation",
 | 
			
		||||
                        "add_peoplepicturerelation",
 | 
			
		||||
                    ]
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        accounting_admin = Group.objects.create(
 | 
			
		||||
            name="Accounting admin", is_manually_manageable=True
 | 
			
		||||
        )
 | 
			
		||||
        accounting_admin.permissions.add(
 | 
			
		||||
            *list(
 | 
			
		||||
                perms.filter(
 | 
			
		||||
                    Q(content_type__app_label="accounting")
 | 
			
		||||
                    | Q(
 | 
			
		||||
                        codename__in=[
 | 
			
		||||
                            "view_customer",
 | 
			
		||||
                            "view_product",
 | 
			
		||||
                            "change_product",
 | 
			
		||||
                            "add_product",
 | 
			
		||||
                            "view_producttype",
 | 
			
		||||
                            "change_producttype",
 | 
			
		||||
                            "add_producttype",
 | 
			
		||||
                            "delete_selling",
 | 
			
		||||
                        ]
 | 
			
		||||
                    )
 | 
			
		||||
                ).values_list("pk", flat=True)
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        com_admin = Group.objects.create(
 | 
			
		||||
            name="Communication admin", is_manually_manageable=True
 | 
			
		||||
        )
 | 
			
		||||
        com_admin.permissions.add(
 | 
			
		||||
            *list(
 | 
			
		||||
                perms.filter(content_type__app_label="com").values_list("pk", flat=True)
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        counter_admin = Group.objects.create(
 | 
			
		||||
            name="Counter admin", is_manually_manageable=True
 | 
			
		||||
        )
 | 
			
		||||
        counter_admin.permissions.add(
 | 
			
		||||
            *list(
 | 
			
		||||
                perms.filter(
 | 
			
		||||
                    Q(content_type__app_label__in=["counter", "launderette"])
 | 
			
		||||
                    & ~Q(codename__in=["delete_product", "delete_producttype"])
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        sas_admin = Group.objects.create(name="SAS admin", is_manually_manageable=True)
 | 
			
		||||
        sas_admin.permissions.add(
 | 
			
		||||
            *list(
 | 
			
		||||
                perms.filter(content_type__app_label="sas").values_list("pk", flat=True)
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        forum_admin = Group.objects.create(
 | 
			
		||||
            name="Forum admin", is_manually_manageable=True
 | 
			
		||||
        )
 | 
			
		||||
        forum_admin.permissions.add(
 | 
			
		||||
            *list(
 | 
			
		||||
                perms.filter(content_type__app_label="forum").values_list(
 | 
			
		||||
                    "pk", flat=True
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        pedagogy_admin = Group.objects.create(
 | 
			
		||||
            name="Pedagogy admin", is_manually_manageable=True
 | 
			
		||||
        )
 | 
			
		||||
        pedagogy_admin.permissions.add(
 | 
			
		||||
            *list(
 | 
			
		||||
                perms.filter(content_type__app_label="pedagogy")
 | 
			
		||||
                .exclude(codename__in=["change_uvcomment"])
 | 
			
		||||
                .values_list("pk", flat=True)
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        self.reset_index("core", "auth")
 | 
			
		||||
 | 
			
		||||
        return PopulatedGroups(
 | 
			
		||||
            root=root_group,
 | 
			
		||||
            public=public_group,
 | 
			
		||||
            subscribers=subscribers,
 | 
			
		||||
            old_subscribers=old_subscribers,
 | 
			
		||||
            com_admin=com_admin,
 | 
			
		||||
            counter_admin=counter_admin,
 | 
			
		||||
            accounting_admin=accounting_admin,
 | 
			
		||||
            sas_admin=sas_admin,
 | 
			
		||||
            pedagogy_admin=pedagogy_admin,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def _create_ban_groups(self):
 | 
			
		||||
        BanGroup.objects.create(name="Banned from buying alcohol", description="")
 | 
			
		||||
        BanGroup.objects.create(name="Banned from counters", description="")
 | 
			
		||||
        BanGroup.objects.create(name="Banned to subscribe", description="")
 | 
			
		||||
 
 | 
			
		||||
@@ -5,13 +5,14 @@ from typing import Iterator
 | 
			
		||||
 | 
			
		||||
from dateutil.relativedelta import relativedelta
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.hashers import make_password
 | 
			
		||||
from django.core.management.base import BaseCommand
 | 
			
		||||
from django.db.models import Count, Exists, Min, OuterRef, Subquery
 | 
			
		||||
from django.utils.timezone import localdate, make_aware, now
 | 
			
		||||
from faker import Faker
 | 
			
		||||
 | 
			
		||||
from club.models import Club, Membership
 | 
			
		||||
from core.models import RealGroup, User
 | 
			
		||||
from core.models import Group, User
 | 
			
		||||
from counter.models import (
 | 
			
		||||
    Counter,
 | 
			
		||||
    Customer,
 | 
			
		||||
@@ -38,26 +39,10 @@ class Command(BaseCommand):
 | 
			
		||||
            raise Exception("Never call this command in prod. Never.")
 | 
			
		||||
 | 
			
		||||
        self.stdout.write("Creating users...")
 | 
			
		||||
        users = [
 | 
			
		||||
            User(
 | 
			
		||||
                username=self.faker.user_name(),
 | 
			
		||||
                first_name=self.faker.first_name(),
 | 
			
		||||
                last_name=self.faker.last_name(),
 | 
			
		||||
                date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25),
 | 
			
		||||
                email=self.faker.email(),
 | 
			
		||||
                phone=self.faker.phone_number(),
 | 
			
		||||
                address=self.faker.address(),
 | 
			
		||||
            )
 | 
			
		||||
            for _ in range(600)
 | 
			
		||||
        ]
 | 
			
		||||
        # there may a duplicate or two
 | 
			
		||||
        # Not a problem, we will just have 599 users instead of 600
 | 
			
		||||
        User.objects.bulk_create(users, ignore_conflicts=True)
 | 
			
		||||
        users = list(User.objects.order_by("-id")[: len(users)])
 | 
			
		||||
 | 
			
		||||
        users = self.create_users()
 | 
			
		||||
        subscribers = random.sample(users, k=int(0.8 * len(users)))
 | 
			
		||||
        self.stdout.write("Creating subscriptions...")
 | 
			
		||||
        self.create_subscriptions(users)
 | 
			
		||||
        self.create_subscriptions(subscribers)
 | 
			
		||||
        self.stdout.write("Creating club memberships...")
 | 
			
		||||
        users_qs = User.objects.filter(id__in=[s.id for s in subscribers])
 | 
			
		||||
        subscribers_now = list(
 | 
			
		||||
@@ -102,11 +87,34 @@ class Command(BaseCommand):
 | 
			
		||||
 | 
			
		||||
        self.stdout.write("Done")
 | 
			
		||||
 | 
			
		||||
    def create_users(self) -> list[User]:
 | 
			
		||||
        password = make_password("plop")
 | 
			
		||||
        users = [
 | 
			
		||||
            User(
 | 
			
		||||
                username=self.faker.user_name(),
 | 
			
		||||
                first_name=self.faker.first_name(),
 | 
			
		||||
                last_name=self.faker.last_name(),
 | 
			
		||||
                date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25),
 | 
			
		||||
                email=self.faker.email(),
 | 
			
		||||
                phone=self.faker.phone_number(),
 | 
			
		||||
                address=self.faker.address(),
 | 
			
		||||
                password=password,
 | 
			
		||||
            )
 | 
			
		||||
            for _ in range(600)
 | 
			
		||||
        ]
 | 
			
		||||
        # there may a duplicate or two
 | 
			
		||||
        # Not a problem, we will just have 599 users instead of 600
 | 
			
		||||
        users = User.objects.bulk_create(users, ignore_conflicts=True)
 | 
			
		||||
        users = list(User.objects.order_by("-id")[: len(users)])
 | 
			
		||||
        public_group = Group.objects.get(pk=settings.SITH_GROUP_PUBLIC_ID)
 | 
			
		||||
        public_group.users.add(*users)
 | 
			
		||||
        return users
 | 
			
		||||
 | 
			
		||||
    def create_subscriptions(self, users: list[User]):
 | 
			
		||||
        def prepare_subscription(user: User, start_date: date) -> Subscription:
 | 
			
		||||
        def prepare_subscription(_user: User, start_date: date) -> Subscription:
 | 
			
		||||
            payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
 | 
			
		||||
            duration = random.randint(1, 4)
 | 
			
		||||
            sub = Subscription(member=user, payment_method=payment_method)
 | 
			
		||||
            sub = Subscription(member=_user, payment_method=payment_method)
 | 
			
		||||
            sub.subscription_start = sub.compute_start(d=start_date, duration=duration)
 | 
			
		||||
            sub.subscription_end = sub.compute_end(duration)
 | 
			
		||||
            return sub
 | 
			
		||||
@@ -130,6 +138,10 @@ class Command(BaseCommand):
 | 
			
		||||
                    user, self.faker.past_date(sub.subscription_end)
 | 
			
		||||
                )
 | 
			
		||||
                subscriptions.append(sub)
 | 
			
		||||
        old_subscriber_group = Group.objects.get(
 | 
			
		||||
            pk=settings.SITH_GROUP_OLD_SUBSCRIBERS_ID
 | 
			
		||||
        )
 | 
			
		||||
        old_subscriber_group.users.add(*users)
 | 
			
		||||
        Subscription.objects.bulk_create(subscriptions)
 | 
			
		||||
        Customer.objects.bulk_create(customers, ignore_conflicts=True)
 | 
			
		||||
 | 
			
		||||
@@ -173,7 +185,8 @@ class Command(BaseCommand):
 | 
			
		||||
                    club=club,
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
        Membership.objects.bulk_create(memberships)
 | 
			
		||||
        memberships = Membership.objects.bulk_create(memberships)
 | 
			
		||||
        Membership._add_club_groups(memberships)
 | 
			
		||||
 | 
			
		||||
    def create_uvs(self):
 | 
			
		||||
        root = User.objects.get(username="root")
 | 
			
		||||
@@ -225,9 +238,7 @@ class Command(BaseCommand):
 | 
			
		||||
        ae = Club.objects.get(unix_name="ae")
 | 
			
		||||
        other_clubs = random.sample(list(Club.objects.all()), k=3)
 | 
			
		||||
        groups = list(
 | 
			
		||||
            RealGroup.objects.filter(
 | 
			
		||||
                name__in=["Subscribers", "Old subscribers", "Public"]
 | 
			
		||||
            )
 | 
			
		||||
            Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
 | 
			
		||||
        )
 | 
			
		||||
        counters = list(
 | 
			
		||||
            Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])
 | 
			
		||||
 
 | 
			
		||||
@@ -563,14 +563,21 @@ class Migration(migrations.Migration):
 | 
			
		||||
            fields=[],
 | 
			
		||||
            options={"proxy": True},
 | 
			
		||||
            bases=("core.group",),
 | 
			
		||||
            managers=[("objects", core.models.MetaGroupManager())],
 | 
			
		||||
            managers=[("objects", django.contrib.auth.models.GroupManager())],
 | 
			
		||||
        ),
 | 
			
		||||
        # at first, there existed a RealGroupManager and a RealGroupManager,
 | 
			
		||||
        # which have been since been removed.
 | 
			
		||||
        # However, this removal broke the migrations because it caused an ImportError.
 | 
			
		||||
        # Thus, the managers MetaGroupManager (above) and RealGroupManager (below)
 | 
			
		||||
        # have been replaced by the base django GroupManager to fix the import.
 | 
			
		||||
        # As those managers aren't actually used in migrations,
 | 
			
		||||
        # this replacement doesn't break anything.
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name="RealGroup",
 | 
			
		||||
            fields=[],
 | 
			
		||||
            options={"proxy": True},
 | 
			
		||||
            bases=("core.group",),
 | 
			
		||||
            managers=[("objects", core.models.RealGroupManager())],
 | 
			
		||||
            managers=[("objects", django.contrib.auth.models.GroupManager())],
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterUniqueTogether(
 | 
			
		||||
            name="page", unique_together={("name", "parent")}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,82 @@
 | 
			
		||||
# Generated by Django 4.2.16 on 2024-11-20 16:22
 | 
			
		||||
 | 
			
		||||
import django.contrib.auth.validators
 | 
			
		||||
import django.utils.timezone
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("auth", "0012_alter_user_first_name_max_length"),
 | 
			
		||||
        ("core", "0039_alter_user_managers"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterModelOptions(
 | 
			
		||||
            name="user",
 | 
			
		||||
            options={"verbose_name": "user", "verbose_name_plural": "users"},
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="user",
 | 
			
		||||
            name="user_permissions",
 | 
			
		||||
            field=models.ManyToManyField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                help_text="Specific permissions for this user.",
 | 
			
		||||
                related_name="user_set",
 | 
			
		||||
                related_query_name="user",
 | 
			
		||||
                to="auth.permission",
 | 
			
		||||
                verbose_name="user permissions",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="user",
 | 
			
		||||
            name="date_joined",
 | 
			
		||||
            field=models.DateTimeField(
 | 
			
		||||
                default=django.utils.timezone.now, verbose_name="date joined"
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="user",
 | 
			
		||||
            name="is_superuser",
 | 
			
		||||
            field=models.BooleanField(
 | 
			
		||||
                default=False,
 | 
			
		||||
                help_text="Designates that this user has all permissions without explicitly assigning them.",
 | 
			
		||||
                verbose_name="superuser status",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="user",
 | 
			
		||||
            name="username",
 | 
			
		||||
            field=models.CharField(
 | 
			
		||||
                error_messages={"unique": "A user with that username already exists."},
 | 
			
		||||
                help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
 | 
			
		||||
                max_length=150,
 | 
			
		||||
                unique=True,
 | 
			
		||||
                validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
 | 
			
		||||
                verbose_name="username",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="user",
 | 
			
		||||
            name="groups",
 | 
			
		||||
            field=models.ManyToManyField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
 | 
			
		||||
                related_name="user_set",
 | 
			
		||||
                related_query_name="user",
 | 
			
		||||
                to="auth.group",
 | 
			
		||||
                verbose_name="groups",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="user",
 | 
			
		||||
            name="groups",
 | 
			
		||||
            field=models.ManyToManyField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
 | 
			
		||||
                related_name="users",
 | 
			
		||||
                to="core.group",
 | 
			
		||||
                verbose_name="groups",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,37 @@
 | 
			
		||||
# Generated by Django 4.2.16 on 2024-11-30 13:16
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("core", "0040_alter_user_options_user_user_permissions_and_more"),
 | 
			
		||||
        ("club", "0013_alter_club_board_group_alter_club_members_group_and_more"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.DeleteModel(
 | 
			
		||||
            name="MetaGroup",
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.DeleteModel(
 | 
			
		||||
            name="RealGroup",
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterModelOptions(
 | 
			
		||||
            name="group",
 | 
			
		||||
            options={},
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name="group",
 | 
			
		||||
            old_name="is_meta",
 | 
			
		||||
            new_name="is_manually_manageable",
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="group",
 | 
			
		||||
            name="is_manually_manageable",
 | 
			
		||||
            field=models.BooleanField(
 | 
			
		||||
                default=False,
 | 
			
		||||
                help_text="If False, this shouldn't be shown on group management pages",
 | 
			
		||||
                verbose_name="Is manually manageable",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,27 @@
 | 
			
		||||
# Generated by Django 4.2.17 on 2025-01-04 16:42
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
from django.db.migrations.state import StateApps
 | 
			
		||||
from django.db.models import F
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def invert_is_manually_manageable(apps: StateApps, schema_editor):
 | 
			
		||||
    """Invert `is_manually_manageable`.
 | 
			
		||||
 | 
			
		||||
    This field is a renaming of `is_meta`.
 | 
			
		||||
    However, the meaning has been inverted : the groups
 | 
			
		||||
    which were meta are not manually manageable and vice versa.
 | 
			
		||||
    Thus, the value must be inverted.
 | 
			
		||||
    """
 | 
			
		||||
    Group = apps.get_model("core", "Group")
 | 
			
		||||
    Group.objects.all().update(is_manually_manageable=~F("is_manually_manageable"))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [("core", "0041_delete_metagroup_alter_group_options_and_more")]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RunPython(
 | 
			
		||||
            invert_is_manually_manageable, reverse_code=invert_is_manually_manageable
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,164 @@
 | 
			
		||||
# Generated by Django 4.2.17 on 2024-12-31 13:30
 | 
			
		||||
 | 
			
		||||
import django.contrib.auth.models
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
from django.db.migrations.state import StateApps
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def migrate_ban_groups(apps: StateApps, schema_editor):
 | 
			
		||||
    Group = apps.get_model("core", "Group")
 | 
			
		||||
    BanGroup = apps.get_model("core", "BanGroup")
 | 
			
		||||
    ban_group_ids = [
 | 
			
		||||
        settings.SITH_GROUP_BANNED_ALCOHOL_ID,
 | 
			
		||||
        settings.SITH_GROUP_BANNED_COUNTER_ID,
 | 
			
		||||
        settings.SITH_GROUP_BANNED_SUBSCRIPTION_ID,
 | 
			
		||||
    ]
 | 
			
		||||
    # this is a N+1 Queries, but the prod database has a grand total of 3 ban groups
 | 
			
		||||
    for group in Group.objects.filter(id__in=ban_group_ids):
 | 
			
		||||
        # auth_group, which both Group and BanGroup inherit,
 | 
			
		||||
        # is unique by name.
 | 
			
		||||
        # If we tried give the exact same name to the migrated BanGroup
 | 
			
		||||
        # before deleting the corresponding Group,
 | 
			
		||||
        # we would have an IntegrityError.
 | 
			
		||||
        # So we append a space to the name, in order to create a name
 | 
			
		||||
        # that will look the same, but that isn't really the same.
 | 
			
		||||
        ban_group = BanGroup.objects.create(
 | 
			
		||||
            name=f"{group.name} ",
 | 
			
		||||
            description=group.description,
 | 
			
		||||
        )
 | 
			
		||||
        perms = list(group.permissions.values_list("id", flat=True))
 | 
			
		||||
        if perms:
 | 
			
		||||
            ban_group.permissions.add(*perms)
 | 
			
		||||
        ban_group.users.add(
 | 
			
		||||
            *group.users.values_list("id", flat=True), through_defaults={"reason": ""}
 | 
			
		||||
        )
 | 
			
		||||
        group.delete()
 | 
			
		||||
        # now that the original group is no longer there,
 | 
			
		||||
        # we can remove the appended space
 | 
			
		||||
        ban_group.name = ban_group.name.strip()
 | 
			
		||||
        ban_group.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("auth", "0012_alter_user_first_name_max_length"),
 | 
			
		||||
        ("core", "0042_invert_is_manually_manageable_20250104_1742"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name="BanGroup",
 | 
			
		||||
            fields=[
 | 
			
		||||
                (
 | 
			
		||||
                    "group_ptr",
 | 
			
		||||
                    models.OneToOneField(
 | 
			
		||||
                        auto_created=True,
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                        parent_link=True,
 | 
			
		||||
                        primary_key=True,
 | 
			
		||||
                        serialize=False,
 | 
			
		||||
                        to="auth.group",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                ("description", models.TextField(verbose_name="description")),
 | 
			
		||||
            ],
 | 
			
		||||
            bases=("auth.group",),
 | 
			
		||||
            managers=[
 | 
			
		||||
                ("objects", django.contrib.auth.models.GroupManager()),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                "verbose_name": "ban group",
 | 
			
		||||
                "verbose_name_plural": "ban groups",
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="group",
 | 
			
		||||
            name="description",
 | 
			
		||||
            field=models.TextField(verbose_name="description"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="user",
 | 
			
		||||
            name="groups",
 | 
			
		||||
            field=models.ManyToManyField(
 | 
			
		||||
                help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
 | 
			
		||||
                related_name="users",
 | 
			
		||||
                to="core.group",
 | 
			
		||||
                verbose_name="groups",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name="UserBan",
 | 
			
		||||
            fields=[
 | 
			
		||||
                (
 | 
			
		||||
                    "id",
 | 
			
		||||
                    models.AutoField(
 | 
			
		||||
                        auto_created=True,
 | 
			
		||||
                        primary_key=True,
 | 
			
		||||
                        serialize=False,
 | 
			
		||||
                        verbose_name="ID",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "created_at",
 | 
			
		||||
                    models.DateTimeField(auto_now_add=True, verbose_name="created at"),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "expires_at",
 | 
			
		||||
                    models.DateTimeField(
 | 
			
		||||
                        blank=True,
 | 
			
		||||
                        help_text="When the ban should be removed. Currently, there is no automatic removal, so this is purely indicative. Automatic ban removal may be implemented later on.",
 | 
			
		||||
                        null=True,
 | 
			
		||||
                        verbose_name="expires at",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                ("reason", models.TextField(verbose_name="reason")),
 | 
			
		||||
                (
 | 
			
		||||
                    "ban_group",
 | 
			
		||||
                    models.ForeignKey(
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                        related_name="user_bans",
 | 
			
		||||
                        to="core.bangroup",
 | 
			
		||||
                        verbose_name="ban type",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "user",
 | 
			
		||||
                    models.ForeignKey(
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                        related_name="bans",
 | 
			
		||||
                        to=settings.AUTH_USER_MODEL,
 | 
			
		||||
                        verbose_name="user",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="user",
 | 
			
		||||
            name="ban_groups",
 | 
			
		||||
            field=models.ManyToManyField(
 | 
			
		||||
                help_text="The bans this user has received.",
 | 
			
		||||
                related_name="users",
 | 
			
		||||
                through="core.UserBan",
 | 
			
		||||
                to="core.bangroup",
 | 
			
		||||
                verbose_name="ban groups",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddConstraint(
 | 
			
		||||
            model_name="userban",
 | 
			
		||||
            constraint=models.UniqueConstraint(
 | 
			
		||||
                fields=("ban_group", "user"), name="unique_ban_type_per_user"
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddConstraint(
 | 
			
		||||
            model_name="userban",
 | 
			
		||||
            constraint=models.CheckConstraint(
 | 
			
		||||
                check=models.Q(("expires_at__gte", models.F("created_at"))),
 | 
			
		||||
                name="user_ban_end_after_start",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(
 | 
			
		||||
            migrate_ban_groups, reverse_code=migrations.RunPython.noop, elidable=True
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										16
									
								
								core/migrations/0044_alter_userban_options.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								core/migrations/0044_alter_userban_options.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
# Generated by Django 4.2.17 on 2025-02-25 14:45
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("core", "0043_bangroup_alter_group_description_alter_user_groups_and_more"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterModelOptions(
 | 
			
		||||
            name="userban",
 | 
			
		||||
            options={"verbose_name": "user ban", "verbose_name_plural": "user bans"},
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										361
									
								
								core/models.py
									
									
									
									
									
								
							
							
						
						
									
										361
									
								
								core/models.py
									
									
									
									
									
								
							@@ -29,27 +29,21 @@ import os
 | 
			
		||||
import string
 | 
			
		||||
import unicodedata
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from io import BytesIO
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from typing import TYPE_CHECKING, Any, Optional, Self
 | 
			
		||||
from typing import TYPE_CHECKING, Optional, Self
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.models import AbstractBaseUser, UserManager
 | 
			
		||||
from django.contrib.auth.models import (
 | 
			
		||||
    AnonymousUser as AuthAnonymousUser,
 | 
			
		||||
)
 | 
			
		||||
from django.contrib.auth.models import (
 | 
			
		||||
    Group as AuthGroup,
 | 
			
		||||
)
 | 
			
		||||
from django.contrib.auth.models import (
 | 
			
		||||
    GroupManager as AuthGroupManager,
 | 
			
		||||
)
 | 
			
		||||
from django.contrib.auth.models import AbstractUser, UserManager
 | 
			
		||||
from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
 | 
			
		||||
from django.contrib.auth.models import Group as AuthGroup
 | 
			
		||||
from django.contrib.staticfiles.storage import staticfiles_storage
 | 
			
		||||
from django.core import validators
 | 
			
		||||
from django.core.cache import cache
 | 
			
		||||
from django.core.exceptions import PermissionDenied, ValidationError
 | 
			
		||||
from django.core.mail import send_mail
 | 
			
		||||
from django.db import models, transaction
 | 
			
		||||
from django.db.models import Exists, OuterRef, Q
 | 
			
		||||
from django.db.models import Exists, F, OuterRef, Q
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.functional import cached_property
 | 
			
		||||
@@ -57,6 +51,7 @@ from django.utils.html import escape
 | 
			
		||||
from django.utils.timezone import localdate, now
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from phonenumber_field.modelfields import PhoneNumberField
 | 
			
		||||
from PIL import Image
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from pydantic import NonNegativeInt
 | 
			
		||||
@@ -64,33 +59,15 @@ if TYPE_CHECKING:
 | 
			
		||||
    from club.models import Club
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RealGroupManager(AuthGroupManager):
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return super().get_queryset().filter(is_meta=False)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MetaGroupManager(AuthGroupManager):
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return super().get_queryset().filter(is_meta=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Group(AuthGroup):
 | 
			
		||||
    """Implement both RealGroups and Meta groups.
 | 
			
		||||
    """Wrapper around django.auth.Group"""
 | 
			
		||||
 | 
			
		||||
    Groups are sorted by their is_meta property
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    #: If False, this is a RealGroup
 | 
			
		||||
    is_meta = models.BooleanField(
 | 
			
		||||
        _("meta group status"),
 | 
			
		||||
    is_manually_manageable = models.BooleanField(
 | 
			
		||||
        _("Is manually manageable"),
 | 
			
		||||
        default=False,
 | 
			
		||||
        help_text=_("Whether a group is a meta group or not"),
 | 
			
		||||
        help_text=_("If False, this shouldn't be shown on group management pages"),
 | 
			
		||||
    )
 | 
			
		||||
    #: Description of the group
 | 
			
		||||
    description = models.CharField(_("description"), max_length=60)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        ordering = ["name"]
 | 
			
		||||
    description = models.TextField(_("description"))
 | 
			
		||||
 | 
			
		||||
    def get_absolute_url(self) -> str:
 | 
			
		||||
        return reverse("core:group_list")
 | 
			
		||||
@@ -106,65 +83,6 @@ class Group(AuthGroup):
 | 
			
		||||
        cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MetaGroup(Group):
 | 
			
		||||
    """MetaGroups are dynamically created groups.
 | 
			
		||||
 | 
			
		||||
    Generally used with clubs where creating a club creates two groups:
 | 
			
		||||
 | 
			
		||||
    * club-SITH_BOARD_SUFFIX
 | 
			
		||||
    * club-SITH_MEMBER_SUFFIX
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    #: Assign a manager in a way that MetaGroup.objects only return groups with is_meta=False
 | 
			
		||||
    objects = MetaGroupManager()
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        proxy = True
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.is_meta = True
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def associated_club(self) -> Club | None:
 | 
			
		||||
        """Return the group associated with this meta group.
 | 
			
		||||
 | 
			
		||||
        The result of this function is cached
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        Returns:
 | 
			
		||||
            The associated club if it exists, else None
 | 
			
		||||
        """
 | 
			
		||||
        from club.models import Club
 | 
			
		||||
 | 
			
		||||
        if self.name.endswith(settings.SITH_BOARD_SUFFIX):
 | 
			
		||||
            # replace this with str.removesuffix as soon as Python
 | 
			
		||||
            # is upgraded to 3.10
 | 
			
		||||
            club_name = self.name[: -len(settings.SITH_BOARD_SUFFIX)]
 | 
			
		||||
        elif self.name.endswith(settings.SITH_MEMBER_SUFFIX):
 | 
			
		||||
            club_name = self.name[: -len(settings.SITH_MEMBER_SUFFIX)]
 | 
			
		||||
        else:
 | 
			
		||||
            return None
 | 
			
		||||
        club = cache.get(f"sith_club_{club_name}")
 | 
			
		||||
        if club is None:
 | 
			
		||||
            club = Club.objects.filter(unix_name=club_name).first()
 | 
			
		||||
            cache.set(f"sith_club_{club_name}", club)
 | 
			
		||||
        return club
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RealGroup(Group):
 | 
			
		||||
    """RealGroups are created by the developer.
 | 
			
		||||
 | 
			
		||||
    Most of the time they match a number in settings to be easily used for permissions.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    #: Assign a manager in a way that MetaGroup.objects only return groups with is_meta=True
 | 
			
		||||
    objects = RealGroupManager()
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        proxy = True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def validate_promo(value: int) -> None:
 | 
			
		||||
    start_year = settings.SITH_SCHOOL_START_YEAR
 | 
			
		||||
    delta = (localdate() + timedelta(days=180)).year - start_year
 | 
			
		||||
@@ -210,13 +128,35 @@ def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None
 | 
			
		||||
    else:
 | 
			
		||||
        group = Group.objects.filter(name=name).first()
 | 
			
		||||
    if group is not None:
 | 
			
		||||
        cache.set(f"sith_group_{group.id}", group)
 | 
			
		||||
        cache.set(f"sith_group_{group.name.replace(' ', '_')}", group)
 | 
			
		||||
        name = group.name.replace(" ", "_")
 | 
			
		||||
        cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group})
 | 
			
		||||
    else:
 | 
			
		||||
        cache.set(f"sith_group_{pk_or_name}", "not_found")
 | 
			
		||||
    return group
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BanGroup(AuthGroup):
 | 
			
		||||
    """An anti-group, that removes permissions instead of giving them.
 | 
			
		||||
 | 
			
		||||
    Users are linked to BanGroups through UserBan objects.
 | 
			
		||||
 | 
			
		||||
    Example:
 | 
			
		||||
        ```python
 | 
			
		||||
        user = User.objects.get(username="...")
 | 
			
		||||
        ban_group = BanGroup.objects.first()
 | 
			
		||||
        UserBan.objects.create(user=user, ban_group=ban_group, reason="...")
 | 
			
		||||
 | 
			
		||||
        assert user.ban_groups.contains(ban_group)
 | 
			
		||||
        ```
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    description = models.TextField(_("description"))
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("ban group")
 | 
			
		||||
        verbose_name_plural = _("ban groups")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserQuerySet(models.QuerySet):
 | 
			
		||||
    def filter_inactive(self) -> Self:
 | 
			
		||||
        from counter.models import Refilling, Selling
 | 
			
		||||
@@ -242,7 +182,7 @@ class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class User(AbstractBaseUser):
 | 
			
		||||
class User(AbstractUser):
 | 
			
		||||
    """Defines the base user class, useable in every app.
 | 
			
		||||
 | 
			
		||||
    This is almost the same as the auth module AbstractUser since it inherits from it,
 | 
			
		||||
@@ -253,51 +193,28 @@ class User(AbstractBaseUser):
 | 
			
		||||
    Required fields: email, first_name, last_name, date_of_birth
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    username = models.CharField(
 | 
			
		||||
        _("username"),
 | 
			
		||||
        max_length=254,
 | 
			
		||||
        unique=True,
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            "Required. 254 characters or fewer. Letters, digits and ./+/-/_ only."
 | 
			
		||||
        ),
 | 
			
		||||
        validators=[
 | 
			
		||||
            validators.RegexValidator(
 | 
			
		||||
                r"^[\w.+-]+$",
 | 
			
		||||
                _(
 | 
			
		||||
                    "Enter a valid username. This value may contain only "
 | 
			
		||||
                    "letters, numbers "
 | 
			
		||||
                    "and ./+/-/_ characters."
 | 
			
		||||
                ),
 | 
			
		||||
            )
 | 
			
		||||
        ],
 | 
			
		||||
        error_messages={"unique": _("A user with that username already exists.")},
 | 
			
		||||
    )
 | 
			
		||||
    first_name = models.CharField(_("first name"), max_length=64)
 | 
			
		||||
    last_name = models.CharField(_("last name"), max_length=64)
 | 
			
		||||
    email = models.EmailField(_("email address"), unique=True)
 | 
			
		||||
    date_of_birth = models.DateField(_("date of birth"), blank=True, null=True)
 | 
			
		||||
    nick_name = models.CharField(_("nick name"), max_length=64, null=True, blank=True)
 | 
			
		||||
    is_staff = models.BooleanField(
 | 
			
		||||
        _("staff status"),
 | 
			
		||||
        default=False,
 | 
			
		||||
        help_text=_("Designates whether the user can log into this admin site."),
 | 
			
		||||
    )
 | 
			
		||||
    is_active = models.BooleanField(
 | 
			
		||||
        _("active"),
 | 
			
		||||
        default=True,
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            "Designates whether this user should be treated as active. "
 | 
			
		||||
            "Unselect this instead of deleting accounts."
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
    date_joined = models.DateField(_("date joined"), auto_now_add=True)
 | 
			
		||||
    last_update = models.DateTimeField(_("last update"), auto_now=True)
 | 
			
		||||
    is_superuser = models.BooleanField(
 | 
			
		||||
        _("superuser"),
 | 
			
		||||
        default=False,
 | 
			
		||||
        help_text=_("Designates whether this user is a superuser. "),
 | 
			
		||||
    groups = models.ManyToManyField(
 | 
			
		||||
        Group,
 | 
			
		||||
        verbose_name=_("groups"),
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            "The groups this user belongs to. A user will get all permissions "
 | 
			
		||||
            "granted to each of their groups."
 | 
			
		||||
        ),
 | 
			
		||||
        related_name="users",
 | 
			
		||||
    )
 | 
			
		||||
    ban_groups = models.ManyToManyField(
 | 
			
		||||
        BanGroup,
 | 
			
		||||
        verbose_name=_("ban groups"),
 | 
			
		||||
        through="UserBan",
 | 
			
		||||
        help_text=_("The bans this user has received."),
 | 
			
		||||
        related_name="users",
 | 
			
		||||
    )
 | 
			
		||||
    groups = models.ManyToManyField(RealGroup, related_name="users", blank=True)
 | 
			
		||||
    home = models.OneToOneField(
 | 
			
		||||
        "SithFile",
 | 
			
		||||
        related_name="home_of",
 | 
			
		||||
@@ -401,18 +318,20 @@ class User(AbstractBaseUser):
 | 
			
		||||
 | 
			
		||||
    objects = CustomUserManager()
 | 
			
		||||
 | 
			
		||||
    USERNAME_FIELD = "username"
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.get_display_name()
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        adding = self._state.adding
 | 
			
		||||
        with transaction.atomic():
 | 
			
		||||
            if self.id:
 | 
			
		||||
            if not adding:
 | 
			
		||||
                old = User.objects.filter(id=self.id).first()
 | 
			
		||||
                if old and old.username != self.username:
 | 
			
		||||
                    self._change_username(self.username)
 | 
			
		||||
            super().save(*args, **kwargs)
 | 
			
		||||
            if adding:
 | 
			
		||||
                # All users are in the public group.
 | 
			
		||||
                self.groups.add(settings.SITH_GROUP_PUBLIC_ID)
 | 
			
		||||
 | 
			
		||||
    def get_absolute_url(self) -> str:
 | 
			
		||||
        return reverse("core:user_profile", kwargs={"user_id": self.pk})
 | 
			
		||||
@@ -422,22 +341,23 @@ class User(AbstractBaseUser):
 | 
			
		||||
            settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
 | 
			
		||||
        ).exists()
 | 
			
		||||
 | 
			
		||||
    def has_module_perms(self, package_name: str) -> bool:
 | 
			
		||||
        return self.is_active
 | 
			
		||||
 | 
			
		||||
    def has_perm(self, perm: str, obj: Any = None) -> bool:
 | 
			
		||||
        return self.is_active and self.is_superuser
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def was_subscribed(self) -> bool:
 | 
			
		||||
        if "is_subscribed" in self.__dict__ and self.is_subscribed:
 | 
			
		||||
            # if the user is currently subscribed, he is an old subscriber too
 | 
			
		||||
            # if the property has already been cached, avoid another request
 | 
			
		||||
            return True
 | 
			
		||||
        return self.subscriptions.exists()
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def is_subscribed(self) -> bool:
 | 
			
		||||
        s = self.subscriptions.filter(
 | 
			
		||||
        if "was_subscribed" in self.__dict__ and not self.was_subscribed:
 | 
			
		||||
            # if the user never subscribed, he cannot be a subscriber now.
 | 
			
		||||
            # if the property has already been cached, avoid another request
 | 
			
		||||
            return False
 | 
			
		||||
        return self.subscriptions.filter(
 | 
			
		||||
            subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
 | 
			
		||||
        )
 | 
			
		||||
        return s.exists()
 | 
			
		||||
        ).exists()
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def account_balance(self):
 | 
			
		||||
@@ -466,26 +386,10 @@ class User(AbstractBaseUser):
 | 
			
		||||
            raise ValueError("You must either provide the id or the name of the group")
 | 
			
		||||
        if group is None:
 | 
			
		||||
            return False
 | 
			
		||||
        if group.id == settings.SITH_GROUP_PUBLIC_ID:
 | 
			
		||||
            return True
 | 
			
		||||
        if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:
 | 
			
		||||
            return self.is_subscribed
 | 
			
		||||
        if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:
 | 
			
		||||
            return self.was_subscribed
 | 
			
		||||
        if group.id == settings.SITH_GROUP_ROOT_ID:
 | 
			
		||||
            return self.is_root
 | 
			
		||||
        if group.is_meta:
 | 
			
		||||
            # check if this group is associated with a club
 | 
			
		||||
            group.__class__ = MetaGroup
 | 
			
		||||
            club = group.associated_club
 | 
			
		||||
            if club is None:
 | 
			
		||||
                return False
 | 
			
		||||
            membership = club.get_membership_for(self)
 | 
			
		||||
            if membership is None:
 | 
			
		||||
                return False
 | 
			
		||||
            if group.name.endswith(settings.SITH_MEMBER_SUFFIX):
 | 
			
		||||
                return True
 | 
			
		||||
            return membership.role > settings.SITH_MAXIMUM_FREE_ROLE
 | 
			
		||||
        return group in self.cached_groups
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
@@ -510,32 +414,8 @@ class User(AbstractBaseUser):
 | 
			
		||||
        return any(g.id == root_id for g in self.cached_groups)
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def is_board_member(self):
 | 
			
		||||
        main_club = settings.SITH_MAIN_CLUB["unix_name"]
 | 
			
		||||
        return self.is_in_group(name=main_club + settings.SITH_BOARD_SUFFIX)
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def can_read_subscription_history(self):
 | 
			
		||||
        if self.is_root or self.is_board_member:
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
        from club.models import Club
 | 
			
		||||
 | 
			
		||||
        for club in Club.objects.filter(
 | 
			
		||||
            id__in=settings.SITH_CAN_READ_SUBSCRIPTION_HISTORY
 | 
			
		||||
        ):
 | 
			
		||||
            if club in self.clubs_with_rights:
 | 
			
		||||
                return True
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def can_create_subscription(self):
 | 
			
		||||
        from club.models import Club
 | 
			
		||||
 | 
			
		||||
        for club in Club.objects.filter(id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS):
 | 
			
		||||
            if club in self.clubs_with_rights:
 | 
			
		||||
                return True
 | 
			
		||||
        return False
 | 
			
		||||
    def is_board_member(self) -> bool:
 | 
			
		||||
        return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def is_launderette_manager(self):
 | 
			
		||||
@@ -550,12 +430,12 @@ class User(AbstractBaseUser):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def is_banned_alcohol(self):
 | 
			
		||||
        return self.is_in_group(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
 | 
			
		||||
    def is_banned_alcohol(self) -> bool:
 | 
			
		||||
        return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def is_banned_counter(self):
 | 
			
		||||
        return self.is_in_group(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
 | 
			
		||||
    def is_banned_counter(self) -> bool:
 | 
			
		||||
        return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_COUNTER_ID).exists()
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def age(self) -> int:
 | 
			
		||||
@@ -599,11 +479,6 @@ class User(AbstractBaseUser):
 | 
			
		||||
            "date_of_birth": self.date_of_birth,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def get_full_name(self):
 | 
			
		||||
        """Returns the first_name plus the last_name, with a space in between."""
 | 
			
		||||
        full_name = "%s %s" % (self.first_name, self.last_name)
 | 
			
		||||
        return full_name.strip()
 | 
			
		||||
 | 
			
		||||
    def get_short_name(self):
 | 
			
		||||
        """Returns the short name for the user."""
 | 
			
		||||
        if self.nick_name:
 | 
			
		||||
@@ -619,14 +494,6 @@ class User(AbstractBaseUser):
 | 
			
		||||
            return "%s (%s)" % (self.get_full_name(), self.nick_name)
 | 
			
		||||
        return self.get_full_name()
 | 
			
		||||
 | 
			
		||||
    def get_age(self):
 | 
			
		||||
        """Returns the age."""
 | 
			
		||||
        today = timezone.now()
 | 
			
		||||
        born = self.date_of_birth
 | 
			
		||||
        return (
 | 
			
		||||
            today.year - born.year - ((today.month, today.day) < (born.month, born.day))
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def get_family(
 | 
			
		||||
        self,
 | 
			
		||||
        godfathers_depth: NonNegativeInt = 4,
 | 
			
		||||
@@ -789,14 +656,6 @@ class AnonymousUser(AuthAnonymousUser):
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        super().__init__()
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def can_create_subscription(self):
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def can_read_subscription_history(self):
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def was_subscribed(self):
 | 
			
		||||
        return False
 | 
			
		||||
@@ -870,6 +729,52 @@ class AnonymousUser(AuthAnonymousUser):
 | 
			
		||||
        return _("Visitor")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserBan(models.Model):
 | 
			
		||||
    """A ban of a user.
 | 
			
		||||
 | 
			
		||||
    A user can be banned for a specific reason, for a specific duration.
 | 
			
		||||
    The expiration date is indicative, and the ban should be removed manually.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    ban_group = models.ForeignKey(
 | 
			
		||||
        BanGroup,
 | 
			
		||||
        verbose_name=_("ban type"),
 | 
			
		||||
        related_name="user_bans",
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
    user = models.ForeignKey(
 | 
			
		||||
        User, verbose_name=_("user"), related_name="bans", on_delete=models.CASCADE
 | 
			
		||||
    )
 | 
			
		||||
    created_at = models.DateTimeField(_("created at"), auto_now_add=True)
 | 
			
		||||
    expires_at = models.DateTimeField(
 | 
			
		||||
        _("expires at"),
 | 
			
		||||
        null=True,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            "When the ban should be removed. "
 | 
			
		||||
            "Currently, there is no automatic removal, so this is purely indicative. "
 | 
			
		||||
            "Automatic ban removal may be implemented later on."
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
    reason = models.TextField(_("reason"))
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("user ban")
 | 
			
		||||
        verbose_name_plural = _("user bans")
 | 
			
		||||
        constraints = [
 | 
			
		||||
            models.UniqueConstraint(
 | 
			
		||||
                fields=["ban_group", "user"], name="unique_ban_type_per_user"
 | 
			
		||||
            ),
 | 
			
		||||
            models.CheckConstraint(
 | 
			
		||||
                check=Q(expires_at__gte=F("created_at")),
 | 
			
		||||
                name="user_ban_end_after_start",
 | 
			
		||||
            ),
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return f"Ban of user {self.user.id}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Preferences(models.Model):
 | 
			
		||||
    user = models.OneToOneField(
 | 
			
		||||
        User, related_name="_preferences", on_delete=models.CASCADE
 | 
			
		||||
@@ -982,19 +887,17 @@ class SithFile(models.Model):
 | 
			
		||||
        if copy_rights:
 | 
			
		||||
            self.copy_rights()
 | 
			
		||||
        if self.is_in_sas:
 | 
			
		||||
            for u in (
 | 
			
		||||
                RealGroup.objects.filter(id=settings.SITH_GROUP_SAS_ADMIN_ID)
 | 
			
		||||
                .first()
 | 
			
		||||
                .users.all()
 | 
			
		||||
            for user in User.objects.filter(
 | 
			
		||||
                groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
 | 
			
		||||
            ):
 | 
			
		||||
                Notification(
 | 
			
		||||
                    user=u,
 | 
			
		||||
                    user=user,
 | 
			
		||||
                    url=reverse("sas:moderation"),
 | 
			
		||||
                    type="SAS_MODERATION",
 | 
			
		||||
                    param="1",
 | 
			
		||||
                ).save()
 | 
			
		||||
 | 
			
		||||
    def is_owned_by(self, user):
 | 
			
		||||
    def is_owned_by(self, user: User) -> bool:
 | 
			
		||||
        if user.is_anonymous:
 | 
			
		||||
            return False
 | 
			
		||||
        if user.is_root:
 | 
			
		||||
@@ -1009,7 +912,7 @@ class SithFile(models.Model):
 | 
			
		||||
            return True
 | 
			
		||||
        return user.id == self.owner_id
 | 
			
		||||
 | 
			
		||||
    def can_be_viewed_by(self, user):
 | 
			
		||||
    def can_be_viewed_by(self, user: User) -> bool:
 | 
			
		||||
        if hasattr(self, "profile_of"):
 | 
			
		||||
            return user.can_view(self.profile_of)
 | 
			
		||||
        if hasattr(self, "avatar_of"):
 | 
			
		||||
@@ -1056,17 +959,11 @@ class SithFile(models.Model):
 | 
			
		||||
        if self.is_folder:
 | 
			
		||||
            if self.file:
 | 
			
		||||
                try:
 | 
			
		||||
                    import imghdr
 | 
			
		||||
 | 
			
		||||
                    if imghdr.what(None, self.file.read()) not in [
 | 
			
		||||
                        "gif",
 | 
			
		||||
                        "png",
 | 
			
		||||
                        "jpeg",
 | 
			
		||||
                    ]:
 | 
			
		||||
                        self.file.delete()
 | 
			
		||||
                        self.file = None
 | 
			
		||||
                except:  # noqa E722 I don't know the exception that can be raised
 | 
			
		||||
                    self.file = None
 | 
			
		||||
                    Image.open(BytesIO(self.file.read()))
 | 
			
		||||
                except Image.UnidentifiedImageError as e:
 | 
			
		||||
                    raise ValidationError(
 | 
			
		||||
                        _("This is not a valid folder thumbnail")
 | 
			
		||||
                    ) from e
 | 
			
		||||
            self.mime_type = "inode/directory"
 | 
			
		||||
        if self.is_file and (self.file is None or self.file == ""):
 | 
			
		||||
            raise ValidationError(_("You must provide a file"))
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ from typing import Annotated
 | 
			
		||||
from annotated_types import MinLen
 | 
			
		||||
from django.contrib.staticfiles.storage import staticfiles_storage
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils.text import slugify
 | 
			
		||||
from haystack.query import SearchQuerySet
 | 
			
		||||
from ninja import FilterSchema, ModelSchema, Schema
 | 
			
		||||
@@ -37,13 +38,13 @@ class UserProfileSchema(ModelSchema):
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def resolve_profile_url(obj: User) -> str:
 | 
			
		||||
        return obj.get_absolute_url()
 | 
			
		||||
        return reverse("core:user_profile", kwargs={"user_id": obj.pk})
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def resolve_profile_pict(obj: User) -> str:
 | 
			
		||||
        if obj.profile_pict_id is None:
 | 
			
		||||
            return staticfiles_storage.url("core/img/unknown.jpg")
 | 
			
		||||
        return obj.profile_pict.get_download_url()
 | 
			
		||||
        return reverse("core:download", kwargs={"file_id": obj.profile_pict_id})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SithFileSchema(ModelSchema):
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,7 @@
 | 
			
		||||
import sort from "@alpinejs/sort";
 | 
			
		||||
import Alpine from "alpinejs";
 | 
			
		||||
 | 
			
		||||
Alpine.plugin(sort);
 | 
			
		||||
window.Alpine = Alpine;
 | 
			
		||||
 | 
			
		||||
window.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
 
 | 
			
		||||
@@ -67,6 +67,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
 | 
			
		||||
        remove_button: {
 | 
			
		||||
          title: gettext("Remove"),
 | 
			
		||||
        },
 | 
			
		||||
        // biome-ignore lint/style/useNamingConvention: this is required by the api
 | 
			
		||||
        restore_on_backspace: {},
 | 
			
		||||
      },
 | 
			
		||||
      persist: false,
 | 
			
		||||
      maxItems: this.node.multiple ? this.max : 1,
 | 
			
		||||
@@ -103,6 +105,12 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
 | 
			
		||||
export abstract class AjaxSelect extends AutoCompleteSelectBase {
 | 
			
		||||
  protected filter?: (items: TomOption[]) => TomOption[] = null;
 | 
			
		||||
  protected minCharNumberForSearch = 2;
 | 
			
		||||
  /**
 | 
			
		||||
   * A cache of researches that have been made using this input.
 | 
			
		||||
   * For each record, the key is the user's query and the value
 | 
			
		||||
   * is the list of results sent back by the server.
 | 
			
		||||
   */
 | 
			
		||||
  protected cache = {} as Record<string, TomOption[]>;
 | 
			
		||||
 | 
			
		||||
  protected abstract valueField: string;
 | 
			
		||||
  protected abstract labelField: string;
 | 
			
		||||
@@ -135,7 +143,13 @@ export abstract class AjaxSelect extends AutoCompleteSelectBase {
 | 
			
		||||
      this.widget.clearOptions();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const resp = await this.search(query);
 | 
			
		||||
    // Check in the cache if this query has already been typed
 | 
			
		||||
    // and do an actual HTTP request only if the result isn't cached
 | 
			
		||||
    let resp = this.cache[query];
 | 
			
		||||
    if (!resp) {
 | 
			
		||||
      resp = await this.search(query);
 | 
			
		||||
      this.cache[query] = resp;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (this.filter) {
 | 
			
		||||
      callback(this.filter(resp), []);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										73
									
								
								core/static/bundled/core/read-more-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								core/static/bundled/core/read-more-index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,73 @@
 | 
			
		||||
import clip from "@arendjr/text-clipper";
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
	This script adds a way to have a 'show more / show less' button
 | 
			
		||||
	on some text content.
 | 
			
		||||
 | 
			
		||||
	The usage is very simple, you just have to add the attribute `show-more`
 | 
			
		||||
	with the desired max size to the element you want to add the button to.
 | 
			
		||||
	This script does html matching and is able to properly cut rendered markdown.
 | 
			
		||||
 | 
			
		||||
	Example usage:
 | 
			
		||||
		<p show-more="20">
 | 
			
		||||
			My very long text will be cut by this script
 | 
			
		||||
		</p>
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
function showMore(element: HTMLElement) {
 | 
			
		||||
  if (!element.hasAttribute("show-more")) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Mark element as loaded so we can hide unloaded
 | 
			
		||||
  // tags with css and avoid blinking text
 | 
			
		||||
  element.setAttribute("show-more-loaded", "");
 | 
			
		||||
 | 
			
		||||
  const fullContent = element.innerHTML;
 | 
			
		||||
  const clippedContent = clip(
 | 
			
		||||
    element.innerHTML,
 | 
			
		||||
    Number.parseInt(element.getAttribute("show-more") as string),
 | 
			
		||||
    {
 | 
			
		||||
      html: true,
 | 
			
		||||
    },
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // If already at the desired size, we don't do anything
 | 
			
		||||
  if (clippedContent === fullContent) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const actionLink = document.createElement("a");
 | 
			
		||||
  actionLink.setAttribute("class", "show-more-link");
 | 
			
		||||
 | 
			
		||||
  let opened = false;
 | 
			
		||||
 | 
			
		||||
  const setText = () => {
 | 
			
		||||
    if (opened) {
 | 
			
		||||
      element.innerHTML = fullContent;
 | 
			
		||||
      actionLink.innerText = gettext("Show less");
 | 
			
		||||
    } else {
 | 
			
		||||
      element.innerHTML = clippedContent;
 | 
			
		||||
      actionLink.innerText = gettext("Show more");
 | 
			
		||||
    }
 | 
			
		||||
    element.appendChild(document.createElement("br"));
 | 
			
		||||
    element.appendChild(actionLink);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const toggle = () => {
 | 
			
		||||
    opened = !opened;
 | 
			
		||||
    setText();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  setText();
 | 
			
		||||
  actionLink.addEventListener("click", (event) => {
 | 
			
		||||
    event.preventDefault();
 | 
			
		||||
    toggle();
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
  for (const elem of document.querySelectorAll("[show-more]")) {
 | 
			
		||||
    showMore(elem as HTMLElement);
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										3
									
								
								core/static/bundled/country-flags-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								core/static/bundled/country-flags-index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
 | 
			
		||||
 | 
			
		||||
polyfillCountryFlagEmojis();
 | 
			
		||||
@@ -1,3 +1,11 @@
 | 
			
		||||
import htmx from "htmx.org";
 | 
			
		||||
 | 
			
		||||
document.body.addEventListener("htmx:beforeRequest", (event) => {
 | 
			
		||||
  event.target.ariaBusy = true;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
document.body.addEventListener("htmx:afterRequest", (event) => {
 | 
			
		||||
  event.originalTarget.ariaBusy = null;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
Object.assign(window, { htmx });
 | 
			
		||||
 
 | 
			
		||||
@@ -1,101 +0,0 @@
 | 
			
		||||
import { paginated } from "#core:utils/api";
 | 
			
		||||
import { HttpReader, ZipWriter } from "@zip.js/zip.js";
 | 
			
		||||
import { showSaveFilePicker } from "native-file-system-adapter";
 | 
			
		||||
import { picturesFetchPictures } from "#openapi";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @typedef UserProfile
 | 
			
		||||
 * @property {number} id
 | 
			
		||||
 * @property {string} first_name
 | 
			
		||||
 * @property {string} last_name
 | 
			
		||||
 * @property {string} nick_name
 | 
			
		||||
 * @property {string} display_name
 | 
			
		||||
 * @property {string} profile_url
 | 
			
		||||
 * @property {string} profile_pict
 | 
			
		||||
 */
 | 
			
		||||
/**
 | 
			
		||||
 * @typedef Picture
 | 
			
		||||
 * @property {number} id
 | 
			
		||||
 * @property {string} name
 | 
			
		||||
 * @property {number} size
 | 
			
		||||
 * @property {string} date
 | 
			
		||||
 * @property {UserProfile} owner
 | 
			
		||||
 * @property {string} full_size_url
 | 
			
		||||
 * @property {string} compressed_url
 | 
			
		||||
 * @property {string} thumb_url
 | 
			
		||||
 * @property {string} album
 | 
			
		||||
 * @property {boolean} is_moderated
 | 
			
		||||
 * @property {boolean} asked_for_removal
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @typedef PicturePageConfig
 | 
			
		||||
 * @property {number} userId Id of the user to get the pictures from
 | 
			
		||||
 **/
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Load user picture page with a nice download bar
 | 
			
		||||
 * @param {PicturePageConfig} config
 | 
			
		||||
 **/
 | 
			
		||||
window.loadPicturePage = (config) => {
 | 
			
		||||
  document.addEventListener("alpine:init", () => {
 | 
			
		||||
    Alpine.data("user_pictures", () => ({
 | 
			
		||||
      isDownloading: false,
 | 
			
		||||
      loading: true,
 | 
			
		||||
      pictures: [],
 | 
			
		||||
      albums: {},
 | 
			
		||||
 | 
			
		||||
      async init() {
 | 
			
		||||
        this.pictures = await paginated(picturesFetchPictures, {
 | 
			
		||||
          // biome-ignore lint/style/useNamingConvention: api is in snake_case
 | 
			
		||||
          query: { users_identified: [config.userId] },
 | 
			
		||||
        });
 | 
			
		||||
        this.albums = this.pictures.reduce((acc, picture) => {
 | 
			
		||||
          if (!acc[picture.album]) {
 | 
			
		||||
            acc[picture.album] = [];
 | 
			
		||||
          }
 | 
			
		||||
          acc[picture.album].push(picture);
 | 
			
		||||
          return acc;
 | 
			
		||||
        }, {});
 | 
			
		||||
        this.loading = false;
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      async downloadZip() {
 | 
			
		||||
        this.isDownloading = true;
 | 
			
		||||
        const bar = this.$refs.progress;
 | 
			
		||||
        bar.value = 0;
 | 
			
		||||
        bar.max = this.pictures.length;
 | 
			
		||||
 | 
			
		||||
        const incrementProgressBar = () => {
 | 
			
		||||
          bar.value++;
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        const fileHandle = await showSaveFilePicker({
 | 
			
		||||
          _preferPolyfill: false,
 | 
			
		||||
          suggestedName: interpolate(
 | 
			
		||||
            gettext("pictures.%(extension)s"),
 | 
			
		||||
            { extension: "zip" },
 | 
			
		||||
            true,
 | 
			
		||||
          ),
 | 
			
		||||
          types: {},
 | 
			
		||||
          excludeAcceptAllOption: false,
 | 
			
		||||
        });
 | 
			
		||||
        const zipWriter = new ZipWriter(await fileHandle.createWritable());
 | 
			
		||||
 | 
			
		||||
        await Promise.all(
 | 
			
		||||
          this.pictures.map((p) => {
 | 
			
		||||
            const imgName = `${p.album}/IMG_${p.date.replaceAll(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
 | 
			
		||||
            return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
 | 
			
		||||
              level: 9,
 | 
			
		||||
              lastModDate: new Date(p.date),
 | 
			
		||||
              onstart: incrementProgressBar,
 | 
			
		||||
            });
 | 
			
		||||
          }),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        await zipWriter.close();
 | 
			
		||||
        this.isDownloading = false;
 | 
			
		||||
      },
 | 
			
		||||
    }));
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
@@ -22,10 +22,13 @@ type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
 | 
			
		||||
 | 
			
		||||
// TODO : If one day a test workflow is made for JS in this project
 | 
			
		||||
//  please test this function. A all cost.
 | 
			
		||||
/**
 | 
			
		||||
 * Load complete dataset from paginated routes.
 | 
			
		||||
 */
 | 
			
		||||
export const paginated = async <T>(
 | 
			
		||||
  endpoint: PaginatedEndpoint<T>,
 | 
			
		||||
  options?: PaginatedRequest,
 | 
			
		||||
) => {
 | 
			
		||||
): Promise<T[]> => {
 | 
			
		||||
  const maxPerPage = 199;
 | 
			
		||||
  const queryParams = options ?? {};
 | 
			
		||||
  queryParams.query = queryParams.query ?? {};
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										49
									
								
								core/static/bundled/utils/csv.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								core/static/bundled/utils/csv.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,49 @@
 | 
			
		||||
import type { NestedKeyOf } from "#core:utils/types";
 | 
			
		||||
 | 
			
		||||
interface StringifyOptions<T extends object> {
 | 
			
		||||
  /** The columns to include in the resulting CSV. */
 | 
			
		||||
  columns: readonly NestedKeyOf<T>[];
 | 
			
		||||
  /** Content of the first row */
 | 
			
		||||
  titleRow?: readonly string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getNested<T extends object>(obj: T, key: NestedKeyOf<T>) {
 | 
			
		||||
  const path: (keyof object)[] = key.split(".") as (keyof unknown)[];
 | 
			
		||||
  let res = obj[path.shift() as keyof T];
 | 
			
		||||
  for (const node of path) {
 | 
			
		||||
    if (res === null) {
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
    res = res[node];
 | 
			
		||||
  }
 | 
			
		||||
  return res;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Convert the content the string to make sure it won't break
 | 
			
		||||
 * the resulting csv.
 | 
			
		||||
 * cf. https://en.wikipedia.org/wiki/Comma-separated_values#Basic_rules
 | 
			
		||||
 */
 | 
			
		||||
function sanitizeCell(content: string): string {
 | 
			
		||||
  return `"${content.replace(/"/g, '""')}"`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const csv = {
 | 
			
		||||
  stringify: <T extends object>(objs: T[], options?: StringifyOptions<T>) => {
 | 
			
		||||
    const columns = options.columns;
 | 
			
		||||
    const content = objs
 | 
			
		||||
      .map((obj) => {
 | 
			
		||||
        return columns
 | 
			
		||||
          .map((col) => {
 | 
			
		||||
            return sanitizeCell((getNested(obj, col) ?? "").toString());
 | 
			
		||||
          })
 | 
			
		||||
          .join(",");
 | 
			
		||||
      })
 | 
			
		||||
      .join("\n");
 | 
			
		||||
    if (!options.titleRow) {
 | 
			
		||||
      return content;
 | 
			
		||||
    }
 | 
			
		||||
    const firstRow = options.titleRow.map(sanitizeCell).join(",");
 | 
			
		||||
    return `${firstRow}\n${content}`;
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										37
									
								
								core/static/bundled/utils/types.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								core/static/bundled/utils/types.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
/**
 | 
			
		||||
 * A key of an object, or of one of its descendants.
 | 
			
		||||
 *
 | 
			
		||||
 * Example :
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * interface Foo {
 | 
			
		||||
 *   foo_inner: number;
 | 
			
		||||
 * }
 | 
			
		||||
 *
 | 
			
		||||
 * interface Bar {
 | 
			
		||||
 *   foo: Foo;
 | 
			
		||||
 * }
 | 
			
		||||
 *
 | 
			
		||||
 * const foo = (key: NestedKeyOf<Bar>) {
 | 
			
		||||
 *     console.log(key);
 | 
			
		||||
 * }
 | 
			
		||||
 *
 | 
			
		||||
 * foo("foo.foo_inner");  // OK
 | 
			
		||||
 * foo("foo.bar"); // FAIL
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export type NestedKeyOf<T extends object> = {
 | 
			
		||||
  [Key in keyof T & (string | number)]: NestedKeyOfHandleValue<T[Key], `${Key}`>;
 | 
			
		||||
}[keyof T & (string | number)];
 | 
			
		||||
 | 
			
		||||
type NestedKeyOfInner<T extends object> = {
 | 
			
		||||
  [Key in keyof T & (string | number)]: NestedKeyOfHandleValue<
 | 
			
		||||
    T[Key],
 | 
			
		||||
    `['${Key}']` | `.${Key}`
 | 
			
		||||
  >;
 | 
			
		||||
}[keyof T & (string | number)];
 | 
			
		||||
 | 
			
		||||
type NestedKeyOfHandleValue<T, Text extends string> = T extends unknown[]
 | 
			
		||||
  ? Text
 | 
			
		||||
  : T extends object
 | 
			
		||||
    ? Text | `${Text}${NestedKeyOfInner<T>}`
 | 
			
		||||
    : Text;
 | 
			
		||||
@@ -6,7 +6,16 @@
 | 
			
		||||
 **/
 | 
			
		||||
export function registerComponent(name: string, options?: ElementDefinitionOptions) {
 | 
			
		||||
  return (component: CustomElementConstructor) => {
 | 
			
		||||
    try {
 | 
			
		||||
      window.customElements.define(name, component, options);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      if (e instanceof DOMException) {
 | 
			
		||||
        // biome-ignore lint/suspicious/noConsole: it's handy to troobleshot
 | 
			
		||||
        console.warn(e.message);
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      throw e;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -24,9 +24,17 @@ $black-color: hsl(0, 0%, 17%);
 | 
			
		||||
 | 
			
		||||
$faceblue: hsl(221, 44%, 41%);
 | 
			
		||||
$twitblue: hsl(206, 82%, 63%);
 | 
			
		||||
$discordblurple: #7289da;
 | 
			
		||||
$instagradient: radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%);
 | 
			
		||||
$githubblack: rgb(22, 22, 20);
 | 
			
		||||
 | 
			
		||||
$shadow-color: rgb(223, 223, 223);
 | 
			
		||||
 | 
			
		||||
$background-button-color: hsl(0, 0%, 95%);
 | 
			
		||||
 | 
			
		||||
$deepblue: #354a5f;
 | 
			
		||||
 | 
			
		||||
@mixin shadow {
 | 
			
		||||
  box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0,
 | 
			
		||||
              rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
 | 
			
		||||
}
 | 
			
		||||
@@ -1,11 +1,27 @@
 | 
			
		||||
.ts-wrapper.multi .ts-control {
 | 
			
		||||
  min-width: calc(100% - 0.2rem);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* This also requires ajax-select-index.css */
 | 
			
		||||
.ts-dropdown {
 | 
			
		||||
  width: calc(100% - 0.2rem);
 | 
			
		||||
  left: 0.1rem;
 | 
			
		||||
  top: calc(100% - 0.2rem - var(--nf-input-border-bottom-width));
 | 
			
		||||
  border: var(--nf-input-border-color) var(--nf-input-border-width) solid;
 | 
			
		||||
  border-top: none;
 | 
			
		||||
  border-bottom-width: var(--nf-input-border-bottom-width);
 | 
			
		||||
 | 
			
		||||
  .option.active {
 | 
			
		||||
    background-color: #e5eafa;
 | 
			
		||||
    color: inherit;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .select-item {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
    gap: 10px;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
 | 
			
		||||
    img {
 | 
			
		||||
      height: 40px;
 | 
			
		||||
@@ -16,19 +32,44 @@
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ts-wrapper {
 | 
			
		||||
  margin: 5px;
 | 
			
		||||
.ts-wrapper.single {
 | 
			
		||||
  > .ts-control {
 | 
			
		||||
    box-shadow: none;
 | 
			
		||||
    max-width: 300px;
 | 
			
		||||
    background-color: var(--nf-input-background-color);
 | 
			
		||||
 | 
			
		||||
    &::after {
 | 
			
		||||
      content: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  > .ts-dropdown {
 | 
			
		||||
    max-width: 300px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ts-wrapper.single {
 | 
			
		||||
  width: 263px; // same length as regular text inputs
 | 
			
		||||
.ts-wrapper input[type="text"] {
 | 
			
		||||
  border: none;
 | 
			
		||||
  border-radius: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ts-wrapper.multi, .ts-wrapper.single {
 | 
			
		||||
  .ts-control:has(input:focus) {
 | 
			
		||||
    outline: none;
 | 
			
		||||
    border-color: var(--nf-input-focus-border-color);
 | 
			
		||||
    box-shadow: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
 | 
			
		||||
  border-left: 1px solid #aaa;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ts-wrapper.multi .ts-control {
 | 
			
		||||
.ts-wrapper.multi.has-items .ts-control {
 | 
			
		||||
  padding: calc(var(--nf-input-size) * 0.65);
 | 
			
		||||
  display: flex;
 | 
			
		||||
  gap: calc(var(--nf-input-size) / 3);
 | 
			
		||||
 | 
			
		||||
  [data-value],
 | 
			
		||||
  [data-value].active {
 | 
			
		||||
    background-image: none;
 | 
			
		||||
@@ -37,19 +78,17 @@
 | 
			
		||||
    border: 1px solid #aaa;
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    margin-left: 5px;
 | 
			
		||||
    margin-top: 5px;
 | 
			
		||||
    margin-bottom: 5px;
 | 
			
		||||
    padding-right: 10px;
 | 
			
		||||
    padding-left: 10px;
 | 
			
		||||
    text-shadow: none;
 | 
			
		||||
    box-shadow: none;
 | 
			
		||||
 | 
			
		||||
    .remove {
 | 
			
		||||
      vertical-align: baseline;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ts-dropdown {
 | 
			
		||||
  .option.active {
 | 
			
		||||
    background-color: #e5eafa;
 | 
			
		||||
    color: inherit;
 | 
			
		||||
  }
 | 
			
		||||
.ts-wrapper.focus .ts-control {
 | 
			
		||||
  box-shadow: none;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										96
									
								
								core/static/core/components/card.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								core/static/core/components/card.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,96 @@
 | 
			
		||||
@import "core/static/core/colors";
 | 
			
		||||
 | 
			
		||||
@mixin row-layout {
 | 
			
		||||
  min-height: 100px;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  max-width: 100%;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  gap: 10px;
 | 
			
		||||
  .card-image {
 | 
			
		||||
    max-width: 75px;
 | 
			
		||||
  }
 | 
			
		||||
  .card-content {
 | 
			
		||||
    flex: 1;
 | 
			
		||||
    text-align: left;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.card {
 | 
			
		||||
  background-color: $primary-neutral-light-color;
 | 
			
		||||
  border-radius: 5px;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  box-sizing: border-box;
 | 
			
		||||
  padding: 20px 10px;
 | 
			
		||||
  height: fit-content;
 | 
			
		||||
  width: 150px;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 20px;
 | 
			
		||||
 | 
			
		||||
  &.clickable:hover {
 | 
			
		||||
    background-color: darken($primary-neutral-light-color, 5%);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.selected {
 | 
			
		||||
    animation: bg-in-out 1s ease;
 | 
			
		||||
    background-color: rgb(216, 236, 255);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .card-image {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    height: 100%;
 | 
			
		||||
    min-height: 70px;
 | 
			
		||||
    max-height: 70px;
 | 
			
		||||
    object-fit: contain;
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    line-height: 70px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  i.card-image {
 | 
			
		||||
    color: black;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    background-color: rgba(173, 173, 173, 0.2);
 | 
			
		||||
    width: 80%;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .card-content {
 | 
			
		||||
    color: black;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    gap: 5px;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
 | 
			
		||||
    p {
 | 
			
		||||
      font-size: 13px;
 | 
			
		||||
      margin: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .card-title {
 | 
			
		||||
      margin: 0;
 | 
			
		||||
      font-size: 15px;
 | 
			
		||||
      word-break: break-word;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @keyframes bg-in-out {
 | 
			
		||||
    0% {
 | 
			
		||||
      background-color: white;
 | 
			
		||||
    }
 | 
			
		||||
    100% {
 | 
			
		||||
      background-color: rgb(216, 236, 255);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media screen and (max-width: 765px) {
 | 
			
		||||
    @include row-layout
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // When combined with card, card-row display the card in a row layout,
 | 
			
		||||
  // whatever the size of the screen.
 | 
			
		||||
  &.card-row {
 | 
			
		||||
    @include row-layout
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										5
									
								
								core/static/core/devices.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								core/static/core/devices.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
			
		||||
/*--------------------------MEDIA QUERY HELPERS------------------------*/
 | 
			
		||||
 | 
			
		||||
$small-devices: 576px;
 | 
			
		||||
$medium-devices: 768px;
 | 
			
		||||
$large-devices: 992px;
 | 
			
		||||
							
								
								
									
										732
									
								
								core/static/core/forms.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										732
									
								
								core/static/core/forms.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,732 @@
 | 
			
		||||
@import "colors";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Style related to forms and form inputs
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Inputs that are not enclosed in a form element.
 | 
			
		||||
 */
 | 
			
		||||
:not(form) {
 | 
			
		||||
  a.button,
 | 
			
		||||
  button,
 | 
			
		||||
  input[type="button"],
 | 
			
		||||
  input[type="submit"],
 | 
			
		||||
  input[type="reset"],
 | 
			
		||||
  input[type="file"] {
 | 
			
		||||
    border: none;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    background-color: $background-button-color;
 | 
			
		||||
    padding: 0.4em;
 | 
			
		||||
    margin: 0.1em;
 | 
			
		||||
    font-size: 1.2em;
 | 
			
		||||
    border-radius: 5px;
 | 
			
		||||
    color: black;
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      background: hsl(0, 0%, 83%);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  a.button,
 | 
			
		||||
  input[type="button"],
 | 
			
		||||
  input[type="submit"],
 | 
			
		||||
  input[type="reset"],
 | 
			
		||||
  input[type="file"] {
 | 
			
		||||
    font-weight: bold;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  a.button:not(:disabled),
 | 
			
		||||
  button:not(:disabled),
 | 
			
		||||
  input[type="button"]:not(:disabled),
 | 
			
		||||
  input[type="submit"]:not(:disabled),
 | 
			
		||||
  input[type="reset"]:not(:disabled),
 | 
			
		||||
  input[type="checkbox"]:not(:disabled),
 | 
			
		||||
  input[type="file"]:not(:disabled) {
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  input,
 | 
			
		||||
  textarea[type="text"],
 | 
			
		||||
  [type="number"],
 | 
			
		||||
  .ts-control {
 | 
			
		||||
    border: none;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    background-color: $background-button-color;
 | 
			
		||||
    padding: 0.4em;
 | 
			
		||||
    margin: 0.1em;
 | 
			
		||||
    font-size: 1.2em;
 | 
			
		||||
    border-radius: 5px;
 | 
			
		||||
    max-width: 95%;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  textarea {
 | 
			
		||||
    border: none;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    background-color: $background-button-color;
 | 
			
		||||
    padding: 7px;
 | 
			
		||||
    font-size: 1.2em;
 | 
			
		||||
    border-radius: 5px;
 | 
			
		||||
    font-family: sans-serif;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  select, .ts-control {
 | 
			
		||||
    border: none;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    font-size: 1.2em;
 | 
			
		||||
    background-color: $background-button-color;
 | 
			
		||||
    padding: 10px;
 | 
			
		||||
    border-radius: 5px;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  a:not(.button) {
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    color: $primary-dark-color;
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      color: $primary-light-color;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:active {
 | 
			
		||||
      color: $primary-color;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
form {
 | 
			
		||||
  // Input size - used for height/padding calculations
 | 
			
		||||
  --nf-input-size: 1rem;
 | 
			
		||||
 | 
			
		||||
  --nf-input-font-size: calc(var(--nf-input-size) * 0.875);
 | 
			
		||||
  --nf-small-font-size: calc(var(--nf-input-size) * 0.875);
 | 
			
		||||
 | 
			
		||||
  // Input
 | 
			
		||||
  --nf-input-color: $text-color;
 | 
			
		||||
  --nf-input-border-radius: 0.25rem;
 | 
			
		||||
  --nf-input-placeholder-color: #929292;
 | 
			
		||||
  --nf-input-border-color: #c0c4c9;
 | 
			
		||||
  --nf-input-border-width: 1px;
 | 
			
		||||
  --nf-input-border-style: solid;
 | 
			
		||||
  --nf-input-border-bottom-width: 2px;
 | 
			
		||||
  --nf-input-focus-border-color: #3b4ce2;
 | 
			
		||||
  --nf-input-background-color: #f3f6f7;
 | 
			
		||||
 | 
			
		||||
  // Valid/invalid
 | 
			
		||||
  --nf-invalid-input-border-color: var(--nf-input-border-color);
 | 
			
		||||
  --nf-invalid-input-background-color: var(--nf-input-background-color);
 | 
			
		||||
  --nf-invalid-input-color: var(--nf-input-color);
 | 
			
		||||
  --nf-valid-input-border-color: var(--nf-input-border-color);
 | 
			
		||||
  --nf-valid-input-background-color: var(--nf-input-background-color);
 | 
			
		||||
  --nf-valid-input-color: inherit;
 | 
			
		||||
  --nf-invalid-input-border-bottom-color: red;
 | 
			
		||||
  --nf-valid-input-border-bottom-color: green;
 | 
			
		||||
 | 
			
		||||
  // Label variables
 | 
			
		||||
  --nf-label-font-size: var(--nf-small-font-size);
 | 
			
		||||
  --nf-label-color: #374151;
 | 
			
		||||
  --nf-label-font-weight: 500;
 | 
			
		||||
 | 
			
		||||
  // Slider variables
 | 
			
		||||
  --nf-slider-track-background: #dfdfdf;
 | 
			
		||||
  --nf-slider-track-height: 0.25rem;
 | 
			
		||||
  --nf-slider-thumb-size: calc(var(--nf-slider-track-height) * 4);
 | 
			
		||||
  --nf-slider-track-border-radius: var(--nf-slider-track-height);
 | 
			
		||||
  --nf-slider-thumb-border-width: 2px;
 | 
			
		||||
  --nf-slider-thumb-border-focus-width: 1px;
 | 
			
		||||
  --nf-slider-thumb-border-color: #ffffff;
 | 
			
		||||
  --nf-slider-thumb-background: var(--nf-input-focus-border-color);
 | 
			
		||||
 | 
			
		||||
  display: block;
 | 
			
		||||
  margin: calc(var(--nf-input-size) * 1.5) auto 10px;
 | 
			
		||||
  line-height: 1;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
 | 
			
		||||
  .helptext {
 | 
			
		||||
    margin-top: .25rem;
 | 
			
		||||
    margin-bottom: .25rem;
 | 
			
		||||
    font-size: 80%;
 | 
			
		||||
    display: block;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  fieldset {
 | 
			
		||||
    margin-bottom: 1rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .row {
 | 
			
		||||
    label {
 | 
			
		||||
      margin: unset;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // ------------- LABEL
 | 
			
		||||
  label, legend {
 | 
			
		||||
    font-weight: var(--nf-label-font-weight);
 | 
			
		||||
    display: block;
 | 
			
		||||
    margin-bottom: calc(var(--nf-input-size) / 2);
 | 
			
		||||
    white-space: initial;
 | 
			
		||||
 | 
			
		||||
    + small {
 | 
			
		||||
      font-style: initial;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.required:after {
 | 
			
		||||
      margin-left: 4px;
 | 
			
		||||
      content: "*";
 | 
			
		||||
      color: red;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // wrap texts
 | 
			
		||||
  label, legend, ul.errorlist > li, .helptext {
 | 
			
		||||
    text-wrap: wrap;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .choose_file_widget {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // ------------- SMALL
 | 
			
		||||
 | 
			
		||||
  small {
 | 
			
		||||
    display: block;
 | 
			
		||||
    font-weight: normal;
 | 
			
		||||
    opacity: 0.75;
 | 
			
		||||
    font-size: var(--nf-small-font-size);
 | 
			
		||||
    margin-bottom: calc(var(--nf-input-size) * 0.75);
 | 
			
		||||
 | 
			
		||||
    &:last-child {
 | 
			
		||||
      margin-bottom: 0;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .form-group,
 | 
			
		||||
  > p,
 | 
			
		||||
  > div {
 | 
			
		||||
    margin-top: calc(var(--nf-input-size) / 2);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // ------------ ERROR LIST
 | 
			
		||||
  ul.errorlist {
 | 
			
		||||
    list-style-type: none;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    opacity: 60%;
 | 
			
		||||
    color: var(--nf-invalid-input-border-bottom-color);
 | 
			
		||||
 | 
			
		||||
    > li {
 | 
			
		||||
      text-align: left;
 | 
			
		||||
      margin-top: 5px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  :not(.ts-control) > {
 | 
			
		||||
    input[type="text"],
 | 
			
		||||
    input[type="email"],
 | 
			
		||||
    input[type="tel"],
 | 
			
		||||
    input[type="url"],
 | 
			
		||||
    input[type="password"],
 | 
			
		||||
    input[type="number"],
 | 
			
		||||
    input[type="date"],
 | 
			
		||||
    input[type="week"],
 | 
			
		||||
    input[type="time"],
 | 
			
		||||
    input[type="search"],
 | 
			
		||||
    textarea,
 | 
			
		||||
    input[type="month"],
 | 
			
		||||
    select {
 | 
			
		||||
      min-width: 300px;
 | 
			
		||||
 | 
			
		||||
      &.grow {
 | 
			
		||||
        width: 95%;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  input[type="text"],
 | 
			
		||||
  input[type="checkbox"],
 | 
			
		||||
  input[type="radio"],
 | 
			
		||||
  input[type="email"],
 | 
			
		||||
  input[type="tel"],
 | 
			
		||||
  input[type="url"],
 | 
			
		||||
  input[type="password"],
 | 
			
		||||
  input[type="number"],
 | 
			
		||||
  input[type="date"],
 | 
			
		||||
  input[type="datetime-local"],
 | 
			
		||||
  input[type="week"],
 | 
			
		||||
  input[type="time"],
 | 
			
		||||
  input[type="month"],
 | 
			
		||||
  input[type="search"],
 | 
			
		||||
  textarea,
 | 
			
		||||
  select,
 | 
			
		||||
  .ts-control {
 | 
			
		||||
    background: var(--nf-input-background-color);
 | 
			
		||||
    font-size: var(--nf-input-font-size);
 | 
			
		||||
    border-color: var(--nf-input-border-color);
 | 
			
		||||
    border-width: var(--nf-input-border-width);
 | 
			
		||||
    border-style: var(--nf-input-border-style);
 | 
			
		||||
    box-shadow: none;
 | 
			
		||||
    border-radius: var(--nf-input-border-radius);
 | 
			
		||||
    border-bottom-width: var(--nf-input-border-bottom-width);
 | 
			
		||||
    color: var(--nf-input-color);
 | 
			
		||||
    max-width: 95%;
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    padding: calc(var(--nf-input-size) * 0.65);
 | 
			
		||||
    line-height: normal;
 | 
			
		||||
    appearance: none;
 | 
			
		||||
    transition: all 0.15s ease-out;
 | 
			
		||||
 | 
			
		||||
    // ------------- VALID/INVALID
 | 
			
		||||
 | 
			
		||||
    &.error {
 | 
			
		||||
      &:not(:placeholder-shown):invalid {
 | 
			
		||||
        background-color: var(--nf-invalid-input-background-color);
 | 
			
		||||
        border-color: var(--nf-valid-input-border-color);
 | 
			
		||||
        border-bottom-color: var(--nf-invalid-input-border-bottom-color);
 | 
			
		||||
        color: var(--nf-invalid-input-color);
 | 
			
		||||
 | 
			
		||||
        // Reset to default when focus
 | 
			
		||||
 | 
			
		||||
        &:focus {
 | 
			
		||||
          background-color: var(--nf-input-background-color);
 | 
			
		||||
          border-color: var(--nf-input-border-color);
 | 
			
		||||
          color: var(--nf-input-color);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &:not(:placeholder-shown):valid {
 | 
			
		||||
        background-color: var(--nf-valid-input-background-color);
 | 
			
		||||
        border-color: var(--nf-valid-input-border-color);
 | 
			
		||||
        border-bottom-color: var(--nf-valid-input-border-bottom-color);
 | 
			
		||||
        color: var(--nf-valid-input-color);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // ------------- DISABLED
 | 
			
		||||
 | 
			
		||||
    &:disabled {
 | 
			
		||||
      cursor: not-allowed;
 | 
			
		||||
      opacity: 0.75;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // -------- PLACEHOLDERS
 | 
			
		||||
 | 
			
		||||
    &::-webkit-input-placeholder {
 | 
			
		||||
      color: var(--nf-input-placeholder-color);
 | 
			
		||||
      letter-spacing: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:-ms-input-placeholder {
 | 
			
		||||
      color: var(--nf-input-placeholder-color);
 | 
			
		||||
      letter-spacing: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &::-moz-placeholder {
 | 
			
		||||
      color: var(--nf-input-placeholder-color);
 | 
			
		||||
      letter-spacing: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:-moz-placeholder {
 | 
			
		||||
      color: var(--nf-input-placeholder-color);
 | 
			
		||||
      letter-spacing: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // -------- FOCUS
 | 
			
		||||
 | 
			
		||||
    &:focus {
 | 
			
		||||
      outline: none;
 | 
			
		||||
      border-color: var(--nf-input-focus-border-color);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // -------- ADDITIONAL TEXT BENEATH INPUT FIELDS
 | 
			
		||||
 | 
			
		||||
    + small {
 | 
			
		||||
      margin-top: 0.5rem;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // -------- ICONS
 | 
			
		||||
 | 
			
		||||
    --icon-padding: calc(var(--nf-input-size) * 2.25);
 | 
			
		||||
    --icon-background-offset: calc(var(--nf-input-size) * 0.75);
 | 
			
		||||
 | 
			
		||||
    &.icon-left {
 | 
			
		||||
      background-position: left var(--icon-background-offset) bottom 50%;
 | 
			
		||||
      padding-left: var(--icon-padding);
 | 
			
		||||
      background-size: var(--nf-input-size);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.icon-right {
 | 
			
		||||
      background-position: right var(--icon-background-offset) bottom 50%;
 | 
			
		||||
      padding-right: var(--icon-padding);
 | 
			
		||||
      background-size: var(--nf-input-size);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // When a field has a icon and is autofilled, the background image is removed
 | 
			
		||||
    // by the browser. To negate this we reset the padding, not great but okay
 | 
			
		||||
 | 
			
		||||
    &:-webkit-autofill {
 | 
			
		||||
      padding: calc(var(--nf-input-size) * 0.75) !important;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // -------- SEARCH
 | 
			
		||||
 | 
			
		||||
  input[type="search"] {
 | 
			
		||||
    &:placeholder-shown {
 | 
			
		||||
      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280'  stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-search'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E");
 | 
			
		||||
      background-position: left calc(var(--nf-input-size) * 0.75) bottom 50%;
 | 
			
		||||
      padding-left: calc(var(--nf-input-size) * 2.25);
 | 
			
		||||
      background-size: var(--nf-input-size);
 | 
			
		||||
      background-repeat: no-repeat;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &::-webkit-search-cancel-button {
 | 
			
		||||
      -webkit-appearance: none;
 | 
			
		||||
      width: var(--nf-input-size);
 | 
			
		||||
      height: var(--nf-input-size);
 | 
			
		||||
      background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280'  stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-x'%3E%3Cline x1='18' y1='6' x2='6' y2='18'/%3E%3Cline x1='6' y1='6' x2='18' y2='18'/%3E%3C/svg%3E");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:focus {
 | 
			
		||||
      padding-left: calc(var(--nf-input-size) * 0.75);
 | 
			
		||||
      background-position: left calc(var(--nf-input-size) * -1) bottom 50%;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // -------- EMAIL
 | 
			
		||||
 | 
			
		||||
  input[type="email"][class^="icon"] {
 | 
			
		||||
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280'  stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-at-sign'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94'/%3E%3C/svg%3E");
 | 
			
		||||
    background-repeat: no-repeat;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // -------- TEL
 | 
			
		||||
 | 
			
		||||
  input[type="tel"][class^="icon"] {
 | 
			
		||||
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280'  stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-phone'%3E%3Cpath d='M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z'/%3E%3C/svg%3E");
 | 
			
		||||
    background-repeat: no-repeat;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // -------- URL
 | 
			
		||||
 | 
			
		||||
  input[type="url"][class^="icon"] {
 | 
			
		||||
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280'  stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-link'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'/%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'/%3E%3C/svg%3E");
 | 
			
		||||
    background-repeat: no-repeat;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // -------- PASSWORD
 | 
			
		||||
 | 
			
		||||
  input[type="password"] {
 | 
			
		||||
    letter-spacing: 2px;
 | 
			
		||||
 | 
			
		||||
    &[class^="icon"] {
 | 
			
		||||
      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280'  stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-lock'%3E%3Crect x='3' y='11' width='18' height='11' rx='2' ry='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E");
 | 
			
		||||
      background-repeat: no-repeat;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // -------- RANGE
 | 
			
		||||
 | 
			
		||||
  input[type="range"] {
 | 
			
		||||
    -webkit-appearance: none;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
 | 
			
		||||
    &:focus {
 | 
			
		||||
      outline: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // NOTE: for some reason grouping these doesn't work (just like :placeholder)
 | 
			
		||||
 | 
			
		||||
    @mixin track {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      height: var(--nf-slider-track-height);
 | 
			
		||||
      background: var(--nf-slider-track-background);
 | 
			
		||||
      border-radius: var(--nf-slider-track-border-radius);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @mixin thumb {
 | 
			
		||||
      height: var(--nf-slider-thumb-size);
 | 
			
		||||
      width: var(--nf-slider-thumb-size);
 | 
			
		||||
      border-radius: var(--nf-slider-thumb-size);
 | 
			
		||||
      background: var(--nf-slider-thumb-background);
 | 
			
		||||
      border: 0;
 | 
			
		||||
      border: var(--nf-slider-thumb-border-width) solid var(--nf-slider-thumb-border-color);
 | 
			
		||||
      appearance: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @mixin thumb-focus {
 | 
			
		||||
      box-shadow: 0 0 0 var(--nf-slider-thumb-border-focus-width) var(--nf-slider-thumb-background);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &::-webkit-slider-runnable-track {
 | 
			
		||||
      @include track;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &::-moz-range-track {
 | 
			
		||||
      @include track;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &::-webkit-slider-thumb {
 | 
			
		||||
      @include thumb;
 | 
			
		||||
      margin-top: calc(
 | 
			
		||||
        (
 | 
			
		||||
          calc(var(--nf-slider-track-height) - var(--nf-slider-thumb-size)) *
 | 
			
		||||
          0.5
 | 
			
		||||
        )
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &::-moz-range-thumb {
 | 
			
		||||
      @include thumb;
 | 
			
		||||
      box-sizing: border-box;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:focus::-webkit-slider-thumb {
 | 
			
		||||
      @include thumb-focus;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:focus::-moz-range-thumb {
 | 
			
		||||
      @include thumb-focus;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // -------- COLOR
 | 
			
		||||
 | 
			
		||||
  input[type="color"] {
 | 
			
		||||
    border: var(--nf-input-border-width) solid var(--nf-input-border-color);
 | 
			
		||||
    border-bottom-width: var(--nf-input-border-bottom-width);
 | 
			
		||||
    height: calc(var(--nf-input-size) * 2);
 | 
			
		||||
    border-radius: var(--nf-input-border-radius);
 | 
			
		||||
    padding: calc(var(--nf-input-border-width) * 2);
 | 
			
		||||
 | 
			
		||||
    &:focus {
 | 
			
		||||
      outline: none;
 | 
			
		||||
      border-color: var(--nf-input-focus-border-color);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &::-webkit-color-swatch-wrapper {
 | 
			
		||||
      padding: 5%;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @mixin swatch {
 | 
			
		||||
      border-radius: calc(var(--nf-input-border-radius) / 2);
 | 
			
		||||
      border: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &::-moz-color-swatch {
 | 
			
		||||
      @include swatch;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &::-webkit-color-swatch {
 | 
			
		||||
      @include swatch;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // --------------- NUMBER
 | 
			
		||||
 | 
			
		||||
  input[type="number"] {
 | 
			
		||||
    width: auto;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // --------------- DATES
 | 
			
		||||
 | 
			
		||||
  input[type="date"],
 | 
			
		||||
  input[type="datetime-local"],
 | 
			
		||||
  input[type="week"],
 | 
			
		||||
  input[type="month"] {
 | 
			
		||||
    min-width: 300px;
 | 
			
		||||
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280'  stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-calendar'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'/%3E%3Cline x1='16' y1='2' x2='16' y2='6'/%3E%3Cline x1='8' y1='2' x2='8' y2='6'/%3E%3Cline x1='3' y1='10' x2='21' y2='10'/%3E%3C/svg%3E");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  input[type="time"] {
 | 
			
		||||
    min-width: 6em;
 | 
			
		||||
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280'  stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-clock'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpolyline points='12 6 12 12 16 14'/%3E%3C/svg%3E");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  input[type="date"],
 | 
			
		||||
  input[type="datetime-local"],
 | 
			
		||||
  input[type="week"],
 | 
			
		||||
  input[type="time"],
 | 
			
		||||
  input[type="month"] {
 | 
			
		||||
    background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%;
 | 
			
		||||
    background-repeat: no-repeat;
 | 
			
		||||
    background-size: var(--nf-input-size);
 | 
			
		||||
 | 
			
		||||
    &::-webkit-inner-spin-button,
 | 
			
		||||
    &::-webkit-calendar-picker-indicator {
 | 
			
		||||
      -webkit-appearance: none;
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
      opacity: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // FireFox reset
 | 
			
		||||
    // FF has restricted control of styling the date/time inputs.
 | 
			
		||||
    // That's why we don't show icons for FF users, and leave basic styling in place.
 | 
			
		||||
    @-moz-document url-prefix() {
 | 
			
		||||
      min-width: auto;
 | 
			
		||||
      width: auto;
 | 
			
		||||
      background-image: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // --------------- TEXAREA
 | 
			
		||||
 | 
			
		||||
  textarea {
 | 
			
		||||
    height: auto;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // --------------- CHECKBOX/RADIO
 | 
			
		||||
 | 
			
		||||
  input[type="checkbox"],
 | 
			
		||||
  input[type="radio"] {
 | 
			
		||||
    width: var(--nf-input-size);
 | 
			
		||||
    height: var(--nf-input-size);
 | 
			
		||||
    padding: inherit;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    vertical-align: top;
 | 
			
		||||
    border-radius: calc(var(--nf-input-border-radius) / 2);
 | 
			
		||||
    border-width: var(--nf-input-border-width);
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    background-position: center center;
 | 
			
		||||
 | 
			
		||||
    &:focus:not(:checked) {
 | 
			
		||||
      border: var(--nf-input-border-width) solid var(--nf-input-focus-border-color);
 | 
			
		||||
      outline: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      border: var(--nf-input-border-width) solid var(--nf-input-focus-border-color);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    + label {
 | 
			
		||||
      display: inline-block;
 | 
			
		||||
      margin-bottom: 0;
 | 
			
		||||
      padding-left: calc(var(--nf-input-size) / 2.5);
 | 
			
		||||
      font-weight: normal;
 | 
			
		||||
      user-select: none;
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
      max-width: calc(100% - calc(var(--nf-input-size) * 2));
 | 
			
		||||
      line-height: normal;
 | 
			
		||||
 | 
			
		||||
      > small {
 | 
			
		||||
        margin-top: calc(var(--nf-input-size) / 4);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  input[type="checkbox"] {
 | 
			
		||||
    &:checked {
 | 
			
		||||
      background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23FFFFFF' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' class='feather feather-check'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") no-repeat center center/85%;
 | 
			
		||||
      background-color: var(--nf-input-focus-border-color);
 | 
			
		||||
      border-color: var(--nf-input-focus-border-color);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  input[type="radio"] {
 | 
			
		||||
    border-radius: 100%;
 | 
			
		||||
 | 
			
		||||
    &:checked {
 | 
			
		||||
      background-color: var(--nf-input-focus-border-color);
 | 
			
		||||
      border-color: var(--nf-input-focus-border-color);
 | 
			
		||||
      box-shadow: 0 0 0 3px white inset;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // --------------- SWITCH
 | 
			
		||||
 | 
			
		||||
  --switch-orb-size: var(--nf-input-size);
 | 
			
		||||
  --switch-orb-offset: calc(var(--nf-input-border-width) * 2);
 | 
			
		||||
  --switch-width: calc(var(--nf-input-size) * 2.5);
 | 
			
		||||
  --switch-height: calc(
 | 
			
		||||
    calc(var(--nf-input-size) * 1.25) + var(--switch-orb-offset)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  input[type="checkbox"].switch {
 | 
			
		||||
    width: var(--switch-width);
 | 
			
		||||
    height: var(--switch-height);
 | 
			
		||||
    border-radius: var(--switch-height);
 | 
			
		||||
    position: relative;
 | 
			
		||||
 | 
			
		||||
    &::after {
 | 
			
		||||
      background: var(--nf-input-border-color);
 | 
			
		||||
      border-radius: var(--switch-orb-size);
 | 
			
		||||
      height: var(--switch-orb-size);
 | 
			
		||||
      left: var(--switch-orb-offset);
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      top: 50%;
 | 
			
		||||
      transform: translateY(-50%);
 | 
			
		||||
      width: var(--switch-orb-size);
 | 
			
		||||
      content: "";
 | 
			
		||||
      transition: all 0.2s ease-out;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    + label {
 | 
			
		||||
      margin-top: calc(var(--switch-height) / 8);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:checked {
 | 
			
		||||
      background: none;
 | 
			
		||||
      background-position: 0 0;
 | 
			
		||||
      background-color: var(--nf-input-focus-border-color);
 | 
			
		||||
 | 
			
		||||
      &::after {
 | 
			
		||||
        transform: translateY(-50%) translateX(
 | 
			
		||||
          calc(calc(var(--switch-width) / 2) - var(--switch-orb-offset))
 | 
			
		||||
        );
 | 
			
		||||
        background: white;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // ---------------- FILE
 | 
			
		||||
 | 
			
		||||
  input[type="file"] {
 | 
			
		||||
    background: rgba(0, 0, 0, 0.025);
 | 
			
		||||
    padding: calc(var(--nf-input-size) / 2);
 | 
			
		||||
    display: block;
 | 
			
		||||
    font-weight: normal;
 | 
			
		||||
    width: 95%;
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    border-radius: var(--nf-input-border-radius);
 | 
			
		||||
    border: 1px dashed var(--nf-input-border-color);
 | 
			
		||||
    outline: none;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
 | 
			
		||||
    &:focus,
 | 
			
		||||
    &:hover {
 | 
			
		||||
      border-color: var(--nf-input-focus-border-color);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @mixin button {
 | 
			
		||||
      background: var(--nf-input-focus-border-color);
 | 
			
		||||
      border: 0;
 | 
			
		||||
      appearance: none;
 | 
			
		||||
      border-radius: var(--nf-input-border-radius);
 | 
			
		||||
      color: white;
 | 
			
		||||
      margin-right: 0.75rem;
 | 
			
		||||
      outline: none;
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &::file-selector-button {
 | 
			
		||||
      @include button();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &::-webkit-file-upload-button {
 | 
			
		||||
      @include button();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // ---------------- SELECT
 | 
			
		||||
 | 
			
		||||
  select,
 | 
			
		||||
  .ts-wrapper.multi .ts-control,
 | 
			
		||||
  .ts-wrapper.single .ts-control,
 | 
			
		||||
  .ts-wrapper.single.input-active .ts-control {
 | 
			
		||||
    background-color: var(--nf-input-background-color);
 | 
			
		||||
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280'  stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-chevron-down'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
 | 
			
		||||
    background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%;
 | 
			
		||||
    background-repeat: no-repeat;
 | 
			
		||||
    background-size: var(--nf-input-size);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -33,8 +33,8 @@ $hovered-red-text-color: #ff4d4d;
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
    gap: 10px;
 | 
			
		||||
 | 
			
		||||
    >a {
 | 
			
		||||
      color: $text-color;
 | 
			
		||||
    > a {
 | 
			
		||||
      color: $text-color!important;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:hover>a {
 | 
			
		||||
@@ -106,6 +106,7 @@ $hovered-red-text-color: #ff4d4d;
 | 
			
		||||
        color: $text-color;
 | 
			
		||||
        font-weight: normal;
 | 
			
		||||
        line-height: 1.3em;
 | 
			
		||||
        font-family: "Twemoji Country Flags", sans-serif;
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          background-color: $background-color-hovered;
 | 
			
		||||
@@ -395,9 +396,9 @@ $hovered-red-text-color: #ff4d4d;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        >input[type=text] {
 | 
			
		||||
          box-sizing: border-box;
 | 
			
		||||
          max-width: 100%;
 | 
			
		||||
          width: 100%;
 | 
			
		||||
          min-width: unset;
 | 
			
		||||
          border: unset;
 | 
			
		||||
          height: 35px;
 | 
			
		||||
          border-radius: 5px;
 | 
			
		||||
          font-size: .9em;
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,3 +1,5 @@
 | 
			
		||||
@import "core/static/core/colors";
 | 
			
		||||
 | 
			
		||||
main {
 | 
			
		||||
  box-sizing: border-box;
 | 
			
		||||
  display: flex;
 | 
			
		||||
@@ -69,7 +71,7 @@ main {
 | 
			
		||||
        border-radius: 50%;
 | 
			
		||||
        justify-content: center;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
        background-color: #f2f2f2;
 | 
			
		||||
        background-color: $primary-neutral-light-color;
 | 
			
		||||
 | 
			
		||||
        > span {
 | 
			
		||||
          font-size: small;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,26 +1,9 @@
 | 
			
		||||
 | 
			
		||||
@media (max-width: 750px) {
 | 
			
		||||
  .title {
 | 
			
		||||
    text-align: center;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.field-error {
 | 
			
		||||
  height: auto !important;
 | 
			
		||||
 | 
			
		||||
  > ul {
 | 
			
		||||
    list-style-type: none;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    color: indianred;
 | 
			
		||||
 | 
			
		||||
    > li {
 | 
			
		||||
      text-align: left !important;
 | 
			
		||||
      line-height: normal;
 | 
			
		||||
      margin-top: 5px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile {
 | 
			
		||||
  &-visible {
 | 
			
		||||
    display: flex;
 | 
			
		||||
@@ -87,11 +70,7 @@
 | 
			
		||||
        max-height: 100%;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      > i {
 | 
			
		||||
        font-size: 32px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      >p {
 | 
			
		||||
      > p {
 | 
			
		||||
        text-align: left !important;
 | 
			
		||||
        width: 100% !important;
 | 
			
		||||
      }
 | 
			
		||||
@@ -107,16 +86,6 @@
 | 
			
		||||
      > div {
 | 
			
		||||
        max-width: 100%;
 | 
			
		||||
 | 
			
		||||
        > input {
 | 
			
		||||
          font-weight: normal;
 | 
			
		||||
          cursor: pointer;
 | 
			
		||||
          text-align: left !important;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        > button {
 | 
			
		||||
          min-width: 30%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        @media (min-width: 750px) {
 | 
			
		||||
          height: auto;
 | 
			
		||||
          align-items: center;
 | 
			
		||||
@@ -124,8 +93,8 @@
 | 
			
		||||
          overflow: hidden;
 | 
			
		||||
 | 
			
		||||
          > input {
 | 
			
		||||
            width: 70%;
 | 
			
		||||
            font-size: .6em;
 | 
			
		||||
 | 
			
		||||
            &::file-selector-button {
 | 
			
		||||
              height: 30px;
 | 
			
		||||
            }
 | 
			
		||||
@@ -167,7 +136,7 @@
 | 
			
		||||
      max-width: 100%;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    >* {
 | 
			
		||||
    > * {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      max-width: 300px;
 | 
			
		||||
 | 
			
		||||
@@ -181,45 +150,22 @@
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &-content {
 | 
			
		||||
 | 
			
		||||
      >* {
 | 
			
		||||
      > * {
 | 
			
		||||
        box-sizing: border-box;
 | 
			
		||||
        text-align: left !important;
 | 
			
		||||
        line-height: 40px;
 | 
			
		||||
        max-width: 100%;
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        height: 40px;
 | 
			
		||||
        margin: 0;
 | 
			
		||||
 | 
			
		||||
        >* {
 | 
			
		||||
        > * {
 | 
			
		||||
          text-align: left !important;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
      >textarea {
 | 
			
		||||
        height: 120px;
 | 
			
		||||
        min-height: 40px;
 | 
			
		||||
        min-width: 300px;
 | 
			
		||||
        max-width: 300px;
 | 
			
		||||
        line-height: initial;
 | 
			
		||||
 | 
			
		||||
        @media (max-width: 750px) {
 | 
			
		||||
          max-width: 100%;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
      >input[type="file"] {
 | 
			
		||||
        font-size: small;
 | 
			
		||||
        line-height: 30px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      >input[type="checkbox"] {
 | 
			
		||||
        width: 20px;
 | 
			
		||||
        height: 20px;
 | 
			
		||||
        margin: 0;
 | 
			
		||||
        float: left;
 | 
			
		||||
    textarea {
 | 
			
		||||
      height: 7rem;
 | 
			
		||||
    }
 | 
			
		||||
    .final-actions {
 | 
			
		||||
      text-align: center;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -23,6 +23,7 @@
 | 
			
		||||
      <script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
 | 
			
		||||
      <script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
 | 
			
		||||
      <script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
 | 
			
		||||
      <script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
 | 
			
		||||
 | 
			
		||||
      <!-- Jquery declared here to be accessible in every django widgets -->
 | 
			
		||||
      <script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
 | 
			
		||||
@@ -108,7 +109,8 @@
 | 
			
		||||
            <a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
 | 
			
		||||
          </div>
 | 
			
		||||
          <a href="https://discord.gg/XK9WfPsUFm" target="_link">
 | 
			
		||||
          <a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
 | 
			
		||||
            <i class="fa-brands fa-github"></i>
 | 
			
		||||
            {% trans %}Site created by the IT Department of the AE{% endtrans %}
 | 
			
		||||
          </a>
 | 
			
		||||
        {% endblock %}
 | 
			
		||||
@@ -124,15 +126,14 @@
 | 
			
		||||
          navbar.style.setProperty("display", current === "none" ? "block" : "none");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $(document).keydown(function (e) {
 | 
			
		||||
          if ($(e.target).is('input')) { return }
 | 
			
		||||
          if ($(e.target).is('textarea')) { return }
 | 
			
		||||
          if ($(e.target).is('select')) { return }
 | 
			
		||||
          if (e.keyCode === 83) {
 | 
			
		||||
            $("#search").focus();
 | 
			
		||||
            return false;
 | 
			
		||||
        document.addEventListener("keydown", (e) => {
 | 
			
		||||
          // Looking at the `s` key when not typing in a form
 | 
			
		||||
          if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
          document.getElementById("search").focus();
 | 
			
		||||
          e.preventDefault(); // Don't type the character in the focused search input
 | 
			
		||||
        })
 | 
			
		||||
      </script>
 | 
			
		||||
    {% endblock %}
 | 
			
		||||
  </body>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,19 +1,40 @@
 | 
			
		||||
{% extends "core/base.jinja" %}
 | 
			
		||||
 | 
			
		||||
{# if the template context has the `object_name` variable,
 | 
			
		||||
   then this one will be used in the page title,
 | 
			
		||||
   instead of the result of `str(object)` #}
 | 
			
		||||
{% if object and not object_name %}
 | 
			
		||||
  {% set object_name=object %}
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
  {% if object %}
 | 
			
		||||
    {% trans obj=object %}Edit {{ obj }}{% endtrans %}
 | 
			
		||||
  {% if object_name %}
 | 
			
		||||
    {% trans name=object_name %}Edit {{ name }}{% endtrans %}
 | 
			
		||||
  {% else %}
 | 
			
		||||
    {% trans %}Save{% endtrans %}
 | 
			
		||||
  {% endif %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  {% if object %}
 | 
			
		||||
    <h2>{% trans obj=object %}Edit {{ obj }}{% endtrans %}</h2>
 | 
			
		||||
  {% if object_name %}
 | 
			
		||||
    <h2>{% trans name=object_name %}Edit {{ name }}{% endtrans %}</h2>
 | 
			
		||||
  {% else %}
 | 
			
		||||
    <h2>{% trans %}Save{% endtrans %}</h2>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  {% if messages %}
 | 
			
		||||
    <div x-data="{show_alert: true}" class="alert alert-green" x-show="show_alert" x-transition>
 | 
			
		||||
      <span class="alert-main">
 | 
			
		||||
        {% for message in messages %}
 | 
			
		||||
          {% if message.level_tag == "success" %}
 | 
			
		||||
            {{ message }}
 | 
			
		||||
          {% endif %}
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
      </span>
 | 
			
		||||
      <span class="clickable" @click="show_alert = false">
 | 
			
		||||
        <i class="fa fa-close"></i>
 | 
			
		||||
      </span>
 | 
			
		||||
    </div>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  <form action="" method="post" enctype="multipart/form-data">
 | 
			
		||||
    {% csrf_token %}
 | 
			
		||||
    {{ form.as_p() }}
 | 
			
		||||
 
 | 
			
		||||
@@ -57,13 +57,4 @@
 | 
			
		||||
    {% endblock %}
 | 
			
		||||
  {% endif %}
 | 
			
		||||
 | 
			
		||||
  {% block script %}
 | 
			
		||||
    {{ super() }}
 | 
			
		||||
    {% if popup %}
 | 
			
		||||
      <script>
 | 
			
		||||
        parent.$(".choose_file_widget").css("height", "75%");
 | 
			
		||||
      </script>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
  {% endblock %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -39,14 +39,6 @@
 | 
			
		||||
  <a rel="nofollow" target="#" class="share_button twitter" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}">{% trans %}Tweet{% endtrans %}</a>
 | 
			
		||||
{%- endmacro %}
 | 
			
		||||
 | 
			
		||||
{% macro fb_quick(news) -%}
 | 
			
		||||
  <a rel="nofollow" target="#" href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}" class="fb fa-brands fa-facebook fa-2x"></a>
 | 
			
		||||
{%- endmacro %}
 | 
			
		||||
 | 
			
		||||
{% macro tweet_quick(news) -%}
 | 
			
		||||
  <a rel="nofollow" target="#" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}" class="twitter fa-brands fa-twitter-square fa-2x"></a>
 | 
			
		||||
{%- endmacro %}
 | 
			
		||||
 | 
			
		||||
{% macro user_mini_profile(user) %}
 | 
			
		||||
  <div class="user_mini_profile">
 | 
			
		||||
    <div class="user_mini_profile_infos">
 | 
			
		||||
@@ -60,13 +52,18 @@
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if user.date_of_birth %}
 | 
			
		||||
          <div class="user_mini_profile_dob">
 | 
			
		||||
            {{ user.date_of_birth|date("d/m/Y") }} ({{ user.get_age() }})
 | 
			
		||||
            {{ user.date_of_birth|date("d/m/Y") }} ({{ user.age }})
 | 
			
		||||
          </div>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </div>
 | 
			
		||||
      {% if user.promo and user.promo_has_logo() %}
 | 
			
		||||
        <div class="user_mini_profile_promo">
 | 
			
		||||
          <img src="{{ static('core/img/promo_%02d.png' % user.promo) }}" title="Promo {{ user.promo }}" alt="Promo {{ user.promo }}" class="promo_pict" />
 | 
			
		||||
          <img
 | 
			
		||||
            src="{{ static('core/img/promo_%02d.png' % user.promo) }}"
 | 
			
		||||
            title="Promo {{ user.promo }}"
 | 
			
		||||
            alt="Promo {{ user.promo }}"
 | 
			
		||||
            class="promo_pict"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      {% endif %}
 | 
			
		||||
    </div>
 | 
			
		||||
@@ -74,8 +71,11 @@
 | 
			
		||||
      {% if user.profile_pict %}
 | 
			
		||||
        <img src="{{ user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}" />
 | 
			
		||||
      {% else %}
 | 
			
		||||
        <img src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}"
 | 
			
		||||
             title="{% trans %}Profile{% endtrans %}" />
 | 
			
		||||
        <img
 | 
			
		||||
          src="{{ static('core/img/unknown.jpg') }}"
 | 
			
		||||
          alt="{% trans %}Profile{% endtrans %}"
 | 
			
		||||
          title="{% trans %}Profile{% endtrans %}"
 | 
			
		||||
        />
 | 
			
		||||
      {% endif %}
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
@@ -132,7 +132,7 @@
 | 
			
		||||
        nb_page (str): call to a javascript function or variable returning
 | 
			
		||||
            the maximum number of pages to paginate
 | 
			
		||||
    #}
 | 
			
		||||
  <nav class="pagination" x-show="{{ nb_pages }} > 1">
 | 
			
		||||
  <nav class="pagination" x-show="{{ nb_pages }} > 1" x-cloak>
 | 
			
		||||
      {# Adding the prevent here is important, because otherwise,
 | 
			
		||||
      clicking on the pagination buttons could submit the picture management form
 | 
			
		||||
      and reload the page #}
 | 
			
		||||
@@ -170,12 +170,12 @@
 | 
			
		||||
{% endmacro %}
 | 
			
		||||
 | 
			
		||||
{% macro paginate_htmx(current_page, paginator) %}
 | 
			
		||||
    {# Add pagination buttons for pages without Alpine but supporting framgents.
 | 
			
		||||
    {# Add pagination buttons for pages without Alpine but supporting fragments.
 | 
			
		||||
 | 
			
		||||
    This must be coupled with a view that handles pagination
 | 
			
		||||
    with the Django Paginator object and supports framgents.
 | 
			
		||||
    with the Django Paginator object and supports fragments.
 | 
			
		||||
 | 
			
		||||
    The relpaced fragment will be #content so make sure you are calling this macro inside your content block.
 | 
			
		||||
    The replaced fragment will be #content so make sure you are calling this macro inside your content block.
 | 
			
		||||
 | 
			
		||||
    Parameters:
 | 
			
		||||
        current_page (django.core.paginator.Page): the current page object
 | 
			
		||||
@@ -247,9 +247,9 @@
 | 
			
		||||
{% macro select_all_checkbox(form_id) %}
 | 
			
		||||
  <script type="text/javascript">
 | 
			
		||||
    function checkbox_{{form_id}}(value) {
 | 
			
		||||
      list = document.getElementById("{{ form_id }}").getElementsByTagName("input");
 | 
			
		||||
      for (let element of list){
 | 
			
		||||
        if (element.type == "checkbox"){
 | 
			
		||||
      const inputs = document.getElementById("{{ form_id }}").getElementsByTagName("input");
 | 
			
		||||
      for (let element of inputs){
 | 
			
		||||
        if (element.type === "checkbox"){
 | 
			
		||||
          element.checked = value;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
@@ -258,3 +258,65 @@
 | 
			
		||||
  <button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button>
 | 
			
		||||
  <button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
 | 
			
		||||
{% endmacro %}
 | 
			
		||||
 | 
			
		||||
{% macro tabs(tab_list, attrs = "") %}
 | 
			
		||||
  {# Tab component
 | 
			
		||||
 | 
			
		||||
  Parameters:
 | 
			
		||||
    tab_list: list[tuple[str, str]] The list of tabs to display.
 | 
			
		||||
        Each element of the list is a tuple which first element
 | 
			
		||||
        is the title of the tab and the second element its content
 | 
			
		||||
    attrs: str Additional attributes to put on the enclosing div
 | 
			
		||||
 | 
			
		||||
  Example:
 | 
			
		||||
    A basic usage would be as follow :
 | 
			
		||||
 | 
			
		||||
        {{ tabs([("title 1", "content 1"), ("title 2", "content 2")]) }}
 | 
			
		||||
 | 
			
		||||
    If you want to display more complex logic, you can define macros
 | 
			
		||||
    and use those macros in parameters :
 | 
			
		||||
 | 
			
		||||
        {{ tabs([("title", my_macro())]) }}
 | 
			
		||||
 | 
			
		||||
    It's also possible to get and set the currently selected tab using Alpine.
 | 
			
		||||
    Here, the title of the currently selected tab will be displayed.
 | 
			
		||||
    Moreover, on page load, the tab will be opened on "tab 2".
 | 
			
		||||
 | 
			
		||||
        <div x-data="{current_tab: 'tab 2'}">
 | 
			
		||||
          <p x-text="current_tab"></p>
 | 
			
		||||
          {{ tabs([("tab 1", "Hello"), ("tab 2", "World")], "x-model=current_tab") }}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
    If you want to have translated tab titles, you can enclose the macro call
 | 
			
		||||
    in a with block :
 | 
			
		||||
 | 
			
		||||
        {% with title=_("title"), content=_("Content") %}
 | 
			
		||||
            {{ tabs([(tab1, content)]) }}
 | 
			
		||||
        {% endwith %}
 | 
			
		||||
  #}
 | 
			
		||||
  <div
 | 
			
		||||
    class="tabs shadow"
 | 
			
		||||
    x-data="{selected: '{{ tab_list[0][0] }}'}"
 | 
			
		||||
    x-modelable="selected"
 | 
			
		||||
    {{ attrs }}
 | 
			
		||||
  >
 | 
			
		||||
    <div class="tab-headers">
 | 
			
		||||
      {% for title, _ in tab_list %}
 | 
			
		||||
        <button
 | 
			
		||||
          class="tab-header clickable"
 | 
			
		||||
          :class="{active: selected === '{{ title }}'}"
 | 
			
		||||
          @click="selected = '{{ title }}'"
 | 
			
		||||
        >
 | 
			
		||||
          {{ title }}
 | 
			
		||||
        </button>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="tab-content">
 | 
			
		||||
      {% for title, content in tab_list %}
 | 
			
		||||
        <section x-show="selected === '{{ title }}'">
 | 
			
		||||
          {{ content }}
 | 
			
		||||
        </section>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
{% endmacro %}
 | 
			
		||||
 
 | 
			
		||||
@@ -3,17 +3,18 @@
 | 
			
		||||
{% macro page_history(page) %}
 | 
			
		||||
  <p>{% trans page_name=page.name %}You're seeing the history of page "{{ page_name }}"{% endtrans %}</p>
 | 
			
		||||
  <ul>
 | 
			
		||||
    {% for r in (page.revisions.all()|sort(attribute='date', reverse=True)) %}
 | 
			
		||||
      {% if loop.index < 2 %}
 | 
			
		||||
        <li><a href="{{ url('core:page', page_name=page.get_full_name()) }}">{% trans %}last{% endtrans %}</a> -
 | 
			
		||||
          {{ user_profile_link(page.revisions.last().author) }} -
 | 
			
		||||
          {{ page.revisions.last().date|localtime|date(DATETIME_FORMAT) }} {{ page.revisions.last().date|localtime|time(DATETIME_FORMAT) }}</a></li>
 | 
			
		||||
    {% set page_name = page.get_full_name() %}
 | 
			
		||||
    {%- for rev in page.revisions.order_by("-date").select_related("author") -%}
 | 
			
		||||
      <li>
 | 
			
		||||
        {% if loop.first %}
 | 
			
		||||
          <a href="{{ url('core:page', page_name=page_name) }}">{% trans %}last{% endtrans %}</a>
 | 
			
		||||
        {% else %}
 | 
			
		||||
        <li><a href="{{ url('core:page_rev', page_name=page.get_full_name(), rev=r['id']) }}">{{ r.revision }}</a> -
 | 
			
		||||
          {{ user_profile_link(r.author) }} -
 | 
			
		||||
          {{ r.date|localtime|date(DATETIME_FORMAT) }} {{ r.date|localtime|time(DATETIME_FORMAT) }}</a></li>
 | 
			
		||||
          <a href="{{ url('core:page_rev', page_name=page_name, rev=rev.id) }}">{{ rev.revision }}</a>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
        {{ user_profile_link(rev.author) }} -
 | 
			
		||||
        {{ rev.date|localtime|date(DATETIME_FORMAT) }} {{ rev.date|localtime|time(DATETIME_FORMAT) }}
 | 
			
		||||
      </li>
 | 
			
		||||
    {%- endfor -%}
 | 
			
		||||
  </ul>
 | 
			
		||||
{% endmacro %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,54 +0,0 @@
 | 
			
		||||
{% extends "core/base.jinja" %}
 | 
			
		||||
 | 
			
		||||
{% block script %}
 | 
			
		||||
  {{ super() }}
 | 
			
		||||
  <script src="{{ static('com/js/poster_list.js') }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
  {% trans %}Poster{% endtrans %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  <div id="poster_list">
 | 
			
		||||
 | 
			
		||||
    <div id="title">
 | 
			
		||||
      <h3>{% trans %}Posters{% endtrans %}</h3>
 | 
			
		||||
      <div id="links" class="right">
 | 
			
		||||
        <a id="create" class="link" href="{{ url(app + ":poster_list") }}">{% trans %}Create{% endtrans %}</a>
 | 
			
		||||
        {% if app == "com" %}
 | 
			
		||||
          <a id="moderation" class="link" href="{{ url("com:poster_moderate_list") }}">{% trans %}Moderation{% endtrans %}</a>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div id="posters">
 | 
			
		||||
 | 
			
		||||
      {% if poster_list.count() == 0 %}
 | 
			
		||||
        <div id="no-posters">{% trans %}No posters{% endtrans %}</div>
 | 
			
		||||
      {% else %}
 | 
			
		||||
 | 
			
		||||
        {% for poster in poster_list %}
 | 
			
		||||
          <div class="poster">
 | 
			
		||||
            <div class="name">{{ poster.name }}</div>
 | 
			
		||||
            <div class="image"><img src="{{ poster.file.url }}"></img></div>
 | 
			
		||||
            <div class="dates">
 | 
			
		||||
              <div class="begin">{{ poster.date_begin | date("d/M/Y H:m") }}</div>
 | 
			
		||||
              <div class="end">{{ poster.date_end | date("d/M/Y H:m") }}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <a class="edit" href="{{ url(poster_edit_url_name, poster.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
			
		||||
          </div>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
 | 
			
		||||
      {% endif %}
 | 
			
		||||
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div id="view"><div id="placeholder"></div></div>
 | 
			
		||||
 | 
			
		||||
  </div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user