mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-11-04 11:03:04 +00:00 
			
		
		
		
	Compare commits
	
		
			11 Commits
		
	
	
		
			docker
			...
			counter-ac
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					dae5cb06e7 | ||
| 
						 | 
					113828f9b6 | ||
| 
						 | 
					203b5d88ac | ||
| 
						 | 
					9206fed4ce | ||
| 
						 | 
					f133bac921 | ||
| 
						 | 
					1bce7e055f | ||
| 
						 | 
					ee19dc01f6 | ||
| 
						 | 
					09dbda87bc | ||
| 
						 | 
					a44e8a68cb | ||
| 
						 | 
					71d155613f | ||
| 
						 | 
					e30a6e8e6e | 
							
								
								
									
										83
									
								
								.env.example
									
									
									
									
									
								
							
							
						
						
									
										83
									
								
								.env.example
									
									
									
									
									
								
							@@ -1,83 +0,0 @@
 | 
				
			|||||||
HTTPS=off
 | 
					 | 
				
			||||||
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
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
DATABASE_URL=sqlite:///db.sqlite3
 | 
					 | 
				
			||||||
# uncomment the next line if you want to use a postgres database
 | 
					 | 
				
			||||||
#DATABASE_URL=postgres://user:password@127.0.0.1:5432/sith
 | 
					 | 
				
			||||||
CACHE_URL=redis://127.0.0.1:6379/0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
MEDIA_ROOT=data
 | 
					 | 
				
			||||||
STATIC_ROOT=static
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
DEFAULT_FROM_EMAIL=bibou@git.an
 | 
					 | 
				
			||||||
SITH_COM_EMAIL=bibou_com@git.an
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
HONEYPOT_VALUE=content
 | 
					 | 
				
			||||||
HONEYPOT_FIELD_NAME=body2
 | 
					 | 
				
			||||||
HONEYPOT_FIELD_NAME_FORUM=message2
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
 | 
					 | 
				
			||||||
EMAIL_HOST=localhost
 | 
					 | 
				
			||||||
EMAIL_PORT=25
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
SITH_URL=127.0.0.1:8000
 | 
					 | 
				
			||||||
SITH_NAME="AE UTBM"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
SITH_MAIN_CLUB_ID=1
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
SITH_GROUP_ROOT_ID=1
 | 
					 | 
				
			||||||
SITH_GROUP_PUBLIC_ID=2
 | 
					 | 
				
			||||||
SITH_GROUP_SUBSCRIBERS_ID=3
 | 
					 | 
				
			||||||
SITH_GROUP_OLD_SUBSCRIBERS_ID=4
 | 
					 | 
				
			||||||
SITH_GROUP_ACCOUNTING_ADMIN_ID=5
 | 
					 | 
				
			||||||
SITH_GROUP_COM_ADMIN_ID=6
 | 
					 | 
				
			||||||
SITH_GROUP_COUNTER_ADMIN_ID=7
 | 
					 | 
				
			||||||
SITH_GROUP_SAS_ADMIN_ID=8
 | 
					 | 
				
			||||||
SITH_GROUP_FORUM_ADMIN_ID=9
 | 
					 | 
				
			||||||
SITH_GROUP_PEDAGOGY_ADMIN_ID=10
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
SITH_GROUP_BANNED_ALCOHOL_ID=11
 | 
					 | 
				
			||||||
SITH_GROUP_BANNED_COUNTER_ID=12
 | 
					 | 
				
			||||||
SITH_GROUP_BANNED_SUBSCRIPTION_ID=13
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
SITH_CLUB_REFOUND_ID=89
 | 
					 | 
				
			||||||
SITH_COUNTER_REFOUND_ID=38
 | 
					 | 
				
			||||||
SITH_PRODUCT_REFOUND_ID=5
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Counter
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
SITH_COUNTER_ACCOUNT_DUMP_ID=39
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Defines which product type is the refilling type, and thus increases the account amount
 | 
					 | 
				
			||||||
SITH_COUNTER_PRODUCTTYPE_REFILLING=3
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
SITH_ECOCUP_CONS=1152
 | 
					 | 
				
			||||||
SITH_ECOCUP_DECO=1151
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Defines which product is the one year subscription and which one is the six month subscription
 | 
					 | 
				
			||||||
SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER=1
 | 
					 | 
				
			||||||
SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS=2
 | 
					 | 
				
			||||||
SITH_PRODUCTTYPE_SUBSCRIPTION=2
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Defines which clubs let its members the ability to see users subscription history
 | 
					 | 
				
			||||||
SITH_CAN_CREATE_SUBSCRIPTION_HISTORY=1
 | 
					 | 
				
			||||||
SITH_CAN_READ_SUBSCRIPTION_HISTORY=1
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# SAS variables
 | 
					 | 
				
			||||||
SITH_SAS_ROOT_DIR_ID=4
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# ET variables
 | 
					 | 
				
			||||||
SITH_EBOUTIC_CB_ENABLED=true
 | 
					 | 
				
			||||||
SITH_EBOUTIC_ET_URL="https://preprod-tpeweb.e-transactions.fr/cgi/MYchoix_pagepaiement.cgi"
 | 
					 | 
				
			||||||
SITH_EBOUTIC_PBX_SITE=1999888
 | 
					 | 
				
			||||||
SITH_EBOUTIC_PBX_RANG=32
 | 
					 | 
				
			||||||
SITH_EBOUTIC_PBX_IDENTIFIANT=2
 | 
					 | 
				
			||||||
SITH_EBOUTIC_HMAC_KEY=0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF
 | 
					 | 
				
			||||||
SITH_EBOUTIC_PUB_KEY_PATH=sith/et_keys/pubkey.pem
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
SITH_MAILING_FETCH_KEY=IloveMails
 | 
					 | 
				
			||||||
SENTRY_DSN=
 | 
					 | 
				
			||||||
SENTRY_ENV=production
 | 
					 | 
				
			||||||
							
								
								
									
										14
									
								
								.envrc
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								.envrc
									
									
									
									
									
								
							@@ -1,6 +1,14 @@
 | 
				
			|||||||
if [[ ! -d .venv ]]; then
 | 
					if [[ ! -f pyproject.toml ]]; then
 | 
				
			||||||
  log_error 'No .venv folder found. Use `uv sync` to create one first.'
 | 
					  log_error 'No pyproject.toml found. Use `poetry new` or `poetry init` to create one first.'
 | 
				
			||||||
  exit 2
 | 
					  exit 2
 | 
				
			||||||
fi
 | 
					fi
 | 
				
			||||||
 | 
					
 | 
				
			||||||
. .venv/bin/activate
 | 
					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"
 | 
				
			||||||
							
								
								
									
										8
									
								
								.github/actions/compile_messages/action.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								.github/actions/compile_messages/action.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					name: "Compile messages"
 | 
				
			||||||
 | 
					description: "Compile the gettext translation messages"
 | 
				
			||||||
 | 
					runs:
 | 
				
			||||||
 | 
					  using: composite
 | 
				
			||||||
 | 
					  steps:
 | 
				
			||||||
 | 
					      - name: Setup project
 | 
				
			||||||
 | 
					        run: poetry run ./manage.py compilemessages
 | 
				
			||||||
 | 
					        shell: bash
 | 
				
			||||||
							
								
								
									
										51
									
								
								.github/actions/setup_project/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										51
									
								
								.github/actions/setup_project/action.yml
									
									
									
									
										vendored
									
									
								
							@@ -9,38 +9,43 @@ runs:
 | 
				
			|||||||
        packages: gettext
 | 
					        packages: gettext
 | 
				
			||||||
        version: 1.0  # increment to reset cache
 | 
					        version: 1.0  # increment to reset cache
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    - name: Install uv
 | 
					    - name: Set up python
 | 
				
			||||||
      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
 | 
					      uses: actions/setup-python@v5
 | 
				
			||||||
      with:
 | 
					      with:
 | 
				
			||||||
        python-version-file: ".python-version"
 | 
					        python-version: "3.12"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    - name: Restore cached virtualenv
 | 
					    - name: Load cached Poetry installation
 | 
				
			||||||
      uses: actions/cache/restore@v4
 | 
					      id: cached-poetry
 | 
				
			||||||
 | 
					      uses: actions/cache@v3
 | 
				
			||||||
      with:
 | 
					      with:
 | 
				
			||||||
        key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
 | 
					        path: ~/.local
 | 
				
			||||||
        path: .venv
 | 
					        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-
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    - name: Install dependencies
 | 
					    - name: Install dependencies
 | 
				
			||||||
      run: uv sync
 | 
					      run: poetry install --with docs,tests
 | 
				
			||||||
      shell: bash
 | 
					      shell: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    - name: Install Xapian
 | 
					    - name: Install xapian
 | 
				
			||||||
      run: uv run ./manage.py install_xapian
 | 
					      run: poetry run ./manage.py install_xapian
 | 
				
			||||||
      shell: bash
 | 
					      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
 | 
					    - name: Compile gettext messages
 | 
				
			||||||
      run: uv run ./manage.py compilemessages
 | 
					      run: poetry run ./manage.py compilemessages
 | 
				
			||||||
      shell: bash
 | 
					      shell: bash
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										10
									
								
								.github/actions/setup_xapian/action.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								.github/actions/setup_xapian/action.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					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
 | 
				
			||||||
							
								
								
									
										17
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										17
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							@@ -7,10 +7,6 @@ on:
 | 
				
			|||||||
    branches: [master, taiste]
 | 
					    branches: [master, taiste]
 | 
				
			||||||
  workflow_dispatch:
 | 
					  workflow_dispatch:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
env:
 | 
					 | 
				
			||||||
  SECRET_KEY: notTheRealOne
 | 
					 | 
				
			||||||
  DATABASE_URL: sqlite:///db.sqlite3
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
jobs:
 | 
					jobs:
 | 
				
			||||||
  pre-commit:
 | 
					  pre-commit:
 | 
				
			||||||
    name: Launch pre-commits checks (ruff)
 | 
					    name: Launch pre-commits checks (ruff)
 | 
				
			||||||
@@ -18,8 +14,6 @@ jobs:
 | 
				
			|||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
    - uses: actions/checkout@v4
 | 
					    - uses: actions/checkout@v4
 | 
				
			||||||
    - uses: actions/setup-python@v5
 | 
					    - uses: actions/setup-python@v5
 | 
				
			||||||
      with:
 | 
					 | 
				
			||||||
        python-version-file: ".python-version"
 | 
					 | 
				
			||||||
    - uses: pre-commit/action@v3.0.1
 | 
					    - uses: pre-commit/action@v3.0.1
 | 
				
			||||||
      with:
 | 
					      with:
 | 
				
			||||||
        extra_args: --all-files
 | 
					        extra_args: --all-files
 | 
				
			||||||
@@ -35,15 +29,14 @@ jobs:
 | 
				
			|||||||
      - name: Check out repository
 | 
					      - name: Check out repository
 | 
				
			||||||
        uses: actions/checkout@v4
 | 
					        uses: actions/checkout@v4
 | 
				
			||||||
      - uses: ./.github/actions/setup_project
 | 
					      - uses: ./.github/actions/setup_project
 | 
				
			||||||
        env:
 | 
					      - uses: ./.github/actions/setup_xapian
 | 
				
			||||||
          # To avoid race conditions on environment cache
 | 
					      - uses: ./.github/actions/compile_messages
 | 
				
			||||||
          CACHE_SUFFIX: ${{ matrix.pytest-mark }}
 | 
					 | 
				
			||||||
      - name: Run tests
 | 
					      - name: Run tests
 | 
				
			||||||
        run: uv run coverage run -m pytest -m "${{ matrix.pytest-mark }}"
 | 
					        run: poetry run coverage run -m pytest -m "${{ matrix.pytest-mark }}"
 | 
				
			||||||
      - name: Generate coverage report
 | 
					      - name: Generate coverage report
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          uv run coverage report
 | 
					          poetry run coverage report
 | 
				
			||||||
          uv run coverage html
 | 
					          poetry run coverage html
 | 
				
			||||||
      - name: Archive code coverage results
 | 
					      - name: Archive code coverage results
 | 
				
			||||||
        uses: actions/upload-artifact@v3
 | 
					        uses: actions/upload-artifact@v3
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										28
									
								
								.github/workflows/deploy.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										28
									
								
								.github/workflows/deploy.yml
									
									
									
									
										vendored
									
									
								
							@@ -37,29 +37,11 @@ jobs:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
          git fetch
 | 
					          git fetch
 | 
				
			||||||
          git reset --hard origin/master
 | 
					          git reset --hard origin/master
 | 
				
			||||||
          uv sync --group prod
 | 
					          poetry install --with prod --without docs,tests
 | 
				
			||||||
          npm install
 | 
					          npm install
 | 
				
			||||||
          uv run ./manage.py install_xapian
 | 
					          poetry run ./manage.py install_xapian
 | 
				
			||||||
          uv run ./manage.py migrate
 | 
					          poetry run ./manage.py migrate
 | 
				
			||||||
          uv run ./manage.py collectstatic --clear --noinput
 | 
					          poetry run ./manage.py collectstatic --clear --noinput
 | 
				
			||||||
          uv run ./manage.py compilemessages
 | 
					          poetry run ./manage.py compilemessages
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          sudo systemctl restart uwsgi
 | 
					          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
 | 
					          path: .cache
 | 
				
			||||||
          restore-keys: |
 | 
					          restore-keys: |
 | 
				
			||||||
            mkdocs-material-
 | 
					            mkdocs-material-
 | 
				
			||||||
      - run: uv run mkdocs gh-deploy --force
 | 
					      - run: poetry 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 fetch
 | 
				
			||||||
          git reset --hard origin/taiste
 | 
					          git reset --hard origin/taiste
 | 
				
			||||||
          uv sync --group prod
 | 
					          poetry install --with prod --without docs,tests
 | 
				
			||||||
          npm install
 | 
					          npm install
 | 
				
			||||||
          uv run ./manage.py install_xapian
 | 
					          poetry run ./manage.py install_xapian
 | 
				
			||||||
          uv run ./manage.py migrate
 | 
					          poetry run ./manage.py migrate
 | 
				
			||||||
          uv run ./manage.py collectstatic --clear --noinput
 | 
					          poetry run ./manage.py collectstatic --clear --noinput
 | 
				
			||||||
          uv run ./manage.py compilemessages
 | 
					          poetry run ./manage.py compilemessages
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          sudo systemctl restart uwsgi
 | 
					          sudo systemctl restart uwsgi
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -8,7 +8,7 @@ pyrightconfig.json
 | 
				
			|||||||
dist/
 | 
					dist/
 | 
				
			||||||
.vscode/
 | 
					.vscode/
 | 
				
			||||||
.idea/
 | 
					.idea/
 | 
				
			||||||
.venv/
 | 
					env/
 | 
				
			||||||
doc/html
 | 
					doc/html
 | 
				
			||||||
data/
 | 
					data/
 | 
				
			||||||
galaxy/test_galaxy_state.json
 | 
					galaxy/test_galaxy_state.json
 | 
				
			||||||
@@ -21,4 +21,3 @@ node_modules/
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# compiled documentation
 | 
					# compiled documentation
 | 
				
			||||||
site/
 | 
					site/
 | 
				
			||||||
.env
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,7 @@
 | 
				
			|||||||
repos:
 | 
					repos:
 | 
				
			||||||
  - repo: https://github.com/astral-sh/ruff-pre-commit
 | 
					  - repo: https://github.com/astral-sh/ruff-pre-commit
 | 
				
			||||||
    # Ruff version.
 | 
					    # Ruff version.
 | 
				
			||||||
    rev: v0.8.3
 | 
					    rev: v0.6.9
 | 
				
			||||||
    hooks:
 | 
					    hooks:
 | 
				
			||||||
      - id: ruff  # just check the code, and print the errors
 | 
					      - id: ruff  # just check the code, and print the errors
 | 
				
			||||||
      - id: ruff  # actually fix the fixable errors, but print nothing
 | 
					      - id: ruff  # actually fix the fixable errors, but print nothing
 | 
				
			||||||
@@ -14,7 +14,7 @@ repos:
 | 
				
			|||||||
      - id: biome-check
 | 
					      - id: biome-check
 | 
				
			||||||
        additional_dependencies: ["@biomejs/biome@1.9.3"]
 | 
					        additional_dependencies: ["@biomejs/biome@1.9.3"]
 | 
				
			||||||
  - repo: https://github.com/rtts/djhtml
 | 
					  - repo: https://github.com/rtts/djhtml
 | 
				
			||||||
    rev: 3.0.7
 | 
					    rev: 3.0.6
 | 
				
			||||||
    hooks:
 | 
					    hooks:
 | 
				
			||||||
      - id: djhtml
 | 
					      - id: djhtml
 | 
				
			||||||
        name: format templates
 | 
					        name: format templates
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1 +0,0 @@
 | 
				
			|||||||
3.12
 | 
					 | 
				
			||||||
@@ -216,7 +216,7 @@ class TestOperation(TestCase):
 | 
				
			|||||||
            self.journal.operations.filter(target_label="Le fantome du jour").exists()
 | 
					            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()
 | 
					        sat = SimplifiedAccountingType.objects.all().first()
 | 
				
			||||||
        response = self.client.post(
 | 
					        response = self.client.post(
 | 
				
			||||||
            reverse("accounting:op_new", args=[self.journal.id]),
 | 
					            reverse("accounting:op_new", args=[self.journal.id]),
 | 
				
			||||||
@@ -237,14 +237,15 @@ class TestOperation(TestCase):
 | 
				
			|||||||
                "done": False,
 | 
					                "done": False,
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        assert response.status_code != 403
 | 
					        self.assertFalse(response.status_code == 403)
 | 
				
			||||||
        assert self.journal.operations.filter(amount=23).exists()
 | 
					        self.assertTrue(self.journal.operations.filter(amount=23).exists())
 | 
				
			||||||
        response_get = self.client.get(
 | 
					        response_get = self.client.get(
 | 
				
			||||||
            reverse("accounting:journal_details", args=[self.journal.id])
 | 
					            reverse("accounting:journal_details", args=[self.journal.id])
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        assert "<td>Le fantome de l'aurore</td>" in str(response_get.content)
 | 
					        self.assertTrue(
 | 
				
			||||||
 | 
					            "<td>Le fantome de l'aurore</td>" in str(response_get.content)
 | 
				
			||||||
        assert (
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertTrue(
 | 
				
			||||||
            self.journal.operations.filter(amount=23)
 | 
					            self.journal.operations.filter(amount=23)
 | 
				
			||||||
            .values("accounting_type")
 | 
					            .values("accounting_type")
 | 
				
			||||||
            .first()["accounting_type"]
 | 
					            .first()["accounting_type"]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -215,14 +215,17 @@ class JournalTabsMixin(TabedViewMixin):
 | 
				
			|||||||
        return _("Journal")
 | 
					        return _("Journal")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_list_of_tabs(self):
 | 
					    def get_list_of_tabs(self):
 | 
				
			||||||
        return [
 | 
					        tab_list = []
 | 
				
			||||||
 | 
					        tab_list.append(
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "url": reverse(
 | 
					                "url": reverse(
 | 
				
			||||||
                    "accounting:journal_details", kwargs={"j_id": self.object.id}
 | 
					                    "accounting:journal_details", kwargs={"j_id": self.object.id}
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
                "slug": "journal",
 | 
					                "slug": "journal",
 | 
				
			||||||
                "name": _("Journal"),
 | 
					                "name": _("Journal"),
 | 
				
			||||||
            },
 | 
					            }
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        tab_list.append(
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "url": reverse(
 | 
					                "url": reverse(
 | 
				
			||||||
                    "accounting:journal_nature_statement",
 | 
					                    "accounting:journal_nature_statement",
 | 
				
			||||||
@@ -230,7 +233,9 @@ class JournalTabsMixin(TabedViewMixin):
 | 
				
			|||||||
                ),
 | 
					                ),
 | 
				
			||||||
                "slug": "nature_statement",
 | 
					                "slug": "nature_statement",
 | 
				
			||||||
                "name": _("Statement by nature"),
 | 
					                "name": _("Statement by nature"),
 | 
				
			||||||
            },
 | 
					            }
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        tab_list.append(
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "url": reverse(
 | 
					                "url": reverse(
 | 
				
			||||||
                    "accounting:journal_person_statement",
 | 
					                    "accounting:journal_person_statement",
 | 
				
			||||||
@@ -238,7 +243,9 @@ class JournalTabsMixin(TabedViewMixin):
 | 
				
			|||||||
                ),
 | 
					                ),
 | 
				
			||||||
                "slug": "person_statement",
 | 
					                "slug": "person_statement",
 | 
				
			||||||
                "name": _("Statement by person"),
 | 
					                "name": _("Statement by person"),
 | 
				
			||||||
            },
 | 
					            }
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        tab_list.append(
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "url": reverse(
 | 
					                "url": reverse(
 | 
				
			||||||
                    "accounting:journal_accounting_statement",
 | 
					                    "accounting:journal_accounting_statement",
 | 
				
			||||||
@@ -246,8 +253,9 @@ class JournalTabsMixin(TabedViewMixin):
 | 
				
			|||||||
                ),
 | 
					                ),
 | 
				
			||||||
                "slug": "accounting_statement",
 | 
					                "slug": "accounting_statement",
 | 
				
			||||||
                "name": _("Accounting statement"),
 | 
					                "name": _("Accounting statement"),
 | 
				
			||||||
            },
 | 
					            }
 | 
				
			||||||
        ]
 | 
					        )
 | 
				
			||||||
 | 
					        return tab_list
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class JournalCreateView(CanCreateMixin, CreateView):
 | 
					class JournalCreateView(CanCreateMixin, CreateView):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,14 +20,6 @@ from club.models import Club, Membership
 | 
				
			|||||||
@admin.register(Club)
 | 
					@admin.register(Club)
 | 
				
			||||||
class ClubAdmin(admin.ModelAdmin):
 | 
					class ClubAdmin(admin.ModelAdmin):
 | 
				
			||||||
    list_display = ("name", "unix_name", "parent", "is_active")
 | 
					    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)
 | 
					@admin.register(Membership)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,6 +3,19 @@ from __future__ import unicode_literals
 | 
				
			|||||||
import django.db.models.deletion
 | 
					import django.db.models.deletion
 | 
				
			||||||
from django.db import migrations, models
 | 
					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):
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
    dependencies = [("core", "0024_auto_20170906_1317"), ("club", "0010_club_logo")]
 | 
					    dependencies = [("core", "0024_auto_20170906_1317"), ("club", "0010_club_logo")]
 | 
				
			||||||
@@ -35,4 +48,11 @@ class Migration(migrations.Migration):
 | 
				
			|||||||
                null=True,
 | 
					                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"
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,106 +0,0 @@
 | 
				
			|||||||
# 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
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
@@ -1,36 +0,0 @@
 | 
				
			|||||||
# 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",
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
							
								
								
									
										328
									
								
								club/models.py
									
									
									
									
									
								
							
							
						
						
									
										328
									
								
								club/models.py
									
									
									
									
									
								
							@@ -23,7 +23,7 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
from __future__ import annotations
 | 
					from __future__ import annotations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from typing import Iterable, Self
 | 
					from typing import Self
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.core import validators
 | 
					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.exceptions import ObjectDoesNotExist, ValidationError
 | 
				
			||||||
from django.core.validators import RegexValidator, validate_email
 | 
					from django.core.validators import RegexValidator, validate_email
 | 
				
			||||||
from django.db import models, transaction
 | 
					from django.db import models, transaction
 | 
				
			||||||
from django.db.models import Exists, F, OuterRef, Q
 | 
					from django.db.models import Q
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
from django.utils import timezone
 | 
					from django.utils import timezone
 | 
				
			||||||
from django.utils.functional import cached_property
 | 
					from django.utils.functional import cached_property
 | 
				
			||||||
from django.utils.timezone import localdate
 | 
					from django.utils.timezone import localdate
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.models import Group, Notification, Page, SithFile, User
 | 
					from core.models import Group, MetaGroup, Notification, Page, RealGroup, SithFile, User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Create your models here.
 | 
					# Create your models here.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -79,6 +79,19 @@ class Club(models.Model):
 | 
				
			|||||||
        _("short description"), max_length=1000, default="", blank=True, null=True
 | 
					        _("short description"), max_length=1000, default="", blank=True, null=True
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    address = models.CharField(_("address"), max_length=254)
 | 
					    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(
 | 
					    home = models.OneToOneField(
 | 
				
			||||||
        SithFile,
 | 
					        SithFile,
 | 
				
			||||||
        related_name="home_of_club",
 | 
					        related_name="home_of_club",
 | 
				
			||||||
@@ -90,12 +103,6 @@ class Club(models.Model):
 | 
				
			|||||||
    page = models.OneToOneField(
 | 
					    page = models.OneToOneField(
 | 
				
			||||||
        Page, related_name="club", blank=True, null=True, on_delete=models.CASCADE
 | 
					        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:
 | 
					    class Meta:
 | 
				
			||||||
        ordering = ["name", "unix_name"]
 | 
					        ordering = ["name", "unix_name"]
 | 
				
			||||||
@@ -105,27 +112,23 @@ class Club(models.Model):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    @transaction.atomic()
 | 
					    @transaction.atomic()
 | 
				
			||||||
    def save(self, *args, **kwargs):
 | 
					    def save(self, *args, **kwargs):
 | 
				
			||||||
        creation = self._state.adding
 | 
					        old = Club.objects.filter(id=self.id).first()
 | 
				
			||||||
        if not creation:
 | 
					        creation = old is None
 | 
				
			||||||
            db_club = Club.objects.get(id=self.id)
 | 
					        if not creation and old.unix_name != self.unix_name:
 | 
				
			||||||
            if self.unix_name != db_club.unix_name:
 | 
					            self._change_unixname(self.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)
 | 
					        super().save(*args, **kwargs)
 | 
				
			||||||
        if creation:
 | 
					        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.make_home()
 | 
				
			||||||
 | 
					            self.home.edit_groups.set([board])
 | 
				
			||||||
 | 
					            self.home.view_groups.set([member, subscribers])
 | 
				
			||||||
 | 
					            self.home.save()
 | 
				
			||||||
        self.make_page()
 | 
					        self.make_page()
 | 
				
			||||||
        cache.set(f"sith_club_{self.unix_name}", self)
 | 
					        cache.set(f"sith_club_{self.unix_name}", self)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -133,8 +136,7 @@ class Club(models.Model):
 | 
				
			|||||||
        return reverse("club:club_view", kwargs={"club_id": self.id})
 | 
					        return reverse("club:club_view", kwargs={"club_id": self.id})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @cached_property
 | 
					    @cached_property
 | 
				
			||||||
    def president(self) -> Membership | None:
 | 
					    def president(self):
 | 
				
			||||||
        """Fetch the membership of the current president of this club."""
 | 
					 | 
				
			||||||
        return self.members.filter(
 | 
					        return self.members.filter(
 | 
				
			||||||
            role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
 | 
					            role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
 | 
				
			||||||
        ).first()
 | 
					        ).first()
 | 
				
			||||||
@@ -152,18 +154,36 @@ class Club(models.Model):
 | 
				
			|||||||
    def clean(self):
 | 
					    def clean(self):
 | 
				
			||||||
        self.check_loop()
 | 
					        self.check_loop()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def make_home(self) -> None:
 | 
					    def _change_unixname(self, old_name, new_name):
 | 
				
			||||||
        if self.home:
 | 
					        c = Club.objects.filter(unix_name=new_name).first()
 | 
				
			||||||
            return
 | 
					        if c is None:
 | 
				
			||||||
        home_root = SithFile.objects.filter(parent=None, name="clubs").first()
 | 
					            # Update all the groups names
 | 
				
			||||||
        root = User.objects.filter(username="root").first()
 | 
					            Group.objects.filter(name=old_name).update(name=new_name)
 | 
				
			||||||
        if home_root and root:
 | 
					            Group.objects.filter(name=old_name + settings.SITH_BOARD_SUFFIX).update(
 | 
				
			||||||
            home = SithFile(parent=home_root, name=self.unix_name, owner=root)
 | 
					                name=new_name + settings.SITH_BOARD_SUFFIX
 | 
				
			||||||
            home.save()
 | 
					            )
 | 
				
			||||||
            self.home = home
 | 
					            Group.objects.filter(name=old_name + settings.SITH_MEMBER_SUFFIX).update(
 | 
				
			||||||
            self.save()
 | 
					                name=new_name + settings.SITH_MEMBER_SUFFIX
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def make_page(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:
 | 
				
			||||||
 | 
					            home_root = SithFile.objects.filter(parent=None, name="clubs").first()
 | 
				
			||||||
 | 
					            root = User.objects.filter(username="root").first()
 | 
				
			||||||
 | 
					            if home_root and root:
 | 
				
			||||||
 | 
					                home = SithFile(parent=home_root, name=self.unix_name, owner=root)
 | 
				
			||||||
 | 
					                home.save()
 | 
				
			||||||
 | 
					                self.home = home
 | 
				
			||||||
 | 
					                self.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def make_page(self):
 | 
				
			||||||
        root = User.objects.filter(username="root").first()
 | 
					        root = User.objects.filter(username="root").first()
 | 
				
			||||||
        if not self.page:
 | 
					        if not self.page:
 | 
				
			||||||
            club_root = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
 | 
					            club_root = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
 | 
				
			||||||
@@ -193,34 +213,35 @@ class Club(models.Model):
 | 
				
			|||||||
            self.page.parent = self.parent.page
 | 
					            self.page.parent = self.parent.page
 | 
				
			||||||
            self.page.save(force_lock=True)
 | 
					            self.page.save(force_lock=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
 | 
					    def delete(self, *args, **kwargs):
 | 
				
			||||||
        # Invalidate the cache of this club and of its memberships
 | 
					        # Invalidate the cache of this club and of its memberships
 | 
				
			||||||
        for membership in self.members.ongoing().select_related("user"):
 | 
					        for membership in self.members.ongoing().select_related("user"):
 | 
				
			||||||
            cache.delete(f"membership_{self.id}_{membership.user.id}")
 | 
					            cache.delete(f"membership_{self.id}_{membership.user.id}")
 | 
				
			||||||
        cache.delete(f"sith_club_{self.unix_name}")
 | 
					        cache.delete(f"sith_club_{self.unix_name}")
 | 
				
			||||||
        self.board_group.delete()
 | 
					        super().delete(*args, **kwargs)
 | 
				
			||||||
        self.members_group.delete()
 | 
					 | 
				
			||||||
        return super().delete(*args, **kwargs)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_display_name(self) -> str:
 | 
					    def get_display_name(self):
 | 
				
			||||||
        return self.name
 | 
					        return self.name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def is_owned_by(self, user: User) -> bool:
 | 
					    def is_owned_by(self, user):
 | 
				
			||||||
        """Method to see if that object can be super edited by the given user."""
 | 
					        """Method to see if that object can be super edited by the given user."""
 | 
				
			||||||
        if user.is_anonymous:
 | 
					        if user.is_anonymous:
 | 
				
			||||||
            return False
 | 
					            return False
 | 
				
			||||||
        return user.is_root or user.is_board_member
 | 
					        return user.is_board_member
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_full_logo_url(self) -> str:
 | 
					    def get_full_logo_url(self):
 | 
				
			||||||
        return f"https://{settings.SITH_URL}{self.logo.url}"
 | 
					        return "https://%s%s" % (settings.SITH_URL, self.logo.url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def can_be_edited_by(self, user: User) -> bool:
 | 
					    def can_be_edited_by(self, user):
 | 
				
			||||||
        """Method to see if that object can be edited by the given user."""
 | 
					        """Method to see if that object can be edited by the given user."""
 | 
				
			||||||
        return self.has_rights_in_club(user)
 | 
					        return self.has_rights_in_club(user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def can_be_viewed_by(self, user: User) -> bool:
 | 
					    def can_be_viewed_by(self, user):
 | 
				
			||||||
        """Method to see if that object can be seen by the given user."""
 | 
					        """Method to see if that object can be seen by the given user."""
 | 
				
			||||||
        return user.was_subscribed
 | 
					        sub = User.objects.filter(pk=user.pk).first()
 | 
				
			||||||
 | 
					        if sub is None:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        return sub.was_subscribed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_membership_for(self, user: User) -> Membership | None:
 | 
					    def get_membership_for(self, user: User) -> Membership | None:
 | 
				
			||||||
        """Return the current membership the given user.
 | 
					        """Return the current membership the given user.
 | 
				
			||||||
@@ -241,8 +262,9 @@ class Club(models.Model):
 | 
				
			|||||||
                cache.set(f"membership_{self.id}_{user.id}", membership)
 | 
					                cache.set(f"membership_{self.id}_{user.id}", membership)
 | 
				
			||||||
        return membership
 | 
					        return membership
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def has_rights_in_club(self, user: User) -> bool:
 | 
					    def has_rights_in_club(self, user):
 | 
				
			||||||
        return user.is_in_group(pk=self.board_group_id)
 | 
					        m = self.get_membership_for(user)
 | 
				
			||||||
 | 
					        return m is not None and m.role > settings.SITH_MAXIMUM_FREE_ROLE
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class MembershipQuerySet(models.QuerySet):
 | 
					class MembershipQuerySet(models.QuerySet):
 | 
				
			||||||
@@ -261,65 +283,42 @@ class MembershipQuerySet(models.QuerySet):
 | 
				
			|||||||
        """
 | 
					        """
 | 
				
			||||||
        return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
 | 
					        return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def update(self, **kwargs) -> int:
 | 
					    def update(self, **kwargs):
 | 
				
			||||||
        """Refresh the cache and edit group ownership.
 | 
					        """Refresh the cache for the elements of the queryset.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Update the cache, when necessary, remove
 | 
					        Besides that, does the same job as a regular update method.
 | 
				
			||||||
        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 three db queries :
 | 
					        Be aware that this adds a db query to retrieve the updated objects
 | 
				
			||||||
        one to retrieve the updated memberships,
 | 
					 | 
				
			||||||
        one to perform group removal and one to perform
 | 
					 | 
				
			||||||
        group attribution.
 | 
					 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        nb_rows = super().update(**kwargs)
 | 
					        nb_rows = super().update(**kwargs)
 | 
				
			||||||
        if nb_rows == 0:
 | 
					        if nb_rows > 0:
 | 
				
			||||||
            # if no row was affected, no need to refresh the cache
 | 
					            # if at least a row was affected, refresh the cache
 | 
				
			||||||
            return 0
 | 
					            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,
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        cache_memberships = {}
 | 
					    def delete(self):
 | 
				
			||||||
        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,
 | 
					        """Work just like the default Django's delete() method,
 | 
				
			||||||
        but add a cache invalidation for the elements of the queryset
 | 
					        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 some db queries :
 | 
					        Be aware that this adds a db query to retrieve the deleted element.
 | 
				
			||||||
 | 
					        As this first query take place before the deletion operation,
 | 
				
			||||||
        - 1 to retrieve the deleted elements in order to perform
 | 
					        it will be performed even if the deletion fails.
 | 
				
			||||||
          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.
 | 
					 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        memberships = set(self.all())
 | 
					        ids = list(self.values_list("club_id", "user_id"))
 | 
				
			||||||
        nb_rows, rows_counts = super().delete()
 | 
					        nb_rows, _ = super().delete()
 | 
				
			||||||
        if nb_rows > 0:
 | 
					        if nb_rows > 0:
 | 
				
			||||||
            Membership._remove_club_groups(memberships)
 | 
					            for club_id, user_id in ids:
 | 
				
			||||||
            cache.set_many(
 | 
					                cache.set(f"membership_{club_id}_{user_id}", "not_member")
 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    f"membership_{m.club_id}_{m.user_id}": "not_member"
 | 
					 | 
				
			||||||
                    for m in memberships
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        return nb_rows, rows_counts
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Membership(models.Model):
 | 
					class Membership(models.Model):
 | 
				
			||||||
@@ -362,13 +361,6 @@ class Membership(models.Model):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    objects = MembershipQuerySet.as_manager()
 | 
					    objects = MembershipQuerySet.as_manager()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					 | 
				
			||||||
        constraints = [
 | 
					 | 
				
			||||||
            models.CheckConstraint(
 | 
					 | 
				
			||||||
                check=Q(end_date__gte=F("start_date")), name="end_after_start"
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
        return (
 | 
					        return (
 | 
				
			||||||
            f"{self.club.name} - {self.user.username} "
 | 
					            f"{self.club.name} - {self.user.username} "
 | 
				
			||||||
@@ -378,14 +370,7 @@ class Membership(models.Model):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def save(self, *args, **kwargs):
 | 
					    def save(self, *args, **kwargs):
 | 
				
			||||||
        super().save(*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:
 | 
					        if self.end_date is None:
 | 
				
			||||||
            self._add_club_groups([self])
 | 
					 | 
				
			||||||
            cache.set(f"membership_{self.club_id}_{self.user_id}", self)
 | 
					            cache.set(f"membership_{self.club_id}_{self.user_id}", self)
 | 
				
			||||||
        else:
 | 
					        else:
 | 
				
			||||||
            cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
 | 
					            cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
 | 
				
			||||||
@@ -393,11 +378,11 @@ class Membership(models.Model):
 | 
				
			|||||||
    def get_absolute_url(self):
 | 
					    def get_absolute_url(self):
 | 
				
			||||||
        return reverse("club:club_members", kwargs={"club_id": self.club_id})
 | 
					        return reverse("club:club_members", kwargs={"club_id": self.club_id})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def is_owned_by(self, user: User) -> bool:
 | 
					    def is_owned_by(self, user):
 | 
				
			||||||
        """Method to see if that object can be super edited by the given user."""
 | 
					        """Method to see if that object can be super edited by the given user."""
 | 
				
			||||||
        if user.is_anonymous:
 | 
					        if user.is_anonymous:
 | 
				
			||||||
            return False
 | 
					            return False
 | 
				
			||||||
        return user.is_root or user.is_board_member
 | 
					        return user.is_board_member
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def can_be_edited_by(self, user: User) -> bool:
 | 
					    def can_be_edited_by(self, user: User) -> bool:
 | 
				
			||||||
        """Check if that object can be edited by the given user."""
 | 
					        """Check if that object can be edited by the given user."""
 | 
				
			||||||
@@ -407,91 +392,9 @@ class Membership(models.Model):
 | 
				
			|||||||
        return membership is not None and membership.role >= self.role
 | 
					        return membership is not None and membership.role >= self.role
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def delete(self, *args, **kwargs):
 | 
					    def delete(self, *args, **kwargs):
 | 
				
			||||||
        self._remove_club_groups([self])
 | 
					 | 
				
			||||||
        super().delete(*args, **kwargs)
 | 
					        super().delete(*args, **kwargs)
 | 
				
			||||||
        cache.delete(f"membership_{self.club_id}_{self.user_id}")
 | 
					        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):
 | 
					class Mailing(models.Model):
 | 
				
			||||||
    """A Mailing list for a club.
 | 
					    """A Mailing list for a club.
 | 
				
			||||||
@@ -535,18 +438,19 @@ class Mailing(models.Model):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def save(self, *args, **kwargs):
 | 
					    def save(self, *args, **kwargs):
 | 
				
			||||||
        if not self.is_moderated:
 | 
					        if not self.is_moderated:
 | 
				
			||||||
            unread_notif_subquery = Notification.objects.filter(
 | 
					            for user in (
 | 
				
			||||||
                user=OuterRef("pk"), type="MAILING_MODERATION", viewed=False
 | 
					                RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
				
			||||||
            )
 | 
					                .first()
 | 
				
			||||||
            for user in User.objects.filter(
 | 
					                .users.all()
 | 
				
			||||||
                ~Exists(unread_notif_subquery),
 | 
					 | 
				
			||||||
                groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
 | 
					 | 
				
			||||||
            ):
 | 
					            ):
 | 
				
			||||||
                Notification(
 | 
					                if not user.notifications.filter(
 | 
				
			||||||
                    user=user,
 | 
					                    type="MAILING_MODERATION", viewed=False
 | 
				
			||||||
                    url=reverse("com:mailing_admin"),
 | 
					                ).exists():
 | 
				
			||||||
                    type="MAILING_MODERATION",
 | 
					                    Notification(
 | 
				
			||||||
                ).save(*args, **kwargs)
 | 
					                        user=user,
 | 
				
			||||||
 | 
					                        url=reverse("com:mailing_admin"),
 | 
				
			||||||
 | 
					                        type="MAILING_MODERATION",
 | 
				
			||||||
 | 
					                    ).save(*args, **kwargs)
 | 
				
			||||||
        super().save(*args, **kwargs)
 | 
					        super().save(*args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def clean(self):
 | 
					    def clean(self):
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										117
									
								
								club/tests.py
									
									
									
									
									
								
							
							
						
						
									
										117
									
								
								club/tests.py
									
									
									
									
									
								
							@@ -21,7 +21,6 @@ from django.urls import reverse
 | 
				
			|||||||
from django.utils import timezone
 | 
					from django.utils import timezone
 | 
				
			||||||
from django.utils.timezone import localdate, localtime, now
 | 
					from django.utils.timezone import localdate, localtime, now
 | 
				
			||||||
from django.utils.translation import gettext as _
 | 
					from django.utils.translation import gettext as _
 | 
				
			||||||
from model_bakery import baker
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
from club.forms import MailingForm
 | 
					from club.forms import MailingForm
 | 
				
			||||||
from club.models import Club, Mailing, Membership
 | 
					from club.models import Club, Mailing, Membership
 | 
				
			||||||
@@ -165,27 +164,6 @@ class TestMembershipQuerySet(TestClub):
 | 
				
			|||||||
        assert new_mem != "not_member"
 | 
					        assert new_mem != "not_member"
 | 
				
			||||||
        assert new_mem.role == 5
 | 
					        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):
 | 
					    def test_delete_invalidate_cache(self):
 | 
				
			||||||
        """Test that the `delete` queryset properly invalidate cache."""
 | 
					        """Test that the `delete` queryset properly invalidate cache."""
 | 
				
			||||||
        mem_skia = self.skia.memberships.get(club=self.club)
 | 
					        mem_skia = self.skia.memberships.get(club=self.club)
 | 
				
			||||||
@@ -204,19 +182,6 @@ class TestMembershipQuerySet(TestClub):
 | 
				
			|||||||
            )
 | 
					            )
 | 
				
			||||||
            assert cached_mem == "not_member"
 | 
					            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()) == club_groups
 | 
					 | 
				
			||||||
        user.memberships.all().delete()
 | 
					 | 
				
			||||||
        assert user.groups.all().count() == 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestClubModel(TestClub):
 | 
					class TestClubModel(TestClub):
 | 
				
			||||||
    def assert_membership_started_today(self, user: User, role: int):
 | 
					    def assert_membership_started_today(self, user: User, role: int):
 | 
				
			||||||
@@ -227,8 +192,10 @@ class TestClubModel(TestClub):
 | 
				
			|||||||
        assert membership.end_date is None
 | 
					        assert membership.end_date is None
 | 
				
			||||||
        assert membership.role == role
 | 
					        assert membership.role == role
 | 
				
			||||||
        assert membership.club.get_membership_for(user) == membership
 | 
					        assert membership.club.get_membership_for(user) == membership
 | 
				
			||||||
        assert user.is_in_group(pk=self.club.members_group_id)
 | 
					        member_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
 | 
				
			||||||
        assert user.is_in_group(pk=self.club.board_group_id)
 | 
					        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)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def assert_membership_ended_today(self, user: User):
 | 
					    def assert_membership_ended_today(self, user: User):
 | 
				
			||||||
        """Assert that the given user have a membership which ended today."""
 | 
					        """Assert that the given user have a membership which ended today."""
 | 
				
			||||||
@@ -507,35 +474,37 @@ class TestClubModel(TestClub):
 | 
				
			|||||||
        assert self.club.members.count() == nb_memberships
 | 
					        assert self.club.members.count() == nb_memberships
 | 
				
			||||||
        assert membership == new_mem
 | 
					        assert membership == new_mem
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_remove_from_club_group(self):
 | 
					    def test_delete_remove_from_meta_group(self):
 | 
				
			||||||
        """Test that when a membership ends, the user is removed from club groups."""
 | 
					        """Test that when a club is deleted, all its members are removed from the
 | 
				
			||||||
        user = baker.make(User)
 | 
					        associated metagroup.
 | 
				
			||||||
        baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
 | 
					        """
 | 
				
			||||||
        assert user.groups.contains(self.club.members_group)
 | 
					        memberships = self.club.members.select_related("user")
 | 
				
			||||||
        assert user.groups.contains(self.club.board_group)
 | 
					        users = [membership.user for membership in memberships]
 | 
				
			||||||
        user.memberships.update(end_date=localdate())
 | 
					        meta_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
 | 
				
			||||||
        assert not user.groups.contains(self.club.members_group)
 | 
					 | 
				
			||||||
        assert not user.groups.contains(self.club.board_group)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_add_to_club_group(self):
 | 
					        self.club.delete()
 | 
				
			||||||
        """Test that when a membership begins, the user is added to the club group."""
 | 
					        for user in users:
 | 
				
			||||||
        assert not self.subscriber.groups.contains(self.club.members_group)
 | 
					            assert not user.is_in_group(name=meta_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_change_position_in_club(self):
 | 
					    def test_add_to_meta_group(self):
 | 
				
			||||||
        """Test that when moving from board to members, club group change"""
 | 
					        """Test that when a membership begins, the user is added to the meta group."""
 | 
				
			||||||
        membership = baker.make(
 | 
					        group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
 | 
				
			||||||
            Membership, club=self.club, user=self.subscriber, role=3
 | 
					        board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX
 | 
				
			||||||
        )
 | 
					        assert not self.subscriber.is_in_group(name=group_members)
 | 
				
			||||||
        assert self.subscriber.groups.contains(self.club.members_group)
 | 
					        assert not self.subscriber.is_in_group(name=board_members)
 | 
				
			||||||
        assert self.subscriber.groups.contains(self.club.board_group)
 | 
					        Membership.objects.create(club=self.club, user=self.subscriber, role=3)
 | 
				
			||||||
        membership.role = 1
 | 
					        assert self.subscriber.is_in_group(name=group_members)
 | 
				
			||||||
        membership.save()
 | 
					        assert self.subscriber.is_in_group(name=board_members)
 | 
				
			||||||
        assert self.subscriber.groups.contains(self.club.members_group)
 | 
					
 | 
				
			||||||
        assert not self.subscriber.groups.contains(self.club.board_group)
 | 
					    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_club_owner(self):
 | 
					    def test_club_owner(self):
 | 
				
			||||||
        """Test that a club is owned only by board members of the main club."""
 | 
					        """Test that a club is owned only by board members of the main club."""
 | 
				
			||||||
@@ -548,26 +517,6 @@ class TestClubModel(TestClub):
 | 
				
			|||||||
        Membership(club=self.ae, user=self.sli, role=3).save()
 | 
					        Membership(club=self.ae, user=self.sli, role=3).save()
 | 
				
			||||||
        assert self.club.is_owned_by(self.sli)
 | 
					        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):
 | 
					class TestMailingForm(TestCase):
 | 
				
			||||||
    """Perform validation tests for MailingForm."""
 | 
					    """Perform validation tests for MailingForm."""
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -71,13 +71,14 @@ class ClubTabsMixin(TabedViewMixin):
 | 
				
			|||||||
        return self.object.get_display_name()
 | 
					        return self.object.get_display_name()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_list_of_tabs(self):
 | 
					    def get_list_of_tabs(self):
 | 
				
			||||||
        tab_list = [
 | 
					        tab_list = []
 | 
				
			||||||
 | 
					        tab_list.append(
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
 | 
					                "url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
 | 
				
			||||||
                "slug": "infos",
 | 
					                "slug": "infos",
 | 
				
			||||||
                "name": _("Infos"),
 | 
					                "name": _("Infos"),
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        ]
 | 
					        )
 | 
				
			||||||
        if self.request.user.can_view(self.object):
 | 
					        if self.request.user.can_view(self.object):
 | 
				
			||||||
            tab_list.append(
 | 
					            tab_list.append(
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										32
									
								
								com/api.py
									
									
									
									
									
								
							
							
						
						
									
										32
									
								
								com/api.py
									
									
									
									
									
								
							@@ -1,32 +0,0 @@
 | 
				
			|||||||
from pathlib import Path
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.conf import settings
 | 
					 | 
				
			||||||
from django.http import Http404
 | 
					 | 
				
			||||||
from ninja_extra import ControllerBase, api_controller, route
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from com.calendar import IcsCalendar
 | 
					 | 
				
			||||||
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 it's 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())
 | 
					 | 
				
			||||||
@@ -1,9 +0,0 @@
 | 
				
			|||||||
from django.apps import AppConfig
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class ComConfig(AppConfig):
 | 
					 | 
				
			||||||
    name = "com"
 | 
					 | 
				
			||||||
    verbose_name = "News and communication"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def ready(self):
 | 
					 | 
				
			||||||
        import com.signals  # noqa F401
 | 
					 | 
				
			||||||
@@ -1,76 +0,0 @@
 | 
				
			|||||||
from datetime import datetime, timedelta
 | 
					 | 
				
			||||||
from pathlib import Path
 | 
					 | 
				
			||||||
from typing import final
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import urllib3
 | 
					 | 
				
			||||||
from dateutil.relativedelta import relativedelta
 | 
					 | 
				
			||||||
from django.conf import settings
 | 
					 | 
				
			||||||
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
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@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 = urllib3.request(
 | 
					 | 
				
			||||||
            "GET",
 | 
					 | 
				
			||||||
            "https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics",
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        if calendar.status != 200:
 | 
					 | 
				
			||||||
            return None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
 | 
					 | 
				
			||||||
        with open(cls._EXTERNAL_CALENDAR, "wb") as f:
 | 
					 | 
				
			||||||
            _ = f.write(calendar.data)
 | 
					 | 
				
			||||||
        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
 | 
					 | 
				
			||||||
        calendar = Calendar()
 | 
					 | 
				
			||||||
        for news_date in NewsDate.objects.filter(
 | 
					 | 
				
			||||||
            news__is_moderated=True,
 | 
					 | 
				
			||||||
            end_date__gte=timezone.now() - (relativedelta(months=6)),
 | 
					 | 
				
			||||||
        ).prefetch_related("news"):
 | 
					 | 
				
			||||||
            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)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # 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(IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8"))
 | 
					 | 
				
			||||||
        return cls._INTERNAL_CALENDAR
 | 
					 | 
				
			||||||
@@ -1,56 +0,0 @@
 | 
				
			|||||||
# 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",
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
@@ -17,12 +17,11 @@
 | 
				
			|||||||
# details.
 | 
					# details.
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
# You should have received a copy of the GNU General Public License along with
 | 
					# You should have received a copy of the GNU General Public License along with
 | 
				
			||||||
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
 | 
					# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
 | 
				
			||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
 | 
					# Place - Suite 330, Boston, MA 02111-1307, USA.
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.core.exceptions import ValidationError
 | 
					from django.core.exceptions import ValidationError
 | 
				
			||||||
from django.core.mail import EmailMultiAlternatives
 | 
					from django.core.mail import EmailMultiAlternatives
 | 
				
			||||||
@@ -35,7 +34,7 @@ from django.utils import timezone
 | 
				
			|||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from club.models import Club
 | 
					from club.models import Club
 | 
				
			||||||
from core.models import Notification, Preferences, User
 | 
					from core.models import Notification, Preferences, RealGroup, User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Sith(models.Model):
 | 
					class Sith(models.Model):
 | 
				
			||||||
@@ -63,31 +62,16 @@ NEWS_TYPES = [
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class News(models.Model):
 | 
					class News(models.Model):
 | 
				
			||||||
    """News about club events."""
 | 
					    """The news class."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    title = models.CharField(_("title"), max_length=64)
 | 
					    title = models.CharField(_("title"), max_length=64)
 | 
				
			||||||
    summary = models.TextField(
 | 
					    summary = models.TextField(_("summary"))
 | 
				
			||||||
        _("summary"),
 | 
					    content = models.TextField(_("content"))
 | 
				
			||||||
        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."),
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    type = models.CharField(
 | 
					    type = models.CharField(
 | 
				
			||||||
        _("type"), max_length=16, choices=NEWS_TYPES, default="EVENT"
 | 
					        _("type"), max_length=16, choices=NEWS_TYPES, default="EVENT"
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    club = models.ForeignKey(
 | 
					    club = models.ForeignKey(
 | 
				
			||||||
        Club,
 | 
					        Club, related_name="news", verbose_name=_("club"), on_delete=models.CASCADE
 | 
				
			||||||
        related_name="news",
 | 
					 | 
				
			||||||
        verbose_name=_("club"),
 | 
					 | 
				
			||||||
        on_delete=models.CASCADE,
 | 
					 | 
				
			||||||
        help_text=_("The club which organizes the event."),
 | 
					 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    author = models.ForeignKey(
 | 
					    author = models.ForeignKey(
 | 
				
			||||||
        User,
 | 
					        User,
 | 
				
			||||||
@@ -101,7 +85,7 @@ class News(models.Model):
 | 
				
			|||||||
        related_name="moderated_news",
 | 
					        related_name="moderated_news",
 | 
				
			||||||
        verbose_name=_("moderator"),
 | 
					        verbose_name=_("moderator"),
 | 
				
			||||||
        null=True,
 | 
					        null=True,
 | 
				
			||||||
        on_delete=models.SET_NULL,
 | 
					        on_delete=models.CASCADE,
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
@@ -109,15 +93,17 @@ class News(models.Model):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def save(self, *args, **kwargs):
 | 
					    def save(self, *args, **kwargs):
 | 
				
			||||||
        super().save(*args, **kwargs)
 | 
					        super().save(*args, **kwargs)
 | 
				
			||||||
        for user in User.objects.filter(
 | 
					        for u in (
 | 
				
			||||||
            groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
 | 
					            RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
				
			||||||
 | 
					            .first()
 | 
				
			||||||
 | 
					            .users.all()
 | 
				
			||||||
        ):
 | 
					        ):
 | 
				
			||||||
            Notification.objects.create(
 | 
					            Notification(
 | 
				
			||||||
                user=user,
 | 
					                user=u,
 | 
				
			||||||
                url=reverse("com:news_admin_list"),
 | 
					                url=reverse("com:news_admin_list"),
 | 
				
			||||||
                type="NEWS_MODERATION",
 | 
					                type="NEWS_MODERATION",
 | 
				
			||||||
                param="1",
 | 
					                param="1",
 | 
				
			||||||
            )
 | 
					            ).save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_absolute_url(self):
 | 
					    def get_absolute_url(self):
 | 
				
			||||||
        return reverse("com:news_detail", kwargs={"news_id": self.id})
 | 
					        return reverse("com:news_detail", kwargs={"news_id": self.id})
 | 
				
			||||||
@@ -335,14 +321,16 @@ class Poster(models.Model):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def save(self, *args, **kwargs):
 | 
					    def save(self, *args, **kwargs):
 | 
				
			||||||
        if not self.is_moderated:
 | 
					        if not self.is_moderated:
 | 
				
			||||||
            for user in User.objects.filter(
 | 
					            for u in (
 | 
				
			||||||
                groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
 | 
					                RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
				
			||||||
 | 
					                .first()
 | 
				
			||||||
 | 
					                .users.all()
 | 
				
			||||||
            ):
 | 
					            ):
 | 
				
			||||||
                Notification.objects.create(
 | 
					                Notification(
 | 
				
			||||||
                    user=user,
 | 
					                    user=u,
 | 
				
			||||||
                    url=reverse("com:poster_moderate_list"),
 | 
					                    url=reverse("com:poster_moderate_list"),
 | 
				
			||||||
                    type="POSTER_MODERATION",
 | 
					                    type="POSTER_MODERATION",
 | 
				
			||||||
                )
 | 
					                ).save()
 | 
				
			||||||
        return super().save(*args, **kwargs)
 | 
					        return super().save(*args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def clean(self, *args, **kwargs):
 | 
					    def clean(self, *args, **kwargs):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +0,0 @@
 | 
				
			|||||||
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()
 | 
					 | 
				
			||||||
@@ -1,194 +0,0 @@
 | 
				
			|||||||
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 } from "#openapi";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@registerComponent("ics-calendar")
 | 
					 | 
				
			||||||
export class IcsCalendar extends inheritHtmlElement("div") {
 | 
					 | 
				
			||||||
  static observedAttributes = ["locale"];
 | 
					 | 
				
			||||||
  private calendar: Calendar;
 | 
					 | 
				
			||||||
  private locale = "en";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
 | 
					 | 
				
			||||||
    if (name !== "locale") {
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    this.locale = newValue;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  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);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  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");
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // 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);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    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: [
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
          url: await makeUrl(calendarCalendarInternal),
 | 
					 | 
				
			||||||
          format: "ics",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
          url: await makeUrl(calendarCalendarExternal),
 | 
					 | 
				
			||||||
          format: "ics",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
      ],
 | 
					 | 
				
			||||||
      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();
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,101 +0,0 @@
 | 
				
			|||||||
@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-x: 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;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,61 +0,0 @@
 | 
				
			|||||||
@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;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,297 +0,0 @@
 | 
				
			|||||||
@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;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  @media screen and (max-width: $small-devices) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    #left_column,
 | 
					 | 
				
			||||||
    #right_column {
 | 
					 | 
				
			||||||
      flex: 100%;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /* 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: block;
 | 
					 | 
				
			||||||
        padding: 0.4em;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        &:not(:last-child) {
 | 
					 | 
				
			||||||
          border-bottom: 1px solid grey;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        div {
 | 
					 | 
				
			||||||
          margin: 0.2em;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        h4 {
 | 
					 | 
				
			||||||
          margin-top: 1em;
 | 
					 | 
				
			||||||
          text-transform: uppercase;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        .club_logo {
 | 
					 | 
				
			||||||
          float: left;
 | 
					 | 
				
			||||||
          min-width: 7em;
 | 
					 | 
				
			||||||
          max-width: 9em;
 | 
					 | 
				
			||||||
          margin: 0;
 | 
					 | 
				
			||||||
          margin-right: 1em;
 | 
					 | 
				
			||||||
          margin-top: 0.8em;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          img {
 | 
					 | 
				
			||||||
            max-height: 6em;
 | 
					 | 
				
			||||||
            max-width: 8em;
 | 
					 | 
				
			||||||
            display: block;
 | 
					 | 
				
			||||||
            margin: 0 auto;
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        .news_date {
 | 
					 | 
				
			||||||
          font-size: 100%;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        .news_content {
 | 
					 | 
				
			||||||
          clear: left;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          .button_bar {
 | 
					 | 
				
			||||||
            text-align: right;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            .fb {
 | 
					 | 
				
			||||||
              color: $faceblue;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            .twitter {
 | 
					 | 
				
			||||||
              color: $twitblue;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /* END EVENTS TODAY AND NEXT FEW DAYS */
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /* COMING SOON */
 | 
					 | 
				
			||||||
  .news_coming_soon {
 | 
					 | 
				
			||||||
    display: list-item;
 | 
					 | 
				
			||||||
    list-style-type: square;
 | 
					 | 
				
			||||||
    list-style-position: inside;
 | 
					 | 
				
			||||||
    margin-left: 1em;
 | 
					 | 
				
			||||||
    padding-left: 0;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    a {
 | 
					 | 
				
			||||||
      font-weight: bold;
 | 
					 | 
				
			||||||
      text-transform: uppercase;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .news_date {
 | 
					 | 
				
			||||||
      font-size: 0.9em;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /* END COMING SOON */
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /* NOTICES */
 | 
					 | 
				
			||||||
  .news_notice {
 | 
					 | 
				
			||||||
    margin: 0 0 1em 1em;
 | 
					 | 
				
			||||||
    padding: 0.4em;
 | 
					 | 
				
			||||||
    padding-left: 1em;
 | 
					 | 
				
			||||||
    background: $secondary-neutral-light-color;
 | 
					 | 
				
			||||||
    box-shadow: $shadow-color 0 0 2px;
 | 
					 | 
				
			||||||
    border-radius: 18px 5px 18px 5px;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    h4 {
 | 
					 | 
				
			||||||
      margin: 0;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .news_content {
 | 
					 | 
				
			||||||
      margin-left: 1em;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /* END NOTICES */
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /* CALLS */
 | 
					 | 
				
			||||||
  .news_call {
 | 
					 | 
				
			||||||
    margin: 0 0 1em 1em;
 | 
					 | 
				
			||||||
    padding: 0.4em;
 | 
					 | 
				
			||||||
    padding-left: 1em;
 | 
					 | 
				
			||||||
    background: $secondary-neutral-light-color;
 | 
					 | 
				
			||||||
    border: 1px solid grey;
 | 
					 | 
				
			||||||
    box-shadow: $shadow-color 1px 1px 1px;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    h4 {
 | 
					 | 
				
			||||||
      margin: 0;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .news_date {
 | 
					 | 
				
			||||||
      font-size: 0.9em;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .news_content {
 | 
					 | 
				
			||||||
      margin-left: 1em;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /* END CALLS */
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  .news_empty {
 | 
					 | 
				
			||||||
    margin-left: 1em;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  .news_date {
 | 
					 | 
				
			||||||
    color: grey;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,230 +0,0 @@
 | 
				
			|||||||
#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%;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -11,11 +11,6 @@
 | 
				
			|||||||
  {{ gen_news_metatags(news) }}
 | 
					  {{ gen_news_metatags(news) }}
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
{% block additional_css %}
 | 
					 | 
				
			||||||
  <link rel="stylesheet" href="{{ static('com/css/news-detail.scss') }}">
 | 
					 | 
				
			||||||
{% endblock %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
  <p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
 | 
					  <p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
 | 
				
			||||||
  <section id="news_details">
 | 
					  <section id="news_details">
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -34,90 +34,43 @@
 | 
				
			|||||||
    {% csrf_token %}
 | 
					    {% csrf_token %}
 | 
				
			||||||
    {{ form.non_field_errors() }}
 | 
					    {{ form.non_field_errors() }}
 | 
				
			||||||
    {{ form.author }}
 | 
					    {{ form.author }}
 | 
				
			||||||
    <p>
 | 
					    <p>{{ form.type.errors }}<label for="{{ form.type.name }}">{{ form.type.label }}</label>
 | 
				
			||||||
      {{ form.type.errors }}
 | 
					 | 
				
			||||||
      <label for="{{ form.type.name }}" class="required">{{ form.type.label }}</label>
 | 
					 | 
				
			||||||
      <ul>
 | 
					      <ul>
 | 
				
			||||||
        <li>{% trans %}Notice: Information, election result - no date{% endtrans %}</li>
 | 
					        <li>{% trans %}Notice: Information, election result - no date{% endtrans %}</li>
 | 
				
			||||||
        <li>{% trans %}Event: punctual event, associated with one date{% endtrans %}</li>
 | 
					        <li>{% trans %}Event: punctual event, associated with one date{% endtrans %}</li>
 | 
				
			||||||
        <li>
 | 
					        <li>{% trans %}Weekly: recurrent event, associated with many dates (specify the first one, and a deadline){% endtrans %}</li>
 | 
				
			||||||
          {% trans trimmed%}
 | 
					        <li>{% trans %}Call: long time event, associated with a long date (election appliance, ...){% endtrans %}</li>
 | 
				
			||||||
            Weekly: recurrent event, associated with many dates
 | 
					 | 
				
			||||||
            (specify the first one, and a deadline)
 | 
					 | 
				
			||||||
          {% endtrans %}
 | 
					 | 
				
			||||||
        </li>
 | 
					 | 
				
			||||||
        <li>
 | 
					 | 
				
			||||||
          {% trans trimmed %}
 | 
					 | 
				
			||||||
            Call: long time event, associated with a long date (like election appliance)
 | 
					 | 
				
			||||||
          {% endtrans %}
 | 
					 | 
				
			||||||
        </li>
 | 
					 | 
				
			||||||
      </ul>
 | 
					      </ul>
 | 
				
			||||||
      {{ form.type }}
 | 
					      {{ form.type }}</p>
 | 
				
			||||||
    </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">
 | 
					    <p class="date">{{ form.end_date.errors }}<label for="{{ form.end_date.name }}">{{ form.end_date.label }}</label> {{ form.end_date }}</p>
 | 
				
			||||||
      {{ form.start_date.errors }}
 | 
					    <p class="until">{{ form.until.errors }}<label for="{{ form.until.name }}">{{ form.until.label }}</label> {{ form.until }}</p>
 | 
				
			||||||
      <label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label>
 | 
					    <p>{{ form.title.errors }}<label for="{{ form.title.name }}">{{ form.title.label }}</label> {{ form.title }}</p>
 | 
				
			||||||
      {{ form.start_date }}
 | 
					    <p>{{ form.club.errors }}<label for="{{ form.club.name }}">{{ form.club.label }}</label> {{ form.club }}</p>
 | 
				
			||||||
    </p>
 | 
					    <p>{{ form.summary.errors }}<label for="{{ form.summary.name }}">{{ form.summary.label }}</label> {{ form.summary }}</p>
 | 
				
			||||||
    <p class="date">
 | 
					    <p>{{ form.content.errors }}<label for="{{ form.content.name }}">{{ form.content.label }}</label> {{ form.content }}</p>
 | 
				
			||||||
      {{ 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 }}" class="required">{{ form.title.label }}</label>
 | 
					 | 
				
			||||||
      {{ form.title }}
 | 
					 | 
				
			||||||
    </p>
 | 
					 | 
				
			||||||
    <p>
 | 
					 | 
				
			||||||
      {{ form.club.errors }}
 | 
					 | 
				
			||||||
      <label for="{{ form.club.name }}" class="required">{{ form.club.label }}</label>
 | 
					 | 
				
			||||||
      <span class="helptext">{{ form.club.help_text }}</span>
 | 
					 | 
				
			||||||
      {{ form.club }}
 | 
					 | 
				
			||||||
    </p>
 | 
					 | 
				
			||||||
    <p>
 | 
					 | 
				
			||||||
      {{ form.summary.errors }}
 | 
					 | 
				
			||||||
      <label for="{{ form.summary.name }}" class="required">{{ form.summary.label }}</label>
 | 
					 | 
				
			||||||
      <span class="helptext">{{ form.summary.help_text }}</span>
 | 
					 | 
				
			||||||
      {{ form.summary }}
 | 
					 | 
				
			||||||
    </p>
 | 
					 | 
				
			||||||
    <p>
 | 
					 | 
				
			||||||
      {{ form.content.errors }}
 | 
					 | 
				
			||||||
      <label for="{{ form.content.name }}">{{ form.content.label }}</label>
 | 
					 | 
				
			||||||
      <span class="helptext">{{ form.content.help_text }}</span>
 | 
					 | 
				
			||||||
      {{ form.content }}
 | 
					 | 
				
			||||||
    </p>
 | 
					 | 
				
			||||||
    {% if user.is_com_admin %}
 | 
					    {% if user.is_com_admin %}
 | 
				
			||||||
      <p>
 | 
					      <p>{{ form.automoderation.errors }}<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
 | 
				
			||||||
        {{ form.automoderation.errors }}
 | 
					        {{ form.automoderation }}</p>
 | 
				
			||||||
        <label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
 | 
					 | 
				
			||||||
        {{ form.automoderation }}
 | 
					 | 
				
			||||||
      </p>
 | 
					 | 
				
			||||||
    {% endif %}
 | 
					    {% endif %}
 | 
				
			||||||
    <p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}"/></p>
 | 
					    <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 %}" /></p>
 | 
				
			||||||
  </form>
 | 
					  </form>
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block script %}
 | 
					{% block script %}
 | 
				
			||||||
  {{ super() }}
 | 
					  {{ super() }}
 | 
				
			||||||
  <script>
 | 
					  <script>
 | 
				
			||||||
    $(function () {
 | 
					    $( function() {
 | 
				
			||||||
      let type = $('input[name=type]');
 | 
					      var type = $('input[name=type]');
 | 
				
			||||||
      let dates = $('.date');
 | 
					      var dates = $('.date');
 | 
				
			||||||
      let until = $('.until');
 | 
					      var until = $('.until');
 | 
				
			||||||
 | 
					      function update_targets () {
 | 
				
			||||||
      function update_targets() {
 | 
					        type_checked = $('input[name=type]:checked');
 | 
				
			||||||
        const type_checked = $('input[name=type]:checked');
 | 
					        if (type_checked.val() == "EVENT" || type_checked.val() == "CALL") {
 | 
				
			||||||
        if (["CALL", "EVENT"].includes(type_checked.val())) {
 | 
					 | 
				
			||||||
          dates.show();
 | 
					          dates.show();
 | 
				
			||||||
          until.hide();
 | 
					          until.hide();
 | 
				
			||||||
        } else if (type_checked.val() === "WEEKLY") {
 | 
					        } else if (type_checked.val() == "WEEKLY") {
 | 
				
			||||||
          dates.show();
 | 
					          dates.show();
 | 
				
			||||||
          until.show();
 | 
					          until.show();
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
@@ -125,10 +78,9 @@
 | 
				
			|||||||
          until.hide();
 | 
					          until.hide();
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					 | 
				
			||||||
      update_targets();
 | 
					      update_targets();
 | 
				
			||||||
      type.change(update_targets);
 | 
					      type.change(update_targets);
 | 
				
			||||||
    });
 | 
					    } );
 | 
				
			||||||
  </script>
 | 
					  </script>
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,15 +5,6 @@
 | 
				
			|||||||
  {% trans %}News{% endtrans %}
 | 
					  {% trans %}News{% endtrans %}
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block additional_css %}
 | 
					 | 
				
			||||||
  <link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
 | 
					 | 
				
			||||||
  <link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
 | 
					 | 
				
			||||||
{% endblock %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% block additional_js %}
 | 
					 | 
				
			||||||
  <script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script>
 | 
					 | 
				
			||||||
{% endblock %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
  {% if user.is_com_admin %}
 | 
					  {% if user.is_com_admin %}
 | 
				
			||||||
    <div id="news_admin">
 | 
					    <div id="news_admin">
 | 
				
			||||||
@@ -92,78 +83,84 @@
 | 
				
			|||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
{% endif %}
 | 
					{% 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>
 | 
					<h3>{% trans %}All coming events{% endtrans %}</h3>
 | 
				
			||||||
<ics-calendar locale="{{ get_language() }}"></ics-calendar>
 | 
					<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>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div id="right_column">
 | 
					<div id="right_column" class="news_column">
 | 
				
			||||||
  <div id="links">
 | 
					  <div id="agenda">
 | 
				
			||||||
    <h3>{% trans %}Links{% endtrans %}</h3>
 | 
					    <div id="agenda_title">{% trans %}Agenda{% endtrans %}</div>
 | 
				
			||||||
    <div id="links_content">
 | 
					    <div id="agenda_content">
 | 
				
			||||||
      <h4>{% trans %}Our services{% endtrans %}</h4>
 | 
					      {% for d in NewsDate.objects.filter(end_date__gte=timezone.now(),
 | 
				
			||||||
      <ul>
 | 
					      news__is_moderated=True, news__type__in=["WEEKLY",
 | 
				
			||||||
        <li>
 | 
					      "EVENT"]).order_by('start_date', 'end_date') %}
 | 
				
			||||||
          <i class="fa-solid fa-graduation-cap fa-xl"></i>
 | 
					      <div class="agenda_item">
 | 
				
			||||||
          <a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
 | 
					        <div class="agenda_date">
 | 
				
			||||||
        </li>
 | 
					          <strong>{{ d.start_date|localtime|date('D d M Y') }}</strong>
 | 
				
			||||||
        <li>
 | 
					        </div>
 | 
				
			||||||
          <i class="fa-solid fa-magnifying-glass fa-xl"></i>
 | 
					        <div class="agenda_time">
 | 
				
			||||||
          <a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
 | 
					          <span>{{ d.start_date|localtime|time(DATETIME_FORMAT) }}</span> -
 | 
				
			||||||
        </li>
 | 
					          <span>{{ d.end_date|localtime|time(DATETIME_FORMAT) }}</span>
 | 
				
			||||||
        <li>
 | 
					        </div>
 | 
				
			||||||
          <i class="fa-solid fa-check-to-slot fa-xl"></i>
 | 
					        <div>
 | 
				
			||||||
          <a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
 | 
					          <strong><a href="{{ url('com:news_detail', news_id=d.news.id) }}">{{ d.news.title }}</a></strong>
 | 
				
			||||||
        </li>
 | 
					          <a href="{{ d.news.club.get_absolute_url() }}">{{ d.news.club }}</a>
 | 
				
			||||||
      </ul>
 | 
					        </div>
 | 
				
			||||||
      <br>
 | 
					        <div class="agenda_item_content">{{ d.news.summary|markdown }}</div>
 | 
				
			||||||
      <h4>{% trans %}Social media{% endtrans %}</h4>
 | 
					      </div>
 | 
				
			||||||
      <ul>
 | 
					      {% endfor %}
 | 
				
			||||||
        <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/">{% trans %}Facebook{% endtrans %}</a>
 | 
					 | 
				
			||||||
        </li>
 | 
					 | 
				
			||||||
        <li>
 | 
					 | 
				
			||||||
          <i class="fa-brands fa-square-instagram fa-xl"></i>
 | 
					 | 
				
			||||||
          <a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">{% trans %}Instagram{% endtrans %}</a>
 | 
					 | 
				
			||||||
        </li>
 | 
					 | 
				
			||||||
      </ul>
 | 
					 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div id="birthdays">
 | 
					  <div id="birthdays">
 | 
				
			||||||
    <h3>{% trans %}Birthdays{% endtrans %}</h3>
 | 
					    <div id="birthdays_title">{% trans %}Birthdays{% endtrans %}</div>
 | 
				
			||||||
    <div id="birthdays_content">
 | 
					    <div id="birthdays_content">
 | 
				
			||||||
      {%- if user.was_subscribed -%}
 | 
					      {% if user.is_subscribed %}
 | 
				
			||||||
        <ul class="birthdays_year">
 | 
					                    {# Cache request for 1 hour #}
 | 
				
			||||||
          {%- for year, users in birthdays -%}
 | 
					        {% cache 3600 "birthdays" %}
 | 
				
			||||||
            <li>
 | 
					          <ul class="birthdays_year">
 | 
				
			||||||
              {% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %}
 | 
					            {% for d in birthdays.dates('date_of_birth', 'year', 'DESC') %}
 | 
				
			||||||
              <ul>
 | 
					              <li>
 | 
				
			||||||
                {%- for u in users -%}
 | 
					                {% trans age=timezone.now().year - d.year %}{{ age }} year old{% endtrans %}
 | 
				
			||||||
                  <li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
 | 
					                <ul>
 | 
				
			||||||
                {%- endfor -%}
 | 
					                  {% for u in birthdays.filter(date_of_birth__year=d.year) %}
 | 
				
			||||||
              </ul>
 | 
					                    <li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
 | 
				
			||||||
            </li>
 | 
					                  {% endfor %}
 | 
				
			||||||
          {%- endfor -%}
 | 
					                </ul>
 | 
				
			||||||
        </ul>
 | 
					              </li>
 | 
				
			||||||
      {%- else -%}
 | 
					            {% endfor %}
 | 
				
			||||||
        <p>{% trans %}You need to subscribe to access this content{% endtrans %}</p>
 | 
					          </ul>
 | 
				
			||||||
      {%- endif -%}
 | 
					        {% endcache %}
 | 
				
			||||||
 | 
					      {% else %}
 | 
				
			||||||
 | 
					        <p>{% trans %}You need an up to date subscription to access this content{% endtrans %}</p>
 | 
				
			||||||
 | 
					      {% endif %}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -10,10 +10,6 @@
 | 
				
			|||||||
  {% trans %}Poster{% endtrans %}
 | 
					  {% trans %}Poster{% endtrans %}
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block additional_css %}
 | 
					 | 
				
			||||||
  <link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
 | 
					 | 
				
			||||||
{% endblock %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
  <div id="poster_list">
 | 
					  <div id="poster_list">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,10 +5,6 @@
 | 
				
			|||||||
  <script src="{{ static('com/js/poster_list.js') }}"></script>
 | 
					  <script src="{{ static('com/js/poster_list.js') }}"></script>
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block additional_css %}
 | 
					 | 
				
			||||||
  <link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
 | 
					 | 
				
			||||||
{% endblock %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
  <div id="poster_list">
 | 
					  <div id="poster_list">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,7 +3,7 @@
 | 
				
			|||||||
  <head>
 | 
					  <head>
 | 
				
			||||||
    <title>{% trans %}Slideshow{% endtrans %}</title>
 | 
					    <title>{% trans %}Slideshow{% endtrans %}</title>
 | 
				
			||||||
    <link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
 | 
					    <link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
 | 
				
			||||||
    <script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
 | 
					    <script type="module" src="{{ static('bundled/jquery-index.js') }}"></script>
 | 
				
			||||||
    <script src="{{ static('com/js/slideshow.js') }}"></script>
 | 
					    <script src="{{ static('com/js/slideshow.js') }}"></script>
 | 
				
			||||||
  </head>
 | 
					  </head>
 | 
				
			||||||
  <body>
 | 
					  <body>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,7 +23,7 @@ from django.utils.translation import gettext as _
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from club.models import Club, Membership
 | 
					from club.models import Club, Membership
 | 
				
			||||||
from com.models import News, Poster, Sith, Weekmail, WeekmailArticle
 | 
					from com.models import News, Poster, Sith, Weekmail, WeekmailArticle
 | 
				
			||||||
from core.models import AnonymousUser, Group, User
 | 
					from core.models import AnonymousUser, RealGroup, User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@pytest.fixture()
 | 
					@pytest.fixture()
 | 
				
			||||||
@@ -49,7 +49,9 @@ class TestCom(TestCase):
 | 
				
			|||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
    def setUpTestData(cls):
 | 
					    def setUpTestData(cls):
 | 
				
			||||||
        cls.skia = User.objects.get(username="skia")
 | 
					        cls.skia = User.objects.get(username="skia")
 | 
				
			||||||
        cls.com_group = Group.objects.get(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
					        cls.com_group = RealGroup.objects.filter(
 | 
				
			||||||
 | 
					            id=settings.SITH_GROUP_COM_ADMIN_ID
 | 
				
			||||||
 | 
					        ).first()
 | 
				
			||||||
        cls.skia.groups.set([cls.com_group])
 | 
					        cls.skia.groups.set([cls.com_group])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def setUp(self):
 | 
					    def setUp(self):
 | 
				
			||||||
@@ -97,7 +99,9 @@ class TestCom(TestCase):
 | 
				
			|||||||
        response = self.client.get(reverse("core:index"))
 | 
					        response = self.client.get(reverse("core:index"))
 | 
				
			||||||
        self.assertContains(
 | 
					        self.assertContains(
 | 
				
			||||||
            response,
 | 
					            response,
 | 
				
			||||||
            text=html.escape(_("You need to subscribe to access this content")),
 | 
					            text=html.escape(
 | 
				
			||||||
 | 
					                _("You need an up to date subscription to access this content")
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_birthday_subscibed_user(self):
 | 
					    def test_birthday_subscibed_user(self):
 | 
				
			||||||
@@ -105,16 +109,9 @@ class TestCom(TestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        self.assertNotContains(
 | 
					        self.assertNotContains(
 | 
				
			||||||
            response,
 | 
					            response,
 | 
				
			||||||
            text=html.escape(_("You need to subscribe to access this content")),
 | 
					            text=html.escape(
 | 
				
			||||||
        )
 | 
					                _("You need an up to date subscription 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")),
 | 
					 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1,122 +0,0 @@
 | 
				
			|||||||
from dataclasses import dataclass
 | 
					 | 
				
			||||||
from datetime import datetime, timedelta
 | 
					 | 
				
			||||||
from pathlib import Path
 | 
					 | 
				
			||||||
from typing import Callable
 | 
					 | 
				
			||||||
from unittest.mock import MagicMock, patch
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import pytest
 | 
					 | 
				
			||||||
from django.conf import settings
 | 
					 | 
				
			||||||
from django.http import HttpResponse
 | 
					 | 
				
			||||||
from django.test.client import Client
 | 
					 | 
				
			||||||
from django.urls import reverse
 | 
					 | 
				
			||||||
from django.utils import timezone
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from com.calendar import IcsCalendar
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@dataclass
 | 
					 | 
				
			||||||
class MockResponse:
 | 
					 | 
				
			||||||
    status: int
 | 
					 | 
				
			||||||
    value: str
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def data(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("urllib3.request", 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)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @pytest.mark.parametrize("error_code", [403, 404, 500])
 | 
					 | 
				
			||||||
    def test_fetch_error(
 | 
					 | 
				
			||||||
        self, client: Client, mock_request: MagicMock, error_code: int
 | 
					 | 
				
			||||||
    ):
 | 
					 | 
				
			||||||
        mock_request.return_value = MockResponse(error_code, "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(200, "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()
 | 
					 | 
				
			||||||
							
								
								
									
										73
									
								
								com/views.py
									
									
									
									
									
								
							
							
						
						
									
										73
									
								
								com/views.py
									
									
									
									
									
								
							@@ -21,14 +21,14 @@
 | 
				
			|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
 | 
					# Place - Suite 330, Boston, MA 02111-1307, USA.
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
import itertools
 | 
					
 | 
				
			||||||
from datetime import timedelta
 | 
					from datetime import timedelta
 | 
				
			||||||
from smtplib import SMTPRecipientsRefused
 | 
					from smtplib import SMTPRecipientsRefused
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django import forms
 | 
					from django import forms
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.core.exceptions import PermissionDenied, ValidationError
 | 
					from django.core.exceptions import PermissionDenied, ValidationError
 | 
				
			||||||
from django.db.models import Exists, Max, OuterRef
 | 
					from django.db.models import Max
 | 
				
			||||||
from django.forms.models import modelform_factory
 | 
					from django.forms.models import modelform_factory
 | 
				
			||||||
from django.http import HttpResponseRedirect
 | 
					from django.http import HttpResponseRedirect
 | 
				
			||||||
from django.shortcuts import get_object_or_404, redirect
 | 
					from django.shortcuts import get_object_or_404, redirect
 | 
				
			||||||
@@ -42,7 +42,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from club.models import Club, Mailing
 | 
					from club.models import Club, Mailing
 | 
				
			||||||
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
 | 
					from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
 | 
				
			||||||
from core.models import Notification, User
 | 
					from core.models import Notification, RealGroup, User
 | 
				
			||||||
from core.views import (
 | 
					from core.views import (
 | 
				
			||||||
    CanCreateMixin,
 | 
					    CanCreateMixin,
 | 
				
			||||||
    CanEditMixin,
 | 
					    CanEditMixin,
 | 
				
			||||||
@@ -223,13 +223,15 @@ class NewsForm(forms.ModelForm):
 | 
				
			|||||||
            ):
 | 
					            ):
 | 
				
			||||||
                self.add_error(
 | 
					                self.add_error(
 | 
				
			||||||
                    "end_date",
 | 
					                    "end_date",
 | 
				
			||||||
                    ValidationError(_("An event cannot end before its beginning.")),
 | 
					                    ValidationError(
 | 
				
			||||||
 | 
					                        _("You crazy? You can not finish an event before starting it.")
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
            if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
 | 
					            if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
 | 
				
			||||||
                self.add_error("until", ValidationError(_("This field is required.")))
 | 
					                self.add_error("until", ValidationError(_("This field is required.")))
 | 
				
			||||||
        return self.cleaned_data
 | 
					        return self.cleaned_data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def save(self, *args, **kwargs):
 | 
					    def save(self):
 | 
				
			||||||
        ret = super().save()
 | 
					        ret = super().save()
 | 
				
			||||||
        self.instance.dates.all().delete()
 | 
					        self.instance.dates.all().delete()
 | 
				
			||||||
        if self.instance.type == "EVENT" or self.instance.type == "CALL":
 | 
					        if self.instance.type == "EVENT" or self.instance.type == "CALL":
 | 
				
			||||||
@@ -278,18 +280,21 @@ class NewsEditView(CanEditMixin, UpdateView):
 | 
				
			|||||||
        else:
 | 
					        else:
 | 
				
			||||||
            self.object.is_moderated = False
 | 
					            self.object.is_moderated = False
 | 
				
			||||||
            self.object.save()
 | 
					            self.object.save()
 | 
				
			||||||
            unread_notif_subquery = Notification.objects.filter(
 | 
					            for u in (
 | 
				
			||||||
                user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
 | 
					                RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
				
			||||||
            )
 | 
					                .first()
 | 
				
			||||||
            for user in User.objects.filter(
 | 
					                .users.all()
 | 
				
			||||||
                ~Exists(unread_notif_subquery),
 | 
					 | 
				
			||||||
                groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
 | 
					 | 
				
			||||||
            ):
 | 
					            ):
 | 
				
			||||||
                Notification.objects.create(
 | 
					                if not u.notifications.filter(
 | 
				
			||||||
                    user=user,
 | 
					                    type="NEWS_MODERATION", viewed=False
 | 
				
			||||||
                    url=self.object.get_absolute_url(),
 | 
					                ).exists():
 | 
				
			||||||
                    type="NEWS_MODERATION",
 | 
					                    Notification(
 | 
				
			||||||
                )
 | 
					                        user=u,
 | 
				
			||||||
 | 
					                        url=reverse(
 | 
				
			||||||
 | 
					                            "com:news_detail", kwargs={"news_id": self.object.id}
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        type="NEWS_MODERATION",
 | 
				
			||||||
 | 
					                    ).save()
 | 
				
			||||||
        return super().form_valid(form)
 | 
					        return super().form_valid(form)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -320,18 +325,19 @@ class NewsCreateView(CanCreateMixin, CreateView):
 | 
				
			|||||||
            self.object.is_moderated = True
 | 
					            self.object.is_moderated = True
 | 
				
			||||||
            self.object.save()
 | 
					            self.object.save()
 | 
				
			||||||
        else:
 | 
					        else:
 | 
				
			||||||
            unread_notif_subquery = Notification.objects.filter(
 | 
					            for u in (
 | 
				
			||||||
                user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
 | 
					                RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
				
			||||||
            )
 | 
					                .first()
 | 
				
			||||||
            for user in User.objects.filter(
 | 
					                .users.all()
 | 
				
			||||||
                ~Exists(unread_notif_subquery),
 | 
					 | 
				
			||||||
                groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
 | 
					 | 
				
			||||||
            ):
 | 
					            ):
 | 
				
			||||||
                Notification.objects.create(
 | 
					                if not u.notifications.filter(
 | 
				
			||||||
                    user=user,
 | 
					                    type="NEWS_MODERATION", viewed=False
 | 
				
			||||||
                    url=reverse("com:news_admin_list"),
 | 
					                ).exists():
 | 
				
			||||||
                    type="NEWS_MODERATION",
 | 
					                    Notification(
 | 
				
			||||||
                )
 | 
					                        user=u,
 | 
				
			||||||
 | 
					                        url=reverse("com:news_admin_list"),
 | 
				
			||||||
 | 
					                        type="NEWS_MODERATION",
 | 
				
			||||||
 | 
					                    ).save()
 | 
				
			||||||
        return super().form_valid(form)
 | 
					        return super().form_valid(form)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -374,14 +380,13 @@ class NewsListView(CanViewMixin, ListView):
 | 
				
			|||||||
        kwargs = super().get_context_data(**kwargs)
 | 
					        kwargs = super().get_context_data(**kwargs)
 | 
				
			||||||
        kwargs["NewsDate"] = NewsDate
 | 
					        kwargs["NewsDate"] = NewsDate
 | 
				
			||||||
        kwargs["timedelta"] = timedelta
 | 
					        kwargs["timedelta"] = timedelta
 | 
				
			||||||
        kwargs["birthdays"] = itertools.groupby(
 | 
					        kwargs["birthdays"] = (
 | 
				
			||||||
            User.objects.filter(
 | 
					            User.objects.filter(
 | 
				
			||||||
                date_of_birth__month=localdate().month,
 | 
					                date_of_birth__month=localdate().month,
 | 
				
			||||||
                date_of_birth__day=localdate().day,
 | 
					                date_of_birth__day=localdate().day,
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            .filter(role__in=["STUDENT", "FORMER STUDENT"])
 | 
					            .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
 | 
					        return kwargs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -685,12 +690,8 @@ class PosterEditBaseView(UpdateView):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def get_initial(self):
 | 
					    def get_initial(self):
 | 
				
			||||||
        return {
 | 
					        return {
 | 
				
			||||||
            "date_begin": self.object.date_begin.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
 | 
					            "date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S"),
 | 
				
			||||||
            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):
 | 
					    def dispatch(self, request, *args, **kwargs):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,32 +15,17 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from django.contrib import admin
 | 
					from django.contrib import admin
 | 
				
			||||||
from django.contrib.auth.models import Group as AuthGroup
 | 
					from django.contrib.auth.models import Group as AuthGroup
 | 
				
			||||||
from django.contrib.auth.models import Permission
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.models import BanGroup, Group, OperationLog, Page, SithFile, User, UserBan
 | 
					from core.models import Group, OperationLog, Page, SithFile, User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
admin.site.unregister(AuthGroup)
 | 
					admin.site.unregister(AuthGroup)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@admin.register(Group)
 | 
					@admin.register(Group)
 | 
				
			||||||
class GroupAdmin(admin.ModelAdmin):
 | 
					class GroupAdmin(admin.ModelAdmin):
 | 
				
			||||||
    list_display = ("name", "description", "is_manually_manageable")
 | 
					    list_display = ("name", "description", "is_meta")
 | 
				
			||||||
    list_filter = ("is_manually_manageable",)
 | 
					    list_filter = ("is_meta",)
 | 
				
			||||||
    search_fields = ("name",)
 | 
					    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)
 | 
					@admin.register(User)
 | 
				
			||||||
@@ -52,24 +37,10 @@ class UserAdmin(admin.ModelAdmin):
 | 
				
			|||||||
        "profile_pict",
 | 
					        "profile_pict",
 | 
				
			||||||
        "avatar_pict",
 | 
					        "avatar_pict",
 | 
				
			||||||
        "scrub_pict",
 | 
					        "scrub_pict",
 | 
				
			||||||
        "user_permissions",
 | 
					 | 
				
			||||||
        "groups",
 | 
					 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    inlines = (UserBanInline,)
 | 
					 | 
				
			||||||
    search_fields = ["first_name", "last_name", "username"]
 | 
					    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)
 | 
					@admin.register(Page)
 | 
				
			||||||
class PageAdmin(admin.ModelAdmin):
 | 
					class PageAdmin(admin.ModelAdmin):
 | 
				
			||||||
    list_display = ("name", "_full_name", "owner_group")
 | 
					    list_display = ("name", "_full_name", "owner_group")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,42 +0,0 @@
 | 
				
			|||||||
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)
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
@@ -7,7 +7,7 @@ from model_bakery import seq
 | 
				
			|||||||
from model_bakery.recipe import Recipe, related
 | 
					from model_bakery.recipe import Recipe, related
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from club.models import Membership
 | 
					from club.models import Membership
 | 
				
			||||||
from core.models import Group, User
 | 
					from core.models import User
 | 
				
			||||||
from subscription.models import Subscription
 | 
					from subscription.models import Subscription
 | 
				
			||||||
 | 
					
 | 
				
			||||||
active_subscription = Recipe(
 | 
					active_subscription = Recipe(
 | 
				
			||||||
@@ -60,6 +60,5 @@ board_user = Recipe(
 | 
				
			|||||||
    first_name="AE",
 | 
					    first_name="AE",
 | 
				
			||||||
    last_name=seq("member "),
 | 
					    last_name=seq("member "),
 | 
				
			||||||
    memberships=related(ae_board_membership),
 | 
					    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."""
 | 
					"""A user which is in the board of the AE."""
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,7 +23,7 @@
 | 
				
			|||||||
from datetime import date, timedelta
 | 
					from datetime import date, timedelta
 | 
				
			||||||
from io import StringIO
 | 
					from io import StringIO
 | 
				
			||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
from typing import ClassVar, NamedTuple
 | 
					from typing import ClassVar
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.contrib.auth.models import Permission
 | 
					from django.contrib.auth.models import Permission
 | 
				
			||||||
@@ -31,7 +31,6 @@ from django.contrib.sites.models import Site
 | 
				
			|||||||
from django.core.management import call_command
 | 
					from django.core.management import call_command
 | 
				
			||||||
from django.core.management.base import BaseCommand
 | 
					from django.core.management.base import BaseCommand
 | 
				
			||||||
from django.db import connection
 | 
					from django.db import connection
 | 
				
			||||||
from django.db.models import Q
 | 
					 | 
				
			||||||
from django.utils import timezone
 | 
					from django.utils import timezone
 | 
				
			||||||
from django.utils.timezone import localdate
 | 
					from django.utils.timezone import localdate
 | 
				
			||||||
from PIL import Image
 | 
					from PIL import Image
 | 
				
			||||||
@@ -46,9 +45,8 @@ from accounting.models import (
 | 
				
			|||||||
    SimplifiedAccountingType,
 | 
					    SimplifiedAccountingType,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from club.models import Club, Membership
 | 
					from club.models import Club, Membership
 | 
				
			||||||
from com.calendar import IcsCalendar
 | 
					 | 
				
			||||||
from com.models import News, NewsDate, Sith, Weekmail
 | 
					from com.models import News, NewsDate, Sith, Weekmail
 | 
				
			||||||
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
 | 
					from core.models import Group, Page, PageRev, RealGroup, SithFile, User
 | 
				
			||||||
from core.utils import resize_image
 | 
					from core.utils import resize_image
 | 
				
			||||||
from counter.models import Counter, Product, ProductType, StudentCard
 | 
					from counter.models import Counter, Product, ProductType, StudentCard
 | 
				
			||||||
from election.models import Candidature, Election, ElectionList, Role
 | 
					from election.models import Candidature, Election, ElectionList, Role
 | 
				
			||||||
@@ -58,18 +56,6 @@ from sas.models import Album, PeoplePictureRelation, Picture
 | 
				
			|||||||
from subscription.models import Subscription
 | 
					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):
 | 
					class Command(BaseCommand):
 | 
				
			||||||
    ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
 | 
					    ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
 | 
				
			||||||
    SAS_FIXTURE_PATH: ClassVar[Path] = (
 | 
					    SAS_FIXTURE_PATH: ClassVar[Path] = (
 | 
				
			||||||
@@ -83,7 +69,7 @@ class Command(BaseCommand):
 | 
				
			|||||||
            # sqlite doesn't support this operation
 | 
					            # sqlite doesn't support this operation
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        sqlcmd = StringIO()
 | 
					        sqlcmd = StringIO()
 | 
				
			||||||
        call_command("sqlsequencereset", "--no-color", *args, stdout=sqlcmd)
 | 
					        call_command("sqlsequencereset", *args, stdout=sqlcmd)
 | 
				
			||||||
        cursor = connection.cursor()
 | 
					        cursor = connection.cursor()
 | 
				
			||||||
        cursor.execute(sqlcmd.getvalue())
 | 
					        cursor.execute(sqlcmd.getvalue())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -93,8 +79,25 @@ class Command(BaseCommand):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
 | 
					        Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
 | 
				
			||||||
        Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
 | 
					        Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
 | 
				
			||||||
        groups = self._create_groups()
 | 
					
 | 
				
			||||||
        self._create_ban_groups()
 | 
					        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")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        change_billing = Permission.objects.get(codename="change_billinginfo")
 | 
				
			||||||
 | 
					        add_billing = Permission.objects.get(codename="add_billinginfo")
 | 
				
			||||||
 | 
					        root_group.permissions.add(change_billing, add_billing)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        root = User.objects.create_superuser(
 | 
					        root = User.objects.create_superuser(
 | 
				
			||||||
            id=0,
 | 
					            id=0,
 | 
				
			||||||
@@ -134,10 +137,11 @@ class Command(BaseCommand):
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.reset_index("club")
 | 
					        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 = [
 | 
					        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="Eboutic", club=main_club, type="EBOUTIC"),
 | 
				
			||||||
            Counter(name="AE", club=main_club, type="OFFICE"),
 | 
					            Counter(name="AE", club=main_club, type="OFFICE"),
 | 
				
			||||||
            Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
 | 
					            Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
 | 
				
			||||||
@@ -145,16 +149,14 @@ class Command(BaseCommand):
 | 
				
			|||||||
        Counter.objects.bulk_create(counters)
 | 
					        Counter.objects.bulk_create(counters)
 | 
				
			||||||
        bar_groups = []
 | 
					        bar_groups = []
 | 
				
			||||||
        for bar_id, bar_name in settings.SITH_COUNTER_BARS:
 | 
					        for bar_id, bar_name in settings.SITH_COUNTER_BARS:
 | 
				
			||||||
            group = Group.objects.create(
 | 
					            group = RealGroup.objects.create(name=f"{bar_name} admin")
 | 
				
			||||||
                name=f"{bar_name} admin", is_manually_manageable=True
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            bar_groups.append(
 | 
					            bar_groups.append(
 | 
				
			||||||
                Counter.edit_groups.through(counter_id=bar_id, group=group)
 | 
					                Counter.edit_groups.through(counter_id=bar_id, group=group)
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        Counter.edit_groups.through.objects.bulk_create(bar_groups)
 | 
					        Counter.edit_groups.through.objects.bulk_create(bar_groups)
 | 
				
			||||||
        self.reset_index("counter")
 | 
					        self.reset_index("counter")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        groups.subscribers.viewable_files.add(home_root, club_root)
 | 
					        subscribers.viewable_files.add(home_root, club_root)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Weekmail().save()
 | 
					        Weekmail().save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -259,11 +261,21 @@ class Command(BaseCommand):
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
        User.groups.through.objects.bulk_create(
 | 
					        User.groups.through.objects.bulk_create(
 | 
				
			||||||
            [
 | 
					            [
 | 
				
			||||||
                User.groups.through(group=groups.counter_admin, user=counter),
 | 
					                User.groups.through(
 | 
				
			||||||
                User.groups.through(group=groups.accounting_admin, user=comptable),
 | 
					                    realgroup_id=settings.SITH_GROUP_COUNTER_ADMIN_ID, user=counter
 | 
				
			||||||
                User.groups.through(group=groups.com_admin, user=comunity),
 | 
					                ),
 | 
				
			||||||
                User.groups.through(group=groups.pedagogy_admin, user=tutu),
 | 
					                User.groups.through(
 | 
				
			||||||
                User.groups.through(group=groups.sas_admin, user=skia),
 | 
					                    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
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
            ]
 | 
					            ]
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        for user in richard, sli, krophil, skia:
 | 
					        for user in richard, sli, krophil, skia:
 | 
				
			||||||
@@ -324,7 +336,7 @@ Welcome to the wiki page!
 | 
				
			|||||||
            content="Fonctionnement de la laverie",
 | 
					            content="Fonctionnement de la laverie",
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        groups.public.viewable_page.set(
 | 
					        public_group.viewable_page.set(
 | 
				
			||||||
            [syntax_page, services_page, index_page, laundry_page]
 | 
					            [syntax_page, services_page, index_page, laundry_page]
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -370,42 +382,46 @@ Welcome to the wiki page!
 | 
				
			|||||||
            parent=main_club,
 | 
					            parent=main_club,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Membership.objects.create(user=skia, club=main_club, role=3)
 | 
					        Membership.objects.bulk_create(
 | 
				
			||||||
        Membership.objects.create(
 | 
					            [
 | 
				
			||||||
            user=comunity,
 | 
					                Membership(user=skia, club=main_club, role=3),
 | 
				
			||||||
            club=bar_club,
 | 
					                Membership(
 | 
				
			||||||
            start_date=localdate(),
 | 
					                    user=comunity,
 | 
				
			||||||
            role=settings.SITH_CLUB_ROLES_ID["Board member"],
 | 
					                    club=bar_club,
 | 
				
			||||||
        )
 | 
					                    start_date=localdate(),
 | 
				
			||||||
        Membership.objects.create(
 | 
					                    role=settings.SITH_CLUB_ROLES_ID["Board member"],
 | 
				
			||||||
            user=sli,
 | 
					                ),
 | 
				
			||||||
            club=troll,
 | 
					                Membership(
 | 
				
			||||||
            role=9,
 | 
					                    user=sli,
 | 
				
			||||||
            description="Padawan Troll",
 | 
					                    club=troll,
 | 
				
			||||||
            start_date=localdate() - timedelta(days=17),
 | 
					                    role=9,
 | 
				
			||||||
        )
 | 
					                    description="Padawan Troll",
 | 
				
			||||||
        Membership.objects.create(
 | 
					                    start_date=localdate() - timedelta(days=17),
 | 
				
			||||||
            user=krophil,
 | 
					                ),
 | 
				
			||||||
            club=troll,
 | 
					                Membership(
 | 
				
			||||||
            role=10,
 | 
					                    user=krophil,
 | 
				
			||||||
            description="Maitre Troll",
 | 
					                    club=troll,
 | 
				
			||||||
            start_date=localdate() - timedelta(days=200),
 | 
					                    role=10,
 | 
				
			||||||
        )
 | 
					                    description="Maitre Troll",
 | 
				
			||||||
        Membership.objects.create(
 | 
					                    start_date=localdate() - timedelta(days=200),
 | 
				
			||||||
            user=skia,
 | 
					                ),
 | 
				
			||||||
            club=troll,
 | 
					                Membership(
 | 
				
			||||||
            role=2,
 | 
					                    user=skia,
 | 
				
			||||||
            description="Grand Ancien Troll",
 | 
					                    club=troll,
 | 
				
			||||||
            start_date=localdate() - timedelta(days=400),
 | 
					                    role=2,
 | 
				
			||||||
            end_date=localdate() - timedelta(days=86),
 | 
					                    description="Grand Ancien Troll",
 | 
				
			||||||
        )
 | 
					                    start_date=localdate() - timedelta(days=400),
 | 
				
			||||||
        Membership.objects.create(
 | 
					                    end_date=localdate() - timedelta(days=86),
 | 
				
			||||||
            user=richard,
 | 
					                ),
 | 
				
			||||||
            club=troll,
 | 
					                Membership(
 | 
				
			||||||
            role=2,
 | 
					                    user=richard,
 | 
				
			||||||
            description="",
 | 
					                    club=troll,
 | 
				
			||||||
            start_date=localdate() - timedelta(days=200),
 | 
					                    role=2,
 | 
				
			||||||
            end_date=localdate() - timedelta(days=100),
 | 
					                    description="",
 | 
				
			||||||
 | 
					                    start_date=localdate() - timedelta(days=200),
 | 
				
			||||||
 | 
					                    end_date=localdate() - timedelta(days=100),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					            ]
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        p = ProductType.objects.create(name="Bières bouteilles")
 | 
					        p = ProductType.objects.create(name="Bières bouteilles")
 | 
				
			||||||
@@ -460,7 +476,6 @@ Welcome to the wiki page!
 | 
				
			|||||||
            limit_age=18,
 | 
					            limit_age=18,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        cons = Product.objects.create(
 | 
					        cons = Product.objects.create(
 | 
				
			||||||
            id=settings.SITH_ECOCUP_CONS,
 | 
					 | 
				
			||||||
            name="Consigne Eco-cup",
 | 
					            name="Consigne Eco-cup",
 | 
				
			||||||
            code="CONS",
 | 
					            code="CONS",
 | 
				
			||||||
            product_type=verre,
 | 
					            product_type=verre,
 | 
				
			||||||
@@ -470,7 +485,6 @@ Welcome to the wiki page!
 | 
				
			|||||||
            club=main_club,
 | 
					            club=main_club,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        dcons = Product.objects.create(
 | 
					        dcons = Product.objects.create(
 | 
				
			||||||
            id=settings.SITH_ECOCUP_DECO,
 | 
					 | 
				
			||||||
            name="Déconsigne Eco-cup",
 | 
					            name="Déconsigne Eco-cup",
 | 
				
			||||||
            code="DECO",
 | 
					            code="DECO",
 | 
				
			||||||
            product_type=verre,
 | 
					            product_type=verre,
 | 
				
			||||||
@@ -499,10 +513,8 @@ Welcome to the wiki page!
 | 
				
			|||||||
            club=main_club,
 | 
					            club=main_club,
 | 
				
			||||||
            limit_age=18,
 | 
					            limit_age=18,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        groups.subscribers.products.add(
 | 
					        subscribers.products.add(cotis, cotis2, refill, barb, cble, cors, carolus)
 | 
				
			||||||
            cotis, cotis2, refill, barb, cble, cors, carolus
 | 
					        old_subscribers.products.add(cotis, cotis2)
 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        groups.old_subscribers.products.add(cotis, cotis2)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        mde = Counter.objects.get(name="MDE")
 | 
					        mde = Counter.objects.get(name="MDE")
 | 
				
			||||||
        mde.products.add(barb, cble, cons, dcons)
 | 
					        mde.products.add(barb, cble, cons, dcons)
 | 
				
			||||||
@@ -596,6 +608,7 @@ Welcome to the wiki page!
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Create an election
 | 
					        # Create an election
 | 
				
			||||||
 | 
					        ae_board_group = Group.objects.get(name=settings.SITH_MAIN_BOARD_GROUP)
 | 
				
			||||||
        el = Election.objects.create(
 | 
					        el = Election.objects.create(
 | 
				
			||||||
            title="Élection 2017",
 | 
					            title="Élection 2017",
 | 
				
			||||||
            description="La roue tourne",
 | 
					            description="La roue tourne",
 | 
				
			||||||
@@ -604,10 +617,10 @@ Welcome to the wiki page!
 | 
				
			|||||||
            start_date="1942-06-12 10:28:45+01",
 | 
					            start_date="1942-06-12 10:28:45+01",
 | 
				
			||||||
            end_date="7942-06-12 10:28:45+01",
 | 
					            end_date="7942-06-12 10:28:45+01",
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        el.view_groups.add(groups.public)
 | 
					        el.view_groups.add(public_group)
 | 
				
			||||||
        el.edit_groups.add(main_club.board_group)
 | 
					        el.edit_groups.add(ae_board_group)
 | 
				
			||||||
        el.candidature_groups.add(groups.subscribers)
 | 
					        el.candidature_groups.add(subscribers)
 | 
				
			||||||
        el.vote_groups.add(groups.subscribers)
 | 
					        el.vote_groups.add(subscribers)
 | 
				
			||||||
        liste = ElectionList.objects.create(title="Candidature Libre", election=el)
 | 
					        liste = ElectionList.objects.create(title="Candidature Libre", election=el)
 | 
				
			||||||
        listeT = ElectionList.objects.create(title="Troll", election=el)
 | 
					        listeT = ElectionList.objects.create(title="Troll", election=el)
 | 
				
			||||||
        pres = Role.objects.create(
 | 
					        pres = Role.objects.create(
 | 
				
			||||||
@@ -742,7 +755,7 @@ Welcome to the wiki page!
 | 
				
			|||||||
            NewsDate(
 | 
					            NewsDate(
 | 
				
			||||||
                news=n,
 | 
					                news=n,
 | 
				
			||||||
                start_date=friday + timedelta(hours=24 * 7 + 1),
 | 
					                start_date=friday + timedelta(hours=24 * 7 + 1),
 | 
				
			||||||
                end_date=friday + timedelta(hours=24 * 7 + 9),
 | 
					                end_date=self.now + timedelta(hours=24 * 7 + 9),
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        # Weekly
 | 
					        # Weekly
 | 
				
			||||||
@@ -768,9 +781,8 @@ Welcome to the wiki page!
 | 
				
			|||||||
            ]
 | 
					            ]
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        NewsDate.objects.bulk_create(news_dates)
 | 
					        NewsDate.objects.bulk_create(news_dates)
 | 
				
			||||||
        IcsCalendar.make_internal()  # Force refresh of the calendar after a bulk_create
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Create some data for pedagogy
 | 
					        # Create som data for pedagogy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        UV(
 | 
					        UV(
 | 
				
			||||||
            code="PA00",
 | 
					            code="PA00",
 | 
				
			||||||
@@ -887,114 +899,3 @@ Welcome to the wiki page!
 | 
				
			|||||||
            start=s.subscription_start,
 | 
					            start=s.subscription_start,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        s.save()
 | 
					        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")
 | 
					 | 
				
			||||||
        old_subscribers = Group.objects.create(name="Old subscribers")
 | 
					 | 
				
			||||||
        old_subscribers.permissions.add(
 | 
					 | 
				
			||||||
            *list(
 | 
					 | 
				
			||||||
                perms.filter(
 | 
					 | 
				
			||||||
                    codename__in=[
 | 
					 | 
				
			||||||
                        "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").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="")
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,7 +11,7 @@ from django.utils.timezone import localdate, make_aware, now
 | 
				
			|||||||
from faker import Faker
 | 
					from faker import Faker
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from club.models import Club, Membership
 | 
					from club.models import Club, Membership
 | 
				
			||||||
from core.models import Group, User
 | 
					from core.models import RealGroup, User
 | 
				
			||||||
from counter.models import (
 | 
					from counter.models import (
 | 
				
			||||||
    Counter,
 | 
					    Counter,
 | 
				
			||||||
    Customer,
 | 
					    Customer,
 | 
				
			||||||
@@ -173,8 +173,7 @@ class Command(BaseCommand):
 | 
				
			|||||||
                    club=club,
 | 
					                    club=club,
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        memberships = Membership.objects.bulk_create(memberships)
 | 
					        Membership.objects.bulk_create(memberships)
 | 
				
			||||||
        Membership._add_club_groups(memberships)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def create_uvs(self):
 | 
					    def create_uvs(self):
 | 
				
			||||||
        root = User.objects.get(username="root")
 | 
					        root = User.objects.get(username="root")
 | 
				
			||||||
@@ -226,7 +225,9 @@ class Command(BaseCommand):
 | 
				
			|||||||
        ae = Club.objects.get(unix_name="ae")
 | 
					        ae = Club.objects.get(unix_name="ae")
 | 
				
			||||||
        other_clubs = random.sample(list(Club.objects.all()), k=3)
 | 
					        other_clubs = random.sample(list(Club.objects.all()), k=3)
 | 
				
			||||||
        groups = list(
 | 
					        groups = list(
 | 
				
			||||||
            Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
 | 
					            RealGroup.objects.filter(
 | 
				
			||||||
 | 
					                name__in=["Subscribers", "Old subscribers", "Public"]
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        counters = list(
 | 
					        counters = list(
 | 
				
			||||||
            Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])
 | 
					            Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,7 +16,6 @@
 | 
				
			|||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.core.management import call_command
 | 
					from django.core.management import call_command
 | 
				
			||||||
from django.core.management.base import BaseCommand
 | 
					from django.core.management.base import BaseCommand
 | 
				
			||||||
from django.db import connection
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Command(BaseCommand):
 | 
					class Command(BaseCommand):
 | 
				
			||||||
@@ -30,7 +29,7 @@ class Command(BaseCommand):
 | 
				
			|||||||
        if not data_dir.is_dir():
 | 
					        if not data_dir.is_dir():
 | 
				
			||||||
            data_dir.mkdir()
 | 
					            data_dir.mkdir()
 | 
				
			||||||
        db_path = settings.BASE_DIR / "db.sqlite3"
 | 
					        db_path = settings.BASE_DIR / "db.sqlite3"
 | 
				
			||||||
        if db_path.exists() or connection.vendor != "sqlite":
 | 
					        if db_path.exists():
 | 
				
			||||||
            call_command("flush", "--noinput")
 | 
					            call_command("flush", "--noinput")
 | 
				
			||||||
            self.stdout.write("Existing database reset")
 | 
					            self.stdout.write("Existing database reset")
 | 
				
			||||||
        call_command("migrate")
 | 
					        call_command("migrate")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -563,21 +563,14 @@ class Migration(migrations.Migration):
 | 
				
			|||||||
            fields=[],
 | 
					            fields=[],
 | 
				
			||||||
            options={"proxy": True},
 | 
					            options={"proxy": True},
 | 
				
			||||||
            bases=("core.group",),
 | 
					            bases=("core.group",),
 | 
				
			||||||
            managers=[("objects", django.contrib.auth.models.GroupManager())],
 | 
					            managers=[("objects", core.models.MetaGroupManager())],
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        # 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(
 | 
					        migrations.CreateModel(
 | 
				
			||||||
            name="RealGroup",
 | 
					            name="RealGroup",
 | 
				
			||||||
            fields=[],
 | 
					            fields=[],
 | 
				
			||||||
            options={"proxy": True},
 | 
					            options={"proxy": True},
 | 
				
			||||||
            bases=("core.group",),
 | 
					            bases=("core.group",),
 | 
				
			||||||
            managers=[("objects", django.contrib.auth.models.GroupManager())],
 | 
					            managers=[("objects", core.models.RealGroupManager())],
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        migrations.AlterUniqueTogether(
 | 
					        migrations.AlterUniqueTogether(
 | 
				
			||||||
            name="page", unique_together={("name", "parent")}
 | 
					            name="page", unique_together={("name", "parent")}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,82 +0,0 @@
 | 
				
			|||||||
# 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",
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
@@ -1,37 +0,0 @@
 | 
				
			|||||||
# 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",
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
@@ -1,27 +0,0 @@
 | 
				
			|||||||
# 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
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
@@ -1,164 +0,0 @@
 | 
				
			|||||||
# 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
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
							
								
								
									
										316
									
								
								core/models.py
									
									
									
									
									
								
							
							
						
						
									
										316
									
								
								core/models.py
									
									
									
									
									
								
							@@ -30,19 +30,26 @@ import string
 | 
				
			|||||||
import unicodedata
 | 
					import unicodedata
 | 
				
			||||||
from datetime import timedelta
 | 
					from datetime import timedelta
 | 
				
			||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
from typing import TYPE_CHECKING, Optional, Self
 | 
					from typing import TYPE_CHECKING, Any, Optional, Self
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.contrib.auth.models import AbstractUser, UserManager
 | 
					from django.contrib.auth.models import AbstractBaseUser, UserManager
 | 
				
			||||||
from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
 | 
					from django.contrib.auth.models import (
 | 
				
			||||||
from django.contrib.auth.models import Group as AuthGroup
 | 
					    AnonymousUser as AuthAnonymousUser,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from django.contrib.auth.models import (
 | 
				
			||||||
 | 
					    Group as AuthGroup,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from django.contrib.auth.models import (
 | 
				
			||||||
 | 
					    GroupManager as AuthGroupManager,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
from django.contrib.staticfiles.storage import staticfiles_storage
 | 
					from django.contrib.staticfiles.storage import staticfiles_storage
 | 
				
			||||||
from django.core import validators
 | 
					from django.core import validators
 | 
				
			||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
from django.core.exceptions import PermissionDenied, ValidationError
 | 
					from django.core.exceptions import PermissionDenied, ValidationError
 | 
				
			||||||
from django.core.mail import send_mail
 | 
					from django.core.mail import send_mail
 | 
				
			||||||
from django.db import models, transaction
 | 
					from django.db import models, transaction
 | 
				
			||||||
from django.db.models import Exists, F, OuterRef, Q
 | 
					from django.db.models import Exists, OuterRef, Q
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
from django.utils import timezone
 | 
					from django.utils import timezone
 | 
				
			||||||
from django.utils.functional import cached_property
 | 
					from django.utils.functional import cached_property
 | 
				
			||||||
@@ -57,15 +64,33 @@ if TYPE_CHECKING:
 | 
				
			|||||||
    from club.models import Club
 | 
					    from club.models import Club
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Group(AuthGroup):
 | 
					class RealGroupManager(AuthGroupManager):
 | 
				
			||||||
    """Wrapper around django.auth.Group"""
 | 
					    def get_queryset(self):
 | 
				
			||||||
 | 
					        return super().get_queryset().filter(is_meta=False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    is_manually_manageable = models.BooleanField(
 | 
					
 | 
				
			||||||
        _("Is manually manageable"),
 | 
					class MetaGroupManager(AuthGroupManager):
 | 
				
			||||||
 | 
					    def get_queryset(self):
 | 
				
			||||||
 | 
					        return super().get_queryset().filter(is_meta=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Group(AuthGroup):
 | 
				
			||||||
 | 
					    """Implement both RealGroups and Meta groups.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Groups are sorted by their is_meta property
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    #: If False, this is a RealGroup
 | 
				
			||||||
 | 
					    is_meta = models.BooleanField(
 | 
				
			||||||
 | 
					        _("meta group status"),
 | 
				
			||||||
        default=False,
 | 
					        default=False,
 | 
				
			||||||
        help_text=_("If False, this shouldn't be shown on group management pages"),
 | 
					        help_text=_("Whether a group is a meta group or not"),
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    description = models.TextField(_("description"))
 | 
					    #: Description of the group
 | 
				
			||||||
 | 
					    description = models.CharField(_("description"), max_length=60)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        ordering = ["name"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_absolute_url(self) -> str:
 | 
					    def get_absolute_url(self) -> str:
 | 
				
			||||||
        return reverse("core:group_list")
 | 
					        return reverse("core:group_list")
 | 
				
			||||||
@@ -81,6 +106,65 @@ class Group(AuthGroup):
 | 
				
			|||||||
        cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
 | 
					        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:
 | 
					def validate_promo(value: int) -> None:
 | 
				
			||||||
    start_year = settings.SITH_SCHOOL_START_YEAR
 | 
					    start_year = settings.SITH_SCHOOL_START_YEAR
 | 
				
			||||||
    delta = (localdate() + timedelta(days=180)).year - start_year
 | 
					    delta = (localdate() + timedelta(days=180)).year - start_year
 | 
				
			||||||
@@ -126,35 +210,13 @@ def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None
 | 
				
			|||||||
    else:
 | 
					    else:
 | 
				
			||||||
        group = Group.objects.filter(name=name).first()
 | 
					        group = Group.objects.filter(name=name).first()
 | 
				
			||||||
    if group is not None:
 | 
					    if group is not None:
 | 
				
			||||||
        name = group.name.replace(" ", "_")
 | 
					        cache.set(f"sith_group_{group.id}", group)
 | 
				
			||||||
        cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group})
 | 
					        cache.set(f"sith_group_{group.name.replace(' ', '_')}", group)
 | 
				
			||||||
    else:
 | 
					    else:
 | 
				
			||||||
        cache.set(f"sith_group_{pk_or_name}", "not_found")
 | 
					        cache.set(f"sith_group_{pk_or_name}", "not_found")
 | 
				
			||||||
    return group
 | 
					    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):
 | 
					class UserQuerySet(models.QuerySet):
 | 
				
			||||||
    def filter_inactive(self) -> Self:
 | 
					    def filter_inactive(self) -> Self:
 | 
				
			||||||
        from counter.models import Refilling, Selling
 | 
					        from counter.models import Refilling, Selling
 | 
				
			||||||
@@ -180,7 +242,7 @@ class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
 | 
				
			|||||||
    pass
 | 
					    pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class User(AbstractUser):
 | 
					class User(AbstractBaseUser):
 | 
				
			||||||
    """Defines the base user class, useable in every app.
 | 
					    """Defines the base user class, useable in every app.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    This is almost the same as the auth module AbstractUser since it inherits from it,
 | 
					    This is almost the same as the auth module AbstractUser since it inherits from it,
 | 
				
			||||||
@@ -191,28 +253,51 @@ class User(AbstractUser):
 | 
				
			|||||||
    Required fields: email, first_name, last_name, date_of_birth
 | 
					    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)
 | 
					    first_name = models.CharField(_("first name"), max_length=64)
 | 
				
			||||||
    last_name = models.CharField(_("last name"), max_length=64)
 | 
					    last_name = models.CharField(_("last name"), max_length=64)
 | 
				
			||||||
    email = models.EmailField(_("email address"), unique=True)
 | 
					    email = models.EmailField(_("email address"), unique=True)
 | 
				
			||||||
    date_of_birth = models.DateField(_("date of birth"), blank=True, null=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)
 | 
					    nick_name = models.CharField(_("nick name"), max_length=64, null=True, blank=True)
 | 
				
			||||||
    last_update = models.DateTimeField(_("last update"), auto_now=True)
 | 
					    is_staff = models.BooleanField(
 | 
				
			||||||
    groups = models.ManyToManyField(
 | 
					        _("staff status"),
 | 
				
			||||||
        Group,
 | 
					        default=False,
 | 
				
			||||||
        verbose_name=_("groups"),
 | 
					        help_text=_("Designates whether the user can log into this admin site."),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    is_active = models.BooleanField(
 | 
				
			||||||
 | 
					        _("active"),
 | 
				
			||||||
 | 
					        default=True,
 | 
				
			||||||
        help_text=_(
 | 
					        help_text=_(
 | 
				
			||||||
            "The groups this user belongs to. A user will get all permissions "
 | 
					            "Designates whether this user should be treated as active. "
 | 
				
			||||||
            "granted to each of their groups."
 | 
					            "Unselect this instead of deleting accounts."
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        related_name="users",
 | 
					 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    ban_groups = models.ManyToManyField(
 | 
					    date_joined = models.DateField(_("date joined"), auto_now_add=True)
 | 
				
			||||||
        BanGroup,
 | 
					    last_update = models.DateTimeField(_("last update"), auto_now=True)
 | 
				
			||||||
        verbose_name=_("ban groups"),
 | 
					    is_superuser = models.BooleanField(
 | 
				
			||||||
        through="UserBan",
 | 
					        _("superuser"),
 | 
				
			||||||
        help_text=_("The bans this user has received."),
 | 
					        default=False,
 | 
				
			||||||
        related_name="users",
 | 
					        help_text=_("Designates whether this user is a superuser. "),
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					    groups = models.ManyToManyField(RealGroup, related_name="users", blank=True)
 | 
				
			||||||
    home = models.OneToOneField(
 | 
					    home = models.OneToOneField(
 | 
				
			||||||
        "SithFile",
 | 
					        "SithFile",
 | 
				
			||||||
        related_name="home_of",
 | 
					        related_name="home_of",
 | 
				
			||||||
@@ -316,6 +401,8 @@ class User(AbstractUser):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    objects = CustomUserManager()
 | 
					    objects = CustomUserManager()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    USERNAME_FIELD = "username"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
        return self.get_display_name()
 | 
					        return self.get_display_name()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -335,23 +422,22 @@ class User(AbstractUser):
 | 
				
			|||||||
            settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
 | 
					            settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
 | 
				
			||||||
        ).exists()
 | 
					        ).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
 | 
					    @cached_property
 | 
				
			||||||
    def was_subscribed(self) -> bool:
 | 
					    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()
 | 
					        return self.subscriptions.exists()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @cached_property
 | 
					    @cached_property
 | 
				
			||||||
    def is_subscribed(self) -> bool:
 | 
					    def is_subscribed(self) -> bool:
 | 
				
			||||||
        if "was_subscribed" in self.__dict__ and not self.was_subscribed:
 | 
					        s = self.subscriptions.filter(
 | 
				
			||||||
            # 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()
 | 
					            subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
 | 
				
			||||||
        ).exists()
 | 
					        )
 | 
				
			||||||
 | 
					        return s.exists()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @cached_property
 | 
					    @cached_property
 | 
				
			||||||
    def account_balance(self):
 | 
					    def account_balance(self):
 | 
				
			||||||
@@ -388,6 +474,18 @@ class User(AbstractUser):
 | 
				
			|||||||
            return self.was_subscribed
 | 
					            return self.was_subscribed
 | 
				
			||||||
        if group.id == settings.SITH_GROUP_ROOT_ID:
 | 
					        if group.id == settings.SITH_GROUP_ROOT_ID:
 | 
				
			||||||
            return self.is_root
 | 
					            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
 | 
					        return group in self.cached_groups
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
@@ -412,11 +510,12 @@ class User(AbstractUser):
 | 
				
			|||||||
        return any(g.id == root_id for g in self.cached_groups)
 | 
					        return any(g.id == root_id for g in self.cached_groups)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @cached_property
 | 
					    @cached_property
 | 
				
			||||||
    def is_board_member(self) -> bool:
 | 
					    def is_board_member(self):
 | 
				
			||||||
        return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
 | 
					        main_club = settings.SITH_MAIN_CLUB["unix_name"]
 | 
				
			||||||
 | 
					        return self.is_in_group(name=main_club + settings.SITH_BOARD_SUFFIX)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @cached_property
 | 
					    @cached_property
 | 
				
			||||||
    def can_read_subscription_history(self) -> bool:
 | 
					    def can_read_subscription_history(self):
 | 
				
			||||||
        if self.is_root or self.is_board_member:
 | 
					        if self.is_root or self.is_board_member:
 | 
				
			||||||
            return True
 | 
					            return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -430,13 +529,13 @@ class User(AbstractUser):
 | 
				
			|||||||
        return False
 | 
					        return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @cached_property
 | 
					    @cached_property
 | 
				
			||||||
    def can_create_subscription(self) -> bool:
 | 
					    def can_create_subscription(self):
 | 
				
			||||||
        return self.is_root or (
 | 
					        from club.models import Club
 | 
				
			||||||
            self.memberships.board()
 | 
					
 | 
				
			||||||
            .ongoing()
 | 
					        for club in Club.objects.filter(id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS):
 | 
				
			||||||
            .filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
 | 
					            if club in self.clubs_with_rights:
 | 
				
			||||||
            .exists()
 | 
					                return True
 | 
				
			||||||
        )
 | 
					        return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @cached_property
 | 
					    @cached_property
 | 
				
			||||||
    def is_launderette_manager(self):
 | 
					    def is_launderette_manager(self):
 | 
				
			||||||
@@ -451,12 +550,12 @@ class User(AbstractUser):
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @cached_property
 | 
					    @cached_property
 | 
				
			||||||
    def is_banned_alcohol(self) -> bool:
 | 
					    def is_banned_alcohol(self):
 | 
				
			||||||
        return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
 | 
					        return self.is_in_group(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @cached_property
 | 
					    @cached_property
 | 
				
			||||||
    def is_banned_counter(self) -> bool:
 | 
					    def is_banned_counter(self):
 | 
				
			||||||
        return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_COUNTER_ID).exists()
 | 
					        return self.is_in_group(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @cached_property
 | 
					    @cached_property
 | 
				
			||||||
    def age(self) -> int:
 | 
					    def age(self) -> int:
 | 
				
			||||||
@@ -500,6 +599,11 @@ class User(AbstractUser):
 | 
				
			|||||||
            "date_of_birth": self.date_of_birth,
 | 
					            "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):
 | 
					    def get_short_name(self):
 | 
				
			||||||
        """Returns the short name for the user."""
 | 
					        """Returns the short name for the user."""
 | 
				
			||||||
        if self.nick_name:
 | 
					        if self.nick_name:
 | 
				
			||||||
@@ -515,6 +619,14 @@ class User(AbstractUser):
 | 
				
			|||||||
            return "%s (%s)" % (self.get_full_name(), self.nick_name)
 | 
					            return "%s (%s)" % (self.get_full_name(), self.nick_name)
 | 
				
			||||||
        return self.get_full_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(
 | 
					    def get_family(
 | 
				
			||||||
        self,
 | 
					        self,
 | 
				
			||||||
        godfathers_depth: NonNegativeInt = 4,
 | 
					        godfathers_depth: NonNegativeInt = 4,
 | 
				
			||||||
@@ -758,52 +870,6 @@ class AnonymousUser(AuthAnonymousUser):
 | 
				
			|||||||
        return _("Visitor")
 | 
					        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):
 | 
					class Preferences(models.Model):
 | 
				
			||||||
    user = models.OneToOneField(
 | 
					    user = models.OneToOneField(
 | 
				
			||||||
        User, related_name="_preferences", on_delete=models.CASCADE
 | 
					        User, related_name="_preferences", on_delete=models.CASCADE
 | 
				
			||||||
@@ -916,17 +982,19 @@ class SithFile(models.Model):
 | 
				
			|||||||
        if copy_rights:
 | 
					        if copy_rights:
 | 
				
			||||||
            self.copy_rights()
 | 
					            self.copy_rights()
 | 
				
			||||||
        if self.is_in_sas:
 | 
					        if self.is_in_sas:
 | 
				
			||||||
            for user in User.objects.filter(
 | 
					            for u in (
 | 
				
			||||||
                groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
 | 
					                RealGroup.objects.filter(id=settings.SITH_GROUP_SAS_ADMIN_ID)
 | 
				
			||||||
 | 
					                .first()
 | 
				
			||||||
 | 
					                .users.all()
 | 
				
			||||||
            ):
 | 
					            ):
 | 
				
			||||||
                Notification(
 | 
					                Notification(
 | 
				
			||||||
                    user=user,
 | 
					                    user=u,
 | 
				
			||||||
                    url=reverse("sas:moderation"),
 | 
					                    url=reverse("sas:moderation"),
 | 
				
			||||||
                    type="SAS_MODERATION",
 | 
					                    type="SAS_MODERATION",
 | 
				
			||||||
                    param="1",
 | 
					                    param="1",
 | 
				
			||||||
                ).save()
 | 
					                ).save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def is_owned_by(self, user: User) -> bool:
 | 
					    def is_owned_by(self, user):
 | 
				
			||||||
        if user.is_anonymous:
 | 
					        if user.is_anonymous:
 | 
				
			||||||
            return False
 | 
					            return False
 | 
				
			||||||
        if user.is_root:
 | 
					        if user.is_root:
 | 
				
			||||||
@@ -941,7 +1009,7 @@ class SithFile(models.Model):
 | 
				
			|||||||
            return True
 | 
					            return True
 | 
				
			||||||
        return user.id == self.owner_id
 | 
					        return user.id == self.owner_id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def can_be_viewed_by(self, user: User) -> bool:
 | 
					    def can_be_viewed_by(self, user):
 | 
				
			||||||
        if hasattr(self, "profile_of"):
 | 
					        if hasattr(self, "profile_of"):
 | 
				
			||||||
            return user.can_view(self.profile_of)
 | 
					            return user.can_view(self.profile_of)
 | 
				
			||||||
        if hasattr(self, "avatar_of"):
 | 
					        if hasattr(self, "avatar_of"):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,7 +4,6 @@ from typing import Annotated
 | 
				
			|||||||
from annotated_types import MinLen
 | 
					from annotated_types import MinLen
 | 
				
			||||||
from django.contrib.staticfiles.storage import staticfiles_storage
 | 
					from django.contrib.staticfiles.storage import staticfiles_storage
 | 
				
			||||||
from django.db.models import Q
 | 
					from django.db.models import Q
 | 
				
			||||||
from django.urls import reverse
 | 
					 | 
				
			||||||
from django.utils.text import slugify
 | 
					from django.utils.text import slugify
 | 
				
			||||||
from haystack.query import SearchQuerySet
 | 
					from haystack.query import SearchQuerySet
 | 
				
			||||||
from ninja import FilterSchema, ModelSchema, Schema
 | 
					from ninja import FilterSchema, ModelSchema, Schema
 | 
				
			||||||
@@ -38,13 +37,13 @@ class UserProfileSchema(ModelSchema):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
    def resolve_profile_url(obj: User) -> str:
 | 
					    def resolve_profile_url(obj: User) -> str:
 | 
				
			||||||
        return reverse("core:user_profile", kwargs={"user_id": obj.pk})
 | 
					        return obj.get_absolute_url()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
    def resolve_profile_pict(obj: User) -> str:
 | 
					    def resolve_profile_pict(obj: User) -> str:
 | 
				
			||||||
        if obj.profile_pict_id is None:
 | 
					        if obj.profile_pict_id is None:
 | 
				
			||||||
            return staticfiles_storage.url("core/img/unknown.jpg")
 | 
					            return staticfiles_storage.url("core/img/unknown.jpg")
 | 
				
			||||||
        return reverse("core:download", kwargs={"file_id": obj.profile_pict_id})
 | 
					        return obj.profile_pict.get_download_url()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SithFileSchema(ModelSchema):
 | 
					class SithFileSchema(ModelSchema):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,5 @@
 | 
				
			|||||||
import sort from "@alpinejs/sort";
 | 
					 | 
				
			||||||
import Alpine from "alpinejs";
 | 
					import Alpine from "alpinejs";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Alpine.plugin(sort);
 | 
					 | 
				
			||||||
window.Alpine = Alpine;
 | 
					window.Alpine = Alpine;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
window.addEventListener("DOMContentLoaded", () => {
 | 
					window.addEventListener("DOMContentLoaded", () => {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -67,8 +67,6 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
 | 
				
			|||||||
        remove_button: {
 | 
					        remove_button: {
 | 
				
			||||||
          title: gettext("Remove"),
 | 
					          title: gettext("Remove"),
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        // biome-ignore lint/style/useNamingConvention: this is required by the api
 | 
					 | 
				
			||||||
        restore_on_backspace: {},
 | 
					 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      persist: false,
 | 
					      persist: false,
 | 
				
			||||||
      maxItems: this.node.multiple ? this.max : 1,
 | 
					      maxItems: this.node.multiple ? this.max : 1,
 | 
				
			||||||
@@ -105,12 +103,6 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
 | 
				
			|||||||
export abstract class AjaxSelect extends AutoCompleteSelectBase {
 | 
					export abstract class AjaxSelect extends AutoCompleteSelectBase {
 | 
				
			||||||
  protected filter?: (items: TomOption[]) => TomOption[] = null;
 | 
					  protected filter?: (items: TomOption[]) => TomOption[] = null;
 | 
				
			||||||
  protected minCharNumberForSearch = 2;
 | 
					  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 valueField: string;
 | 
				
			||||||
  protected abstract labelField: string;
 | 
					  protected abstract labelField: string;
 | 
				
			||||||
@@ -143,13 +135,7 @@ export abstract class AjaxSelect extends AutoCompleteSelectBase {
 | 
				
			|||||||
      this.widget.clearOptions();
 | 
					      this.widget.clearOptions();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Check in the cache if this query has already been typed
 | 
					    const resp = await this.search(query);
 | 
				
			||||||
    // 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) {
 | 
					    if (this.filter) {
 | 
				
			||||||
      callback(this.filter(resp), []);
 | 
					      callback(this.filter(resp), []);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,73 +0,0 @@
 | 
				
			|||||||
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);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
@@ -1,11 +1,3 @@
 | 
				
			|||||||
import htmx from "htmx.org";
 | 
					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 });
 | 
					Object.assign(window, { htmx });
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -22,13 +22,10 @@ type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// TODO : If one day a test workflow is made for JS in this project
 | 
					// TODO : If one day a test workflow is made for JS in this project
 | 
				
			||||||
//  please test this function. A all cost.
 | 
					//  please test this function. A all cost.
 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Load complete dataset from paginated routes.
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
export const paginated = async <T>(
 | 
					export const paginated = async <T>(
 | 
				
			||||||
  endpoint: PaginatedEndpoint<T>,
 | 
					  endpoint: PaginatedEndpoint<T>,
 | 
				
			||||||
  options?: PaginatedRequest,
 | 
					  options?: PaginatedRequest,
 | 
				
			||||||
): Promise<T[]> => {
 | 
					) => {
 | 
				
			||||||
  const maxPerPage = 199;
 | 
					  const maxPerPage = 199;
 | 
				
			||||||
  const queryParams = options ?? {};
 | 
					  const queryParams = options ?? {};
 | 
				
			||||||
  queryParams.query = queryParams.query ?? {};
 | 
					  queryParams.query = queryParams.query ?? {};
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,49 +0,0 @@
 | 
				
			|||||||
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
									
									
								
							
							
						
						
									
										37
									
								
								core/static/bundled/utils/types.d.ts
									
									
									
									
										vendored
									
									
								
							@@ -1,37 +0,0 @@
 | 
				
			|||||||
/**
 | 
					 | 
				
			||||||
 * 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,16 +6,7 @@
 | 
				
			|||||||
 **/
 | 
					 **/
 | 
				
			||||||
export function registerComponent(name: string, options?: ElementDefinitionOptions) {
 | 
					export function registerComponent(name: string, options?: ElementDefinitionOptions) {
 | 
				
			||||||
  return (component: CustomElementConstructor) => {
 | 
					  return (component: CustomElementConstructor) => {
 | 
				
			||||||
    try {
 | 
					    window.customElements.define(name, component, options);
 | 
				
			||||||
      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,17 +24,9 @@ $black-color: hsl(0, 0%, 17%);
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
$faceblue: hsl(221, 44%, 41%);
 | 
					$faceblue: hsl(221, 44%, 41%);
 | 
				
			||||||
$twitblue: hsl(206, 82%, 63%);
 | 
					$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);
 | 
					$shadow-color: rgb(223, 223, 223);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
$background-button-color: hsl(0, 0%, 95%);
 | 
					$background-button-color: hsl(0, 0%, 95%);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
$deepblue: #354a5f;
 | 
					$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,27 +1,11 @@
 | 
				
			|||||||
.ts-wrapper.multi .ts-control {
 | 
					 | 
				
			||||||
  min-width: calc(100% - 0.2rem);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/* This also requires ajax-select-index.css */
 | 
					/* This also requires ajax-select-index.css */
 | 
				
			||||||
.ts-dropdown {
 | 
					.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 {
 | 
					  .select-item {
 | 
				
			||||||
    display: flex;
 | 
					    display: flex;
 | 
				
			||||||
    flex-direction: row;
 | 
					    flex-direction: row;
 | 
				
			||||||
    gap: 10px;
 | 
					    gap: 10px;
 | 
				
			||||||
    align-items: center;
 | 
					    align-items: center;
 | 
				
			||||||
    overflow: hidden;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    img {
 | 
					    img {
 | 
				
			||||||
      height: 40px;
 | 
					      height: 40px;
 | 
				
			||||||
@@ -32,44 +16,19 @@
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.ts-wrapper {
 | 
				
			||||||
 | 
					  margin: 5px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.ts-wrapper.single {
 | 
					.ts-wrapper.single {
 | 
				
			||||||
  > .ts-control {
 | 
					  width: 263px; // same length as regular text inputs
 | 
				
			||||||
    box-shadow: none;
 | 
					 | 
				
			||||||
    max-width: 300px;
 | 
					 | 
				
			||||||
    background-color: var(--nf-input-background-color);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    &::after {
 | 
					 | 
				
			||||||
      content: none;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  > .ts-dropdown {
 | 
					 | 
				
			||||||
    max-width: 300px;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.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 {
 | 
					.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
 | 
				
			||||||
  border-left: 1px solid #aaa;
 | 
					  border-left: 1px solid #aaa;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.ts-wrapper.multi.has-items .ts-control {
 | 
					.ts-wrapper.multi .ts-control {
 | 
				
			||||||
  padding: calc(var(--nf-input-size) * 0.65);
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  gap: calc(var(--nf-input-size) / 3);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  [data-value],
 | 
					  [data-value],
 | 
				
			||||||
  [data-value].active {
 | 
					  [data-value].active {
 | 
				
			||||||
    background-image: none;
 | 
					    background-image: none;
 | 
				
			||||||
@@ -78,17 +37,19 @@
 | 
				
			|||||||
    border: 1px solid #aaa;
 | 
					    border: 1px solid #aaa;
 | 
				
			||||||
    border-radius: 4px;
 | 
					    border-radius: 4px;
 | 
				
			||||||
    display: inline-block;
 | 
					    display: inline-block;
 | 
				
			||||||
 | 
					    margin-left: 5px;
 | 
				
			||||||
 | 
					    margin-top: 5px;
 | 
				
			||||||
 | 
					    margin-bottom: 5px;
 | 
				
			||||||
    padding-right: 10px;
 | 
					    padding-right: 10px;
 | 
				
			||||||
    padding-left: 10px;
 | 
					    padding-left: 10px;
 | 
				
			||||||
    text-shadow: none;
 | 
					    text-shadow: none;
 | 
				
			||||||
    box-shadow: none;
 | 
					    box-shadow: none;
 | 
				
			||||||
 | 
					 | 
				
			||||||
    .remove {
 | 
					 | 
				
			||||||
      vertical-align: baseline;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.ts-wrapper.focus .ts-control {
 | 
					.ts-dropdown {
 | 
				
			||||||
  box-shadow: none;
 | 
					  .option.active {
 | 
				
			||||||
 | 
					    background-color: #e5eafa;
 | 
				
			||||||
 | 
					    color: inherit;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -1,96 +0,0 @@
 | 
				
			|||||||
@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
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@@ -1,5 +0,0 @@
 | 
				
			|||||||
/*--------------------------MEDIA QUERY HELPERS------------------------*/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
$small-devices: 576px;
 | 
					 | 
				
			||||||
$medium-devices: 768px;
 | 
					 | 
				
			||||||
$large-devices: 992px;
 | 
					 | 
				
			||||||
@@ -1,730 +0,0 @@
 | 
				
			|||||||
@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: var(--nf-input-focus-border-color) none initial;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      &::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;
 | 
					    flex-direction: row;
 | 
				
			||||||
    gap: 10px;
 | 
					    gap: 10px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    > a {
 | 
					    >a {
 | 
				
			||||||
      color: $text-color!important;
 | 
					      color: $text-color;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    &:hover>a {
 | 
					    &:hover>a {
 | 
				
			||||||
@@ -395,9 +395,9 @@ $hovered-red-text-color: #ff4d4d;
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        >input[type=text] {
 | 
					        >input[type=text] {
 | 
				
			||||||
 | 
					          box-sizing: border-box;
 | 
				
			||||||
 | 
					          max-width: 100%;
 | 
				
			||||||
          width: 100%;
 | 
					          width: 100%;
 | 
				
			||||||
          min-width: unset;
 | 
					 | 
				
			||||||
          border: unset;
 | 
					 | 
				
			||||||
          height: 35px;
 | 
					          height: 35px;
 | 
				
			||||||
          border-radius: 5px;
 | 
					          border-radius: 5px;
 | 
				
			||||||
          font-size: .9em;
 | 
					          font-size: .9em;
 | 
				
			||||||
 
 | 
				
			|||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,5 +1,3 @@
 | 
				
			|||||||
@import "core/static/core/colors";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
main {
 | 
					main {
 | 
				
			||||||
  box-sizing: border-box;
 | 
					  box-sizing: border-box;
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
@@ -71,7 +69,7 @@ main {
 | 
				
			|||||||
        border-radius: 50%;
 | 
					        border-radius: 50%;
 | 
				
			||||||
        justify-content: center;
 | 
					        justify-content: center;
 | 
				
			||||||
        align-items: center;
 | 
					        align-items: center;
 | 
				
			||||||
        background-color: $primary-neutral-light-color;
 | 
					        background-color: #f2f2f2;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        > span {
 | 
					        > span {
 | 
				
			||||||
          font-size: small;
 | 
					          font-size: small;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,9 +1,26 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
@media (max-width: 750px) {
 | 
					@media (max-width: 750px) {
 | 
				
			||||||
  .title {
 | 
					  .title {
 | 
				
			||||||
    text-align: center;
 | 
					    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 {
 | 
					.profile {
 | 
				
			||||||
  &-visible {
 | 
					  &-visible {
 | 
				
			||||||
    display: flex;
 | 
					    display: flex;
 | 
				
			||||||
@@ -70,7 +87,11 @@
 | 
				
			|||||||
        max-height: 100%;
 | 
					        max-height: 100%;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      > p {
 | 
					      > i {
 | 
				
			||||||
 | 
					        font-size: 32px;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      >p {
 | 
				
			||||||
        text-align: left !important;
 | 
					        text-align: left !important;
 | 
				
			||||||
        width: 100% !important;
 | 
					        width: 100% !important;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
@@ -86,6 +107,16 @@
 | 
				
			|||||||
      > div {
 | 
					      > div {
 | 
				
			||||||
        max-width: 100%;
 | 
					        max-width: 100%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        > input {
 | 
				
			||||||
 | 
					          font-weight: normal;
 | 
				
			||||||
 | 
					          cursor: pointer;
 | 
				
			||||||
 | 
					          text-align: left !important;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        > button {
 | 
				
			||||||
 | 
					          min-width: 30%;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        @media (min-width: 750px) {
 | 
					        @media (min-width: 750px) {
 | 
				
			||||||
          height: auto;
 | 
					          height: auto;
 | 
				
			||||||
          align-items: center;
 | 
					          align-items: center;
 | 
				
			||||||
@@ -93,8 +124,8 @@
 | 
				
			|||||||
          overflow: hidden;
 | 
					          overflow: hidden;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          > input {
 | 
					          > input {
 | 
				
			||||||
 | 
					            width: 70%;
 | 
				
			||||||
            font-size: .6em;
 | 
					            font-size: .6em;
 | 
				
			||||||
 | 
					 | 
				
			||||||
            &::file-selector-button {
 | 
					            &::file-selector-button {
 | 
				
			||||||
              height: 30px;
 | 
					              height: 30px;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
@@ -136,7 +167,7 @@
 | 
				
			|||||||
      max-width: 100%;
 | 
					      max-width: 100%;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    > * {
 | 
					    >* {
 | 
				
			||||||
      width: 100%;
 | 
					      width: 100%;
 | 
				
			||||||
      max-width: 300px;
 | 
					      max-width: 300px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -150,22 +181,45 @@
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    &-content {
 | 
					    &-content {
 | 
				
			||||||
      > * {
 | 
					
 | 
				
			||||||
 | 
					      >* {
 | 
				
			||||||
        box-sizing: border-box;
 | 
					        box-sizing: border-box;
 | 
				
			||||||
        text-align: left !important;
 | 
					        text-align: left !important;
 | 
				
			||||||
 | 
					        line-height: 40px;
 | 
				
			||||||
 | 
					        max-width: 100%;
 | 
				
			||||||
 | 
					        width: 100%;
 | 
				
			||||||
 | 
					        height: 40px;
 | 
				
			||||||
        margin: 0;
 | 
					        margin: 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        > * {
 | 
					        >* {
 | 
				
			||||||
          text-align: left !important;
 | 
					          text-align: left !important;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    textarea {
 | 
					
 | 
				
			||||||
      height: 7rem;
 | 
					      >textarea {
 | 
				
			||||||
    }
 | 
					        height: 120px;
 | 
				
			||||||
    .final-actions {
 | 
					        min-height: 40px;
 | 
				
			||||||
      text-align: center;
 | 
					        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;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -108,8 +108,7 @@
 | 
				
			|||||||
            <a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
 | 
					            <a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
 | 
				
			||||||
            <a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
 | 
					            <a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
 | 
					          <a href="https://discord.gg/XK9WfPsUFm" target="_link">
 | 
				
			||||||
            <i class="fa-brands fa-github"></i>
 | 
					 | 
				
			||||||
            {% trans %}Site created by the IT Department of the AE{% endtrans %}
 | 
					            {% trans %}Site created by the IT Department of the AE{% endtrans %}
 | 
				
			||||||
          </a>
 | 
					          </a>
 | 
				
			||||||
        {% endblock %}
 | 
					        {% endblock %}
 | 
				
			||||||
@@ -125,14 +124,15 @@
 | 
				
			|||||||
          navbar.style.setProperty("display", current === "none" ? "block" : "none");
 | 
					          navbar.style.setProperty("display", current === "none" ? "block" : "none");
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        document.addEventListener("keydown", (e) => {
 | 
					        $(document).keydown(function (e) {
 | 
				
			||||||
          // Looking at the `s` key when not typing in a form
 | 
					          if ($(e.target).is('input')) { return }
 | 
				
			||||||
          if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {
 | 
					          if ($(e.target).is('textarea')) { return }
 | 
				
			||||||
            return;
 | 
					          if ($(e.target).is('select')) { return }
 | 
				
			||||||
 | 
					          if (e.keyCode === 83) {
 | 
				
			||||||
 | 
					            $("#search").focus();
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
          document.getElementById("search").focus();
 | 
					        });
 | 
				
			||||||
          e.preventDefault(); // Don't type the character in the focused search input
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
      </script>
 | 
					      </script>
 | 
				
			||||||
    {% endblock %}
 | 
					    {% endblock %}
 | 
				
			||||||
  </body>
 | 
					  </body>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -57,4 +57,13 @@
 | 
				
			|||||||
    {% endblock %}
 | 
					    {% endblock %}
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  {% block script %}
 | 
				
			||||||
 | 
					    {{ super() }}
 | 
				
			||||||
 | 
					    {% if popup %}
 | 
				
			||||||
 | 
					      <script>
 | 
				
			||||||
 | 
					        parent.$(".choose_file_widget").css("height", "75%");
 | 
				
			||||||
 | 
					      </script>
 | 
				
			||||||
 | 
					    {% endif %}
 | 
				
			||||||
 | 
					  {% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -60,18 +60,13 @@
 | 
				
			|||||||
        {% endif %}
 | 
					        {% endif %}
 | 
				
			||||||
        {% if user.date_of_birth %}
 | 
					        {% if user.date_of_birth %}
 | 
				
			||||||
          <div class="user_mini_profile_dob">
 | 
					          <div class="user_mini_profile_dob">
 | 
				
			||||||
            {{ user.date_of_birth|date("d/m/Y") }} ({{ user.age }})
 | 
					            {{ user.date_of_birth|date("d/m/Y") }} ({{ user.get_age() }})
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        {% endif %}
 | 
					        {% endif %}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      {% if user.promo and user.promo_has_logo() %}
 | 
					      {% if user.promo and user.promo_has_logo() %}
 | 
				
			||||||
        <div class="user_mini_profile_promo">
 | 
					        <div class="user_mini_profile_promo">
 | 
				
			||||||
          <img
 | 
					          <img src="{{ static('core/img/promo_%02d.png' % user.promo) }}" title="Promo {{ user.promo }}" alt="Promo {{ user.promo }}" class="promo_pict" />
 | 
				
			||||||
            src="{{ static('core/img/promo_%02d.png' % user.promo) }}"
 | 
					 | 
				
			||||||
            title="Promo {{ user.promo }}"
 | 
					 | 
				
			||||||
            alt="Promo {{ user.promo }}"
 | 
					 | 
				
			||||||
            class="promo_pict"
 | 
					 | 
				
			||||||
          />
 | 
					 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      {% endif %}
 | 
					      {% endif %}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
@@ -79,11 +74,8 @@
 | 
				
			|||||||
      {% if user.profile_pict %}
 | 
					      {% if user.profile_pict %}
 | 
				
			||||||
        <img src="{{ user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}" />
 | 
					        <img src="{{ user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}" />
 | 
				
			||||||
      {% else %}
 | 
					      {% else %}
 | 
				
			||||||
        <img
 | 
					        <img src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}"
 | 
				
			||||||
          src="{{ static('core/img/unknown.jpg') }}"
 | 
					             title="{% trans %}Profile{% endtrans %}" />
 | 
				
			||||||
          alt="{% trans %}Profile{% endtrans %}"
 | 
					 | 
				
			||||||
          title="{% trans %}Profile{% endtrans %}"
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      {% endif %}
 | 
					      {% endif %}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
@@ -140,7 +132,7 @@
 | 
				
			|||||||
        nb_page (str): call to a javascript function or variable returning
 | 
					        nb_page (str): call to a javascript function or variable returning
 | 
				
			||||||
            the maximum number of pages to paginate
 | 
					            the maximum number of pages to paginate
 | 
				
			||||||
    #}
 | 
					    #}
 | 
				
			||||||
  <nav class="pagination" x-show="{{ nb_pages }} > 1" x-cloak>
 | 
					  <nav class="pagination" x-show="{{ nb_pages }} > 1">
 | 
				
			||||||
      {# Adding the prevent here is important, because otherwise,
 | 
					      {# Adding the prevent here is important, because otherwise,
 | 
				
			||||||
      clicking on the pagination buttons could submit the picture management form
 | 
					      clicking on the pagination buttons could submit the picture management form
 | 
				
			||||||
      and reload the page #}
 | 
					      and reload the page #}
 | 
				
			||||||
@@ -178,12 +170,12 @@
 | 
				
			|||||||
{% endmacro %}
 | 
					{% endmacro %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% macro paginate_htmx(current_page, paginator) %}
 | 
					{% macro paginate_htmx(current_page, paginator) %}
 | 
				
			||||||
    {# Add pagination buttons for pages without Alpine but supporting fragments.
 | 
					    {# Add pagination buttons for pages without Alpine but supporting framgents.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    This must be coupled with a view that handles pagination
 | 
					    This must be coupled with a view that handles pagination
 | 
				
			||||||
    with the Django Paginator object and supports fragments.
 | 
					    with the Django Paginator object and supports framgents.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    The replaced fragment will be #content so make sure you are calling this macro inside your content block.
 | 
					    The relpaced fragment will be #content so make sure you are calling this macro inside your content block.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Parameters:
 | 
					    Parameters:
 | 
				
			||||||
        current_page (django.core.paginator.Page): the current page object
 | 
					        current_page (django.core.paginator.Page): the current page object
 | 
				
			||||||
@@ -255,9 +247,9 @@
 | 
				
			|||||||
{% macro select_all_checkbox(form_id) %}
 | 
					{% macro select_all_checkbox(form_id) %}
 | 
				
			||||||
  <script type="text/javascript">
 | 
					  <script type="text/javascript">
 | 
				
			||||||
    function checkbox_{{form_id}}(value) {
 | 
					    function checkbox_{{form_id}}(value) {
 | 
				
			||||||
      const inputs = document.getElementById("{{ form_id }}").getElementsByTagName("input");
 | 
					      list = document.getElementById("{{ form_id }}").getElementsByTagName("input");
 | 
				
			||||||
      for (let element of inputs){
 | 
					      for (let element of list){
 | 
				
			||||||
        if (element.type === "checkbox"){
 | 
					        if (element.type == "checkbox"){
 | 
				
			||||||
          element.checked = value;
 | 
					          element.checked = value;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
@@ -266,65 +258,3 @@
 | 
				
			|||||||
  <button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button>
 | 
					  <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>
 | 
					  <button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
 | 
				
			||||||
{% endmacro %}
 | 
					{% 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,18 +3,17 @@
 | 
				
			|||||||
{% macro page_history(page) %}
 | 
					{% macro page_history(page) %}
 | 
				
			||||||
  <p>{% trans page_name=page.name %}You're seeing the history of page "{{ page_name }}"{% endtrans %}</p>
 | 
					  <p>{% trans page_name=page.name %}You're seeing the history of page "{{ page_name }}"{% endtrans %}</p>
 | 
				
			||||||
  <ul>
 | 
					  <ul>
 | 
				
			||||||
    {% set page_name = page.get_full_name() %}
 | 
					    {% for r in (page.revisions.all()|sort(attribute='date', reverse=True)) %}
 | 
				
			||||||
    {%- for rev in page.revisions.order_by("-date").select_related("author") -%}
 | 
					      {% if loop.index < 2 %}
 | 
				
			||||||
      <li>
 | 
					        <li><a href="{{ url('core:page', page_name=page.get_full_name()) }}">{% trans %}last{% endtrans %}</a> -
 | 
				
			||||||
        {% if loop.first %}
 | 
					          {{ user_profile_link(page.revisions.last().author) }} -
 | 
				
			||||||
          <a href="{{ url('core:page', page_name=page_name) }}">{% trans %}last{% endtrans %}</a>
 | 
					          {{ page.revisions.last().date|localtime|date(DATETIME_FORMAT) }} {{ page.revisions.last().date|localtime|time(DATETIME_FORMAT) }}</a></li>
 | 
				
			||||||
        {% else %}
 | 
					      {% else %}
 | 
				
			||||||
          <a href="{{ url('core:page_rev', page_name=page_name, rev=rev.id) }}">{{ rev.revision }}</a>
 | 
					        <li><a href="{{ url('core:page_rev', page_name=page.get_full_name(), rev=r['id']) }}">{{ r.revision }}</a> -
 | 
				
			||||||
        {% endif %}
 | 
					          {{ user_profile_link(r.author) }} -
 | 
				
			||||||
        {{ user_profile_link(rev.author) }} -
 | 
					          {{ r.date|localtime|date(DATETIME_FORMAT) }} {{ r.date|localtime|time(DATETIME_FORMAT) }}</a></li>
 | 
				
			||||||
        {{ rev.date|localtime|date(DATETIME_FORMAT) }} {{ rev.date|localtime|time(DATETIME_FORMAT) }}
 | 
					      {% endif %}
 | 
				
			||||||
      </li>
 | 
					    {% endfor %}
 | 
				
			||||||
    {%- endfor -%}
 | 
					 | 
				
			||||||
  </ul>
 | 
					  </ul>
 | 
				
			||||||
{% endmacro %}
 | 
					{% endmacro %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										54
									
								
								core/templates/core/poster_list.jinja
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								core/templates/core/poster_list.jinja
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,54 @@
 | 
				
			|||||||
 | 
					{% 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 %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -244,30 +244,27 @@
 | 
				
			|||||||
{% block script %}
 | 
					{% block script %}
 | 
				
			||||||
  {{ super() }}
 | 
					  {{ super() }}
 | 
				
			||||||
  <script>
 | 
					  <script>
 | 
				
			||||||
    // Image selection
 | 
					    $(function () {
 | 
				
			||||||
    for (const img of document.querySelectorAll("#small_pictures img")){
 | 
					      var keys = [];
 | 
				
			||||||
      img.addEventListener("click", (e) => {
 | 
					      var pattern = "71,85,89,71,85,89";
 | 
				
			||||||
        const displayed = document.querySelector("#big_picture img");
 | 
					      $(document).keydown(function (e) {
 | 
				
			||||||
        displayed.src = e.target.src;
 | 
					        keys.push(e.keyCode);
 | 
				
			||||||
        displayed.alt = e.target.alt;
 | 
					        if (keys.toString() == pattern) {
 | 
				
			||||||
        displayed.title = e.target.title;
 | 
					          keys = [];
 | 
				
			||||||
      })
 | 
					          $("#big_picture img").attr("src", "{{ static('core/img/yug.jpg') }}");
 | 
				
			||||||
    }
 | 
					        }
 | 
				
			||||||
 | 
					        if (keys.length == 6) {
 | 
				
			||||||
    let keys = [];
 | 
					          keys.shift();
 | 
				
			||||||
    const pattern = "71,85,89,71,85,89";
 | 
					        }
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
    document.addEventListener("keydown", (e) => {
 | 
					    });
 | 
				
			||||||
      keys.push(e.keyCode);
 | 
					    $(function () {
 | 
				
			||||||
      if (keys.toString() === pattern) {
 | 
					      $("#small_pictures img").click(function () {
 | 
				
			||||||
        keys = [];
 | 
					        $("#big_picture img").attr("src", $(this)[0].src);
 | 
				
			||||||
        document.querySelector("#big_picture img").src = "{{ static('core/img/yug.jpg') }}";
 | 
					        $("#big_picture img").attr("alt", $(this)[0].alt);
 | 
				
			||||||
      }
 | 
					        $("#big_picture img").attr("title", $(this)[0].title);
 | 
				
			||||||
      if (keys.length === 6) {
 | 
					      })
 | 
				
			||||||
        keys.shift();
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					 | 
				
			||||||
    $(function () {
 | 
					    $(function () {
 | 
				
			||||||
      $("#drop_gifts").accordion({
 | 
					      $("#drop_gifts").accordion({
 | 
				
			||||||
        heightStyle: "content",
 | 
					        heightStyle: "content",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -63,7 +63,9 @@
 | 
				
			|||||||
            {%- trans -%}Delete{%- endtrans -%}
 | 
					            {%- trans -%}Delete{%- endtrans -%}
 | 
				
			||||||
          </button>
 | 
					          </button>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        {{ form[field_name].label_tag() }}
 | 
					        <p>
 | 
				
			||||||
 | 
					          {{ form[field_name].label }}
 | 
				
			||||||
 | 
					        </p>
 | 
				
			||||||
        {{ form[field_name].errors }}
 | 
					        {{ form[field_name].errors }}
 | 
				
			||||||
      {%- else -%}
 | 
					      {%- else -%}
 | 
				
			||||||
        <em>{% trans %}To edit your profile picture, ask a member of the AE{% endtrans %}</em>
 | 
					        <em>{% trans %}To edit your profile picture, ask a member of the AE{% endtrans %}</em>
 | 
				
			||||||
@@ -116,68 +118,68 @@
 | 
				
			|||||||
    {# All fields #}
 | 
					    {# All fields #}
 | 
				
			||||||
    <div class="profile-fields">
 | 
					    <div class="profile-fields">
 | 
				
			||||||
      {%- for field in form -%}
 | 
					      {%- for field in form -%}
 | 
				
			||||||
        {%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"] -%}
 | 
					        {%-
 | 
				
			||||||
          {%- continue -%}
 | 
					        if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"]
 | 
				
			||||||
        {%- endif -%}
 | 
					        -%}
 | 
				
			||||||
 | 
					        {%- continue -%}
 | 
				
			||||||
        <div class="profile-field">
 | 
					 | 
				
			||||||
          <div class="profile-field-label">{{ field.label }}</div>
 | 
					 | 
				
			||||||
          <div class="profile-field-content">
 | 
					 | 
				
			||||||
            {{ field }}
 | 
					 | 
				
			||||||
            {%- if field.errors -%}
 | 
					 | 
				
			||||||
              <div class="field-error">{{ field.errors }}</div>
 | 
					 | 
				
			||||||
            {%- endif -%}
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      {%- endfor -%}
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    {# Textareas #}
 | 
					 | 
				
			||||||
    <div class="profile-fields">
 | 
					 | 
				
			||||||
      {%- for field in [form.quote, form.forum_signature] -%}
 | 
					 | 
				
			||||||
        <div class="profile-field">
 | 
					 | 
				
			||||||
          <div class="profile-field-label">{{ field.label }}</div>
 | 
					 | 
				
			||||||
          <div class="profile-field-content">
 | 
					 | 
				
			||||||
            {{ field }}
 | 
					 | 
				
			||||||
            {%- if field.errors -%}
 | 
					 | 
				
			||||||
              <div class="field-error">{{ field.errors }}</div>
 | 
					 | 
				
			||||||
            {%- endif -%}
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      {%- endfor -%}
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    {# Checkboxes #}
 | 
					 | 
				
			||||||
    <div class="profile-visible">
 | 
					 | 
				
			||||||
      {{ form.is_subscriber_viewable }}
 | 
					 | 
				
			||||||
      {{ form.is_subscriber_viewable.label }}
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
    <div class="final-actions">
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {%- if form.instance == user -%}
 | 
					 | 
				
			||||||
        <p>
 | 
					 | 
				
			||||||
          <a href="{{ url('core:password_change') }}">{%- trans -%}Change my password{%- endtrans -%}</a>
 | 
					 | 
				
			||||||
        </p>
 | 
					 | 
				
			||||||
      {%- elif user.is_root -%}
 | 
					 | 
				
			||||||
        <p>
 | 
					 | 
				
			||||||
          <a href="{{ url('core:password_root_change', user_id=form.instance.id) }}">
 | 
					 | 
				
			||||||
            {%- trans -%}Change user password{%- endtrans -%}
 | 
					 | 
				
			||||||
          </a>
 | 
					 | 
				
			||||||
        </p>
 | 
					 | 
				
			||||||
      {%- endif -%}
 | 
					      {%- endif -%}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <p>
 | 
					      <div class="profile-field">
 | 
				
			||||||
        <input type="submit" value="{%- trans -%}Update{%- endtrans -%}" />
 | 
					        <div class="profile-field-label">{{ field.label }}</div>
 | 
				
			||||||
      </p>
 | 
					        <div class="profile-field-content">
 | 
				
			||||||
    </div>
 | 
					          {{ field }}
 | 
				
			||||||
  </form>
 | 
					          {%- if field.errors -%}
 | 
				
			||||||
 | 
					            <div class="field-error">{{ field.errors }}</div>
 | 
				
			||||||
 | 
					          {%- endif -%}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					{%- endfor -%}
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    {# Textareas #}
 | 
				
			||||||
 | 
					<div class="profile-fields">
 | 
				
			||||||
 | 
					  {%- for field in [form.quote, form.forum_signature] -%}
 | 
				
			||||||
 | 
					    <div class="profile-field">
 | 
				
			||||||
 | 
					      <div class="profile-field-label">{{ field.label }}</div>
 | 
				
			||||||
 | 
					      <div class="profile-field-content">
 | 
				
			||||||
 | 
					        {{ field }}
 | 
				
			||||||
 | 
					        {%- if field.errors -%}
 | 
				
			||||||
 | 
					          <div class="field-error">{{ field.errors }}</div>
 | 
				
			||||||
 | 
					        {%- endif -%}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  {%- endfor -%}
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    {# Checkboxes #}
 | 
				
			||||||
 | 
					<div class="profile-visible">
 | 
				
			||||||
 | 
					  {{ form.is_subscriber_viewable }}
 | 
				
			||||||
 | 
					  {{ form.is_subscriber_viewable.label }}
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{%- if form.instance == user -%}
 | 
				
			||||||
  <p>
 | 
					  <p>
 | 
				
			||||||
    <em>{%- trans -%}Username: {%- endtrans -%} {{ form.instance.username }}</em>
 | 
					    <a href="{{ url('core:password_change') }}">{%- trans -%}Change my password{%- endtrans -%}</a>
 | 
				
			||||||
    <br />
 | 
					 | 
				
			||||||
    {%- if form.instance.customer -%}
 | 
					 | 
				
			||||||
      <em>{%- trans -%}Account number: {%- endtrans -%} {{ form.instance.customer.account_id }}</em>
 | 
					 | 
				
			||||||
    {%- endif -%}
 | 
					 | 
				
			||||||
  </p>
 | 
					  </p>
 | 
				
			||||||
 | 
					{%- elif user.is_root -%}
 | 
				
			||||||
 | 
					  <p>
 | 
				
			||||||
 | 
					    <a href="{{ url('core:password_root_change', user_id=form.instance.id) }}">
 | 
				
			||||||
 | 
					      {%- trans -%}Change user password{%- endtrans -%}
 | 
				
			||||||
 | 
					    </a>
 | 
				
			||||||
 | 
					  </p>
 | 
				
			||||||
 | 
					{%- endif -%}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<p>
 | 
				
			||||||
 | 
					  <input type="submit" value="{%- trans -%}Update{%- endtrans -%}" />
 | 
				
			||||||
 | 
					</p>
 | 
				
			||||||
 | 
					</form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<p>
 | 
				
			||||||
 | 
					  <em>{%- trans -%}Username: {%- endtrans -%} {{ form.instance.username }}</em>
 | 
				
			||||||
 | 
					  <br />
 | 
				
			||||||
 | 
					  {%- if form.instance.customer -%}
 | 
				
			||||||
 | 
					    <em>{%- trans -%}Account number: {%- endtrans -%} {{ form.instance.customer.account_id }}</em>
 | 
				
			||||||
 | 
					  {%- endif -%}
 | 
				
			||||||
 | 
					</p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{%- endblock -%}
 | 
					{%- endblock -%}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -28,20 +28,42 @@
 | 
				
			|||||||
      </form>
 | 
					      </form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    {% else %}
 | 
					    {% else %}
 | 
				
			||||||
      <p>{% trans trombi=profile.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
 | 
					      <p>{% trans trombi=user.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
 | 
				
			||||||
        <br />
 | 
					        <br />
 | 
				
			||||||
        <a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a>
 | 
					        <a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a>
 | 
				
			||||||
      </p>
 | 
					      </p>
 | 
				
			||||||
    {% endif %}
 | 
					    {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    {% if student_card_fragment %}
 | 
					    {% if profile.customer %}
 | 
				
			||||||
      <h3>{% trans %}Student card{% endtrans %}</h3>
 | 
					      <h3>{% trans %}Student cards{% endtrans %}</h3>
 | 
				
			||||||
      {{ student_card_fragment }}
 | 
					
 | 
				
			||||||
      <p class="justify">
 | 
					      {% if profile.customer.student_cards.exists() %}
 | 
				
			||||||
        {% trans %}You can add a card by asking at a counter or add it yourself here. If you want to manually
 | 
					        <ul class="student-cards">
 | 
				
			||||||
          add a student card yourself, you'll need a NFC reader. We store the UID of the card which is 14 characters long.{% endtrans %}
 | 
					          {% for card in profile.customer.student_cards.all() %}
 | 
				
			||||||
      </p>
 | 
					            <li>
 | 
				
			||||||
 | 
					              {{ card.uid }}
 | 
				
			||||||
 | 
					               - 
 | 
				
			||||||
 | 
					              <a href="{{ url('counter:delete_student_card', customer_id=profile.customer.pk, card_id=card.id) }}">
 | 
				
			||||||
 | 
					                {% trans %}Delete{% endtrans %}
 | 
				
			||||||
 | 
					              </a>
 | 
				
			||||||
 | 
					            </li>
 | 
				
			||||||
 | 
					          {% endfor %}
 | 
				
			||||||
 | 
					        </ul>
 | 
				
			||||||
 | 
					      {% else %}
 | 
				
			||||||
 | 
					        <em class="no-cards">{% trans %}No student card registered.{% endtrans %}</em>
 | 
				
			||||||
 | 
					        <p class="justify">
 | 
				
			||||||
 | 
					          {% trans %}You can add a card by asking at a counter or add it yourself here. If you want to manually
 | 
				
			||||||
 | 
					            add a student card yourself, you'll need a NFC reader. We store the UID of the card which is 14 characters long.{% endtrans %}
 | 
				
			||||||
 | 
					        </p>
 | 
				
			||||||
 | 
					      {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <form class="form form-cards" action="{{ url('counter:add_student_card', customer_id=profile.customer.pk) }}"
 | 
				
			||||||
 | 
					            method="post">
 | 
				
			||||||
 | 
					        {% csrf_token %}
 | 
				
			||||||
 | 
					        {{ student_card_form.as_p() }}
 | 
				
			||||||
 | 
					        <input class="form-submit-btn" type="submit" value="{% trans %}Save{% endtrans %}" />
 | 
				
			||||||
 | 
					      </form>
 | 
				
			||||||
    {% endif %}
 | 
					    {% endif %}
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
@@ -23,9 +23,6 @@
 | 
				
			|||||||
              <li><a href="{{ url('rootplace:operation_logs') }}">{% trans %}Operation logs{% endtrans %}</a></li>
 | 
					              <li><a href="{{ url('rootplace:operation_logs') }}">{% trans %}Operation logs{% endtrans %}</a></li>
 | 
				
			||||||
              <li><a href="{{ url('rootplace:delete_forum_messages') }}">{% trans %}Delete user's forum messages{% endtrans %}</a></li>
 | 
					              <li><a href="{{ url('rootplace:delete_forum_messages') }}">{% trans %}Delete user's forum messages{% endtrans %}</a></li>
 | 
				
			||||||
            {% endif %}
 | 
					            {% endif %}
 | 
				
			||||||
            {% if user.has_perm("core.view_userban") %}
 | 
					 | 
				
			||||||
              <li><a href="{{ url("rootplace:ban_list") }}">{% trans %}Bans{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
            {% endif %}
 | 
					 | 
				
			||||||
            {% if user.can_create_subscription or user.is_root %}
 | 
					            {% if user.can_create_subscription or user.is_root %}
 | 
				
			||||||
              <li><a href="{{ url('subscription:subscription') }}">{% trans %}Subscriptions{% endtrans %}</a></li>
 | 
					              <li><a href="{{ url('subscription:subscription') }}">{% trans %}Subscriptions{% endtrans %}</a></li>
 | 
				
			||||||
            {% endif %}
 | 
					            {% endif %}
 | 
				
			||||||
@@ -55,7 +52,7 @@
 | 
				
			|||||||
          %}
 | 
					          %}
 | 
				
			||||||
          <li><a href="{{ url('counter:admin_list') }}">{% trans %}General counters management{% endtrans %}</a></li>
 | 
					          <li><a href="{{ url('counter:admin_list') }}">{% trans %}General counters management{% endtrans %}</a></li>
 | 
				
			||||||
          <li><a href="{{ url('counter:product_list') }}">{% trans %}Products management{% endtrans %}</a></li>
 | 
					          <li><a href="{{ url('counter:product_list') }}">{% trans %}Products management{% endtrans %}</a></li>
 | 
				
			||||||
          <li><a href="{{ url('counter:product_type_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
 | 
					          <li><a href="{{ url('counter:producttype_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
 | 
				
			||||||
          <li><a href="{{ url('counter:cash_summary_list') }}">{% trans %}Cash register summaries{% endtrans %}</a></li>
 | 
					          <li><a href="{{ url('counter:cash_summary_list') }}">{% trans %}Cash register summaries{% endtrans %}</a></li>
 | 
				
			||||||
          <li><a href="{{ url('counter:invoices_call') }}">{% trans %}Invoices call{% endtrans %}</a></li>
 | 
					          <li><a href="{{ url('counter:invoices_call') }}">{% trans %}Invoices call{% endtrans %}</a></li>
 | 
				
			||||||
          <li><a href="{{ url('counter:eticket_list') }}">{% trans %}Etickets{% endtrans %}</a></li>
 | 
					          <li><a href="{{ url('counter:eticket_list') }}">{% trans %}Etickets{% endtrans %}</a></li>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,7 +18,6 @@ from smtplib import SMTPException
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import freezegun
 | 
					import freezegun
 | 
				
			||||||
import pytest
 | 
					import pytest
 | 
				
			||||||
from django.contrib.auth.hashers import make_password
 | 
					 | 
				
			||||||
from django.core import mail
 | 
					from django.core import mail
 | 
				
			||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
from django.core.mail import EmailMessage
 | 
					from django.core.mail import EmailMessage
 | 
				
			||||||
@@ -31,7 +30,7 @@ from model_bakery import baker
 | 
				
			|||||||
from pytest_django.asserts import assertInHTML, assertRedirects
 | 
					from pytest_django.asserts import assertInHTML, assertRedirects
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from antispam.models import ToxicDomain
 | 
					from antispam.models import ToxicDomain
 | 
				
			||||||
from club.models import Club, Membership
 | 
					from club.models import Membership
 | 
				
			||||||
from core.markdown import markdown
 | 
					from core.markdown import markdown
 | 
				
			||||||
from core.models import AnonymousUser, Group, Page, User
 | 
					from core.models import AnonymousUser, Group, Page, User
 | 
				
			||||||
from core.utils import get_semester_code, get_start_of_semester
 | 
					from core.utils import get_semester_code, get_start_of_semester
 | 
				
			||||||
@@ -119,9 +118,7 @@ class TestUserRegistration:
 | 
				
			|||||||
        response = client.post(reverse("core:register"), valid_payload)
 | 
					        response = client.post(reverse("core:register"), valid_payload)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        assert response.status_code == 200
 | 
					        assert response.status_code == 200
 | 
				
			||||||
        error_html = (
 | 
					        error_html = "<li>Un objet User avec ce champ Adresse email existe déjà.</li>"
 | 
				
			||||||
            "<li>Un objet Utilisateur avec ce champ Adresse email existe déjà.</li>"
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        assertInHTML(error_html, str(response.content.decode()))
 | 
					        assertInHTML(error_html, str(response.content.decode()))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_register_fail_with_not_existing_email(
 | 
					    def test_register_fail_with_not_existing_email(
 | 
				
			||||||
@@ -146,7 +143,7 @@ class TestUserRegistration:
 | 
				
			|||||||
class TestUserLogin:
 | 
					class TestUserLogin:
 | 
				
			||||||
    @pytest.fixture()
 | 
					    @pytest.fixture()
 | 
				
			||||||
    def user(self) -> User:
 | 
					    def user(self) -> User:
 | 
				
			||||||
        return baker.make(User, password=make_password("plop"))
 | 
					        return User.objects.first()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_login_fail(self, client, user):
 | 
					    def test_login_fail(self, client, user):
 | 
				
			||||||
        """Should not login a user correctly."""
 | 
					        """Should not login a user correctly."""
 | 
				
			||||||
@@ -350,35 +347,56 @@ class TestUserIsInGroup(TestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
    def setUpTestData(cls):
 | 
					    def setUpTestData(cls):
 | 
				
			||||||
 | 
					        from club.models import Club
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        cls.root_group = Group.objects.get(name="Root")
 | 
					        cls.root_group = Group.objects.get(name="Root")
 | 
				
			||||||
        cls.public_group = Group.objects.get(name="Public")
 | 
					        cls.public = Group.objects.get(name="Public")
 | 
				
			||||||
        cls.public_user = baker.make(User)
 | 
					        cls.skia = User.objects.get(username="skia")
 | 
				
			||||||
 | 
					        cls.toto = User.objects.create(
 | 
				
			||||||
 | 
					            username="toto", first_name="a", last_name="b", email="a.b@toto.fr"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        cls.subscribers = Group.objects.get(name="Subscribers")
 | 
					        cls.subscribers = Group.objects.get(name="Subscribers")
 | 
				
			||||||
        cls.old_subscribers = Group.objects.get(name="Old subscribers")
 | 
					        cls.old_subscribers = Group.objects.get(name="Old subscribers")
 | 
				
			||||||
        cls.accounting_admin = Group.objects.get(name="Accounting admin")
 | 
					        cls.accounting_admin = Group.objects.get(name="Accounting admin")
 | 
				
			||||||
        cls.com_admin = Group.objects.get(name="Communication admin")
 | 
					        cls.com_admin = Group.objects.get(name="Communication admin")
 | 
				
			||||||
        cls.counter_admin = Group.objects.get(name="Counter admin")
 | 
					        cls.counter_admin = Group.objects.get(name="Counter admin")
 | 
				
			||||||
 | 
					        cls.banned_alcohol = Group.objects.get(name="Banned from buying alcohol")
 | 
				
			||||||
 | 
					        cls.banned_counters = Group.objects.get(name="Banned from counters")
 | 
				
			||||||
 | 
					        cls.banned_subscription = Group.objects.get(name="Banned to subscribe")
 | 
				
			||||||
        cls.sas_admin = Group.objects.get(name="SAS admin")
 | 
					        cls.sas_admin = Group.objects.get(name="SAS admin")
 | 
				
			||||||
        cls.club = baker.make(Club)
 | 
					        cls.club = Club.objects.create(
 | 
				
			||||||
 | 
					            name="Fake Club",
 | 
				
			||||||
 | 
					            unix_name="fake-club",
 | 
				
			||||||
 | 
					            address="Fake address",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        cls.main_club = Club.objects.get(id=1)
 | 
					        cls.main_club = Club.objects.get(id=1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def assert_in_public_group(self, user):
 | 
					    def assert_in_public_group(self, user):
 | 
				
			||||||
        assert user.is_in_group(pk=self.public_group.id)
 | 
					        assert user.is_in_group(pk=self.public.id)
 | 
				
			||||||
        assert user.is_in_group(name=self.public_group.name)
 | 
					        assert user.is_in_group(name=self.public.name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def assert_in_club_metagroups(self, user, club):
 | 
				
			||||||
 | 
					        meta_groups_board = club.unix_name + settings.SITH_BOARD_SUFFIX
 | 
				
			||||||
 | 
					        meta_groups_members = club.unix_name + settings.SITH_MEMBER_SUFFIX
 | 
				
			||||||
 | 
					        assert user.is_in_group(name=meta_groups_board) is False
 | 
				
			||||||
 | 
					        assert user.is_in_group(name=meta_groups_members) is False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def assert_only_in_public_group(self, user):
 | 
					    def assert_only_in_public_group(self, user):
 | 
				
			||||||
        self.assert_in_public_group(user)
 | 
					        self.assert_in_public_group(user)
 | 
				
			||||||
        for group in (
 | 
					        for group in (
 | 
				
			||||||
            self.root_group,
 | 
					            self.root_group,
 | 
				
			||||||
 | 
					            self.banned_counters,
 | 
				
			||||||
            self.accounting_admin,
 | 
					            self.accounting_admin,
 | 
				
			||||||
            self.sas_admin,
 | 
					            self.sas_admin,
 | 
				
			||||||
            self.subscribers,
 | 
					            self.subscribers,
 | 
				
			||||||
            self.old_subscribers,
 | 
					            self.old_subscribers,
 | 
				
			||||||
            self.club.members_group,
 | 
					 | 
				
			||||||
            self.club.board_group,
 | 
					 | 
				
			||||||
        ):
 | 
					        ):
 | 
				
			||||||
            assert not user.is_in_group(pk=group.pk)
 | 
					            assert not user.is_in_group(pk=group.pk)
 | 
				
			||||||
            assert not user.is_in_group(name=group.name)
 | 
					            assert not user.is_in_group(name=group.name)
 | 
				
			||||||
 | 
					        meta_groups_board = self.club.unix_name + settings.SITH_BOARD_SUFFIX
 | 
				
			||||||
 | 
					        meta_groups_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
 | 
				
			||||||
 | 
					        assert user.is_in_group(name=meta_groups_board) is False
 | 
				
			||||||
 | 
					        assert user.is_in_group(name=meta_groups_members) is False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_anonymous_user(self):
 | 
					    def test_anonymous_user(self):
 | 
				
			||||||
        """Test that anonymous users are only in the public group."""
 | 
					        """Test that anonymous users are only in the public group."""
 | 
				
			||||||
@@ -387,80 +405,80 @@ class TestUserIsInGroup(TestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def test_not_subscribed_user(self):
 | 
					    def test_not_subscribed_user(self):
 | 
				
			||||||
        """Test that users who never subscribed are only in the public group."""
 | 
					        """Test that users who never subscribed are only in the public group."""
 | 
				
			||||||
        self.assert_only_in_public_group(self.public_user)
 | 
					        self.assert_only_in_public_group(self.toto)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_wrong_parameter_fail(self):
 | 
					    def test_wrong_parameter_fail(self):
 | 
				
			||||||
        """Test that when neither the pk nor the name argument is given,
 | 
					        """Test that when neither the pk nor the name argument is given,
 | 
				
			||||||
        the function raises a ValueError.
 | 
					        the function raises a ValueError.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        with self.assertRaises(ValueError):
 | 
					        with self.assertRaises(ValueError):
 | 
				
			||||||
            self.public_user.is_in_group()
 | 
					            self.toto.is_in_group()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_number_queries(self):
 | 
					    def test_number_queries(self):
 | 
				
			||||||
        """Test that the number of db queries is stable
 | 
					        """Test that the number of db queries is stable
 | 
				
			||||||
        and that less queries are made when making a new call.
 | 
					        and that less queries are made when making a new call.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        # make sure Skia is in at least one group
 | 
					        # make sure Skia is in at least one group
 | 
				
			||||||
        group_in = baker.make(Group)
 | 
					        self.skia.groups.add(Group.objects.first().pk)
 | 
				
			||||||
        self.public_user.groups.add(group_in)
 | 
					        skia_groups = self.skia.groups.all()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        group_in = skia_groups.first()
 | 
				
			||||||
        cache.clear()
 | 
					        cache.clear()
 | 
				
			||||||
        # Test when the user is in the group
 | 
					        # Test when the user is in the group
 | 
				
			||||||
        with self.assertNumQueries(2):
 | 
					        with self.assertNumQueries(2):
 | 
				
			||||||
            self.public_user.is_in_group(pk=group_in.id)
 | 
					            self.skia.is_in_group(pk=group_in.id)
 | 
				
			||||||
        with self.assertNumQueries(0):
 | 
					        with self.assertNumQueries(0):
 | 
				
			||||||
            self.public_user.is_in_group(pk=group_in.id)
 | 
					            self.skia.is_in_group(pk=group_in.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        group_not_in = baker.make(Group)
 | 
					        ids = skia_groups.values_list("pk", flat=True)
 | 
				
			||||||
 | 
					        group_not_in = Group.objects.exclude(pk__in=ids).first()
 | 
				
			||||||
        cache.clear()
 | 
					        cache.clear()
 | 
				
			||||||
        # Test when the user is not in the group
 | 
					        # Test when the user is not in the group
 | 
				
			||||||
        with self.assertNumQueries(2):
 | 
					        with self.assertNumQueries(2):
 | 
				
			||||||
            self.public_user.is_in_group(pk=group_not_in.id)
 | 
					            self.skia.is_in_group(pk=group_not_in.id)
 | 
				
			||||||
        with self.assertNumQueries(0):
 | 
					        with self.assertNumQueries(0):
 | 
				
			||||||
            self.public_user.is_in_group(pk=group_not_in.id)
 | 
					            self.skia.is_in_group(pk=group_not_in.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_cache_properly_cleared_membership(self):
 | 
					    def test_cache_properly_cleared_membership(self):
 | 
				
			||||||
        """Test that when the membership of a user end,
 | 
					        """Test that when the membership of a user end,
 | 
				
			||||||
        the cache is properly invalidated.
 | 
					        the cache is properly invalidated.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        membership = baker.make(Membership, club=self.club, user=self.public_user)
 | 
					        membership = Membership.objects.create(
 | 
				
			||||||
        cache.clear()
 | 
					            club=self.club, user=self.toto, end_date=None
 | 
				
			||||||
        self.club.get_membership_for(self.public_user)  # this should populate the cache
 | 
					 | 
				
			||||||
        assert membership == cache.get(
 | 
					 | 
				
			||||||
            f"membership_{self.club.id}_{self.public_user.id}"
 | 
					 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					        meta_groups_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
 | 
				
			||||||
 | 
					        cache.clear()
 | 
				
			||||||
 | 
					        assert self.toto.is_in_group(name=meta_groups_members) is True
 | 
				
			||||||
 | 
					        assert membership == cache.get(f"membership_{self.club.id}_{self.toto.id}")
 | 
				
			||||||
        membership.end_date = now() - timedelta(minutes=5)
 | 
					        membership.end_date = now() - timedelta(minutes=5)
 | 
				
			||||||
        membership.save()
 | 
					        membership.save()
 | 
				
			||||||
        cached_membership = cache.get(
 | 
					        cached_membership = cache.get(f"membership_{self.club.id}_{self.toto.id}")
 | 
				
			||||||
            f"membership_{self.club.id}_{self.public_user.id}"
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        assert cached_membership == "not_member"
 | 
					        assert cached_membership == "not_member"
 | 
				
			||||||
 | 
					        assert self.toto.is_in_group(name=meta_groups_members) is False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_cache_properly_cleared_group(self):
 | 
					    def test_cache_properly_cleared_group(self):
 | 
				
			||||||
        """Test that when a user is removed from a group,
 | 
					        """Test that when a user is removed from a group,
 | 
				
			||||||
        the is_in_group_method return False when calling it again.
 | 
					        the is_in_group_method return False when calling it again.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        # testing with pk
 | 
					        # testing with pk
 | 
				
			||||||
        self.public_user.groups.add(self.com_admin.pk)
 | 
					        self.toto.groups.add(self.com_admin.pk)
 | 
				
			||||||
        assert self.public_user.is_in_group(pk=self.com_admin.pk) is True
 | 
					        assert self.toto.is_in_group(pk=self.com_admin.pk) is True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.public_user.groups.remove(self.com_admin.pk)
 | 
					        self.toto.groups.remove(self.com_admin.pk)
 | 
				
			||||||
        assert self.public_user.is_in_group(pk=self.com_admin.pk) is False
 | 
					        assert self.toto.is_in_group(pk=self.com_admin.pk) is False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # testing with name
 | 
					        # testing with name
 | 
				
			||||||
        self.public_user.groups.add(self.sas_admin.pk)
 | 
					        self.toto.groups.add(self.sas_admin.pk)
 | 
				
			||||||
        assert self.public_user.is_in_group(name="SAS admin") is True
 | 
					        assert self.toto.is_in_group(name="SAS admin") is True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.public_user.groups.remove(self.sas_admin.pk)
 | 
					        self.toto.groups.remove(self.sas_admin.pk)
 | 
				
			||||||
        assert self.public_user.is_in_group(name="SAS admin") is False
 | 
					        assert self.toto.is_in_group(name="SAS admin") is False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_not_existing_group(self):
 | 
					    def test_not_existing_group(self):
 | 
				
			||||||
        """Test that searching for a not existing group
 | 
					        """Test that searching for a not existing group
 | 
				
			||||||
        returns False.
 | 
					        returns False.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        user = baker.make(User)
 | 
					        assert self.skia.is_in_group(name="This doesn't exist") is False
 | 
				
			||||||
        user.groups.set(list(Group.objects.all()))
 | 
					 | 
				
			||||||
        assert not user.is_in_group(name="This doesn't exist")
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestDateUtils(TestCase):
 | 
					class TestDateUtils(TestCase):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,7 +14,7 @@ from PIL import Image
 | 
				
			|||||||
from pytest_django.asserts import assertNumQueries
 | 
					from pytest_django.asserts import assertNumQueries
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
 | 
					from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
 | 
				
			||||||
from core.models import Group, SithFile, User
 | 
					from core.models import Group, RealGroup, SithFile, User
 | 
				
			||||||
from sas.models import Picture
 | 
					from sas.models import Picture
 | 
				
			||||||
from sith import settings
 | 
					from sith import settings
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -26,10 +26,12 @@ class TestImageAccess:
 | 
				
			|||||||
        [
 | 
					        [
 | 
				
			||||||
            lambda: baker.make(User, is_superuser=True),
 | 
					            lambda: baker.make(User, is_superuser=True),
 | 
				
			||||||
            lambda: baker.make(
 | 
					            lambda: baker.make(
 | 
				
			||||||
                User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
 | 
					                User,
 | 
				
			||||||
 | 
					                groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)],
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
            lambda: baker.make(
 | 
					            lambda: baker.make(
 | 
				
			||||||
                User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
 | 
					                User,
 | 
				
			||||||
 | 
					                groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)],
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,41 +13,22 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from dataclasses import dataclass
 | 
					 | 
				
			||||||
from datetime import date
 | 
					from datetime import date
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Image utils
 | 
					# Image utils
 | 
				
			||||||
from io import BytesIO
 | 
					from io import BytesIO
 | 
				
			||||||
from typing import Any
 | 
					from typing import Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import PIL
 | 
					import PIL
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.core.files.base import ContentFile
 | 
					from django.core.files.base import ContentFile
 | 
				
			||||||
from django.forms import BaseForm
 | 
					 | 
				
			||||||
from django.http import HttpRequest
 | 
					from django.http import HttpRequest
 | 
				
			||||||
from django.template.loader import render_to_string
 | 
					 | 
				
			||||||
from django.utils.html import SafeString
 | 
					 | 
				
			||||||
from django.utils.timezone import localdate
 | 
					from django.utils.timezone import localdate
 | 
				
			||||||
from PIL import ExifTags
 | 
					from PIL import ExifTags
 | 
				
			||||||
from PIL.Image import Image, Resampling
 | 
					from PIL.Image import Image, Resampling
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@dataclass
 | 
					def get_start_of_semester(today: Optional[date] = None) -> date:
 | 
				
			||||||
class FormFragmentTemplateData[T: BaseForm]:
 | 
					 | 
				
			||||||
    """Dataclass used to pre-render form fragments"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    form: T
 | 
					 | 
				
			||||||
    template: str
 | 
					 | 
				
			||||||
    context: dict[str, Any]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def render(self, request: HttpRequest) -> SafeString:
 | 
					 | 
				
			||||||
        # Request is needed for csrf_tokens
 | 
					 | 
				
			||||||
        return render_to_string(
 | 
					 | 
				
			||||||
            self.template, context={"form": self.form, **self.context}, request=request
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def get_start_of_semester(today: date | None = None) -> date:
 | 
					 | 
				
			||||||
    """Return the date of the start of the semester of the given date.
 | 
					    """Return the date of the start of the semester of the given date.
 | 
				
			||||||
    If no date is given, return the start date of the current semester.
 | 
					    If no date is given, return the start date of the current semester.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -77,7 +58,7 @@ def get_start_of_semester(today: date | None = None) -> date:
 | 
				
			|||||||
    return autumn.replace(year=autumn.year - 1)
 | 
					    return autumn.replace(year=autumn.year - 1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_semester_code(d: date | None = None) -> str:
 | 
					def get_semester_code(d: Optional[date] = None) -> str:
 | 
				
			||||||
    """Return the semester code of the given date.
 | 
					    """Return the semester code of the given date.
 | 
				
			||||||
    If no date is given, return the semester code of the current semester.
 | 
					    If no date is given, return the semester code of the current semester.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,7 +13,6 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
import mimetypes
 | 
					import mimetypes
 | 
				
			||||||
from pathlib import Path
 | 
					 | 
				
			||||||
from urllib.parse import quote, urljoin
 | 
					from urllib.parse import quote, urljoin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# This file contains all the views that concern the page model
 | 
					# This file contains all the views that concern the page model
 | 
				
			||||||
@@ -22,7 +21,6 @@ from wsgiref.util import FileWrapper
 | 
				
			|||||||
from django import forms
 | 
					from django import forms
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.core.exceptions import PermissionDenied
 | 
					from django.core.exceptions import PermissionDenied
 | 
				
			||||||
from django.db.models import Exists, OuterRef
 | 
					 | 
				
			||||||
from django.forms.models import modelform_factory
 | 
					from django.forms.models import modelform_factory
 | 
				
			||||||
from django.http import Http404, HttpRequest, HttpResponse
 | 
					from django.http import Http404, HttpRequest, HttpResponse
 | 
				
			||||||
from django.shortcuts import get_object_or_404, redirect
 | 
					from django.shortcuts import get_object_or_404, redirect
 | 
				
			||||||
@@ -33,7 +31,7 @@ from django.views.generic import DetailView, ListView
 | 
				
			|||||||
from django.views.generic.detail import SingleObjectMixin
 | 
					from django.views.generic.detail import SingleObjectMixin
 | 
				
			||||||
from django.views.generic.edit import DeleteView, FormMixin, UpdateView
 | 
					from django.views.generic.edit import DeleteView, FormMixin, UpdateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.models import Notification, SithFile, User
 | 
					from core.models import Notification, RealGroup, SithFile, User
 | 
				
			||||||
from core.views import (
 | 
					from core.views import (
 | 
				
			||||||
    AllowFragment,
 | 
					    AllowFragment,
 | 
				
			||||||
    CanEditMixin,
 | 
					    CanEditMixin,
 | 
				
			||||||
@@ -49,41 +47,6 @@ from core.views.widgets.select import (
 | 
				
			|||||||
from counter.utils import is_logged_in_counter
 | 
					from counter.utils import is_logged_in_counter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def send_raw_file(path: Path) -> HttpResponse:
 | 
					 | 
				
			||||||
    """Send a file located in the MEDIA_ROOT
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    This handles all the logic of using production reverse proxy or debug server.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    THIS DOESN'T CHECK ANY PERMISSIONS !
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    if not path.is_relative_to(settings.MEDIA_ROOT):
 | 
					 | 
				
			||||||
        raise Http404
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if not path.is_file() or not path.exists():
 | 
					 | 
				
			||||||
        raise Http404
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    response = HttpResponse(
 | 
					 | 
				
			||||||
        headers={"Content-Disposition": f'inline; filename="{quote(path.name)}"'}
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    if not settings.DEBUG:
 | 
					 | 
				
			||||||
        # When receiving a response with the Accel-Redirect header,
 | 
					 | 
				
			||||||
        # the reverse proxy will automatically handle the file sending.
 | 
					 | 
				
			||||||
        # This is really hard to test (thus isn't tested)
 | 
					 | 
				
			||||||
        # so please do not mess with this.
 | 
					 | 
				
			||||||
        response["Content-Type"] = ""  # automatically set by nginx
 | 
					 | 
				
			||||||
        response["X-Accel-Redirect"] = quote(
 | 
					 | 
				
			||||||
            urljoin(settings.MEDIA_URL, str(path.relative_to(settings.MEDIA_ROOT)))
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        return response
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    with open(path, "rb") as filename:
 | 
					 | 
				
			||||||
        response.content = FileWrapper(filename)
 | 
					 | 
				
			||||||
        response["Content-Type"] = mimetypes.guess_type(path)[0]
 | 
					 | 
				
			||||||
        response["Last-Modified"] = http_date(path.stat().st_mtime)
 | 
					 | 
				
			||||||
        response["Content-Length"] = path.stat().st_size
 | 
					 | 
				
			||||||
        return response
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def send_file(
 | 
					def send_file(
 | 
				
			||||||
    request: HttpRequest,
 | 
					    request: HttpRequest,
 | 
				
			||||||
    file_id: int,
 | 
					    file_id: int,
 | 
				
			||||||
@@ -102,7 +65,28 @@ def send_file(
 | 
				
			|||||||
        raise PermissionDenied
 | 
					        raise PermissionDenied
 | 
				
			||||||
    name = getattr(f, file_attr).name
 | 
					    name = getattr(f, file_attr).name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return send_raw_file(settings.MEDIA_ROOT / name)
 | 
					    response = HttpResponse(
 | 
				
			||||||
 | 
					        headers={"Content-Disposition": f'inline; filename="{quote(name)}"'}
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    if not settings.DEBUG:
 | 
				
			||||||
 | 
					        # When receiving a response with the Accel-Redirect header,
 | 
				
			||||||
 | 
					        # the reverse proxy will automatically handle the file sending.
 | 
				
			||||||
 | 
					        # This is really hard to test (thus isn't tested)
 | 
				
			||||||
 | 
					        # so please do not mess with this.
 | 
				
			||||||
 | 
					        response["Content-Type"] = ""  # automatically set by nginx
 | 
				
			||||||
 | 
					        response["X-Accel-Redirect"] = quote(urljoin(settings.MEDIA_URL, name))
 | 
				
			||||||
 | 
					        return response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    filepath = settings.MEDIA_ROOT / name
 | 
				
			||||||
 | 
					    # check if file exists on disk
 | 
				
			||||||
 | 
					    if not filepath.exists():
 | 
				
			||||||
 | 
					        raise Http404
 | 
				
			||||||
 | 
					    with open(filepath, "rb") as filename:
 | 
				
			||||||
 | 
					        response.content = FileWrapper(filename)
 | 
				
			||||||
 | 
					        response["Content-Type"] = mimetypes.guess_type(filepath)[0]
 | 
				
			||||||
 | 
					        response["Last-Modified"] = http_date(f.date.timestamp())
 | 
				
			||||||
 | 
					        response["Content-Length"] = filepath.stat().st_size
 | 
				
			||||||
 | 
					        return response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class MultipleFileInput(forms.ClearableFileInput):
 | 
					class MultipleFileInput(forms.ClearableFileInput):
 | 
				
			||||||
@@ -175,18 +159,19 @@ class AddFilesForm(forms.Form):
 | 
				
			|||||||
                    % {"file_name": f, "msg": repr(e)},
 | 
					                    % {"file_name": f, "msg": repr(e)},
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
        if notif:
 | 
					        if notif:
 | 
				
			||||||
            unread_notif_subquery = Notification.objects.filter(
 | 
					            for u in (
 | 
				
			||||||
                user=OuterRef("pk"), type="FILE_MODERATION", viewed=False
 | 
					                RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
				
			||||||
            )
 | 
					                .first()
 | 
				
			||||||
            for user in User.objects.filter(
 | 
					                .users.all()
 | 
				
			||||||
                ~Exists(unread_notif_subquery),
 | 
					 | 
				
			||||||
                groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
 | 
					 | 
				
			||||||
            ):
 | 
					            ):
 | 
				
			||||||
                Notification.objects.create(
 | 
					                if not u.notifications.filter(
 | 
				
			||||||
                    user=user,
 | 
					                    type="FILE_MODERATION", viewed=False
 | 
				
			||||||
                    url=reverse("core:file_moderation"),
 | 
					                ).exists():
 | 
				
			||||||
                    type="FILE_MODERATION",
 | 
					                    Notification(
 | 
				
			||||||
                )
 | 
					                        user=u,
 | 
				
			||||||
 | 
					                        url=reverse("core:file_moderation"),
 | 
				
			||||||
 | 
					                        type="FILE_MODERATION",
 | 
				
			||||||
 | 
					                    ).save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class FileListView(ListView):
 | 
					class FileListView(ListView):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -21,7 +21,6 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
import re
 | 
					import re
 | 
				
			||||||
from datetime import date, datetime
 | 
					 | 
				
			||||||
from io import BytesIO
 | 
					from io import BytesIO
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from captcha.fields import CaptchaField
 | 
					from captcha.fields import CaptchaField
 | 
				
			||||||
@@ -38,16 +37,14 @@ from django.forms import (
 | 
				
			|||||||
    DateInput,
 | 
					    DateInput,
 | 
				
			||||||
    DateTimeInput,
 | 
					    DateTimeInput,
 | 
				
			||||||
    TextInput,
 | 
					    TextInput,
 | 
				
			||||||
    Widget,
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from django.utils.timezone import now
 | 
					 | 
				
			||||||
from django.utils.translation import gettext
 | 
					from django.utils.translation import gettext
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
 | 
					from phonenumber_field.widgets import RegionalPhoneNumberWidget
 | 
				
			||||||
from PIL import Image
 | 
					from PIL import Image
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from antispam.forms import AntiSpamEmailField
 | 
					from antispam.forms import AntiSpamEmailField
 | 
				
			||||||
from core.models import Gift, Group, Page, SithFile, User
 | 
					from core.models import Gift, Page, SithFile, User
 | 
				
			||||||
from core.utils import resize_image
 | 
					from core.utils import resize_image
 | 
				
			||||||
from core.views.widgets.select import (
 | 
					from core.views.widgets.select import (
 | 
				
			||||||
    AutoCompleteSelect,
 | 
					    AutoCompleteSelect,
 | 
				
			||||||
@@ -133,23 +130,6 @@ class SelectUser(TextInput):
 | 
				
			|||||||
        return output
 | 
					        return output
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Fields
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def validate_future_timestamp(value: date | datetime):
 | 
					 | 
				
			||||||
    if value <= now():
 | 
					 | 
				
			||||||
        raise ValueError(_("Ensure this timestamp is set in the future"))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class FutureDateTimeField(forms.DateTimeField):
 | 
					 | 
				
			||||||
    """A datetime field that accepts only future timestamps."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    default_validators = [validate_future_timestamp]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def widget_attrs(self, widget: Widget) -> dict[str, str]:
 | 
					 | 
				
			||||||
        return {"min": widget.format_value(now())}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Forms
 | 
					# Forms
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -187,15 +167,14 @@ class RegisteringForm(UserCreationForm):
 | 
				
			|||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        model = User
 | 
					        model = User
 | 
				
			||||||
        fields = ("first_name", "last_name", "email")
 | 
					        fields = ("first_name", "last_name", "email")
 | 
				
			||||||
        field_classes = {"email": AntiSpamEmailField}
 | 
					        field_classes = {
 | 
				
			||||||
 | 
					            "email": AntiSpamEmailField,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UserProfileForm(forms.ModelForm):
 | 
					class UserProfileForm(forms.ModelForm):
 | 
				
			||||||
    """Form handling the user profile, managing the files"""
 | 
					    """Form handling the user profile, managing the files"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    required_css_class = "required"
 | 
					 | 
				
			||||||
    error_css_class = "error"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        model = User
 | 
					        model = User
 | 
				
			||||||
        fields = [
 | 
					        fields = [
 | 
				
			||||||
@@ -308,20 +287,15 @@ class UserProfileForm(forms.ModelForm):
 | 
				
			|||||||
        self._post_clean()
 | 
					        self._post_clean()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UserGroupsForm(forms.ModelForm):
 | 
					class UserPropForm(forms.ModelForm):
 | 
				
			||||||
    error_css_class = "error"
 | 
					    error_css_class = "error"
 | 
				
			||||||
    required_css_class = "required"
 | 
					    required_css_class = "required"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    groups = forms.ModelMultipleChoiceField(
 | 
					 | 
				
			||||||
        queryset=Group.objects.filter(is_manually_manageable=True),
 | 
					 | 
				
			||||||
        widget=CheckboxSelectMultiple,
 | 
					 | 
				
			||||||
        label=_("Groups"),
 | 
					 | 
				
			||||||
        required=False,
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        model = User
 | 
					        model = User
 | 
				
			||||||
        fields = ["groups"]
 | 
					        fields = ["groups"]
 | 
				
			||||||
 | 
					        help_texts = {"groups": "Which groups this user belongs to"}
 | 
				
			||||||
 | 
					        widgets = {"groups": CheckboxSelectMultiple}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UserGodfathersForm(forms.Form):
 | 
					class UserGodfathersForm(forms.Form):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -21,9 +21,11 @@ from django.utils.translation import gettext_lazy as _
 | 
				
			|||||||
from django.views.generic import ListView
 | 
					from django.views.generic import ListView
 | 
				
			||||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
 | 
					from django.views.generic.edit import CreateView, DeleteView, UpdateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.models import Group, User
 | 
					from core.models import RealGroup, User
 | 
				
			||||||
from core.views import CanCreateMixin, CanEditMixin, DetailFormView
 | 
					from core.views import CanCreateMixin, CanEditMixin, DetailFormView
 | 
				
			||||||
from core.views.widgets.select import AutoCompleteSelectMultipleUser
 | 
					from core.views.widgets.select import (
 | 
				
			||||||
 | 
					    AutoCompleteSelectMultipleUser,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Forms
 | 
					# Forms
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -57,8 +59,7 @@ class EditMembersForm(forms.Form):
 | 
				
			|||||||
class GroupListView(CanEditMixin, ListView):
 | 
					class GroupListView(CanEditMixin, ListView):
 | 
				
			||||||
    """Displays the Group list."""
 | 
					    """Displays the Group list."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    model = Group
 | 
					    model = RealGroup
 | 
				
			||||||
    queryset = Group.objects.filter(is_manually_manageable=True)
 | 
					 | 
				
			||||||
    ordering = ["name"]
 | 
					    ordering = ["name"]
 | 
				
			||||||
    template_name = "core/group_list.jinja"
 | 
					    template_name = "core/group_list.jinja"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -66,8 +67,7 @@ class GroupListView(CanEditMixin, ListView):
 | 
				
			|||||||
class GroupEditView(CanEditMixin, UpdateView):
 | 
					class GroupEditView(CanEditMixin, UpdateView):
 | 
				
			||||||
    """Edit infos of a Group."""
 | 
					    """Edit infos of a Group."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    model = Group
 | 
					    model = RealGroup
 | 
				
			||||||
    queryset = Group.objects.filter(is_manually_manageable=True)
 | 
					 | 
				
			||||||
    pk_url_kwarg = "group_id"
 | 
					    pk_url_kwarg = "group_id"
 | 
				
			||||||
    template_name = "core/group_edit.jinja"
 | 
					    template_name = "core/group_edit.jinja"
 | 
				
			||||||
    fields = ["name", "description"]
 | 
					    fields = ["name", "description"]
 | 
				
			||||||
@@ -76,8 +76,7 @@ class GroupEditView(CanEditMixin, UpdateView):
 | 
				
			|||||||
class GroupCreateView(CanCreateMixin, CreateView):
 | 
					class GroupCreateView(CanCreateMixin, CreateView):
 | 
				
			||||||
    """Add a new Group."""
 | 
					    """Add a new Group."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    model = Group
 | 
					    model = RealGroup
 | 
				
			||||||
    queryset = Group.objects.filter(is_manually_manageable=True)
 | 
					 | 
				
			||||||
    template_name = "core/create.jinja"
 | 
					    template_name = "core/create.jinja"
 | 
				
			||||||
    fields = ["name", "description"]
 | 
					    fields = ["name", "description"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -87,8 +86,7 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
 | 
				
			|||||||
    Allow adding and removing users from it.
 | 
					    Allow adding and removing users from it.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    model = Group
 | 
					    model = RealGroup
 | 
				
			||||||
    queryset = Group.objects.filter(is_manually_manageable=True)
 | 
					 | 
				
			||||||
    form_class = EditMembersForm
 | 
					    form_class = EditMembersForm
 | 
				
			||||||
    pk_url_kwarg = "group_id"
 | 
					    pk_url_kwarg = "group_id"
 | 
				
			||||||
    template_name = "core/group_detail.jinja"
 | 
					    template_name = "core/group_detail.jinja"
 | 
				
			||||||
@@ -122,8 +120,7 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
 | 
				
			|||||||
class GroupDeleteView(CanEditMixin, DeleteView):
 | 
					class GroupDeleteView(CanEditMixin, DeleteView):
 | 
				
			||||||
    """Delete a Group."""
 | 
					    """Delete a Group."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    model = Group
 | 
					    model = RealGroup
 | 
				
			||||||
    queryset = Group.objects.filter(is_manually_manageable=True)
 | 
					 | 
				
			||||||
    pk_url_kwarg = "group_id"
 | 
					    pk_url_kwarg = "group_id"
 | 
				
			||||||
    template_name = "core/delete_confirm.jinja"
 | 
					    template_name = "core/delete_confirm.jinja"
 | 
				
			||||||
    success_url = reverse_lazy("core:group_list")
 | 
					    success_url = reverse_lazy("core:group_list")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -64,20 +64,16 @@ class PageView(CanViewMixin, DetailView):
 | 
				
			|||||||
class PageHistView(CanViewMixin, DetailView):
 | 
					class PageHistView(CanViewMixin, DetailView):
 | 
				
			||||||
    model = Page
 | 
					    model = Page
 | 
				
			||||||
    template_name = "core/page_hist.jinja"
 | 
					    template_name = "core/page_hist.jinja"
 | 
				
			||||||
    slug_field = "_full_name"
 | 
					 | 
				
			||||||
    slug_url_kwarg = "page_name"
 | 
					 | 
				
			||||||
    _cached_object: Page | None = None
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def dispatch(self, request, *args, **kwargs):
 | 
					    def dispatch(self, request, *args, **kwargs):
 | 
				
			||||||
        page = self.get_object()
 | 
					        res = super().dispatch(request, *args, **kwargs)
 | 
				
			||||||
        if page.need_club_redirection:
 | 
					        if self.object.need_club_redirection:
 | 
				
			||||||
            return redirect("club:club_hist", club_id=page.club.id)
 | 
					            return redirect("club:club_hist", club_id=self.object.club.id)
 | 
				
			||||||
        return super().dispatch(request, *args, **kwargs)
 | 
					        return res
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_object(self, *args, **kwargs):
 | 
					    def get_object(self):
 | 
				
			||||||
        if not self._cached_object:
 | 
					        self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
 | 
				
			||||||
            self._cached_object = super().get_object()
 | 
					        return self.page
 | 
				
			||||||
        return self._cached_object
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PageRevView(CanViewMixin, DetailView):
 | 
					class PageRevView(CanViewMixin, DetailView):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -35,6 +35,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
 | 
				
			|||||||
from django.core.exceptions import PermissionDenied
 | 
					from django.core.exceptions import PermissionDenied
 | 
				
			||||||
from django.db.models import DateField, QuerySet
 | 
					from django.db.models import DateField, QuerySet
 | 
				
			||||||
from django.db.models.functions import Trunc
 | 
					from django.db.models.functions import Trunc
 | 
				
			||||||
 | 
					from django.forms import CheckboxSelectMultiple
 | 
				
			||||||
from django.forms.models import modelform_factory
 | 
					from django.forms.models import modelform_factory
 | 
				
			||||||
from django.http import Http404
 | 
					from django.http import Http404
 | 
				
			||||||
from django.shortcuts import get_object_or_404, redirect
 | 
					from django.shortcuts import get_object_or_404, redirect
 | 
				
			||||||
@@ -67,11 +68,10 @@ from core.views.forms import (
 | 
				
			|||||||
    LoginForm,
 | 
					    LoginForm,
 | 
				
			||||||
    RegisteringForm,
 | 
					    RegisteringForm,
 | 
				
			||||||
    UserGodfathersForm,
 | 
					    UserGodfathersForm,
 | 
				
			||||||
    UserGroupsForm,
 | 
					 | 
				
			||||||
    UserProfileForm,
 | 
					    UserProfileForm,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					from counter.forms import StudentCardForm
 | 
				
			||||||
from counter.models import Refilling, Selling
 | 
					from counter.models import Refilling, Selling
 | 
				
			||||||
from counter.views.student_card import StudentCardFormView
 | 
					 | 
				
			||||||
from eboutic.models import Invoice
 | 
					from eboutic.models import Invoice
 | 
				
			||||||
from subscription.models import Subscription
 | 
					from subscription.models import Subscription
 | 
				
			||||||
from trombi.views import UserTrombiForm
 | 
					from trombi.views import UserTrombiForm
 | 
				
			||||||
@@ -559,6 +559,10 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
 | 
				
			|||||||
    context_object_name = "profile"
 | 
					    context_object_name = "profile"
 | 
				
			||||||
    current_tab = "prefs"
 | 
					    current_tab = "prefs"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_object(self, queryset=None):
 | 
				
			||||||
 | 
					        user = get_object_or_404(User, pk=self.kwargs["user_id"])
 | 
				
			||||||
 | 
					        return user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_form_kwargs(self):
 | 
					    def get_form_kwargs(self):
 | 
				
			||||||
        kwargs = super().get_form_kwargs()
 | 
					        kwargs = super().get_form_kwargs()
 | 
				
			||||||
        pref = self.object.preferences
 | 
					        pref = self.object.preferences
 | 
				
			||||||
@@ -568,12 +572,13 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
 | 
				
			|||||||
    def get_context_data(self, **kwargs):
 | 
					    def get_context_data(self, **kwargs):
 | 
				
			||||||
        kwargs = super().get_context_data(**kwargs)
 | 
					        kwargs = super().get_context_data(**kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if not hasattr(self.object, "trombi_user"):
 | 
					        if not (
 | 
				
			||||||
 | 
					            hasattr(self.object, "trombi_user") and self.request.user.trombi_user.trombi
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
            kwargs["trombi_form"] = UserTrombiForm()
 | 
					            kwargs["trombi_form"] = UserTrombiForm()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if hasattr(self.object, "customer"):
 | 
					        if hasattr(self.object, "customer"):
 | 
				
			||||||
            kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
 | 
					            kwargs["student_card_form"] = StudentCardForm()
 | 
				
			||||||
                self.object.customer
 | 
					 | 
				
			||||||
            ).render(self.request)
 | 
					 | 
				
			||||||
        return kwargs
 | 
					        return kwargs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -583,7 +588,9 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView):
 | 
				
			|||||||
    model = User
 | 
					    model = User
 | 
				
			||||||
    pk_url_kwarg = "user_id"
 | 
					    pk_url_kwarg = "user_id"
 | 
				
			||||||
    template_name = "core/user_group.jinja"
 | 
					    template_name = "core/user_group.jinja"
 | 
				
			||||||
    form_class = UserGroupsForm
 | 
					    form_class = modelform_factory(
 | 
				
			||||||
 | 
					        User, fields=["groups"], widgets={"groups": CheckboxSelectMultiple}
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    context_object_name = "profile"
 | 
					    context_object_name = "profile"
 | 
				
			||||||
    current_tab = "groups"
 | 
					    current_tab = "groups"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -129,7 +129,7 @@ class PermanencyAdmin(SearchModelAdmin):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
@admin.register(ProductType)
 | 
					@admin.register(ProductType)
 | 
				
			||||||
class ProductTypeAdmin(admin.ModelAdmin):
 | 
					class ProductTypeAdmin(admin.ModelAdmin):
 | 
				
			||||||
    list_display = ("name", "order")
 | 
					    list_display = ("name", "priority")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@admin.register(CashRegisterSummary)
 | 
					@admin.register(CashRegisterSummary)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										115
									
								
								counter/api.py
									
									
									
									
									
								
							
							
						
						
									
										115
									
								
								counter/api.py
									
									
									
									
									
								
							@@ -12,49 +12,41 @@
 | 
				
			|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
					# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
from django.conf import settings
 | 
					from typing import Annotated
 | 
				
			||||||
from django.db.models import F
 | 
					
 | 
				
			||||||
from django.shortcuts import get_object_or_404
 | 
					from annotated_types import MinLen
 | 
				
			||||||
 | 
					from django.db.models import Q
 | 
				
			||||||
from ninja import Query
 | 
					from ninja import Query
 | 
				
			||||||
from ninja_extra import ControllerBase, api_controller, paginate, route
 | 
					from ninja_extra import ControllerBase, api_controller, paginate, route
 | 
				
			||||||
from ninja_extra.pagination import PageNumberPaginationExtra
 | 
					from ninja_extra.pagination import PageNumberPaginationExtra
 | 
				
			||||||
 | 
					from ninja_extra.permissions import IsAuthenticated
 | 
				
			||||||
from ninja_extra.schemas import PaginatedResponseSchema
 | 
					from ninja_extra.schemas import PaginatedResponseSchema
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
 | 
					from core.api_permissions import CanAccessLookup, CanView, IsRoot
 | 
				
			||||||
from counter.models import Counter, Product, ProductType
 | 
					from counter.models import Counter, Permanency, Product
 | 
				
			||||||
from counter.schemas import (
 | 
					from counter.schemas import (
 | 
				
			||||||
    CounterFilterSchema,
 | 
					    CounterFilterSchema,
 | 
				
			||||||
    CounterSchema,
 | 
					    CounterSchema,
 | 
				
			||||||
    ProductFilterSchema,
 | 
					    PermanencyFilterSchema,
 | 
				
			||||||
 | 
					    PermanencySchema,
 | 
				
			||||||
    ProductSchema,
 | 
					    ProductSchema,
 | 
				
			||||||
    ProductTypeSchema,
 | 
					 | 
				
			||||||
    ReorderProductTypeSchema,
 | 
					 | 
				
			||||||
    SimpleProductSchema,
 | 
					 | 
				
			||||||
    SimplifiedCounterSchema,
 | 
					    SimplifiedCounterSchema,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
IsCounterAdmin = (
 | 
					 | 
				
			||||||
    IsRoot
 | 
					 | 
				
			||||||
    | IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID)
 | 
					 | 
				
			||||||
    | IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
@api_controller("/counter")
 | 
					@api_controller("/counter")
 | 
				
			||||||
class CounterController(ControllerBase):
 | 
					class CounterController(ControllerBase):
 | 
				
			||||||
    @route.get("", response=list[CounterSchema], permissions=[IsRoot])
 | 
					    @route.get("", response=list[CounterSchema], permissions=[IsRoot])
 | 
				
			||||||
    def fetch_all(self):
 | 
					    def fetch_all(self):
 | 
				
			||||||
        return Counter.objects.annotate_is_open()
 | 
					        return Counter.objects.all()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @route.get("{counter_id}/", response=CounterSchema, permissions=[CanView])
 | 
					    @route.get("{counter_id}/", response=CounterSchema, permissions=[CanView])
 | 
				
			||||||
    def fetch_one(self, counter_id: int):
 | 
					    def fetch_one(self, counter_id: int):
 | 
				
			||||||
        return self.get_object_or_exception(
 | 
					        return self.get_object_or_exception(Counter.objects.all(), pk=counter_id)
 | 
				
			||||||
            Counter.objects.annotate_is_open(), pk=counter_id
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @route.get("bar/", response=list[CounterSchema], permissions=[CanView])
 | 
					    @route.get("bar/", response=list[CounterSchema], permissions=[CanView])
 | 
				
			||||||
    def fetch_bars(self):
 | 
					    def fetch_bars(self):
 | 
				
			||||||
        counters = list(Counter.objects.annotate_is_open().filter(type="BAR"))
 | 
					        counters = list(Counter.objects.all().filter(type="BAR"))
 | 
				
			||||||
        for c in counters:
 | 
					        for c in counters:
 | 
				
			||||||
            self.check_object_permissions(c)
 | 
					            self.check_object_permissions(c)
 | 
				
			||||||
        return counters
 | 
					        return counters
 | 
				
			||||||
@@ -73,72 +65,33 @@ class CounterController(ControllerBase):
 | 
				
			|||||||
class ProductController(ControllerBase):
 | 
					class ProductController(ControllerBase):
 | 
				
			||||||
    @route.get(
 | 
					    @route.get(
 | 
				
			||||||
        "/search",
 | 
					        "/search",
 | 
				
			||||||
        response=PaginatedResponseSchema[SimpleProductSchema],
 | 
					        response=PaginatedResponseSchema[ProductSchema],
 | 
				
			||||||
        permissions=[CanAccessLookup],
 | 
					        permissions=[CanAccessLookup],
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @paginate(PageNumberPaginationExtra, page_size=50)
 | 
					    @paginate(PageNumberPaginationExtra, page_size=50)
 | 
				
			||||||
    def search_products(self, filters: Query[ProductFilterSchema]):
 | 
					    def search_products(self, search: Annotated[str, MinLen(1)]):
 | 
				
			||||||
        return filters.filter(
 | 
					        return (
 | 
				
			||||||
            Product.objects.order_by(
 | 
					            Product.objects.filter(
 | 
				
			||||||
                F("product_type__order").asc(nulls_last=True),
 | 
					                Q(name__icontains=search) | Q(code__icontains=search)
 | 
				
			||||||
                "product_type",
 | 
					 | 
				
			||||||
                "name",
 | 
					 | 
				
			||||||
            ).values()
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @route.get(
 | 
					 | 
				
			||||||
        "/search/detailed",
 | 
					 | 
				
			||||||
        response=PaginatedResponseSchema[ProductSchema],
 | 
					 | 
				
			||||||
        permissions=[IsCounterAdmin],
 | 
					 | 
				
			||||||
        url_name="search_products_detailed",
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    @paginate(PageNumberPaginationExtra, page_size=50)
 | 
					 | 
				
			||||||
    def search_products_detailed(self, filters: Query[ProductFilterSchema]):
 | 
					 | 
				
			||||||
        """Get the detailed information about the products."""
 | 
					 | 
				
			||||||
        return filters.filter(
 | 
					 | 
				
			||||||
            Product.objects.select_related("club")
 | 
					 | 
				
			||||||
            .prefetch_related("buying_groups")
 | 
					 | 
				
			||||||
            .select_related("product_type")
 | 
					 | 
				
			||||||
            .order_by(
 | 
					 | 
				
			||||||
                F("product_type__order").asc(nulls_last=True),
 | 
					 | 
				
			||||||
                "product_type",
 | 
					 | 
				
			||||||
                "name",
 | 
					 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					            .filter(archived=False)
 | 
				
			||||||
 | 
					            .values()
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@api_controller("/product-type", permissions=[IsCounterAdmin])
 | 
					@api_controller("/permanency")
 | 
				
			||||||
class ProductTypeController(ControllerBase):
 | 
					class PermanencyController(ControllerBase):
 | 
				
			||||||
    @route.get("", response=list[ProductTypeSchema], url_name="fetch_product_types")
 | 
					    @route.get(
 | 
				
			||||||
    def fetch_all(self):
 | 
					        "",
 | 
				
			||||||
        return ProductType.objects.order_by("order")
 | 
					        response=PaginatedResponseSchema[PermanencySchema],
 | 
				
			||||||
 | 
					        permissions=[IsAuthenticated],
 | 
				
			||||||
    @route.patch("/{type_id}/move")
 | 
					        exclude_none=True,
 | 
				
			||||||
    def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]):
 | 
					    )
 | 
				
			||||||
        """Change the order of a product type.
 | 
					    @paginate(PageNumberPaginationExtra, page_size=100)
 | 
				
			||||||
 | 
					    def fetch_permanencies(self, filters: Query[PermanencyFilterSchema]):
 | 
				
			||||||
        To use this route, give either the id of the product type
 | 
					        return (
 | 
				
			||||||
        this one should be above of,
 | 
					            filters.filter(Permanency.objects.all())
 | 
				
			||||||
        of the id of the product type this one should be below of.
 | 
					            .distinct()
 | 
				
			||||||
 | 
					            .order_by("-start")
 | 
				
			||||||
        Order affects the display order of the product types.
 | 
					            .select_related("counter")
 | 
				
			||||||
 | 
					 | 
				
			||||||
        Examples:
 | 
					 | 
				
			||||||
            ```
 | 
					 | 
				
			||||||
            GET /api/counter/product-type
 | 
					 | 
				
			||||||
            => [<1: type A>, <2: type B>, <3: type C>]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            PATCH /api/counter/product-type/3/move?below=1
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            GET /api/counter/product-type
 | 
					 | 
				
			||||||
            => [<1: type A>, <3: type C>, <2: type B>]
 | 
					 | 
				
			||||||
            ```
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        product_type: ProductType = self.get_object_or_exception(
 | 
					 | 
				
			||||||
            ProductType, pk=type_id
 | 
					 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        other = get_object_or_404(ProductType, pk=other_id.above or other_id.below)
 | 
					 | 
				
			||||||
        if other_id.below is not None:
 | 
					 | 
				
			||||||
            product_type.below(other)
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            product_type.above(other)
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,12 +24,6 @@
 | 
				
			|||||||
from django.apps import AppConfig
 | 
					from django.apps import AppConfig
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
 | 
					
 | 
				
			||||||
PAYMENT_METHOD = [
 | 
					 | 
				
			||||||
    ("CHECK", _("Check")),
 | 
					 | 
				
			||||||
    ("CASH", _("Cash")),
 | 
					 | 
				
			||||||
    ("CARD", _("Credit card")),
 | 
					 | 
				
			||||||
]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
class CounterConfig(AppConfig):
 | 
					class CounterConfig(AppConfig):
 | 
				
			||||||
    name = "counter"
 | 
					    name = "counter"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -45,14 +45,16 @@ class BillingInfoForm(forms.ModelForm):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class StudentCardForm(forms.ModelForm):
 | 
					class StudentCardForm(forms.ModelForm):
 | 
				
			||||||
    """Form for adding student cards"""
 | 
					    """Form for adding student cards
 | 
				
			||||||
 | 
					    Only used for user profile since CounterClick is to complicated.
 | 
				
			||||||
    error_css_class = "error"
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        model = StudentCard
 | 
					        model = StudentCard
 | 
				
			||||||
        fields = ["uid"]
 | 
					        fields = ["uid"]
 | 
				
			||||||
        widgets = {"uid": NFCTextInput}
 | 
					        widgets = {
 | 
				
			||||||
 | 
					            "uid": NFCTextInput,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def clean(self):
 | 
					    def clean(self):
 | 
				
			||||||
        cleaned_data = super().clean()
 | 
					        cleaned_data = super().clean()
 | 
				
			||||||
@@ -89,7 +91,7 @@ class GetUserForm(forms.Form):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def clean(self):
 | 
					    def clean(self):
 | 
				
			||||||
        cleaned_data = super().clean()
 | 
					        cleaned_data = super().clean()
 | 
				
			||||||
        customer = None
 | 
					        cus = None
 | 
				
			||||||
        if cleaned_data["code"] != "":
 | 
					        if cleaned_data["code"] != "":
 | 
				
			||||||
            if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
 | 
					            if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
 | 
				
			||||||
                card = (
 | 
					                card = (
 | 
				
			||||||
@@ -98,24 +100,29 @@ class GetUserForm(forms.Form):
 | 
				
			|||||||
                    .first()
 | 
					                    .first()
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
                if card is not None:
 | 
					                if card is not None:
 | 
				
			||||||
                    customer = card.customer
 | 
					                    cus = card.customer
 | 
				
			||||||
            if customer is None:
 | 
					            if cus is None:
 | 
				
			||||||
                customer = Customer.objects.filter(
 | 
					                cus = Customer.objects.filter(
 | 
				
			||||||
                    account_id__iexact=cleaned_data["code"]
 | 
					                    account_id__iexact=cleaned_data["code"]
 | 
				
			||||||
                ).first()
 | 
					                ).first()
 | 
				
			||||||
        elif cleaned_data["id"]:
 | 
					        elif cleaned_data["id"] is not None:
 | 
				
			||||||
            customer = Customer.objects.filter(user=cleaned_data["id"]).first()
 | 
					            cus = Customer.objects.filter(user=cleaned_data["id"]).first()
 | 
				
			||||||
 | 
					        if cus is None or not cus.can_buy:
 | 
				
			||||||
        if customer is None or not customer.can_buy:
 | 
					 | 
				
			||||||
            raise forms.ValidationError(_("User not found"))
 | 
					            raise forms.ValidationError(_("User not found"))
 | 
				
			||||||
        cleaned_data["user_id"] = customer.user.id
 | 
					        cleaned_data["user_id"] = cus.user.id
 | 
				
			||||||
        cleaned_data["user"] = customer.user
 | 
					        cleaned_data["user"] = cus.user
 | 
				
			||||||
        return cleaned_data
 | 
					        return cleaned_data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RefillForm(forms.ModelForm):
 | 
					class NFCCardForm(forms.Form):
 | 
				
			||||||
    allowed_refilling_methods = ["CASH", "CARD"]
 | 
					    student_card_uid = forms.CharField(
 | 
				
			||||||
 | 
					        max_length=StudentCard.UID_SIZE,
 | 
				
			||||||
 | 
					        required=False,
 | 
				
			||||||
 | 
					        widget=NFCTextInput,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class RefillForm(forms.ModelForm):
 | 
				
			||||||
    error_css_class = "error"
 | 
					    error_css_class = "error"
 | 
				
			||||||
    required_css_class = "required"
 | 
					    required_css_class = "required"
 | 
				
			||||||
    amount = forms.FloatField(
 | 
					    amount = forms.FloatField(
 | 
				
			||||||
@@ -125,21 +132,6 @@ class RefillForm(forms.ModelForm):
 | 
				
			|||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        model = Refilling
 | 
					        model = Refilling
 | 
				
			||||||
        fields = ["amount", "payment_method", "bank"]
 | 
					        fields = ["amount", "payment_method", "bank"]
 | 
				
			||||||
        widgets = {"payment_method": forms.RadioSelect}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __init__(self, *args, **kwargs):
 | 
					 | 
				
			||||||
        super().__init__(*args, **kwargs)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.fields["payment_method"].choices = (
 | 
					 | 
				
			||||||
            method
 | 
					 | 
				
			||||||
            for method in self.fields["payment_method"].choices
 | 
					 | 
				
			||||||
            if method[0] in self.allowed_refilling_methods
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
 | 
					 | 
				
			||||||
            self.fields["payment_method"].initial = self.allowed_refilling_methods[0]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if "CHECK" not in self.allowed_refilling_methods:
 | 
					 | 
				
			||||||
            del self.fields["bank"]
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class CounterEditForm(forms.ModelForm):
 | 
					class CounterEditForm(forms.ModelForm):
 | 
				
			||||||
@@ -154,9 +146,6 @@ class CounterEditForm(forms.ModelForm):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ProductEditForm(forms.ModelForm):
 | 
					class ProductEditForm(forms.ModelForm):
 | 
				
			||||||
    error_css_class = "error"
 | 
					 | 
				
			||||||
    required_css_class = "required"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        model = Product
 | 
					        model = Product
 | 
				
			||||||
        fields = [
 | 
					        fields = [
 | 
				
			||||||
@@ -164,6 +153,7 @@ class ProductEditForm(forms.ModelForm):
 | 
				
			|||||||
            "description",
 | 
					            "description",
 | 
				
			||||||
            "product_type",
 | 
					            "product_type",
 | 
				
			||||||
            "code",
 | 
					            "code",
 | 
				
			||||||
 | 
					            "parent_product",
 | 
				
			||||||
            "buying_groups",
 | 
					            "buying_groups",
 | 
				
			||||||
            "purchase_price",
 | 
					            "purchase_price",
 | 
				
			||||||
            "selling_price",
 | 
					            "selling_price",
 | 
				
			||||||
@@ -174,13 +164,8 @@ class ProductEditForm(forms.ModelForm):
 | 
				
			|||||||
            "tray",
 | 
					            "tray",
 | 
				
			||||||
            "archived",
 | 
					            "archived",
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
        help_texts = {
 | 
					 | 
				
			||||||
            "description": _(
 | 
					 | 
				
			||||||
                "Describe the product. If it's an event's click, "
 | 
					 | 
				
			||||||
                "give some insights about it, like the date (including the year)."
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        widgets = {
 | 
					        widgets = {
 | 
				
			||||||
 | 
					            "parent_product": AutoCompleteSelectMultipleProduct,
 | 
				
			||||||
            "product_type": AutoCompleteSelect,
 | 
					            "product_type": AutoCompleteSelect,
 | 
				
			||||||
            "buying_groups": AutoCompleteSelectMultipleGroup,
 | 
					            "buying_groups": AutoCompleteSelectMultipleGroup,
 | 
				
			||||||
            "club": AutoCompleteSelectClub,
 | 
					            "club": AutoCompleteSelectClub,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -55,9 +55,7 @@ class Command(BaseCommand):
 | 
				
			|||||||
            customer__user__in=reactivated_users
 | 
					            customer__user__in=reactivated_users
 | 
				
			||||||
        ).delete()
 | 
					        ).delete()
 | 
				
			||||||
        self._dump_accounts({u.customer for u in users_to_dump})
 | 
					        self._dump_accounts({u.customer for u in users_to_dump})
 | 
				
			||||||
        self.stdout.write("Accounts dumped")
 | 
					        self._send_mails(users_to_dump)
 | 
				
			||||||
        nb_successful_mails = self._send_mails(users_to_dump)
 | 
					 | 
				
			||||||
        self.stdout.write(f"{nb_successful_mails} were successfuly sent.")
 | 
					 | 
				
			||||||
        self.stdout.write("Finished !")
 | 
					        self.stdout.write("Finished !")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
@@ -105,14 +103,13 @@ class Command(BaseCommand):
 | 
				
			|||||||
        if len(pending_dumps) != len(customer_ids):
 | 
					        if len(pending_dumps) != len(customer_ids):
 | 
				
			||||||
            raise ValueError("One or more accounts were not engaged in a dump process")
 | 
					            raise ValueError("One or more accounts were not engaged in a dump process")
 | 
				
			||||||
        counter = Counter.objects.get(pk=settings.SITH_COUNTER_ACCOUNT_DUMP_ID)
 | 
					        counter = Counter.objects.get(pk=settings.SITH_COUNTER_ACCOUNT_DUMP_ID)
 | 
				
			||||||
        seller = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
 | 
					 | 
				
			||||||
        sales = Selling.objects.bulk_create(
 | 
					        sales = Selling.objects.bulk_create(
 | 
				
			||||||
            [
 | 
					            [
 | 
				
			||||||
                Selling(
 | 
					                Selling(
 | 
				
			||||||
                    label="Vidange compte inactif",
 | 
					                    label="Vidange compte inactif",
 | 
				
			||||||
                    club=counter.club,
 | 
					                    club=counter.club,
 | 
				
			||||||
                    counter=counter,
 | 
					                    counter=counter,
 | 
				
			||||||
                    seller=seller,
 | 
					                    seller=None,
 | 
				
			||||||
                    product=None,
 | 
					                    product=None,
 | 
				
			||||||
                    customer=account,
 | 
					                    customer=account,
 | 
				
			||||||
                    quantity=1,
 | 
					                    quantity=1,
 | 
				
			||||||
@@ -127,7 +124,7 @@ class Command(BaseCommand):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        # dumps and sales are linked to the same customers
 | 
					        # dumps and sales are linked to the same customers
 | 
				
			||||||
        # and or both ordered with the same key, so zipping them is valid
 | 
					        # and or both ordered with the same key, so zipping them is valid
 | 
				
			||||||
        for dump, sale in zip(pending_dumps, sales, strict=False):
 | 
					        for dump, sale in zip(pending_dumps, sales):
 | 
				
			||||||
            dump.dump_operation = sale
 | 
					            dump.dump_operation = sale
 | 
				
			||||||
        AccountDump.objects.bulk_update(pending_dumps, ["dump_operation"])
 | 
					        AccountDump.objects.bulk_update(pending_dumps, ["dump_operation"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -137,12 +134,8 @@ class Command(BaseCommand):
 | 
				
			|||||||
        Customer.objects.filter(pk__in=customer_ids).update(amount=0)
 | 
					        Customer.objects.filter(pk__in=customer_ids).update(amount=0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
    def _send_mails(users: Iterable[User]) -> int:
 | 
					    def _send_mails(users: Iterable[User]):
 | 
				
			||||||
        """Send the mails informing users that their account has been dumped.
 | 
					        """Send the mails informing users that their account has been dumped."""
 | 
				
			||||||
 | 
					 | 
				
			||||||
        Returns:
 | 
					 | 
				
			||||||
            The number of emails successfully sent.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        mails = [
 | 
					        mails = [
 | 
				
			||||||
            (
 | 
					            (
 | 
				
			||||||
                _("Your AE account has been emptied"),
 | 
					                _("Your AE account has been emptied"),
 | 
				
			||||||
@@ -152,4 +145,4 @@ class Command(BaseCommand):
 | 
				
			|||||||
            )
 | 
					            )
 | 
				
			||||||
            for user in users
 | 
					            for user in users
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
        return send_mass_mail(mails, fail_silently=True)
 | 
					        send_mass_mail(mails)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,38 @@
 | 
				
			|||||||
from __future__ import unicode_literals
 | 
					from __future__ import unicode_literals
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.conf import settings
 | 
				
			||||||
from django.db import migrations, models
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from core.models import User
 | 
				
			||||||
 | 
					from counter.models import Counter, Customer, Product, Selling
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def balance_ecocups(apps, schema_editor):
 | 
				
			||||||
 | 
					    for customer in Customer.objects.all():
 | 
				
			||||||
 | 
					        customer.recorded_products = 0
 | 
				
			||||||
 | 
					        for selling in customer.buyings.filter(
 | 
				
			||||||
 | 
					            product__id__in=[settings.SITH_ECOCUP_CONS, settings.SITH_ECOCUP_DECO]
 | 
				
			||||||
 | 
					        ).all():
 | 
				
			||||||
 | 
					            if selling.product.is_record_product:
 | 
				
			||||||
 | 
					                customer.recorded_products += selling.quantity
 | 
				
			||||||
 | 
					            elif selling.product.is_unrecord_product:
 | 
				
			||||||
 | 
					                customer.recorded_products -= selling.quantity
 | 
				
			||||||
 | 
					        if customer.recorded_products < -settings.SITH_ECOCUP_LIMIT:
 | 
				
			||||||
 | 
					            qt = -(customer.recorded_products + settings.SITH_ECOCUP_LIMIT)
 | 
				
			||||||
 | 
					            cons = Product.objects.get(id=settings.SITH_ECOCUP_CONS)
 | 
				
			||||||
 | 
					            Selling(
 | 
				
			||||||
 | 
					                label=_("Ecocup regularization"),
 | 
				
			||||||
 | 
					                product=cons,
 | 
				
			||||||
 | 
					                unit_price=cons.selling_price,
 | 
				
			||||||
 | 
					                club=cons.club,
 | 
				
			||||||
 | 
					                counter=Counter.objects.filter(name="Foyer").first(),
 | 
				
			||||||
 | 
					                quantity=qt,
 | 
				
			||||||
 | 
					                seller=User.objects.get(id=0),
 | 
				
			||||||
 | 
					                customer=customer,
 | 
				
			||||||
 | 
					            ).save(allow_negative=True)
 | 
				
			||||||
 | 
					            customer.recorded_products += qt
 | 
				
			||||||
 | 
					        customer.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Migration(migrations.Migration):
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
@@ -12,4 +44,5 @@ class Migration(migrations.Migration):
 | 
				
			|||||||
            name="recorded_products",
 | 
					            name="recorded_products",
 | 
				
			||||||
            field=models.IntegerField(verbose_name="recorded items", default=0),
 | 
					            field=models.IntegerField(verbose_name="recorded items", default=0),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.RunPython(balance_ecocups),
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,38 +0,0 @@
 | 
				
			|||||||
# Generated by Django 4.2.17 on 2024-12-09 11:07
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.db import migrations, models
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import accounting.models
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Migration(migrations.Migration):
 | 
					 | 
				
			||||||
    dependencies = [("counter", "0024_accountdump_accountdump_unique_ongoing_dump")]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    operations = [
 | 
					 | 
				
			||||||
        migrations.RemoveField(model_name="product", name="parent_product"),
 | 
					 | 
				
			||||||
        migrations.AlterField(
 | 
					 | 
				
			||||||
            model_name="product",
 | 
					 | 
				
			||||||
            name="description",
 | 
					 | 
				
			||||||
            field=models.TextField(default="", verbose_name="description"),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.AlterField(
 | 
					 | 
				
			||||||
            model_name="product",
 | 
					 | 
				
			||||||
            name="purchase_price",
 | 
					 | 
				
			||||||
            field=accounting.models.CurrencyField(
 | 
					 | 
				
			||||||
                decimal_places=2,
 | 
					 | 
				
			||||||
                help_text="Initial cost of purchasing the product",
 | 
					 | 
				
			||||||
                max_digits=12,
 | 
					 | 
				
			||||||
                verbose_name="purchase price",
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.AlterField(
 | 
					 | 
				
			||||||
            model_name="product",
 | 
					 | 
				
			||||||
            name="special_selling_price",
 | 
					 | 
				
			||||||
            field=accounting.models.CurrencyField(
 | 
					 | 
				
			||||||
                decimal_places=2,
 | 
					 | 
				
			||||||
                help_text="Price for barmen during their permanence",
 | 
					 | 
				
			||||||
                max_digits=12,
 | 
					 | 
				
			||||||
                verbose_name="special selling price",
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
@@ -1,53 +0,0 @@
 | 
				
			|||||||
# Generated by Django 4.2.17 on 2024-12-08 13:30
 | 
					 | 
				
			||||||
from operator import attrgetter
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import django.db.models.deletion
 | 
					 | 
				
			||||||
from django.db import migrations, models
 | 
					 | 
				
			||||||
from django.db.migrations.state import StateApps
 | 
					 | 
				
			||||||
from django.db.models import Count
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def delete_duplicates(apps: StateApps, schema_editor):
 | 
					 | 
				
			||||||
    """Delete cards of users with more than one student cards.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    For all users who have more than one registered student card, all
 | 
					 | 
				
			||||||
    the cards except the last one are deleted.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Customer = apps.get_model("counter", "Customer")
 | 
					 | 
				
			||||||
    StudentCard = apps.get_model("counter", "StudentCard")
 | 
					 | 
				
			||||||
    customers = (
 | 
					 | 
				
			||||||
        Customer.objects.annotate(nb_cards=Count("student_cards"))
 | 
					 | 
				
			||||||
        .filter(nb_cards__gt=1)
 | 
					 | 
				
			||||||
        .prefetch_related("student_cards")
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    to_delete = [
 | 
					 | 
				
			||||||
        card.id
 | 
					 | 
				
			||||||
        for customer in customers
 | 
					 | 
				
			||||||
        for card in sorted(customer.student_cards.all(), key=attrgetter("id"))[:-1]
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
    StudentCard.objects.filter(id__in=to_delete).delete()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Migration(migrations.Migration):
 | 
					 | 
				
			||||||
    dependencies = [("counter", "0025_remove_product_parent_product_and_more")]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    operations = [
 | 
					 | 
				
			||||||
        migrations.RunPython(delete_duplicates, migrations.RunPython.noop),
 | 
					 | 
				
			||||||
        migrations.AlterField(
 | 
					 | 
				
			||||||
            model_name="studentcard",
 | 
					 | 
				
			||||||
            name="customer",
 | 
					 | 
				
			||||||
            field=models.OneToOneField(
 | 
					 | 
				
			||||||
                on_delete=django.db.models.deletion.CASCADE,
 | 
					 | 
				
			||||||
                related_name="student_card",
 | 
					 | 
				
			||||||
                to="counter.customer",
 | 
					 | 
				
			||||||
                verbose_name="student card",
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.AlterModelOptions(
 | 
					 | 
				
			||||||
            name="studentcard",
 | 
					 | 
				
			||||||
            options={
 | 
					 | 
				
			||||||
                "verbose_name": "student card",
 | 
					 | 
				
			||||||
                "verbose_name_plural": "student cards",
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
@@ -1,22 +0,0 @@
 | 
				
			|||||||
# Generated by Django 4.2.17 on 2024-12-15 22:21
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.db import migrations, models
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Migration(migrations.Migration):
 | 
					 | 
				
			||||||
    dependencies = [
 | 
					 | 
				
			||||||
        ("counter", "0026_alter_studentcard_customer"),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    operations = [
 | 
					 | 
				
			||||||
        migrations.AlterField(
 | 
					 | 
				
			||||||
            model_name="refilling",
 | 
					 | 
				
			||||||
            name="payment_method",
 | 
					 | 
				
			||||||
            field=models.CharField(
 | 
					 | 
				
			||||||
                choices=[("CHECK", "Check"), ("CASH", "Cash"), ("CARD", "Credit card")],
 | 
					 | 
				
			||||||
                default="CARD",
 | 
					 | 
				
			||||||
                max_length=255,
 | 
					 | 
				
			||||||
                verbose_name="payment method",
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
@@ -1,62 +0,0 @@
 | 
				
			|||||||
# Generated by Django 4.2.17 on 2024-12-15 17:53
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.db import migrations, models
 | 
					 | 
				
			||||||
from django.db.migrations.state import StateApps
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def move_priority_to_order(apps: StateApps, schema_editor):
 | 
					 | 
				
			||||||
    """Migrate the previous homemade `priority` to `OrderedModel.order`.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    `priority` was a system were click managers set themselves the priority
 | 
					 | 
				
			||||||
    of a ProductType.
 | 
					 | 
				
			||||||
    The higher the priority, the higher it was to be displayed in the eboutic.
 | 
					 | 
				
			||||||
    Multiple product types could share the same priority, in which
 | 
					 | 
				
			||||||
    case they were ordered by alphabetic order.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    The new field is unique per object, and works in the other way :
 | 
					 | 
				
			||||||
    the nearer from 0, the higher it should appear.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    ProductType = apps.get_model("counter", "ProductType")
 | 
					 | 
				
			||||||
    product_types = list(ProductType.objects.order_by("-priority", "name"))
 | 
					 | 
				
			||||||
    for order, product_type in enumerate(product_types):
 | 
					 | 
				
			||||||
        product_type.order = order
 | 
					 | 
				
			||||||
    ProductType.objects.bulk_update(product_types, ["order"])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Migration(migrations.Migration):
 | 
					 | 
				
			||||||
    dependencies = [("counter", "0027_alter_refilling_payment_method")]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    operations = [
 | 
					 | 
				
			||||||
        migrations.AlterField(
 | 
					 | 
				
			||||||
            model_name="producttype",
 | 
					 | 
				
			||||||
            name="comment",
 | 
					 | 
				
			||||||
            field=models.TextField(
 | 
					 | 
				
			||||||
                default="",
 | 
					 | 
				
			||||||
                help_text="A text that will be shown on the eboutic.",
 | 
					 | 
				
			||||||
                verbose_name="comment",
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.AlterField(
 | 
					 | 
				
			||||||
            model_name="producttype",
 | 
					 | 
				
			||||||
            name="description",
 | 
					 | 
				
			||||||
            field=models.TextField(default="", verbose_name="description"),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.AlterModelOptions(
 | 
					 | 
				
			||||||
            name="producttype",
 | 
					 | 
				
			||||||
            options={"ordering": ["order"], "verbose_name": "product type"},
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.AddField(
 | 
					 | 
				
			||||||
            model_name="producttype",
 | 
					 | 
				
			||||||
            name="order",
 | 
					 | 
				
			||||||
            field=models.PositiveIntegerField(
 | 
					 | 
				
			||||||
                db_index=True, default=0, editable=False, verbose_name="order"
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
            preserve_default=False,
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.RunPython(
 | 
					 | 
				
			||||||
            move_priority_to_order,
 | 
					 | 
				
			||||||
            reverse_code=migrations.RunPython.noop,
 | 
					 | 
				
			||||||
            elidable=True,
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.RemoveField(model_name="producttype", name="priority"),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
@@ -1,17 +0,0 @@
 | 
				
			|||||||
# Generated by Django 4.2.17 on 2024-12-22 22:59
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.db import migrations, models
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Migration(migrations.Migration):
 | 
					 | 
				
			||||||
    dependencies = [
 | 
					 | 
				
			||||||
        ("counter", "0028_alter_producttype_comment_and_more"),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    operations = [
 | 
					 | 
				
			||||||
        migrations.AlterField(
 | 
					 | 
				
			||||||
            model_name="selling",
 | 
					 | 
				
			||||||
            name="label",
 | 
					 | 
				
			||||||
            field=models.CharField(max_length=128, verbose_name="label"),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user