mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-11-04 02:53:06 +00:00 
			
		
		
		
	Compare commits
	
		
			159 Commits
		
	
	
		
			trombi
			...
			windows-up
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1d03fcf6ea | |||
| a6ba65a494 | |||
| c90fcc838e | |||
| 
						 | 
					bc9cb9b36c | ||
| edafc06c3f | |||
| 
						 | 
					134f8a7989 | ||
| 771cbdbd77 | |||
| a491baddb9 | |||
| 8d10a5e0ab | |||
| cbe42d3a60 | |||
| 0c4d72e17a | |||
| 
						 | 
					429df81ec9 | ||
| bb24516474 | |||
| 
						 | 
					8e339c3d4b | ||
| 25298518bc | |||
| 
						 | 
					2e26ff2cde | ||
| a8702d4f5e | |||
| 
						 | 
					7f4cc5fb0f | ||
| 
						 | 
					e7215be00e | ||
| 
						 | 
					4f35cc00bc | ||
| 
						 | 
					af47587116 | ||
| 
						 | 
					3c4daeadb0 | ||
| 
						 | 
					348ab19ac6 | ||
| 
						 | 
					ada74a3e42 | ||
| 
						 | 
					785ac9bdab | ||
| 
						 | 
					d1e604e7a5 | ||
| 2749a88704 | |||
| eb3db134f8 | |||
| fa7f5d24b0 | |||
| ba76015c71 | |||
| 1887a2790f | |||
| 5d0fc38107 | |||
| 65df55a635 | |||
| a60e1f1fdc | |||
| 0a0f44607e | |||
| 007080ee48 | |||
| a13e3e95b7 | |||
| 169938e1da | |||
| e5fb875968 | |||
| 9bd14f1b4e | |||
| fd2295119d | |||
| eac2709e86 | |||
| 48f6d134bf | |||
| 6d7467e746 | |||
| 0d1629495b | |||
| 63839dc22b | |||
| 
						 | 
					c627944bd1 | ||
| 
						 | 
					f0be4b270b | ||
| 
						 | 
					728065e771 | ||
| 
						 | 
					849fac490d | ||
| 
						 | 
					5752229312 | ||
| 
						 | 
					6eb860579a | ||
| 
						 | 
					d08d54b4c9 | ||
| 
						 | 
					bb210f8d47 | ||
| 
						 | 
					efca10e252 | ||
| 
						 | 
					b8f851b009 | ||
| 
						 | 
					1e29ae4171 | ||
| 
						 | 
					0ae1e850f4 | ||
| 
						 | 
					d380668c0f | ||
| 
						 | 
					9a72c5eb72 | ||
| 
						 | 
					407cfbe02b | ||
| 
						 | 
					6400b2c2c2 | ||
| 
						 | 
					cce7ecbe73 | ||
| 
						 | 
					d200c1e381 | ||
| 
						 | 
					2f9e5bfee1 | ||
| 
						 | 
					11702d3d7c | ||
| 43f47e2087 | |||
| 
						 | 
					4b881903f0 | ||
| 
						 | 
					761e37ade6 | ||
| 
						 | 
					10ed2f7404 | ||
| 43768f1691 | |||
| 280d27343d | |||
| 138e1662c7 | |||
| c80fe094a2 | |||
| 139221dd22 | |||
| 
						 | 
					72c2981d66 | ||
| 6f003ffa53 | |||
| 7f6fd7dc47 | |||
| ccf5118c9d | |||
| 022c19c020 | |||
| 2e5e217842 | |||
| 9c93c004ec | |||
| 472800eff6 | |||
| b8d43a629b | |||
| f6693e12cf | |||
| 38f491cf57 | |||
| 3464d5d860 | |||
| 
						 | 
					81773dc800 | ||
| 
						 | 
					da400155eb | ||
| 5079938a5b | |||
| b8430adc50 | |||
| eed434aeb2 | |||
| 372470b44b | |||
| 7071553c3b | |||
| eea237b813 | |||
| c37288c285 | |||
| ccf5767a01 | |||
| ffe6fc8c2a | |||
| 5f0b4d2050 | |||
| f9d7dc7d3a | |||
| 8ebea00896 | |||
| a548f4744e | |||
| a383f3e717 | |||
| 60f18669c8 | |||
| a36946529b | |||
| 
						 | 
					eaac0c728f | ||
| 
						 | 
					9ca95774a3 | ||
| 
						 | 
					fa66851889 | ||
| 
						 | 
					ab81f11199 | ||
| 
						 | 
					bea7741d35 | ||
| 
						 | 
					81e163812e | ||
| 
						 | 
					4f233538e0 | ||
| 4ac09ac08b | |||
| 
						 | 
					6d02970676 | ||
| 
						 | 
					accf1befce | ||
| 
						 | 
					6953eaa9d0 | ||
| 
						 | 
					180bae59c8 | ||
| 
						 | 
					9cafc163e8 | ||
| 
						 | 
					8f8eef4107 | ||
| 
						 | 
					7af745087e | ||
| 
						 | 
					aab093200b | ||
| 
						 | 
					1a9556f811 | ||
| 
						 | 
					39b36aa509 | ||
| 
						 | 
					3fc260a12c | ||
| 
						 | 
					1696a2f579 | ||
| 
						 | 
					baebc0b690 | ||
| 
						 | 
					9f3a10ca71 | ||
| 
						 | 
					38ceaf3106 | ||
| 87b619794d | |||
| 
						 | 
					29c4a36479 | ||
| 
						 | 
					ddeb12f08c | ||
| 
						 | 
					a7b1406e06 | ||
| 
						 | 
					871ef60cf6 | ||
| 
						 | 
					7e9071a533 | ||
| 
						 | 
					8c660e9856 | ||
| 
						 | 
					6ca641ab7f | ||
| 
						 | 
					8d6609566f | ||
| 
						 | 
					17e4c63737 | ||
| 
						 | 
					fad470b670 | ||
| 
						 | 
					c5646b1e59 | ||
| 
						 | 
					5da27bb266 | ||
| 
						 | 
					be6a077c8e | ||
| 
						 | 
					8d643fc6b4 | ||
| 
						 | 
					47876e3971 | ||
| 
						 | 
					c79c251ba7 | ||
| 
						 | 
					483670e798 | ||
| 
						 | 
					6c8a6008d5 | ||
| 
						 | 
					e680124d7b | ||
| 
						 | 
					b06a06f50c | ||
| 
						 | 
					6416de237f | ||
| ad44fd52a4 | |||
| 03c27b10e5 | |||
| fc0ef29738 | |||
| a0eb53a607 | |||
| 66e5ef64fd | |||
| 379527cd58 | |||
| f63fb59cbf | |||
| cde864fdc7 | |||
| e9361697f7 | 
							
								
								
									
										14
									
								
								.envrc
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								.envrc
									
									
									
									
									
								
							@@ -1,14 +1,6 @@
 | 
			
		||||
if [[ ! -f pyproject.toml ]]; then
 | 
			
		||||
  log_error 'No pyproject.toml found. Use `poetry new` or `poetry init` to create one first.'
 | 
			
		||||
if [[ ! -d .venv ]]; then
 | 
			
		||||
  log_error 'No .venv folder found. Use `uv sync` to create one first.'
 | 
			
		||||
  exit 2
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
local VENV=$(poetry env list --full-path | cut -d' ' -f1)
 | 
			
		||||
if [[ -z $VENV || ! -d $VENV/bin ]]; then
 | 
			
		||||
  log_error 'No poetry virtual environment found. Use `poetry install` to create one first.'
 | 
			
		||||
  exit 2
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
export VIRTUAL_ENV=$VENV
 | 
			
		||||
export POETRY_ACTIVE=1
 | 
			
		||||
PATH_add "$VENV/bin"
 | 
			
		||||
. .venv/bin/activate
 | 
			
		||||
							
								
								
									
										8
									
								
								.github/actions/compile_messages/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/actions/compile_messages/action.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,8 +0,0 @@
 | 
			
		||||
name: "Compile messages"
 | 
			
		||||
description: "Compile the gettext translation messages"
 | 
			
		||||
runs:
 | 
			
		||||
  using: composite
 | 
			
		||||
  steps:
 | 
			
		||||
      - name: Setup project
 | 
			
		||||
        run: poetry run ./manage.py compilemessages
 | 
			
		||||
        shell: bash
 | 
			
		||||
							
								
								
									
										51
									
								
								.github/actions/setup_project/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										51
									
								
								.github/actions/setup_project/action.yml
									
									
									
									
										vendored
									
									
								
							@@ -9,43 +9,38 @@ runs:
 | 
			
		||||
        packages: gettext
 | 
			
		||||
        version: 1.0  # increment to reset cache
 | 
			
		||||
 | 
			
		||||
    - name: Set up python
 | 
			
		||||
    - name: Install uv
 | 
			
		||||
      uses: astral-sh/setup-uv@v5
 | 
			
		||||
      with:
 | 
			
		||||
        version: "0.5.14"
 | 
			
		||||
        enable-cache: true
 | 
			
		||||
        cache-dependency-glob: "uv.lock"
 | 
			
		||||
 | 
			
		||||
    - name: "Set up Python"
 | 
			
		||||
      uses: actions/setup-python@v5
 | 
			
		||||
      with:
 | 
			
		||||
        python-version: "3.12"
 | 
			
		||||
        python-version-file: ".python-version"
 | 
			
		||||
 | 
			
		||||
    - name: Load cached Poetry installation
 | 
			
		||||
      id: cached-poetry
 | 
			
		||||
      uses: actions/cache@v3
 | 
			
		||||
    - name: Restore cached virtualenv
 | 
			
		||||
      uses: actions/cache/restore@v4
 | 
			
		||||
      with:
 | 
			
		||||
        path: ~/.local
 | 
			
		||||
        key: poetry-3  # increment to reset cache
 | 
			
		||||
 | 
			
		||||
    - name: Install Poetry
 | 
			
		||||
      if: steps.cached-poetry.outputs.cache-hit != 'true'
 | 
			
		||||
      shell: bash
 | 
			
		||||
      run: curl -sSL https://install.python-poetry.org | python3 -
 | 
			
		||||
 | 
			
		||||
    - name: Check pyproject.toml syntax
 | 
			
		||||
      shell: bash
 | 
			
		||||
      run: poetry check
 | 
			
		||||
 | 
			
		||||
    - name: Load cached dependencies
 | 
			
		||||
      uses: actions/cache@v3
 | 
			
		||||
      with:
 | 
			
		||||
        path: ~/.cache/pypoetry
 | 
			
		||||
        key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
 | 
			
		||||
        restore-keys: |
 | 
			
		||||
          ${{ runner.os }}-poetry-
 | 
			
		||||
        key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
 | 
			
		||||
        path: .venv
 | 
			
		||||
 | 
			
		||||
    - name: Install dependencies
 | 
			
		||||
      run: poetry install --with docs,tests
 | 
			
		||||
      run: uv sync
 | 
			
		||||
      shell: bash
 | 
			
		||||
 | 
			
		||||
    - name: Install xapian
 | 
			
		||||
      run: poetry run ./manage.py install_xapian
 | 
			
		||||
    - name: Install Xapian
 | 
			
		||||
      run: uv run ./manage.py install_xapian
 | 
			
		||||
      shell: bash
 | 
			
		||||
 | 
			
		||||
    - name: Save cached virtualenv
 | 
			
		||||
      uses: actions/cache/save@v4
 | 
			
		||||
      with:
 | 
			
		||||
        key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
 | 
			
		||||
        path: .venv
 | 
			
		||||
 | 
			
		||||
    - name: Compile gettext messages
 | 
			
		||||
      run: poetry run ./manage.py compilemessages
 | 
			
		||||
      run: uv run ./manage.py compilemessages
 | 
			
		||||
      shell: bash
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10
									
								
								.github/actions/setup_xapian/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/actions/setup_xapian/action.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,10 +0,0 @@
 | 
			
		||||
name: "Setup xapian"
 | 
			
		||||
description: "Setup the xapian indexes"
 | 
			
		||||
runs:
 | 
			
		||||
  using: composite
 | 
			
		||||
  steps:
 | 
			
		||||
    - name: Setup xapian index
 | 
			
		||||
      run: |
 | 
			
		||||
        mkdir -p /dev/shm/search_indexes
 | 
			
		||||
        ln -s /dev/shm/search_indexes sith/search_indexes
 | 
			
		||||
      shell: bash
 | 
			
		||||
							
								
								
									
										13
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										13
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							@@ -14,6 +14,8 @@ jobs:
 | 
			
		||||
    steps:
 | 
			
		||||
    - uses: actions/checkout@v4
 | 
			
		||||
    - uses: actions/setup-python@v5
 | 
			
		||||
      with:
 | 
			
		||||
        python-version-file: ".python-version"
 | 
			
		||||
    - uses: pre-commit/action@v3.0.1
 | 
			
		||||
      with:
 | 
			
		||||
        extra_args: --all-files
 | 
			
		||||
@@ -29,14 +31,15 @@ jobs:
 | 
			
		||||
      - name: Check out repository
 | 
			
		||||
        uses: actions/checkout@v4
 | 
			
		||||
      - uses: ./.github/actions/setup_project
 | 
			
		||||
      - uses: ./.github/actions/setup_xapian
 | 
			
		||||
      - uses: ./.github/actions/compile_messages
 | 
			
		||||
        env:
 | 
			
		||||
          # To avoid race conditions on environment cache
 | 
			
		||||
          CACHE_SUFFIX: ${{ matrix.pytest-mark }}
 | 
			
		||||
      - name: Run tests
 | 
			
		||||
        run: poetry run coverage run -m pytest -m "${{ matrix.pytest-mark }}"
 | 
			
		||||
        run: uv run coverage run -m pytest -m "${{ matrix.pytest-mark }}"
 | 
			
		||||
      - name: Generate coverage report
 | 
			
		||||
        run: |
 | 
			
		||||
          poetry run coverage report
 | 
			
		||||
          poetry run coverage html
 | 
			
		||||
          uv run coverage report
 | 
			
		||||
          uv run coverage html
 | 
			
		||||
      - name: Archive code coverage results
 | 
			
		||||
        uses: actions/upload-artifact@v3
 | 
			
		||||
        with:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10
									
								
								.github/workflows/deploy.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/deploy.yml
									
									
									
									
										vendored
									
									
								
							@@ -37,12 +37,12 @@ jobs:
 | 
			
		||||
 | 
			
		||||
          git fetch
 | 
			
		||||
          git reset --hard origin/master
 | 
			
		||||
          poetry install --with prod --without docs,tests
 | 
			
		||||
          uv sync --group prod
 | 
			
		||||
          npm install
 | 
			
		||||
          poetry run ./manage.py install_xapian
 | 
			
		||||
          poetry run ./manage.py migrate
 | 
			
		||||
          poetry run ./manage.py collectstatic --clear --noinput
 | 
			
		||||
          poetry run ./manage.py compilemessages
 | 
			
		||||
          uv run ./manage.py install_xapian
 | 
			
		||||
          uv run ./manage.py migrate
 | 
			
		||||
          uv run ./manage.py collectstatic --clear --noinput
 | 
			
		||||
          uv run ./manage.py compilemessages
 | 
			
		||||
 | 
			
		||||
          sudo systemctl restart uwsgi
 | 
			
		||||
  
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/deploy_docs.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/deploy_docs.yml
									
									
									
									
										vendored
									
									
								
							@@ -18,4 +18,4 @@ jobs:
 | 
			
		||||
          path: .cache
 | 
			
		||||
          restore-keys: |
 | 
			
		||||
            mkdocs-material-
 | 
			
		||||
      - run: poetry run mkdocs gh-deploy --force
 | 
			
		||||
      - run: uv run mkdocs gh-deploy --force
 | 
			
		||||
							
								
								
									
										10
									
								
								.github/workflows/taiste.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/taiste.yml
									
									
									
									
										vendored
									
									
								
							@@ -36,11 +36,11 @@ jobs:
 | 
			
		||||
 | 
			
		||||
          git fetch
 | 
			
		||||
          git reset --hard origin/taiste
 | 
			
		||||
          poetry install --with prod --without docs,tests
 | 
			
		||||
          uv sync --group prod
 | 
			
		||||
          npm install
 | 
			
		||||
          poetry run ./manage.py install_xapian
 | 
			
		||||
          poetry run ./manage.py migrate
 | 
			
		||||
          poetry run ./manage.py collectstatic --clear --noinput
 | 
			
		||||
          poetry run ./manage.py compilemessages
 | 
			
		||||
          uv run ./manage.py install_xapian
 | 
			
		||||
          uv run ./manage.py migrate
 | 
			
		||||
          uv run ./manage.py collectstatic --clear --noinput
 | 
			
		||||
          uv run ./manage.py compilemessages
 | 
			
		||||
 | 
			
		||||
          sudo systemctl restart uwsgi
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -8,7 +8,7 @@ pyrightconfig.json
 | 
			
		||||
dist/
 | 
			
		||||
.vscode/
 | 
			
		||||
.idea/
 | 
			
		||||
env/
 | 
			
		||||
.venv/
 | 
			
		||||
doc/html
 | 
			
		||||
data/
 | 
			
		||||
galaxy/test_galaxy_state.json
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								.python-version
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.python-version
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
3.12
 | 
			
		||||
@@ -216,7 +216,7 @@ class TestOperation(TestCase):
 | 
			
		||||
            self.journal.operations.filter(target_label="Le fantome du jour").exists()
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test__operation_simple_accounting(self):
 | 
			
		||||
    def test_operation_simple_accounting(self):
 | 
			
		||||
        sat = SimplifiedAccountingType.objects.all().first()
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("accounting:op_new", args=[self.journal.id]),
 | 
			
		||||
@@ -237,15 +237,14 @@ class TestOperation(TestCase):
 | 
			
		||||
                "done": False,
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.assertFalse(response.status_code == 403)
 | 
			
		||||
        self.assertTrue(self.journal.operations.filter(amount=23).exists())
 | 
			
		||||
        assert response.status_code != 403
 | 
			
		||||
        assert self.journal.operations.filter(amount=23).exists()
 | 
			
		||||
        response_get = self.client.get(
 | 
			
		||||
            reverse("accounting:journal_details", args=[self.journal.id])
 | 
			
		||||
        )
 | 
			
		||||
        self.assertTrue(
 | 
			
		||||
            "<td>Le fantome de l'aurore</td>" in str(response_get.content)
 | 
			
		||||
        )
 | 
			
		||||
        self.assertTrue(
 | 
			
		||||
        assert "<td>Le fantome de l'aurore</td>" in str(response_get.content)
 | 
			
		||||
 | 
			
		||||
        assert (
 | 
			
		||||
            self.journal.operations.filter(amount=23)
 | 
			
		||||
            .values("accounting_type")
 | 
			
		||||
            .first()["accounting_type"]
 | 
			
		||||
 
 | 
			
		||||
@@ -215,17 +215,14 @@ class JournalTabsMixin(TabedViewMixin):
 | 
			
		||||
        return _("Journal")
 | 
			
		||||
 | 
			
		||||
    def get_list_of_tabs(self):
 | 
			
		||||
        tab_list = []
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
        return [
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse(
 | 
			
		||||
                    "accounting:journal_details", kwargs={"j_id": self.object.id}
 | 
			
		||||
                ),
 | 
			
		||||
                "slug": "journal",
 | 
			
		||||
                "name": _("Journal"),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse(
 | 
			
		||||
                    "accounting:journal_nature_statement",
 | 
			
		||||
@@ -233,9 +230,7 @@ class JournalTabsMixin(TabedViewMixin):
 | 
			
		||||
                ),
 | 
			
		||||
                "slug": "nature_statement",
 | 
			
		||||
                "name": _("Statement by nature"),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse(
 | 
			
		||||
                    "accounting:journal_person_statement",
 | 
			
		||||
@@ -243,9 +238,7 @@ class JournalTabsMixin(TabedViewMixin):
 | 
			
		||||
                ),
 | 
			
		||||
                "slug": "person_statement",
 | 
			
		||||
                "name": _("Statement by person"),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse(
 | 
			
		||||
                    "accounting:journal_accounting_statement",
 | 
			
		||||
@@ -253,9 +246,8 @@ class JournalTabsMixin(TabedViewMixin):
 | 
			
		||||
                ),
 | 
			
		||||
                "slug": "accounting_statement",
 | 
			
		||||
                "name": _("Accounting statement"),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        return tab_list
 | 
			
		||||
            },
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class JournalCreateView(CanCreateMixin, CreateView):
 | 
			
		||||
 
 | 
			
		||||
@@ -20,6 +20,14 @@ from club.models import Club, Membership
 | 
			
		||||
@admin.register(Club)
 | 
			
		||||
class ClubAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ("name", "unix_name", "parent", "is_active")
 | 
			
		||||
    search_fields = ("name", "unix_name")
 | 
			
		||||
    autocomplete_fields = (
 | 
			
		||||
        "parent",
 | 
			
		||||
        "board_group",
 | 
			
		||||
        "members_group",
 | 
			
		||||
        "home",
 | 
			
		||||
        "page",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Membership)
 | 
			
		||||
 
 | 
			
		||||
@@ -3,19 +3,6 @@ from __future__ import unicode_literals
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
from club.models import Club
 | 
			
		||||
from core.operations import PsqlRunOnly
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def generate_club_pages(apps, schema_editor):
 | 
			
		||||
    def recursive_generate_club_page(club):
 | 
			
		||||
        club.make_page()
 | 
			
		||||
        for child in Club.objects.filter(parent=club).all():
 | 
			
		||||
            recursive_generate_club_page(child)
 | 
			
		||||
 | 
			
		||||
    for club in Club.objects.filter(parent=None).all():
 | 
			
		||||
        recursive_generate_club_page(club)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [("core", "0024_auto_20170906_1317"), ("club", "0010_club_logo")]
 | 
			
		||||
@@ -48,11 +35,4 @@ class Migration(migrations.Migration):
 | 
			
		||||
                null=True,
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        PsqlRunOnly(
 | 
			
		||||
            "SET CONSTRAINTS ALL IMMEDIATE", reverse_sql=migrations.RunSQL.noop
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(generate_club_pages),
 | 
			
		||||
        PsqlRunOnly(
 | 
			
		||||
            migrations.RunSQL.noop, reverse_sql="SET CONSTRAINTS ALL IMMEDIATE"
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										106
									
								
								club/migrations/0012_club_board_group_club_members_group.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								club/migrations/0012_club_board_group_club_members_group.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,106 @@
 | 
			
		||||
# Generated by Django 4.2.16 on 2024-11-20 17:08
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
import django.db.models.functions.datetime
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
from django.db.migrations.state import StateApps
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.utils.timezone import localdate
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def migrate_meta_groups(apps: StateApps, schema_editor):
 | 
			
		||||
    """Attach the existing meta groups to the clubs.
 | 
			
		||||
 | 
			
		||||
    Until now, the meta groups were not attached to the clubs,
 | 
			
		||||
    nor to the users.
 | 
			
		||||
    This creates actual foreign relationships between the clubs
 | 
			
		||||
    and theirs groups and the users and theirs groups.
 | 
			
		||||
 | 
			
		||||
    Warnings:
 | 
			
		||||
        When the meta groups associated with the clubs aren't found,
 | 
			
		||||
        they are created.
 | 
			
		||||
        Thus the migration shouldn't fail, and all the clubs will
 | 
			
		||||
        have their groups.
 | 
			
		||||
        However, there will probably be some groups that have
 | 
			
		||||
        not been found but exist nonetheless,
 | 
			
		||||
        so there will be duplicates and dangling groups.
 | 
			
		||||
        There must be a manual cleanup after this migration.
 | 
			
		||||
    """
 | 
			
		||||
    Group = apps.get_model("core", "Group")
 | 
			
		||||
    Club = apps.get_model("club", "Club")
 | 
			
		||||
 | 
			
		||||
    meta_groups = Group.objects.filter(is_meta=True)
 | 
			
		||||
    clubs = list(Club.objects.all())
 | 
			
		||||
    for club in clubs:
 | 
			
		||||
        club.board_group = meta_groups.get_or_create(
 | 
			
		||||
            name=club.unix_name + settings.SITH_BOARD_SUFFIX,
 | 
			
		||||
            defaults={"is_meta": True},
 | 
			
		||||
        )[0]
 | 
			
		||||
        club.members_group = meta_groups.get_or_create(
 | 
			
		||||
            name=club.unix_name + settings.SITH_MEMBER_SUFFIX,
 | 
			
		||||
            defaults={"is_meta": True},
 | 
			
		||||
        )[0]
 | 
			
		||||
        club.save()
 | 
			
		||||
        club.refresh_from_db()
 | 
			
		||||
        memberships = club.members.filter(
 | 
			
		||||
            Q(end_date=None) | Q(end_date__gt=localdate())
 | 
			
		||||
        ).select_related("user")
 | 
			
		||||
        club.members_group.users.set([m.user for m in memberships])
 | 
			
		||||
        club.board_group.users.set(
 | 
			
		||||
            [
 | 
			
		||||
                m.user
 | 
			
		||||
                for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# steps of the migration :
 | 
			
		||||
# - Create a nullable field for the board group and the member group
 | 
			
		||||
# - Edit those new fields to make them point to currently existing meta groups
 | 
			
		||||
# - When this data migration is done, make the fields non-nullable
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("core", "0040_alter_user_options_user_user_permissions_and_more"),
 | 
			
		||||
        ("club", "0011_auto_20180426_2013"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RemoveField(
 | 
			
		||||
            model_name="club",
 | 
			
		||||
            name="edit_groups",
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RemoveField(
 | 
			
		||||
            model_name="club",
 | 
			
		||||
            name="owner_group",
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RemoveField(
 | 
			
		||||
            model_name="club",
 | 
			
		||||
            name="view_groups",
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="club",
 | 
			
		||||
            name="board_group",
 | 
			
		||||
            field=models.OneToOneField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.PROTECT,
 | 
			
		||||
                related_name="club_board",
 | 
			
		||||
                to="core.group",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="club",
 | 
			
		||||
            name="members_group",
 | 
			
		||||
            field=models.OneToOneField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.PROTECT,
 | 
			
		||||
                related_name="club",
 | 
			
		||||
                to="core.group",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(
 | 
			
		||||
            migrate_meta_groups, reverse_code=migrations.RunPython.noop, elidable=True
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,36 @@
 | 
			
		||||
# Generated by Django 4.2.17 on 2025-01-04 16:46
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [("club", "0012_club_board_group_club_members_group")]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="club",
 | 
			
		||||
            name="board_group",
 | 
			
		||||
            field=models.OneToOneField(
 | 
			
		||||
                on_delete=django.db.models.deletion.PROTECT,
 | 
			
		||||
                related_name="club_board",
 | 
			
		||||
                to="core.group",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="club",
 | 
			
		||||
            name="members_group",
 | 
			
		||||
            field=models.OneToOneField(
 | 
			
		||||
                on_delete=django.db.models.deletion.PROTECT,
 | 
			
		||||
                related_name="club",
 | 
			
		||||
                to="core.group",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddConstraint(
 | 
			
		||||
            model_name="membership",
 | 
			
		||||
            constraint=models.CheckConstraint(
 | 
			
		||||
                check=models.Q(("end_date__gte", models.F("start_date"))),
 | 
			
		||||
                name="end_after_start",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										328
									
								
								club/models.py
									
									
									
									
									
								
							
							
						
						
									
										328
									
								
								club/models.py
									
									
									
									
									
								
							@@ -23,7 +23,7 @@
 | 
			
		||||
#
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from typing import Self
 | 
			
		||||
from typing import Iterable, Self
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.core import validators
 | 
			
		||||
@@ -31,14 +31,14 @@ from django.core.cache import cache
 | 
			
		||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
 | 
			
		||||
from django.core.validators import RegexValidator, validate_email
 | 
			
		||||
from django.db import models, transaction
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.db.models import Exists, F, OuterRef, Q
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.functional import cached_property
 | 
			
		||||
from django.utils.timezone import localdate
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
from core.models import Group, MetaGroup, Notification, Page, RealGroup, SithFile, User
 | 
			
		||||
from core.models import Group, Notification, Page, SithFile, User
 | 
			
		||||
 | 
			
		||||
# Create your models here.
 | 
			
		||||
 | 
			
		||||
@@ -79,19 +79,6 @@ class Club(models.Model):
 | 
			
		||||
        _("short description"), max_length=1000, default="", blank=True, null=True
 | 
			
		||||
    )
 | 
			
		||||
    address = models.CharField(_("address"), max_length=254)
 | 
			
		||||
 | 
			
		||||
    owner_group = models.ForeignKey(
 | 
			
		||||
        Group,
 | 
			
		||||
        related_name="owned_club",
 | 
			
		||||
        default=get_default_owner_group,
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
    edit_groups = models.ManyToManyField(
 | 
			
		||||
        Group, related_name="editable_club", blank=True
 | 
			
		||||
    )
 | 
			
		||||
    view_groups = models.ManyToManyField(
 | 
			
		||||
        Group, related_name="viewable_club", blank=True
 | 
			
		||||
    )
 | 
			
		||||
    home = models.OneToOneField(
 | 
			
		||||
        SithFile,
 | 
			
		||||
        related_name="home_of_club",
 | 
			
		||||
@@ -103,6 +90,12 @@ class Club(models.Model):
 | 
			
		||||
    page = models.OneToOneField(
 | 
			
		||||
        Page, related_name="club", blank=True, null=True, on_delete=models.CASCADE
 | 
			
		||||
    )
 | 
			
		||||
    members_group = models.OneToOneField(
 | 
			
		||||
        Group, related_name="club", on_delete=models.PROTECT
 | 
			
		||||
    )
 | 
			
		||||
    board_group = models.OneToOneField(
 | 
			
		||||
        Group, related_name="club_board", on_delete=models.PROTECT
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        ordering = ["name", "unix_name"]
 | 
			
		||||
@@ -112,23 +105,27 @@ class Club(models.Model):
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic()
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        old = Club.objects.filter(id=self.id).first()
 | 
			
		||||
        creation = old is None
 | 
			
		||||
        if not creation and old.unix_name != self.unix_name:
 | 
			
		||||
            self._change_unixname(self.unix_name)
 | 
			
		||||
        creation = self._state.adding
 | 
			
		||||
        if not creation:
 | 
			
		||||
            db_club = Club.objects.get(id=self.id)
 | 
			
		||||
            if self.unix_name != db_club.unix_name:
 | 
			
		||||
                self.home.name = self.unix_name
 | 
			
		||||
                self.home.save()
 | 
			
		||||
            if self.name != db_club.name:
 | 
			
		||||
                self.board_group.name = f"{self.name} - Bureau"
 | 
			
		||||
                self.board_group.save()
 | 
			
		||||
                self.members_group.name = f"{self.name} - Membres"
 | 
			
		||||
                self.members_group.save()
 | 
			
		||||
        if creation:
 | 
			
		||||
            self.board_group = Group.objects.create(
 | 
			
		||||
                name=f"{self.name} - Bureau", is_manually_manageable=False
 | 
			
		||||
            )
 | 
			
		||||
            self.members_group = Group.objects.create(
 | 
			
		||||
                name=f"{self.name} - Membres", is_manually_manageable=False
 | 
			
		||||
            )
 | 
			
		||||
        super().save(*args, **kwargs)
 | 
			
		||||
        if creation:
 | 
			
		||||
            board = MetaGroup(name=self.unix_name + settings.SITH_BOARD_SUFFIX)
 | 
			
		||||
            board.save()
 | 
			
		||||
            member = MetaGroup(name=self.unix_name + settings.SITH_MEMBER_SUFFIX)
 | 
			
		||||
            member.save()
 | 
			
		||||
            subscribers = Group.objects.filter(
 | 
			
		||||
                name=settings.SITH_MAIN_MEMBERS_GROUP
 | 
			
		||||
            ).first()
 | 
			
		||||
            self.make_home()
 | 
			
		||||
            self.home.edit_groups.set([board])
 | 
			
		||||
            self.home.view_groups.set([member, subscribers])
 | 
			
		||||
            self.home.save()
 | 
			
		||||
        self.make_page()
 | 
			
		||||
        cache.set(f"sith_club_{self.unix_name}", self)
 | 
			
		||||
 | 
			
		||||
@@ -136,7 +133,8 @@ class Club(models.Model):
 | 
			
		||||
        return reverse("club:club_view", kwargs={"club_id": self.id})
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def president(self):
 | 
			
		||||
    def president(self) -> Membership | None:
 | 
			
		||||
        """Fetch the membership of the current president of this club."""
 | 
			
		||||
        return self.members.filter(
 | 
			
		||||
            role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
 | 
			
		||||
        ).first()
 | 
			
		||||
@@ -154,36 +152,18 @@ class Club(models.Model):
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        self.check_loop()
 | 
			
		||||
 | 
			
		||||
    def _change_unixname(self, old_name, new_name):
 | 
			
		||||
        c = Club.objects.filter(unix_name=new_name).first()
 | 
			
		||||
        if c is None:
 | 
			
		||||
            # Update all the groups names
 | 
			
		||||
            Group.objects.filter(name=old_name).update(name=new_name)
 | 
			
		||||
            Group.objects.filter(name=old_name + settings.SITH_BOARD_SUFFIX).update(
 | 
			
		||||
                name=new_name + settings.SITH_BOARD_SUFFIX
 | 
			
		||||
            )
 | 
			
		||||
            Group.objects.filter(name=old_name + settings.SITH_MEMBER_SUFFIX).update(
 | 
			
		||||
                name=new_name + settings.SITH_MEMBER_SUFFIX
 | 
			
		||||
            )
 | 
			
		||||
    def make_home(self) -> None:
 | 
			
		||||
        if self.home:
 | 
			
		||||
            return
 | 
			
		||||
        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()
 | 
			
		||||
 | 
			
		||||
            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):
 | 
			
		||||
    def make_page(self) -> None:
 | 
			
		||||
        root = User.objects.filter(username="root").first()
 | 
			
		||||
        if not self.page:
 | 
			
		||||
            club_root = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
 | 
			
		||||
@@ -213,35 +193,34 @@ class Club(models.Model):
 | 
			
		||||
            self.page.parent = self.parent.page
 | 
			
		||||
            self.page.save(force_lock=True)
 | 
			
		||||
 | 
			
		||||
    def delete(self, *args, **kwargs):
 | 
			
		||||
    def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
 | 
			
		||||
        # Invalidate the cache of this club and of its memberships
 | 
			
		||||
        for membership in self.members.ongoing().select_related("user"):
 | 
			
		||||
            cache.delete(f"membership_{self.id}_{membership.user.id}")
 | 
			
		||||
        cache.delete(f"sith_club_{self.unix_name}")
 | 
			
		||||
        super().delete(*args, **kwargs)
 | 
			
		||||
        self.board_group.delete()
 | 
			
		||||
        self.members_group.delete()
 | 
			
		||||
        return super().delete(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def get_display_name(self):
 | 
			
		||||
    def get_display_name(self) -> str:
 | 
			
		||||
        return self.name
 | 
			
		||||
 | 
			
		||||
    def is_owned_by(self, user):
 | 
			
		||||
    def is_owned_by(self, user: User) -> bool:
 | 
			
		||||
        """Method to see if that object can be super edited by the given user."""
 | 
			
		||||
        if user.is_anonymous:
 | 
			
		||||
            return False
 | 
			
		||||
        return user.is_board_member
 | 
			
		||||
        return user.is_root or user.is_board_member
 | 
			
		||||
 | 
			
		||||
    def get_full_logo_url(self):
 | 
			
		||||
        return "https://%s%s" % (settings.SITH_URL, self.logo.url)
 | 
			
		||||
    def get_full_logo_url(self) -> str:
 | 
			
		||||
        return f"https://{settings.SITH_URL}{self.logo.url}"
 | 
			
		||||
 | 
			
		||||
    def can_be_edited_by(self, user):
 | 
			
		||||
    def can_be_edited_by(self, user: User) -> bool:
 | 
			
		||||
        """Method to see if that object can be edited by the given user."""
 | 
			
		||||
        return self.has_rights_in_club(user)
 | 
			
		||||
 | 
			
		||||
    def can_be_viewed_by(self, user):
 | 
			
		||||
    def can_be_viewed_by(self, user: User) -> bool:
 | 
			
		||||
        """Method to see if that object can be seen by the given user."""
 | 
			
		||||
        sub = User.objects.filter(pk=user.pk).first()
 | 
			
		||||
        if sub is None:
 | 
			
		||||
            return False
 | 
			
		||||
        return sub.was_subscribed
 | 
			
		||||
        return user.was_subscribed
 | 
			
		||||
 | 
			
		||||
    def get_membership_for(self, user: User) -> Membership | None:
 | 
			
		||||
        """Return the current membership the given user.
 | 
			
		||||
@@ -262,9 +241,8 @@ class Club(models.Model):
 | 
			
		||||
                cache.set(f"membership_{self.id}_{user.id}", membership)
 | 
			
		||||
        return membership
 | 
			
		||||
 | 
			
		||||
    def has_rights_in_club(self, user):
 | 
			
		||||
        m = self.get_membership_for(user)
 | 
			
		||||
        return m is not None and m.role > settings.SITH_MAXIMUM_FREE_ROLE
 | 
			
		||||
    def has_rights_in_club(self, user: User) -> bool:
 | 
			
		||||
        return user.is_in_group(pk=self.board_group_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MembershipQuerySet(models.QuerySet):
 | 
			
		||||
@@ -283,42 +261,65 @@ class MembershipQuerySet(models.QuerySet):
 | 
			
		||||
        """
 | 
			
		||||
        return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
 | 
			
		||||
 | 
			
		||||
    def update(self, **kwargs):
 | 
			
		||||
        """Refresh the cache for the elements of the queryset.
 | 
			
		||||
    def update(self, **kwargs) -> int:
 | 
			
		||||
        """Refresh the cache and edit group ownership.
 | 
			
		||||
 | 
			
		||||
        Besides that, does the same job as a regular update method.
 | 
			
		||||
        Update the cache, when necessary, remove
 | 
			
		||||
        users from club groups they are no more in
 | 
			
		||||
        and add them in the club groups they should be in.
 | 
			
		||||
 | 
			
		||||
        Be aware that this adds a db query to retrieve the updated objects
 | 
			
		||||
        Be aware that this adds three db queries :
 | 
			
		||||
        one to retrieve the updated memberships,
 | 
			
		||||
        one to perform group removal and one to perform
 | 
			
		||||
        group attribution.
 | 
			
		||||
        """
 | 
			
		||||
        nb_rows = super().update(**kwargs)
 | 
			
		||||
        if nb_rows > 0:
 | 
			
		||||
            # if at least a row was affected, refresh the cache
 | 
			
		||||
            for membership in self.all():
 | 
			
		||||
                if membership.end_date is not None:
 | 
			
		||||
                    cache.set(
 | 
			
		||||
                        f"membership_{membership.club_id}_{membership.user_id}",
 | 
			
		||||
                        "not_member",
 | 
			
		||||
                    )
 | 
			
		||||
                else:
 | 
			
		||||
                    cache.set(
 | 
			
		||||
                        f"membership_{membership.club_id}_{membership.user_id}",
 | 
			
		||||
                        membership,
 | 
			
		||||
                    )
 | 
			
		||||
        if nb_rows == 0:
 | 
			
		||||
            # if no row was affected, no need to refresh the cache
 | 
			
		||||
            return 0
 | 
			
		||||
 | 
			
		||||
    def delete(self):
 | 
			
		||||
        cache_memberships = {}
 | 
			
		||||
        memberships = set(self.select_related("club"))
 | 
			
		||||
        # delete all User-Group relations and recreate the necessary ones
 | 
			
		||||
        # It's more concise to write and more reliable
 | 
			
		||||
        Membership._remove_club_groups(memberships)
 | 
			
		||||
        Membership._add_club_groups(memberships)
 | 
			
		||||
        for member in memberships:
 | 
			
		||||
            cache_key = f"membership_{member.club_id}_{member.user_id}"
 | 
			
		||||
            if member.end_date is None:
 | 
			
		||||
                cache_memberships[cache_key] = member
 | 
			
		||||
            else:
 | 
			
		||||
                cache_memberships[cache_key] = "not_member"
 | 
			
		||||
        cache.set_many(cache_memberships)
 | 
			
		||||
        return nb_rows
 | 
			
		||||
 | 
			
		||||
    def delete(self) -> tuple[int, dict[str, int]]:
 | 
			
		||||
        """Work just like the default Django's delete() method,
 | 
			
		||||
        but add a cache invalidation for the elements of the queryset
 | 
			
		||||
        before the deletion.
 | 
			
		||||
        before the deletion,
 | 
			
		||||
        and a removal of the user from the club groups.
 | 
			
		||||
 | 
			
		||||
        Be aware that this adds a db query to retrieve the deleted element.
 | 
			
		||||
        As this first query take place before the deletion operation,
 | 
			
		||||
        it will be performed even if the deletion fails.
 | 
			
		||||
        Be aware that this adds some db queries :
 | 
			
		||||
 | 
			
		||||
        - 1 to retrieve the deleted elements in order to perform
 | 
			
		||||
          post-delete operations.
 | 
			
		||||
          As we can't know if a delete will affect rows or not,
 | 
			
		||||
          this query will always happen
 | 
			
		||||
        - 1 query to remove the users from the club groups.
 | 
			
		||||
          If the delete operation affected no row,
 | 
			
		||||
          this query won't happen.
 | 
			
		||||
        """
 | 
			
		||||
        ids = list(self.values_list("club_id", "user_id"))
 | 
			
		||||
        nb_rows, _ = super().delete()
 | 
			
		||||
        memberships = set(self.all())
 | 
			
		||||
        nb_rows, rows_counts = super().delete()
 | 
			
		||||
        if nb_rows > 0:
 | 
			
		||||
            for club_id, user_id in ids:
 | 
			
		||||
                cache.set(f"membership_{club_id}_{user_id}", "not_member")
 | 
			
		||||
            Membership._remove_club_groups(memberships)
 | 
			
		||||
            cache.set_many(
 | 
			
		||||
                {
 | 
			
		||||
                    f"membership_{m.club_id}_{m.user_id}": "not_member"
 | 
			
		||||
                    for m in memberships
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
        return nb_rows, rows_counts
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Membership(models.Model):
 | 
			
		||||
@@ -361,6 +362,13 @@ class Membership(models.Model):
 | 
			
		||||
 | 
			
		||||
    objects = MembershipQuerySet.as_manager()
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        constraints = [
 | 
			
		||||
            models.CheckConstraint(
 | 
			
		||||
                check=Q(end_date__gte=F("start_date")), name="end_after_start"
 | 
			
		||||
            ),
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return (
 | 
			
		||||
            f"{self.club.name} - {self.user.username} "
 | 
			
		||||
@@ -370,7 +378,14 @@ class Membership(models.Model):
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        super().save(*args, **kwargs)
 | 
			
		||||
        # a save may either be an update or a creation
 | 
			
		||||
        # and may result in either an ongoing or an ended membership.
 | 
			
		||||
        # It could also be a retrogradation from the board to being a simple member.
 | 
			
		||||
        # To avoid problems, the user is removed from the club groups beforehand ;
 | 
			
		||||
        # he will be added back if necessary
 | 
			
		||||
        self._remove_club_groups([self])
 | 
			
		||||
        if self.end_date is None:
 | 
			
		||||
            self._add_club_groups([self])
 | 
			
		||||
            cache.set(f"membership_{self.club_id}_{self.user_id}", self)
 | 
			
		||||
        else:
 | 
			
		||||
            cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
 | 
			
		||||
@@ -378,11 +393,11 @@ class Membership(models.Model):
 | 
			
		||||
    def get_absolute_url(self):
 | 
			
		||||
        return reverse("club:club_members", kwargs={"club_id": self.club_id})
 | 
			
		||||
 | 
			
		||||
    def is_owned_by(self, user):
 | 
			
		||||
    def is_owned_by(self, user: User) -> bool:
 | 
			
		||||
        """Method to see if that object can be super edited by the given user."""
 | 
			
		||||
        if user.is_anonymous:
 | 
			
		||||
            return False
 | 
			
		||||
        return user.is_board_member
 | 
			
		||||
        return user.is_root or user.is_board_member
 | 
			
		||||
 | 
			
		||||
    def can_be_edited_by(self, user: User) -> bool:
 | 
			
		||||
        """Check if that object can be edited by the given user."""
 | 
			
		||||
@@ -392,9 +407,91 @@ class Membership(models.Model):
 | 
			
		||||
        return membership is not None and membership.role >= self.role
 | 
			
		||||
 | 
			
		||||
    def delete(self, *args, **kwargs):
 | 
			
		||||
        self._remove_club_groups([self])
 | 
			
		||||
        super().delete(*args, **kwargs)
 | 
			
		||||
        cache.delete(f"membership_{self.club_id}_{self.user_id}")
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _remove_club_groups(
 | 
			
		||||
        memberships: Iterable[Membership],
 | 
			
		||||
    ) -> tuple[int, dict[str, int]]:
 | 
			
		||||
        """Remove users of those memberships from the club groups.
 | 
			
		||||
 | 
			
		||||
        For example, if a user is in the Troll club board,
 | 
			
		||||
        he is in the board group and the members group of the Troll.
 | 
			
		||||
        After calling this function, he will be in neither.
 | 
			
		||||
 | 
			
		||||
        Returns:
 | 
			
		||||
            The result of the deletion queryset.
 | 
			
		||||
 | 
			
		||||
        Warnings:
 | 
			
		||||
            If this function isn't used in combination
 | 
			
		||||
            with an actual deletion of the memberships,
 | 
			
		||||
            it will result in an inconsistent state,
 | 
			
		||||
            where users will be in the clubs, without
 | 
			
		||||
            having the associated rights.
 | 
			
		||||
        """
 | 
			
		||||
        clubs = {m.club_id for m in memberships}
 | 
			
		||||
        users = {m.user_id for m in memberships}
 | 
			
		||||
        groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs))
 | 
			
		||||
        return User.groups.through.objects.filter(
 | 
			
		||||
            Q(group__in=groups) & Q(user__in=users)
 | 
			
		||||
        ).delete()
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _add_club_groups(
 | 
			
		||||
        memberships: Iterable[Membership],
 | 
			
		||||
    ) -> list[User.groups.through]:
 | 
			
		||||
        """Add users of those memberships to the club groups.
 | 
			
		||||
 | 
			
		||||
        For example, if a user just joined the Troll club board,
 | 
			
		||||
        he will be added in both the members group and the board group
 | 
			
		||||
        of the club.
 | 
			
		||||
 | 
			
		||||
        Returns:
 | 
			
		||||
            The created User-Group relations.
 | 
			
		||||
 | 
			
		||||
        Warnings:
 | 
			
		||||
            If this function isn't used in combination
 | 
			
		||||
            with an actual update/creation of the memberships,
 | 
			
		||||
            it will result in an inconsistent state,
 | 
			
		||||
            where users will have the rights associated to the
 | 
			
		||||
            club, without actually being part of it.
 | 
			
		||||
        """
 | 
			
		||||
        # only active membership (i.e. `end_date=None`)
 | 
			
		||||
        # grant the attribution of club groups.
 | 
			
		||||
        memberships = [m for m in memberships if m.end_date is None]
 | 
			
		||||
        if not memberships:
 | 
			
		||||
            return []
 | 
			
		||||
 | 
			
		||||
        if sum(1 for m in memberships if not hasattr(m, "club")) > 1:
 | 
			
		||||
            # if more than one membership hasn't its `club` attribute set
 | 
			
		||||
            # it's less expensive to reload the whole query with
 | 
			
		||||
            # a select_related than perform a distinct query
 | 
			
		||||
            # to fetch each club.
 | 
			
		||||
            ids = {m.id for m in memberships}
 | 
			
		||||
            memberships = list(
 | 
			
		||||
                Membership.objects.filter(id__in=ids).select_related("club")
 | 
			
		||||
            )
 | 
			
		||||
        club_groups = []
 | 
			
		||||
        for membership in memberships:
 | 
			
		||||
            club_groups.append(
 | 
			
		||||
                User.groups.through(
 | 
			
		||||
                    user_id=membership.user_id,
 | 
			
		||||
                    group_id=membership.club.members_group_id,
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
            if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
 | 
			
		||||
                club_groups.append(
 | 
			
		||||
                    User.groups.through(
 | 
			
		||||
                        user_id=membership.user_id,
 | 
			
		||||
                        group_id=membership.club.board_group_id,
 | 
			
		||||
                    )
 | 
			
		||||
                )
 | 
			
		||||
        return User.groups.through.objects.bulk_create(
 | 
			
		||||
            club_groups, ignore_conflicts=True
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Mailing(models.Model):
 | 
			
		||||
    """A Mailing list for a club.
 | 
			
		||||
@@ -438,19 +535,18 @@ class Mailing(models.Model):
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        if not self.is_moderated:
 | 
			
		||||
            for user in (
 | 
			
		||||
                RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
			
		||||
                .first()
 | 
			
		||||
                .users.all()
 | 
			
		||||
            unread_notif_subquery = Notification.objects.filter(
 | 
			
		||||
                user=OuterRef("pk"), type="MAILING_MODERATION", viewed=False
 | 
			
		||||
            )
 | 
			
		||||
            for user in User.objects.filter(
 | 
			
		||||
                ~Exists(unread_notif_subquery),
 | 
			
		||||
                groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
 | 
			
		||||
            ):
 | 
			
		||||
                if not user.notifications.filter(
 | 
			
		||||
                    type="MAILING_MODERATION", viewed=False
 | 
			
		||||
                ).exists():
 | 
			
		||||
                    Notification(
 | 
			
		||||
                        user=user,
 | 
			
		||||
                        url=reverse("com:mailing_admin"),
 | 
			
		||||
                        type="MAILING_MODERATION",
 | 
			
		||||
                    ).save(*args, **kwargs)
 | 
			
		||||
                Notification(
 | 
			
		||||
                    user=user,
 | 
			
		||||
                    url=reverse("com:mailing_admin"),
 | 
			
		||||
                    type="MAILING_MODERATION",
 | 
			
		||||
                ).save(*args, **kwargs)
 | 
			
		||||
        super().save(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def clean(self):
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										117
									
								
								club/tests.py
									
									
									
									
									
								
							
							
						
						
									
										117
									
								
								club/tests.py
									
									
									
									
									
								
							@@ -21,6 +21,7 @@ from django.urls import reverse
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.timezone import localdate, localtime, now
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from model_bakery import baker
 | 
			
		||||
 | 
			
		||||
from club.forms import MailingForm
 | 
			
		||||
from club.models import Club, Mailing, Membership
 | 
			
		||||
@@ -164,6 +165,27 @@ class TestMembershipQuerySet(TestClub):
 | 
			
		||||
        assert new_mem != "not_member"
 | 
			
		||||
        assert new_mem.role == 5
 | 
			
		||||
 | 
			
		||||
    def test_update_change_club_groups(self):
 | 
			
		||||
        """Test that `update` set the user groups accordingly."""
 | 
			
		||||
        user = baker.make(User)
 | 
			
		||||
        membership = baker.make(Membership, end_date=None, user=user, role=5)
 | 
			
		||||
        members_group = membership.club.members_group
 | 
			
		||||
        board_group = membership.club.board_group
 | 
			
		||||
        assert user.groups.contains(members_group)
 | 
			
		||||
        assert user.groups.contains(board_group)
 | 
			
		||||
 | 
			
		||||
        user.memberships.update(role=1)  # from board to simple member
 | 
			
		||||
        assert user.groups.contains(members_group)
 | 
			
		||||
        assert not user.groups.contains(board_group)
 | 
			
		||||
 | 
			
		||||
        user.memberships.update(role=5)  # from member to board
 | 
			
		||||
        assert user.groups.contains(members_group)
 | 
			
		||||
        assert user.groups.contains(board_group)
 | 
			
		||||
 | 
			
		||||
        user.memberships.update(end_date=localdate())  # end the membership
 | 
			
		||||
        assert not user.groups.contains(members_group)
 | 
			
		||||
        assert not user.groups.contains(board_group)
 | 
			
		||||
 | 
			
		||||
    def test_delete_invalidate_cache(self):
 | 
			
		||||
        """Test that the `delete` queryset properly invalidate cache."""
 | 
			
		||||
        mem_skia = self.skia.memberships.get(club=self.club)
 | 
			
		||||
@@ -182,6 +204,19 @@ class TestMembershipQuerySet(TestClub):
 | 
			
		||||
            )
 | 
			
		||||
            assert cached_mem == "not_member"
 | 
			
		||||
 | 
			
		||||
    def test_delete_remove_from_groups(self):
 | 
			
		||||
        """Test that `delete` removes from club groups"""
 | 
			
		||||
        user = baker.make(User)
 | 
			
		||||
        memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
 | 
			
		||||
        club_groups = {
 | 
			
		||||
            memberships[0].club.members_group,
 | 
			
		||||
            memberships[1].club.members_group,
 | 
			
		||||
            memberships[1].club.board_group,
 | 
			
		||||
        }
 | 
			
		||||
        assert set(user.groups.all()) == club_groups
 | 
			
		||||
        user.memberships.all().delete()
 | 
			
		||||
        assert user.groups.all().count() == 0
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestClubModel(TestClub):
 | 
			
		||||
    def assert_membership_started_today(self, user: User, role: int):
 | 
			
		||||
@@ -192,10 +227,8 @@ class TestClubModel(TestClub):
 | 
			
		||||
        assert membership.end_date is None
 | 
			
		||||
        assert membership.role == role
 | 
			
		||||
        assert membership.club.get_membership_for(user) == membership
 | 
			
		||||
        member_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
 | 
			
		||||
        board_group = self.club.unix_name + settings.SITH_BOARD_SUFFIX
 | 
			
		||||
        assert user.is_in_group(name=member_group)
 | 
			
		||||
        assert user.is_in_group(name=board_group)
 | 
			
		||||
        assert user.is_in_group(pk=self.club.members_group_id)
 | 
			
		||||
        assert user.is_in_group(pk=self.club.board_group_id)
 | 
			
		||||
 | 
			
		||||
    def assert_membership_ended_today(self, user: User):
 | 
			
		||||
        """Assert that the given user have a membership which ended today."""
 | 
			
		||||
@@ -474,37 +507,35 @@ class TestClubModel(TestClub):
 | 
			
		||||
        assert self.club.members.count() == nb_memberships
 | 
			
		||||
        assert membership == new_mem
 | 
			
		||||
 | 
			
		||||
    def test_delete_remove_from_meta_group(self):
 | 
			
		||||
        """Test that when a club is deleted, all its members are removed from the
 | 
			
		||||
        associated metagroup.
 | 
			
		||||
        """
 | 
			
		||||
        memberships = self.club.members.select_related("user")
 | 
			
		||||
        users = [membership.user for membership in memberships]
 | 
			
		||||
        meta_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
 | 
			
		||||
    def test_remove_from_club_group(self):
 | 
			
		||||
        """Test that when a membership ends, the user is removed from club groups."""
 | 
			
		||||
        user = baker.make(User)
 | 
			
		||||
        baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
 | 
			
		||||
        assert user.groups.contains(self.club.members_group)
 | 
			
		||||
        assert user.groups.contains(self.club.board_group)
 | 
			
		||||
        user.memberships.update(end_date=localdate())
 | 
			
		||||
        assert not user.groups.contains(self.club.members_group)
 | 
			
		||||
        assert not user.groups.contains(self.club.board_group)
 | 
			
		||||
 | 
			
		||||
        self.club.delete()
 | 
			
		||||
        for user in users:
 | 
			
		||||
            assert not user.is_in_group(name=meta_group)
 | 
			
		||||
    def test_add_to_club_group(self):
 | 
			
		||||
        """Test that when a membership begins, the user is added to the club group."""
 | 
			
		||||
        assert not self.subscriber.groups.contains(self.club.members_group)
 | 
			
		||||
        assert not self.subscriber.groups.contains(self.club.board_group)
 | 
			
		||||
        baker.make(Membership, club=self.club, user=self.subscriber, role=3)
 | 
			
		||||
        assert self.subscriber.groups.contains(self.club.members_group)
 | 
			
		||||
        assert self.subscriber.groups.contains(self.club.board_group)
 | 
			
		||||
 | 
			
		||||
    def test_add_to_meta_group(self):
 | 
			
		||||
        """Test that when a membership begins, the user is added to the meta group."""
 | 
			
		||||
        group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
 | 
			
		||||
        board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX
 | 
			
		||||
        assert not self.subscriber.is_in_group(name=group_members)
 | 
			
		||||
        assert not self.subscriber.is_in_group(name=board_members)
 | 
			
		||||
        Membership.objects.create(club=self.club, user=self.subscriber, role=3)
 | 
			
		||||
        assert self.subscriber.is_in_group(name=group_members)
 | 
			
		||||
        assert self.subscriber.is_in_group(name=board_members)
 | 
			
		||||
 | 
			
		||||
    def test_remove_from_meta_group(self):
 | 
			
		||||
        """Test that when a membership ends, the user is removed from meta group."""
 | 
			
		||||
        group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
 | 
			
		||||
        board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX
 | 
			
		||||
        assert self.comptable.is_in_group(name=group_members)
 | 
			
		||||
        assert self.comptable.is_in_group(name=board_members)
 | 
			
		||||
        self.comptable.memberships.update(end_date=localtime(now()))
 | 
			
		||||
        assert not self.comptable.is_in_group(name=group_members)
 | 
			
		||||
        assert not self.comptable.is_in_group(name=board_members)
 | 
			
		||||
    def test_change_position_in_club(self):
 | 
			
		||||
        """Test that when moving from board to members, club group change"""
 | 
			
		||||
        membership = baker.make(
 | 
			
		||||
            Membership, club=self.club, user=self.subscriber, role=3
 | 
			
		||||
        )
 | 
			
		||||
        assert self.subscriber.groups.contains(self.club.members_group)
 | 
			
		||||
        assert self.subscriber.groups.contains(self.club.board_group)
 | 
			
		||||
        membership.role = 1
 | 
			
		||||
        membership.save()
 | 
			
		||||
        assert self.subscriber.groups.contains(self.club.members_group)
 | 
			
		||||
        assert not self.subscriber.groups.contains(self.club.board_group)
 | 
			
		||||
 | 
			
		||||
    def test_club_owner(self):
 | 
			
		||||
        """Test that a club is owned only by board members of the main club."""
 | 
			
		||||
@@ -517,6 +548,26 @@ class TestClubModel(TestClub):
 | 
			
		||||
        Membership(club=self.ae, user=self.sli, role=3).save()
 | 
			
		||||
        assert self.club.is_owned_by(self.sli)
 | 
			
		||||
 | 
			
		||||
    def test_change_club_name(self):
 | 
			
		||||
        """Test that changing the club name doesn't break things."""
 | 
			
		||||
        members_group = self.club.members_group
 | 
			
		||||
        board_group = self.club.board_group
 | 
			
		||||
        initial_members = set(members_group.users.values_list("id", flat=True))
 | 
			
		||||
        initial_board = set(board_group.users.values_list("id", flat=True))
 | 
			
		||||
        self.club.name = "something else"
 | 
			
		||||
        self.club.save()
 | 
			
		||||
        self.club.refresh_from_db()
 | 
			
		||||
 | 
			
		||||
        # The names should have changed, but not the ids nor the group members
 | 
			
		||||
        assert self.club.members_group.name == "something else - Membres"
 | 
			
		||||
        assert self.club.board_group.name == "something else - Bureau"
 | 
			
		||||
        assert self.club.members_group.id == members_group.id
 | 
			
		||||
        assert self.club.board_group.id == board_group.id
 | 
			
		||||
        new_members = set(self.club.members_group.users.values_list("id", flat=True))
 | 
			
		||||
        new_board = set(self.club.board_group.users.values_list("id", flat=True))
 | 
			
		||||
        assert new_members == initial_members
 | 
			
		||||
        assert new_board == initial_board
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestMailingForm(TestCase):
 | 
			
		||||
    """Perform validation tests for MailingForm."""
 | 
			
		||||
 
 | 
			
		||||
@@ -71,14 +71,13 @@ class ClubTabsMixin(TabedViewMixin):
 | 
			
		||||
        return self.object.get_display_name()
 | 
			
		||||
 | 
			
		||||
    def get_list_of_tabs(self):
 | 
			
		||||
        tab_list = []
 | 
			
		||||
        tab_list.append(
 | 
			
		||||
        tab_list = [
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
 | 
			
		||||
                "slug": "infos",
 | 
			
		||||
                "name": _("Infos"),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        ]
 | 
			
		||||
        if self.request.user.can_view(self.object):
 | 
			
		||||
            tab_list.append(
 | 
			
		||||
                {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										32
									
								
								com/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								com/api.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
			
		||||
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())
 | 
			
		||||
							
								
								
									
										9
									
								
								com/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								com/apps.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ComConfig(AppConfig):
 | 
			
		||||
    name = "com"
 | 
			
		||||
    verbose_name = "News and communication"
 | 
			
		||||
 | 
			
		||||
    def ready(self):
 | 
			
		||||
        import com.signals  # noqa F401
 | 
			
		||||
							
								
								
									
										76
									
								
								com/calendar.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								com/calendar.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,76 @@
 | 
			
		||||
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
 | 
			
		||||
@@ -0,0 +1,56 @@
 | 
			
		||||
# Generated by Django 4.2.17 on 2024-12-16 14:51
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("club", "0011_auto_20180426_2013"),
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
        ("com", "0006_remove_sith_index_page"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="news",
 | 
			
		||||
            name="club",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                help_text="The club which organizes the event.",
 | 
			
		||||
                on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                related_name="news",
 | 
			
		||||
                to="club.club",
 | 
			
		||||
                verbose_name="club",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="news",
 | 
			
		||||
            name="content",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                default="",
 | 
			
		||||
                help_text="A more detailed and exhaustive description of the event.",
 | 
			
		||||
                verbose_name="content",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="news",
 | 
			
		||||
            name="moderator",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.SET_NULL,
 | 
			
		||||
                related_name="moderated_news",
 | 
			
		||||
                to=settings.AUTH_USER_MODEL,
 | 
			
		||||
                verbose_name="moderator",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="news",
 | 
			
		||||
            name="summary",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                help_text="A description of the event (what is the activity ? is there an associated clic ? is there a inscription form ?)",
 | 
			
		||||
                verbose_name="summary",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -17,11 +17,12 @@
 | 
			
		||||
# details.
 | 
			
		||||
#
 | 
			
		||||
# You should have received a copy of the GNU General Public License along with
 | 
			
		||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
 | 
			
		||||
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
 | 
			
		||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django.core.mail import EmailMultiAlternatives
 | 
			
		||||
@@ -34,7 +35,7 @@ from django.utils import timezone
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
from club.models import Club
 | 
			
		||||
from core.models import Notification, Preferences, RealGroup, User
 | 
			
		||||
from core.models import Notification, Preferences, User
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Sith(models.Model):
 | 
			
		||||
@@ -62,16 +63,31 @@ NEWS_TYPES = [
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class News(models.Model):
 | 
			
		||||
    """The news class."""
 | 
			
		||||
    """News about club events."""
 | 
			
		||||
 | 
			
		||||
    title = models.CharField(_("title"), max_length=64)
 | 
			
		||||
    summary = models.TextField(_("summary"))
 | 
			
		||||
    content = models.TextField(_("content"))
 | 
			
		||||
    summary = models.TextField(
 | 
			
		||||
        _("summary"),
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            "A description of the event (what is the activity ? "
 | 
			
		||||
            "is there an associated clic ? is there a inscription form ?)"
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
    content = models.TextField(
 | 
			
		||||
        _("content"),
 | 
			
		||||
        blank=True,
 | 
			
		||||
        default="",
 | 
			
		||||
        help_text=_("A more detailed and exhaustive description of the event."),
 | 
			
		||||
    )
 | 
			
		||||
    type = models.CharField(
 | 
			
		||||
        _("type"), max_length=16, choices=NEWS_TYPES, default="EVENT"
 | 
			
		||||
    )
 | 
			
		||||
    club = models.ForeignKey(
 | 
			
		||||
        Club, related_name="news", verbose_name=_("club"), on_delete=models.CASCADE
 | 
			
		||||
        Club,
 | 
			
		||||
        related_name="news",
 | 
			
		||||
        verbose_name=_("club"),
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
        help_text=_("The club which organizes the event."),
 | 
			
		||||
    )
 | 
			
		||||
    author = models.ForeignKey(
 | 
			
		||||
        User,
 | 
			
		||||
@@ -85,7 +101,7 @@ class News(models.Model):
 | 
			
		||||
        related_name="moderated_news",
 | 
			
		||||
        verbose_name=_("moderator"),
 | 
			
		||||
        null=True,
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
        on_delete=models.SET_NULL,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
@@ -93,17 +109,15 @@ class News(models.Model):
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        super().save(*args, **kwargs)
 | 
			
		||||
        for u in (
 | 
			
		||||
            RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
			
		||||
            .first()
 | 
			
		||||
            .users.all()
 | 
			
		||||
        for user in User.objects.filter(
 | 
			
		||||
            groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
 | 
			
		||||
        ):
 | 
			
		||||
            Notification(
 | 
			
		||||
                user=u,
 | 
			
		||||
            Notification.objects.create(
 | 
			
		||||
                user=user,
 | 
			
		||||
                url=reverse("com:news_admin_list"),
 | 
			
		||||
                type="NEWS_MODERATION",
 | 
			
		||||
                param="1",
 | 
			
		||||
            ).save()
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def get_absolute_url(self):
 | 
			
		||||
        return reverse("com:news_detail", kwargs={"news_id": self.id})
 | 
			
		||||
@@ -321,16 +335,14 @@ class Poster(models.Model):
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        if not self.is_moderated:
 | 
			
		||||
            for u in (
 | 
			
		||||
                RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
			
		||||
                .first()
 | 
			
		||||
                .users.all()
 | 
			
		||||
            for user in User.objects.filter(
 | 
			
		||||
                groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
 | 
			
		||||
            ):
 | 
			
		||||
                Notification(
 | 
			
		||||
                    user=u,
 | 
			
		||||
                Notification.objects.create(
 | 
			
		||||
                    user=user,
 | 
			
		||||
                    url=reverse("com:poster_moderate_list"),
 | 
			
		||||
                    type="POSTER_MODERATION",
 | 
			
		||||
                ).save()
 | 
			
		||||
                )
 | 
			
		||||
        return super().save(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def clean(self, *args, **kwargs):
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10
									
								
								com/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								com/signals.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
from django.db.models.base import post_save
 | 
			
		||||
from django.dispatch import receiver
 | 
			
		||||
 | 
			
		||||
from com.calendar import IcsCalendar
 | 
			
		||||
from com.models import News
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(post_save, sender=News, dispatch_uid="update_internal_ics")
 | 
			
		||||
def update_internal_ics(*args, **kwargs):
 | 
			
		||||
    _ = IcsCalendar.make_internal()
 | 
			
		||||
							
								
								
									
										194
									
								
								com/static/bundled/com/components/ics-calendar-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										194
									
								
								com/static/bundled/com/components/ics-calendar-index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,194 @@
 | 
			
		||||
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();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										101
									
								
								com/static/com/components/ics-calendar.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								com/static/com/components/ics-calendar.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,101 @@
 | 
			
		||||
@import "core/static/core/colors";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
:root {
 | 
			
		||||
  --fc-button-border-color: #fff;
 | 
			
		||||
  --fc-button-hover-border-color: #fff;
 | 
			
		||||
  --fc-button-active-border-color: #fff;
 | 
			
		||||
  --fc-button-text-color: #fff;
 | 
			
		||||
  --fc-button-bg-color: #1a78b3;
 | 
			
		||||
  --fc-button-active-bg-color: #15608F;
 | 
			
		||||
  --fc-button-hover-bg-color: #15608F;
 | 
			
		||||
  --fc-today-bg-color: rgba(26, 120, 179, 0.1);
 | 
			
		||||
  --fc-border-color: #DDDDDD;
 | 
			
		||||
  --event-details-background-color: white;
 | 
			
		||||
  --event-details-padding: 20px;
 | 
			
		||||
  --event-details-border: 1px solid #EEEEEE;
 | 
			
		||||
  --event-details-border-radius: 4px;
 | 
			
		||||
  --event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
 | 
			
		||||
  --event-details-max-width: 600px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ics-calendar {
 | 
			
		||||
  border: none;
 | 
			
		||||
  box-shadow: none;
 | 
			
		||||
 | 
			
		||||
  #event-details {
 | 
			
		||||
    z-index: 10;
 | 
			
		||||
    max-width: 1151px;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
 | 
			
		||||
    .event-details-container {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      color: black;
 | 
			
		||||
      flex-direction: column;
 | 
			
		||||
      min-width: 200px;
 | 
			
		||||
      max-width: var(--event-details-max-width);
 | 
			
		||||
      padding: var(--event-details-padding);
 | 
			
		||||
      border: var(--event-details-border);
 | 
			
		||||
      border-radius: var(--event-details-border-radius);
 | 
			
		||||
      background-color: var(--event-details-background-color);
 | 
			
		||||
      box-shadow: var(--event-details-box-shadow);
 | 
			
		||||
      gap: 20px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .event-detail-row-icon {
 | 
			
		||||
      margin-left: 10px;
 | 
			
		||||
      margin-right: 20px;
 | 
			
		||||
      align-content: center;
 | 
			
		||||
      align-self: center;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .event-details-row {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: start;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .event-details-row-content {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: start;
 | 
			
		||||
      flex-direction: row;
 | 
			
		||||
      background-color: var(--event-details-background-color);
 | 
			
		||||
      margin-top: 0px;
 | 
			
		||||
      margin-bottom: 4px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  a.fc-col-header-cell-cushion,
 | 
			
		||||
  a.fc-col-header-cell-cushion:hover {
 | 
			
		||||
    color: black;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  a.fc-daygrid-day-number,
 | 
			
		||||
  a.fc-daygrid-day-number:hover {
 | 
			
		||||
    color: rgb(34, 34, 34);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  td {
 | 
			
		||||
    overflow-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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										61
									
								
								com/static/com/css/news-detail.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								com/static/com/css/news-detail.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
			
		||||
@import "core/static/core/colors";
 | 
			
		||||
 | 
			
		||||
#news_details {
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  margin-top: 20px;
 | 
			
		||||
  padding: 0.4em;
 | 
			
		||||
  width: 80%;
 | 
			
		||||
  background: $white-color;
 | 
			
		||||
 | 
			
		||||
  h4 {
 | 
			
		||||
    margin-top: 1em;
 | 
			
		||||
    text-transform: uppercase;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .club_logo {
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    width: 19%;
 | 
			
		||||
    float: left;
 | 
			
		||||
    min-width: 15em;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
 | 
			
		||||
    img {
 | 
			
		||||
      max-height: 15em;
 | 
			
		||||
      max-width: 12em;
 | 
			
		||||
      display: block;
 | 
			
		||||
      margin: 0 auto;
 | 
			
		||||
      margin-bottom: 10px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .share_button {
 | 
			
		||||
    border: none;
 | 
			
		||||
    color: white;
 | 
			
		||||
    padding: 0.5em 1em;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    font-size: 1.2em;
 | 
			
		||||
    border-radius: 2px;
 | 
			
		||||
    float: right;
 | 
			
		||||
    display: block;
 | 
			
		||||
    margin-left: 0.3em;
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      color: lightgrey;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .facebook {
 | 
			
		||||
    background: $faceblue;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .twitter {
 | 
			
		||||
    background: $twitblue;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .news_meta {
 | 
			
		||||
    margin-top: 10em;
 | 
			
		||||
    font-size: small;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										297
									
								
								com/static/com/css/news-list.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										297
									
								
								com/static/com/css/news-list.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,297 @@
 | 
			
		||||
@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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										230
									
								
								com/static/com/css/posters.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										230
									
								
								com/static/com/css/posters.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,230 @@
 | 
			
		||||
#poster_list,
 | 
			
		||||
#screen_list,
 | 
			
		||||
#poster_edit,
 | 
			
		||||
#screen_edit {
 | 
			
		||||
  position: relative;
 | 
			
		||||
 | 
			
		||||
  #title {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    padding: 10px;
 | 
			
		||||
    margin: 10px;
 | 
			
		||||
    border-bottom: 2px solid black;
 | 
			
		||||
 | 
			
		||||
    h3 {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      justify-content: center;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #links {
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      display: flex;
 | 
			
		||||
      bottom: 5px;
 | 
			
		||||
 | 
			
		||||
      &.left {
 | 
			
		||||
        left: 0;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &.right {
 | 
			
		||||
        right: 0;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .link {
 | 
			
		||||
        padding: 5px;
 | 
			
		||||
        padding-left: 20px;
 | 
			
		||||
        padding-right: 20px;
 | 
			
		||||
        margin-left: 5px;
 | 
			
		||||
        border-radius: 20px;
 | 
			
		||||
        background-color: hsl(40, 100%, 50%);
 | 
			
		||||
        color: black;
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          color: black;
 | 
			
		||||
          background-color: hsl(40, 58%, 50%);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &.delete {
 | 
			
		||||
          background-color: hsl(0, 100%, 40%);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #posters,
 | 
			
		||||
  #screens {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
 | 
			
		||||
    #no-posters,
 | 
			
		||||
    #no-screens {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      justify-content: center;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .poster,
 | 
			
		||||
    .screen {
 | 
			
		||||
      min-width: 10%;
 | 
			
		||||
      max-width: 20%;
 | 
			
		||||
      display: flex;
 | 
			
		||||
      flex-direction: column;
 | 
			
		||||
      margin: 10px;
 | 
			
		||||
      border: 2px solid darkgrey;
 | 
			
		||||
      border-radius: 4px;
 | 
			
		||||
      padding: 10px;
 | 
			
		||||
      background-color: lightgrey;
 | 
			
		||||
 | 
			
		||||
      * {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        justify-content: center;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .name {
 | 
			
		||||
        padding-bottom: 5px;
 | 
			
		||||
        margin-bottom: 5px;
 | 
			
		||||
        border-bottom: 1px solid whitesmoke;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .image {
 | 
			
		||||
        flex-grow: 1;
 | 
			
		||||
        position: relative;
 | 
			
		||||
        padding-bottom: 5px;
 | 
			
		||||
        margin-bottom: 5px;
 | 
			
		||||
        border-bottom: 1px solid whitesmoke;
 | 
			
		||||
 | 
			
		||||
        img {
 | 
			
		||||
          max-height: 20vw;
 | 
			
		||||
          max-width: 100%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          &::before {
 | 
			
		||||
            position: absolute;
 | 
			
		||||
            width: 100%;
 | 
			
		||||
            height: 100%;
 | 
			
		||||
            display: flex;
 | 
			
		||||
            justify-content: center;
 | 
			
		||||
            align-items: center;
 | 
			
		||||
            flex-wrap: wrap;
 | 
			
		||||
            top: 0;
 | 
			
		||||
            left: 0;
 | 
			
		||||
            z-index: 10;
 | 
			
		||||
            content: "Click to expand";
 | 
			
		||||
            color: white;
 | 
			
		||||
            background-color: rgba(black, 0.5);
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .dates {
 | 
			
		||||
        padding-bottom: 5px;
 | 
			
		||||
        margin-bottom: 5px;
 | 
			
		||||
        border-bottom: 1px solid whitesmoke;
 | 
			
		||||
 | 
			
		||||
        * {
 | 
			
		||||
          display: flex;
 | 
			
		||||
          justify-content: center;
 | 
			
		||||
          align-items: center;
 | 
			
		||||
          flex-wrap: wrap;
 | 
			
		||||
          margin-left: 5px;
 | 
			
		||||
          margin-right: 5px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .begin,
 | 
			
		||||
        .end {
 | 
			
		||||
          width: 48%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .begin {
 | 
			
		||||
          border-right: 1px solid whitesmoke;
 | 
			
		||||
          padding-right: 2%;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .edit,
 | 
			
		||||
      .moderate,
 | 
			
		||||
      .slideshow {
 | 
			
		||||
        padding: 5px;
 | 
			
		||||
        border-radius: 20px;
 | 
			
		||||
        background-color: hsl(40, 100%, 50%);
 | 
			
		||||
        color: black;
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          color: black;
 | 
			
		||||
          background-color: hsl(40, 58%, 50%);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &:nth-child(2n) {
 | 
			
		||||
          margin-top: 5px;
 | 
			
		||||
          margin-bottom: 5px;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .tooltip {
 | 
			
		||||
        visibility: hidden;
 | 
			
		||||
        width: 120px;
 | 
			
		||||
        background-color: hsl(210, 20%, 98%);
 | 
			
		||||
        color: hsl(0, 0%, 0%);
 | 
			
		||||
        text-align: center;
 | 
			
		||||
        padding: 5px 0;
 | 
			
		||||
        border-radius: 6px;
 | 
			
		||||
        position: absolute;
 | 
			
		||||
        z-index: 10;
 | 
			
		||||
 | 
			
		||||
        ul {
 | 
			
		||||
          margin-left: 0;
 | 
			
		||||
          display: inline-block;
 | 
			
		||||
 | 
			
		||||
          li {
 | 
			
		||||
            display: list-item;
 | 
			
		||||
            list-style-type: none;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &.not_moderated {
 | 
			
		||||
        border: 1px solid red;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &:hover .tooltip {
 | 
			
		||||
        visibility: visible;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #view {
 | 
			
		||||
    position: fixed;
 | 
			
		||||
    width: 100vw;
 | 
			
		||||
    height: 100vh;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    top: 0;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    z-index: 10;
 | 
			
		||||
    visibility: hidden;
 | 
			
		||||
    background-color: rgba(10, 10, 10, 0.9);
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
 | 
			
		||||
    &.active {
 | 
			
		||||
      visibility: visible;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #placeholder {
 | 
			
		||||
      width: 80vw;
 | 
			
		||||
      height: 80vh;
 | 
			
		||||
      display: flex;
 | 
			
		||||
      justify-content: center;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      top: 0;
 | 
			
		||||
      left: 0;
 | 
			
		||||
 | 
			
		||||
      img {
 | 
			
		||||
        max-width: 100%;
 | 
			
		||||
        max-height: 100%;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -11,6 +11,11 @@
 | 
			
		||||
  {{ gen_news_metatags(news) }}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% block additional_css %}
 | 
			
		||||
  <link rel="stylesheet" href="{{ static('com/css/news-detail.scss') }}">
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  <p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
 | 
			
		||||
  <section id="news_details">
 | 
			
		||||
 
 | 
			
		||||
@@ -34,43 +34,90 @@
 | 
			
		||||
    {% csrf_token %}
 | 
			
		||||
    {{ form.non_field_errors() }}
 | 
			
		||||
    {{ form.author }}
 | 
			
		||||
    <p>{{ form.type.errors }}<label for="{{ form.type.name }}">{{ form.type.label }}</label>
 | 
			
		||||
    <p>
 | 
			
		||||
      {{ form.type.errors }}
 | 
			
		||||
      <label for="{{ form.type.name }}" class="required">{{ form.type.label }}</label>
 | 
			
		||||
      <ul>
 | 
			
		||||
        <li>{% trans %}Notice: Information, election result - no date{% endtrans %}</li>
 | 
			
		||||
        <li>{% trans %}Event: punctual event, associated with one date{% endtrans %}</li>
 | 
			
		||||
        <li>{% trans %}Weekly: recurrent event, associated with many dates (specify the first one, and a deadline){% endtrans %}</li>
 | 
			
		||||
        <li>{% trans %}Call: long time event, associated with a long date (election appliance, ...){% endtrans %}</li>
 | 
			
		||||
        <li>
 | 
			
		||||
          {% trans trimmed%}
 | 
			
		||||
            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>
 | 
			
		||||
      {{ form.type }}</p>
 | 
			
		||||
    <p class="date">{{ form.start_date.errors }}<label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label> {{ form.start_date }}</p>
 | 
			
		||||
    <p class="date">{{ form.end_date.errors }}<label for="{{ form.end_date.name }}">{{ form.end_date.label }}</label> {{ form.end_date }}</p>
 | 
			
		||||
    <p class="until">{{ form.until.errors }}<label for="{{ form.until.name }}">{{ form.until.label }}</label> {{ form.until }}</p>
 | 
			
		||||
    <p>{{ form.title.errors }}<label for="{{ form.title.name }}">{{ form.title.label }}</label> {{ form.title }}</p>
 | 
			
		||||
    <p>{{ form.club.errors }}<label for="{{ form.club.name }}">{{ form.club.label }}</label> {{ form.club }}</p>
 | 
			
		||||
    <p>{{ form.summary.errors }}<label for="{{ form.summary.name }}">{{ form.summary.label }}</label> {{ form.summary }}</p>
 | 
			
		||||
    <p>{{ form.content.errors }}<label for="{{ form.content.name }}">{{ form.content.label }}</label> {{ form.content }}</p>
 | 
			
		||||
      {{ form.type }}
 | 
			
		||||
    </p>
 | 
			
		||||
    <p class="date">
 | 
			
		||||
      {{ form.start_date.errors }}
 | 
			
		||||
      <label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label>
 | 
			
		||||
      {{ form.start_date }}
 | 
			
		||||
    </p>
 | 
			
		||||
    <p class="date">
 | 
			
		||||
      {{ form.end_date.errors }}
 | 
			
		||||
      <label for="{{ form.end_date.name }}">{{ form.end_date.label }}</label>
 | 
			
		||||
      {{ form.end_date }}
 | 
			
		||||
    </p>
 | 
			
		||||
    <p class="until">
 | 
			
		||||
      {{ form.until.errors }}
 | 
			
		||||
      <label for="{{ form.until.name }}">{{ form.until.label }}</label>
 | 
			
		||||
      {{ form.until }}
 | 
			
		||||
    </p>
 | 
			
		||||
    <p>
 | 
			
		||||
      {{ form.title.errors }}
 | 
			
		||||
      <label for="{{ form.title.name }}" 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 %}
 | 
			
		||||
      <p>{{ form.automoderation.errors }}<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
 | 
			
		||||
        {{ form.automoderation }}</p>
 | 
			
		||||
      <p>
 | 
			
		||||
        {{ form.automoderation.errors }}
 | 
			
		||||
        <label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
 | 
			
		||||
        {{ form.automoderation }}
 | 
			
		||||
      </p>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}" /></p>
 | 
			
		||||
    <p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
 | 
			
		||||
    <p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}"/></p>
 | 
			
		||||
    <p><input type="submit" value="{% trans %}Save{% endtrans %}"/></p>
 | 
			
		||||
  </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block script %}
 | 
			
		||||
  {{ super() }}
 | 
			
		||||
  <script>
 | 
			
		||||
    $( function() {
 | 
			
		||||
      var type = $('input[name=type]');
 | 
			
		||||
      var dates = $('.date');
 | 
			
		||||
      var until = $('.until');
 | 
			
		||||
      function update_targets () {
 | 
			
		||||
        type_checked = $('input[name=type]:checked');
 | 
			
		||||
        if (type_checked.val() == "EVENT" || type_checked.val() == "CALL") {
 | 
			
		||||
    $(function () {
 | 
			
		||||
      let type = $('input[name=type]');
 | 
			
		||||
      let dates = $('.date');
 | 
			
		||||
      let until = $('.until');
 | 
			
		||||
 | 
			
		||||
      function update_targets() {
 | 
			
		||||
        const type_checked = $('input[name=type]:checked');
 | 
			
		||||
        if (["CALL", "EVENT"].includes(type_checked.val())) {
 | 
			
		||||
          dates.show();
 | 
			
		||||
          until.hide();
 | 
			
		||||
        } else if (type_checked.val() == "WEEKLY") {
 | 
			
		||||
        } else if (type_checked.val() === "WEEKLY") {
 | 
			
		||||
          dates.show();
 | 
			
		||||
          until.show();
 | 
			
		||||
        } else {
 | 
			
		||||
@@ -78,9 +125,10 @@
 | 
			
		||||
          until.hide();
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      update_targets();
 | 
			
		||||
      type.change(update_targets);
 | 
			
		||||
    } );
 | 
			
		||||
    });
 | 
			
		||||
  </script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,15 @@
 | 
			
		||||
  {% trans %}News{% endtrans %}
 | 
			
		||||
{% 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 %}
 | 
			
		||||
  {% if user.is_com_admin %}
 | 
			
		||||
    <div id="news_admin">
 | 
			
		||||
@@ -83,84 +92,78 @@
 | 
			
		||||
  </div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% set coming_soon = object_list.filter(dates__start_date__gte=timezone.now()+timedelta(days=5),
 | 
			
		||||
type="EVENT").order_by('dates__start_date') %}
 | 
			
		||||
{% if coming_soon %}
 | 
			
		||||
  <h3>{% trans %}Coming soon... don't miss!{% endtrans %}</h3>
 | 
			
		||||
  {% for news in coming_soon %}
 | 
			
		||||
    <section class="news_coming_soon">
 | 
			
		||||
      <a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a>
 | 
			
		||||
      <span class="news_date">{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
        {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} -
 | 
			
		||||
        {{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
 | 
			
		||||
        {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
 | 
			
		||||
    </section>
 | 
			
		||||
  {% endfor %}
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
<h3>{% trans %}All coming events{% endtrans %}</h3>
 | 
			
		||||
<iframe
 | 
			
		||||
  src="https://embed.styledcalendar.com/#2mF2is8CEXhr4ADcX6qN"
 | 
			
		||||
  title="Styled Calendar"
 | 
			
		||||
  class="styled-calendar-container"
 | 
			
		||||
  style="width: 100%; border: none; height: 1060px"
 | 
			
		||||
  data-cy="calendar-embed-iframe">
 | 
			
		||||
</iframe>
 | 
			
		||||
<ics-calendar locale="{{ get_language() }}"></ics-calendar>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div id="right_column" class="news_column">
 | 
			
		||||
  <div id="agenda">
 | 
			
		||||
    <div id="agenda_title">{% trans %}Agenda{% endtrans %}</div>
 | 
			
		||||
    <div id="agenda_content">
 | 
			
		||||
      {% for d in NewsDate.objects.filter(end_date__gte=timezone.now(),
 | 
			
		||||
      news__is_moderated=True, news__type__in=["WEEKLY",
 | 
			
		||||
      "EVENT"]).order_by('start_date', 'end_date') %}
 | 
			
		||||
      <div class="agenda_item">
 | 
			
		||||
        <div class="agenda_date">
 | 
			
		||||
          <strong>{{ d.start_date|localtime|date('D d M Y') }}</strong>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="agenda_time">
 | 
			
		||||
          <span>{{ d.start_date|localtime|time(DATETIME_FORMAT) }}</span> -
 | 
			
		||||
          <span>{{ d.end_date|localtime|time(DATETIME_FORMAT) }}</span>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div>
 | 
			
		||||
          <strong><a href="{{ url('com:news_detail', news_id=d.news.id) }}">{{ d.news.title }}</a></strong>
 | 
			
		||||
          <a href="{{ d.news.club.get_absolute_url() }}">{{ d.news.club }}</a>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="agenda_item_content">{{ d.news.summary|markdown }}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
<div id="right_column">
 | 
			
		||||
  <div id="links">
 | 
			
		||||
    <h3>{% trans %}Links{% endtrans %}</h3>
 | 
			
		||||
    <div id="links_content">
 | 
			
		||||
      <h4>{% trans %}Our services{% endtrans %}</h4>
 | 
			
		||||
      <ul>
 | 
			
		||||
        <li>
 | 
			
		||||
          <i class="fa-solid fa-graduation-cap fa-xl"></i>
 | 
			
		||||
          <a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
 | 
			
		||||
        </li>
 | 
			
		||||
        <li>
 | 
			
		||||
          <i class="fa-solid fa-magnifying-glass fa-xl"></i>
 | 
			
		||||
          <a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
 | 
			
		||||
        </li>
 | 
			
		||||
        <li>
 | 
			
		||||
          <i class="fa-solid fa-check-to-slot fa-xl"></i>
 | 
			
		||||
          <a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
 | 
			
		||||
        </li>
 | 
			
		||||
      </ul>
 | 
			
		||||
      <br>
 | 
			
		||||
      <h4>{% trans %}Social media{% endtrans %}</h4>
 | 
			
		||||
      <ul>
 | 
			
		||||
        <li>
 | 
			
		||||
          <i class="fa-brands fa-discord fa-xl"></i>
 | 
			
		||||
          <a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">{% trans %}Discord AE{% endtrans %}</a>
 | 
			
		||||
          {% if user.was_subscribed %}
 | 
			
		||||
            - <a rel="nofollow" target="#" href="https://discord.gg/XK9WfPsUFm">{% 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 id="birthdays">
 | 
			
		||||
    <div id="birthdays_title">{% trans %}Birthdays{% endtrans %}</div>
 | 
			
		||||
    <h3>{% trans %}Birthdays{% endtrans %}</h3>
 | 
			
		||||
    <div id="birthdays_content">
 | 
			
		||||
      {% if user.is_subscribed %}
 | 
			
		||||
                    {# Cache request for 1 hour #}
 | 
			
		||||
        {% cache 3600 "birthdays" %}
 | 
			
		||||
          <ul class="birthdays_year">
 | 
			
		||||
            {% for d in birthdays.dates('date_of_birth', 'year', 'DESC') %}
 | 
			
		||||
              <li>
 | 
			
		||||
                {% trans age=timezone.now().year - d.year %}{{ age }} year old{% endtrans %}
 | 
			
		||||
                <ul>
 | 
			
		||||
                  {% for u in birthdays.filter(date_of_birth__year=d.year) %}
 | 
			
		||||
                    <li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
 | 
			
		||||
                  {% endfor %}
 | 
			
		||||
                </ul>
 | 
			
		||||
              </li>
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
          </ul>
 | 
			
		||||
        {% endcache %}
 | 
			
		||||
      {% else %}
 | 
			
		||||
        <p>{% trans %}You need an up to date subscription to access this content{% endtrans %}</p>
 | 
			
		||||
      {% endif %}
 | 
			
		||||
      {%- if user.was_subscribed -%}
 | 
			
		||||
        <ul class="birthdays_year">
 | 
			
		||||
          {%- for year, users in birthdays -%}
 | 
			
		||||
            <li>
 | 
			
		||||
              {% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %}
 | 
			
		||||
              <ul>
 | 
			
		||||
                {%- for u in users -%}
 | 
			
		||||
                  <li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
 | 
			
		||||
                {%- endfor -%}
 | 
			
		||||
              </ul>
 | 
			
		||||
            </li>
 | 
			
		||||
          {%- endfor -%}
 | 
			
		||||
        </ul>
 | 
			
		||||
      {%- else -%}
 | 
			
		||||
        <p>{% trans %}You need to subscribe to access this content{% endtrans %}</p>
 | 
			
		||||
      {%- endif -%}
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,10 @@
 | 
			
		||||
  {% trans %}Poster{% endtrans %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block additional_css %}
 | 
			
		||||
  <link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  <div id="poster_list">
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,10 @@
 | 
			
		||||
  <script src="{{ static('com/js/poster_list.js') }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block additional_css %}
 | 
			
		||||
  <link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  <div id="poster_list">
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@
 | 
			
		||||
  <head>
 | 
			
		||||
    <title>{% trans %}Slideshow{% endtrans %}</title>
 | 
			
		||||
    <link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
 | 
			
		||||
    <script type="module" src="{{ static('bundled/jquery-index.js') }}"></script>
 | 
			
		||||
    <script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
 | 
			
		||||
    <script src="{{ static('com/js/slideshow.js') }}"></script>
 | 
			
		||||
  </head>
 | 
			
		||||
  <body>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										122
									
								
								com/tests/test_api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								com/tests/test_api.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,122 @@
 | 
			
		||||
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()
 | 
			
		||||
@@ -23,7 +23,7 @@ from django.utils.translation import gettext as _
 | 
			
		||||
 | 
			
		||||
from club.models import Club, Membership
 | 
			
		||||
from com.models import News, Poster, Sith, Weekmail, WeekmailArticle
 | 
			
		||||
from core.models import AnonymousUser, RealGroup, User
 | 
			
		||||
from core.models import AnonymousUser, Group, User
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.fixture()
 | 
			
		||||
@@ -49,9 +49,7 @@ class TestCom(TestCase):
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def setUpTestData(cls):
 | 
			
		||||
        cls.skia = User.objects.get(username="skia")
 | 
			
		||||
        cls.com_group = RealGroup.objects.filter(
 | 
			
		||||
            id=settings.SITH_GROUP_COM_ADMIN_ID
 | 
			
		||||
        ).first()
 | 
			
		||||
        cls.com_group = Group.objects.get(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
			
		||||
        cls.skia.groups.set([cls.com_group])
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
@@ -99,9 +97,7 @@ class TestCom(TestCase):
 | 
			
		||||
        response = self.client.get(reverse("core:index"))
 | 
			
		||||
        self.assertContains(
 | 
			
		||||
            response,
 | 
			
		||||
            text=html.escape(
 | 
			
		||||
                _("You need an up to date subscription to access this content")
 | 
			
		||||
            ),
 | 
			
		||||
            text=html.escape(_("You need to subscribe to access this content")),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_birthday_subscibed_user(self):
 | 
			
		||||
@@ -109,9 +105,16 @@ class TestCom(TestCase):
 | 
			
		||||
 | 
			
		||||
        self.assertNotContains(
 | 
			
		||||
            response,
 | 
			
		||||
            text=html.escape(
 | 
			
		||||
                _("You need an up to date subscription to access this content")
 | 
			
		||||
            ),
 | 
			
		||||
            text=html.escape(_("You need to subscribe to access this content")),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_birthday_old_subscibed_user(self):
 | 
			
		||||
        self.client.force_login(User.objects.get(username="old_subscriber"))
 | 
			
		||||
        response = self.client.get(reverse("core:index"))
 | 
			
		||||
 | 
			
		||||
        self.assertNotContains(
 | 
			
		||||
            response,
 | 
			
		||||
            text=html.escape(_("You need to subscribe to access this content")),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										73
									
								
								com/views.py
									
									
									
									
									
								
							
							
						
						
									
										73
									
								
								com/views.py
									
									
									
									
									
								
							@@ -21,14 +21,14 @@
 | 
			
		||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
import itertools
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from smtplib import SMTPRecipientsRefused
 | 
			
		||||
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.core.exceptions import PermissionDenied, ValidationError
 | 
			
		||||
from django.db.models import Max
 | 
			
		||||
from django.db.models import Exists, Max, OuterRef
 | 
			
		||||
from django.forms.models import modelform_factory
 | 
			
		||||
from django.http import HttpResponseRedirect
 | 
			
		||||
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 com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
 | 
			
		||||
from core.models import Notification, RealGroup, User
 | 
			
		||||
from core.models import Notification, User
 | 
			
		||||
from core.views import (
 | 
			
		||||
    CanCreateMixin,
 | 
			
		||||
    CanEditMixin,
 | 
			
		||||
@@ -223,15 +223,13 @@ class NewsForm(forms.ModelForm):
 | 
			
		||||
            ):
 | 
			
		||||
                self.add_error(
 | 
			
		||||
                    "end_date",
 | 
			
		||||
                    ValidationError(
 | 
			
		||||
                        _("You crazy? You can not finish an event before starting it.")
 | 
			
		||||
                    ),
 | 
			
		||||
                    ValidationError(_("An event cannot end before its beginning.")),
 | 
			
		||||
                )
 | 
			
		||||
            if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
 | 
			
		||||
                self.add_error("until", ValidationError(_("This field is required.")))
 | 
			
		||||
        return self.cleaned_data
 | 
			
		||||
 | 
			
		||||
    def save(self):
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        ret = super().save()
 | 
			
		||||
        self.instance.dates.all().delete()
 | 
			
		||||
        if self.instance.type == "EVENT" or self.instance.type == "CALL":
 | 
			
		||||
@@ -280,21 +278,18 @@ class NewsEditView(CanEditMixin, UpdateView):
 | 
			
		||||
        else:
 | 
			
		||||
            self.object.is_moderated = False
 | 
			
		||||
            self.object.save()
 | 
			
		||||
            for u in (
 | 
			
		||||
                RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
			
		||||
                .first()
 | 
			
		||||
                .users.all()
 | 
			
		||||
            unread_notif_subquery = Notification.objects.filter(
 | 
			
		||||
                user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
 | 
			
		||||
            )
 | 
			
		||||
            for user in User.objects.filter(
 | 
			
		||||
                ~Exists(unread_notif_subquery),
 | 
			
		||||
                groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
 | 
			
		||||
            ):
 | 
			
		||||
                if not u.notifications.filter(
 | 
			
		||||
                    type="NEWS_MODERATION", viewed=False
 | 
			
		||||
                ).exists():
 | 
			
		||||
                    Notification(
 | 
			
		||||
                        user=u,
 | 
			
		||||
                        url=reverse(
 | 
			
		||||
                            "com:news_detail", kwargs={"news_id": self.object.id}
 | 
			
		||||
                        ),
 | 
			
		||||
                        type="NEWS_MODERATION",
 | 
			
		||||
                    ).save()
 | 
			
		||||
                Notification.objects.create(
 | 
			
		||||
                    user=user,
 | 
			
		||||
                    url=self.object.get_absolute_url(),
 | 
			
		||||
                    type="NEWS_MODERATION",
 | 
			
		||||
                )
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -325,19 +320,18 @@ class NewsCreateView(CanCreateMixin, CreateView):
 | 
			
		||||
            self.object.is_moderated = True
 | 
			
		||||
            self.object.save()
 | 
			
		||||
        else:
 | 
			
		||||
            for u in (
 | 
			
		||||
                RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
			
		||||
                .first()
 | 
			
		||||
                .users.all()
 | 
			
		||||
            unread_notif_subquery = Notification.objects.filter(
 | 
			
		||||
                user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
 | 
			
		||||
            )
 | 
			
		||||
            for user in User.objects.filter(
 | 
			
		||||
                ~Exists(unread_notif_subquery),
 | 
			
		||||
                groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
 | 
			
		||||
            ):
 | 
			
		||||
                if not u.notifications.filter(
 | 
			
		||||
                    type="NEWS_MODERATION", viewed=False
 | 
			
		||||
                ).exists():
 | 
			
		||||
                    Notification(
 | 
			
		||||
                        user=u,
 | 
			
		||||
                        url=reverse("com:news_admin_list"),
 | 
			
		||||
                        type="NEWS_MODERATION",
 | 
			
		||||
                    ).save()
 | 
			
		||||
                Notification.objects.create(
 | 
			
		||||
                    user=user,
 | 
			
		||||
                    url=reverse("com:news_admin_list"),
 | 
			
		||||
                    type="NEWS_MODERATION",
 | 
			
		||||
                )
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -380,13 +374,14 @@ class NewsListView(CanViewMixin, ListView):
 | 
			
		||||
        kwargs = super().get_context_data(**kwargs)
 | 
			
		||||
        kwargs["NewsDate"] = NewsDate
 | 
			
		||||
        kwargs["timedelta"] = timedelta
 | 
			
		||||
        kwargs["birthdays"] = (
 | 
			
		||||
        kwargs["birthdays"] = itertools.groupby(
 | 
			
		||||
            User.objects.filter(
 | 
			
		||||
                date_of_birth__month=localdate().month,
 | 
			
		||||
                date_of_birth__day=localdate().day,
 | 
			
		||||
            )
 | 
			
		||||
            .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
 | 
			
		||||
 | 
			
		||||
@@ -690,8 +685,12 @@ class PosterEditBaseView(UpdateView):
 | 
			
		||||
 | 
			
		||||
    def get_initial(self):
 | 
			
		||||
        return {
 | 
			
		||||
            "date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S"),
 | 
			
		||||
            "date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S"),
 | 
			
		||||
            "date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S")
 | 
			
		||||
            if self.object.date_begin
 | 
			
		||||
            else None,
 | 
			
		||||
            "date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S")
 | 
			
		||||
            if self.object.date_end
 | 
			
		||||
            else None,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def dispatch(self, request, *args, **kwargs):
 | 
			
		||||
 
 | 
			
		||||
@@ -15,17 +15,32 @@
 | 
			
		||||
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
from django.contrib.auth.models import Group as AuthGroup
 | 
			
		||||
from django.contrib.auth.models import Permission
 | 
			
		||||
 | 
			
		||||
from core.models import Group, OperationLog, Page, SithFile, User
 | 
			
		||||
from core.models import BanGroup, Group, OperationLog, Page, SithFile, User, UserBan
 | 
			
		||||
 | 
			
		||||
admin.site.unregister(AuthGroup)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Group)
 | 
			
		||||
class GroupAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ("name", "description", "is_meta")
 | 
			
		||||
    list_filter = ("is_meta",)
 | 
			
		||||
    list_display = ("name", "description", "is_manually_manageable")
 | 
			
		||||
    list_filter = ("is_manually_manageable",)
 | 
			
		||||
    search_fields = ("name",)
 | 
			
		||||
    autocomplete_fields = ("permissions",)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(BanGroup)
 | 
			
		||||
class BanGroupAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ("name", "description")
 | 
			
		||||
    search_fields = ("name",)
 | 
			
		||||
    autocomplete_fields = ("permissions",)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserBanInline(admin.TabularInline):
 | 
			
		||||
    model = UserBan
 | 
			
		||||
    extra = 0
 | 
			
		||||
    autocomplete_fields = ("ban_group",)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(User)
 | 
			
		||||
@@ -37,10 +52,24 @@ class UserAdmin(admin.ModelAdmin):
 | 
			
		||||
        "profile_pict",
 | 
			
		||||
        "avatar_pict",
 | 
			
		||||
        "scrub_pict",
 | 
			
		||||
        "user_permissions",
 | 
			
		||||
        "groups",
 | 
			
		||||
    )
 | 
			
		||||
    inlines = (UserBanInline,)
 | 
			
		||||
    search_fields = ["first_name", "last_name", "username"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(UserBan)
 | 
			
		||||
class UserBanAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ("user", "ban_group", "created_at", "expires_at")
 | 
			
		||||
    autocomplete_fields = ("user", "ban_group")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Permission)
 | 
			
		||||
class PermissionAdmin(admin.ModelAdmin):
 | 
			
		||||
    search_fields = ("codename",)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Page)
 | 
			
		||||
class PageAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ("name", "_full_name", "owner_group")
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										42
									
								
								core/auth_backends.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								core/auth_backends.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from typing import TYPE_CHECKING
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.backends import ModelBackend
 | 
			
		||||
from django.contrib.auth.models import Permission
 | 
			
		||||
 | 
			
		||||
from core.models import Group
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from core.models import User
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SithModelBackend(ModelBackend):
 | 
			
		||||
    """Custom auth backend for the Sith.
 | 
			
		||||
 | 
			
		||||
    In fact, it's the exact same backend as `django.contrib.auth.backend.ModelBackend`,
 | 
			
		||||
    with the exception that group permissions are fetched slightly differently.
 | 
			
		||||
    Indeed, django tries by default to fetch the permissions associated
 | 
			
		||||
    with all the `django.contrib.auth.models.Group` of a user ;
 | 
			
		||||
    however, our User model overrides that, so the actual linked group model
 | 
			
		||||
    is [core.models.Group][].
 | 
			
		||||
    Instead of having the relation `auth_perm --> auth_group <-- core_user`,
 | 
			
		||||
    we have `auth_perm --> auth_group <-- core_group <-- core_user`.
 | 
			
		||||
 | 
			
		||||
    Thus, this backend make the small tweaks necessary to make
 | 
			
		||||
    our custom models interact with the django auth.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def _get_group_permissions(self, user_obj: User):
 | 
			
		||||
        # union of querysets doesn't work if the queryset is ordered.
 | 
			
		||||
        # The empty `order_by` here are actually there to *remove*
 | 
			
		||||
        # any default ordering defined in managers or model Meta
 | 
			
		||||
        groups = user_obj.groups.order_by()
 | 
			
		||||
        if user_obj.is_subscribed:
 | 
			
		||||
            groups = groups.union(
 | 
			
		||||
                Group.objects.filter(pk=settings.SITH_GROUP_SUBSCRIBERS_ID).order_by()
 | 
			
		||||
            )
 | 
			
		||||
        return Permission.objects.filter(
 | 
			
		||||
            group__group__in=groups.values_list("pk", flat=True)
 | 
			
		||||
        )
 | 
			
		||||
@@ -7,7 +7,7 @@ from model_bakery import seq
 | 
			
		||||
from model_bakery.recipe import Recipe, related
 | 
			
		||||
 | 
			
		||||
from club.models import Membership
 | 
			
		||||
from core.models import User
 | 
			
		||||
from core.models import Group, User
 | 
			
		||||
from subscription.models import Subscription
 | 
			
		||||
 | 
			
		||||
active_subscription = Recipe(
 | 
			
		||||
@@ -60,5 +60,6 @@ board_user = Recipe(
 | 
			
		||||
    first_name="AE",
 | 
			
		||||
    last_name=seq("member "),
 | 
			
		||||
    memberships=related(ae_board_membership),
 | 
			
		||||
    groups=lambda: [Group.objects.get(club_board=settings.SITH_MAIN_CLUB_ID)],
 | 
			
		||||
)
 | 
			
		||||
"""A user which is in the board of the AE."""
 | 
			
		||||
 
 | 
			
		||||
@@ -13,12 +13,42 @@
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
import hashlib
 | 
			
		||||
import multiprocessing
 | 
			
		||||
import os
 | 
			
		||||
import platform
 | 
			
		||||
import shutil
 | 
			
		||||
import subprocess
 | 
			
		||||
import sys
 | 
			
		||||
import tarfile
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from typing import Self
 | 
			
		||||
 | 
			
		||||
import tomli
 | 
			
		||||
from django.core.management.base import BaseCommand, CommandParser
 | 
			
		||||
import urllib3
 | 
			
		||||
from django.core.management.base import BaseCommand, CommandParser, OutputWrapper
 | 
			
		||||
from urllib3.response import HTTPException
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class XapianSpec:
 | 
			
		||||
    version: str
 | 
			
		||||
    core_sha1: str
 | 
			
		||||
    bindings_sha1: str
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def from_pyproject(cls) -> Self:
 | 
			
		||||
        with open(
 | 
			
		||||
            Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
 | 
			
		||||
        ) as f:
 | 
			
		||||
            pyproject = tomli.load(f)
 | 
			
		||||
            spec = pyproject["tool"]["xapian"]
 | 
			
		||||
            return cls(
 | 
			
		||||
                version=spec["version"],
 | 
			
		||||
                core_sha1=spec["core-sha1"],
 | 
			
		||||
                bindings_sha1=spec["bindings-sha1"],
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Command(BaseCommand):
 | 
			
		||||
@@ -39,13 +69,6 @@ class Command(BaseCommand):
 | 
			
		||||
            return None
 | 
			
		||||
        return xapian.version_string()
 | 
			
		||||
 | 
			
		||||
    def _desired_version(self) -> str:
 | 
			
		||||
        with open(
 | 
			
		||||
            Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
 | 
			
		||||
        ) as f:
 | 
			
		||||
            pyproject = tomli.load(f)
 | 
			
		||||
            return pyproject["tool"]["xapian"]["version"]
 | 
			
		||||
 | 
			
		||||
    def handle(self, *args, force: bool, **options):
 | 
			
		||||
        if not os.environ.get("VIRTUAL_ENV", None):
 | 
			
		||||
            self.stdout.write(
 | 
			
		||||
@@ -53,20 +76,185 @@ class Command(BaseCommand):
 | 
			
		||||
            )
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        desired = self._desired_version()
 | 
			
		||||
        if desired == self._current_version():
 | 
			
		||||
        desired = XapianSpec.from_pyproject()
 | 
			
		||||
        if desired.version == self._current_version():
 | 
			
		||||
            if not force:
 | 
			
		||||
                self.stdout.write(
 | 
			
		||||
                    f"Version {desired} is already installed, use --force to re-install"
 | 
			
		||||
                    f"Version {desired.version} is already installed, use --force to re-install"
 | 
			
		||||
                )
 | 
			
		||||
                return
 | 
			
		||||
            self.stdout.write(f"Version {desired} is already installed, re-installing")
 | 
			
		||||
        self.stdout.write(
 | 
			
		||||
            f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
 | 
			
		||||
        )
 | 
			
		||||
        subprocess.run(
 | 
			
		||||
            [str(Path(__file__).parent / "install_xapian.sh"), desired],
 | 
			
		||||
            env=dict(os.environ),
 | 
			
		||||
            check=True,
 | 
			
		||||
        )
 | 
			
		||||
            self.stdout.write(
 | 
			
		||||
                f"Version {desired.version} is already installed, re-installing"
 | 
			
		||||
            )
 | 
			
		||||
        XapianInstaller(desired, self.stdout, self.stderr).run()
 | 
			
		||||
        self.stdout.write("Installation success")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class XapianInstaller:
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self,
 | 
			
		||||
        spec: XapianSpec,
 | 
			
		||||
        stdout: OutputWrapper,
 | 
			
		||||
        stderr: OutputWrapper,
 | 
			
		||||
    ):
 | 
			
		||||
        self._version = spec.version
 | 
			
		||||
        self._core_sha1 = spec.core_sha1
 | 
			
		||||
        self._bindings_sha1 = spec.bindings_sha1
 | 
			
		||||
 | 
			
		||||
        self._stdout = stdout
 | 
			
		||||
        self._stderr = stderr
 | 
			
		||||
        self._virtual_env = os.environ.get("VIRTUAL_ENV", None)
 | 
			
		||||
 | 
			
		||||
        if not self._virtual_env:
 | 
			
		||||
            raise RuntimeError("You are not inside a virtual environment")
 | 
			
		||||
        self._virtual_env = Path(self._virtual_env)
 | 
			
		||||
 | 
			
		||||
        self._dest_dir = Path(self._virtual_env) / "packages"
 | 
			
		||||
        self._core = f"xapian-core-{self._version}"
 | 
			
		||||
        self._bindings = f"xapian-bindings-{self._version}"
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def _is_windows(self) -> bool:
 | 
			
		||||
        return platform.system() == "Windows"
 | 
			
		||||
 | 
			
		||||
    def _util_download(self, url: str, dest: Path, sha1_hash: str) -> None:
 | 
			
		||||
        resp = urllib3.request("GET", url)
 | 
			
		||||
        if resp.status != 200:
 | 
			
		||||
            raise HTTPException(f"Could not download {url}")
 | 
			
		||||
        if hashlib.sha1(resp.data).hexdigest() != sha1_hash:
 | 
			
		||||
            raise ValueError(f"File downloaded from {url} is compromised")
 | 
			
		||||
        with open(dest, "wb") as f:
 | 
			
		||||
            f.write(resp.data)
 | 
			
		||||
 | 
			
		||||
    def _setup_env(self):
 | 
			
		||||
        os.environ.update(
 | 
			
		||||
            {
 | 
			
		||||
                "CPATH": "",
 | 
			
		||||
                "LIBRARY_PATH": "",
 | 
			
		||||
                "CFLAGS": "",
 | 
			
		||||
                "LDFLAGS": "",
 | 
			
		||||
                "CCFLAGS": "",
 | 
			
		||||
                "CXXFLAGS": "",
 | 
			
		||||
                "CPPFLAGS": "",
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def _prepare_dest_folder(self):
 | 
			
		||||
        shutil.rmtree(self._dest_dir, ignore_errors=True)
 | 
			
		||||
        self._dest_dir.mkdir(parents=True, exist_ok=True)
 | 
			
		||||
 | 
			
		||||
    def _download(self):
 | 
			
		||||
        self._stdout.write("Downloading source…")
 | 
			
		||||
 | 
			
		||||
        core = self._dest_dir / f"{self._core}.tar.xz"
 | 
			
		||||
        bindings = self._dest_dir / f"{self._bindings}.tar.xz"
 | 
			
		||||
        self._util_download(
 | 
			
		||||
            f"https://oligarchy.co.uk/xapian/{self._version}/{self._core}.tar.xz",
 | 
			
		||||
            core,
 | 
			
		||||
            self._core_sha1,
 | 
			
		||||
        )
 | 
			
		||||
        self._util_download(
 | 
			
		||||
            f"https://oligarchy.co.uk/xapian/{self._version}/{self._bindings}.tar.xz",
 | 
			
		||||
            bindings,
 | 
			
		||||
            self._bindings_sha1,
 | 
			
		||||
        )
 | 
			
		||||
        self._stdout.write("Extracting source …")
 | 
			
		||||
        with tarfile.open(core) as tar:
 | 
			
		||||
            tar.extractall(self._dest_dir)
 | 
			
		||||
        with tarfile.open(bindings) as tar:
 | 
			
		||||
            tar.extractall(self._dest_dir)
 | 
			
		||||
 | 
			
		||||
        os.remove(core)
 | 
			
		||||
        os.remove(bindings)
 | 
			
		||||
 | 
			
		||||
    def _install(self):
 | 
			
		||||
        self._stdout.write("Installing Xapian-core…")
 | 
			
		||||
        def configure() -> list[str]:
 | 
			
		||||
            if self._is_windows:
 | 
			
		||||
                return ["sh", "configure"]
 | 
			
		||||
            return ["./configure"]
 | 
			
		||||
        def enable_static() -> list[str]:
 | 
			
		||||
            if self._is_windows:
 | 
			
		||||
                return ["--enable-shared", "--disable-static"]
 | 
			
		||||
            return []
 | 
			
		||||
 | 
			
		||||
        # Make sure that xapian finds the correct executable
 | 
			
		||||
        os.environ["PYTHON3"] = str(Path(sys.executable).as_posix())
 | 
			
		||||
        
 | 
			
		||||
        subprocess.run(
 | 
			
		||||
            [*configure(), "--prefix", str(self._virtual_env.as_posix()), *enable_static(),],
 | 
			
		||||
            env=dict(os.environ),
 | 
			
		||||
            cwd=self._dest_dir / self._core,
 | 
			
		||||
            check=False,
 | 
			
		||||
            shell=self._is_windows,
 | 
			
		||||
        ).check_returncode()
 | 
			
		||||
        subprocess.run(
 | 
			
		||||
            [
 | 
			
		||||
                "make",
 | 
			
		||||
                "-j",
 | 
			
		||||
                str(multiprocessing.cpu_count()),
 | 
			
		||||
            ],
 | 
			
		||||
            env=dict(os.environ),
 | 
			
		||||
            cwd=self._dest_dir / self._core,
 | 
			
		||||
            check=False,
 | 
			
		||||
            shell=self._is_windows,
 | 
			
		||||
        ).check_returncode()
 | 
			
		||||
        subprocess.run(
 | 
			
		||||
            ["make", "install"],
 | 
			
		||||
            env=dict(os.environ),
 | 
			
		||||
            cwd=self._dest_dir / self._core,
 | 
			
		||||
            check=False,
 | 
			
		||||
            shell=self._is_windows,
 | 
			
		||||
 | 
			
		||||
        ).check_returncode()
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        self._stdout.write("Installing Xapian-bindings")
 | 
			
		||||
        subprocess.run(
 | 
			
		||||
            [
 | 
			
		||||
                *configure(),
 | 
			
		||||
                "--prefix",
 | 
			
		||||
                str(self._virtual_env.as_posix()),
 | 
			
		||||
                "--with-python3",
 | 
			
		||||
                f"XAPIAN_CONFIG={(self._virtual_env / 'bin'/'xapian-config').as_posix()}",
 | 
			
		||||
                *enable_static(),
 | 
			
		||||
            ],
 | 
			
		||||
            env=dict(os.environ),
 | 
			
		||||
            cwd=self._dest_dir / self._bindings,
 | 
			
		||||
            check=False,
 | 
			
		||||
            shell=self._is_windows,
 | 
			
		||||
        ).check_returncode()
 | 
			
		||||
        subprocess.run(
 | 
			
		||||
            [
 | 
			
		||||
                "make",
 | 
			
		||||
                "-j",
 | 
			
		||||
                str(multiprocessing.cpu_count()),
 | 
			
		||||
            ],
 | 
			
		||||
            env=dict(os.environ),
 | 
			
		||||
            cwd=self._dest_dir / self._bindings,
 | 
			
		||||
            check=False,
 | 
			
		||||
            shell=self._is_windows,
 | 
			
		||||
        ).check_returncode()
 | 
			
		||||
        subprocess.run(
 | 
			
		||||
            ["make", "install"],
 | 
			
		||||
            env=dict(os.environ),
 | 
			
		||||
            cwd=self._dest_dir / self._bindings,
 | 
			
		||||
            check=False,
 | 
			
		||||
            shell=self._is_windows,
 | 
			
		||||
        ).check_returncode()
 | 
			
		||||
 | 
			
		||||
    def _post_clean(self):
 | 
			
		||||
        shutil.rmtree(self._dest_dir, ignore_errors=True)
 | 
			
		||||
 | 
			
		||||
    def _test(self):
 | 
			
		||||
        subprocess.run(
 | 
			
		||||
            [sys.executable, "-c", "import xapian"], check=False, shell=self._is_windows,
 | 
			
		||||
        ).check_returncode()
 | 
			
		||||
 | 
			
		||||
    def run(self):
 | 
			
		||||
        self._setup_env()
 | 
			
		||||
        self._prepare_dest_folder()
 | 
			
		||||
        self._download()
 | 
			
		||||
        self._install()
 | 
			
		||||
        self._post_clean()
 | 
			
		||||
        self._test()
 | 
			
		||||
 
 | 
			
		||||
@@ -1,47 +0,0 @@
 | 
			
		||||
#!/usr/bin/env bash
 | 
			
		||||
# Originates from https://gist.github.com/jorgecarleitao/ab6246c86c936b9c55fd
 | 
			
		||||
# first argument of the script is Xapian version (e.g. 1.2.19)
 | 
			
		||||
VERSION="$1"
 | 
			
		||||
 | 
			
		||||
# Cleanup env vars for auto discovery mechanism
 | 
			
		||||
export CPATH=
 | 
			
		||||
export LIBRARY_PATH=
 | 
			
		||||
export CFLAGS=
 | 
			
		||||
export LDFLAGS=
 | 
			
		||||
export CCFLAGS=
 | 
			
		||||
export CXXFLAGS=
 | 
			
		||||
export CPPFLAGS=
 | 
			
		||||
 | 
			
		||||
# prepare
 | 
			
		||||
rm -rf "$VIRTUAL_ENV/packages"
 | 
			
		||||
mkdir -p "$VIRTUAL_ENV/packages" && cd "$VIRTUAL_ENV/packages" || exit 1
 | 
			
		||||
 | 
			
		||||
CORE=xapian-core-$VERSION
 | 
			
		||||
BINDINGS=xapian-bindings-$VERSION
 | 
			
		||||
 | 
			
		||||
# download
 | 
			
		||||
echo "Downloading source..."
 | 
			
		||||
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz"
 | 
			
		||||
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${BINDINGS}.tar.xz"
 | 
			
		||||
 | 
			
		||||
# extract
 | 
			
		||||
echo "Extracting source..."
 | 
			
		||||
tar xf "${CORE}.tar.xz"
 | 
			
		||||
tar xf "${BINDINGS}.tar.xz"
 | 
			
		||||
 | 
			
		||||
# install
 | 
			
		||||
echo "Installing Xapian-core..."
 | 
			
		||||
cd "$VIRTUAL_ENV/packages/${CORE}" || exit 1
 | 
			
		||||
./configure --prefix="$VIRTUAL_ENV" && make -j"$(nproc)" && make install
 | 
			
		||||
 | 
			
		||||
PYTHON_FLAG=--with-python3
 | 
			
		||||
 | 
			
		||||
echo "Installing Xapian-bindings..."
 | 
			
		||||
cd "$VIRTUAL_ENV/packages/${BINDINGS}" || exit 1
 | 
			
		||||
./configure --prefix="$VIRTUAL_ENV" $PYTHON_FLAG XAPIAN_CONFIG="$VIRTUAL_ENV/bin/xapian-config" && make -j"$(nproc)" && make install
 | 
			
		||||
 | 
			
		||||
# clean
 | 
			
		||||
rm -rf "$VIRTUAL_ENV/packages"
 | 
			
		||||
 | 
			
		||||
# test
 | 
			
		||||
python -c "import xapian"
 | 
			
		||||
@@ -23,7 +23,7 @@
 | 
			
		||||
from datetime import date, timedelta
 | 
			
		||||
from io import StringIO
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from typing import ClassVar
 | 
			
		||||
from typing import ClassVar, NamedTuple
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.models import Permission
 | 
			
		||||
@@ -31,6 +31,7 @@ from django.contrib.sites.models import Site
 | 
			
		||||
from django.core.management import call_command
 | 
			
		||||
from django.core.management.base import BaseCommand
 | 
			
		||||
from django.db import connection
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.timezone import localdate
 | 
			
		||||
from PIL import Image
 | 
			
		||||
@@ -45,8 +46,9 @@ from accounting.models import (
 | 
			
		||||
    SimplifiedAccountingType,
 | 
			
		||||
)
 | 
			
		||||
from club.models import Club, Membership
 | 
			
		||||
from com.calendar import IcsCalendar
 | 
			
		||||
from com.models import News, NewsDate, Sith, Weekmail
 | 
			
		||||
from core.models import Group, Page, PageRev, RealGroup, SithFile, User
 | 
			
		||||
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
 | 
			
		||||
from core.utils import resize_image
 | 
			
		||||
from counter.models import Counter, Product, ProductType, StudentCard
 | 
			
		||||
from election.models import Candidature, Election, ElectionList, Role
 | 
			
		||||
@@ -56,6 +58,18 @@ from sas.models import Album, PeoplePictureRelation, Picture
 | 
			
		||||
from subscription.models import Subscription
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PopulatedGroups(NamedTuple):
 | 
			
		||||
    root: Group
 | 
			
		||||
    public: Group
 | 
			
		||||
    subscribers: Group
 | 
			
		||||
    old_subscribers: Group
 | 
			
		||||
    sas_admin: Group
 | 
			
		||||
    com_admin: Group
 | 
			
		||||
    counter_admin: Group
 | 
			
		||||
    accounting_admin: Group
 | 
			
		||||
    pedagogy_admin: Group
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Command(BaseCommand):
 | 
			
		||||
    ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
 | 
			
		||||
    SAS_FIXTURE_PATH: ClassVar[Path] = (
 | 
			
		||||
@@ -79,25 +93,8 @@ class Command(BaseCommand):
 | 
			
		||||
 | 
			
		||||
        Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
 | 
			
		||||
        Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
 | 
			
		||||
 | 
			
		||||
        root_group = Group.objects.create(name="Root")
 | 
			
		||||
        public_group = Group.objects.create(name="Public")
 | 
			
		||||
        subscribers = Group.objects.create(name="Subscribers")
 | 
			
		||||
        old_subscribers = Group.objects.create(name="Old subscribers")
 | 
			
		||||
        Group.objects.create(name="Accounting admin")
 | 
			
		||||
        Group.objects.create(name="Communication admin")
 | 
			
		||||
        Group.objects.create(name="Counter admin")
 | 
			
		||||
        Group.objects.create(name="Banned from buying alcohol")
 | 
			
		||||
        Group.objects.create(name="Banned from counters")
 | 
			
		||||
        Group.objects.create(name="Banned to subscribe")
 | 
			
		||||
        Group.objects.create(name="SAS admin")
 | 
			
		||||
        Group.objects.create(name="Forum admin")
 | 
			
		||||
        Group.objects.create(name="Pedagogy admin")
 | 
			
		||||
        self.reset_index("core", "auth")
 | 
			
		||||
 | 
			
		||||
        change_billing = Permission.objects.get(codename="change_billinginfo")
 | 
			
		||||
        add_billing = Permission.objects.get(codename="add_billinginfo")
 | 
			
		||||
        root_group.permissions.add(change_billing, add_billing)
 | 
			
		||||
        groups = self._create_groups()
 | 
			
		||||
        self._create_ban_groups()
 | 
			
		||||
 | 
			
		||||
        root = User.objects.create_superuser(
 | 
			
		||||
            id=0,
 | 
			
		||||
@@ -148,14 +145,16 @@ class Command(BaseCommand):
 | 
			
		||||
        Counter.objects.bulk_create(counters)
 | 
			
		||||
        bar_groups = []
 | 
			
		||||
        for bar_id, bar_name in settings.SITH_COUNTER_BARS:
 | 
			
		||||
            group = RealGroup.objects.create(name=f"{bar_name} admin")
 | 
			
		||||
            group = Group.objects.create(
 | 
			
		||||
                name=f"{bar_name} admin", is_manually_manageable=True
 | 
			
		||||
            )
 | 
			
		||||
            bar_groups.append(
 | 
			
		||||
                Counter.edit_groups.through(counter_id=bar_id, group=group)
 | 
			
		||||
            )
 | 
			
		||||
        Counter.edit_groups.through.objects.bulk_create(bar_groups)
 | 
			
		||||
        self.reset_index("counter")
 | 
			
		||||
 | 
			
		||||
        subscribers.viewable_files.add(home_root, club_root)
 | 
			
		||||
        groups.subscribers.viewable_files.add(home_root, club_root)
 | 
			
		||||
 | 
			
		||||
        Weekmail().save()
 | 
			
		||||
 | 
			
		||||
@@ -260,21 +259,11 @@ class Command(BaseCommand):
 | 
			
		||||
        )
 | 
			
		||||
        User.groups.through.objects.bulk_create(
 | 
			
		||||
            [
 | 
			
		||||
                User.groups.through(
 | 
			
		||||
                    realgroup_id=settings.SITH_GROUP_COUNTER_ADMIN_ID, user=counter
 | 
			
		||||
                ),
 | 
			
		||||
                User.groups.through(
 | 
			
		||||
                    realgroup_id=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID, user=comptable
 | 
			
		||||
                ),
 | 
			
		||||
                User.groups.through(
 | 
			
		||||
                    realgroup_id=settings.SITH_GROUP_COM_ADMIN_ID, user=comunity
 | 
			
		||||
                ),
 | 
			
		||||
                User.groups.through(
 | 
			
		||||
                    realgroup_id=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID, user=tutu
 | 
			
		||||
                ),
 | 
			
		||||
                User.groups.through(
 | 
			
		||||
                    realgroup_id=settings.SITH_GROUP_SAS_ADMIN_ID, user=skia
 | 
			
		||||
                ),
 | 
			
		||||
                User.groups.through(group=groups.counter_admin, user=counter),
 | 
			
		||||
                User.groups.through(group=groups.accounting_admin, user=comptable),
 | 
			
		||||
                User.groups.through(group=groups.com_admin, user=comunity),
 | 
			
		||||
                User.groups.through(group=groups.pedagogy_admin, user=tutu),
 | 
			
		||||
                User.groups.through(group=groups.sas_admin, user=skia),
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
        for user in richard, sli, krophil, skia:
 | 
			
		||||
@@ -335,7 +324,7 @@ Welcome to the wiki page!
 | 
			
		||||
            content="Fonctionnement de la laverie",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        public_group.viewable_page.set(
 | 
			
		||||
        groups.public.viewable_page.set(
 | 
			
		||||
            [syntax_page, services_page, index_page, laundry_page]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@@ -381,46 +370,42 @@ Welcome to the wiki page!
 | 
			
		||||
            parent=main_club,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        Membership.objects.bulk_create(
 | 
			
		||||
            [
 | 
			
		||||
                Membership(user=skia, club=main_club, role=3),
 | 
			
		||||
                Membership(
 | 
			
		||||
                    user=comunity,
 | 
			
		||||
                    club=bar_club,
 | 
			
		||||
                    start_date=localdate(),
 | 
			
		||||
                    role=settings.SITH_CLUB_ROLES_ID["Board member"],
 | 
			
		||||
                ),
 | 
			
		||||
                Membership(
 | 
			
		||||
                    user=sli,
 | 
			
		||||
                    club=troll,
 | 
			
		||||
                    role=9,
 | 
			
		||||
                    description="Padawan Troll",
 | 
			
		||||
                    start_date=localdate() - timedelta(days=17),
 | 
			
		||||
                ),
 | 
			
		||||
                Membership(
 | 
			
		||||
                    user=krophil,
 | 
			
		||||
                    club=troll,
 | 
			
		||||
                    role=10,
 | 
			
		||||
                    description="Maitre Troll",
 | 
			
		||||
                    start_date=localdate() - timedelta(days=200),
 | 
			
		||||
                ),
 | 
			
		||||
                Membership(
 | 
			
		||||
                    user=skia,
 | 
			
		||||
                    club=troll,
 | 
			
		||||
                    role=2,
 | 
			
		||||
                    description="Grand Ancien Troll",
 | 
			
		||||
                    start_date=localdate() - timedelta(days=400),
 | 
			
		||||
                    end_date=localdate() - timedelta(days=86),
 | 
			
		||||
                ),
 | 
			
		||||
                Membership(
 | 
			
		||||
                    user=richard,
 | 
			
		||||
                    club=troll,
 | 
			
		||||
                    role=2,
 | 
			
		||||
                    description="",
 | 
			
		||||
                    start_date=localdate() - timedelta(days=200),
 | 
			
		||||
                    end_date=localdate() - timedelta(days=100),
 | 
			
		||||
                ),
 | 
			
		||||
            ]
 | 
			
		||||
        Membership.objects.create(user=skia, club=main_club, role=3)
 | 
			
		||||
        Membership.objects.create(
 | 
			
		||||
            user=comunity,
 | 
			
		||||
            club=bar_club,
 | 
			
		||||
            start_date=localdate(),
 | 
			
		||||
            role=settings.SITH_CLUB_ROLES_ID["Board member"],
 | 
			
		||||
        )
 | 
			
		||||
        Membership.objects.create(
 | 
			
		||||
            user=sli,
 | 
			
		||||
            club=troll,
 | 
			
		||||
            role=9,
 | 
			
		||||
            description="Padawan Troll",
 | 
			
		||||
            start_date=localdate() - timedelta(days=17),
 | 
			
		||||
        )
 | 
			
		||||
        Membership.objects.create(
 | 
			
		||||
            user=krophil,
 | 
			
		||||
            club=troll,
 | 
			
		||||
            role=10,
 | 
			
		||||
            description="Maitre Troll",
 | 
			
		||||
            start_date=localdate() - timedelta(days=200),
 | 
			
		||||
        )
 | 
			
		||||
        Membership.objects.create(
 | 
			
		||||
            user=skia,
 | 
			
		||||
            club=troll,
 | 
			
		||||
            role=2,
 | 
			
		||||
            description="Grand Ancien Troll",
 | 
			
		||||
            start_date=localdate() - timedelta(days=400),
 | 
			
		||||
            end_date=localdate() - timedelta(days=86),
 | 
			
		||||
        )
 | 
			
		||||
        Membership.objects.create(
 | 
			
		||||
            user=richard,
 | 
			
		||||
            club=troll,
 | 
			
		||||
            role=2,
 | 
			
		||||
            description="",
 | 
			
		||||
            start_date=localdate() - timedelta(days=200),
 | 
			
		||||
            end_date=localdate() - timedelta(days=100),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        p = ProductType.objects.create(name="Bières bouteilles")
 | 
			
		||||
@@ -512,8 +497,10 @@ Welcome to the wiki page!
 | 
			
		||||
            club=main_club,
 | 
			
		||||
            limit_age=18,
 | 
			
		||||
        )
 | 
			
		||||
        subscribers.products.add(cotis, cotis2, refill, barb, cble, cors, carolus)
 | 
			
		||||
        old_subscribers.products.add(cotis, cotis2)
 | 
			
		||||
        groups.subscribers.products.add(
 | 
			
		||||
            cotis, cotis2, refill, barb, cble, cors, carolus
 | 
			
		||||
        )
 | 
			
		||||
        groups.old_subscribers.products.add(cotis, cotis2)
 | 
			
		||||
 | 
			
		||||
        mde = Counter.objects.get(name="MDE")
 | 
			
		||||
        mde.products.add(barb, cble, cons, dcons)
 | 
			
		||||
@@ -607,7 +594,6 @@ Welcome to the wiki page!
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Create an election
 | 
			
		||||
        ae_board_group = Group.objects.get(name=settings.SITH_MAIN_BOARD_GROUP)
 | 
			
		||||
        el = Election.objects.create(
 | 
			
		||||
            title="Élection 2017",
 | 
			
		||||
            description="La roue tourne",
 | 
			
		||||
@@ -616,10 +602,10 @@ Welcome to the wiki page!
 | 
			
		||||
            start_date="1942-06-12 10:28:45+01",
 | 
			
		||||
            end_date="7942-06-12 10:28:45+01",
 | 
			
		||||
        )
 | 
			
		||||
        el.view_groups.add(public_group)
 | 
			
		||||
        el.edit_groups.add(ae_board_group)
 | 
			
		||||
        el.candidature_groups.add(subscribers)
 | 
			
		||||
        el.vote_groups.add(subscribers)
 | 
			
		||||
        el.view_groups.add(groups.public)
 | 
			
		||||
        el.edit_groups.add(main_club.board_group)
 | 
			
		||||
        el.candidature_groups.add(groups.subscribers)
 | 
			
		||||
        el.vote_groups.add(groups.subscribers)
 | 
			
		||||
        liste = ElectionList.objects.create(title="Candidature Libre", election=el)
 | 
			
		||||
        listeT = ElectionList.objects.create(title="Troll", election=el)
 | 
			
		||||
        pres = Role.objects.create(
 | 
			
		||||
@@ -754,7 +740,7 @@ Welcome to the wiki page!
 | 
			
		||||
            NewsDate(
 | 
			
		||||
                news=n,
 | 
			
		||||
                start_date=friday + timedelta(hours=24 * 7 + 1),
 | 
			
		||||
                end_date=self.now + timedelta(hours=24 * 7 + 9),
 | 
			
		||||
                end_date=friday + timedelta(hours=24 * 7 + 9),
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        # Weekly
 | 
			
		||||
@@ -780,8 +766,9 @@ Welcome to the wiki page!
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
        NewsDate.objects.bulk_create(news_dates)
 | 
			
		||||
        IcsCalendar.make_internal()  # Force refresh of the calendar after a bulk_create
 | 
			
		||||
 | 
			
		||||
        # Create som data for pedagogy
 | 
			
		||||
        # Create some data for pedagogy
 | 
			
		||||
 | 
			
		||||
        UV(
 | 
			
		||||
            code="PA00",
 | 
			
		||||
@@ -898,3 +885,114 @@ Welcome to the wiki page!
 | 
			
		||||
            start=s.subscription_start,
 | 
			
		||||
        )
 | 
			
		||||
        s.save()
 | 
			
		||||
 | 
			
		||||
    def _create_groups(self) -> PopulatedGroups:
 | 
			
		||||
        perms = Permission.objects.all()
 | 
			
		||||
 | 
			
		||||
        root_group = Group.objects.create(name="Root", is_manually_manageable=True)
 | 
			
		||||
        root_group.permissions.add(*list(perms.values_list("pk", flat=True)))
 | 
			
		||||
        # public has no permission.
 | 
			
		||||
        # Its purpose is not to link users to permissions,
 | 
			
		||||
        # but to other objects (like products)
 | 
			
		||||
        public_group = Group.objects.create(name="Public")
 | 
			
		||||
 | 
			
		||||
        subscribers = Group.objects.create(name="Subscribers")
 | 
			
		||||
        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 club.models import Club, Membership
 | 
			
		||||
from core.models import RealGroup, User
 | 
			
		||||
from core.models import Group, User
 | 
			
		||||
from counter.models import (
 | 
			
		||||
    Counter,
 | 
			
		||||
    Customer,
 | 
			
		||||
@@ -173,7 +173,8 @@ class Command(BaseCommand):
 | 
			
		||||
                    club=club,
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
        Membership.objects.bulk_create(memberships)
 | 
			
		||||
        memberships = Membership.objects.bulk_create(memberships)
 | 
			
		||||
        Membership._add_club_groups(memberships)
 | 
			
		||||
 | 
			
		||||
    def create_uvs(self):
 | 
			
		||||
        root = User.objects.get(username="root")
 | 
			
		||||
@@ -225,9 +226,7 @@ class Command(BaseCommand):
 | 
			
		||||
        ae = Club.objects.get(unix_name="ae")
 | 
			
		||||
        other_clubs = random.sample(list(Club.objects.all()), k=3)
 | 
			
		||||
        groups = list(
 | 
			
		||||
            RealGroup.objects.filter(
 | 
			
		||||
                name__in=["Subscribers", "Old subscribers", "Public"]
 | 
			
		||||
            )
 | 
			
		||||
            Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
 | 
			
		||||
        )
 | 
			
		||||
        counters = list(
 | 
			
		||||
            Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])
 | 
			
		||||
 
 | 
			
		||||
@@ -563,14 +563,21 @@ class Migration(migrations.Migration):
 | 
			
		||||
            fields=[],
 | 
			
		||||
            options={"proxy": True},
 | 
			
		||||
            bases=("core.group",),
 | 
			
		||||
            managers=[("objects", core.models.MetaGroupManager())],
 | 
			
		||||
            managers=[("objects", django.contrib.auth.models.GroupManager())],
 | 
			
		||||
        ),
 | 
			
		||||
        # at first, there existed a RealGroupManager and a RealGroupManager,
 | 
			
		||||
        # which have been since been removed.
 | 
			
		||||
        # However, this removal broke the migrations because it caused an ImportError.
 | 
			
		||||
        # Thus, the managers MetaGroupManager (above) and RealGroupManager (below)
 | 
			
		||||
        # have been replaced by the base django GroupManager to fix the import.
 | 
			
		||||
        # As those managers aren't actually used in migrations,
 | 
			
		||||
        # this replacement doesn't break anything.
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name="RealGroup",
 | 
			
		||||
            fields=[],
 | 
			
		||||
            options={"proxy": True},
 | 
			
		||||
            bases=("core.group",),
 | 
			
		||||
            managers=[("objects", core.models.RealGroupManager())],
 | 
			
		||||
            managers=[("objects", django.contrib.auth.models.GroupManager())],
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterUniqueTogether(
 | 
			
		||||
            name="page", unique_together={("name", "parent")}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,82 @@
 | 
			
		||||
# Generated by Django 4.2.16 on 2024-11-20 16:22
 | 
			
		||||
 | 
			
		||||
import django.contrib.auth.validators
 | 
			
		||||
import django.utils.timezone
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("auth", "0012_alter_user_first_name_max_length"),
 | 
			
		||||
        ("core", "0039_alter_user_managers"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterModelOptions(
 | 
			
		||||
            name="user",
 | 
			
		||||
            options={"verbose_name": "user", "verbose_name_plural": "users"},
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="user",
 | 
			
		||||
            name="user_permissions",
 | 
			
		||||
            field=models.ManyToManyField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                help_text="Specific permissions for this user.",
 | 
			
		||||
                related_name="user_set",
 | 
			
		||||
                related_query_name="user",
 | 
			
		||||
                to="auth.permission",
 | 
			
		||||
                verbose_name="user permissions",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="user",
 | 
			
		||||
            name="date_joined",
 | 
			
		||||
            field=models.DateTimeField(
 | 
			
		||||
                default=django.utils.timezone.now, verbose_name="date joined"
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="user",
 | 
			
		||||
            name="is_superuser",
 | 
			
		||||
            field=models.BooleanField(
 | 
			
		||||
                default=False,
 | 
			
		||||
                help_text="Designates that this user has all permissions without explicitly assigning them.",
 | 
			
		||||
                verbose_name="superuser status",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="user",
 | 
			
		||||
            name="username",
 | 
			
		||||
            field=models.CharField(
 | 
			
		||||
                error_messages={"unique": "A user with that username already exists."},
 | 
			
		||||
                help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
 | 
			
		||||
                max_length=150,
 | 
			
		||||
                unique=True,
 | 
			
		||||
                validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
 | 
			
		||||
                verbose_name="username",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="user",
 | 
			
		||||
            name="groups",
 | 
			
		||||
            field=models.ManyToManyField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
 | 
			
		||||
                related_name="user_set",
 | 
			
		||||
                related_query_name="user",
 | 
			
		||||
                to="auth.group",
 | 
			
		||||
                verbose_name="groups",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="user",
 | 
			
		||||
            name="groups",
 | 
			
		||||
            field=models.ManyToManyField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
 | 
			
		||||
                related_name="users",
 | 
			
		||||
                to="core.group",
 | 
			
		||||
                verbose_name="groups",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,37 @@
 | 
			
		||||
# Generated by Django 4.2.16 on 2024-11-30 13:16
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("core", "0040_alter_user_options_user_user_permissions_and_more"),
 | 
			
		||||
        ("club", "0013_alter_club_board_group_alter_club_members_group_and_more"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.DeleteModel(
 | 
			
		||||
            name="MetaGroup",
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.DeleteModel(
 | 
			
		||||
            name="RealGroup",
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterModelOptions(
 | 
			
		||||
            name="group",
 | 
			
		||||
            options={},
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name="group",
 | 
			
		||||
            old_name="is_meta",
 | 
			
		||||
            new_name="is_manually_manageable",
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="group",
 | 
			
		||||
            name="is_manually_manageable",
 | 
			
		||||
            field=models.BooleanField(
 | 
			
		||||
                default=False,
 | 
			
		||||
                help_text="If False, this shouldn't be shown on group management pages",
 | 
			
		||||
                verbose_name="Is manually manageable",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,27 @@
 | 
			
		||||
# Generated by Django 4.2.17 on 2025-01-04 16:42
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
from django.db.migrations.state import StateApps
 | 
			
		||||
from django.db.models import F
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def invert_is_manually_manageable(apps: StateApps, schema_editor):
 | 
			
		||||
    """Invert `is_manually_manageable`.
 | 
			
		||||
 | 
			
		||||
    This field is a renaming of `is_meta`.
 | 
			
		||||
    However, the meaning has been inverted : the groups
 | 
			
		||||
    which were meta are not manually manageable and vice versa.
 | 
			
		||||
    Thus, the value must be inverted.
 | 
			
		||||
    """
 | 
			
		||||
    Group = apps.get_model("core", "Group")
 | 
			
		||||
    Group.objects.all().update(is_manually_manageable=~F("is_manually_manageable"))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [("core", "0041_delete_metagroup_alter_group_options_and_more")]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RunPython(
 | 
			
		||||
            invert_is_manually_manageable, reverse_code=invert_is_manually_manageable
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,164 @@
 | 
			
		||||
# Generated by Django 4.2.17 on 2024-12-31 13:30
 | 
			
		||||
 | 
			
		||||
import django.contrib.auth.models
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
from django.db.migrations.state import StateApps
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def migrate_ban_groups(apps: StateApps, schema_editor):
 | 
			
		||||
    Group = apps.get_model("core", "Group")
 | 
			
		||||
    BanGroup = apps.get_model("core", "BanGroup")
 | 
			
		||||
    ban_group_ids = [
 | 
			
		||||
        settings.SITH_GROUP_BANNED_ALCOHOL_ID,
 | 
			
		||||
        settings.SITH_GROUP_BANNED_COUNTER_ID,
 | 
			
		||||
        settings.SITH_GROUP_BANNED_SUBSCRIPTION_ID,
 | 
			
		||||
    ]
 | 
			
		||||
    # this is a N+1 Queries, but the prod database has a grand total of 3 ban groups
 | 
			
		||||
    for group in Group.objects.filter(id__in=ban_group_ids):
 | 
			
		||||
        # auth_group, which both Group and BanGroup inherit,
 | 
			
		||||
        # is unique by name.
 | 
			
		||||
        # If we tried give the exact same name to the migrated BanGroup
 | 
			
		||||
        # before deleting the corresponding Group,
 | 
			
		||||
        # we would have an IntegrityError.
 | 
			
		||||
        # So we append a space to the name, in order to create a name
 | 
			
		||||
        # that will look the same, but that isn't really the same.
 | 
			
		||||
        ban_group = BanGroup.objects.create(
 | 
			
		||||
            name=f"{group.name} ",
 | 
			
		||||
            description=group.description,
 | 
			
		||||
        )
 | 
			
		||||
        perms = list(group.permissions.values_list("id", flat=True))
 | 
			
		||||
        if perms:
 | 
			
		||||
            ban_group.permissions.add(*perms)
 | 
			
		||||
        ban_group.users.add(
 | 
			
		||||
            *group.users.values_list("id", flat=True), through_defaults={"reason": ""}
 | 
			
		||||
        )
 | 
			
		||||
        group.delete()
 | 
			
		||||
        # now that the original group is no longer there,
 | 
			
		||||
        # we can remove the appended space
 | 
			
		||||
        ban_group.name = ban_group.name.strip()
 | 
			
		||||
        ban_group.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("auth", "0012_alter_user_first_name_max_length"),
 | 
			
		||||
        ("core", "0042_invert_is_manually_manageable_20250104_1742"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name="BanGroup",
 | 
			
		||||
            fields=[
 | 
			
		||||
                (
 | 
			
		||||
                    "group_ptr",
 | 
			
		||||
                    models.OneToOneField(
 | 
			
		||||
                        auto_created=True,
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                        parent_link=True,
 | 
			
		||||
                        primary_key=True,
 | 
			
		||||
                        serialize=False,
 | 
			
		||||
                        to="auth.group",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                ("description", models.TextField(verbose_name="description")),
 | 
			
		||||
            ],
 | 
			
		||||
            bases=("auth.group",),
 | 
			
		||||
            managers=[
 | 
			
		||||
                ("objects", django.contrib.auth.models.GroupManager()),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                "verbose_name": "ban group",
 | 
			
		||||
                "verbose_name_plural": "ban groups",
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="group",
 | 
			
		||||
            name="description",
 | 
			
		||||
            field=models.TextField(verbose_name="description"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="user",
 | 
			
		||||
            name="groups",
 | 
			
		||||
            field=models.ManyToManyField(
 | 
			
		||||
                help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
 | 
			
		||||
                related_name="users",
 | 
			
		||||
                to="core.group",
 | 
			
		||||
                verbose_name="groups",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name="UserBan",
 | 
			
		||||
            fields=[
 | 
			
		||||
                (
 | 
			
		||||
                    "id",
 | 
			
		||||
                    models.AutoField(
 | 
			
		||||
                        auto_created=True,
 | 
			
		||||
                        primary_key=True,
 | 
			
		||||
                        serialize=False,
 | 
			
		||||
                        verbose_name="ID",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "created_at",
 | 
			
		||||
                    models.DateTimeField(auto_now_add=True, verbose_name="created at"),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "expires_at",
 | 
			
		||||
                    models.DateTimeField(
 | 
			
		||||
                        blank=True,
 | 
			
		||||
                        help_text="When the ban should be removed. Currently, there is no automatic removal, so this is purely indicative. Automatic ban removal may be implemented later on.",
 | 
			
		||||
                        null=True,
 | 
			
		||||
                        verbose_name="expires at",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                ("reason", models.TextField(verbose_name="reason")),
 | 
			
		||||
                (
 | 
			
		||||
                    "ban_group",
 | 
			
		||||
                    models.ForeignKey(
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                        related_name="user_bans",
 | 
			
		||||
                        to="core.bangroup",
 | 
			
		||||
                        verbose_name="ban type",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "user",
 | 
			
		||||
                    models.ForeignKey(
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                        related_name="bans",
 | 
			
		||||
                        to=settings.AUTH_USER_MODEL,
 | 
			
		||||
                        verbose_name="user",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="user",
 | 
			
		||||
            name="ban_groups",
 | 
			
		||||
            field=models.ManyToManyField(
 | 
			
		||||
                help_text="The bans this user has received.",
 | 
			
		||||
                related_name="users",
 | 
			
		||||
                through="core.UserBan",
 | 
			
		||||
                to="core.bangroup",
 | 
			
		||||
                verbose_name="ban groups",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddConstraint(
 | 
			
		||||
            model_name="userban",
 | 
			
		||||
            constraint=models.UniqueConstraint(
 | 
			
		||||
                fields=("ban_group", "user"), name="unique_ban_type_per_user"
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddConstraint(
 | 
			
		||||
            model_name="userban",
 | 
			
		||||
            constraint=models.CheckConstraint(
 | 
			
		||||
                check=models.Q(("expires_at__gte", models.F("created_at"))),
 | 
			
		||||
                name="user_ban_end_after_start",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(
 | 
			
		||||
            migrate_ban_groups, reverse_code=migrations.RunPython.noop, elidable=True
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										310
									
								
								core/models.py
									
									
									
									
									
								
							
							
						
						
									
										310
									
								
								core/models.py
									
									
									
									
									
								
							@@ -30,26 +30,19 @@ import string
 | 
			
		||||
import unicodedata
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from typing import TYPE_CHECKING, Any, Optional, Self
 | 
			
		||||
from typing import TYPE_CHECKING, Optional, Self
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.models import AbstractBaseUser, UserManager
 | 
			
		||||
from django.contrib.auth.models import (
 | 
			
		||||
    AnonymousUser as AuthAnonymousUser,
 | 
			
		||||
)
 | 
			
		||||
from django.contrib.auth.models import (
 | 
			
		||||
    Group as AuthGroup,
 | 
			
		||||
)
 | 
			
		||||
from django.contrib.auth.models import (
 | 
			
		||||
    GroupManager as AuthGroupManager,
 | 
			
		||||
)
 | 
			
		||||
from django.contrib.auth.models import AbstractUser, UserManager
 | 
			
		||||
from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
 | 
			
		||||
from django.contrib.auth.models import Group as AuthGroup
 | 
			
		||||
from django.contrib.staticfiles.storage import staticfiles_storage
 | 
			
		||||
from django.core import validators
 | 
			
		||||
from django.core.cache import cache
 | 
			
		||||
from django.core.exceptions import PermissionDenied, ValidationError
 | 
			
		||||
from django.core.mail import send_mail
 | 
			
		||||
from django.db import models, transaction
 | 
			
		||||
from django.db.models import Exists, OuterRef, Q
 | 
			
		||||
from django.db.models import Exists, F, OuterRef, Q
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.functional import cached_property
 | 
			
		||||
@@ -64,33 +57,15 @@ if TYPE_CHECKING:
 | 
			
		||||
    from club.models import Club
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RealGroupManager(AuthGroupManager):
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return super().get_queryset().filter(is_meta=False)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MetaGroupManager(AuthGroupManager):
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return super().get_queryset().filter(is_meta=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Group(AuthGroup):
 | 
			
		||||
    """Implement both RealGroups and Meta groups.
 | 
			
		||||
    """Wrapper around django.auth.Group"""
 | 
			
		||||
 | 
			
		||||
    Groups are sorted by their is_meta property
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    #: If False, this is a RealGroup
 | 
			
		||||
    is_meta = models.BooleanField(
 | 
			
		||||
        _("meta group status"),
 | 
			
		||||
    is_manually_manageable = models.BooleanField(
 | 
			
		||||
        _("Is manually manageable"),
 | 
			
		||||
        default=False,
 | 
			
		||||
        help_text=_("Whether a group is a meta group or not"),
 | 
			
		||||
        help_text=_("If False, this shouldn't be shown on group management pages"),
 | 
			
		||||
    )
 | 
			
		||||
    #: Description of the group
 | 
			
		||||
    description = models.CharField(_("description"), max_length=60)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        ordering = ["name"]
 | 
			
		||||
    description = models.TextField(_("description"))
 | 
			
		||||
 | 
			
		||||
    def get_absolute_url(self) -> str:
 | 
			
		||||
        return reverse("core:group_list")
 | 
			
		||||
@@ -106,65 +81,6 @@ class Group(AuthGroup):
 | 
			
		||||
        cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MetaGroup(Group):
 | 
			
		||||
    """MetaGroups are dynamically created groups.
 | 
			
		||||
 | 
			
		||||
    Generally used with clubs where creating a club creates two groups:
 | 
			
		||||
 | 
			
		||||
    * club-SITH_BOARD_SUFFIX
 | 
			
		||||
    * club-SITH_MEMBER_SUFFIX
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    #: Assign a manager in a way that MetaGroup.objects only return groups with is_meta=False
 | 
			
		||||
    objects = MetaGroupManager()
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        proxy = True
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.is_meta = True
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def associated_club(self) -> Club | None:
 | 
			
		||||
        """Return the group associated with this meta group.
 | 
			
		||||
 | 
			
		||||
        The result of this function is cached
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        Returns:
 | 
			
		||||
            The associated club if it exists, else None
 | 
			
		||||
        """
 | 
			
		||||
        from club.models import Club
 | 
			
		||||
 | 
			
		||||
        if self.name.endswith(settings.SITH_BOARD_SUFFIX):
 | 
			
		||||
            # replace this with str.removesuffix as soon as Python
 | 
			
		||||
            # is upgraded to 3.10
 | 
			
		||||
            club_name = self.name[: -len(settings.SITH_BOARD_SUFFIX)]
 | 
			
		||||
        elif self.name.endswith(settings.SITH_MEMBER_SUFFIX):
 | 
			
		||||
            club_name = self.name[: -len(settings.SITH_MEMBER_SUFFIX)]
 | 
			
		||||
        else:
 | 
			
		||||
            return None
 | 
			
		||||
        club = cache.get(f"sith_club_{club_name}")
 | 
			
		||||
        if club is None:
 | 
			
		||||
            club = Club.objects.filter(unix_name=club_name).first()
 | 
			
		||||
            cache.set(f"sith_club_{club_name}", club)
 | 
			
		||||
        return club
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RealGroup(Group):
 | 
			
		||||
    """RealGroups are created by the developer.
 | 
			
		||||
 | 
			
		||||
    Most of the time they match a number in settings to be easily used for permissions.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    #: Assign a manager in a way that MetaGroup.objects only return groups with is_meta=True
 | 
			
		||||
    objects = RealGroupManager()
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        proxy = True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def validate_promo(value: int) -> None:
 | 
			
		||||
    start_year = settings.SITH_SCHOOL_START_YEAR
 | 
			
		||||
    delta = (localdate() + timedelta(days=180)).year - start_year
 | 
			
		||||
@@ -210,13 +126,35 @@ def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None
 | 
			
		||||
    else:
 | 
			
		||||
        group = Group.objects.filter(name=name).first()
 | 
			
		||||
    if group is not None:
 | 
			
		||||
        cache.set(f"sith_group_{group.id}", group)
 | 
			
		||||
        cache.set(f"sith_group_{group.name.replace(' ', '_')}", group)
 | 
			
		||||
        name = group.name.replace(" ", "_")
 | 
			
		||||
        cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group})
 | 
			
		||||
    else:
 | 
			
		||||
        cache.set(f"sith_group_{pk_or_name}", "not_found")
 | 
			
		||||
    return group
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BanGroup(AuthGroup):
 | 
			
		||||
    """An anti-group, that removes permissions instead of giving them.
 | 
			
		||||
 | 
			
		||||
    Users are linked to BanGroups through UserBan objects.
 | 
			
		||||
 | 
			
		||||
    Example:
 | 
			
		||||
        ```python
 | 
			
		||||
        user = User.objects.get(username="...")
 | 
			
		||||
        ban_group = BanGroup.objects.first()
 | 
			
		||||
        UserBan.objects.create(user=user, ban_group=ban_group, reason="...")
 | 
			
		||||
 | 
			
		||||
        assert user.ban_groups.contains(ban_group)
 | 
			
		||||
        ```
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    description = models.TextField(_("description"))
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("ban group")
 | 
			
		||||
        verbose_name_plural = _("ban groups")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserQuerySet(models.QuerySet):
 | 
			
		||||
    def filter_inactive(self) -> Self:
 | 
			
		||||
        from counter.models import Refilling, Selling
 | 
			
		||||
@@ -242,7 +180,7 @@ class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class User(AbstractBaseUser):
 | 
			
		||||
class User(AbstractUser):
 | 
			
		||||
    """Defines the base user class, useable in every app.
 | 
			
		||||
 | 
			
		||||
    This is almost the same as the auth module AbstractUser since it inherits from it,
 | 
			
		||||
@@ -253,51 +191,28 @@ class User(AbstractBaseUser):
 | 
			
		||||
    Required fields: email, first_name, last_name, date_of_birth
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    username = models.CharField(
 | 
			
		||||
        _("username"),
 | 
			
		||||
        max_length=254,
 | 
			
		||||
        unique=True,
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            "Required. 254 characters or fewer. Letters, digits and ./+/-/_ only."
 | 
			
		||||
        ),
 | 
			
		||||
        validators=[
 | 
			
		||||
            validators.RegexValidator(
 | 
			
		||||
                r"^[\w.+-]+$",
 | 
			
		||||
                _(
 | 
			
		||||
                    "Enter a valid username. This value may contain only "
 | 
			
		||||
                    "letters, numbers "
 | 
			
		||||
                    "and ./+/-/_ characters."
 | 
			
		||||
                ),
 | 
			
		||||
            )
 | 
			
		||||
        ],
 | 
			
		||||
        error_messages={"unique": _("A user with that username already exists.")},
 | 
			
		||||
    )
 | 
			
		||||
    first_name = models.CharField(_("first name"), max_length=64)
 | 
			
		||||
    last_name = models.CharField(_("last name"), max_length=64)
 | 
			
		||||
    email = models.EmailField(_("email address"), unique=True)
 | 
			
		||||
    date_of_birth = models.DateField(_("date of birth"), blank=True, null=True)
 | 
			
		||||
    nick_name = models.CharField(_("nick name"), max_length=64, null=True, blank=True)
 | 
			
		||||
    is_staff = models.BooleanField(
 | 
			
		||||
        _("staff status"),
 | 
			
		||||
        default=False,
 | 
			
		||||
        help_text=_("Designates whether the user can log into this admin site."),
 | 
			
		||||
    )
 | 
			
		||||
    is_active = models.BooleanField(
 | 
			
		||||
        _("active"),
 | 
			
		||||
        default=True,
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            "Designates whether this user should be treated as active. "
 | 
			
		||||
            "Unselect this instead of deleting accounts."
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
    date_joined = models.DateField(_("date joined"), auto_now_add=True)
 | 
			
		||||
    last_update = models.DateTimeField(_("last update"), auto_now=True)
 | 
			
		||||
    is_superuser = models.BooleanField(
 | 
			
		||||
        _("superuser"),
 | 
			
		||||
        default=False,
 | 
			
		||||
        help_text=_("Designates whether this user is a superuser. "),
 | 
			
		||||
    groups = models.ManyToManyField(
 | 
			
		||||
        Group,
 | 
			
		||||
        verbose_name=_("groups"),
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            "The groups this user belongs to. A user will get all permissions "
 | 
			
		||||
            "granted to each of their groups."
 | 
			
		||||
        ),
 | 
			
		||||
        related_name="users",
 | 
			
		||||
    )
 | 
			
		||||
    ban_groups = models.ManyToManyField(
 | 
			
		||||
        BanGroup,
 | 
			
		||||
        verbose_name=_("ban groups"),
 | 
			
		||||
        through="UserBan",
 | 
			
		||||
        help_text=_("The bans this user has received."),
 | 
			
		||||
        related_name="users",
 | 
			
		||||
    )
 | 
			
		||||
    groups = models.ManyToManyField(RealGroup, related_name="users", blank=True)
 | 
			
		||||
    home = models.OneToOneField(
 | 
			
		||||
        "SithFile",
 | 
			
		||||
        related_name="home_of",
 | 
			
		||||
@@ -401,8 +316,6 @@ class User(AbstractBaseUser):
 | 
			
		||||
 | 
			
		||||
    objects = CustomUserManager()
 | 
			
		||||
 | 
			
		||||
    USERNAME_FIELD = "username"
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.get_display_name()
 | 
			
		||||
 | 
			
		||||
@@ -422,22 +335,23 @@ class User(AbstractBaseUser):
 | 
			
		||||
            settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
 | 
			
		||||
        ).exists()
 | 
			
		||||
 | 
			
		||||
    def has_module_perms(self, package_name: str) -> bool:
 | 
			
		||||
        return self.is_active
 | 
			
		||||
 | 
			
		||||
    def has_perm(self, perm: str, obj: Any = None) -> bool:
 | 
			
		||||
        return self.is_active and self.is_superuser
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def was_subscribed(self) -> bool:
 | 
			
		||||
        if "is_subscribed" in self.__dict__ and self.is_subscribed:
 | 
			
		||||
            # if the user is currently subscribed, he is an old subscriber too
 | 
			
		||||
            # if the property has already been cached, avoid another request
 | 
			
		||||
            return True
 | 
			
		||||
        return self.subscriptions.exists()
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def is_subscribed(self) -> bool:
 | 
			
		||||
        s = self.subscriptions.filter(
 | 
			
		||||
        if "was_subscribed" in self.__dict__ and not self.was_subscribed:
 | 
			
		||||
            # if the user never subscribed, he cannot be a subscriber now.
 | 
			
		||||
            # if the property has already been cached, avoid another request
 | 
			
		||||
            return False
 | 
			
		||||
        return self.subscriptions.filter(
 | 
			
		||||
            subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
 | 
			
		||||
        )
 | 
			
		||||
        return s.exists()
 | 
			
		||||
        ).exists()
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def account_balance(self):
 | 
			
		||||
@@ -474,18 +388,6 @@ class User(AbstractBaseUser):
 | 
			
		||||
            return self.was_subscribed
 | 
			
		||||
        if group.id == settings.SITH_GROUP_ROOT_ID:
 | 
			
		||||
            return self.is_root
 | 
			
		||||
        if group.is_meta:
 | 
			
		||||
            # check if this group is associated with a club
 | 
			
		||||
            group.__class__ = MetaGroup
 | 
			
		||||
            club = group.associated_club
 | 
			
		||||
            if club is None:
 | 
			
		||||
                return False
 | 
			
		||||
            membership = club.get_membership_for(self)
 | 
			
		||||
            if membership is None:
 | 
			
		||||
                return False
 | 
			
		||||
            if group.name.endswith(settings.SITH_MEMBER_SUFFIX):
 | 
			
		||||
                return True
 | 
			
		||||
            return membership.role > settings.SITH_MAXIMUM_FREE_ROLE
 | 
			
		||||
        return group in self.cached_groups
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
@@ -510,12 +412,11 @@ class User(AbstractBaseUser):
 | 
			
		||||
        return any(g.id == root_id for g in self.cached_groups)
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def is_board_member(self):
 | 
			
		||||
        main_club = settings.SITH_MAIN_CLUB["unix_name"]
 | 
			
		||||
        return self.is_in_group(name=main_club + settings.SITH_BOARD_SUFFIX)
 | 
			
		||||
    def is_board_member(self) -> bool:
 | 
			
		||||
        return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def can_read_subscription_history(self):
 | 
			
		||||
    def can_read_subscription_history(self) -> bool:
 | 
			
		||||
        if self.is_root or self.is_board_member:
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
@@ -530,10 +431,8 @@ class User(AbstractBaseUser):
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def can_create_subscription(self) -> bool:
 | 
			
		||||
        from club.models import Membership
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
            Membership.objects.board()
 | 
			
		||||
        return self.is_root or (
 | 
			
		||||
            self.memberships.board()
 | 
			
		||||
            .ongoing()
 | 
			
		||||
            .filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
 | 
			
		||||
            .exists()
 | 
			
		||||
@@ -552,12 +451,12 @@ class User(AbstractBaseUser):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def is_banned_alcohol(self):
 | 
			
		||||
        return self.is_in_group(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
 | 
			
		||||
    def is_banned_alcohol(self) -> bool:
 | 
			
		||||
        return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def is_banned_counter(self):
 | 
			
		||||
        return self.is_in_group(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
 | 
			
		||||
    def is_banned_counter(self) -> bool:
 | 
			
		||||
        return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_COUNTER_ID).exists()
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def age(self) -> int:
 | 
			
		||||
@@ -601,11 +500,6 @@ class User(AbstractBaseUser):
 | 
			
		||||
            "date_of_birth": self.date_of_birth,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def get_full_name(self):
 | 
			
		||||
        """Returns the first_name plus the last_name, with a space in between."""
 | 
			
		||||
        full_name = "%s %s" % (self.first_name, self.last_name)
 | 
			
		||||
        return full_name.strip()
 | 
			
		||||
 | 
			
		||||
    def get_short_name(self):
 | 
			
		||||
        """Returns the short name for the user."""
 | 
			
		||||
        if self.nick_name:
 | 
			
		||||
@@ -621,14 +515,6 @@ class User(AbstractBaseUser):
 | 
			
		||||
            return "%s (%s)" % (self.get_full_name(), self.nick_name)
 | 
			
		||||
        return self.get_full_name()
 | 
			
		||||
 | 
			
		||||
    def get_age(self):
 | 
			
		||||
        """Returns the age."""
 | 
			
		||||
        today = timezone.now()
 | 
			
		||||
        born = self.date_of_birth
 | 
			
		||||
        return (
 | 
			
		||||
            today.year - born.year - ((today.month, today.day) < (born.month, born.day))
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def get_family(
 | 
			
		||||
        self,
 | 
			
		||||
        godfathers_depth: NonNegativeInt = 4,
 | 
			
		||||
@@ -872,6 +758,52 @@ class AnonymousUser(AuthAnonymousUser):
 | 
			
		||||
        return _("Visitor")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserBan(models.Model):
 | 
			
		||||
    """A ban of a user.
 | 
			
		||||
 | 
			
		||||
    A user can be banned for a specific reason, for a specific duration.
 | 
			
		||||
    The expiration date is indicative, and the ban should be removed manually.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    ban_group = models.ForeignKey(
 | 
			
		||||
        BanGroup,
 | 
			
		||||
        verbose_name=_("ban type"),
 | 
			
		||||
        related_name="user_bans",
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
    user = models.ForeignKey(
 | 
			
		||||
        User, verbose_name=_("user"), related_name="bans", on_delete=models.CASCADE
 | 
			
		||||
    )
 | 
			
		||||
    created_at = models.DateTimeField(_("created at"), auto_now_add=True)
 | 
			
		||||
    expires_at = models.DateTimeField(
 | 
			
		||||
        _("expires at"),
 | 
			
		||||
        null=True,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            "When the ban should be removed. "
 | 
			
		||||
            "Currently, there is no automatic removal, so this is purely indicative. "
 | 
			
		||||
            "Automatic ban removal may be implemented later on."
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
    reason = models.TextField(_("reason"))
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("user ban")
 | 
			
		||||
        verbose_name_plural = _("user bans")
 | 
			
		||||
        constraints = [
 | 
			
		||||
            models.UniqueConstraint(
 | 
			
		||||
                fields=["ban_group", "user"], name="unique_ban_type_per_user"
 | 
			
		||||
            ),
 | 
			
		||||
            models.CheckConstraint(
 | 
			
		||||
                check=Q(expires_at__gte=F("created_at")),
 | 
			
		||||
                name="user_ban_end_after_start",
 | 
			
		||||
            ),
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return f"Ban of user {self.user.id}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Preferences(models.Model):
 | 
			
		||||
    user = models.OneToOneField(
 | 
			
		||||
        User, related_name="_preferences", on_delete=models.CASCADE
 | 
			
		||||
@@ -984,19 +916,17 @@ class SithFile(models.Model):
 | 
			
		||||
        if copy_rights:
 | 
			
		||||
            self.copy_rights()
 | 
			
		||||
        if self.is_in_sas:
 | 
			
		||||
            for u in (
 | 
			
		||||
                RealGroup.objects.filter(id=settings.SITH_GROUP_SAS_ADMIN_ID)
 | 
			
		||||
                .first()
 | 
			
		||||
                .users.all()
 | 
			
		||||
            for user in User.objects.filter(
 | 
			
		||||
                groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
 | 
			
		||||
            ):
 | 
			
		||||
                Notification(
 | 
			
		||||
                    user=u,
 | 
			
		||||
                    user=user,
 | 
			
		||||
                    url=reverse("sas:moderation"),
 | 
			
		||||
                    type="SAS_MODERATION",
 | 
			
		||||
                    param="1",
 | 
			
		||||
                ).save()
 | 
			
		||||
 | 
			
		||||
    def is_owned_by(self, user):
 | 
			
		||||
    def is_owned_by(self, user: User) -> bool:
 | 
			
		||||
        if user.is_anonymous:
 | 
			
		||||
            return False
 | 
			
		||||
        if user.is_root:
 | 
			
		||||
@@ -1011,7 +941,7 @@ class SithFile(models.Model):
 | 
			
		||||
            return True
 | 
			
		||||
        return user.id == self.owner_id
 | 
			
		||||
 | 
			
		||||
    def can_be_viewed_by(self, user):
 | 
			
		||||
    def can_be_viewed_by(self, user: User) -> bool:
 | 
			
		||||
        if hasattr(self, "profile_of"):
 | 
			
		||||
            return user.can_view(self.profile_of)
 | 
			
		||||
        if hasattr(self, "avatar_of"):
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,7 @@
 | 
			
		||||
import sort from "@alpinejs/sort";
 | 
			
		||||
import Alpine from "alpinejs";
 | 
			
		||||
 | 
			
		||||
Alpine.plugin(sort);
 | 
			
		||||
window.Alpine = Alpine;
 | 
			
		||||
 | 
			
		||||
window.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
 
 | 
			
		||||
@@ -67,6 +67,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
 | 
			
		||||
        remove_button: {
 | 
			
		||||
          title: gettext("Remove"),
 | 
			
		||||
        },
 | 
			
		||||
        // biome-ignore lint/style/useNamingConvention: this is required by the api
 | 
			
		||||
        restore_on_backspace: {},
 | 
			
		||||
      },
 | 
			
		||||
      persist: false,
 | 
			
		||||
      maxItems: this.node.multiple ? this.max : 1,
 | 
			
		||||
 
 | 
			
		||||
@@ -22,10 +22,13 @@ type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
 | 
			
		||||
 | 
			
		||||
// TODO : If one day a test workflow is made for JS in this project
 | 
			
		||||
//  please test this function. A all cost.
 | 
			
		||||
/**
 | 
			
		||||
 * Load complete dataset from paginated routes.
 | 
			
		||||
 */
 | 
			
		||||
export const paginated = async <T>(
 | 
			
		||||
  endpoint: PaginatedEndpoint<T>,
 | 
			
		||||
  options?: PaginatedRequest,
 | 
			
		||||
) => {
 | 
			
		||||
): Promise<T[]> => {
 | 
			
		||||
  const maxPerPage = 199;
 | 
			
		||||
  const queryParams = options ?? {};
 | 
			
		||||
  queryParams.query = queryParams.query ?? {};
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										49
									
								
								core/static/bundled/utils/csv.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								core/static/bundled/utils/csv.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,49 @@
 | 
			
		||||
import type { NestedKeyOf } from "#core:utils/types";
 | 
			
		||||
 | 
			
		||||
interface StringifyOptions<T extends object> {
 | 
			
		||||
  /** The columns to include in the resulting CSV. */
 | 
			
		||||
  columns: readonly NestedKeyOf<T>[];
 | 
			
		||||
  /** Content of the first row */
 | 
			
		||||
  titleRow?: readonly string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getNested<T extends object>(obj: T, key: NestedKeyOf<T>) {
 | 
			
		||||
  const path: (keyof object)[] = key.split(".") as (keyof unknown)[];
 | 
			
		||||
  let res = obj[path.shift() as keyof T];
 | 
			
		||||
  for (const node of path) {
 | 
			
		||||
    if (res === null) {
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
    res = res[node];
 | 
			
		||||
  }
 | 
			
		||||
  return res;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Convert the content the string to make sure it won't break
 | 
			
		||||
 * the resulting csv.
 | 
			
		||||
 * cf. https://en.wikipedia.org/wiki/Comma-separated_values#Basic_rules
 | 
			
		||||
 */
 | 
			
		||||
function sanitizeCell(content: string): string {
 | 
			
		||||
  return `"${content.replace(/"/g, '""')}"`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const csv = {
 | 
			
		||||
  stringify: <T extends object>(objs: T[], options?: StringifyOptions<T>) => {
 | 
			
		||||
    const columns = options.columns;
 | 
			
		||||
    const content = objs
 | 
			
		||||
      .map((obj) => {
 | 
			
		||||
        return columns
 | 
			
		||||
          .map((col) => {
 | 
			
		||||
            return sanitizeCell((getNested(obj, col) ?? "").toString());
 | 
			
		||||
          })
 | 
			
		||||
          .join(",");
 | 
			
		||||
      })
 | 
			
		||||
      .join("\n");
 | 
			
		||||
    if (!options.titleRow) {
 | 
			
		||||
      return content;
 | 
			
		||||
    }
 | 
			
		||||
    const firstRow = options.titleRow.map(sanitizeCell).join(",");
 | 
			
		||||
    return `${firstRow}\n${content}`;
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										37
									
								
								core/static/bundled/utils/types.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								core/static/bundled/utils/types.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
/**
 | 
			
		||||
 * A key of an object, or of one of its descendants.
 | 
			
		||||
 *
 | 
			
		||||
 * Example :
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * interface Foo {
 | 
			
		||||
 *   foo_inner: number;
 | 
			
		||||
 * }
 | 
			
		||||
 *
 | 
			
		||||
 * interface Bar {
 | 
			
		||||
 *   foo: Foo;
 | 
			
		||||
 * }
 | 
			
		||||
 *
 | 
			
		||||
 * const foo = (key: NestedKeyOf<Bar>) {
 | 
			
		||||
 *     console.log(key);
 | 
			
		||||
 * }
 | 
			
		||||
 *
 | 
			
		||||
 * foo("foo.foo_inner");  // OK
 | 
			
		||||
 * foo("foo.bar"); // FAIL
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export type NestedKeyOf<T extends object> = {
 | 
			
		||||
  [Key in keyof T & (string | number)]: NestedKeyOfHandleValue<T[Key], `${Key}`>;
 | 
			
		||||
}[keyof T & (string | number)];
 | 
			
		||||
 | 
			
		||||
type NestedKeyOfInner<T extends object> = {
 | 
			
		||||
  [Key in keyof T & (string | number)]: NestedKeyOfHandleValue<
 | 
			
		||||
    T[Key],
 | 
			
		||||
    `['${Key}']` | `.${Key}`
 | 
			
		||||
  >;
 | 
			
		||||
}[keyof T & (string | number)];
 | 
			
		||||
 | 
			
		||||
type NestedKeyOfHandleValue<T, Text extends string> = T extends unknown[]
 | 
			
		||||
  ? Text
 | 
			
		||||
  : T extends object
 | 
			
		||||
    ? Text | `${Text}${NestedKeyOfInner<T>}`
 | 
			
		||||
    : Text;
 | 
			
		||||
@@ -24,6 +24,9 @@ $black-color: hsl(0, 0%, 17%);
 | 
			
		||||
 | 
			
		||||
$faceblue: hsl(221, 44%, 41%);
 | 
			
		||||
$twitblue: hsl(206, 82%, 63%);
 | 
			
		||||
$discordblurple: #7289da;
 | 
			
		||||
$instagradient: radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%);
 | 
			
		||||
$githubblack: rgb(22, 22, 20);
 | 
			
		||||
 | 
			
		||||
$shadow-color: rgb(223, 223, 223);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										96
									
								
								core/static/core/components/card.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								core/static/core/components/card.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,96 @@
 | 
			
		||||
@import "core/static/core/colors";
 | 
			
		||||
 | 
			
		||||
@mixin row-layout {
 | 
			
		||||
  min-height: 100px;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  max-width: 100%;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  gap: 10px;
 | 
			
		||||
  .card-image {
 | 
			
		||||
    max-width: 75px;
 | 
			
		||||
  }
 | 
			
		||||
  .card-content {
 | 
			
		||||
    flex: 1;
 | 
			
		||||
    text-align: left;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.card {
 | 
			
		||||
  background-color: $primary-neutral-light-color;
 | 
			
		||||
  border-radius: 5px;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  box-sizing: border-box;
 | 
			
		||||
  padding: 20px 10px;
 | 
			
		||||
  height: fit-content;
 | 
			
		||||
  width: 150px;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 20px;
 | 
			
		||||
 | 
			
		||||
  &.clickable:hover {
 | 
			
		||||
    background-color: darken($primary-neutral-light-color, 5%);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.selected {
 | 
			
		||||
    animation: bg-in-out 1s ease;
 | 
			
		||||
    background-color: rgb(216, 236, 255);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .card-image {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    height: 100%;
 | 
			
		||||
    min-height: 70px;
 | 
			
		||||
    max-height: 70px;
 | 
			
		||||
    object-fit: contain;
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    line-height: 70px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  i.card-image {
 | 
			
		||||
    color: black;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    background-color: rgba(173, 173, 173, 0.2);
 | 
			
		||||
    width: 80%;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .card-content {
 | 
			
		||||
    color: black;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    gap: 5px;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
 | 
			
		||||
    p {
 | 
			
		||||
      font-size: 13px;
 | 
			
		||||
      margin: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .card-title {
 | 
			
		||||
      margin: 0;
 | 
			
		||||
      font-size: 15px;
 | 
			
		||||
      word-break: break-word;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @keyframes bg-in-out {
 | 
			
		||||
    0% {
 | 
			
		||||
      background-color: white;
 | 
			
		||||
    }
 | 
			
		||||
    100% {
 | 
			
		||||
      background-color: rgb(216, 236, 255);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media screen and (max-width: 765px) {
 | 
			
		||||
    @include row-layout
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // When combined with card, card-row display the card in a row layout,
 | 
			
		||||
  // whatever the size of the screen.
 | 
			
		||||
  &.card-row {
 | 
			
		||||
    @include row-layout
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										5
									
								
								core/static/core/devices.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								core/static/core/devices.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
			
		||||
/*--------------------------MEDIA QUERY HELPERS------------------------*/
 | 
			
		||||
 | 
			
		||||
$small-devices: 576px;
 | 
			
		||||
$medium-devices: 768px;
 | 
			
		||||
$large-devices: 992px;
 | 
			
		||||
@@ -1,89 +1,722 @@
 | 
			
		||||
@import "colors";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Style related to forms
 | 
			
		||||
 * Style related to forms and form inputs
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
/**
 | 
			
		||||
 * 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%);
 | 
			
		||||
    &: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"] {
 | 
			
		||||
    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 {
 | 
			
		||||
    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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
a.button,
 | 
			
		||||
input[type="button"],
 | 
			
		||||
input[type="submit"],
 | 
			
		||||
input[type="reset"],
 | 
			
		||||
input[type="file"] {
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
form {
 | 
			
		||||
  // Input size - used for height/padding calculations
 | 
			
		||||
  --nf-input-size: 1rem;
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
}
 | 
			
		||||
  --nf-input-font-size: calc(var(--nf-input-size) * 0.875);
 | 
			
		||||
  --nf-small-font-size: calc(var(--nf-input-size) * 0.875);
 | 
			
		||||
 | 
			
		||||
input,
 | 
			
		||||
textarea[type="text"],
 | 
			
		||||
[type="number"] {
 | 
			
		||||
  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%;
 | 
			
		||||
}
 | 
			
		||||
  // 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;
 | 
			
		||||
 | 
			
		||||
textarea {
 | 
			
		||||
  border: none;
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
  background-color: $background-button-color;
 | 
			
		||||
  padding: 7px;
 | 
			
		||||
  font-size: 1.2em;
 | 
			
		||||
  border-radius: 5px;
 | 
			
		||||
  font-family: sans-serif;
 | 
			
		||||
}
 | 
			
		||||
  // 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;
 | 
			
		||||
 | 
			
		||||
select {
 | 
			
		||||
  border: none;
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
  font-size: 1.2em;
 | 
			
		||||
  background-color: $background-button-color;
 | 
			
		||||
  padding: 10px;
 | 
			
		||||
  border-radius: 5px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
  // Label variables
 | 
			
		||||
  --nf-label-font-size: var(--nf-small-font-size);
 | 
			
		||||
  --nf-label-color: #374151;
 | 
			
		||||
  --nf-label-font-weight: 500;
 | 
			
		||||
 | 
			
		||||
a:not(.button) {
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
  color: $primary-dark-color;
 | 
			
		||||
  // 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);
 | 
			
		||||
 | 
			
		||||
  &:hover {
 | 
			
		||||
    color: $primary-light-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;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &:active {
 | 
			
		||||
    color: $primary-color;
 | 
			
		||||
  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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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="month"],
 | 
			
		||||
  input[type="search"],
 | 
			
		||||
  textarea,
 | 
			
		||||
  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 {
 | 
			
		||||
    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 {
 | 
			
		||||
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280'  stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-chevron-down'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
 | 
			
		||||
    background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%;
 | 
			
		||||
    background-repeat: no-repeat;
 | 
			
		||||
    background-size: var(--nf-input-size);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -33,8 +33,8 @@ $hovered-red-text-color: #ff4d4d;
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
    gap: 10px;
 | 
			
		||||
 | 
			
		||||
    >a {
 | 
			
		||||
      color: $text-color;
 | 
			
		||||
    > a {
 | 
			
		||||
      color: $text-color!important;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:hover>a {
 | 
			
		||||
@@ -395,9 +395,9 @@ $hovered-red-text-color: #ff4d4d;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        >input[type=text] {
 | 
			
		||||
          box-sizing: border-box;
 | 
			
		||||
          max-width: 100%;
 | 
			
		||||
          width: 100%;
 | 
			
		||||
          min-width: unset;
 | 
			
		||||
          border: unset;
 | 
			
		||||
          height: 35px;
 | 
			
		||||
          border-radius: 5px;
 | 
			
		||||
          font-size: .9em;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,6 @@
 | 
			
		||||
@import "colors";
 | 
			
		||||
@import "forms";
 | 
			
		||||
 | 
			
		||||
/*--------------------------MEDIA QUERY HELPERS------------------------*/
 | 
			
		||||
$small-devices: 576px;
 | 
			
		||||
$medium-devices: 768px;
 | 
			
		||||
$large-devices: 992px;
 | 
			
		||||
@import "devices";
 | 
			
		||||
 | 
			
		||||
/*--------------------------------GENERAL------------------------------*/
 | 
			
		||||
 | 
			
		||||
@@ -19,6 +15,13 @@ body {
 | 
			
		||||
  --loading-stroke: 5px;
 | 
			
		||||
  --loading-duration: 1s;
 | 
			
		||||
  position: relative;
 | 
			
		||||
 | 
			
		||||
  &.aria-busy-grow {
 | 
			
		||||
    // Make sure the element take enough place to hold the loading wheel
 | 
			
		||||
    min-height: calc((var(--loading-size)) * 1.5);
 | 
			
		||||
    min-width: calc((var(--loading-size)) * 1.5);
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[aria-busy]:after {
 | 
			
		||||
@@ -198,6 +201,10 @@ body {
 | 
			
		||||
  margin: 20px auto 0;
 | 
			
		||||
 | 
			
		||||
  /*---------------------------------NAV---------------------------------*/
 | 
			
		||||
  a.btn {
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .btn {
 | 
			
		||||
    font-size: 15px;
 | 
			
		||||
    font-weight: normal;
 | 
			
		||||
@@ -252,6 +259,13 @@ body {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
  * A spacer below an element. Somewhat cleaner than putting <br/> everywhere.
 | 
			
		||||
  */
 | 
			
		||||
  .margin-bottom {
 | 
			
		||||
    margin-bottom: 1.5rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /*--------------------------------CONTENT------------------------------*/
 | 
			
		||||
  #quick_notif {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
@@ -314,6 +328,18 @@ body {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .snackbar {
 | 
			
		||||
    width: 250px;
 | 
			
		||||
    margin-left: -125px;
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    position: fixed;
 | 
			
		||||
    z-index: 10;
 | 
			
		||||
    /* to get on top of tomselect */
 | 
			
		||||
    left: 50%;
 | 
			
		||||
    top: 60px;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .tabs {
 | 
			
		||||
    border-radius: 5px;
 | 
			
		||||
 | 
			
		||||
@@ -398,302 +424,31 @@ body {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /*---------------------------------NEWS--------------------------------*/
 | 
			
		||||
  #news {
 | 
			
		||||
  .row {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
 | 
			
		||||
    @media (max-width: 800px) {
 | 
			
		||||
      flex-direction: column;
 | 
			
		||||
    $col-gap: 1rem;
 | 
			
		||||
    $row-gap: 0.5rem;
 | 
			
		||||
 | 
			
		||||
    &.gap {
 | 
			
		||||
      column-gap: var($col-gap);
 | 
			
		||||
      row-gap: var($row-gap);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .news_column {
 | 
			
		||||
      display: inline-block;
 | 
			
		||||
      margin: 0;
 | 
			
		||||
      vertical-align: top;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #news_admin {
 | 
			
		||||
      margin-bottom: 1em;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #right_column {
 | 
			
		||||
      flex: 20%;
 | 
			
		||||
      float: right;
 | 
			
		||||
      margin: 0.2em;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #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: 1.1em;
 | 
			
		||||
 | 
			
		||||
        &:not(:first-of-type) {
 | 
			
		||||
          margin: 2em 0 1em 0;
 | 
			
		||||
        }
 | 
			
		||||
    @for $i from 2 through 5 {
 | 
			
		||||
      &.gap-#{$i}x {
 | 
			
		||||
        column-gap: $i * $col-gap;
 | 
			
		||||
        row-gap: $i * $row-gap;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @media screen and (max-width: $small-devices) {
 | 
			
		||||
 | 
			
		||||
      #left_column,
 | 
			
		||||
      #right_column {
 | 
			
		||||
        flex: 100%;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* AGENDA/BIRTHDAYS */
 | 
			
		||||
    #agenda,
 | 
			
		||||
    #birthdays {
 | 
			
		||||
      display: block;
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      background: white;
 | 
			
		||||
      font-size: 70%;
 | 
			
		||||
      margin-bottom: 1em;
 | 
			
		||||
 | 
			
		||||
      #agenda_title,
 | 
			
		||||
      #birthdays_title {
 | 
			
		||||
        margin: 0;
 | 
			
		||||
        border-radius: 5px 5px 0 0;
 | 
			
		||||
        box-shadow: $shadow-color 1px 1px 1px;
 | 
			
		||||
        padding: 0.5em;
 | 
			
		||||
        font-weight: bold;
 | 
			
		||||
        font-size: 150%;
 | 
			
		||||
        text-align: center;
 | 
			
		||||
        text-transform: uppercase;
 | 
			
		||||
        background: $second-color;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      #agenda_content {
 | 
			
		||||
        overflow: auto;
 | 
			
		||||
        box-shadow: $shadow-color 1px 1px 1px;
 | 
			
		||||
        height: 20em;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      #agenda_content,
 | 
			
		||||
      #birthdays_content {
 | 
			
		||||
        .agenda_item {
 | 
			
		||||
          padding: 0.5em;
 | 
			
		||||
          margin-bottom: 0.5em;
 | 
			
		||||
 | 
			
		||||
          &:nth-of-type(even) {
 | 
			
		||||
            background: $secondary-neutral-light-color;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          .agenda_time {
 | 
			
		||||
            font-size: 90%;
 | 
			
		||||
            color: grey;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          .agenda_item_content {
 | 
			
		||||
            p {
 | 
			
		||||
              margin-top: 0.2em;
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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;
 | 
			
		||||
    // Make an element of the row take as much space as needed
 | 
			
		||||
    .grow {
 | 
			
		||||
      flex: 1;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media screen and (max-width: $small-devices) {
 | 
			
		||||
@@ -702,304 +457,6 @@ body {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.helptext {
 | 
			
		||||
  margin-top: 10px;
 | 
			
		||||
  display: block;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*---------------------------POSTERS----------------------------*/
 | 
			
		||||
 | 
			
		||||
#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%;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*---------------------------ACCOUNTING----------------------------*/
 | 
			
		||||
#accounting {
 | 
			
		||||
  .journal-table {
 | 
			
		||||
@@ -1199,40 +656,6 @@ u,
 | 
			
		||||
  text-decoration: underline;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#bar-ui {
 | 
			
		||||
  padding: 0.4em;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  flex-direction: row-reverse;
 | 
			
		||||
 | 
			
		||||
  #products {
 | 
			
		||||
    flex-basis: 100%;
 | 
			
		||||
    margin: 0.2em;
 | 
			
		||||
    overflow: auto;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #click_form {
 | 
			
		||||
    flex: auto;
 | 
			
		||||
    margin: 0.2em;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #user_info {
 | 
			
		||||
    flex: auto;
 | 
			
		||||
    padding: 0.5em;
 | 
			
		||||
    margin: 0.2em;
 | 
			
		||||
    height: 100%;
 | 
			
		||||
    background: $secondary-neutral-light-color;
 | 
			
		||||
 | 
			
		||||
    img {
 | 
			
		||||
      max-width: 70%;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    input {
 | 
			
		||||
      background: white;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*-----------------------------USER PROFILE----------------------------*/
 | 
			
		||||
 | 
			
		||||
.user_mini_profile {
 | 
			
		||||
@@ -1399,23 +822,12 @@ footer {
 | 
			
		||||
    margin-top: 3px;
 | 
			
		||||
    color: rgba(0, 0, 0, 0.3);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .fa-github {
 | 
			
		||||
    color: $githubblack;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*---------------------------------FORMS-------------------------------*/
 | 
			
		||||
 | 
			
		||||
form {
 | 
			
		||||
  margin: 0 auto;
 | 
			
		||||
  margin-bottom: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
label {
 | 
			
		||||
  display: block;
 | 
			
		||||
  margin-bottom: 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.choose_file_widget {
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ui-dialog .ui-dialog-buttonpane {
 | 
			
		||||
  bottom: 0;
 | 
			
		||||
@@ -1423,16 +835,6 @@ label {
 | 
			
		||||
  width: 97%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#user_edit {
 | 
			
		||||
  * {
 | 
			
		||||
    text-align: center;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  img {
 | 
			
		||||
    width: 100px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#cash_summary_form label,
 | 
			
		||||
.inline {
 | 
			
		||||
  display: inline;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,5 @@
 | 
			
		||||
@import "core/static/core/colors";
 | 
			
		||||
 | 
			
		||||
main {
 | 
			
		||||
  box-sizing: border-box;
 | 
			
		||||
  display: flex;
 | 
			
		||||
@@ -69,7 +71,7 @@ main {
 | 
			
		||||
        border-radius: 50%;
 | 
			
		||||
        justify-content: center;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
        background-color: #f2f2f2;
 | 
			
		||||
        background-color: $primary-neutral-light-color;
 | 
			
		||||
 | 
			
		||||
        > span {
 | 
			
		||||
          font-size: small;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,26 +1,9 @@
 | 
			
		||||
 | 
			
		||||
@media (max-width: 750px) {
 | 
			
		||||
  .title {
 | 
			
		||||
    text-align: center;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.field-error {
 | 
			
		||||
  height: auto !important;
 | 
			
		||||
 | 
			
		||||
  > ul {
 | 
			
		||||
    list-style-type: none;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    color: indianred;
 | 
			
		||||
 | 
			
		||||
    > li {
 | 
			
		||||
      text-align: left !important;
 | 
			
		||||
      line-height: normal;
 | 
			
		||||
      margin-top: 5px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile {
 | 
			
		||||
  &-visible {
 | 
			
		||||
    display: flex;
 | 
			
		||||
@@ -87,11 +70,7 @@
 | 
			
		||||
        max-height: 100%;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      > i {
 | 
			
		||||
        font-size: 32px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      >p {
 | 
			
		||||
      > p {
 | 
			
		||||
        text-align: left !important;
 | 
			
		||||
        width: 100% !important;
 | 
			
		||||
      }
 | 
			
		||||
@@ -107,16 +86,6 @@
 | 
			
		||||
      > div {
 | 
			
		||||
        max-width: 100%;
 | 
			
		||||
 | 
			
		||||
        > input {
 | 
			
		||||
          font-weight: normal;
 | 
			
		||||
          cursor: pointer;
 | 
			
		||||
          text-align: left !important;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        > button {
 | 
			
		||||
          min-width: 30%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        @media (min-width: 750px) {
 | 
			
		||||
          height: auto;
 | 
			
		||||
          align-items: center;
 | 
			
		||||
@@ -124,8 +93,8 @@
 | 
			
		||||
          overflow: hidden;
 | 
			
		||||
 | 
			
		||||
          > input {
 | 
			
		||||
            width: 70%;
 | 
			
		||||
            font-size: .6em;
 | 
			
		||||
 | 
			
		||||
            &::file-selector-button {
 | 
			
		||||
              height: 30px;
 | 
			
		||||
            }
 | 
			
		||||
@@ -167,7 +136,7 @@
 | 
			
		||||
      max-width: 100%;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    >* {
 | 
			
		||||
    > * {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      max-width: 300px;
 | 
			
		||||
 | 
			
		||||
@@ -181,45 +150,22 @@
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &-content {
 | 
			
		||||
 | 
			
		||||
      >* {
 | 
			
		||||
      > * {
 | 
			
		||||
        box-sizing: border-box;
 | 
			
		||||
        text-align: left !important;
 | 
			
		||||
        line-height: 40px;
 | 
			
		||||
        max-width: 100%;
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        height: 40px;
 | 
			
		||||
        margin: 0;
 | 
			
		||||
 | 
			
		||||
        >* {
 | 
			
		||||
        > * {
 | 
			
		||||
          text-align: left !important;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
      >textarea {
 | 
			
		||||
        height: 120px;
 | 
			
		||||
        min-height: 40px;
 | 
			
		||||
        min-width: 300px;
 | 
			
		||||
        max-width: 300px;
 | 
			
		||||
        line-height: initial;
 | 
			
		||||
 | 
			
		||||
        @media (max-width: 750px) {
 | 
			
		||||
          max-width: 100%;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      >input[type="file"] {
 | 
			
		||||
        font-size: small;
 | 
			
		||||
        line-height: 30px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      >input[type="checkbox"] {
 | 
			
		||||
        width: 20px;
 | 
			
		||||
        height: 20px;
 | 
			
		||||
        margin: 0;
 | 
			
		||||
        float: left;
 | 
			
		||||
      }
 | 
			
		||||
    textarea {
 | 
			
		||||
      height: 7rem;
 | 
			
		||||
    }
 | 
			
		||||
    .final-actions {
 | 
			
		||||
      text-align: center;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -108,7 +108,8 @@
 | 
			
		||||
            <a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
 | 
			
		||||
            <a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
 | 
			
		||||
          </div>
 | 
			
		||||
          <a href="https://discord.gg/XK9WfPsUFm" target="_link">
 | 
			
		||||
          <a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
 | 
			
		||||
            <i class="fa-brands fa-github"></i>
 | 
			
		||||
            {% trans %}Site created by the IT Department of the AE{% endtrans %}
 | 
			
		||||
          </a>
 | 
			
		||||
        {% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -60,7 +60,7 @@
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if user.date_of_birth %}
 | 
			
		||||
          <div class="user_mini_profile_dob">
 | 
			
		||||
            {{ user.date_of_birth|date("d/m/Y") }} ({{ user.get_age() }})
 | 
			
		||||
            {{ user.date_of_birth|date("d/m/Y") }} ({{ user.age }})
 | 
			
		||||
          </div>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </div>
 | 
			
		||||
@@ -140,7 +140,7 @@
 | 
			
		||||
        nb_page (str): call to a javascript function or variable returning
 | 
			
		||||
            the maximum number of pages to paginate
 | 
			
		||||
    #}
 | 
			
		||||
  <nav class="pagination" x-show="{{ nb_pages }} > 1">
 | 
			
		||||
  <nav class="pagination" x-show="{{ nb_pages }} > 1" x-cloak>
 | 
			
		||||
      {# Adding the prevent here is important, because otherwise,
 | 
			
		||||
      clicking on the pagination buttons could submit the picture management form
 | 
			
		||||
      and reload the page #}
 | 
			
		||||
 
 | 
			
		||||
@@ -3,17 +3,18 @@
 | 
			
		||||
{% macro page_history(page) %}
 | 
			
		||||
  <p>{% trans page_name=page.name %}You're seeing the history of page "{{ page_name }}"{% endtrans %}</p>
 | 
			
		||||
  <ul>
 | 
			
		||||
    {% for r in (page.revisions.all()|sort(attribute='date', reverse=True)) %}
 | 
			
		||||
      {% if loop.index < 2 %}
 | 
			
		||||
        <li><a href="{{ url('core:page', page_name=page.get_full_name()) }}">{% trans %}last{% endtrans %}</a> -
 | 
			
		||||
          {{ user_profile_link(page.revisions.last().author) }} -
 | 
			
		||||
          {{ page.revisions.last().date|localtime|date(DATETIME_FORMAT) }} {{ page.revisions.last().date|localtime|time(DATETIME_FORMAT) }}</a></li>
 | 
			
		||||
      {% else %}
 | 
			
		||||
        <li><a href="{{ url('core:page_rev', page_name=page.get_full_name(), rev=r['id']) }}">{{ r.revision }}</a> -
 | 
			
		||||
          {{ user_profile_link(r.author) }} -
 | 
			
		||||
          {{ r.date|localtime|date(DATETIME_FORMAT) }} {{ r.date|localtime|time(DATETIME_FORMAT) }}</a></li>
 | 
			
		||||
      {% endif %}
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
    {% set page_name = page.get_full_name() %}
 | 
			
		||||
    {%- for rev in page.revisions.order_by("-date").select_related("author") -%}
 | 
			
		||||
      <li>
 | 
			
		||||
        {% if loop.first %}
 | 
			
		||||
          <a href="{{ url('core:page', page_name=page_name) }}">{% trans %}last{% endtrans %}</a>
 | 
			
		||||
        {% else %}
 | 
			
		||||
          <a href="{{ url('core:page_rev', page_name=page_name, rev=rev.id) }}">{{ rev.revision }}</a>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {{ user_profile_link(rev.author) }} -
 | 
			
		||||
        {{ rev.date|localtime|date(DATETIME_FORMAT) }} {{ rev.date|localtime|time(DATETIME_FORMAT) }}
 | 
			
		||||
      </li>
 | 
			
		||||
    {%- endfor -%}
 | 
			
		||||
  </ul>
 | 
			
		||||
{% endmacro %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,54 +0,0 @@
 | 
			
		||||
{% extends "core/base.jinja" %}
 | 
			
		||||
 | 
			
		||||
{% block script %}
 | 
			
		||||
  {{ super() }}
 | 
			
		||||
  <script src="{{ static('com/js/poster_list.js') }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
  {% trans %}Poster{% endtrans %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  <div id="poster_list">
 | 
			
		||||
 | 
			
		||||
    <div id="title">
 | 
			
		||||
      <h3>{% trans %}Posters{% endtrans %}</h3>
 | 
			
		||||
      <div id="links" class="right">
 | 
			
		||||
        <a id="create" class="link" href="{{ url(app + ":poster_list") }}">{% trans %}Create{% endtrans %}</a>
 | 
			
		||||
        {% if app == "com" %}
 | 
			
		||||
          <a id="moderation" class="link" href="{{ url("com:poster_moderate_list") }}">{% trans %}Moderation{% endtrans %}</a>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div id="posters">
 | 
			
		||||
 | 
			
		||||
      {% if poster_list.count() == 0 %}
 | 
			
		||||
        <div id="no-posters">{% trans %}No posters{% endtrans %}</div>
 | 
			
		||||
      {% else %}
 | 
			
		||||
 | 
			
		||||
        {% for poster in poster_list %}
 | 
			
		||||
          <div class="poster">
 | 
			
		||||
            <div class="name">{{ poster.name }}</div>
 | 
			
		||||
            <div class="image"><img src="{{ poster.file.url }}"></img></div>
 | 
			
		||||
            <div class="dates">
 | 
			
		||||
              <div class="begin">{{ poster.date_begin | date("d/M/Y H:m") }}</div>
 | 
			
		||||
              <div class="end">{{ poster.date_end | date("d/M/Y H:m") }}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <a class="edit" href="{{ url(poster_edit_url_name, poster.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
			
		||||
          </div>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
 | 
			
		||||
      {% endif %}
 | 
			
		||||
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div id="view"><div id="placeholder"></div></div>
 | 
			
		||||
 | 
			
		||||
  </div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -63,9 +63,7 @@
 | 
			
		||||
            {%- trans -%}Delete{%- endtrans -%}
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <p>
 | 
			
		||||
          {{ form[field_name].label }}
 | 
			
		||||
        </p>
 | 
			
		||||
        {{ form[field_name].label_tag() }}
 | 
			
		||||
        {{ form[field_name].errors }}
 | 
			
		||||
      {%- else -%}
 | 
			
		||||
        <em>{% trans %}To edit your profile picture, ask a member of the AE{% endtrans %}</em>
 | 
			
		||||
@@ -118,68 +116,68 @@
 | 
			
		||||
    {# All fields #}
 | 
			
		||||
    <div class="profile-fields">
 | 
			
		||||
      {%- for field in form -%}
 | 
			
		||||
        {%-
 | 
			
		||||
        if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"]
 | 
			
		||||
        -%}
 | 
			
		||||
        {%- continue -%}
 | 
			
		||||
      {%- endif -%}
 | 
			
		||||
        {%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"] -%}
 | 
			
		||||
          {%- continue -%}
 | 
			
		||||
        {%- endif -%}
 | 
			
		||||
 | 
			
		||||
      <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 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>
 | 
			
		||||
      </div>
 | 
			
		||||
{%- endfor -%}
 | 
			
		||||
</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 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>
 | 
			
		||||
  {%- endfor -%}
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
    {# Checkboxes #}
 | 
			
		||||
<div class="profile-visible">
 | 
			
		||||
  {{ form.is_subscriber_viewable }}
 | 
			
		||||
  {{ form.is_subscriber_viewable.label }}
 | 
			
		||||
</div>
 | 
			
		||||
    <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 -%}
 | 
			
		||||
 | 
			
		||||
      <p>
 | 
			
		||||
        <input type="submit" value="{%- trans -%}Update{%- endtrans -%}" />
 | 
			
		||||
      </p>
 | 
			
		||||
    </div>
 | 
			
		||||
  </form>
 | 
			
		||||
 | 
			
		||||
{%- if form.instance == user -%}
 | 
			
		||||
  <p>
 | 
			
		||||
    <a href="{{ url('core:password_change') }}">{%- trans -%}Change my password{%- endtrans -%}</a>
 | 
			
		||||
    <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>
 | 
			
		||||
{%- 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 -%}
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,9 @@
 | 
			
		||||
              <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>
 | 
			
		||||
            {% 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 %}
 | 
			
		||||
              <li><a href="{{ url('subscription:subscription') }}">{% trans %}Subscriptions{% endtrans %}</a></li>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
@@ -52,7 +55,7 @@
 | 
			
		||||
          %}
 | 
			
		||||
          <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:producttype_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
 | 
			
		||||
          <li><a href="{{ url('counter:product_type_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:invoices_call') }}">{% trans %}Invoices call{% endtrans %}</a></li>
 | 
			
		||||
          <li><a href="{{ url('counter:eticket_list') }}">{% trans %}Etickets{% endtrans %}</a></li>
 | 
			
		||||
 
 | 
			
		||||
@@ -18,6 +18,7 @@ from smtplib import SMTPException
 | 
			
		||||
 | 
			
		||||
import freezegun
 | 
			
		||||
import pytest
 | 
			
		||||
from django.contrib.auth.hashers import make_password
 | 
			
		||||
from django.core import mail
 | 
			
		||||
from django.core.cache import cache
 | 
			
		||||
from django.core.mail import EmailMessage
 | 
			
		||||
@@ -30,7 +31,7 @@ from model_bakery import baker
 | 
			
		||||
from pytest_django.asserts import assertInHTML, assertRedirects
 | 
			
		||||
 | 
			
		||||
from antispam.models import ToxicDomain
 | 
			
		||||
from club.models import Membership
 | 
			
		||||
from club.models import Club, Membership
 | 
			
		||||
from core.markdown import markdown
 | 
			
		||||
from core.models import AnonymousUser, Group, Page, User
 | 
			
		||||
from core.utils import get_semester_code, get_start_of_semester
 | 
			
		||||
@@ -118,7 +119,9 @@ class TestUserRegistration:
 | 
			
		||||
        response = client.post(reverse("core:register"), valid_payload)
 | 
			
		||||
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        error_html = "<li>Un objet User avec ce champ Adresse email existe déjà.</li>"
 | 
			
		||||
        error_html = (
 | 
			
		||||
            "<li>Un objet Utilisateur avec ce champ Adresse email existe déjà.</li>"
 | 
			
		||||
        )
 | 
			
		||||
        assertInHTML(error_html, str(response.content.decode()))
 | 
			
		||||
 | 
			
		||||
    def test_register_fail_with_not_existing_email(
 | 
			
		||||
@@ -143,7 +146,7 @@ class TestUserRegistration:
 | 
			
		||||
class TestUserLogin:
 | 
			
		||||
    @pytest.fixture()
 | 
			
		||||
    def user(self) -> User:
 | 
			
		||||
        return User.objects.first()
 | 
			
		||||
        return baker.make(User, password=make_password("plop"))
 | 
			
		||||
 | 
			
		||||
    def test_login_fail(self, client, user):
 | 
			
		||||
        """Should not login a user correctly."""
 | 
			
		||||
@@ -347,56 +350,35 @@ class TestUserIsInGroup(TestCase):
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def setUpTestData(cls):
 | 
			
		||||
        from club.models import Club
 | 
			
		||||
 | 
			
		||||
        cls.root_group = Group.objects.get(name="Root")
 | 
			
		||||
        cls.public = Group.objects.get(name="Public")
 | 
			
		||||
        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.public_group = Group.objects.get(name="Public")
 | 
			
		||||
        cls.public_user = baker.make(User)
 | 
			
		||||
        cls.subscribers = Group.objects.get(name="Subscribers")
 | 
			
		||||
        cls.old_subscribers = Group.objects.get(name="Old subscribers")
 | 
			
		||||
        cls.accounting_admin = Group.objects.get(name="Accounting admin")
 | 
			
		||||
        cls.com_admin = Group.objects.get(name="Communication 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.club = Club.objects.create(
 | 
			
		||||
            name="Fake Club",
 | 
			
		||||
            unix_name="fake-club",
 | 
			
		||||
            address="Fake address",
 | 
			
		||||
        )
 | 
			
		||||
        cls.club = baker.make(Club)
 | 
			
		||||
        cls.main_club = Club.objects.get(id=1)
 | 
			
		||||
 | 
			
		||||
    def assert_in_public_group(self, user):
 | 
			
		||||
        assert user.is_in_group(pk=self.public.id)
 | 
			
		||||
        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
 | 
			
		||||
        assert user.is_in_group(pk=self.public_group.id)
 | 
			
		||||
        assert user.is_in_group(name=self.public_group.name)
 | 
			
		||||
 | 
			
		||||
    def assert_only_in_public_group(self, user):
 | 
			
		||||
        self.assert_in_public_group(user)
 | 
			
		||||
        for group in (
 | 
			
		||||
            self.root_group,
 | 
			
		||||
            self.banned_counters,
 | 
			
		||||
            self.accounting_admin,
 | 
			
		||||
            self.sas_admin,
 | 
			
		||||
            self.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(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):
 | 
			
		||||
        """Test that anonymous users are only in the public group."""
 | 
			
		||||
@@ -405,80 +387,80 @@ class TestUserIsInGroup(TestCase):
 | 
			
		||||
 | 
			
		||||
    def test_not_subscribed_user(self):
 | 
			
		||||
        """Test that users who never subscribed are only in the public group."""
 | 
			
		||||
        self.assert_only_in_public_group(self.toto)
 | 
			
		||||
        self.assert_only_in_public_group(self.public_user)
 | 
			
		||||
 | 
			
		||||
    def test_wrong_parameter_fail(self):
 | 
			
		||||
        """Test that when neither the pk nor the name argument is given,
 | 
			
		||||
        the function raises a ValueError.
 | 
			
		||||
        """
 | 
			
		||||
        with self.assertRaises(ValueError):
 | 
			
		||||
            self.toto.is_in_group()
 | 
			
		||||
            self.public_user.is_in_group()
 | 
			
		||||
 | 
			
		||||
    def test_number_queries(self):
 | 
			
		||||
        """Test that the number of db queries is stable
 | 
			
		||||
        and that less queries are made when making a new call.
 | 
			
		||||
        """
 | 
			
		||||
        # make sure Skia is in at least one group
 | 
			
		||||
        self.skia.groups.add(Group.objects.first().pk)
 | 
			
		||||
        skia_groups = self.skia.groups.all()
 | 
			
		||||
        group_in = baker.make(Group)
 | 
			
		||||
        self.public_user.groups.add(group_in)
 | 
			
		||||
 | 
			
		||||
        group_in = skia_groups.first()
 | 
			
		||||
        cache.clear()
 | 
			
		||||
        # Test when the user is in the group
 | 
			
		||||
        with self.assertNumQueries(2):
 | 
			
		||||
            self.skia.is_in_group(pk=group_in.id)
 | 
			
		||||
            self.public_user.is_in_group(pk=group_in.id)
 | 
			
		||||
        with self.assertNumQueries(0):
 | 
			
		||||
            self.skia.is_in_group(pk=group_in.id)
 | 
			
		||||
            self.public_user.is_in_group(pk=group_in.id)
 | 
			
		||||
 | 
			
		||||
        ids = skia_groups.values_list("pk", flat=True)
 | 
			
		||||
        group_not_in = Group.objects.exclude(pk__in=ids).first()
 | 
			
		||||
        group_not_in = baker.make(Group)
 | 
			
		||||
        cache.clear()
 | 
			
		||||
        # Test when the user is not in the group
 | 
			
		||||
        with self.assertNumQueries(2):
 | 
			
		||||
            self.skia.is_in_group(pk=group_not_in.id)
 | 
			
		||||
            self.public_user.is_in_group(pk=group_not_in.id)
 | 
			
		||||
        with self.assertNumQueries(0):
 | 
			
		||||
            self.skia.is_in_group(pk=group_not_in.id)
 | 
			
		||||
            self.public_user.is_in_group(pk=group_not_in.id)
 | 
			
		||||
 | 
			
		||||
    def test_cache_properly_cleared_membership(self):
 | 
			
		||||
        """Test that when the membership of a user end,
 | 
			
		||||
        the cache is properly invalidated.
 | 
			
		||||
        """
 | 
			
		||||
        membership = Membership.objects.create(
 | 
			
		||||
            club=self.club, user=self.toto, end_date=None
 | 
			
		||||
        )
 | 
			
		||||
        meta_groups_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
 | 
			
		||||
        membership = baker.make(Membership, club=self.club, user=self.public_user)
 | 
			
		||||
        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}")
 | 
			
		||||
        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}"
 | 
			
		||||
        )
 | 
			
		||||
        membership.end_date = now() - timedelta(minutes=5)
 | 
			
		||||
        membership.save()
 | 
			
		||||
        cached_membership = cache.get(f"membership_{self.club.id}_{self.toto.id}")
 | 
			
		||||
        cached_membership = cache.get(
 | 
			
		||||
            f"membership_{self.club.id}_{self.public_user.id}"
 | 
			
		||||
        )
 | 
			
		||||
        assert cached_membership == "not_member"
 | 
			
		||||
        assert self.toto.is_in_group(name=meta_groups_members) is False
 | 
			
		||||
 | 
			
		||||
    def test_cache_properly_cleared_group(self):
 | 
			
		||||
        """Test that when a user is removed from a group,
 | 
			
		||||
        the is_in_group_method return False when calling it again.
 | 
			
		||||
        """
 | 
			
		||||
        # testing with pk
 | 
			
		||||
        self.toto.groups.add(self.com_admin.pk)
 | 
			
		||||
        assert self.toto.is_in_group(pk=self.com_admin.pk) is True
 | 
			
		||||
        self.public_user.groups.add(self.com_admin.pk)
 | 
			
		||||
        assert self.public_user.is_in_group(pk=self.com_admin.pk) is True
 | 
			
		||||
 | 
			
		||||
        self.toto.groups.remove(self.com_admin.pk)
 | 
			
		||||
        assert self.toto.is_in_group(pk=self.com_admin.pk) is False
 | 
			
		||||
        self.public_user.groups.remove(self.com_admin.pk)
 | 
			
		||||
        assert self.public_user.is_in_group(pk=self.com_admin.pk) is False
 | 
			
		||||
 | 
			
		||||
        # testing with name
 | 
			
		||||
        self.toto.groups.add(self.sas_admin.pk)
 | 
			
		||||
        assert self.toto.is_in_group(name="SAS admin") is True
 | 
			
		||||
        self.public_user.groups.add(self.sas_admin.pk)
 | 
			
		||||
        assert self.public_user.is_in_group(name="SAS admin") is True
 | 
			
		||||
 | 
			
		||||
        self.toto.groups.remove(self.sas_admin.pk)
 | 
			
		||||
        assert self.toto.is_in_group(name="SAS admin") is False
 | 
			
		||||
        self.public_user.groups.remove(self.sas_admin.pk)
 | 
			
		||||
        assert self.public_user.is_in_group(name="SAS admin") is False
 | 
			
		||||
 | 
			
		||||
    def test_not_existing_group(self):
 | 
			
		||||
        """Test that searching for a not existing group
 | 
			
		||||
        returns False.
 | 
			
		||||
        """
 | 
			
		||||
        assert self.skia.is_in_group(name="This doesn't exist") is False
 | 
			
		||||
        user = baker.make(User)
 | 
			
		||||
        user.groups.set(list(Group.objects.all()))
 | 
			
		||||
        assert not user.is_in_group(name="This doesn't exist")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestDateUtils(TestCase):
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,7 @@ from PIL import Image
 | 
			
		||||
from pytest_django.asserts import assertNumQueries
 | 
			
		||||
 | 
			
		||||
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
 | 
			
		||||
from core.models import Group, RealGroup, SithFile, User
 | 
			
		||||
from core.models import Group, SithFile, User
 | 
			
		||||
from sas.models import Picture
 | 
			
		||||
from sith import settings
 | 
			
		||||
 | 
			
		||||
@@ -26,12 +26,10 @@ class TestImageAccess:
 | 
			
		||||
        [
 | 
			
		||||
            lambda: baker.make(User, is_superuser=True),
 | 
			
		||||
            lambda: baker.make(
 | 
			
		||||
                User,
 | 
			
		||||
                groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)],
 | 
			
		||||
                User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
 | 
			
		||||
            ),
 | 
			
		||||
            lambda: baker.make(
 | 
			
		||||
                User,
 | 
			
		||||
                groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)],
 | 
			
		||||
                User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
 | 
			
		||||
            ),
 | 
			
		||||
        ],
 | 
			
		||||
    )
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,7 @@
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
import mimetypes
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from urllib.parse import quote, urljoin
 | 
			
		||||
 | 
			
		||||
# This file contains all the views that concern the page model
 | 
			
		||||
@@ -21,6 +22,7 @@ from wsgiref.util import FileWrapper
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.core.exceptions import PermissionDenied
 | 
			
		||||
from django.db.models import Exists, OuterRef
 | 
			
		||||
from django.forms.models import modelform_factory
 | 
			
		||||
from django.http import Http404, HttpRequest, HttpResponse
 | 
			
		||||
from django.shortcuts import get_object_or_404, redirect
 | 
			
		||||
@@ -31,7 +33,7 @@ from django.views.generic import DetailView, ListView
 | 
			
		||||
from django.views.generic.detail import SingleObjectMixin
 | 
			
		||||
from django.views.generic.edit import DeleteView, FormMixin, UpdateView
 | 
			
		||||
 | 
			
		||||
from core.models import Notification, RealGroup, SithFile, User
 | 
			
		||||
from core.models import Notification, SithFile, User
 | 
			
		||||
from core.views import (
 | 
			
		||||
    AllowFragment,
 | 
			
		||||
    CanEditMixin,
 | 
			
		||||
@@ -47,6 +49,41 @@ from core.views.widgets.select import (
 | 
			
		||||
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(
 | 
			
		||||
    request: HttpRequest,
 | 
			
		||||
    file_id: int,
 | 
			
		||||
@@ -65,28 +102,7 @@ def send_file(
 | 
			
		||||
        raise PermissionDenied
 | 
			
		||||
    name = getattr(f, file_attr).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
 | 
			
		||||
    return send_raw_file(settings.MEDIA_ROOT / name)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MultipleFileInput(forms.ClearableFileInput):
 | 
			
		||||
@@ -159,19 +175,18 @@ class AddFilesForm(forms.Form):
 | 
			
		||||
                    % {"file_name": f, "msg": repr(e)},
 | 
			
		||||
                )
 | 
			
		||||
        if notif:
 | 
			
		||||
            for u in (
 | 
			
		||||
                RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
 | 
			
		||||
                .first()
 | 
			
		||||
                .users.all()
 | 
			
		||||
            unread_notif_subquery = Notification.objects.filter(
 | 
			
		||||
                user=OuterRef("pk"), type="FILE_MODERATION", viewed=False
 | 
			
		||||
            )
 | 
			
		||||
            for user in User.objects.filter(
 | 
			
		||||
                ~Exists(unread_notif_subquery),
 | 
			
		||||
                groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
 | 
			
		||||
            ):
 | 
			
		||||
                if not u.notifications.filter(
 | 
			
		||||
                    type="FILE_MODERATION", viewed=False
 | 
			
		||||
                ).exists():
 | 
			
		||||
                    Notification(
 | 
			
		||||
                        user=u,
 | 
			
		||||
                        url=reverse("core:file_moderation"),
 | 
			
		||||
                        type="FILE_MODERATION",
 | 
			
		||||
                    ).save()
 | 
			
		||||
                Notification.objects.create(
 | 
			
		||||
                    user=user,
 | 
			
		||||
                    url=reverse("core:file_moderation"),
 | 
			
		||||
                    type="FILE_MODERATION",
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FileListView(ListView):
 | 
			
		||||
 
 | 
			
		||||
@@ -21,6 +21,7 @@
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
import re
 | 
			
		||||
from datetime import date, datetime
 | 
			
		||||
from io import BytesIO
 | 
			
		||||
 | 
			
		||||
from captcha.fields import CaptchaField
 | 
			
		||||
@@ -37,14 +38,16 @@ from django.forms import (
 | 
			
		||||
    DateInput,
 | 
			
		||||
    DateTimeInput,
 | 
			
		||||
    TextInput,
 | 
			
		||||
    Widget,
 | 
			
		||||
)
 | 
			
		||||
from django.utils.timezone import now
 | 
			
		||||
from django.utils.translation import gettext
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
 | 
			
		||||
from PIL import Image
 | 
			
		||||
 | 
			
		||||
from antispam.forms import AntiSpamEmailField
 | 
			
		||||
from core.models import Gift, Page, SithFile, User
 | 
			
		||||
from core.models import Gift, Group, Page, SithFile, User
 | 
			
		||||
from core.utils import resize_image
 | 
			
		||||
from core.views.widgets.select import (
 | 
			
		||||
    AutoCompleteSelect,
 | 
			
		||||
@@ -130,6 +133,23 @@ class SelectUser(TextInput):
 | 
			
		||||
        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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -167,14 +187,15 @@ class RegisteringForm(UserCreationForm):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = User
 | 
			
		||||
        fields = ("first_name", "last_name", "email")
 | 
			
		||||
        field_classes = {
 | 
			
		||||
            "email": AntiSpamEmailField,
 | 
			
		||||
        }
 | 
			
		||||
        field_classes = {"email": AntiSpamEmailField}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserProfileForm(forms.ModelForm):
 | 
			
		||||
    """Form handling the user profile, managing the files"""
 | 
			
		||||
 | 
			
		||||
    required_css_class = "required"
 | 
			
		||||
    error_css_class = "error"
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = User
 | 
			
		||||
        fields = [
 | 
			
		||||
@@ -287,15 +308,20 @@ class UserProfileForm(forms.ModelForm):
 | 
			
		||||
        self._post_clean()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserPropForm(forms.ModelForm):
 | 
			
		||||
class UserGroupsForm(forms.ModelForm):
 | 
			
		||||
    error_css_class = "error"
 | 
			
		||||
    required_css_class = "required"
 | 
			
		||||
 | 
			
		||||
    groups = forms.ModelMultipleChoiceField(
 | 
			
		||||
        queryset=Group.objects.filter(is_manually_manageable=True),
 | 
			
		||||
        widget=CheckboxSelectMultiple,
 | 
			
		||||
        label=_("Groups"),
 | 
			
		||||
        required=False,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = User
 | 
			
		||||
        fields = ["groups"]
 | 
			
		||||
        help_texts = {"groups": "Which groups this user belongs to"}
 | 
			
		||||
        widgets = {"groups": CheckboxSelectMultiple}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserGodfathersForm(forms.Form):
 | 
			
		||||
 
 | 
			
		||||
@@ -21,11 +21,9 @@ from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django.views.generic import ListView
 | 
			
		||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
 | 
			
		||||
 | 
			
		||||
from core.models import RealGroup, User
 | 
			
		||||
from core.models import Group, User
 | 
			
		||||
from core.views import CanCreateMixin, CanEditMixin, DetailFormView
 | 
			
		||||
from core.views.widgets.select import (
 | 
			
		||||
    AutoCompleteSelectMultipleUser,
 | 
			
		||||
)
 | 
			
		||||
from core.views.widgets.select import AutoCompleteSelectMultipleUser
 | 
			
		||||
 | 
			
		||||
# Forms
 | 
			
		||||
 | 
			
		||||
@@ -59,7 +57,8 @@ class EditMembersForm(forms.Form):
 | 
			
		||||
class GroupListView(CanEditMixin, ListView):
 | 
			
		||||
    """Displays the Group list."""
 | 
			
		||||
 | 
			
		||||
    model = RealGroup
 | 
			
		||||
    model = Group
 | 
			
		||||
    queryset = Group.objects.filter(is_manually_manageable=True)
 | 
			
		||||
    ordering = ["name"]
 | 
			
		||||
    template_name = "core/group_list.jinja"
 | 
			
		||||
 | 
			
		||||
@@ -67,7 +66,8 @@ class GroupListView(CanEditMixin, ListView):
 | 
			
		||||
class GroupEditView(CanEditMixin, UpdateView):
 | 
			
		||||
    """Edit infos of a Group."""
 | 
			
		||||
 | 
			
		||||
    model = RealGroup
 | 
			
		||||
    model = Group
 | 
			
		||||
    queryset = Group.objects.filter(is_manually_manageable=True)
 | 
			
		||||
    pk_url_kwarg = "group_id"
 | 
			
		||||
    template_name = "core/group_edit.jinja"
 | 
			
		||||
    fields = ["name", "description"]
 | 
			
		||||
@@ -76,7 +76,8 @@ class GroupEditView(CanEditMixin, UpdateView):
 | 
			
		||||
class GroupCreateView(CanCreateMixin, CreateView):
 | 
			
		||||
    """Add a new Group."""
 | 
			
		||||
 | 
			
		||||
    model = RealGroup
 | 
			
		||||
    model = Group
 | 
			
		||||
    queryset = Group.objects.filter(is_manually_manageable=True)
 | 
			
		||||
    template_name = "core/create.jinja"
 | 
			
		||||
    fields = ["name", "description"]
 | 
			
		||||
 | 
			
		||||
@@ -86,7 +87,8 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
 | 
			
		||||
    Allow adding and removing users from it.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    model = RealGroup
 | 
			
		||||
    model = Group
 | 
			
		||||
    queryset = Group.objects.filter(is_manually_manageable=True)
 | 
			
		||||
    form_class = EditMembersForm
 | 
			
		||||
    pk_url_kwarg = "group_id"
 | 
			
		||||
    template_name = "core/group_detail.jinja"
 | 
			
		||||
@@ -120,7 +122,8 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
 | 
			
		||||
class GroupDeleteView(CanEditMixin, DeleteView):
 | 
			
		||||
    """Delete a Group."""
 | 
			
		||||
 | 
			
		||||
    model = RealGroup
 | 
			
		||||
    model = Group
 | 
			
		||||
    queryset = Group.objects.filter(is_manually_manageable=True)
 | 
			
		||||
    pk_url_kwarg = "group_id"
 | 
			
		||||
    template_name = "core/delete_confirm.jinja"
 | 
			
		||||
    success_url = reverse_lazy("core:group_list")
 | 
			
		||||
 
 | 
			
		||||
@@ -64,16 +64,20 @@ class PageView(CanViewMixin, DetailView):
 | 
			
		||||
class PageHistView(CanViewMixin, DetailView):
 | 
			
		||||
    model = Page
 | 
			
		||||
    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):
 | 
			
		||||
        res = super().dispatch(request, *args, **kwargs)
 | 
			
		||||
        if self.object.need_club_redirection:
 | 
			
		||||
            return redirect("club:club_hist", club_id=self.object.club.id)
 | 
			
		||||
        return res
 | 
			
		||||
        page = self.get_object()
 | 
			
		||||
        if page.need_club_redirection:
 | 
			
		||||
            return redirect("club:club_hist", club_id=page.club.id)
 | 
			
		||||
        return super().dispatch(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def get_object(self):
 | 
			
		||||
        self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
 | 
			
		||||
        return self.page
 | 
			
		||||
    def get_object(self, *args, **kwargs):
 | 
			
		||||
        if not self._cached_object:
 | 
			
		||||
            self._cached_object = super().get_object()
 | 
			
		||||
        return self._cached_object
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PageRevView(CanViewMixin, DetailView):
 | 
			
		||||
 
 | 
			
		||||
@@ -35,7 +35,6 @@ from django.contrib.auth.mixins import LoginRequiredMixin
 | 
			
		||||
from django.core.exceptions import PermissionDenied
 | 
			
		||||
from django.db.models import DateField, QuerySet
 | 
			
		||||
from django.db.models.functions import Trunc
 | 
			
		||||
from django.forms import CheckboxSelectMultiple
 | 
			
		||||
from django.forms.models import modelform_factory
 | 
			
		||||
from django.http import Http404
 | 
			
		||||
from django.shortcuts import get_object_or_404, redirect
 | 
			
		||||
@@ -68,6 +67,7 @@ from core.views.forms import (
 | 
			
		||||
    LoginForm,
 | 
			
		||||
    RegisteringForm,
 | 
			
		||||
    UserGodfathersForm,
 | 
			
		||||
    UserGroupsForm,
 | 
			
		||||
    UserProfileForm,
 | 
			
		||||
)
 | 
			
		||||
from counter.models import Refilling, Selling
 | 
			
		||||
@@ -583,9 +583,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView):
 | 
			
		||||
    model = User
 | 
			
		||||
    pk_url_kwarg = "user_id"
 | 
			
		||||
    template_name = "core/user_group.jinja"
 | 
			
		||||
    form_class = modelform_factory(
 | 
			
		||||
        User, fields=["groups"], widgets={"groups": CheckboxSelectMultiple}
 | 
			
		||||
    )
 | 
			
		||||
    form_class = UserGroupsForm
 | 
			
		||||
    context_object_name = "profile"
 | 
			
		||||
    current_tab = "groups"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -129,7 +129,7 @@ class PermanencyAdmin(SearchModelAdmin):
 | 
			
		||||
 | 
			
		||||
@admin.register(ProductType)
 | 
			
		||||
class ProductTypeAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ("name", "priority")
 | 
			
		||||
    list_display = ("name", "order")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(CashRegisterSummary)
 | 
			
		||||
 
 | 
			
		||||
@@ -12,24 +12,33 @@
 | 
			
		||||
# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
from typing import Annotated
 | 
			
		||||
 | 
			
		||||
from annotated_types import MinLen
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db.models import F
 | 
			
		||||
from django.shortcuts import get_object_or_404
 | 
			
		||||
from ninja import Query
 | 
			
		||||
from ninja_extra import ControllerBase, api_controller, paginate, route
 | 
			
		||||
from ninja_extra.pagination import PageNumberPaginationExtra
 | 
			
		||||
from ninja_extra.schemas import PaginatedResponseSchema
 | 
			
		||||
 | 
			
		||||
from core.api_permissions import CanAccessLookup, CanView, IsRoot
 | 
			
		||||
from counter.models import Counter, Product
 | 
			
		||||
from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
 | 
			
		||||
from counter.models import Counter, Product, ProductType
 | 
			
		||||
from counter.schemas import (
 | 
			
		||||
    CounterFilterSchema,
 | 
			
		||||
    CounterSchema,
 | 
			
		||||
    ProductFilterSchema,
 | 
			
		||||
    ProductSchema,
 | 
			
		||||
    ProductTypeSchema,
 | 
			
		||||
    ReorderProductTypeSchema,
 | 
			
		||||
    SimpleProductSchema,
 | 
			
		||||
    SimplifiedCounterSchema,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
IsCounterAdmin = (
 | 
			
		||||
    IsRoot
 | 
			
		||||
    | IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID)
 | 
			
		||||
    | IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@api_controller("/counter")
 | 
			
		||||
class CounterController(ControllerBase):
 | 
			
		||||
@@ -64,15 +73,72 @@ class CounterController(ControllerBase):
 | 
			
		||||
class ProductController(ControllerBase):
 | 
			
		||||
    @route.get(
 | 
			
		||||
        "/search",
 | 
			
		||||
        response=PaginatedResponseSchema[ProductSchema],
 | 
			
		||||
        response=PaginatedResponseSchema[SimpleProductSchema],
 | 
			
		||||
        permissions=[CanAccessLookup],
 | 
			
		||||
    )
 | 
			
		||||
    @paginate(PageNumberPaginationExtra, page_size=50)
 | 
			
		||||
    def search_products(self, search: Annotated[str, MinLen(1)]):
 | 
			
		||||
        return (
 | 
			
		||||
            Product.objects.filter(
 | 
			
		||||
                Q(name__icontains=search) | Q(code__icontains=search)
 | 
			
		||||
            )
 | 
			
		||||
            .filter(archived=False)
 | 
			
		||||
            .values()
 | 
			
		||||
    def search_products(self, filters: Query[ProductFilterSchema]):
 | 
			
		||||
        return filters.filter(
 | 
			
		||||
            Product.objects.order_by(
 | 
			
		||||
                F("product_type__order").asc(nulls_last=True),
 | 
			
		||||
                "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",
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@api_controller("/product-type", permissions=[IsCounterAdmin])
 | 
			
		||||
class ProductTypeController(ControllerBase):
 | 
			
		||||
    @route.get("", response=list[ProductTypeSchema], url_name="fetch_product_types")
 | 
			
		||||
    def fetch_all(self):
 | 
			
		||||
        return ProductType.objects.order_by("order")
 | 
			
		||||
 | 
			
		||||
    @route.patch("/{type_id}/move")
 | 
			
		||||
    def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]):
 | 
			
		||||
        """Change the order of a product type.
 | 
			
		||||
 | 
			
		||||
        To use this route, give either the id of the product type
 | 
			
		||||
        this one should be above of,
 | 
			
		||||
        of the id of the product type this one should be below of.
 | 
			
		||||
 | 
			
		||||
        Order affects the display order of the product types.
 | 
			
		||||
 | 
			
		||||
        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,6 +24,12 @@
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
PAYMENT_METHOD = [
 | 
			
		||||
    ("CHECK", _("Check")),
 | 
			
		||||
    ("CASH", _("Cash")),
 | 
			
		||||
    ("CARD", _("Credit card")),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CounterConfig(AppConfig):
 | 
			
		||||
    name = "counter"
 | 
			
		||||
 
 | 
			
		||||
@@ -47,6 +47,8 @@ class BillingInfoForm(forms.ModelForm):
 | 
			
		||||
class StudentCardForm(forms.ModelForm):
 | 
			
		||||
    """Form for adding student cards"""
 | 
			
		||||
 | 
			
		||||
    error_css_class = "error"
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = StudentCard
 | 
			
		||||
        fields = ["uid"]
 | 
			
		||||
@@ -87,7 +89,7 @@ class GetUserForm(forms.Form):
 | 
			
		||||
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        cleaned_data = super().clean()
 | 
			
		||||
        cus = None
 | 
			
		||||
        customer = None
 | 
			
		||||
        if cleaned_data["code"] != "":
 | 
			
		||||
            if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
 | 
			
		||||
                card = (
 | 
			
		||||
@@ -96,21 +98,24 @@ class GetUserForm(forms.Form):
 | 
			
		||||
                    .first()
 | 
			
		||||
                )
 | 
			
		||||
                if card is not None:
 | 
			
		||||
                    cus = card.customer
 | 
			
		||||
            if cus is None:
 | 
			
		||||
                cus = Customer.objects.filter(
 | 
			
		||||
                    customer = card.customer
 | 
			
		||||
            if customer is None:
 | 
			
		||||
                customer = Customer.objects.filter(
 | 
			
		||||
                    account_id__iexact=cleaned_data["code"]
 | 
			
		||||
                ).first()
 | 
			
		||||
        elif cleaned_data["id"] is not None:
 | 
			
		||||
            cus = Customer.objects.filter(user=cleaned_data["id"]).first()
 | 
			
		||||
        if cus is None or not cus.can_buy:
 | 
			
		||||
        elif cleaned_data["id"]:
 | 
			
		||||
            customer = Customer.objects.filter(user=cleaned_data["id"]).first()
 | 
			
		||||
 | 
			
		||||
        if customer is None or not customer.can_buy:
 | 
			
		||||
            raise forms.ValidationError(_("User not found"))
 | 
			
		||||
        cleaned_data["user_id"] = cus.user.id
 | 
			
		||||
        cleaned_data["user"] = cus.user
 | 
			
		||||
        cleaned_data["user_id"] = customer.user.id
 | 
			
		||||
        cleaned_data["user"] = customer.user
 | 
			
		||||
        return cleaned_data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RefillForm(forms.ModelForm):
 | 
			
		||||
    allowed_refilling_methods = ["CASH", "CARD"]
 | 
			
		||||
 | 
			
		||||
    error_css_class = "error"
 | 
			
		||||
    required_css_class = "required"
 | 
			
		||||
    amount = forms.FloatField(
 | 
			
		||||
@@ -120,6 +125,21 @@ class RefillForm(forms.ModelForm):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Refilling
 | 
			
		||||
        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):
 | 
			
		||||
@@ -134,6 +154,9 @@ class CounterEditForm(forms.ModelForm):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProductEditForm(forms.ModelForm):
 | 
			
		||||
    error_css_class = "error"
 | 
			
		||||
    required_css_class = "required"
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Product
 | 
			
		||||
        fields = [
 | 
			
		||||
@@ -151,6 +174,12 @@ class ProductEditForm(forms.ModelForm):
 | 
			
		||||
            "tray",
 | 
			
		||||
            "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 = {
 | 
			
		||||
            "product_type": AutoCompleteSelect,
 | 
			
		||||
            "buying_groups": AutoCompleteSelectMultipleGroup,
 | 
			
		||||
 
 | 
			
		||||
@@ -127,7 +127,7 @@ class Command(BaseCommand):
 | 
			
		||||
 | 
			
		||||
        # dumps and sales are linked to the same customers
 | 
			
		||||
        # and or both ordered with the same key, so zipping them is valid
 | 
			
		||||
        for dump, sale in zip(pending_dumps, sales):
 | 
			
		||||
        for dump, sale in zip(pending_dumps, sales, strict=False):
 | 
			
		||||
            dump.dump_operation = sale
 | 
			
		||||
        AccountDump.objects.bulk_update(pending_dumps, ["dump_operation"])
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,38 +1,6 @@
 | 
			
		||||
from __future__ import unicode_literals
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
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):
 | 
			
		||||
@@ -44,5 +12,4 @@ class Migration(migrations.Migration):
 | 
			
		||||
            name="recorded_products",
 | 
			
		||||
            field=models.IntegerField(verbose_name="recorded items", default=0),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(balance_ecocups),
 | 
			
		||||
    ]
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										22
									
								
								counter/migrations/0027_alter_refilling_payment_method.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								counter/migrations/0027_alter_refilling_payment_method.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
# 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",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -0,0 +1,62 @@
 | 
			
		||||
# 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"),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										17
									
								
								counter/migrations/0029_alter_selling_label.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								counter/migrations/0029_alter_selling_label.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
# 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"),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -21,7 +21,7 @@ import string
 | 
			
		||||
from datetime import date, datetime, timedelta
 | 
			
		||||
from datetime import timezone as tz
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
from typing import Self, Tuple
 | 
			
		||||
from typing import Self
 | 
			
		||||
 | 
			
		||||
from dict2xml import dict2xml
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
@@ -35,6 +35,7 @@ from django.utils import timezone
 | 
			
		||||
from django.utils.functional import cached_property
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django_countries.fields import CountryField
 | 
			
		||||
from ordered_model.models import OrderedModel
 | 
			
		||||
from phonenumber_field.modelfields import PhoneNumberField
 | 
			
		||||
 | 
			
		||||
from accounting.models import CurrencyField
 | 
			
		||||
@@ -42,7 +43,8 @@ from club.models import Club
 | 
			
		||||
from core.fields import ResizedImageField
 | 
			
		||||
from core.models import Group, Notification, User
 | 
			
		||||
from core.utils import get_start_of_semester
 | 
			
		||||
from sith.settings import SITH_COUNTER_OFFICES, SITH_MAIN_CLUB
 | 
			
		||||
from counter.apps import PAYMENT_METHOD
 | 
			
		||||
from sith.settings import SITH_MAIN_CLUB
 | 
			
		||||
from subscription.models import Subscription
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -136,7 +138,7 @@ class Customer(models.Model):
 | 
			
		||||
        return (date.today() - subscription.subscription_end) < timedelta(days=90)
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def get_or_create(cls, user: User) -> Tuple[Customer, bool]:
 | 
			
		||||
    def get_or_create(cls, user: User) -> tuple[Customer, bool]:
 | 
			
		||||
        """Work in pretty much the same way as the usual get_or_create method,
 | 
			
		||||
        but with the default field replaced by some under the hood.
 | 
			
		||||
 | 
			
		||||
@@ -288,32 +290,32 @@ class AccountDump(models.Model):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProductType(models.Model):
 | 
			
		||||
class ProductType(OrderedModel):
 | 
			
		||||
    """A product type.
 | 
			
		||||
 | 
			
		||||
    Useful only for categorizing.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    name = models.CharField(_("name"), max_length=30)
 | 
			
		||||
    description = models.TextField(_("description"), null=True, blank=True)
 | 
			
		||||
    comment = models.TextField(_("comment"), null=True, blank=True)
 | 
			
		||||
    description = models.TextField(_("description"), default="")
 | 
			
		||||
    comment = models.TextField(
 | 
			
		||||
        _("comment"),
 | 
			
		||||
        default="",
 | 
			
		||||
        help_text=_("A text that will be shown on the eboutic."),
 | 
			
		||||
    )
 | 
			
		||||
    icon = ResizedImageField(
 | 
			
		||||
        height=70, force_format="WEBP", upload_to="products", null=True, blank=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # priority holds no real backend logic but helps to handle the order in which
 | 
			
		||||
    # the items are to be shown to the user
 | 
			
		||||
    priority = models.PositiveIntegerField(default=0)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("product type")
 | 
			
		||||
        ordering = ["-priority", "name"]
 | 
			
		||||
        ordering = ["order"]
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
 | 
			
		||||
    def get_absolute_url(self):
 | 
			
		||||
        return reverse("counter:producttype_list")
 | 
			
		||||
        return reverse("counter:product_type_list")
 | 
			
		||||
 | 
			
		||||
    def is_owned_by(self, user):
 | 
			
		||||
        """Method to see if that object can be edited by the given user."""
 | 
			
		||||
@@ -325,6 +327,8 @@ class ProductType(models.Model):
 | 
			
		||||
class Product(models.Model):
 | 
			
		||||
    """A product, with all its related information."""
 | 
			
		||||
 | 
			
		||||
    QUANTITY_FOR_TRAY_PRICE = 6
 | 
			
		||||
 | 
			
		||||
    name = models.CharField(_("name"), max_length=64)
 | 
			
		||||
    description = models.TextField(_("description"), default="")
 | 
			
		||||
    product_type = models.ForeignKey(
 | 
			
		||||
@@ -523,14 +527,17 @@ class Counter(models.Model):
 | 
			
		||||
        if user.is_anonymous:
 | 
			
		||||
            return False
 | 
			
		||||
        mem = self.club.get_membership_for(user)
 | 
			
		||||
        if mem and mem.role >= 7:
 | 
			
		||||
        if mem and mem.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
 | 
			
		||||
            return True
 | 
			
		||||
        return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
 | 
			
		||||
 | 
			
		||||
    def can_be_viewed_by(self, user: User) -> bool:
 | 
			
		||||
        if self.type == "BAR":
 | 
			
		||||
            return True
 | 
			
		||||
        return user.is_board_member or user in self.sellers.all()
 | 
			
		||||
        return (
 | 
			
		||||
            self.type == "BAR"
 | 
			
		||||
            or user.is_root
 | 
			
		||||
            or user.is_in_group(pk=self.club.board_group_id)
 | 
			
		||||
            or user in self.sellers.all()
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def gen_token(self) -> None:
 | 
			
		||||
        """Generate a new token for this counter."""
 | 
			
		||||
@@ -558,9 +565,6 @@ class Counter(models.Model):
 | 
			
		||||
        """Show if the counter authorize the refilling with physic money."""
 | 
			
		||||
        if self.type != "BAR":
 | 
			
		||||
            return False
 | 
			
		||||
        if self.id in SITH_COUNTER_OFFICES:
 | 
			
		||||
            # If the counter is either 'AE' or 'BdF', refills are authorized
 | 
			
		||||
            return True
 | 
			
		||||
        # at least one of the barmen is in the AE board
 | 
			
		||||
        ae = Club.objects.get(unix_name=SITH_MAIN_CLUB["unix_name"])
 | 
			
		||||
        return any(ae.get_membership_for(barman) for barman in self.barmen_list)
 | 
			
		||||
@@ -650,6 +654,42 @@ class Counter(models.Model):
 | 
			
		||||
            )
 | 
			
		||||
        )["total"]
 | 
			
		||||
 | 
			
		||||
    def customer_is_barman(self, customer: Customer | User) -> bool:
 | 
			
		||||
        """Check if this counter is a `bar` and if the customer is currently logged in.
 | 
			
		||||
        This is useful to compute special prices."""
 | 
			
		||||
 | 
			
		||||
        # Customer and User are two different tables,
 | 
			
		||||
        # but they share the same primary key
 | 
			
		||||
        return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
 | 
			
		||||
 | 
			
		||||
    def get_products_for(self, customer: Customer) -> list[Product]:
 | 
			
		||||
        """
 | 
			
		||||
        Get all allowed products for the provided customer on this counter
 | 
			
		||||
        Prices will be annotated
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        products = self.products.select_related("product_type").prefetch_related(
 | 
			
		||||
            "buying_groups"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Only include age appropriate products
 | 
			
		||||
        age = customer.user.age
 | 
			
		||||
        if customer.user.is_banned_alcohol:
 | 
			
		||||
            age = min(age, 17)
 | 
			
		||||
        products = products.filter(limit_age__lte=age)
 | 
			
		||||
 | 
			
		||||
        # Compute special price for customer if he is a barmen on that bar
 | 
			
		||||
        if self.customer_is_barman(customer):
 | 
			
		||||
            products = products.annotate(price=F("special_selling_price"))
 | 
			
		||||
        else:
 | 
			
		||||
            products = products.annotate(price=F("selling_price"))
 | 
			
		||||
 | 
			
		||||
        return [
 | 
			
		||||
            product
 | 
			
		||||
            for product in products.all()
 | 
			
		||||
            if product.can_be_sold_to(customer.user)
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RefillingQuerySet(models.QuerySet):
 | 
			
		||||
    def annotate_total(self) -> Self:
 | 
			
		||||
@@ -688,8 +728,8 @@ class Refilling(models.Model):
 | 
			
		||||
    payment_method = models.CharField(
 | 
			
		||||
        _("payment method"),
 | 
			
		||||
        max_length=255,
 | 
			
		||||
        choices=settings.SITH_COUNTER_PAYMENT_METHOD,
 | 
			
		||||
        default="CASH",
 | 
			
		||||
        choices=PAYMENT_METHOD,
 | 
			
		||||
        default="CARD",
 | 
			
		||||
    )
 | 
			
		||||
    bank = models.CharField(
 | 
			
		||||
        _("bank"), max_length=255, choices=settings.SITH_COUNTER_BANK, default="OTHER"
 | 
			
		||||
@@ -754,7 +794,8 @@ class SellingQuerySet(models.QuerySet):
 | 
			
		||||
class Selling(models.Model):
 | 
			
		||||
    """Handle the sellings."""
 | 
			
		||||
 | 
			
		||||
    label = models.CharField(_("label"), max_length=64)
 | 
			
		||||
    # We make sure that sellings have a way begger label than any product name is allowed to
 | 
			
		||||
    label = models.CharField(_("label"), max_length=128)
 | 
			
		||||
    product = models.ForeignKey(
 | 
			
		||||
        Product,
 | 
			
		||||
        related_name="sellings",
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,13 @@
 | 
			
		||||
from typing import Annotated
 | 
			
		||||
from typing import Annotated, Self
 | 
			
		||||
 | 
			
		||||
from annotated_types import MinLen
 | 
			
		||||
from ninja import Field, FilterSchema, ModelSchema
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from ninja import Field, FilterSchema, ModelSchema, Schema
 | 
			
		||||
from pydantic import model_validator
 | 
			
		||||
 | 
			
		||||
from core.schemas import SimpleUserSchema
 | 
			
		||||
from counter.models import Counter, Product
 | 
			
		||||
from club.schemas import ClubSchema
 | 
			
		||||
from core.schemas import GroupSchema, SimpleUserSchema
 | 
			
		||||
from counter.models import Counter, Product, ProductType
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CounterSchema(ModelSchema):
 | 
			
		||||
@@ -26,7 +29,72 @@ class SimplifiedCounterSchema(ModelSchema):
 | 
			
		||||
        fields = ["id", "name"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProductSchema(ModelSchema):
 | 
			
		||||
class ProductTypeSchema(ModelSchema):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = ProductType
 | 
			
		||||
        fields = ["id", "name", "description", "comment", "icon", "order"]
 | 
			
		||||
 | 
			
		||||
    url: str
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def resolve_url(obj: ProductType) -> str:
 | 
			
		||||
        return reverse("counter:product_type_edit", kwargs={"type_id": obj.id})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SimpleProductTypeSchema(ModelSchema):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = ProductType
 | 
			
		||||
        fields = ["id", "name"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReorderProductTypeSchema(Schema):
 | 
			
		||||
    below: int | None = None
 | 
			
		||||
    above: int | None = None
 | 
			
		||||
 | 
			
		||||
    @model_validator(mode="after")
 | 
			
		||||
    def validate_exclusive(self) -> Self:
 | 
			
		||||
        if self.below is None and self.above is None:
 | 
			
		||||
            raise ValueError("Either 'below' or 'above' must be set.")
 | 
			
		||||
        if self.below is not None and self.above is not None:
 | 
			
		||||
            raise ValueError("Only one of 'below' or 'above' must be set.")
 | 
			
		||||
        return self
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SimpleProductSchema(ModelSchema):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Product
 | 
			
		||||
        fields = ["id", "name", "code"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProductSchema(ModelSchema):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Product
 | 
			
		||||
        fields = [
 | 
			
		||||
            "id",
 | 
			
		||||
            "name",
 | 
			
		||||
            "code",
 | 
			
		||||
            "description",
 | 
			
		||||
            "purchase_price",
 | 
			
		||||
            "selling_price",
 | 
			
		||||
            "icon",
 | 
			
		||||
            "limit_age",
 | 
			
		||||
            "archived",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    buying_groups: list[GroupSchema]
 | 
			
		||||
    club: ClubSchema
 | 
			
		||||
    product_type: SimpleProductTypeSchema | None
 | 
			
		||||
    url: str
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def resolve_url(obj: Product) -> str:
 | 
			
		||||
        return reverse("counter:product_edit", kwargs={"product_id": obj.id})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProductFilterSchema(FilterSchema):
 | 
			
		||||
    search: Annotated[str, MinLen(1)] | None = Field(
 | 
			
		||||
        None, q=["name__icontains", "code__icontains"]
 | 
			
		||||
    )
 | 
			
		||||
    is_archived: bool | None = Field(None, q="archived")
 | 
			
		||||
    buying_groups: set[int] | None = Field(None, q="buying_groups__in")
 | 
			
		||||
    product_type: set[int] | None = Field(None, q="product_type__in")
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										25
									
								
								counter/static/bundled/counter/basket.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								counter/static/bundled/counter/basket.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
import type { Product } from "#counter:counter/types";
 | 
			
		||||
 | 
			
		||||
export class BasketItem {
 | 
			
		||||
  quantity: number;
 | 
			
		||||
  product: Product;
 | 
			
		||||
  quantityForTrayPrice: number;
 | 
			
		||||
  errors: string[];
 | 
			
		||||
 | 
			
		||||
  constructor(product: Product, quantity: number) {
 | 
			
		||||
    this.quantity = quantity;
 | 
			
		||||
    this.product = product;
 | 
			
		||||
    this.errors = [];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getBonusQuantity(): number {
 | 
			
		||||
    if (!this.product.hasTrayPrice) {
 | 
			
		||||
      return 0;
 | 
			
		||||
    }
 | 
			
		||||
    return Math.floor(this.quantity / this.product.quantityForTrayPrice);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  sum(): number {
 | 
			
		||||
    return (this.quantity - this.getBonusQuantity()) * this.product.price;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -4,9 +4,11 @@ import type { TomOption } from "tom-select/dist/types/types";
 | 
			
		||||
import type { escape_html } from "tom-select/dist/types/utils";
 | 
			
		||||
import {
 | 
			
		||||
  type CounterSchema,
 | 
			
		||||
  type ProductSchema,
 | 
			
		||||
  type ProductTypeSchema,
 | 
			
		||||
  type SimpleProductSchema,
 | 
			
		||||
  counterSearchCounter,
 | 
			
		||||
  productSearchProducts,
 | 
			
		||||
  producttypeFetchAll,
 | 
			
		||||
} from "#openapi";
 | 
			
		||||
 | 
			
		||||
@registerComponent("product-ajax-select")
 | 
			
		||||
@@ -23,17 +25,48 @@ export class ProductAjaxSelect extends AjaxSelect {
 | 
			
		||||
    return [];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected renderOption(item: ProductSchema, sanitize: typeof escape_html) {
 | 
			
		||||
  protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
 | 
			
		||||
    return `<div class="select-item">
 | 
			
		||||
            <span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span>
 | 
			
		||||
          </div>`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected renderItem(item: ProductSchema, sanitize: typeof escape_html) {
 | 
			
		||||
  protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
 | 
			
		||||
    return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@registerComponent("product-type-ajax-select")
 | 
			
		||||
export class ProductTypeAjaxSelect extends AjaxSelect {
 | 
			
		||||
  protected valueField = "id";
 | 
			
		||||
  protected labelField = "name";
 | 
			
		||||
  protected searchField = ["name"];
 | 
			
		||||
  private productTypes = null as ProductTypeSchema[];
 | 
			
		||||
 | 
			
		||||
  protected async search(query: string): Promise<TomOption[]> {
 | 
			
		||||
    // The production database has a grand total of 26 product types
 | 
			
		||||
    // and the filter logic is really simple.
 | 
			
		||||
    // Thus, it's appropriate to fetch all product types during first use,
 | 
			
		||||
    // then to reuse the result again and again.
 | 
			
		||||
    if (this.productTypes === null) {
 | 
			
		||||
      this.productTypes = (await producttypeFetchAll()).data || null;
 | 
			
		||||
    }
 | 
			
		||||
    return this.productTypes.filter((t) =>
 | 
			
		||||
      t.name.toLowerCase().includes(query.toLowerCase()),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected renderOption(item: ProductTypeSchema, sanitize: typeof escape_html) {
 | 
			
		||||
    return `<div class="select-item">
 | 
			
		||||
            <span class="select-item-text">${sanitize(item.name)}</span>
 | 
			
		||||
          </div>`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected renderItem(item: ProductTypeSchema, sanitize: typeof escape_html) {
 | 
			
		||||
    return `<span>${sanitize(item.name)}</span>`;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@registerComponent("counter-ajax-select")
 | 
			
		||||
export class CounterAjaxSelect extends AjaxSelect {
 | 
			
		||||
  protected valueField = "id";
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,82 @@
 | 
			
		||||
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base";
 | 
			
		||||
import { registerComponent } from "#core:utils/web-components";
 | 
			
		||||
import type { RecursivePartial, TomSettings } from "tom-select/dist/types/types";
 | 
			
		||||
 | 
			
		||||
const productParsingRegex = /^(\d+x)?(.*)/i;
 | 
			
		||||
const codeParsingRegex = / \((\w+)\)$/;
 | 
			
		||||
 | 
			
		||||
function parseProduct(query: string): [number, string] {
 | 
			
		||||
  const parsed = productParsingRegex.exec(query);
 | 
			
		||||
  return [Number.parseInt(parsed[1] || "1"), parsed[2]];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@registerComponent("counter-product-select")
 | 
			
		||||
export class CounterProductSelect extends AutoCompleteSelectBase {
 | 
			
		||||
  public getOperationCodes(): string[] {
 | 
			
		||||
    return ["FIN", "ANN"];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public getSelectedProduct(): [number, string] {
 | 
			
		||||
    return parseProduct(this.widget.getValue() as string);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected attachBehaviors(): void {
 | 
			
		||||
    this.allowMultipleProducts();
 | 
			
		||||
    this.parseCodes();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseCodes(): void {
 | 
			
		||||
    // We guess the code from the product name so we can prioritize search on it
 | 
			
		||||
    // If no code is found, we just ignore it and everything still is fine
 | 
			
		||||
    for (const option of Object.values(this.widget.options)) {
 | 
			
		||||
      const match = codeParsingRegex.exec(option.text);
 | 
			
		||||
      if (match !== null) {
 | 
			
		||||
        option.code = match[1];
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private allowMultipleProducts(): void {
 | 
			
		||||
    const search = this.widget.search;
 | 
			
		||||
    const onOptionSelect = this.widget.onOptionSelect;
 | 
			
		||||
    this.widget.hook("instead", "search", (query: string) => {
 | 
			
		||||
      return search.call(this.widget, parseProduct(query)[1]);
 | 
			
		||||
    });
 | 
			
		||||
    this.widget.hook(
 | 
			
		||||
      "instead",
 | 
			
		||||
      "onOptionSelect",
 | 
			
		||||
      (evt: MouseEvent | KeyboardEvent, option: HTMLElement) => {
 | 
			
		||||
        const [quantity, _] = parseProduct(this.widget.inputValue());
 | 
			
		||||
        const originalValue = option.getAttribute("data-value") ?? option.innerText;
 | 
			
		||||
 | 
			
		||||
        if (quantity === 1 || this.getOperationCodes().includes(originalValue)) {
 | 
			
		||||
          return onOptionSelect.call(this.widget, evt, option);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const value = `${quantity}x${originalValue}`;
 | 
			
		||||
        const label = `${quantity}x${option.innerText}`;
 | 
			
		||||
        this.widget.addOption({ value: value, text: label }, true);
 | 
			
		||||
        return onOptionSelect.call(
 | 
			
		||||
          this.widget,
 | 
			
		||||
          evt,
 | 
			
		||||
          this.widget.getOption(value, true),
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    this.widget.hook("after", "onOptionSelect", () => {
 | 
			
		||||
      /* Focus the next element if it's an input */
 | 
			
		||||
      if (this.nextElementSibling.nodeName === "INPUT") {
 | 
			
		||||
        (this.nextElementSibling as HTMLInputElement).focus();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  protected tomSelectSettings(): RecursivePartial<TomSettings> {
 | 
			
		||||
    /* We disable the dropdown on focus because we're going to always autofocus the widget */
 | 
			
		||||
    return {
 | 
			
		||||
      ...super.tomSelectSettings(),
 | 
			
		||||
      openOnFocus: false,
 | 
			
		||||
      searchField: ["code", "text"],
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										155
									
								
								counter/static/bundled/counter/counter-click-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								counter/static/bundled/counter/counter-click-index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,155 @@
 | 
			
		||||
import { exportToHtml } from "#core:utils/globals";
 | 
			
		||||
import { BasketItem } from "#counter:counter/basket";
 | 
			
		||||
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
 | 
			
		||||
 | 
			
		||||
exportToHtml("loadCounter", (config: CounterConfig) => {
 | 
			
		||||
  document.addEventListener("alpine:init", () => {
 | 
			
		||||
    Alpine.data("counter", () => ({
 | 
			
		||||
      basket: {} as Record<string, BasketItem>,
 | 
			
		||||
      errors: [],
 | 
			
		||||
      customerBalance: config.customerBalance,
 | 
			
		||||
      codeField: undefined,
 | 
			
		||||
      alertMessage: {
 | 
			
		||||
        content: "",
 | 
			
		||||
        show: false,
 | 
			
		||||
        timeout: null,
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      init() {
 | 
			
		||||
        // Fill the basket with the initial data
 | 
			
		||||
        for (const entry of config.formInitial) {
 | 
			
		||||
          if (entry.id !== undefined && entry.quantity !== undefined) {
 | 
			
		||||
            this.addToBasket(entry.id, entry.quantity);
 | 
			
		||||
            this.basket[entry.id].errors = entry.errors ?? [];
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.codeField = this.$refs.codeField;
 | 
			
		||||
        this.codeField.widget.focus();
 | 
			
		||||
 | 
			
		||||
        // It's quite tricky to manually apply attributes to the management part
 | 
			
		||||
        // of a formset so we dynamically apply it here
 | 
			
		||||
        this.$refs.basketManagementForm
 | 
			
		||||
          .querySelector("#id_form-TOTAL_FORMS")
 | 
			
		||||
          .setAttribute(":value", "getBasketSize()");
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      removeFromBasket(id: string) {
 | 
			
		||||
        delete this.basket[id];
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      addToBasket(id: string, quantity: number): ErrorMessage {
 | 
			
		||||
        const item: BasketItem =
 | 
			
		||||
          this.basket[id] || new BasketItem(config.products[id], 0);
 | 
			
		||||
 | 
			
		||||
        const oldQty = item.quantity;
 | 
			
		||||
        item.quantity += quantity;
 | 
			
		||||
 | 
			
		||||
        if (item.quantity <= 0) {
 | 
			
		||||
          delete this.basket[id];
 | 
			
		||||
          return "";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.basket[id] = item;
 | 
			
		||||
 | 
			
		||||
        if (this.sumBasket() > this.customerBalance) {
 | 
			
		||||
          item.quantity = oldQty;
 | 
			
		||||
          if (item.quantity === 0) {
 | 
			
		||||
            delete this.basket[id];
 | 
			
		||||
          }
 | 
			
		||||
          return gettext("Not enough money");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return "";
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      getBasketSize() {
 | 
			
		||||
        return Object.keys(this.basket).length;
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      sumBasket() {
 | 
			
		||||
        if (this.getBasketSize() === 0) {
 | 
			
		||||
          return 0;
 | 
			
		||||
        }
 | 
			
		||||
        const total = Object.values(this.basket).reduce(
 | 
			
		||||
          (acc: number, cur: BasketItem) => acc + cur.sum(),
 | 
			
		||||
          0,
 | 
			
		||||
        ) as number;
 | 
			
		||||
        return total;
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      showAlertMessage(message: string) {
 | 
			
		||||
        if (this.alertMessage.timeout !== null) {
 | 
			
		||||
          clearTimeout(this.alertMessage.timeout);
 | 
			
		||||
        }
 | 
			
		||||
        this.alertMessage.content = message;
 | 
			
		||||
        this.alertMessage.show = true;
 | 
			
		||||
        this.alertMessage.timeout = setTimeout(() => {
 | 
			
		||||
          this.alertMessage.show = false;
 | 
			
		||||
          this.alertMessage.timeout = null;
 | 
			
		||||
        }, 2000);
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      addToBasketWithMessage(id: string, quantity: number) {
 | 
			
		||||
        const message = this.addToBasket(id, quantity);
 | 
			
		||||
        if (message.length > 0) {
 | 
			
		||||
          this.showAlertMessage(message);
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      onRefillingSuccess(event: CustomEvent) {
 | 
			
		||||
        if (event.type !== "htmx:after-request" || event.detail.failed) {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        this.customerBalance += Number.parseFloat(
 | 
			
		||||
          (event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
 | 
			
		||||
        );
 | 
			
		||||
        document.getElementById("selling-accordion").click();
 | 
			
		||||
        this.codeField.widget.focus();
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      finish() {
 | 
			
		||||
        if (this.getBasketSize() === 0) {
 | 
			
		||||
          this.showAlertMessage(gettext("You can't send an empty basket."));
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        this.$refs.basketForm.submit();
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      cancel() {
 | 
			
		||||
        location.href = config.cancelUrl;
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      handleCode() {
 | 
			
		||||
        const [quantity, code] = this.codeField.getSelectedProduct() as [
 | 
			
		||||
          number,
 | 
			
		||||
          string,
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
 | 
			
		||||
          if (code === "ANN") {
 | 
			
		||||
            this.cancel();
 | 
			
		||||
          }
 | 
			
		||||
          if (code === "FIN") {
 | 
			
		||||
            this.finish();
 | 
			
		||||
          }
 | 
			
		||||
        } else {
 | 
			
		||||
          this.addToBasketWithMessage(code, quantity);
 | 
			
		||||
        }
 | 
			
		||||
        this.codeField.widget.clear();
 | 
			
		||||
        this.codeField.widget.focus();
 | 
			
		||||
      },
 | 
			
		||||
    }));
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
$(() => {
 | 
			
		||||
  /* Accordion UI between basket and refills */
 | 
			
		||||
  // biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
 | 
			
		||||
  ($("#click-form") as any).accordion({
 | 
			
		||||
    heightStyle: "content",
 | 
			
		||||
    activate: () => $(".focus").focus(),
 | 
			
		||||
  });
 | 
			
		||||
  // biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
 | 
			
		||||
  ($("#products") as any).tabs();
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										163
									
								
								counter/static/bundled/counter/product-list-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								counter/static/bundled/counter/product-list-index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,163 @@
 | 
			
		||||
import { paginated } from "#core:utils/api";
 | 
			
		||||
import { csv } from "#core:utils/csv";
 | 
			
		||||
import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history";
 | 
			
		||||
import type { NestedKeyOf } from "#core:utils/types";
 | 
			
		||||
import { showSaveFilePicker } from "native-file-system-adapter";
 | 
			
		||||
import type TomSelect from "tom-select";
 | 
			
		||||
import {
 | 
			
		||||
  type ProductSchema,
 | 
			
		||||
  type ProductSearchProductsDetailedData,
 | 
			
		||||
  productSearchProductsDetailed,
 | 
			
		||||
} from "#openapi";
 | 
			
		||||
 | 
			
		||||
type ProductType = string;
 | 
			
		||||
type GroupedProducts = Record<ProductType, ProductSchema[]>;
 | 
			
		||||
 | 
			
		||||
const defaultPageSize = 100;
 | 
			
		||||
const defaultPage = 1;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Keys of the properties to include in the CSV.
 | 
			
		||||
 */
 | 
			
		||||
const csvColumns = [
 | 
			
		||||
  "id",
 | 
			
		||||
  "name",
 | 
			
		||||
  "code",
 | 
			
		||||
  "description",
 | 
			
		||||
  "product_type.name",
 | 
			
		||||
  "club.name",
 | 
			
		||||
  "limit_age",
 | 
			
		||||
  "purchase_price",
 | 
			
		||||
  "selling_price",
 | 
			
		||||
  "archived",
 | 
			
		||||
] as NestedKeyOf<ProductSchema>[];
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Title of the csv columns.
 | 
			
		||||
 */
 | 
			
		||||
const csvColumnTitles = [
 | 
			
		||||
  "id",
 | 
			
		||||
  gettext("name"),
 | 
			
		||||
  "code",
 | 
			
		||||
  "description",
 | 
			
		||||
  gettext("product type"),
 | 
			
		||||
  "club",
 | 
			
		||||
  gettext("limit age"),
 | 
			
		||||
  gettext("purchase price"),
 | 
			
		||||
  gettext("selling price"),
 | 
			
		||||
  gettext("archived"),
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
document.addEventListener("alpine:init", () => {
 | 
			
		||||
  Alpine.data("productList", () => ({
 | 
			
		||||
    loading: false,
 | 
			
		||||
    csvLoading: false,
 | 
			
		||||
    products: {} as GroupedProducts,
 | 
			
		||||
 | 
			
		||||
    /** Total number of elements corresponding to the current query. */
 | 
			
		||||
    nbPages: 0,
 | 
			
		||||
 | 
			
		||||
    productStatus: "" as "active" | "archived" | "both",
 | 
			
		||||
    search: "",
 | 
			
		||||
    productTypes: [] as string[],
 | 
			
		||||
    pageSize: defaultPageSize,
 | 
			
		||||
    page: defaultPage,
 | 
			
		||||
 | 
			
		||||
    async init() {
 | 
			
		||||
      const url = getCurrentUrlParams();
 | 
			
		||||
      this.search = url.get("search") || "";
 | 
			
		||||
      this.productStatus = url.get("productStatus") ?? "active";
 | 
			
		||||
      const widget = this.$refs.productTypesInput.widget as TomSelect;
 | 
			
		||||
      widget.on("change", (items: string[]) => {
 | 
			
		||||
        this.productTypes = [...items];
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await this.load();
 | 
			
		||||
      const searchParams = ["search", "productStatus", "productTypes"];
 | 
			
		||||
      for (const param of searchParams) {
 | 
			
		||||
        this.$watch(param, () => {
 | 
			
		||||
          this.page = defaultPage;
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
      for (const param of [...searchParams, "page"]) {
 | 
			
		||||
        this.$watch(param, async (value: string) => {
 | 
			
		||||
          updateQueryString(param, value, History.Replace);
 | 
			
		||||
          this.nbPages = 0;
 | 
			
		||||
          await this.load();
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Build the object containing the query parameters corresponding
 | 
			
		||||
     * to the current filters
 | 
			
		||||
     */
 | 
			
		||||
    getQueryParams(): ProductSearchProductsDetailedData {
 | 
			
		||||
      const search = this.search.length > 0 ? this.search : null;
 | 
			
		||||
      // If active or archived products must be filtered, put the filter in the request
 | 
			
		||||
      // Else, don't include the filter
 | 
			
		||||
      const isArchived = ["active", "archived"].includes(this.productStatus)
 | 
			
		||||
        ? this.productStatus === "archived"
 | 
			
		||||
        : undefined;
 | 
			
		||||
      return {
 | 
			
		||||
        query: {
 | 
			
		||||
          page: this.page,
 | 
			
		||||
          // biome-ignore lint/style/useNamingConvention: api is in snake_case
 | 
			
		||||
          page_size: this.pageSize,
 | 
			
		||||
          search: search,
 | 
			
		||||
          // biome-ignore lint/style/useNamingConvention: api is in snake_case
 | 
			
		||||
          is_archived: isArchived,
 | 
			
		||||
          // biome-ignore lint/style/useNamingConvention: api is in snake_case
 | 
			
		||||
          product_type: [...this.productTypes],
 | 
			
		||||
        },
 | 
			
		||||
      };
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Fetch the products corresponding to the current filters
 | 
			
		||||
     */
 | 
			
		||||
    async load() {
 | 
			
		||||
      this.loading = true;
 | 
			
		||||
      const options = this.getQueryParams();
 | 
			
		||||
      const resp = await productSearchProductsDetailed(options);
 | 
			
		||||
      this.nbPages = Math.ceil(resp.data.count / defaultPageSize);
 | 
			
		||||
      this.products = resp.data.results.reduce<GroupedProducts>((acc, curr) => {
 | 
			
		||||
        const key = curr.product_type?.name ?? gettext("Uncategorized");
 | 
			
		||||
        if (!(key in acc)) {
 | 
			
		||||
          acc[key] = [];
 | 
			
		||||
        }
 | 
			
		||||
        acc[key].push(curr);
 | 
			
		||||
        return acc;
 | 
			
		||||
      }, {});
 | 
			
		||||
      this.loading = false;
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Download products corresponding to the current filters as a CSV file.
 | 
			
		||||
     * If the pagination has multiple pages, all pages are downloaded.
 | 
			
		||||
     */
 | 
			
		||||
    async downloadCsv() {
 | 
			
		||||
      this.csvLoading = true;
 | 
			
		||||
      const fileHandle = await showSaveFilePicker({
 | 
			
		||||
        _preferPolyfill: false,
 | 
			
		||||
        suggestedName: gettext("products.csv"),
 | 
			
		||||
        types: [],
 | 
			
		||||
        excludeAcceptAllOption: false,
 | 
			
		||||
      });
 | 
			
		||||
      // if products to download are already in-memory, directly take them.
 | 
			
		||||
      // If not, fetch them.
 | 
			
		||||
      const products =
 | 
			
		||||
        this.nbPages > 1
 | 
			
		||||
          ? await paginated(productSearchProductsDetailed, this.getQueryParams())
 | 
			
		||||
          : Object.values<ProductSchema[]>(this.products).flat();
 | 
			
		||||
      const content = csv.stringify(products, {
 | 
			
		||||
        columns: csvColumns,
 | 
			
		||||
        titleRow: csvColumnTitles,
 | 
			
		||||
      });
 | 
			
		||||
      const file = await fileHandle.createWritable();
 | 
			
		||||
      await file.write(content);
 | 
			
		||||
      await file.close();
 | 
			
		||||
      this.csvLoading = false;
 | 
			
		||||
    },
 | 
			
		||||
  }));
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										64
									
								
								counter/static/bundled/counter/product-type-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								counter/static/bundled/counter/product-type-index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,64 @@
 | 
			
		||||
import Alpine from "alpinejs";
 | 
			
		||||
import { producttypeReorder } from "#openapi";
 | 
			
		||||
 | 
			
		||||
document.addEventListener("alpine:init", () => {
 | 
			
		||||
  Alpine.data("productTypesList", () => ({
 | 
			
		||||
    loading: false,
 | 
			
		||||
    alertMessage: {
 | 
			
		||||
      open: false,
 | 
			
		||||
      success: true,
 | 
			
		||||
      content: "",
 | 
			
		||||
      timeout: null,
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async reorder(itemId: number, newPosition: number) {
 | 
			
		||||
      // The sort plugin of Alpine doesn't manage dynamic lists with x-sort
 | 
			
		||||
      // (cf. https://github.com/alpinejs/alpine/discussions/4157).
 | 
			
		||||
      // There is an open PR that fixes this issue
 | 
			
		||||
      // (cf. https://github.com/alpinejs/alpine/pull/4361).
 | 
			
		||||
      // However, it hasn't been merged yet.
 | 
			
		||||
      // To overcome this, I get the list of DOM elements
 | 
			
		||||
      // And fetch the `x-sort:item` attribute, which value is
 | 
			
		||||
      // the id of the object in database.
 | 
			
		||||
      // Please make this a little bit cleaner when the fix has been merged
 | 
			
		||||
      // into the main Alpine repo.
 | 
			
		||||
      this.loading = true;
 | 
			
		||||
      const productTypes = this.$refs.productTypes
 | 
			
		||||
        .childNodes as NodeListOf<HTMLLIElement>;
 | 
			
		||||
      const getId = (elem: HTMLLIElement) =>
 | 
			
		||||
        Number.parseInt(elem.getAttribute("x-sort:item"));
 | 
			
		||||
      const query =
 | 
			
		||||
        newPosition === 0
 | 
			
		||||
          ? { above: getId(productTypes.item(1)) }
 | 
			
		||||
          : { below: getId(productTypes.item(newPosition - 1)) };
 | 
			
		||||
      const response = await producttypeReorder({
 | 
			
		||||
        // biome-ignore lint/style/useNamingConvention: api is snake_case
 | 
			
		||||
        path: { type_id: itemId },
 | 
			
		||||
        query: query,
 | 
			
		||||
      });
 | 
			
		||||
      this.openAlertMessage(response.response);
 | 
			
		||||
      this.loading = false;
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    openAlertMessage(response: Response) {
 | 
			
		||||
      if (response.ok) {
 | 
			
		||||
        this.alertMessage.success = true;
 | 
			
		||||
        this.alertMessage.content = gettext("Products types reordered!");
 | 
			
		||||
      } else {
 | 
			
		||||
        this.alertMessage.success = false;
 | 
			
		||||
        this.alertMessage.content = interpolate(
 | 
			
		||||
          gettext("Product type reorganisation failed with status code : %d"),
 | 
			
		||||
          [response.status],
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      this.alertMessage.open = true;
 | 
			
		||||
      if (this.alertMessage.timeout !== null) {
 | 
			
		||||
        clearTimeout(this.alertMessage.timeout);
 | 
			
		||||
      }
 | 
			
		||||
      this.alertMessage.timeout = setTimeout(() => {
 | 
			
		||||
        this.alertMessage.open = false;
 | 
			
		||||
      }, 2000);
 | 
			
		||||
      this.loading = false;
 | 
			
		||||
    },
 | 
			
		||||
  }));
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										25
									
								
								counter/static/bundled/counter/types.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								counter/static/bundled/counter/types.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
type ErrorMessage = string;
 | 
			
		||||
 | 
			
		||||
export interface InitialFormData {
 | 
			
		||||
  /* Used to refill the form when the backend raises an error */
 | 
			
		||||
  id?: keyof Record<string, Product>;
 | 
			
		||||
  quantity?: number;
 | 
			
		||||
  errors?: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface CounterConfig {
 | 
			
		||||
  customerBalance: number;
 | 
			
		||||
  customerId: number;
 | 
			
		||||
  products: Record<string, Product>;
 | 
			
		||||
  formInitial: InitialFormData[];
 | 
			
		||||
  cancelUrl: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface Product {
 | 
			
		||||
  id: string;
 | 
			
		||||
  code: string;
 | 
			
		||||
  name: string;
 | 
			
		||||
  price: number;
 | 
			
		||||
  hasTrayPrice: boolean;
 | 
			
		||||
  quantityForTrayPrice: number;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								counter/static/counter/css/admin.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								counter/static/counter/css/admin.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
.product-group {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  gap: 15px;
 | 
			
		||||
  margin-bottom: 30px;
 | 
			
		||||
 | 
			
		||||
  @media screen and (min-width: 768px) {
 | 
			
		||||
    max-width: 50%;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										62
									
								
								counter/static/counter/css/counter-click.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								counter/static/counter/css/counter-click.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,62 @@
 | 
			
		||||
@import "core/static/core/colors";
 | 
			
		||||
 | 
			
		||||
.quantity {
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  min-width: 1.2em;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.remove-item {
 | 
			
		||||
  float: right;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.basket-error-container {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  display: block
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.basket-error {
 | 
			
		||||
  z-index: 10; // to get on top of tomselect
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  position: absolute;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#bar-ui {
 | 
			
		||||
  padding: 0.4em;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  flex-direction: row-reverse;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#products {
 | 
			
		||||
  flex-basis: 100%;
 | 
			
		||||
  margin: 0.2em;
 | 
			
		||||
  overflow: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#click-form {
 | 
			
		||||
  flex: auto;
 | 
			
		||||
  margin: 0.2em;
 | 
			
		||||
  width: 20%;
 | 
			
		||||
 | 
			
		||||
  ul {
 | 
			
		||||
    list-style-type: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#user_info {
 | 
			
		||||
  flex: auto;
 | 
			
		||||
  padding: 0.5em;
 | 
			
		||||
  margin: 0.2em;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  background: $secondary-neutral-light-color;
 | 
			
		||||
 | 
			
		||||
  img {
 | 
			
		||||
    max-width: 70%;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  input {
 | 
			
		||||
    background: white;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										15
									
								
								counter/static/counter/css/product_type.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								counter/static/counter/css/product_type.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
.product-type-list {
 | 
			
		||||
  li {
 | 
			
		||||
    list-style: none;
 | 
			
		||||
    margin-bottom: 10px;
 | 
			
		||||
 | 
			
		||||
    i {
 | 
			
		||||
      cursor: grab;
 | 
			
		||||
      visibility: hidden;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body:not(.sorting) .product-type-list li:hover i {
 | 
			
		||||
  visibility: visible;
 | 
			
		||||
}
 | 
			
		||||
@@ -1,86 +0,0 @@
 | 
			
		||||
document.addEventListener("alpine:init", () => {
 | 
			
		||||
  Alpine.data("counter", () => ({
 | 
			
		||||
    // biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
 | 
			
		||||
    basket: sessionBasket,
 | 
			
		||||
    errors: [],
 | 
			
		||||
 | 
			
		||||
    sumBasket() {
 | 
			
		||||
      if (!this.basket || Object.keys(this.basket).length === 0) {
 | 
			
		||||
        return 0;
 | 
			
		||||
      }
 | 
			
		||||
      const total = Object.values(this.basket).reduce(
 | 
			
		||||
        (acc, cur) => acc + cur.qty * cur.price,
 | 
			
		||||
        0,
 | 
			
		||||
      );
 | 
			
		||||
      return total / 100;
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async handleCode(event) {
 | 
			
		||||
      const code = $(event.target).find("#code_field").val().toUpperCase();
 | 
			
		||||
      if (["FIN", "ANN"].includes(code)) {
 | 
			
		||||
        $(event.target).submit();
 | 
			
		||||
      } else {
 | 
			
		||||
        await this.handleAction(event);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async handleAction(event) {
 | 
			
		||||
      const payload = $(event.target).serialize();
 | 
			
		||||
      // biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
 | 
			
		||||
      const request = new Request(clickApiUrl, {
 | 
			
		||||
        method: "POST",
 | 
			
		||||
        body: payload,
 | 
			
		||||
        headers: {
 | 
			
		||||
          // biome-ignore lint/style/useNamingConvention: this goes into http headers
 | 
			
		||||
          Accept: "application/json",
 | 
			
		||||
          // biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
 | 
			
		||||
          "X-CSRFToken": csrfToken,
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
      const response = await fetch(request);
 | 
			
		||||
      const json = await response.json();
 | 
			
		||||
      this.basket = json.basket;
 | 
			
		||||
      this.errors = json.errors;
 | 
			
		||||
      $("form.code_form #code_field").val("").focus();
 | 
			
		||||
    },
 | 
			
		||||
  }));
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
$(() => {
 | 
			
		||||
  /* Autocompletion in the code field */
 | 
			
		||||
  const codeField = $("#code_field");
 | 
			
		||||
 | 
			
		||||
  let quantity = "";
 | 
			
		||||
  codeField.autocomplete({
 | 
			
		||||
    select: (event, ui) => {
 | 
			
		||||
      event.preventDefault();
 | 
			
		||||
      codeField.val(quantity + ui.item.value);
 | 
			
		||||
    },
 | 
			
		||||
    focus: (event, ui) => {
 | 
			
		||||
      event.preventDefault();
 | 
			
		||||
      codeField.val(quantity + ui.item.value);
 | 
			
		||||
    },
 | 
			
		||||
    source: (request, response) => {
 | 
			
		||||
      // biome-ignore lint/performance/useTopLevelRegex: performance impact is minimal
 | 
			
		||||
      const res = /^(\d+x)?(.*)/i.exec(request.term);
 | 
			
		||||
      quantity = res[1] || "";
 | 
			
		||||
      const search = res[2];
 | 
			
		||||
      const matcher = new RegExp($.ui.autocomplete.escapeRegex(search), "i");
 | 
			
		||||
      response(
 | 
			
		||||
        // biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
 | 
			
		||||
        $.grep(productsAutocomplete, (value) => {
 | 
			
		||||
          return matcher.test(value.tags);
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  /* Accordion UI between basket and refills */
 | 
			
		||||
  $("#click_form").accordion({
 | 
			
		||||
    heightStyle: "content",
 | 
			
		||||
    activate: () => $(".focus").focus(),
 | 
			
		||||
  });
 | 
			
		||||
  $("#products").tabs();
 | 
			
		||||
 | 
			
		||||
  codeField.focus();
 | 
			
		||||
});
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user